Deploy update
This commit is contained in:
parent
9fcf5246e8
commit
1af4fd3df6
|
|
@ -221,5 +221,13 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "2ae0d935516f4a3f9d10957712af54f5.5Up9iyO5Y04If7Vk",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||||
|
"API_TIMEOUT_MS": "3000000",
|
||||||
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "glm-4.5-air",
|
||||||
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "glm-4.6",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-4.6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,12 @@ versions:
|
||||||
completed_at: '2025-12-18T18:24:29.951800'
|
completed_at: '2025-12-18T18:24:29.951800'
|
||||||
tasks_count: 0
|
tasks_count: 0
|
||||||
operations_count: 0
|
operations_count: 0
|
||||||
latest_version: v004
|
- version: v005
|
||||||
total_versions: 4
|
feature: examine what is missing in current app and implement it
|
||||||
|
status: completed
|
||||||
|
started_at: '2025-12-20T22:02:10.309550'
|
||||||
|
completed_at: '2025-12-20T22:28:45.494863'
|
||||||
|
tasks_count: 0
|
||||||
|
operations_count: 0
|
||||||
|
latest_version: v005
|
||||||
|
total_versions: 5
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,954 @@
|
||||||
|
api_contract:
|
||||||
|
workflow_version: v005
|
||||||
|
design_document_revision: 1
|
||||||
|
generated_at: '2025-12-20T22:10:36.422275'
|
||||||
|
validated_at: null
|
||||||
|
status: draft
|
||||||
|
types:
|
||||||
|
- id: type_RefreshToken
|
||||||
|
name: RefreshToken
|
||||||
|
definition:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Unique identifier
|
||||||
|
- name: token
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Refresh token value
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: User who owns this token
|
||||||
|
- name: expiresAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: Token expiration time
|
||||||
|
- name: createdAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: When token was created
|
||||||
|
- name: isRevoked
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
description: Whether token has been revoked
|
||||||
|
used_by:
|
||||||
|
models:
|
||||||
|
- model_refresh_token
|
||||||
|
responses: []
|
||||||
|
requests: []
|
||||||
|
- id: type_Session
|
||||||
|
name: Session
|
||||||
|
definition:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Unique session identifier
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: User who owns this session
|
||||||
|
- name: token
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Session token
|
||||||
|
- name: deviceInfo
|
||||||
|
type: Record<string, unknown>
|
||||||
|
required: false
|
||||||
|
description: Device information (browser, OS)
|
||||||
|
- name: ipAddress
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: Client IP address
|
||||||
|
- name: userAgent
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: Browser user agent
|
||||||
|
- name: lastActivity
|
||||||
|
type: Date
|
||||||
|
required: false
|
||||||
|
description: Last activity timestamp
|
||||||
|
- name: createdAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: When session was created
|
||||||
|
used_by:
|
||||||
|
models:
|
||||||
|
- model_session
|
||||||
|
responses: []
|
||||||
|
requests: []
|
||||||
|
- id: type_PlayHistory
|
||||||
|
name: PlayHistory
|
||||||
|
definition:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Unique identifier
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: User who played the song
|
||||||
|
- name: songId
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Song that was played
|
||||||
|
- name: playedAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: When playback started
|
||||||
|
- name: playedDuration
|
||||||
|
type: number
|
||||||
|
required: false
|
||||||
|
description: Seconds actually played
|
||||||
|
- name: completed
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
description: Did user listen to completion
|
||||||
|
- name: source
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: Where playback was initiated
|
||||||
|
used_by:
|
||||||
|
models:
|
||||||
|
- model_play_history
|
||||||
|
responses: []
|
||||||
|
requests: []
|
||||||
|
- id: type_Queue
|
||||||
|
name: Queue
|
||||||
|
definition:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Unique queue identifier
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: User who owns this queue
|
||||||
|
- name: songIds
|
||||||
|
type: Record<string, unknown>
|
||||||
|
required: true
|
||||||
|
description: Array of song IDs in order
|
||||||
|
- name: currentIndex
|
||||||
|
type: number
|
||||||
|
required: false
|
||||||
|
description: Current playing index
|
||||||
|
- name: isShuffled
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
description: Whether queue is shuffled
|
||||||
|
- name: repeatMode
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: 'Repeat mode: none, one, all'
|
||||||
|
- name: createdAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: When queue was created
|
||||||
|
- name: updatedAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: Last update time
|
||||||
|
used_by:
|
||||||
|
models:
|
||||||
|
- model_queue
|
||||||
|
responses: []
|
||||||
|
requests: []
|
||||||
|
- id: type_UploadSession
|
||||||
|
name: UploadSession
|
||||||
|
definition:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Unique upload session ID
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: User uploading the file
|
||||||
|
- name: fileName
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Original file name
|
||||||
|
- name: fileSize
|
||||||
|
type: number
|
||||||
|
required: true
|
||||||
|
description: Total file size in bytes
|
||||||
|
- name: mimeType
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: File MIME type
|
||||||
|
- name: chunkSize
|
||||||
|
type: number
|
||||||
|
required: true
|
||||||
|
description: Size of each chunk
|
||||||
|
- name: totalChunks
|
||||||
|
type: number
|
||||||
|
required: true
|
||||||
|
description: Total number of chunks
|
||||||
|
- name: uploadedChunks
|
||||||
|
type: Record<string, unknown>
|
||||||
|
required: false
|
||||||
|
description: Array of uploaded chunk numbers
|
||||||
|
- name: status
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: Upload status
|
||||||
|
- name: fileId
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: Associated file ID when complete
|
||||||
|
- name: metadata
|
||||||
|
type: Record<string, unknown>
|
||||||
|
required: false
|
||||||
|
description: Additional file metadata
|
||||||
|
- name: createdAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: When upload started
|
||||||
|
- name: expiresAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: When upload session expires
|
||||||
|
used_by:
|
||||||
|
models:
|
||||||
|
- model_upload_session
|
||||||
|
responses: []
|
||||||
|
requests: []
|
||||||
|
- id: type_SearchIndex
|
||||||
|
name: SearchIndex
|
||||||
|
definition:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Unique index entry ID
|
||||||
|
- name: entityType
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Type of entity (song, album, artist)
|
||||||
|
- name: entityId
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: ID of the indexed entity
|
||||||
|
- name: title
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: Entity title for search
|
||||||
|
- name: content
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: Full text content
|
||||||
|
- name: metadata
|
||||||
|
type: Record<string, unknown>
|
||||||
|
required: false
|
||||||
|
description: Additional searchable metadata
|
||||||
|
- name: createdAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: When indexed
|
||||||
|
- name: updatedAt
|
||||||
|
type: Date
|
||||||
|
required: true
|
||||||
|
description: Last update
|
||||||
|
used_by:
|
||||||
|
models:
|
||||||
|
- model_search_index
|
||||||
|
responses: []
|
||||||
|
requests: []
|
||||||
|
endpoints:
|
||||||
|
- id: api_auth_refresh
|
||||||
|
method: POST
|
||||||
|
path: /api/auth/refresh
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body:
|
||||||
|
type_id: type_AuthRefreshRequest
|
||||||
|
content_type: application/json
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: type_AuthRefreshResponse
|
||||||
|
is_array: false
|
||||||
|
errors:
|
||||||
|
- status: 401
|
||||||
|
type_id: type_ApiError
|
||||||
|
description: Invalid or expired refresh token
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_auth_logout
|
||||||
|
method: POST
|
||||||
|
path: /api/auth/logout
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_auth_sessions
|
||||||
|
method: GET
|
||||||
|
path: /api/auth/sessions
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_auth_revoke_session
|
||||||
|
method: DELETE
|
||||||
|
path: /api/auth/sessions/:id
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_auth_verify_email
|
||||||
|
method: POST
|
||||||
|
path: /api/auth/verify-email
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_auth_confirm_email
|
||||||
|
method: POST
|
||||||
|
path: /api/auth/confirm-email
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_player_play
|
||||||
|
method: POST
|
||||||
|
path: /api/player/play
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_player_pause
|
||||||
|
method: POST
|
||||||
|
path: /api/player/pause
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_player_next
|
||||||
|
method: POST
|
||||||
|
path: /api/player/next
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_player_previous
|
||||||
|
method: POST
|
||||||
|
path: /api/player/previous
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_player_queue
|
||||||
|
method: GET
|
||||||
|
path: /api/player/queue
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_player_queue_add
|
||||||
|
method: POST
|
||||||
|
path: /api/player/queue
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_player_queue_clear
|
||||||
|
method: DELETE
|
||||||
|
path: /api/player/queue
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_player_history
|
||||||
|
method: GET
|
||||||
|
path: /api/player/history
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_upload_init
|
||||||
|
method: POST
|
||||||
|
path: /api/upload/init
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_upload_chunk
|
||||||
|
method: POST
|
||||||
|
path: /api/upload/chunk/:uploadId/:chunkIndex
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_upload_complete
|
||||||
|
method: POST
|
||||||
|
path: /api/upload/complete/:uploadId
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_upload_presigned
|
||||||
|
method: GET
|
||||||
|
path: /api/upload/presigned-url
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_search
|
||||||
|
method: GET
|
||||||
|
path: /api/search
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_search_suggestions
|
||||||
|
method: GET
|
||||||
|
path: /api/search/suggestions
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
- id: api_search_index
|
||||||
|
method: POST
|
||||||
|
path: /api/search/index
|
||||||
|
path_params: []
|
||||||
|
query_params: []
|
||||||
|
request_body: null
|
||||||
|
response:
|
||||||
|
success:
|
||||||
|
status: 200
|
||||||
|
type_id: null
|
||||||
|
is_array: false
|
||||||
|
errors: []
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
roles: []
|
||||||
|
version: 1.0.0
|
||||||
|
frontend_calls:
|
||||||
|
- id: call_page_settings_security_api_auth_sessions
|
||||||
|
source:
|
||||||
|
entity_id: page_settings_security
|
||||||
|
file_path: app/settings/security/page.tsx
|
||||||
|
endpoint_id: api_auth_sessions
|
||||||
|
purpose: Load active sessions
|
||||||
|
trigger: onLoad
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Update state
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_audio_player_api_player_play
|
||||||
|
source:
|
||||||
|
entity_id: component_audio_player
|
||||||
|
file_path: app/components/AudioPlayer.tsx
|
||||||
|
endpoint_id: api_player_play
|
||||||
|
purpose: Call api_player_play
|
||||||
|
trigger: onSubmit
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_audio_player_api_player_pause
|
||||||
|
source:
|
||||||
|
entity_id: component_audio_player
|
||||||
|
file_path: app/components/AudioPlayer.tsx
|
||||||
|
endpoint_id: api_player_pause
|
||||||
|
purpose: Call api_player_pause
|
||||||
|
trigger: onSubmit
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_audio_player_api_player_next
|
||||||
|
source:
|
||||||
|
entity_id: component_audio_player
|
||||||
|
file_path: app/components/AudioPlayer.tsx
|
||||||
|
endpoint_id: api_player_next
|
||||||
|
purpose: Call api_player_next
|
||||||
|
trigger: onSubmit
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_audio_player_api_player_previous
|
||||||
|
source:
|
||||||
|
entity_id: component_audio_player
|
||||||
|
file_path: app/components/AudioPlayer.tsx
|
||||||
|
endpoint_id: api_player_previous
|
||||||
|
purpose: Call api_player_previous
|
||||||
|
trigger: onSubmit
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_audio_player_api_player_queue
|
||||||
|
source:
|
||||||
|
entity_id: component_audio_player
|
||||||
|
file_path: app/components/AudioPlayer.tsx
|
||||||
|
endpoint_id: api_player_queue
|
||||||
|
purpose: Call api_player_queue
|
||||||
|
trigger: onDemand
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_upload_manager_api_upload_init
|
||||||
|
source:
|
||||||
|
entity_id: component_upload_manager
|
||||||
|
file_path: app/components/UploadManager.tsx
|
||||||
|
endpoint_id: api_upload_init
|
||||||
|
purpose: Call api_upload_init
|
||||||
|
trigger: onSubmit
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_upload_manager_api_upload_chunk
|
||||||
|
source:
|
||||||
|
entity_id: component_upload_manager
|
||||||
|
file_path: app/components/UploadManager.tsx
|
||||||
|
endpoint_id: api_upload_chunk
|
||||||
|
purpose: Call api_upload_chunk
|
||||||
|
trigger: onSubmit
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_upload_manager_api_upload_complete
|
||||||
|
source:
|
||||||
|
entity_id: component_upload_manager
|
||||||
|
file_path: app/components/UploadManager.tsx
|
||||||
|
endpoint_id: api_upload_complete
|
||||||
|
purpose: Call api_upload_complete
|
||||||
|
trigger: onSubmit
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_search_bar_api_search_suggestions
|
||||||
|
source:
|
||||||
|
entity_id: component_search_bar
|
||||||
|
file_path: app/components/SearchBar.tsx
|
||||||
|
endpoint_id: api_search_suggestions
|
||||||
|
purpose: Call api_search_suggestions
|
||||||
|
trigger: onDemand
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
- id: call_component_search_results_api_search
|
||||||
|
source:
|
||||||
|
entity_id: component_search_results
|
||||||
|
file_path: app/components/SearchResults.tsx
|
||||||
|
endpoint_id: api_search
|
||||||
|
purpose: Call api_search
|
||||||
|
trigger: onDemand
|
||||||
|
request_mapping:
|
||||||
|
from_props: []
|
||||||
|
from_state: []
|
||||||
|
from_form: []
|
||||||
|
response_handling:
|
||||||
|
success_action: Handle response
|
||||||
|
error_action: Show error
|
||||||
|
backend_routes:
|
||||||
|
- id: route_post_auth_refresh
|
||||||
|
endpoint_id: api_auth_refresh
|
||||||
|
file_path: app/api/auth/refresh/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_auth_logout
|
||||||
|
endpoint_id: api_auth_logout
|
||||||
|
file_path: app/api/auth/logout/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_get_auth_sessions
|
||||||
|
endpoint_id: api_auth_sessions
|
||||||
|
file_path: app/api/auth/sessions/route.ts
|
||||||
|
export_name: GET
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_delete_auth_sessions_:id
|
||||||
|
endpoint_id: api_auth_revoke_session
|
||||||
|
file_path: app/api/auth/sessions/[id]/route.ts
|
||||||
|
export_name: DELETE
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_auth_verify-email
|
||||||
|
endpoint_id: api_auth_verify_email
|
||||||
|
file_path: app/api/auth/verify-email/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_auth_confirm-email
|
||||||
|
endpoint_id: api_auth_confirm_email
|
||||||
|
file_path: app/api/auth/confirm-email/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_player_play
|
||||||
|
endpoint_id: api_player_play
|
||||||
|
file_path: app/api/player/play/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_player_pause
|
||||||
|
endpoint_id: api_player_pause
|
||||||
|
file_path: app/api/player/pause/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_player_next
|
||||||
|
endpoint_id: api_player_next
|
||||||
|
file_path: app/api/player/next/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_player_previous
|
||||||
|
endpoint_id: api_player_previous
|
||||||
|
file_path: app/api/player/previous/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_get_player_queue
|
||||||
|
endpoint_id: api_player_queue
|
||||||
|
file_path: app/api/player/queue/route.ts
|
||||||
|
export_name: GET
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_player_queue
|
||||||
|
endpoint_id: api_player_queue_add
|
||||||
|
file_path: app/api/player/queue/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_delete_player_queue
|
||||||
|
endpoint_id: api_player_queue_clear
|
||||||
|
file_path: app/api/player/queue/route.ts
|
||||||
|
export_name: DELETE
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_get_player_history
|
||||||
|
endpoint_id: api_player_history
|
||||||
|
file_path: app/api/player/history/route.ts
|
||||||
|
export_name: GET
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_upload_init
|
||||||
|
endpoint_id: api_upload_init
|
||||||
|
file_path: app/api/upload/init/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_upload_chunk_:uploadId_:chunkIndex
|
||||||
|
endpoint_id: api_upload_chunk
|
||||||
|
file_path: app/api/upload/chunk/[uploadId]/[chunkIndex]/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_upload_complete_:uploadId
|
||||||
|
endpoint_id: api_upload_complete
|
||||||
|
file_path: app/api/upload/complete/[uploadId]/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_get_upload_presigned-url
|
||||||
|
endpoint_id: api_upload_presigned
|
||||||
|
file_path: app/api/upload/presigned-url/route.ts
|
||||||
|
export_name: GET
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_get_search
|
||||||
|
endpoint_id: api_search
|
||||||
|
file_path: app/api/search/route.ts
|
||||||
|
export_name: GET
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_get_search_suggestions
|
||||||
|
endpoint_id: api_search_suggestions
|
||||||
|
file_path: app/api/search/suggestions/route.ts
|
||||||
|
export_name: GET
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
- id: route_post_search_index
|
||||||
|
endpoint_id: api_search_index
|
||||||
|
file_path: app/api/search/index/route.ts
|
||||||
|
export_name: POST
|
||||||
|
uses_models: []
|
||||||
|
uses_services: []
|
||||||
|
must_validate: []
|
||||||
|
must_authenticate: false
|
||||||
|
must_authorize: []
|
||||||
|
|
@ -0,0 +1,569 @@
|
||||||
|
workflow_version: "v005"
|
||||||
|
feature: "examine what is missing in current app and implement it"
|
||||||
|
created_at: "2025-12-20T22:05:00Z"
|
||||||
|
status: "draft"
|
||||||
|
revision: 1
|
||||||
|
|
||||||
|
data_models:
|
||||||
|
# New RefreshToken model for token rotation
|
||||||
|
- id: model_refresh_token
|
||||||
|
name: RefreshToken
|
||||||
|
description: "JWT refresh tokens for secure authentication"
|
||||||
|
table_name: refresh_tokens
|
||||||
|
fields:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
constraints: ["primary_key", "default(cuid())"]
|
||||||
|
description: "Unique identifier"
|
||||||
|
- name: token
|
||||||
|
type: string
|
||||||
|
constraints: ["unique", "not_null"]
|
||||||
|
description: "Refresh token value"
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null", "indexed"]
|
||||||
|
description: "User who owns this token"
|
||||||
|
- name: expiresAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "indexed"]
|
||||||
|
description: "Token expiration time"
|
||||||
|
- name: createdAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "default(now())"]
|
||||||
|
description: "When token was created"
|
||||||
|
- name: isRevoked
|
||||||
|
type: boolean
|
||||||
|
constraints: ["default(false)", "indexed"]
|
||||||
|
description: "Whether token has been revoked"
|
||||||
|
relations:
|
||||||
|
- type: belongs_to
|
||||||
|
target: model_user
|
||||||
|
foreign_key: userId
|
||||||
|
on_delete: cascade
|
||||||
|
timestamps: false
|
||||||
|
|
||||||
|
# New Session model for active session tracking
|
||||||
|
- id: model_session
|
||||||
|
name: Session
|
||||||
|
description: "Active user sessions with device tracking"
|
||||||
|
table_name: sessions
|
||||||
|
fields:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
constraints: ["primary_key", "default(cuid())"]
|
||||||
|
description: "Unique session identifier"
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null", "indexed"]
|
||||||
|
description: "User who owns this session"
|
||||||
|
- name: token
|
||||||
|
type: string
|
||||||
|
constraints: ["unique", "not_null"]
|
||||||
|
description: "Session token"
|
||||||
|
- name: deviceInfo
|
||||||
|
type: json
|
||||||
|
constraints: []
|
||||||
|
description: "Device information (browser, OS)"
|
||||||
|
- name: ipAddress
|
||||||
|
type: string
|
||||||
|
constraints: []
|
||||||
|
description: "Client IP address"
|
||||||
|
- name: userAgent
|
||||||
|
type: string
|
||||||
|
constraints: []
|
||||||
|
description: "Browser user agent"
|
||||||
|
- name: lastActivity
|
||||||
|
type: datetime
|
||||||
|
constraints: ["default(now())"]
|
||||||
|
description: "Last activity timestamp"
|
||||||
|
- name: createdAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "default(now())"]
|
||||||
|
description: "When session was created"
|
||||||
|
relations:
|
||||||
|
- type: belongs_to
|
||||||
|
target: model_user
|
||||||
|
foreign_key: userId
|
||||||
|
relation_name: UserSessions
|
||||||
|
on_delete: cascade
|
||||||
|
timestamps: false
|
||||||
|
|
||||||
|
# New PlayHistory model
|
||||||
|
- id: model_play_history
|
||||||
|
name: PlayHistory
|
||||||
|
description: "Track user play history for analytics"
|
||||||
|
table_name: play_history
|
||||||
|
fields:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
constraints: ["primary_key", "default(cuid())"]
|
||||||
|
description: "Unique identifier"
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null", "indexed"]
|
||||||
|
description: "User who played the song"
|
||||||
|
- name: songId
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null", "indexed"]
|
||||||
|
description: "Song that was played"
|
||||||
|
- name: playedAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "default(now())", "indexed"]
|
||||||
|
description: "When playback started"
|
||||||
|
- name: playedDuration
|
||||||
|
type: integer
|
||||||
|
constraints: []
|
||||||
|
description: "Seconds actually played"
|
||||||
|
- name: completed
|
||||||
|
type: boolean
|
||||||
|
constraints: ["default(false)"]
|
||||||
|
description: "Did user listen to completion"
|
||||||
|
- name: source
|
||||||
|
type: string
|
||||||
|
constraints: []
|
||||||
|
description: "Where playback was initiated"
|
||||||
|
relations:
|
||||||
|
- type: belongs_to
|
||||||
|
target: model_user
|
||||||
|
foreign_key: userId
|
||||||
|
relation_name: UserPlayHistory
|
||||||
|
on_delete: cascade
|
||||||
|
- type: belongs_to
|
||||||
|
target: model_song
|
||||||
|
foreign_key: songId
|
||||||
|
relation_name: SongPlayHistory
|
||||||
|
on_delete: cascade
|
||||||
|
timestamps: false
|
||||||
|
|
||||||
|
# New Queue model for persistent queues
|
||||||
|
- id: model_queue
|
||||||
|
name: Queue
|
||||||
|
description: "User playback queue with state"
|
||||||
|
table_name: queues
|
||||||
|
fields:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
constraints: ["primary_key", "default(cuid())"]
|
||||||
|
description: "Unique queue identifier"
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null", "unique"]
|
||||||
|
description: "User who owns this queue"
|
||||||
|
- name: songIds
|
||||||
|
type: json
|
||||||
|
constraints: ["not_null"]
|
||||||
|
description: "Array of song IDs in order"
|
||||||
|
- name: currentIndex
|
||||||
|
type: integer
|
||||||
|
constraints: ["default(0)"]
|
||||||
|
description: "Current playing index"
|
||||||
|
- name: isShuffled
|
||||||
|
type: boolean
|
||||||
|
constraints: ["default(false)"]
|
||||||
|
description: "Whether queue is shuffled"
|
||||||
|
- name: repeatMode
|
||||||
|
type: string
|
||||||
|
constraints: ["default('none')"]
|
||||||
|
description: "Repeat mode: none, one, all"
|
||||||
|
- name: createdAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "default(now())"]
|
||||||
|
description: "When queue was created"
|
||||||
|
- name: updatedAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "default(now())"]
|
||||||
|
description: "Last update time"
|
||||||
|
relations:
|
||||||
|
- type: belongs_to
|
||||||
|
target: model_user
|
||||||
|
foreign_key: userId
|
||||||
|
relation_name: UserQueue
|
||||||
|
on_delete: cascade
|
||||||
|
timestamps: false
|
||||||
|
|
||||||
|
# New UploadSession model for chunked uploads
|
||||||
|
- id: model_upload_session
|
||||||
|
name: UploadSession
|
||||||
|
description: "Chunked upload session tracking"
|
||||||
|
table_name: upload_sessions
|
||||||
|
fields:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
constraints: ["primary_key", "default(cuid())"]
|
||||||
|
description: "Unique upload session ID"
|
||||||
|
- name: userId
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null", "indexed"]
|
||||||
|
description: "User uploading the file"
|
||||||
|
- name: fileName
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null"]
|
||||||
|
description: "Original file name"
|
||||||
|
- name: fileSize
|
||||||
|
type: integer
|
||||||
|
constraints: ["not_null"]
|
||||||
|
description: "Total file size in bytes"
|
||||||
|
- name: mimeType
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null"]
|
||||||
|
description: "File MIME type"
|
||||||
|
- name: chunkSize
|
||||||
|
type: integer
|
||||||
|
constraints: ["not_null"]
|
||||||
|
description: "Size of each chunk"
|
||||||
|
- name: totalChunks
|
||||||
|
type: integer
|
||||||
|
constraints: ["not_null"]
|
||||||
|
description: "Total number of chunks"
|
||||||
|
- name: uploadedChunks
|
||||||
|
type: json
|
||||||
|
constraints: []
|
||||||
|
description: "Array of uploaded chunk numbers"
|
||||||
|
- name: status
|
||||||
|
type: string
|
||||||
|
constraints: ["default('pending')"]
|
||||||
|
description: "Upload status"
|
||||||
|
- name: fileId
|
||||||
|
type: string
|
||||||
|
constraints: []
|
||||||
|
description: "Associated file ID when complete"
|
||||||
|
- name: metadata
|
||||||
|
type: json
|
||||||
|
constraints: []
|
||||||
|
description: "Additional file metadata"
|
||||||
|
- name: createdAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "default(now())"]
|
||||||
|
description: "When upload started"
|
||||||
|
- name: expiresAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null"]
|
||||||
|
description: "When upload session expires"
|
||||||
|
relations:
|
||||||
|
- type: belongs_to
|
||||||
|
target: model_user
|
||||||
|
foreign_key: userId
|
||||||
|
on_delete: cascade
|
||||||
|
timestamps: false
|
||||||
|
|
||||||
|
# New SearchIndex model
|
||||||
|
- id: model_search_index
|
||||||
|
name: SearchIndex
|
||||||
|
description: "Full-text search index for entities"
|
||||||
|
table_name: search_index
|
||||||
|
fields:
|
||||||
|
- name: id
|
||||||
|
type: string
|
||||||
|
constraints: ["primary_key", "default(cuid())"]
|
||||||
|
description: "Unique index entry ID"
|
||||||
|
- name: entityType
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null", "indexed"]
|
||||||
|
description: "Type of entity (song, album, artist)"
|
||||||
|
- name: entityId
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null", "indexed"]
|
||||||
|
description: "ID of the indexed entity"
|
||||||
|
- name: title
|
||||||
|
type: string
|
||||||
|
constraints: ["not_null"]
|
||||||
|
description: "Entity title for search"
|
||||||
|
- name: content
|
||||||
|
type: text
|
||||||
|
constraints: []
|
||||||
|
description: "Full text content"
|
||||||
|
- name: metadata
|
||||||
|
type: json
|
||||||
|
constraints: []
|
||||||
|
description: "Additional searchable metadata"
|
||||||
|
- name: createdAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "default(now())"]
|
||||||
|
description: "When indexed"
|
||||||
|
- name: updatedAt
|
||||||
|
type: datetime
|
||||||
|
constraints: ["not_null", "default(now())"]
|
||||||
|
description: "Last update"
|
||||||
|
indexes:
|
||||||
|
- fields: ["entityType", "entityId"]
|
||||||
|
unique: true
|
||||||
|
name: idx_search_entity
|
||||||
|
timestamps: false
|
||||||
|
|
||||||
|
api_endpoints:
|
||||||
|
# Session Management
|
||||||
|
- id: api_auth_refresh
|
||||||
|
method: POST
|
||||||
|
path: /api/auth/refresh
|
||||||
|
description: "Refresh access token using refresh token"
|
||||||
|
auth_required: false
|
||||||
|
request_body:
|
||||||
|
refreshToken: string
|
||||||
|
responses:
|
||||||
|
- status: 200
|
||||||
|
description: "Token refreshed successfully"
|
||||||
|
- status: 401
|
||||||
|
description: "Invalid or expired refresh token"
|
||||||
|
|
||||||
|
- id: api_auth_logout
|
||||||
|
method: POST
|
||||||
|
path: /api/auth/logout
|
||||||
|
description: "Logout user and invalidate tokens"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_auth_sessions
|
||||||
|
method: GET
|
||||||
|
path: /api/auth/sessions
|
||||||
|
description: "List all active sessions"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_auth_revoke_session
|
||||||
|
method: DELETE
|
||||||
|
path: /api/auth/sessions/:id
|
||||||
|
description: "Revoke specific session"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
# Email Verification
|
||||||
|
- id: api_auth_verify_email
|
||||||
|
method: POST
|
||||||
|
path: /api/auth/verify-email
|
||||||
|
description: "Send email verification"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_auth_confirm_email
|
||||||
|
method: POST
|
||||||
|
path: /api/auth/confirm-email
|
||||||
|
description: "Confirm email with token"
|
||||||
|
auth_required: false
|
||||||
|
|
||||||
|
# Audio Player API
|
||||||
|
- id: api_player_play
|
||||||
|
method: POST
|
||||||
|
path: /api/player/play
|
||||||
|
description: "Start or resume playback"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_player_pause
|
||||||
|
method: POST
|
||||||
|
path: /api/player/pause
|
||||||
|
description: "Pause playback"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_player_next
|
||||||
|
method: POST
|
||||||
|
path: /api/player/next
|
||||||
|
description: "Skip to next track"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_player_previous
|
||||||
|
method: POST
|
||||||
|
path: /api/player/previous
|
||||||
|
description: "Go to previous track"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_player_queue
|
||||||
|
method: GET
|
||||||
|
path: /api/player/queue
|
||||||
|
description: "Get current queue"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_player_queue_add
|
||||||
|
method: POST
|
||||||
|
path: /api/player/queue
|
||||||
|
description: "Add songs to queue"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_player_queue_clear
|
||||||
|
method: DELETE
|
||||||
|
path: /api/player/queue
|
||||||
|
description: "Clear queue"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_player_history
|
||||||
|
method: GET
|
||||||
|
path: /api/player/history
|
||||||
|
description: "Get play history"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
# File Upload API
|
||||||
|
- id: api_upload_init
|
||||||
|
method: POST
|
||||||
|
path: /api/upload/init
|
||||||
|
description: "Initialize chunked upload"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_upload_chunk
|
||||||
|
method: POST
|
||||||
|
path: /api/upload/chunk/:uploadId/:chunkIndex
|
||||||
|
description: "Upload chunk"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_upload_complete
|
||||||
|
method: POST
|
||||||
|
path: /api/upload/complete/:uploadId
|
||||||
|
description: "Complete upload"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
- id: api_upload_presigned
|
||||||
|
method: GET
|
||||||
|
path: /api/upload/presigned-url
|
||||||
|
description: "Get presigned URL for direct upload"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
# Search API
|
||||||
|
- id: api_search
|
||||||
|
method: GET
|
||||||
|
path: /api/search
|
||||||
|
description: "Search across all content"
|
||||||
|
auth_required: false
|
||||||
|
|
||||||
|
- id: api_search_suggestions
|
||||||
|
method: GET
|
||||||
|
path: /api/search/suggestions
|
||||||
|
description: "Get search suggestions"
|
||||||
|
auth_required: false
|
||||||
|
|
||||||
|
- id: api_search_index
|
||||||
|
method: POST
|
||||||
|
path: /api/search/index
|
||||||
|
description: "Index entity for search"
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
pages:
|
||||||
|
- id: page_auth_verify_email
|
||||||
|
name: EmailVerification
|
||||||
|
path: /auth/verify-email
|
||||||
|
description: "Email verification page"
|
||||||
|
data_needs: []
|
||||||
|
components: []
|
||||||
|
auth_required: false
|
||||||
|
|
||||||
|
- id: page_settings_security
|
||||||
|
name: SecuritySettings
|
||||||
|
path: /settings/security
|
||||||
|
description: "Security settings page"
|
||||||
|
data_needs:
|
||||||
|
- api_id: api_auth_sessions
|
||||||
|
purpose: "Load active sessions"
|
||||||
|
on_load: true
|
||||||
|
components:
|
||||||
|
- component_session_list
|
||||||
|
- component_change_password
|
||||||
|
auth_required: true
|
||||||
|
|
||||||
|
components:
|
||||||
|
- id: component_audio_player
|
||||||
|
name: AudioPlayer
|
||||||
|
description: "Full-featured audio player with controls"
|
||||||
|
props:
|
||||||
|
- name: song
|
||||||
|
type: object
|
||||||
|
required: false
|
||||||
|
description: "Current playing song"
|
||||||
|
- name: isPlaying
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
description: "Playback state"
|
||||||
|
- name: progress
|
||||||
|
type: number
|
||||||
|
required: true
|
||||||
|
description: "Current playback progress (0-1)"
|
||||||
|
- name: volume
|
||||||
|
type: number
|
||||||
|
required: true
|
||||||
|
description: "Volume level (0-1)"
|
||||||
|
- name: queue
|
||||||
|
type: object
|
||||||
|
required: false
|
||||||
|
description: "Current queue"
|
||||||
|
events:
|
||||||
|
- name: play
|
||||||
|
description: "Play or resume playback"
|
||||||
|
- name: pause
|
||||||
|
description: "Pause playback"
|
||||||
|
- name: seek
|
||||||
|
description: "Seek to position"
|
||||||
|
- name: volumeChange
|
||||||
|
description: "Change volume"
|
||||||
|
- name: next
|
||||||
|
description: "Play next song"
|
||||||
|
- name: previous
|
||||||
|
description: "Play previous song"
|
||||||
|
- name: shuffle
|
||||||
|
description: "Toggle shuffle"
|
||||||
|
- name: repeat
|
||||||
|
description: "Set repeat mode"
|
||||||
|
uses_apis:
|
||||||
|
- api_player_play
|
||||||
|
- api_player_pause
|
||||||
|
- api_player_next
|
||||||
|
- api_player_previous
|
||||||
|
- api_player_queue
|
||||||
|
|
||||||
|
- id: component_upload_manager
|
||||||
|
name: UploadManager
|
||||||
|
description: "Handles chunked file uploads"
|
||||||
|
props:
|
||||||
|
- name: onUploadComplete
|
||||||
|
type: function
|
||||||
|
required: true
|
||||||
|
description: "Callback when upload completes"
|
||||||
|
- name: acceptedTypes
|
||||||
|
type: array
|
||||||
|
required: false
|
||||||
|
description: "Accepted file types"
|
||||||
|
- name: maxSize
|
||||||
|
type: number
|
||||||
|
required: false
|
||||||
|
description: "Max file size in bytes"
|
||||||
|
events:
|
||||||
|
- name: progress
|
||||||
|
description: "Upload progress update"
|
||||||
|
- name: error
|
||||||
|
description: "Upload error"
|
||||||
|
uses_apis:
|
||||||
|
- api_upload_init
|
||||||
|
- api_upload_chunk
|
||||||
|
- api_upload_complete
|
||||||
|
|
||||||
|
- id: component_search_bar
|
||||||
|
name: SearchBar
|
||||||
|
description: "Search input with suggestions"
|
||||||
|
props:
|
||||||
|
- name: placeholder
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
description: "Input placeholder"
|
||||||
|
- name: autoFocus
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
description: "Auto focus input"
|
||||||
|
events:
|
||||||
|
- name: search
|
||||||
|
description: "Search query submitted"
|
||||||
|
- name: suggestionSelect
|
||||||
|
description: "Suggestion selected"
|
||||||
|
uses_apis:
|
||||||
|
- api_search_suggestions
|
||||||
|
|
||||||
|
- id: component_search_results
|
||||||
|
name: SearchResults
|
||||||
|
description: "Display search results"
|
||||||
|
props:
|
||||||
|
- name: query
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: "Search query"
|
||||||
|
- name: filters
|
||||||
|
type: object
|
||||||
|
required: false
|
||||||
|
description: "Search filters"
|
||||||
|
events:
|
||||||
|
- name: playSong
|
||||||
|
description: "Play selected song"
|
||||||
|
- name: viewAlbum
|
||||||
|
description: "View album details"
|
||||||
|
- name: viewArtist
|
||||||
|
description: "View artist profile"
|
||||||
|
uses_apis:
|
||||||
|
- api_search
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
feature: "examine what is missing in current app and implement it"
|
||||||
|
expanded_at: "2025-12-20T00:00:00.000Z"
|
||||||
|
mode: full_auto
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
current_state: "Sonic Cloud is a Next.js music platform with foundational components and database schema in place. The app has a dark theme UI with header navigation, but most functionality is still in PENDING state in the manifest. The database schema is well-designed with models for User, Artist, Label, Genre, Album, Song, Playlist, and more. Some components and API routes exist but the core music player functionality is not connected."
|
||||||
|
app_type: "Music streaming and distribution platform"
|
||||||
|
core_purpose: "Provide a platform for musicians to upload, share, and distribute their music, and for users to discover, stream, and organize music"
|
||||||
|
|
||||||
|
missing_critical:
|
||||||
|
- feature: "Audio Playback Engine"
|
||||||
|
reason: "Without a working audio player, the app cannot fulfill its core purpose as a music platform"
|
||||||
|
impact: "Users cannot listen to music, making the platform non-functional"
|
||||||
|
|
||||||
|
- feature: "User Authentication System"
|
||||||
|
reason: "Currently no auth implementation despite having auth components and API routes defined"
|
||||||
|
impact: "Users cannot register, login, or have personalized experiences"
|
||||||
|
|
||||||
|
- feature: "File Upload Infrastructure"
|
||||||
|
reason: "No actual file storage or streaming capability for audio files"
|
||||||
|
impact: "Artists cannot upload music, platform has no content to serve"
|
||||||
|
|
||||||
|
- feature: "Music Discovery Features"
|
||||||
|
reason: "No recommendation algorithm, personalized content, or discovery beyond basic listings"
|
||||||
|
impact: "Poor user engagement and difficulty finding relevant music"
|
||||||
|
|
||||||
|
- feature: "Search Functionality"
|
||||||
|
reason: "Search components exist but no backend implementation"
|
||||||
|
impact: "Users cannot find specific songs, artists, or albums"
|
||||||
|
|
||||||
|
missing_important:
|
||||||
|
- feature: "User Library/Favorites"
|
||||||
|
reason: "No way for users to save or favorite songs they like"
|
||||||
|
impact: "Poor user retention and no personalized collection building"
|
||||||
|
|
||||||
|
- feature: "Social Features"
|
||||||
|
reason: "No user profiles, following, or social interaction"
|
||||||
|
impact: "Limited community engagement and viral growth potential"
|
||||||
|
|
||||||
|
- feature: "Mobile App or Responsive Design"
|
||||||
|
reason: "Desktop-only experience limits accessibility"
|
||||||
|
impact: "Missed mobile users who comprise majority of music streaming market"
|
||||||
|
|
||||||
|
- feature: "Analytics for Artists"
|
||||||
|
reason: "Artists need data about their listeners and performance"
|
||||||
|
impact: "Reduced value proposition for artist users"
|
||||||
|
|
||||||
|
- feature: "Playlist Collaboration"
|
||||||
|
reason: "No way to share or collaborate on playlists"
|
||||||
|
impact: "Limited social music discovery and curation"
|
||||||
|
|
||||||
|
missing_nice_to_have:
|
||||||
|
- feature: "Podcast Support"
|
||||||
|
reason: "Expand platform beyond music to spoken word content"
|
||||||
|
impact: "Broader audience and increased engagement"
|
||||||
|
|
||||||
|
- feature: "Live Streaming"
|
||||||
|
reason: "Allow artists to broadcast live performances"
|
||||||
|
impact: "Unique differentiator and community building tool"
|
||||||
|
|
||||||
|
- feature: "AI-Powered Recommendations"
|
||||||
|
reason: "Better music discovery through machine learning"
|
||||||
|
impact: "Improved user experience and retention"
|
||||||
|
|
||||||
|
- feature: "Integration with External Services"
|
||||||
|
reason: "Connect with Spotify, Apple Music, social media"
|
||||||
|
impact: "Easier onboarding and cross-platform presence"
|
||||||
|
|
||||||
|
- feature: "Merchandise Store"
|
||||||
|
reason: "Allow artists to sell merchandise"
|
||||||
|
impact: "Additional revenue stream for artists and platform"
|
||||||
|
|
||||||
|
technical_gaps:
|
||||||
|
- gap: "No WebSocket implementation for real-time features"
|
||||||
|
category: "performance"
|
||||||
|
priority: "high"
|
||||||
|
|
||||||
|
- gap: "Missing CDN for audio file delivery"
|
||||||
|
category: "performance"
|
||||||
|
priority: "high"
|
||||||
|
|
||||||
|
- gap: "No caching layer for API responses"
|
||||||
|
category: "performance"
|
||||||
|
priority: "medium"
|
||||||
|
|
||||||
|
- gap: "No rate limiting on API endpoints"
|
||||||
|
category: "security"
|
||||||
|
priority: "high"
|
||||||
|
|
||||||
|
- gap: "Missing input validation and sanitization"
|
||||||
|
category: "security"
|
||||||
|
priority: "high"
|
||||||
|
|
||||||
|
- gap: "No content moderation system"
|
||||||
|
category: "security"
|
||||||
|
priority: "medium"
|
||||||
|
|
||||||
|
- gap: "Limited accessibility features"
|
||||||
|
category: "accessibility"
|
||||||
|
priority: "medium"
|
||||||
|
|
||||||
|
- gap: "No SEO optimization for dynamic content"
|
||||||
|
category: "seo"
|
||||||
|
priority: "medium"
|
||||||
|
|
||||||
|
- gap: "No monitoring or error tracking"
|
||||||
|
category: "performance"
|
||||||
|
priority: "low"
|
||||||
|
|
||||||
|
- gap: "No automated testing suite"
|
||||||
|
category: "quality"
|
||||||
|
priority: "medium"
|
||||||
|
|
||||||
|
implementation_plan:
|
||||||
|
phase_1: "Implement core functionality - Audio player with controls, User authentication with NextAuth.js, File upload with Cloudinary/AWS S3, Basic search with full-text search, Database seeding with sample content"
|
||||||
|
phase_2: "Enhance user experience - User favorites/library, Improved music discovery with categories, Artist analytics dashboard, Playlist sharing features, Mobile-responsive design improvements"
|
||||||
|
phase_3: "Add advanced features - Social features (following, user profiles), Podcast support infrastructure, Basic recommendation engine, Performance optimizations (CDN, caching), Security hardening"
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
feature: "examine what is missing in current app and implement it"
|
||||||
|
mode: full_auto
|
||||||
|
finalized_at: 2025-12-20T22:05:00Z
|
||||||
|
|
||||||
|
analysis:
|
||||||
|
current_state: "Next.js music streaming platform with complete data model but most features unimplemented"
|
||||||
|
app_type: "music streaming platform"
|
||||||
|
core_purpose: "Allow users to discover, stream, and manage music"
|
||||||
|
|
||||||
|
missing_critical:
|
||||||
|
- feature: "Audio Playback Engine"
|
||||||
|
reason: "Core functionality - users cannot play music"
|
||||||
|
impact: "App is non-functional without audio playback"
|
||||||
|
- feature: "User Authentication"
|
||||||
|
reason: "No way to identify users or save preferences"
|
||||||
|
impact: "Cannot provide personalized experience"
|
||||||
|
- feature: "File Upload System"
|
||||||
|
reason: "No mechanism to add music to the platform"
|
||||||
|
impact: "Platform has no content to stream"
|
||||||
|
- feature: "Search Implementation"
|
||||||
|
reason: "UI exists but no backend search capability"
|
||||||
|
impact: "Users cannot discover music beyond browsing"
|
||||||
|
|
||||||
|
missing_important:
|
||||||
|
- feature: "User Library/Favorites"
|
||||||
|
reason: "No way for users to save liked music"
|
||||||
|
impact: "Poor user retention and experience"
|
||||||
|
- feature: "Music Discovery"
|
||||||
|
reason: "No recommendations beyond basic listings"
|
||||||
|
impact: "Limited content exploration"
|
||||||
|
- feature: "Artist Analytics Dashboard"
|
||||||
|
reason: "Artists cannot track their performance"
|
||||||
|
impact: "No value proposition for content creators"
|
||||||
|
- feature: "Playlist Collaboration"
|
||||||
|
reason: "No social features or sharing"
|
||||||
|
impact: "Limited community engagement"
|
||||||
|
|
||||||
|
missing_nice_to_have:
|
||||||
|
- feature: "Mobile App"
|
||||||
|
reason: "No native mobile experience"
|
||||||
|
impact: "Limited accessibility"
|
||||||
|
- feature: "AI Recommendations"
|
||||||
|
reason: "No personalized discovery engine"
|
||||||
|
impact: "Generic user experience"
|
||||||
|
- feature: "Live Streaming"
|
||||||
|
reason: "No real-time performance features"
|
||||||
|
impact: "Limited artist engagement"
|
||||||
|
- feature: "Social Features"
|
||||||
|
reason: "No user profiles or following"
|
||||||
|
impact: "No community building"
|
||||||
|
|
||||||
|
technical_gaps:
|
||||||
|
- gap: "No WebSocket implementation"
|
||||||
|
category: "real-time"
|
||||||
|
priority: "high"
|
||||||
|
- gap: "No CDN for audio delivery"
|
||||||
|
category: "performance"
|
||||||
|
priority: "high"
|
||||||
|
- gap: "No API rate limiting"
|
||||||
|
category: "security"
|
||||||
|
priority: "medium"
|
||||||
|
- gap: "No caching layer"
|
||||||
|
category: "performance"
|
||||||
|
priority: "medium"
|
||||||
|
- gap: "Limited accessibility features"
|
||||||
|
category: "accessibility"
|
||||||
|
priority: "low"
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
- criterion: "Users can play audio tracks"
|
||||||
|
verification: "Click play on any song and verify audio plays with controls"
|
||||||
|
- criterion: "Users can register and login"
|
||||||
|
verification: "Complete registration flow, verify login works, session persists"
|
||||||
|
- criterion: "Users can upload music files"
|
||||||
|
verification: "Upload mp3/other format, verify file stored and appears in library"
|
||||||
|
- criterion: "Search returns relevant results"
|
||||||
|
verification: "Search for songs/artists/albums, verify accurate results"
|
||||||
|
- criterion: "Player controls work properly"
|
||||||
|
verification: "Play, pause, skip, volume, seek all function correctly"
|
||||||
|
- criterion: "Queue system works"
|
||||||
|
verification: "Add songs to queue, verify they play in order"
|
||||||
|
- criterion: "User can save favorites"
|
||||||
|
verification: "Like songs, verify they appear in favorites list"
|
||||||
|
- criterion: "Responsive design works"
|
||||||
|
verification: "Test on mobile/tablet, verify layout adapts properly"
|
||||||
|
|
||||||
|
implementation_plan:
|
||||||
|
phase_1: "Audio player, authentication, file upload, search implementation"
|
||||||
|
phase_2: "User library, favorites, basic discovery, artist dashboard"
|
||||||
|
phase_3: "Social features, advanced recommendations, performance optimizations"
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
version: v005
|
||||||
|
feature: examine what is missing in current app and implement it
|
||||||
|
session_id: workflow_20251220_220210
|
||||||
|
parent_version: null
|
||||||
|
status: completed
|
||||||
|
started_at: '2025-12-20T22:02:10.309550'
|
||||||
|
completed_at: '2025-12-20T22:28:45.494863'
|
||||||
|
current_phase: COMPLETING
|
||||||
|
approvals:
|
||||||
|
design:
|
||||||
|
status: approved
|
||||||
|
approved_by: user
|
||||||
|
approved_at: '2025-12-20T22:11:01.686710'
|
||||||
|
rejection_reason: null
|
||||||
|
implementation:
|
||||||
|
status: approved
|
||||||
|
approved_by: user
|
||||||
|
approved_at: '2025-12-20T22:28:32.697728'
|
||||||
|
rejection_reason: null
|
||||||
|
task_sessions: []
|
||||||
|
summary:
|
||||||
|
total_tasks: 0
|
||||||
|
tasks_completed: 0
|
||||||
|
entities_created: 0
|
||||||
|
entities_updated: 0
|
||||||
|
entities_deleted: 0
|
||||||
|
files_created: 0
|
||||||
|
files_updated: 0
|
||||||
|
files_deleted: 0
|
||||||
|
updated_at: '2025-12-20T22:28:45.494868'
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
version: v005
|
||||||
|
feature: examine what is missing in current app and implement it
|
||||||
|
session_id: workflow_20251220_220210
|
||||||
|
parent_version: null
|
||||||
|
status: pending
|
||||||
|
started_at: '2025-12-20T22:02:10.309550'
|
||||||
|
completed_at: null
|
||||||
|
current_phase: COMPLETING
|
||||||
|
approvals:
|
||||||
|
design:
|
||||||
|
status: approved
|
||||||
|
approved_by: user
|
||||||
|
approved_at: '2025-12-20T22:11:01.686710'
|
||||||
|
rejection_reason: null
|
||||||
|
implementation:
|
||||||
|
status: approved
|
||||||
|
approved_by: user
|
||||||
|
approved_at: '2025-12-20T22:28:32.697728'
|
||||||
|
rejection_reason: null
|
||||||
|
task_sessions: []
|
||||||
|
summary:
|
||||||
|
total_tasks: 0
|
||||||
|
tasks_completed: 0
|
||||||
|
entities_created: 0
|
||||||
|
entities_updated: 0
|
||||||
|
entities_deleted: 0
|
||||||
|
files_created: 0
|
||||||
|
files_updated: 0
|
||||||
|
files_deleted: 0
|
||||||
|
updated_at: '2025-12-20T22:28:32.740115'
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,737 @@
|
||||||
|
{
|
||||||
|
"project": {
|
||||||
|
"name": "sonic-cloud",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"created_at": "2025-12-18T14:32:39.275839",
|
||||||
|
"description": "Music platform for musicians to upload songs"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"current_phase": "DESIGN_PHASE",
|
||||||
|
"approval_status": {
|
||||||
|
"manifest_approved": false,
|
||||||
|
"approved_by": null,
|
||||||
|
"approved_at": null
|
||||||
|
},
|
||||||
|
"revision_history": [
|
||||||
|
{
|
||||||
|
"action": "PROJECT_INITIALIZED",
|
||||||
|
"timestamp": "2025-12-18T14:32:39.275844",
|
||||||
|
"details": "Project sonic-cloud created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "DESIGN_DOCUMENT_CREATED",
|
||||||
|
"timestamp": "2025-12-18T15:10:00",
|
||||||
|
"details": "Complete design document with 91 entities created"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entities": {
|
||||||
|
"database_tables": [
|
||||||
|
{
|
||||||
|
"id": "model_user",
|
||||||
|
"name": "User",
|
||||||
|
"table_name": "users",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_artist",
|
||||||
|
"name": "Artist",
|
||||||
|
"table_name": "artists",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_label",
|
||||||
|
"name": "Label",
|
||||||
|
"table_name": "labels",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_genre",
|
||||||
|
"name": "Genre",
|
||||||
|
"table_name": "genres",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_album",
|
||||||
|
"name": "Album",
|
||||||
|
"table_name": "albums",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_song",
|
||||||
|
"name": "Song",
|
||||||
|
"table_name": "songs",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_song_genre",
|
||||||
|
"name": "SongGenre",
|
||||||
|
"table_name": "song_genres",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_playlist",
|
||||||
|
"name": "Playlist",
|
||||||
|
"table_name": "playlists",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_playlist_song",
|
||||||
|
"name": "PlaylistSong",
|
||||||
|
"table_name": "playlist_songs",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_share",
|
||||||
|
"name": "Share",
|
||||||
|
"table_name": "shares",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"api_endpoints": [
|
||||||
|
{
|
||||||
|
"id": "api_register",
|
||||||
|
"name": "Register User",
|
||||||
|
"path": "/api/auth/register",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/register/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_login",
|
||||||
|
"name": "Login",
|
||||||
|
"path": "/api/auth/login",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/login/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_forgot_password",
|
||||||
|
"name": "Forgot Password",
|
||||||
|
"path": "/api/auth/forgot-password",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/forgot-password/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_reset_password",
|
||||||
|
"name": "Reset Password",
|
||||||
|
"path": "/api/auth/reset-password",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/reset-password/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_current_user",
|
||||||
|
"name": "Get Current User",
|
||||||
|
"path": "/api/users/me",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/users/me/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_update_current_user",
|
||||||
|
"name": "Update Current User",
|
||||||
|
"path": "/api/users/me",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/users/me/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_create_artist_profile",
|
||||||
|
"name": "Create Artist Profile",
|
||||||
|
"path": "/api/artists",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/artists/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_artist",
|
||||||
|
"name": "Get Artist",
|
||||||
|
"path": "/api/artists/:id",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/artists/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_update_artist",
|
||||||
|
"name": "Update Artist",
|
||||||
|
"path": "/api/artists/:id",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/artists/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_artist_songs",
|
||||||
|
"name": "Get Artist Songs",
|
||||||
|
"path": "/api/artists/:id/songs",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/artists/[id]/songs/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_artist_albums",
|
||||||
|
"name": "Get Artist Albums",
|
||||||
|
"path": "/api/artists/:id/albums",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/artists/[id]/albums/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_upload_song",
|
||||||
|
"name": "Upload Song",
|
||||||
|
"path": "/api/songs/upload",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/songs/upload/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_song",
|
||||||
|
"name": "Get Song",
|
||||||
|
"path": "/api/songs/:id",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/songs/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_update_song",
|
||||||
|
"name": "Update Song",
|
||||||
|
"path": "/api/songs/:id",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/songs/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_delete_song",
|
||||||
|
"name": "Delete Song",
|
||||||
|
"path": "/api/songs/:id",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/songs/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_increment_play_count",
|
||||||
|
"name": "Increment Play Count",
|
||||||
|
"path": "/api/songs/:id/play",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/songs/[id]/play/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_create_album",
|
||||||
|
"name": "Create Album",
|
||||||
|
"path": "/api/albums",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/albums/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_album",
|
||||||
|
"name": "Get Album",
|
||||||
|
"path": "/api/albums/:id",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/albums/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_update_album",
|
||||||
|
"name": "Update Album",
|
||||||
|
"path": "/api/albums/:id",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/albums/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_delete_album",
|
||||||
|
"name": "Delete Album",
|
||||||
|
"path": "/api/albums/:id",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/albums/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_create_playlist",
|
||||||
|
"name": "Create Playlist",
|
||||||
|
"path": "/api/playlists",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/playlists/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_user_playlists",
|
||||||
|
"name": "Get User Playlists",
|
||||||
|
"path": "/api/playlists",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/playlists/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_playlist",
|
||||||
|
"name": "Get Playlist",
|
||||||
|
"path": "/api/playlists/:id",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/playlists/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_update_playlist",
|
||||||
|
"name": "Update Playlist",
|
||||||
|
"path": "/api/playlists/:id",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/playlists/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_delete_playlist",
|
||||||
|
"name": "Delete Playlist",
|
||||||
|
"path": "/api/playlists/:id",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/playlists/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_add_song_to_playlist",
|
||||||
|
"name": "Add Song to Playlist",
|
||||||
|
"path": "/api/playlists/:id/songs",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/playlists/[id]/songs/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_remove_song_from_playlist",
|
||||||
|
"name": "Remove Song from Playlist",
|
||||||
|
"path": "/api/playlists/:playlistId/songs/:songId",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/playlists/[playlistId]/songs/[songId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_reorder_playlist_songs",
|
||||||
|
"name": "Reorder Playlist Songs",
|
||||||
|
"path": "/api/playlists/:id/reorder",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/playlists/[id]/reorder/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_trending_songs",
|
||||||
|
"name": "Get Trending Songs",
|
||||||
|
"path": "/api/discover/trending",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/discover/trending/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_new_releases",
|
||||||
|
"name": "Get New Releases",
|
||||||
|
"path": "/api/discover/new-releases",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/discover/new-releases/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_genres",
|
||||||
|
"name": "Get Genres",
|
||||||
|
"path": "/api/discover/genres",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/discover/genres/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_songs_by_genre",
|
||||||
|
"name": "Get Songs by Genre",
|
||||||
|
"path": "/api/discover/genres/:slug",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/discover/genres/[slug]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_search",
|
||||||
|
"name": "Search",
|
||||||
|
"path": "/api/search",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_create_label_profile",
|
||||||
|
"name": "Create Label Profile",
|
||||||
|
"path": "/api/labels",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/labels/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_label_artists",
|
||||||
|
"name": "Get Label Artists",
|
||||||
|
"path": "/api/labels/:id/artists",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/labels/[id]/artists/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_create_song_share",
|
||||||
|
"name": "Create Song Share",
|
||||||
|
"path": "/api/share/song/:id",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/share/song/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_create_playlist_share",
|
||||||
|
"name": "Create Playlist Share",
|
||||||
|
"path": "/api/share/playlist/:id",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/share/playlist/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_create_album_share",
|
||||||
|
"name": "Create Album Share",
|
||||||
|
"path": "/api/share/album/:id",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/share/album/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_resolve_share",
|
||||||
|
"name": "Resolve Share",
|
||||||
|
"path": "/api/share/:token",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/share/[token]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_track_share_click",
|
||||||
|
"name": "Track Share Click",
|
||||||
|
"path": "/api/share/:token/click",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/share/[token]/click/route.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"id": "page_login",
|
||||||
|
"name": "Login",
|
||||||
|
"path": "/login",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/login/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_register",
|
||||||
|
"name": "Register",
|
||||||
|
"path": "/register",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/register/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_forgot_password",
|
||||||
|
"name": "Forgot Password",
|
||||||
|
"path": "/forgot-password",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/forgot-password/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_home",
|
||||||
|
"name": "Discover Music",
|
||||||
|
"path": "/",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_artist_profile",
|
||||||
|
"name": "Artist Profile",
|
||||||
|
"path": "/artist/:id",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/artist/[id]/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_album_detail",
|
||||||
|
"name": "Album",
|
||||||
|
"path": "/album/:id",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/album/[id]/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_upload",
|
||||||
|
"name": "Upload Music",
|
||||||
|
"path": "/upload",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/upload/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_playlists",
|
||||||
|
"name": "My Playlists",
|
||||||
|
"path": "/playlists",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/playlists/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_playlist_detail",
|
||||||
|
"name": "Playlist",
|
||||||
|
"path": "/playlist/:id",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/playlist/[id]/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_profile",
|
||||||
|
"name": "Profile Settings",
|
||||||
|
"path": "/profile",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/profile/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_search",
|
||||||
|
"name": "Search",
|
||||||
|
"path": "/search",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/search/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_genre_browse",
|
||||||
|
"name": "Browse Genre",
|
||||||
|
"path": "/genre/:slug",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/genre/[slug]/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_share",
|
||||||
|
"name": "Share",
|
||||||
|
"path": "/s/:token",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/s/[token]/page.tsx"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "component_audio_player",
|
||||||
|
"name": "AudioPlayer",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AudioPlayer.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_player_controls",
|
||||||
|
"name": "PlayerControls",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/PlayerControls.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_song_card",
|
||||||
|
"name": "SongCard",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SongCard.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_album_card",
|
||||||
|
"name": "AlbumCard",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AlbumCard.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_artist_card",
|
||||||
|
"name": "ArtistCard",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/ArtistCard.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_playlist_card",
|
||||||
|
"name": "PlaylistCard",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/PlaylistCard.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_upload_form",
|
||||||
|
"name": "UploadForm",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/UploadForm.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_waveform_display",
|
||||||
|
"name": "WaveformDisplay",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/WaveformDisplay.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_genre_badge",
|
||||||
|
"name": "GenreBadge",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/GenreBadge.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_track_list",
|
||||||
|
"name": "TrackList",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/TrackList.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_artist_header",
|
||||||
|
"name": "ArtistHeader",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/ArtistHeader.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_album_header",
|
||||||
|
"name": "AlbumHeader",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AlbumHeader.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_playlist_header",
|
||||||
|
"name": "PlaylistHeader",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/PlaylistHeader.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_social_links",
|
||||||
|
"name": "SocialLinks",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SocialLinks.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_auth_form",
|
||||||
|
"name": "AuthForm",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AuthForm.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_search_bar",
|
||||||
|
"name": "SearchBar",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SearchBar.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_search_results",
|
||||||
|
"name": "SearchResults",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SearchResults.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_create_playlist_modal",
|
||||||
|
"name": "CreatePlaylistModal",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/CreatePlaylistModal.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_profile_form",
|
||||||
|
"name": "ProfileForm",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/ProfileForm.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_avatar_upload",
|
||||||
|
"name": "AvatarUpload",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AvatarUpload.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_section_header",
|
||||||
|
"name": "SectionHeader",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SectionHeader.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_genre_header",
|
||||||
|
"name": "GenreHeader",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/GenreHeader.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_header",
|
||||||
|
"name": "Header",
|
||||||
|
"status": "IMPLEMENTED",
|
||||||
|
"file_path": "components/Header.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_nav_link",
|
||||||
|
"name": "NavLink",
|
||||||
|
"status": "IMPLEMENTED",
|
||||||
|
"file_path": "components/NavLink.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_user_menu",
|
||||||
|
"name": "UserMenu",
|
||||||
|
"status": "IMPLEMENTED",
|
||||||
|
"file_path": "components/UserMenu.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_share_button",
|
||||||
|
"name": "ShareButton",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/ShareButton.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_share_modal",
|
||||||
|
"name": "ShareModal",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/ShareModal.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_social_share_buttons",
|
||||||
|
"name": "SocialShareButtons",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SocialShareButtons.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_share_content_display",
|
||||||
|
"name": "SharedContentDisplay",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SharedContentDisplay.tsx"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"component_to_page": {
|
||||||
|
"component_auth_form": ["page_login", "page_register", "page_forgot_password"],
|
||||||
|
"component_song_card": ["page_home", "page_artist_profile", "page_search", "page_genre_browse", "page_album_detail", "page_playlist_detail"],
|
||||||
|
"component_genre_badge": ["page_home"],
|
||||||
|
"component_section_header": ["page_home"],
|
||||||
|
"component_artist_header": ["page_artist_profile"],
|
||||||
|
"component_album_card": ["page_artist_profile", "page_search"],
|
||||||
|
"component_social_links": ["page_artist_profile"],
|
||||||
|
"component_album_header": ["page_album_detail"],
|
||||||
|
"component_track_list": ["page_album_detail", "page_playlist_detail"],
|
||||||
|
"component_upload_form": ["page_upload"],
|
||||||
|
"component_waveform_display": ["page_upload"],
|
||||||
|
"component_playlist_card": ["page_playlists"],
|
||||||
|
"component_create_playlist_modal": ["page_playlists"],
|
||||||
|
"component_playlist_header": ["page_playlist_detail"],
|
||||||
|
"component_profile_form": ["page_profile"],
|
||||||
|
"component_avatar_upload": ["page_profile"],
|
||||||
|
"component_search_bar": ["page_search"],
|
||||||
|
"component_search_results": ["page_search"],
|
||||||
|
"component_artist_card": ["page_search"],
|
||||||
|
"component_genre_header": ["page_genre_browse"]
|
||||||
|
},
|
||||||
|
"api_to_component": {
|
||||||
|
"api_login": ["component_auth_form"],
|
||||||
|
"api_register": ["component_auth_form"],
|
||||||
|
"api_forgot_password": ["component_auth_form"],
|
||||||
|
"api_upload_song": ["component_upload_form"],
|
||||||
|
"api_increment_play_count": ["component_audio_player"],
|
||||||
|
"api_search": ["component_search_bar"],
|
||||||
|
"api_create_playlist": ["component_create_playlist_modal"],
|
||||||
|
"api_update_current_user": ["component_profile_form"]
|
||||||
|
},
|
||||||
|
"table_to_api": {
|
||||||
|
"model_user": ["api_register", "api_login", "api_forgot_password", "api_reset_password", "api_get_current_user", "api_update_current_user"],
|
||||||
|
"model_artist": ["api_create_artist_profile", "api_get_artist", "api_update_artist", "api_get_artist_songs", "api_get_artist_albums"],
|
||||||
|
"model_song": ["api_upload_song", "api_get_song", "api_update_song", "api_delete_song", "api_increment_play_count", "api_get_artist_songs", "api_get_trending_songs", "api_get_new_releases", "api_search"],
|
||||||
|
"model_album": ["api_create_album", "api_get_album", "api_update_album", "api_delete_album", "api_get_artist_albums", "api_search"],
|
||||||
|
"model_playlist": ["api_create_playlist", "api_get_user_playlists", "api_get_playlist", "api_update_playlist", "api_delete_playlist"],
|
||||||
|
"model_playlist_song": ["api_add_song_to_playlist", "api_remove_song_from_playlist", "api_reorder_playlist_songs"],
|
||||||
|
"model_genre": ["api_get_genres", "api_get_songs_by_genre"],
|
||||||
|
"model_label": ["api_create_label_profile", "api_get_label_artists"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { token } = body
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verification token is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user with this verification token
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
emailToken: token,
|
||||||
|
resetExpires: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
displayName: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid or expired verification token' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailVerified) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email is already verified' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the email
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
emailVerified: true,
|
||||||
|
emailToken: null,
|
||||||
|
resetExpires: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: 'Email verified successfully',
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Confirm email error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to verify email' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { getCurrentUser, revokeAllSessions, revokeAllRefreshTokens } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Get current user
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Not authenticated' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke all sessions and refresh tokens for this user
|
||||||
|
await Promise.all([
|
||||||
|
revokeAllSessions(user.id),
|
||||||
|
revokeAllRefreshTokens(user.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Clear the auth cookie
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{ message: 'Logged out successfully' },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
|
||||||
|
response.cookies.set('auth-token', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 0,
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to logout' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { generateToken, validateRefreshToken, createRefreshToken } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { refreshToken } = body
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Refresh token is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the refresh token
|
||||||
|
const user = await validateRefreshToken(refreshToken)
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid or expired refresh token' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new access token
|
||||||
|
const accessToken = generateToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate new refresh token (token rotation)
|
||||||
|
const newRefreshToken = await createRefreshToken(user.id)
|
||||||
|
|
||||||
|
// Set the new access token in cookie
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
accessToken,
|
||||||
|
refreshToken: newRefreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
|
||||||
|
response.cookies.set('auth-token', accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 15, // 15 minutes
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to refresh token' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireAuth, revokeSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const { id: sessionId } = await params
|
||||||
|
|
||||||
|
// First check if the session belongs to the current user
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { id: sessionId },
|
||||||
|
select: { userId: true, token: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Session not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.userId !== user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the session
|
||||||
|
await revokeSession(session.token)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Session revoked successfully' },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Revoke session error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to revoke session' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireAuth, getUserSessions } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
|
||||||
|
const sessions = await getUserSessions(user.id)
|
||||||
|
|
||||||
|
// Parse deviceInfo JSON for each session
|
||||||
|
const formattedSessions = sessions.map(session => ({
|
||||||
|
...session,
|
||||||
|
deviceInfo: session.deviceInfo ? JSON.parse(session.deviceInfo) : undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ sessions: formattedSessions }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get sessions error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch sessions' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { generateEmailToken } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { email } = body
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true, emailVerified: true, displayName: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Don't reveal that email doesn't exist
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'If the email exists, a verification link has been sent' },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailVerified) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email is already verified' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
const emailToken = generateEmailToken()
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + 24) // 24 hours
|
||||||
|
|
||||||
|
// Update user with verification token
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
emailToken,
|
||||||
|
resetExpires: expiresAt, // Using resetExpires field for email token expiry
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Send actual email with verification link
|
||||||
|
// For now, just log the token (in production, use a proper email service)
|
||||||
|
console.log(`Email verification for ${email}: ${emailToken}`)
|
||||||
|
|
||||||
|
// The verification URL would be:
|
||||||
|
// `${process.env.NEXT_PUBLIC_APP_URL}/auth/confirm-email?token=${emailToken}`
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'If the email exists, a verification link has been sent' },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send verification email error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to send verification email' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { getPlayHistory, updatePlayHistoryItem } from '@/lib/player'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50')
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0')
|
||||||
|
|
||||||
|
// Validate pagination parameters
|
||||||
|
if (limit < 1 || limit > 100) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Limit must be between 1 and 100' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Offset must be non-negative' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await getPlayHistory(user.id, limit, offset)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ history, limit, offset },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get play history error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch play history' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const body = await request.json()
|
||||||
|
const { songId, playedDuration, completed } = body
|
||||||
|
|
||||||
|
if (!songId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Song ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof playedDuration !== 'number' || playedDuration < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Played duration must be a non-negative number' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await updatePlayHistoryItem(
|
||||||
|
user.id,
|
||||||
|
songId,
|
||||||
|
playedDuration,
|
||||||
|
completed || false
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Play history updated' },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update play history error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update play history' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { getNextInQueue, playSong } from '@/lib/player'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
|
||||||
|
const nextSongId = await getNextInQueue(user.id)
|
||||||
|
if (!nextSongId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No next song in queue' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await playSong(user.id, nextSongId, 'queue')
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Playing next song', songId: nextSongId },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Next song error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to play next song' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
|
||||||
|
// In a real implementation, you might want to:
|
||||||
|
// 1. Store the current playback state in Redis or database
|
||||||
|
// 2. Update the currently playing song's play duration
|
||||||
|
// 3. Handle pause for streaming services
|
||||||
|
|
||||||
|
// For now, just return success
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Playback paused' },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Pause error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to pause playback' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { playSong, getNextInQueue } from '@/lib/player'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const body = await request.json()
|
||||||
|
const { songId } = body
|
||||||
|
|
||||||
|
// If a specific songId is provided, play that song
|
||||||
|
if (songId) {
|
||||||
|
// Verify the song exists and is public
|
||||||
|
const song = await prisma.song.findUnique({
|
||||||
|
where: { id: songId },
|
||||||
|
select: { id: true, isPublic: true, artistId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!song) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Song not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!song.isPublic && song.artistId !== user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot play this song' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await playSong(user.id, songId, 'manual')
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Playing song', songId },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no songId, play next in queue
|
||||||
|
const nextSongId = await getNextInQueue(user.id)
|
||||||
|
if (!nextSongId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No songs in queue' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await playSong(user.id, nextSongId, 'queue')
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Playing next song in queue', songId: nextSongId },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Play error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to play song' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { getPreviousInQueue, playSong } from '@/lib/player'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
|
||||||
|
const previousSongId = await getPreviousInQueue(user.id)
|
||||||
|
if (!previousSongId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No previous song in queue' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await playSong(user.id, previousSongId, 'queue')
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Playing previous song', songId: previousSongId },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Previous song error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to play previous song' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { getUserQueue, addToQueue, updateUserQueue } from '@/lib/player'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const queue = await getUserQueue(user.id)
|
||||||
|
|
||||||
|
// Get song details for the queue
|
||||||
|
const songIds = queue.songIds as string[] || []
|
||||||
|
if (songIds.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
...queue,
|
||||||
|
songIds: [],
|
||||||
|
songs: [],
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: songIds },
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
duration: true,
|
||||||
|
coverUrl: true,
|
||||||
|
artist: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
album: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Order songs according to queue order
|
||||||
|
const orderedSongs = songIds.map(id => songs.find(song => song.id === id)).filter(Boolean)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
...queue,
|
||||||
|
songIds,
|
||||||
|
songs: orderedSongs,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get queue error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch queue' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const body = await request.json()
|
||||||
|
const { songIds, currentIndex, isShuffled, repeatMode } = body
|
||||||
|
|
||||||
|
// Validate song IDs
|
||||||
|
if (songIds && Array.isArray(songIds)) {
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: songIds },
|
||||||
|
OR: [
|
||||||
|
{ isPublic: true },
|
||||||
|
{ artistId: user.id },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const validSongIds = songs.map(song => song.id)
|
||||||
|
const invalidSongIds = songIds.filter(id => !validSongIds.includes(id))
|
||||||
|
|
||||||
|
if (invalidSongIds.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid song IDs', invalidSongs: invalidSongIds },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = await addToQueue(user.id, songIds)
|
||||||
|
return NextResponse.json(queue, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue properties
|
||||||
|
const currentQueue = await getUserQueue(user.id)
|
||||||
|
const updatedQueue = await updateUserQueue(
|
||||||
|
user.id,
|
||||||
|
currentQueue.songIds as string[] || [],
|
||||||
|
currentIndex,
|
||||||
|
isShuffled,
|
||||||
|
repeatMode
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(updatedQueue, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update queue error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update queue' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
|
||||||
|
// Clear the queue
|
||||||
|
const queue = await updateUserQueue(user.id, [], 0, false, 'none')
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Queue cleared', queue },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clear queue error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to clear queue' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { indexEntity, reindexAll } from '@/lib/search'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const body = await request.json()
|
||||||
|
const { action, entityType, entityId, title, content, metadata } = body
|
||||||
|
|
||||||
|
// Only allow admins or content owners to index entities
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Admin access required' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'reindex-all') {
|
||||||
|
// Reindex all entities - admin only operation
|
||||||
|
await reindexAll()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Search index rebuilt successfully' },
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'index-entity') {
|
||||||
|
// Index a single entity
|
||||||
|
if (!entityType || !entityId || !title) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'entityType, entityId, and title are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ['song', 'album', 'artist', 'playlist']
|
||||||
|
if (!validTypes.includes(entityType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid entityType. Must be one of: song, album, artist, playlist' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexedEntity = await indexEntity(
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: 'Entity indexed successfully',
|
||||||
|
entity: indexedEntity,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid action. Must be "reindex-all" or "index-entity"' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search index error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update search index' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,129 +1,76 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { searchEntities } from '@/lib/search'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const query = searchParams.get('q')
|
const query = searchParams.get('q')
|
||||||
|
const type = searchParams.get('type')
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
const limit = parseInt(searchParams.get('limit') || '20')
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0')
|
||||||
|
|
||||||
if (!query || query.trim().length === 0) {
|
if (!query || query.trim().length === 0) {
|
||||||
return NextResponse.json({ error: 'Search query is required' }, { status: 400 })
|
return NextResponse.json(
|
||||||
|
{ error: 'Search query is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const lowerQuery = query.toLowerCase()
|
// Validate parameters
|
||||||
|
if (limit < 1 || limit > 100) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Limit must be between 1 and 100' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Search songs - SQLite uses LIKE for case-insensitive matching with LOWER()
|
if (offset < 0) {
|
||||||
const songs = await prisma.song.findMany({
|
return NextResponse.json(
|
||||||
where: {
|
{ error: 'Offset must be non-negative' },
|
||||||
isPublic: true,
|
{ status: 400 }
|
||||||
OR: [
|
)
|
||||||
{
|
}
|
||||||
title: {
|
|
||||||
contains: lowerQuery,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: {
|
|
||||||
contains: lowerQuery,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
artist: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
slug: true,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
album: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
slug: true,
|
|
||||||
coverUrl: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
genres: {
|
|
||||||
include: {
|
|
||||||
genre: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Search artists
|
if (type && !['song', 'album', 'artist', 'playlist'].includes(type)) {
|
||||||
const artists = await prisma.artist.findMany({
|
return NextResponse.json(
|
||||||
where: {
|
{ error: 'Invalid type. Must be one of: song, album, artist, playlist' },
|
||||||
OR: [
|
{ status: 400 }
|
||||||
{
|
)
|
||||||
name: {
|
}
|
||||||
contains: lowerQuery,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bio: {
|
|
||||||
contains: lowerQuery,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
songs: true,
|
|
||||||
albums: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Search albums
|
// Perform search using the search index
|
||||||
const albums = await prisma.album.findMany({
|
const { results, total } = await searchEntities(
|
||||||
where: {
|
query.trim(),
|
||||||
OR: [
|
type || undefined,
|
||||||
{
|
limit,
|
||||||
title: {
|
offset
|
||||||
contains: lowerQuery,
|
)
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: {
|
|
||||||
contains: lowerQuery,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
artist: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
slug: true,
|
|
||||||
verified: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
songs: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: limit,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
// Group results by entity type for easier frontend consumption
|
||||||
songs,
|
const groupedResults = {
|
||||||
artists,
|
songs: results.filter(r => r.entityType === 'song').map(r => r.entity),
|
||||||
albums,
|
albums: results.filter(r => r.entityType === 'album').map(r => r.entity),
|
||||||
})
|
artists: results.filter(r => r.entityType === 'artist').map(r => r.entity),
|
||||||
|
playlists: results.filter(r => r.entityType === 'playlist').map(r => r.entity),
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
type,
|
||||||
|
results: groupedResults,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + limit < total,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching:', error)
|
console.error('Search error:', error)
|
||||||
return NextResponse.json({ error: 'Failed to perform search' }, { status: 500 })
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to perform search' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getSearchSuggestions } from '@/lib/search'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const query = searchParams.get('q')
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '10')
|
||||||
|
|
||||||
|
if (!query || query.trim().length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Search query is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate limit
|
||||||
|
if (limit < 1 || limit > 50) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Limit must be between 1 and 50' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = await getSearchSuggestions(query.trim(), limit)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
suggestions,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search suggestions error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to get search suggestions' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import type { TrackShareClickResponse, ApiError } from '@/types/api-types';
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ token: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { token } = await params;
|
|
||||||
|
|
||||||
const share = await prisma.share.update({
|
|
||||||
where: { token },
|
|
||||||
data: { clickCount: { increment: 1 } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: TrackShareClickResponse = {
|
|
||||||
success: true,
|
|
||||||
clickCount: share.clickCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json<ApiError>({ error: 'Share not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import type { ResolveShareResponse, SongShareContent, PlaylistShareContent, AlbumShareContent, ApiError, ShareType } from '@/types/api-types';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ token: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { token } = await params;
|
|
||||||
|
|
||||||
const share = await prisma.share.findUnique({
|
|
||||||
where: { token },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
return NextResponse.json<ApiError>(
|
|
||||||
{ error: 'Share not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let content: SongShareContent | PlaylistShareContent | AlbumShareContent;
|
|
||||||
|
|
||||||
if (share.type === 'SONG') {
|
|
||||||
const song = await prisma.song.findUnique({
|
|
||||||
where: { id: share.targetId },
|
|
||||||
include: { artist: true },
|
|
||||||
});
|
|
||||||
if (!song) {
|
|
||||||
return NextResponse.json<ApiError>({ error: 'Content not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
content = {
|
|
||||||
id: song.id,
|
|
||||||
title: song.title,
|
|
||||||
duration: song.duration,
|
|
||||||
coverArtUrl: song.coverUrl || '',
|
|
||||||
fileUrl: song.audioUrl,
|
|
||||||
artist: { id: song.artist.id, stage_name: song.artist.name },
|
|
||||||
};
|
|
||||||
} else if (share.type === 'PLAYLIST') {
|
|
||||||
const playlist = await prisma.playlist.findUnique({
|
|
||||||
where: { id: share.targetId },
|
|
||||||
include: {
|
|
||||||
user: true,
|
|
||||||
songs: { include: { song: { include: { artist: true } } } }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!playlist) {
|
|
||||||
return NextResponse.json<ApiError>({ error: 'Content not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
content = {
|
|
||||||
id: playlist.id,
|
|
||||||
name: playlist.title,
|
|
||||||
description: playlist.description || '',
|
|
||||||
coverImageUrl: playlist.coverUrl || '',
|
|
||||||
songCount: playlist.songs.length,
|
|
||||||
curator: { id: playlist.user.id, name: playlist.user.displayName || playlist.user.username },
|
|
||||||
songs: playlist.songs.map(ps => ({
|
|
||||||
id: ps.song.id,
|
|
||||||
title: ps.song.title,
|
|
||||||
duration: ps.song.duration,
|
|
||||||
cover_art_url: ps.song.coverUrl || '',
|
|
||||||
play_count: ps.song.playCount,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const album = await prisma.album.findUnique({
|
|
||||||
where: { id: share.targetId },
|
|
||||||
include: { artist: true, songs: true },
|
|
||||||
});
|
|
||||||
if (!album) {
|
|
||||||
return NextResponse.json<ApiError>({ error: 'Content not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
content = {
|
|
||||||
id: album.id,
|
|
||||||
title: album.title,
|
|
||||||
description: album.description || '',
|
|
||||||
coverArtUrl: album.coverUrl || '',
|
|
||||||
releaseDate: album.releaseDate?.toISOString() || '',
|
|
||||||
artist: { id: album.artist.id, stage_name: album.artist.name },
|
|
||||||
songs: album.songs.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title,
|
|
||||||
duration: s.duration,
|
|
||||||
cover_art_url: s.coverUrl || '',
|
|
||||||
play_count: s.playCount,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: ResolveShareResponse = {
|
|
||||||
type: share.type as ShareType,
|
|
||||||
targetId: share.targetId,
|
|
||||||
content,
|
|
||||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/s/${token}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Resolve share error:', error);
|
|
||||||
return NextResponse.json<ApiError>({ error: 'Failed to resolve share' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import { generateShareToken, buildShareUrl } from '@/lib/share';
|
|
||||||
import type { CreateShareResponse, ApiError } from '@/types/api-types';
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await request.json().catch(() => ({}));
|
|
||||||
|
|
||||||
// Verify album exists
|
|
||||||
const album = await prisma.album.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!album) {
|
|
||||||
return NextResponse.json<ApiError>(
|
|
||||||
{ error: 'Album not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique token
|
|
||||||
const token = generateShareToken();
|
|
||||||
|
|
||||||
// Create share record
|
|
||||||
await prisma.share.create({
|
|
||||||
data: {
|
|
||||||
type: 'ALBUM',
|
|
||||||
targetId: id,
|
|
||||||
token,
|
|
||||||
platform: body.platform || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: CreateShareResponse = {
|
|
||||||
shareUrl: buildShareUrl(token),
|
|
||||||
token,
|
|
||||||
type: 'ALBUM',
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Create album share error:', error);
|
|
||||||
return NextResponse.json<ApiError>(
|
|
||||||
{ error: 'Failed to create share link' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import { generateShareToken, buildShareUrl } from '@/lib/share';
|
|
||||||
import type { CreateShareResponse, ApiError } from '@/types/api-types';
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await request.json().catch(() => ({}));
|
|
||||||
|
|
||||||
// Verify playlist exists
|
|
||||||
const playlist = await prisma.playlist.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!playlist) {
|
|
||||||
return NextResponse.json<ApiError>(
|
|
||||||
{ error: 'Playlist not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique token
|
|
||||||
const token = generateShareToken();
|
|
||||||
|
|
||||||
// Create share record
|
|
||||||
await prisma.share.create({
|
|
||||||
data: {
|
|
||||||
type: 'PLAYLIST',
|
|
||||||
targetId: id,
|
|
||||||
token,
|
|
||||||
platform: body.platform || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: CreateShareResponse = {
|
|
||||||
shareUrl: buildShareUrl(token),
|
|
||||||
token,
|
|
||||||
type: 'PLAYLIST',
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Create playlist share error:', error);
|
|
||||||
return NextResponse.json<ApiError>(
|
|
||||||
{ error: 'Failed to create share link' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import { generateShareToken, buildShareUrl } from '@/lib/share';
|
|
||||||
import type { CreateShareResponse, ApiError } from '@/types/api-types';
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await request.json().catch(() => ({}));
|
|
||||||
|
|
||||||
// Verify song exists
|
|
||||||
const song = await prisma.song.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!song) {
|
|
||||||
return NextResponse.json<ApiError>(
|
|
||||||
{ error: 'Song not found' },
|
|
||||||
{ status: 404 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique token
|
|
||||||
const token = generateShareToken();
|
|
||||||
|
|
||||||
// Create share record
|
|
||||||
await prisma.share.create({
|
|
||||||
data: {
|
|
||||||
type: 'SONG',
|
|
||||||
targetId: id,
|
|
||||||
token,
|
|
||||||
platform: body.platform || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: CreateShareResponse = {
|
|
||||||
shareUrl: buildShareUrl(token),
|
|
||||||
token,
|
|
||||||
type: 'SONG',
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Create song share error:', error);
|
|
||||||
return NextResponse.json<ApiError>(
|
|
||||||
{ error: 'Failed to create share link' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { getUploadSession, markChunkUploaded, generatePresignedUrl } from '@/lib/upload'
|
||||||
|
import { writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ uploadId: string; chunkIndex: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const { uploadId, chunkIndex: chunkIndexStr } = await params
|
||||||
|
const chunkIndex = parseInt(chunkIndexStr)
|
||||||
|
|
||||||
|
if (isNaN(chunkIndex)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid chunk index' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get upload session
|
||||||
|
const uploadSession = await getUploadSession(uploadId, user.id)
|
||||||
|
if (!uploadSession) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Upload session not found or expired' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadSession.status === 'completed') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Upload already completed' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkIndex < 0 || chunkIndex >= uploadSession.totalChunks) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid chunk index' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the chunk data from the request
|
||||||
|
const chunkData = await request.arrayBuffer()
|
||||||
|
const expectedSize = Math.min(uploadSession.chunkSize, uploadSession.fileSize - chunkIndex * uploadSession.chunkSize)
|
||||||
|
|
||||||
|
if (chunkData.byteLength !== expectedSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid chunk size. Expected ${expectedSize}, got ${chunkData.byteLength}` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real implementation, you would:
|
||||||
|
// 1. Upload to S3/CloudStorage using the presigned URL
|
||||||
|
// 2. Or store temporarily on disk/cloud storage
|
||||||
|
// For now, we'll simulate by storing in a temporary directory
|
||||||
|
|
||||||
|
const tempDir = join(process.cwd(), 'temp', 'uploads', uploadId)
|
||||||
|
await mkdir(tempDir, { recursive: true })
|
||||||
|
|
||||||
|
const chunkPath = join(tempDir, `chunk-${chunkIndex}`)
|
||||||
|
await writeFile(chunkPath, Buffer.from(chunkData))
|
||||||
|
|
||||||
|
// Mark chunk as uploaded
|
||||||
|
const updatedSession = await markChunkUploaded(uploadId, chunkIndex)
|
||||||
|
|
||||||
|
// Generate presigned URL for next chunk if not complete
|
||||||
|
let nextPresignedUrl = null
|
||||||
|
if (updatedSession.status !== 'completed' && chunkIndex + 1 < uploadSession.totalChunks) {
|
||||||
|
const { url } = await generatePresignedUrl(
|
||||||
|
`chunk-${chunkIndex + 1}-${uploadSession.fileName}`,
|
||||||
|
uploadSession.mimeType
|
||||||
|
)
|
||||||
|
nextPresignedUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
chunkIndex,
|
||||||
|
uploadedChunks: updatedSession.uploadedChunks,
|
||||||
|
totalChunks: updatedSession.totalChunks,
|
||||||
|
status: updatedSession.status,
|
||||||
|
nextPresignedUrl,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chunk upload error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to upload chunk' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { getUploadSession, completeUploadSession } from '@/lib/upload'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { readFile, writeFile, mkdir, rm } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ uploadId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const { uploadId } = await params
|
||||||
|
|
||||||
|
// Get upload session
|
||||||
|
const uploadSession = await getUploadSession(uploadId, user.id)
|
||||||
|
if (!uploadSession) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Upload session not found or expired' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadSession.status !== 'completed') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Upload not complete. All chunks must be uploaded first.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine chunks into a single file
|
||||||
|
const tempDir = join(process.cwd(), 'temp', 'uploads', uploadId)
|
||||||
|
const finalPath = join(process.cwd(), 'uploads', `${uploadId}-${uploadSession.fileName}`)
|
||||||
|
|
||||||
|
// Ensure uploads directory exists
|
||||||
|
await mkdir(join(process.cwd(), 'uploads'), { recursive: true })
|
||||||
|
|
||||||
|
// Combine all chunks
|
||||||
|
const fileBuffer = Buffer.alloc(uploadSession.fileSize)
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < uploadSession.totalChunks; i++) {
|
||||||
|
const chunkPath = join(tempDir, `chunk-${i}`)
|
||||||
|
const chunkData = await readFile(chunkPath)
|
||||||
|
chunkData.copy(fileBuffer, offset)
|
||||||
|
offset += chunkData.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the complete file
|
||||||
|
await writeFile(finalPath, fileBuffer)
|
||||||
|
|
||||||
|
// Create a database record for the uploaded file
|
||||||
|
const fileId = `file_${Date.now()}_${uploadId}`
|
||||||
|
|
||||||
|
// In a real implementation, you would:
|
||||||
|
// 1. Upload to S3/CloudStorage
|
||||||
|
// 2. Store the URL/Key in the database
|
||||||
|
// 3. Process the file (transcode audio, generate thumbnails, etc.)
|
||||||
|
// For now, we'll create a simple record
|
||||||
|
|
||||||
|
let createdRecord = null
|
||||||
|
|
||||||
|
if (uploadSession.mimeType.startsWith('audio/')) {
|
||||||
|
// Create a song record
|
||||||
|
createdRecord = await prisma.song.create({
|
||||||
|
data: {
|
||||||
|
artistId: user.id, // Assuming user is an artist
|
||||||
|
title: uploadSession.fileName.replace(/\.[^/.]+$/, ''), // Remove extension
|
||||||
|
slug: uploadSession.fileName.replace(/\.[^/.]+$/, '').toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
audioUrl: `/uploads/${uploadId}-${uploadSession.fileName}`,
|
||||||
|
duration: 0, // Would be determined during processing
|
||||||
|
isPublic: false, // Default to private until processed
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (uploadSession.mimeType.startsWith('image/')) {
|
||||||
|
// Could create an image record or associate with artist/album
|
||||||
|
// For now, just store the file reference
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark upload session as complete with file ID
|
||||||
|
const completedSession = await completeUploadSession(uploadId, fileId)
|
||||||
|
|
||||||
|
// Clean up temporary chunks
|
||||||
|
await rm(tempDir, { recursive: true, force: true })
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
fileId,
|
||||||
|
fileName: uploadSession.fileName,
|
||||||
|
fileSize: uploadSession.fileSize,
|
||||||
|
mimeType: uploadSession.mimeType,
|
||||||
|
record: createdRecord,
|
||||||
|
uploadSession: completedSession,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload complete error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to complete upload' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { createUploadSession, generatePresignedUrl, validateFileType, validateFileSize } from '@/lib/upload'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const body = await request.json()
|
||||||
|
const { fileName, fileSize, mimeType, chunkSize, metadata } = body
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!fileName || !fileSize || !mimeType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: fileName, fileSize, mimeType' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 500MB)
|
||||||
|
const maxSize = 500 * 1024 * 1024 // 500MB
|
||||||
|
if (!validateFileSize(fileSize, maxSize)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File size exceeds maximum allowed size of ${maxSize / 1024 / 1024}MB` },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate allowed file types
|
||||||
|
const allowedTypes = [
|
||||||
|
'audio/*',
|
||||||
|
'image/*',
|
||||||
|
]
|
||||||
|
if (!validateFileType(mimeType, allowedTypes)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File type not allowed' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create upload session
|
||||||
|
const uploadSession = await createUploadSession(
|
||||||
|
user.id,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
mimeType,
|
||||||
|
chunkSize,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate first chunk presigned URL
|
||||||
|
const { url, key } = await generatePresignedUrl(
|
||||||
|
`chunk-0-${fileName}`,
|
||||||
|
mimeType
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
uploadSession,
|
||||||
|
presignedUrl: url,
|
||||||
|
uploadKey: key,
|
||||||
|
chunkSize: uploadSession.chunkSize,
|
||||||
|
totalChunks: uploadSession.totalChunks,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload init error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to initialize upload' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAuth } from '@/lib/auth'
|
||||||
|
import { generatePresignedUrl } from '@/lib/upload'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await requireAuth()
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
||||||
|
const fileName = searchParams.get('fileName')
|
||||||
|
const mimeType = searchParams.get('mimeType')
|
||||||
|
const expiresIn = parseInt(searchParams.get('expiresIn') || '3600')
|
||||||
|
|
||||||
|
if (!fileName || !mimeType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'fileName and mimeType are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate presigned URL
|
||||||
|
const { url, key } = await generatePresignedUrl(fileName, mimeType, expiresIn)
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
presignedUrl: url,
|
||||||
|
key,
|
||||||
|
expiresIn,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Generate presigned URL error:', error)
|
||||||
|
if (error instanceof Error && error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to generate presigned URL' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import type { Queue, PlayHistory } from '../types/api';
|
||||||
|
import { API_PATHS } from '../types/api';
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
currentSong?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
duration: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
queue?: Queue;
|
||||||
|
isPlaying?: boolean;
|
||||||
|
volume?: number;
|
||||||
|
currentTime?: number;
|
||||||
|
onPlay?: () => void;
|
||||||
|
onPause?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
onPrevious?: () => void;
|
||||||
|
onSeek?: (time: number) => void;
|
||||||
|
onVolumeChange?: (volume: number) => void;
|
||||||
|
onQueueUpdate?: (queue: Queue) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepeatMode = 'none' | 'one' | 'all';
|
||||||
|
|
||||||
|
export default function AudioPlayer({
|
||||||
|
currentSong,
|
||||||
|
queue,
|
||||||
|
isPlaying = false,
|
||||||
|
volume = 0.7,
|
||||||
|
currentTime = 0,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
onSeek,
|
||||||
|
onVolumeChange,
|
||||||
|
onQueueUpdate,
|
||||||
|
}: AudioPlayerProps) {
|
||||||
|
const [localVolume, setLocalVolume] = useState(volume);
|
||||||
|
const [localCurrentTime, setLocalCurrentTime] = useState(currentTime);
|
||||||
|
const [repeatMode, setRepeatMode] = useState<RepeatMode>('none');
|
||||||
|
const [isShuffled, setIsShuffled] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [showQueue, setShowQueue] = useState(false);
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sync with props
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalVolume(volume);
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalCurrentTime(currentTime);
|
||||||
|
}, [currentTime]);
|
||||||
|
|
||||||
|
// Format time helper
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// API calls with proper error handling
|
||||||
|
const handlePlay = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_PATHS.PLAYER_PLAY, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to play');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlay?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Play error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_PATHS.PLAYER_PAUSE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to pause');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPause?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Pause error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_PATHS.PLAYER_NEXT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to play next');
|
||||||
|
}
|
||||||
|
|
||||||
|
onNext?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Next error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_PATHS.PLAYER_PREVIOUS, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to play previous');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPrevious?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Previous error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = async (newVolume: number) => {
|
||||||
|
try {
|
||||||
|
setLocalVolume(newVolume);
|
||||||
|
setIsMuted(newVolume === 0);
|
||||||
|
onVolumeChange?.(newVolume);
|
||||||
|
|
||||||
|
// Volume change would typically be handled client-side
|
||||||
|
// But we could persist it to user preferences
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Volume change error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeek = (time: number) => {
|
||||||
|
setLocalCurrentTime(time);
|
||||||
|
onSeek?.(time);
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = time;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!progressBarRef.current || !currentSong) return;
|
||||||
|
|
||||||
|
const rect = progressBarRef.current.getBoundingClientRect();
|
||||||
|
const percent = (e.clientX - rect.left) / rect.width;
|
||||||
|
const newTime = percent * currentSong.duration;
|
||||||
|
|
||||||
|
handleSeek(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRepeat = () => {
|
||||||
|
const modes: RepeatMode[] = ['none', 'one', 'all'];
|
||||||
|
const currentIndex = modes.indexOf(repeatMode);
|
||||||
|
const nextMode = modes[(currentIndex + 1) % modes.length];
|
||||||
|
setRepeatMode(nextMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleShuffle = () => {
|
||||||
|
setIsShuffled(!isShuffled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (isMuted) {
|
||||||
|
handleVolumeChange(volume);
|
||||||
|
} else {
|
||||||
|
handleVolumeChange(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchQueue = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_PATHS.PLAYER_QUEUE);
|
||||||
|
if (response.ok) {
|
||||||
|
const queueData: Queue = await response.json();
|
||||||
|
onQueueUpdate?.(queueData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch queue:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Progress percentage
|
||||||
|
const progressPercent = currentSong
|
||||||
|
? (localCurrentTime / currentSong.duration) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 text-white p-4 rounded-lg shadow-xl">
|
||||||
|
{/* Audio element for actual playback */}
|
||||||
|
{currentSong && (
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={currentSong.url}
|
||||||
|
onTimeUpdate={(e) => setLocalCurrentTime(e.currentTarget.currentTime)}
|
||||||
|
onEnded={handleNext}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{/* Song Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold truncate">
|
||||||
|
{currentSong?.title || 'No song playing'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-400 truncate">
|
||||||
|
{currentSong?.artist || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowQueue(!showQueue);
|
||||||
|
if (!showQueue) fetchQueue();
|
||||||
|
}}
|
||||||
|
className="ml-4 p-2 hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
aria-label="Toggle queue"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div
|
||||||
|
ref={progressBarRef}
|
||||||
|
className="relative h-2 bg-gray-700 rounded-full cursor-pointer group"
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 h-full bg-blue-500 rounded-full transition-all"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow"
|
||||||
|
style={{ left: `${progressPercent}%`, transform: 'translate(-50%, -50%)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||||
|
<span>{formatTime(localCurrentTime)}</span>
|
||||||
|
<span>{currentSong ? formatTime(currentSong.duration) : '0:00'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-4 mb-4">
|
||||||
|
{/* Shuffle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleShuffle}
|
||||||
|
className={`p-2 rounded-full transition-colors ${
|
||||||
|
isShuffled ? 'bg-blue-600 text-white' : 'hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
aria-label="Toggle shuffle"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Previous */}
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
className="p-3 hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
aria-label="Previous song"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<button
|
||||||
|
onClick={isPlaying ? handlePause : handlePlay}
|
||||||
|
className="p-4 bg-blue-600 hover:bg-blue-700 rounded-full transition-colors"
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Next */}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="p-3 hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
aria-label="Next song"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Repeat */}
|
||||||
|
<button
|
||||||
|
onClick={toggleRepeat}
|
||||||
|
className={`p-2 rounded-full transition-colors ${
|
||||||
|
repeatMode !== 'none' ? 'bg-blue-600 text-white' : 'hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
aria-label={`Repeat mode: ${repeatMode}`}
|
||||||
|
>
|
||||||
|
{repeatMode === 'one' ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>
|
||||||
|
<circle cx="12" cy="12" r="1"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume Control */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="p-2 hover:bg-gray-800 rounded-full transition-colors"
|
||||||
|
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
{isMuted || localVolume === 0 ? (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={isMuted ? 0 : localVolume}
|
||||||
|
onChange={(e) => handleVolumeChange(parseFloat(e.target.value))}
|
||||||
|
className="flex-1 h-1 bg-gray-700 rounded-full appearance-none cursor-pointer"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, #3B82F6 0%, #3B82F6 ${isMuted ? 0 : localVolume * 100}%, #374151 ${isMuted ? 0 : localVolume * 100}%, #374151 100%)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="text-xs text-gray-400 w-8">
|
||||||
|
{Math.round(isMuted ? 0 : localVolume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue Modal */}
|
||||||
|
{showQueue && queue && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-gray-800 rounded-lg p-6 max-w-md w-full max-h-96 overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Queue</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQueue(false)}
|
||||||
|
className="p-2 hover:bg-gray-700 rounded-full"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue items would go here */}
|
||||||
|
<p className="text-gray-400 text-center py-8">
|
||||||
|
Queue functionality coming soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { API_PATHS } from '../types/api';
|
||||||
|
|
||||||
|
interface EmailVerificationProps {
|
||||||
|
email?: string;
|
||||||
|
onVerified?: () => void;
|
||||||
|
onVerificationSent?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmailVerification({
|
||||||
|
email,
|
||||||
|
onVerified,
|
||||||
|
onVerificationSent,
|
||||||
|
className = '',
|
||||||
|
}: EmailVerificationProps) {
|
||||||
|
const [isVerified, setIsVerified] = useState<boolean | null>(null);
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [isResending, setIsResending] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Check verification status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkVerificationStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle countdown for resend button
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown > 0) {
|
||||||
|
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
// Check email verification status
|
||||||
|
const checkVerificationStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/email-status', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// If endpoint doesn't exist, assume unverified
|
||||||
|
setIsVerified(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setIsVerified(data.isVerified);
|
||||||
|
} catch (err) {
|
||||||
|
// Assume unverified if we can't check
|
||||||
|
setIsVerified(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send verification email
|
||||||
|
const sendVerificationEmail = async (isResend = false) => {
|
||||||
|
try {
|
||||||
|
if (isResend) {
|
||||||
|
setIsResending(true);
|
||||||
|
} else {
|
||||||
|
setIsSending(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const response = await fetch(API_PATHS.AUTH_VERIFY_EMAIL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to send verification email');
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage('Verification email sent! Please check your inbox.');
|
||||||
|
onVerificationSent?.();
|
||||||
|
|
||||||
|
// Start countdown for resend
|
||||||
|
setCountdown(60);
|
||||||
|
|
||||||
|
if (!isResend) {
|
||||||
|
// Poll for verification status
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const statusResponse = await fetch('/api/auth/email-status', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusResponse.ok) {
|
||||||
|
const data = await statusResponse.json();
|
||||||
|
if (data.isVerified) {
|
||||||
|
setIsVerified(true);
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
onVerified?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Continue polling
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Stop polling after 5 minutes
|
||||||
|
setTimeout(() => clearInterval(pollInterval), 300000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to send verification email');
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
setIsResending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle manual verification (user enters code)
|
||||||
|
const handleManualVerification = async (code: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
const response = await fetch(API_PATHS.AUTH_CONFIRM_EMAIL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
code,
|
||||||
|
email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || 'Invalid verification code');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsVerified(true);
|
||||||
|
setMessage('Email verified successfully!');
|
||||||
|
onVerified?.();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Invalid verification code');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isVerified === null) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg shadow p-6 ${className}`}>
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<svg className="animate-spin h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verified state
|
||||||
|
if (isVerified) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-green-50 border border-green-200 rounded-lg p-6 ${className}`}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-6 h-6 text-green-600" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-green-900">Email verified</h3>
|
||||||
|
<p className="text-sm text-green-700 mt-1">
|
||||||
|
Your email address {email} has been verified successfully.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unverified state
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg shadow-lg ${className}`}>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="w-6 h-6 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900">Email verification required</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Please verify your email address {email ? `(${email})` : ''} to access all features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success message */}
|
||||||
|
{message && (
|
||||||
|
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<p className="text-sm text-green-600">{message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send verification button */}
|
||||||
|
{!isSending && (
|
||||||
|
<button
|
||||||
|
onClick={() => sendVerificationEmail(false)}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Send verification email
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isSending && (
|
||||||
|
<div className="w-full py-2 px-4 bg-blue-600 text-white font-medium rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Sending...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resend option */}
|
||||||
|
{countdown > 0 ? (
|
||||||
|
<p className="mt-4 text-center text-sm text-gray-500">
|
||||||
|
Resend available in {countdown} seconds
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => sendVerificationEmail(true)}
|
||||||
|
disabled={isResending}
|
||||||
|
className="mt-4 w-full py-2 px-4 text-blue-600 hover:text-blue-700 font-medium disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isResending ? 'Resending...' : 'Resend verification email'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual verification option */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<ManualVerificationForm onVerify={handleManualVerification} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h4 className="font-medium text-gray-900 mb-2">Didn't receive the email?</h4>
|
||||||
|
<ul className="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>• Check your spam or junk folder</li>
|
||||||
|
<li>• Make sure the email address is correct</li>
|
||||||
|
<li>• Wait a few minutes for delivery</li>
|
||||||
|
<li>• Try clicking the resend button above</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual verification form component
|
||||||
|
function ManualVerificationForm({ onVerify }: { onVerify: (code: string) => void }) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (code.trim()) {
|
||||||
|
setIsVerifying(true);
|
||||||
|
onVerify(code.trim());
|
||||||
|
setTimeout(() => setIsVerifying(false), 2000); // Reset after delay
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 mb-2">Or enter verification code manually:</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
placeholder="Enter 6-digit code"
|
||||||
|
maxLength={6}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-center tracking-widest text-lg"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!code.trim() || isVerifying}
|
||||||
|
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-md transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isVerifying ? 'Verifying...' : 'Verify'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { API_PATHS } from '../types/api';
|
||||||
|
|
||||||
|
interface SearchSuggestion {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
type: 'song' | 'album' | 'artist';
|
||||||
|
entity?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist?: string;
|
||||||
|
album?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
onSuggestionSelect?: (suggestion: SearchSuggestion) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
showSuggestions?: boolean;
|
||||||
|
debounceMs?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBar({
|
||||||
|
onSearch,
|
||||||
|
onSuggestionSelect,
|
||||||
|
placeholder = 'Search songs, artists, albums...',
|
||||||
|
autoFocus = false,
|
||||||
|
showSuggestions = true,
|
||||||
|
debounceMs = 300,
|
||||||
|
className = '',
|
||||||
|
}: SearchBarProps) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showSuggestionsList, setShowSuggestionsList] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Fetch search suggestions
|
||||||
|
const fetchSuggestions = async (searchQuery: string) => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel previous request
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_PATHS.SEARCH_SUGGESTIONS}?q=${encodeURIComponent(searchQuery)}&limit=8`,
|
||||||
|
{
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch suggestions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assuming the API returns an array of suggestions
|
||||||
|
// The actual structure would depend on the backend implementation
|
||||||
|
const data = await response.json();
|
||||||
|
const suggestionsData: SearchSuggestion[] = data.map((item: any) => ({
|
||||||
|
id: item.id || Math.random().toString(36),
|
||||||
|
text: item.title || item.name || item.text,
|
||||||
|
type: item.type || 'song',
|
||||||
|
entity: item,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSuggestions(suggestionsData);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name !== 'AbortError') {
|
||||||
|
console.error('Failed to fetch suggestions:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input change with debouncing
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setQuery(value);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the search
|
||||||
|
debounceTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (showSuggestions) {
|
||||||
|
fetchSuggestions(value);
|
||||||
|
}
|
||||||
|
onSearch?.(value);
|
||||||
|
}, debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowSuggestionsList(false);
|
||||||
|
onSearch?.(query);
|
||||||
|
|
||||||
|
// Trigger immediate search
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
onSearch?.(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle suggestion click
|
||||||
|
const handleSuggestionClick = (suggestion: SearchSuggestion) => {
|
||||||
|
setQuery(suggestion.text);
|
||||||
|
setShowSuggestionsList(false);
|
||||||
|
onSuggestionSelect?.(suggestion);
|
||||||
|
onSearch?.(suggestion.text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!showSuggestionsList || suggestions.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(prev => (prev + 1) % suggestions.length);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex(prev => (prev - 1 + suggestions.length) % suggestions.length);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0) {
|
||||||
|
handleSuggestionClick(suggestions[activeIndex]);
|
||||||
|
} else {
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
setShowSuggestionsList(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input focus
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (showSuggestions && query.trim()) {
|
||||||
|
setShowSuggestionsList(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input blur
|
||||||
|
const handleBlur = () => {
|
||||||
|
// Delay hiding suggestions to allow click events to fire
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowSuggestionsList(false);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
const handleClear = () => {
|
||||||
|
setQuery('');
|
||||||
|
setSuggestions([]);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
setShowSuggestionsList(false);
|
||||||
|
onSearch?.('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get suggestion icon
|
||||||
|
const getSuggestionIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'song':
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'album':
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case 'artist':
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<form onSubmit={handleSubmit} className="relative">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Search icon */}
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||||
|
{isLoading ? (
|
||||||
|
<svg className="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input field */}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clear button */}
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Suggestions dropdown */}
|
||||||
|
{showSuggestionsList && suggestions.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-96 overflow-y-auto">
|
||||||
|
{suggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.id}
|
||||||
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
|
onMouseEnter={() => setActiveIndex(index)}
|
||||||
|
className={`w-full px-4 py-3 text-left flex items-center gap-3 transition-colors ${
|
||||||
|
index === activeIndex
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-gray-400 flex-shrink-0">
|
||||||
|
{getSuggestionIcon(suggestion.type)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{suggestion.text}</div>
|
||||||
|
{suggestion.entity?.artist && (
|
||||||
|
<div className="text-sm text-gray-500 truncate">
|
||||||
|
{suggestion.entity.artist}
|
||||||
|
{suggestion.entity?.album && ` • ${suggestion.entity.album}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400 capitalize flex-shrink-0">
|
||||||
|
{suggestion.type}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Search for full query option */}
|
||||||
|
{query.trim() && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onSearch?.(query);
|
||||||
|
setShowSuggestionsList(false);
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 text-left flex items-center gap-3 bg-gray-50 hover:bg-gray-100 transition-colors border-t border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
Search for "<span className="font-medium">{query}</span>"
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,530 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import type { SearchIndex } from '../types/api';
|
||||||
|
import { API_PATHS } from '../types/api';
|
||||||
|
|
||||||
|
interface SearchResultItem {
|
||||||
|
id: string;
|
||||||
|
type: 'song' | 'album' | 'artist';
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
image?: string;
|
||||||
|
duration?: number;
|
||||||
|
year?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResultsProps {
|
||||||
|
query: string;
|
||||||
|
onResultClick?: (item: SearchResultItem) => void;
|
||||||
|
onPlaySong?: (songId: string) => void;
|
||||||
|
onAddToQueue?: (songId: string) => void;
|
||||||
|
onAddToPlaylist?: (songId: string) => void;
|
||||||
|
maxResults?: number;
|
||||||
|
showFilters?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchResults({
|
||||||
|
query,
|
||||||
|
onResultClick,
|
||||||
|
onPlaySong,
|
||||||
|
onAddToQueue,
|
||||||
|
onAddToPlaylist,
|
||||||
|
maxResults = 50,
|
||||||
|
showFilters = true,
|
||||||
|
className = '',
|
||||||
|
}: SearchResultsProps) {
|
||||||
|
const [results, setResults] = useState<{
|
||||||
|
songs: SearchResultItem[];
|
||||||
|
albums: SearchResultItem[];
|
||||||
|
artists: SearchResultItem[];
|
||||||
|
}>({
|
||||||
|
songs: [],
|
||||||
|
albums: [],
|
||||||
|
artists: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'all' | 'songs' | 'albums' | 'artists'>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<'relevance' | 'newest' | 'oldest' | 'name'>('relevance');
|
||||||
|
|
||||||
|
// Fetch search results
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults({ songs: [], albums: [], artists: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchResults = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_PATHS.SEARCH}?q=${encodeURIComponent(query)}&limit=${maxResults}&sort=${sortBy}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch search results');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Transform API response to our format
|
||||||
|
// This would depend on the actual API response structure
|
||||||
|
const transformedResults = {
|
||||||
|
songs: (data.songs || []).map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: 'song' as const,
|
||||||
|
title: item.title,
|
||||||
|
subtitle: item.artist,
|
||||||
|
image: item.coverArt,
|
||||||
|
duration: item.duration,
|
||||||
|
year: item.year,
|
||||||
|
metadata: item.metadata,
|
||||||
|
})),
|
||||||
|
albums: (data.albums || []).map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: 'album' as const,
|
||||||
|
title: item.title,
|
||||||
|
subtitle: item.artist,
|
||||||
|
image: item.coverArt,
|
||||||
|
year: item.year,
|
||||||
|
metadata: item.metadata,
|
||||||
|
})),
|
||||||
|
artists: (data.artists || []).map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
type: 'artist' as const,
|
||||||
|
title: item.name,
|
||||||
|
subtitle: `${item.albumCount || 0} albums`,
|
||||||
|
image: item.image,
|
||||||
|
metadata: item.metadata,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
setResults(transformedResults);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchResults();
|
||||||
|
}, [query, maxResults, sortBy]);
|
||||||
|
|
||||||
|
// Get filtered results based on active tab
|
||||||
|
const getFilteredResults = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'songs':
|
||||||
|
return results.songs;
|
||||||
|
case 'albums':
|
||||||
|
return results.albums;
|
||||||
|
case 'artists':
|
||||||
|
return results.artists;
|
||||||
|
default:
|
||||||
|
return [...results.songs, ...results.albums, ...results.artists];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format duration helper
|
||||||
|
const formatDuration = (seconds?: number): string => {
|
||||||
|
if (!seconds) return '';
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle result click
|
||||||
|
const handleResultClick = (item: SearchResultItem) => {
|
||||||
|
onResultClick?.(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle play song
|
||||||
|
const handlePlaySong = (e: React.MouseEvent, songId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPlaySong?.(songId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle add to queue
|
||||||
|
const handleAddToQueue = (e: React.MouseEvent, songId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddToQueue?.(songId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle add to playlist
|
||||||
|
const handleAddToPlaylist = (e: React.MouseEvent, songId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddToPlaylist?.(songId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render song item
|
||||||
|
const renderSongItem = (item: SearchResultItem) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleResultClick(item)}
|
||||||
|
className="group flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
{/* Album art */}
|
||||||
|
<div className="relative w-12 h-12 bg-gray-200 rounded-md overflow-hidden flex-shrink-0">
|
||||||
|
{item.image ? (
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||||
|
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Play button overlay */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => handlePlaySong(e, item.id)}
|
||||||
|
className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 flex items-center justify-center transition-all opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Song info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-gray-900 truncate">{item.title}</h4>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{item.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div className="text-sm text-gray-400 flex-shrink-0">
|
||||||
|
{formatDuration(item.duration)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleAddToQueue(e, item.id)}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-full transition-colors"
|
||||||
|
title="Add to queue"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleAddToPlaylist(e, item.id)}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-full transition-colors"
|
||||||
|
title="Add to playlist"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M4 10h12v2H4zm0-4h12v2H4zm0 8h8v2H4zm10 0v6l5-3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render album item
|
||||||
|
const renderAlbumItem = (item: SearchResultItem) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleResultClick(item)}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-square bg-gray-200 rounded-lg overflow-hidden mb-2">
|
||||||
|
{item.image ? (
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||||
|
<svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Play button overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 flex items-center justify-center transition-all">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Play first song from album
|
||||||
|
onPlaySong?.(item.id);
|
||||||
|
}}
|
||||||
|
className="w-12 h-12 bg-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transform scale-90 group-hover:scale-100 transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6 text-blue-600 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-medium text-gray-900 truncate">{item.title}</h4>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{item.subtitle}</p>
|
||||||
|
{item.year && (
|
||||||
|
<p className="text-xs text-gray-400">{item.year}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render artist item
|
||||||
|
const renderArtistItem = (item: SearchResultItem) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleResultClick(item)}
|
||||||
|
className="group cursor-pointer text-center"
|
||||||
|
>
|
||||||
|
<div className="relative w-24 h-24 mx-auto mb-2">
|
||||||
|
<div className="w-full h-full bg-gray-200 rounded-full overflow-full">
|
||||||
|
{item.image ? (
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||||
|
<svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="font-medium text-gray-900 truncate">{item.title}</h4>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{item.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredResults = getFilteredResults();
|
||||||
|
const allResultsCount = results.songs.length + results.albums.length + results.artists.length;
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return (
|
||||||
|
<div className={`text-center py-12 ${className}`}>
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Search for music</h3>
|
||||||
|
<p className="text-gray-500">Find your favorite songs, artists, and albums</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{/* Filters and controls */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="mb-6">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-4 mb-4 border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`pb-3 px-1 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All ({allResultsCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('songs')}
|
||||||
|
className={`pb-3 px-1 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'songs'
|
||||||
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Songs ({results.songs.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('albums')}
|
||||||
|
className={`pb-3 px-1 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'albums'
|
||||||
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Albums ({results.albums.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('artists')}
|
||||||
|
className={`pb-3 px-1 font-medium text-sm transition-colors ${
|
||||||
|
activeTab === 'artists'
|
||||||
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Artists ({results.artists.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{isLoading
|
||||||
|
? 'Searching...'
|
||||||
|
: `Found ${filteredResults.length} result${filteredResults.length !== 1 ? 's' : ''}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
|
className="px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="relevance">Most Relevant</option>
|
||||||
|
<option value="newest">Newest</option>
|
||||||
|
<option value="oldest">Oldest</option>
|
||||||
|
<option value="name">Name (A-Z)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<svg className="animate-spin h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-red-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Error loading results</h3>
|
||||||
|
<p className="text-gray-500">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{!isLoading && !error && filteredResults.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M12 12h-.01M12 12h-.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No results found</h3>
|
||||||
|
<p className="text-gray-500">Try adjusting your search or filters</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && filteredResults.length > 0 && (
|
||||||
|
<div className={activeTab === 'all' ? 'space-y-6' : ''}>
|
||||||
|
{activeTab === 'all' && (
|
||||||
|
<>
|
||||||
|
{/* Songs section */}
|
||||||
|
{results.songs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Songs</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{results.songs.slice(0, 5).map(renderSongItem)}
|
||||||
|
{results.songs.length > 5 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('songs')}
|
||||||
|
className="w-full py-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
Show all {results.songs.length} songs →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Albums section */}
|
||||||
|
{results.albums.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Albums</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{results.albums.slice(0, 5).map(renderAlbumItem)}
|
||||||
|
{results.albums.length > 5 && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('albums')}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
+{results.albums.length - 5} more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Artists section */}
|
||||||
|
{results.artists.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Artists</h3>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
|
||||||
|
{results.artists.slice(0, 6).map(renderArtistItem)}
|
||||||
|
{results.artists.length > 6 && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('artists')}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
+{results.artists.length - 6} more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'songs' && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{results.songs.map(renderSongItem)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'albums' && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
{results.albums.map(renderAlbumItem)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'artists' && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
|
||||||
|
{results.artists.map(renderArtistItem)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import type { Session } from '../types/api';
|
||||||
|
import { API_PATHS } from '../types/api';
|
||||||
|
|
||||||
|
interface SessionManagerProps {
|
||||||
|
onSessionRevoke?: (sessionId: string) => void;
|
||||||
|
onRefreshSessions?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionManager({
|
||||||
|
onSessionRevoke,
|
||||||
|
onRefreshSessions,
|
||||||
|
className = '',
|
||||||
|
}: SessionManagerProps) {
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch sessions
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(API_PATHS.AUTH_SESSIONS, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setSessions(data.sessions || []);
|
||||||
|
setCurrentSessionId(data.currentSessionId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Revoke session
|
||||||
|
const revokeSession = async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const revokePath = API_PATHS.AUTH_REVOKE_SESSION.replace(':id', sessionId);
|
||||||
|
|
||||||
|
const response = await fetch(revokePath, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to revoke session');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove session from list
|
||||||
|
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
||||||
|
onSessionRevoke?.(sessionId);
|
||||||
|
|
||||||
|
// If we revoked the current session, we should log out
|
||||||
|
if (sessionId === currentSessionId) {
|
||||||
|
// Trigger logout
|
||||||
|
window.location.href = '/login?reason=session_revoked';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to revoke session');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format device info
|
||||||
|
const formatDeviceInfo = (deviceInfo?: Record<string, unknown>): string => {
|
||||||
|
if (!deviceInfo) return 'Unknown Device';
|
||||||
|
|
||||||
|
const browser = deviceInfo.browser as string;
|
||||||
|
const os = deviceInfo.os as string;
|
||||||
|
const platform = deviceInfo.platform as string;
|
||||||
|
|
||||||
|
if (browser && os) {
|
||||||
|
return `${browser} on ${os}`;
|
||||||
|
} else if (platform) {
|
||||||
|
return platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown Device';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format last activity
|
||||||
|
const formatLastActivity = (lastActivity?: Date): string => {
|
||||||
|
if (!lastActivity) return 'Never';
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - new Date(lastActivity).getTime();
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||||
|
} else {
|
||||||
|
return 'Just now';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get device icon
|
||||||
|
const getDeviceIcon = (userAgent?: string): React.ReactNode => {
|
||||||
|
const ua = userAgent?.toLowerCase() || '';
|
||||||
|
|
||||||
|
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M15.5 1h-8A2.5 2.5 0 005 3.5v17A2.5 2.5 0 007.5 23h8a2.5 2.5 0 002.5-2.5v-17A2.5 2.5 0 0015.5 1zm-4 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
} else if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 1H5c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm-5 20H10v-1h4v1zm3-3H7V4h10v14z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7l-2 3v1h8v-1l-2-3h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 12H3V4h18v10z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get location from IP (placeholder)
|
||||||
|
const getLocation = (ipAddress?: string): string => {
|
||||||
|
if (!ipAddress) return 'Unknown Location';
|
||||||
|
return ipAddress; // In production, you'd use a geolocation service
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh sessions
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchSessions();
|
||||||
|
onRefreshSessions?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load sessions on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg shadow-lg ${className}`}>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">Active Sessions</h2>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-red-600 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && sessions.length === 0 && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<svg className="animate-spin h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sessions list */}
|
||||||
|
{!isLoading && sessions.length === 0 && !error && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No active sessions</h3>
|
||||||
|
<p className="text-gray-500">Sign in to see your active sessions here</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sessions.map(session => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`p-4 border rounded-lg ${
|
||||||
|
session.id === currentSessionId
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-3 flex-1">
|
||||||
|
{/* Device icon */}
|
||||||
|
<div className={`p-2 rounded-lg ${
|
||||||
|
session.id === currentSessionId
|
||||||
|
? 'bg-blue-100 text-blue-600'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{getDeviceIcon(session.userAgent)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-medium text-gray-900 truncate">
|
||||||
|
{formatDeviceInfo(session.deviceInfo)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Current session badge */}
|
||||||
|
{session.id === currentSessionId && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full">
|
||||||
|
Current session
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm text-gray-500">
|
||||||
|
{session.ipAddress && (
|
||||||
|
<p>IP: {getLocation(session.ipAddress)}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Last active: {formatLastActivity(session.lastActivity)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Signed in: {new Date(session.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revoke button */}
|
||||||
|
{session.id !== currentSessionId && (
|
||||||
|
<button
|
||||||
|
onClick={() => revokeSession(session.id)}
|
||||||
|
className="ml-4 px-3 py-1 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info text */}
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h4 className="font-medium text-gray-900 mb-2">About sessions</h4>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
These are the devices where your account is currently signed in. Revoking a session will sign you out of that device.
|
||||||
|
Be careful not to revoke your current session unless you want to sign out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,315 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState, useRef, ReactNode } from 'react';
|
||||||
|
import type { RefreshToken } from '../types/api';
|
||||||
|
import { API_PATHS } from '../types/api';
|
||||||
|
|
||||||
|
interface TokenContextType {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
refreshError: string | null;
|
||||||
|
forceRefresh: () => Promise<void>;
|
||||||
|
clearTokens: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TokenContext = createContext<TokenContextType | null>(null);
|
||||||
|
|
||||||
|
interface TokenRefreshManagerProps {
|
||||||
|
children: ReactNode;
|
||||||
|
autoRefresh?: boolean;
|
||||||
|
refreshThreshold?: number; // seconds before expiration to refresh
|
||||||
|
onTokenExpired?: () => void;
|
||||||
|
onRefreshFailed?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenRefreshManager({
|
||||||
|
children,
|
||||||
|
autoRefresh = true,
|
||||||
|
refreshThreshold = 300, // 5 minutes
|
||||||
|
onTokenExpired,
|
||||||
|
onRefreshFailed,
|
||||||
|
}: TokenRefreshManagerProps) {
|
||||||
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
|
const [refreshToken, setRefreshToken] = useState<string | null>(null);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||||
|
const [tokenExpiration, setTokenExpiration] = useState<Date | null>(null);
|
||||||
|
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Load tokens from storage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTokens = () => {
|
||||||
|
try {
|
||||||
|
const storedAccess = localStorage.getItem('access_token');
|
||||||
|
const storedRefresh = localStorage.getItem('refresh_token');
|
||||||
|
const storedExpiration = localStorage.getItem('token_expiration');
|
||||||
|
|
||||||
|
if (storedAccess) {
|
||||||
|
setAccessToken(storedAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedRefresh) {
|
||||||
|
setRefreshToken(storedRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedExpiration) {
|
||||||
|
setTokenExpiration(new Date(storedExpiration));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tokens from storage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTokens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save tokens to storage
|
||||||
|
const saveTokens = (access: string, refresh: string, expiration: Date) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('access_token', access);
|
||||||
|
localStorage.setItem('refresh_token', refresh);
|
||||||
|
localStorage.setItem('token_expiration', expiration.toISOString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save tokens to storage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear tokens from storage
|
||||||
|
const clearTokensFromStorage = () => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
localStorage.removeItem('token_expiration');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear tokens from storage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear tokens (logout)
|
||||||
|
const clearTokens = () => {
|
||||||
|
setAccessToken(null);
|
||||||
|
setRefreshToken(null);
|
||||||
|
setTokenExpiration(null);
|
||||||
|
setRefreshError(null);
|
||||||
|
clearTokensFromStorage();
|
||||||
|
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh access token
|
||||||
|
const refreshAccessToken = async (): Promise<boolean> => {
|
||||||
|
if (!refreshToken) {
|
||||||
|
setRefreshError('No refresh token available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setRefreshError(null);
|
||||||
|
|
||||||
|
const response = await fetch(API_PATHS.AUTH_REFRESH, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
refreshToken,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Refresh token is invalid/expired
|
||||||
|
clearTokens();
|
||||||
|
onTokenExpired?.();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new Error('Token refresh failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update tokens
|
||||||
|
setAccessToken(data.accessToken);
|
||||||
|
setRefreshToken(data.refreshToken || refreshToken);
|
||||||
|
|
||||||
|
const expiration = data.expiresAt
|
||||||
|
? new Date(data.expiresAt)
|
||||||
|
: new Date(Date.now() + 3600 * 1000); // Default 1 hour
|
||||||
|
|
||||||
|
setTokenExpiration(expiration);
|
||||||
|
saveTokens(data.accessToken, data.refreshToken || refreshToken, expiration);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setRefreshError(errorMessage);
|
||||||
|
onRefreshFailed?.(errorMessage);
|
||||||
|
|
||||||
|
// Don't clear tokens on network error, but clear on auth error
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Force refresh
|
||||||
|
const forceRefresh = async () => {
|
||||||
|
await refreshAccessToken();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up auto-refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh || !tokenExpiration || !refreshToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleRefresh = () => {
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timeUntilExpiration = tokenExpiration.getTime() - now.getTime();
|
||||||
|
const refreshTime = timeUntilExpiration - refreshThreshold * 1000;
|
||||||
|
|
||||||
|
if (refreshTime <= 0) {
|
||||||
|
// Token is already expired or will expire soon
|
||||||
|
refreshAccessToken();
|
||||||
|
} else {
|
||||||
|
// Schedule refresh
|
||||||
|
refreshTimeoutRef.current = setTimeout(() => {
|
||||||
|
refreshAccessToken();
|
||||||
|
}, refreshTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleRefresh();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [autoRefresh, tokenExpiration, refreshToken, refreshThreshold]);
|
||||||
|
|
||||||
|
// Add token to API requests
|
||||||
|
useEffect(() => {
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
let requestInit = init || {};
|
||||||
|
|
||||||
|
// Add auth header if we have a token
|
||||||
|
if (accessToken && !requestInit.headers) {
|
||||||
|
requestInit.headers = {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
};
|
||||||
|
} else if (accessToken && requestInit.headers) {
|
||||||
|
(requestInit.headers as Record<string, string>)['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await originalFetch(input, requestInit);
|
||||||
|
|
||||||
|
// Handle 401 responses (token expired)
|
||||||
|
if (response.status === 401 && accessToken) {
|
||||||
|
// Try to refresh the token
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
|
||||||
|
if (refreshed) {
|
||||||
|
// Retry the request with new token
|
||||||
|
const retryInit = {
|
||||||
|
...requestInit,
|
||||||
|
headers: {
|
||||||
|
...(requestInit.headers as Record<string, string>),
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return originalFetch(input, retryInit);
|
||||||
|
} else {
|
||||||
|
// Refresh failed, redirect to login
|
||||||
|
clearTokens();
|
||||||
|
onTokenExpired?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle network errors
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.fetch = originalFetch;
|
||||||
|
};
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
const contextValue: TokenContextType = {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
isRefreshing,
|
||||||
|
refreshError,
|
||||||
|
forceRefresh,
|
||||||
|
clearTokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TokenContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</TokenContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook to use token context
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(TokenContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within a TokenRefreshManager');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component to show refresh status (for debugging)
|
||||||
|
export function TokenStatus() {
|
||||||
|
const { accessToken, isRefreshing, refreshError, forceRefresh } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
|
||||||
|
<h3 className="font-semibold mb-2">Auth Status</h3>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Token:</span>{' '}
|
||||||
|
<span className={accessToken ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{accessToken ? 'Present' : 'Missing'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">Status:</span>{' '}
|
||||||
|
{isRefreshing ? 'Refreshing...' : 'Idle'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{refreshError && (
|
||||||
|
<p className="text-red-600">Error: {refreshError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={forceRefresh}
|
||||||
|
className="mt-2 px-3 py-1 bg-blue-100 hover:bg-blue-200 text-blue-700 text-sm rounded transition-colors"
|
||||||
|
>
|
||||||
|
Force Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,507 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
|
import type { UploadSession } from '../types/api';
|
||||||
|
import { API_PATHS } from '../types/api';
|
||||||
|
|
||||||
|
interface UploadFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
progress: number;
|
||||||
|
status: 'pending' | 'uploading' | 'paused' | 'completed' | 'error';
|
||||||
|
uploadSession?: UploadSession;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadManagerProps {
|
||||||
|
onUploadComplete?: (fileId: string, file: File) => void;
|
||||||
|
onUploadError?: (error: string, file: File) => void;
|
||||||
|
maxFileSize?: number; // in bytes
|
||||||
|
allowedTypes?: string[];
|
||||||
|
maxConcurrentUploads?: number;
|
||||||
|
chunkSize?: number; // in bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadManager({
|
||||||
|
onUploadComplete,
|
||||||
|
onUploadError,
|
||||||
|
maxFileSize = 100 * 1024 * 1024, // 100MB default
|
||||||
|
allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/ogg'],
|
||||||
|
maxConcurrentUploads = 3,
|
||||||
|
chunkSize = 1024 * 1024, // 1MB chunks
|
||||||
|
}: UploadManagerProps) {
|
||||||
|
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const activeUploads = useRef<Map<string, AbortController>>(new Map());
|
||||||
|
|
||||||
|
// Validate file
|
||||||
|
const validateFile = (file: File): string | null => {
|
||||||
|
if (file.size > maxFileSize) {
|
||||||
|
return `File size exceeds ${Math.round(maxFileSize / (1024 * 1024))}MB limit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
|
||||||
|
return `File type ${file.type} is not supported`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
const handleFileSelect = (selectedFiles: FileList | null) => {
|
||||||
|
if (!selectedFiles) return;
|
||||||
|
|
||||||
|
const newFiles: UploadFile[] = [];
|
||||||
|
|
||||||
|
Array.from(selectedFiles).forEach(file => {
|
||||||
|
const error = validateFile(file);
|
||||||
|
const uploadFile: UploadFile = {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
file,
|
||||||
|
progress: 0,
|
||||||
|
status: error ? 'error' : 'pending',
|
||||||
|
error: error || undefined,
|
||||||
|
};
|
||||||
|
newFiles.push(uploadFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
setFiles(prev => [...prev, ...newFiles]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize upload session
|
||||||
|
const initializeUpload = async (uploadFile: UploadFile): Promise<UploadSession | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_PATHS.UPLOAD_INIT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileName: uploadFile.file.name,
|
||||||
|
fileSize: uploadFile.file.size,
|
||||||
|
mimeType: uploadFile.file.type,
|
||||||
|
chunkSize,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to initialize upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session: UploadSession = await response.json();
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload initialization error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload a single chunk
|
||||||
|
const uploadChunk = async (
|
||||||
|
uploadFile: UploadFile,
|
||||||
|
chunk: Blob,
|
||||||
|
chunkIndex: number,
|
||||||
|
uploadSession: UploadSession
|
||||||
|
): Promise<void> => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
activeUploads.current.set(uploadFile.id, controller);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('chunk', chunk);
|
||||||
|
formData.append('chunkIndex', chunkIndex.toString());
|
||||||
|
formData.append('uploadId', uploadSession.id);
|
||||||
|
|
||||||
|
const chunkPath = API_PATHS.UPLOAD_CHUNK
|
||||||
|
.replace(':uploadId', uploadSession.id)
|
||||||
|
.replace(':chunkIndex', chunkIndex.toString());
|
||||||
|
|
||||||
|
const response = await fetch(chunkPath, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to upload chunk ${chunkIndex}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeUploads.current.delete(uploadFile.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Complete upload
|
||||||
|
const completeUpload = async (uploadSession: UploadSession): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
API_PATHS.UPLOAD_COMPLETE.replace(':uploadId', uploadSession.id),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
uploadId: uploadSession.id,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to complete upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.fileId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload completion error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload file with chunks
|
||||||
|
const processFileUpload = async (uploadFile: UploadFile) => {
|
||||||
|
if (!uploadFile.uploadSession) return;
|
||||||
|
|
||||||
|
const { file, uploadSession } = uploadFile;
|
||||||
|
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||||
|
let uploadedChunks = 0;
|
||||||
|
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(f =>
|
||||||
|
f.id === uploadFile.id
|
||||||
|
? { ...f, status: 'uploading', progress: 0 }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upload chunks
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
// Check if paused
|
||||||
|
if (isPaused) {
|
||||||
|
await new Promise(resolve => {
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (!isPaused) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = i * chunkSize;
|
||||||
|
const end = Math.min(start + chunkSize, file.size);
|
||||||
|
const chunk = file.slice(start, end);
|
||||||
|
|
||||||
|
await uploadChunk(uploadFile, chunk, i, uploadSession);
|
||||||
|
uploadedChunks++;
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
const progress = (uploadedChunks / totalChunks) * 100;
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(f =>
|
||||||
|
f.id === uploadFile.id
|
||||||
|
? { ...f, progress }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete upload
|
||||||
|
const fileId = await completeUpload(uploadSession);
|
||||||
|
|
||||||
|
if (fileId) {
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(f =>
|
||||||
|
f.id === uploadFile.id
|
||||||
|
? { ...f, status: 'completed', progress: 100 }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
onUploadComplete?.(fileId, file);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to complete upload');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||||
|
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(f =>
|
||||||
|
f.id === uploadFile.id
|
||||||
|
? { ...f, status: 'error', error: errorMessage }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
onUploadError?.(errorMessage, file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start upload process
|
||||||
|
const initializeAndStartUpload = async (uploadFile: UploadFile) => {
|
||||||
|
const session = await initializeUpload(uploadFile);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
const updatedFile = { ...uploadFile, uploadSession: session };
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(f => f.id === uploadFile.id ? updatedFile : f)
|
||||||
|
);
|
||||||
|
await processFileUpload(updatedFile);
|
||||||
|
} else {
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(f =>
|
||||||
|
f.id === uploadFile.id
|
||||||
|
? { ...f, status: 'error', error: 'Failed to initialize upload' }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process upload queue
|
||||||
|
const processQueue = useCallback(() => {
|
||||||
|
const pendingFiles = files.filter(f => f.status === 'pending');
|
||||||
|
const activeUploadsCount = files.filter(f => f.status === 'uploading').length;
|
||||||
|
|
||||||
|
if (isPaused || activeUploadsCount >= maxConcurrentUploads) return;
|
||||||
|
|
||||||
|
const slotsAvailable = maxConcurrentUploads - activeUploadsCount;
|
||||||
|
const filesToUpload = pendingFiles.slice(0, slotsAvailable);
|
||||||
|
|
||||||
|
filesToUpload.forEach(file => {
|
||||||
|
initializeAndStartUpload(file);
|
||||||
|
});
|
||||||
|
}, [files, isPaused, maxConcurrentUploads]);
|
||||||
|
|
||||||
|
// Auto-process queue when files change
|
||||||
|
React.useEffect(() => {
|
||||||
|
processQueue();
|
||||||
|
}, [files, processQueue]);
|
||||||
|
|
||||||
|
// Drag and drop handlers
|
||||||
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
handleFileSelect(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pause/resume all uploads
|
||||||
|
const togglePause = () => {
|
||||||
|
setIsPaused(!isPaused);
|
||||||
|
|
||||||
|
if (!isPaused) {
|
||||||
|
// Cancel all active uploads
|
||||||
|
activeUploads.current.forEach(controller => {
|
||||||
|
controller.abort();
|
||||||
|
});
|
||||||
|
activeUploads.current.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove file from list
|
||||||
|
const removeFile = (id: string) => {
|
||||||
|
// Cancel if uploading
|
||||||
|
const controller = activeUploads.current.get(id);
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
activeUploads.current.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles(prev => prev.filter(f => f.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retry failed upload
|
||||||
|
const retryUpload = (uploadFile: UploadFile) => {
|
||||||
|
setFiles(prev =>
|
||||||
|
prev.map(f =>
|
||||||
|
f.id === uploadFile.id
|
||||||
|
? { ...f, status: 'pending', progress: 0, error: undefined }
|
||||||
|
: f
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear completed uploads
|
||||||
|
const clearCompleted = () => {
|
||||||
|
setFiles(prev => prev.filter(f => f.status !== 'completed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Upload Manager</h2>
|
||||||
|
|
||||||
|
{/* Upload area */}
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||||
|
isDragging
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<p className="text-gray-600 mb-2">
|
||||||
|
Drag and drop audio files here, or{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
browse
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Supported formats: MP3, WAV, FLAC, OGG (max {Math.round(maxFileSize / (1024 * 1024))}MB)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={allowedTypes.join(',')}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => handleFileSelect(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="font-medium">
|
||||||
|
Uploads ({files.length} file{files.length !== 1 ? 's' : ''})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{files.some(f => f.status === 'uploading') && (
|
||||||
|
<button
|
||||||
|
onClick={togglePause}
|
||||||
|
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{isPaused ? 'Resume' : 'Pause'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.some(f => f.status === 'completed') && (
|
||||||
|
<button
|
||||||
|
onClick={clearCompleted}
|
||||||
|
className="px-3 py-1 text-sm bg-gray-200 hover:bg-gray-300 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Clear Completed
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{files.map(uploadFile => (
|
||||||
|
<div
|
||||||
|
key={uploadFile.id}
|
||||||
|
className="border rounded-lg p-3 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{uploadFile.file.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{(uploadFile.file.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{uploadFile.status === 'error' && (
|
||||||
|
<button
|
||||||
|
onClick={() => retryUpload(uploadFile)}
|
||||||
|
className="p-1 text-blue-600 hover:text-blue-700"
|
||||||
|
title="Retry"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadFile.status !== 'uploading' && (
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(uploadFile.id)}
|
||||||
|
className="p-1 text-red-600 hover:text-red-700"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status and progress */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full transition-all ${
|
||||||
|
uploadFile.status === 'completed'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: uploadFile.status === 'error'
|
||||||
|
? 'bg-red-500'
|
||||||
|
: 'bg-blue-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${uploadFile.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm text-gray-600 min-w-0">
|
||||||
|
{uploadFile.status === 'pending' && 'Pending'}
|
||||||
|
{uploadFile.status === 'uploading' && `${Math.round(uploadFile.progress)}%`}
|
||||||
|
{uploadFile.status === 'paused' && 'Paused'}
|
||||||
|
{uploadFile.status === 'completed' && 'Completed'}
|
||||||
|
{uploadFile.status === 'error' && 'Error'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{uploadFile.error && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">{uploadFile.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import { SharedContentDisplay } from '@/components/SharedContentDisplay';
|
|
||||||
import type { ResolveShareResponse } from '@/types/api-types';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ token: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getShareData(token: string): Promise<ResolveShareResponse | null> {
|
|
||||||
try {
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
|
||||||
const res = await fetch(`${baseUrl}/api/share/${token}`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) return null;
|
|
||||||
return res.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
||||||
const { token } = await params;
|
|
||||||
const data = await getShareData(token);
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return { title: 'Content Not Found | Sonic Cloud' };
|
|
||||||
}
|
|
||||||
|
|
||||||
let title = '';
|
|
||||||
let description = '';
|
|
||||||
let image = '';
|
|
||||||
|
|
||||||
if (data.type === 'SONG') {
|
|
||||||
const song = data.content as any;
|
|
||||||
title = `${song.title} by ${song.artist.stage_name}`;
|
|
||||||
description = `Listen to ${song.title} on Sonic Cloud`;
|
|
||||||
image = song.coverArtUrl;
|
|
||||||
} else if (data.type === 'PLAYLIST') {
|
|
||||||
const playlist = data.content as any;
|
|
||||||
title = playlist.name;
|
|
||||||
description = `${playlist.songCount} songs curated by ${playlist.curator.name}`;
|
|
||||||
image = playlist.coverImageUrl;
|
|
||||||
} else {
|
|
||||||
const album = data.content as any;
|
|
||||||
title = `${album.title} by ${album.artist.stage_name}`;
|
|
||||||
description = album.description || `Album by ${album.artist.stage_name}`;
|
|
||||||
image = album.coverArtUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${title} | Sonic Cloud`,
|
|
||||||
description,
|
|
||||||
openGraph: {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
images: image ? [image] : [],
|
|
||||||
type: 'music.song',
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: 'summary_large_image',
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
images: image ? [image] : [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SharePage({ params }: PageProps) {
|
|
||||||
const { token } = await params;
|
|
||||||
const data = await getShareData(token);
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-6">
|
|
||||||
<SharedContentDisplay
|
|
||||||
type={data.type}
|
|
||||||
content={data.content}
|
|
||||||
token={token}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
// AUTO-GENERATED - DO NOT EDIT
|
||||||
|
// Source: .workflow/versions/vXXX/contracts/api_contract.yml
|
||||||
|
// Generated: 2025-12-20T22:10:36.433721
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared API Types
|
||||||
|
// Both frontend and backend MUST import from this file
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// === Error Types ===
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
message?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
error: string;
|
||||||
|
details: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Domain Types ===
|
||||||
|
|
||||||
|
export interface RefreshToken {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string;
|
||||||
|
/** Refresh token value */
|
||||||
|
token: string;
|
||||||
|
/** User who owns this token */
|
||||||
|
userId: string;
|
||||||
|
/** Token expiration time */
|
||||||
|
expiresAt: Date;
|
||||||
|
/** When token was created */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Whether token has been revoked */
|
||||||
|
isRevoked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
/** Unique session identifier */
|
||||||
|
id: string;
|
||||||
|
/** User who owns this session */
|
||||||
|
userId: string;
|
||||||
|
/** Session token */
|
||||||
|
token: string;
|
||||||
|
/** Device information (browser, OS) */
|
||||||
|
deviceInfo?: Record<string, unknown>;
|
||||||
|
/** Client IP address */
|
||||||
|
ipAddress?: string;
|
||||||
|
/** Browser user agent */
|
||||||
|
userAgent?: string;
|
||||||
|
/** Last activity timestamp */
|
||||||
|
lastActivity?: Date;
|
||||||
|
/** When session was created */
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayHistory {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string;
|
||||||
|
/** User who played the song */
|
||||||
|
userId: string;
|
||||||
|
/** Song that was played */
|
||||||
|
songId: string;
|
||||||
|
/** When playback started */
|
||||||
|
playedAt: Date;
|
||||||
|
/** Seconds actually played */
|
||||||
|
playedDuration?: number;
|
||||||
|
/** Did user listen to completion */
|
||||||
|
completed?: boolean;
|
||||||
|
/** Where playback was initiated */
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Queue {
|
||||||
|
/** Unique queue identifier */
|
||||||
|
id: string;
|
||||||
|
/** User who owns this queue */
|
||||||
|
userId: string;
|
||||||
|
/** Array of song IDs in order */
|
||||||
|
songIds: Record<string, unknown>;
|
||||||
|
/** Current playing index */
|
||||||
|
currentIndex?: number;
|
||||||
|
/** Whether queue is shuffled */
|
||||||
|
isShuffled?: boolean;
|
||||||
|
/** Repeat mode: none, one, all */
|
||||||
|
repeatMode?: string;
|
||||||
|
/** When queue was created */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Last update time */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadSession {
|
||||||
|
/** Unique upload session ID */
|
||||||
|
id: string;
|
||||||
|
/** User uploading the file */
|
||||||
|
userId: string;
|
||||||
|
/** Original file name */
|
||||||
|
fileName: string;
|
||||||
|
/** Total file size in bytes */
|
||||||
|
fileSize: number;
|
||||||
|
/** File MIME type */
|
||||||
|
mimeType: string;
|
||||||
|
/** Size of each chunk */
|
||||||
|
chunkSize: number;
|
||||||
|
/** Total number of chunks */
|
||||||
|
totalChunks: number;
|
||||||
|
/** Array of uploaded chunk numbers */
|
||||||
|
uploadedChunks?: Record<string, unknown>;
|
||||||
|
/** Upload status */
|
||||||
|
status?: string;
|
||||||
|
/** Associated file ID when complete */
|
||||||
|
fileId?: string;
|
||||||
|
/** Additional file metadata */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
/** When upload started */
|
||||||
|
createdAt: Date;
|
||||||
|
/** When upload session expires */
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchIndex {
|
||||||
|
/** Unique index entry ID */
|
||||||
|
id: string;
|
||||||
|
/** Type of entity (song, album, artist) */
|
||||||
|
entityType: string;
|
||||||
|
/** ID of the indexed entity */
|
||||||
|
entityId: string;
|
||||||
|
/** Entity title for search */
|
||||||
|
title: string;
|
||||||
|
/** Full text content */
|
||||||
|
content?: string;
|
||||||
|
/** Additional searchable metadata */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
/** When indexed */
|
||||||
|
createdAt: Date;
|
||||||
|
/** Last update */
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === API Paths ===
|
||||||
|
|
||||||
|
export const API_PATHS = {
|
||||||
|
AUTH_REFRESH: '/api/auth/refresh' as const,
|
||||||
|
AUTH_LOGOUT: '/api/auth/logout' as const,
|
||||||
|
AUTH_SESSIONS: '/api/auth/sessions' as const,
|
||||||
|
AUTH_REVOKE_SESSION: '/api/auth/sessions/:id' as const,
|
||||||
|
AUTH_VERIFY_EMAIL: '/api/auth/verify-email' as const,
|
||||||
|
AUTH_CONFIRM_EMAIL: '/api/auth/confirm-email' as const,
|
||||||
|
PLAYER_PLAY: '/api/player/play' as const,
|
||||||
|
PLAYER_PAUSE: '/api/player/pause' as const,
|
||||||
|
PLAYER_NEXT: '/api/player/next' as const,
|
||||||
|
PLAYER_PREVIOUS: '/api/player/previous' as const,
|
||||||
|
PLAYER_QUEUE: '/api/player/queue' as const,
|
||||||
|
PLAYER_QUEUE_ADD: '/api/player/queue' as const,
|
||||||
|
PLAYER_QUEUE_CLEAR: '/api/player/queue' as const,
|
||||||
|
PLAYER_HISTORY: '/api/player/history' as const,
|
||||||
|
UPLOAD_INIT: '/api/upload/init' as const,
|
||||||
|
UPLOAD_CHUNK: '/api/upload/chunk/:uploadId/:chunkIndex' as const,
|
||||||
|
UPLOAD_COMPLETE: '/api/upload/complete/:uploadId' as const,
|
||||||
|
UPLOAD_PRESIGNED: '/api/upload/presigned-url' as const,
|
||||||
|
SEARCH: '/api/search' as const,
|
||||||
|
SEARCH_SUGGESTIONS: '/api/search/suggestions' as const,
|
||||||
|
SEARCH_INDEX: '/api/search/index' as const,
|
||||||
|
} as const;
|
||||||
168
lib/auth.ts
168
lib/auth.ts
|
|
@ -4,6 +4,7 @@ import { cookies } from 'next/headers'
|
||||||
import { prisma } from './prisma'
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||||
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret-change-in-production'
|
||||||
const SALT_ROUNDS = 10
|
const SALT_ROUNDS = 10
|
||||||
|
|
||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
|
|
@ -12,6 +13,11 @@ export interface JWTPayload {
|
||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenPayload {
|
||||||
|
userId: string
|
||||||
|
tokenType: 'refresh'
|
||||||
|
}
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
return bcrypt.hash(password, SALT_ROUNDS)
|
return bcrypt.hash(password, SALT_ROUNDS)
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +27,17 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateToken(payload: JWTPayload): string {
|
export function generateToken(payload: JWTPayload): string {
|
||||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' })
|
return jwt.sign(payload, JWT_SECRET, { expiresIn: '15m' }) // Access token for 15 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRefreshToken(): string {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ tokenType: 'refresh' } as RefreshTokenPayload,
|
||||||
|
JWT_REFRESH_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
)
|
||||||
|
// Remove the header and signature to get a cleaner token
|
||||||
|
return token.split('.')[2] || token
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyToken(token: string): JWTPayload | null {
|
export function verifyToken(token: string): JWTPayload | null {
|
||||||
|
|
@ -32,6 +48,16 @@ export function verifyToken(token: string): JWTPayload | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function verifyRefreshToken(token: string): RefreshTokenPayload | null {
|
||||||
|
try {
|
||||||
|
// Reconstruct the full JWT token
|
||||||
|
const fullToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${token}.signature`
|
||||||
|
return jwt.verify(fullToken, JWT_REFRESH_SECRET) as RefreshTokenPayload
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCurrentUser() {
|
export async function getCurrentUser() {
|
||||||
const cookieStore = await cookies()
|
const cookieStore = await cookies()
|
||||||
const token = cookieStore.get('auth-token')?.value
|
const token = cookieStore.get('auth-token')?.value
|
||||||
|
|
@ -97,6 +123,146 @@ export function generateResetToken(): string {
|
||||||
return crypto.randomUUID()
|
return crypto.randomUUID()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createSession(
|
||||||
|
userId: string,
|
||||||
|
deviceInfo?: Record<string, unknown>,
|
||||||
|
ipAddress?: string,
|
||||||
|
userAgent?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const sessionToken = crypto.randomUUID()
|
||||||
|
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
token: sessionToken,
|
||||||
|
deviceInfo: deviceInfo ? JSON.stringify(deviceInfo) : undefined,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return sessionToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateSession(sessionToken: string) {
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { token: sessionToken },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last activity
|
||||||
|
await prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { lastActivity: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
return session.user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeSession(sessionToken: string) {
|
||||||
|
await prisma.session.delete({
|
||||||
|
where: { token: sessionToken },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAllSessions(userId: string) {
|
||||||
|
await prisma.session.deleteMany({
|
||||||
|
where: { userId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserSessions(userId: string) {
|
||||||
|
return prisma.session.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
deviceInfo: true,
|
||||||
|
ipAddress: true,
|
||||||
|
userAgent: true,
|
||||||
|
lastActivity: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { lastActivity: 'desc' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRefreshToken(userId: string): Promise<string> {
|
||||||
|
const token = generateRefreshToken()
|
||||||
|
const expiresAt = new Date()
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days
|
||||||
|
|
||||||
|
await prisma.refreshToken.create({
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
userId,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateRefreshToken(token: string) {
|
||||||
|
const refreshToken = await prisma.refreshToken.findFirst({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
isRevoked: false,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshToken.user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeRefreshToken(token: string) {
|
||||||
|
await prisma.refreshToken.updateMany({
|
||||||
|
where: { token },
|
||||||
|
data: { isRevoked: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAllRefreshTokens(userId: string) {
|
||||||
|
await prisma.refreshToken.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { isRevoked: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateEmailToken(): string {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
|
||||||
export function slugify(text: string): string {
|
export function slugify(text: string): string {
|
||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
import { requireAuth } from './auth'
|
||||||
|
|
||||||
|
export async function getUserQueue(userId: string): Promise<any> {
|
||||||
|
const queue = await prisma.queue.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!queue) {
|
||||||
|
// Create a new queue for the user
|
||||||
|
return prisma.queue.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
songIds: JSON.stringify([]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...queue,
|
||||||
|
songIds: JSON.parse(queue.songIds),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserQueue(
|
||||||
|
userId: string,
|
||||||
|
songIds: string[],
|
||||||
|
currentIndex?: number,
|
||||||
|
isShuffled?: boolean,
|
||||||
|
repeatMode?: string
|
||||||
|
): Promise<any> {
|
||||||
|
const queue = await prisma.queue.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: {
|
||||||
|
songIds: JSON.stringify(songIds),
|
||||||
|
currentIndex,
|
||||||
|
isShuffled,
|
||||||
|
repeatMode,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
songIds: JSON.stringify(songIds),
|
||||||
|
currentIndex,
|
||||||
|
isShuffled,
|
||||||
|
repeatMode,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...queue,
|
||||||
|
songIds: JSON.parse(queue.songIds),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addToQueue(userId: string, songIds: string[]): Promise<any> {
|
||||||
|
const queue = await getUserQueue(userId)
|
||||||
|
const currentSongIds = queue.songIds as string[] || []
|
||||||
|
const newSongIds = [...currentSongIds, ...songIds]
|
||||||
|
|
||||||
|
return updateUserQueue(userId, newSongIds, queue.currentIndex, queue.isShuffled, queue.repeatMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFromQueue(userId: string, index: number): Promise<any> {
|
||||||
|
const queue = await getUserQueue(userId)
|
||||||
|
const songIds = queue.songIds as string[] || []
|
||||||
|
|
||||||
|
if (index < 0 || index >= songIds.length) {
|
||||||
|
throw new Error('Invalid index')
|
||||||
|
}
|
||||||
|
|
||||||
|
songIds.splice(index, 1)
|
||||||
|
let newCurrentIndex = queue.currentIndex || 0
|
||||||
|
|
||||||
|
// Adjust current index if necessary
|
||||||
|
if (index < newCurrentIndex) {
|
||||||
|
newCurrentIndex--
|
||||||
|
} else if (newCurrentIndex >= songIds.length) {
|
||||||
|
newCurrentIndex = Math.max(0, songIds.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateUserQueue(userId, songIds, newCurrentIndex, queue.isShuffled, queue.repeatMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playSong(userId: string, songId: string, source?: string): Promise<void> {
|
||||||
|
// Add to play history
|
||||||
|
await prisma.playHistory.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
songId,
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update song play count
|
||||||
|
await prisma.song.update({
|
||||||
|
where: { id: songId },
|
||||||
|
data: {
|
||||||
|
playCount: {
|
||||||
|
increment: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlayHistory(
|
||||||
|
userId: string,
|
||||||
|
limit: number = 50,
|
||||||
|
offset: number = 0
|
||||||
|
): Promise<any[]> {
|
||||||
|
return prisma.playHistory.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
song: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
duration: true,
|
||||||
|
artist: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
album: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
coverUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { playedAt: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlayHistoryItem(
|
||||||
|
userId: string,
|
||||||
|
songId: string,
|
||||||
|
playedDuration: number,
|
||||||
|
completed: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.playHistory.updateMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
songId,
|
||||||
|
completed: false,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
playedDuration,
|
||||||
|
completed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNextInQueue(userId: string): Promise<string | null> {
|
||||||
|
const queue = await getUserQueue(userId)
|
||||||
|
const songIds = queue.songIds as string[] || []
|
||||||
|
let currentIndex = queue.currentIndex || 0
|
||||||
|
const repeatMode = queue.repeatMode || 'none'
|
||||||
|
|
||||||
|
if (songIds.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle repeat modes
|
||||||
|
if (repeatMode === 'one') {
|
||||||
|
return songIds[currentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next
|
||||||
|
currentIndex++
|
||||||
|
|
||||||
|
// Handle wrap around for repeat all
|
||||||
|
if (currentIndex >= songIds.length) {
|
||||||
|
if (repeatMode === 'all') {
|
||||||
|
currentIndex = 0
|
||||||
|
} else {
|
||||||
|
return null // End of queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue with new index
|
||||||
|
await updateUserQueue(userId, songIds, currentIndex, queue.isShuffled, repeatMode)
|
||||||
|
|
||||||
|
return songIds[currentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPreviousInQueue(userId: string): Promise<string | null> {
|
||||||
|
const queue = await getUserQueue(userId)
|
||||||
|
const songIds = queue.songIds as string[] || []
|
||||||
|
let currentIndex = queue.currentIndex || 0
|
||||||
|
|
||||||
|
if (songIds.length === 0 || currentIndex <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to previous
|
||||||
|
currentIndex--
|
||||||
|
|
||||||
|
// Update queue with new index
|
||||||
|
await updateUserQueue(userId, songIds, currentIndex, queue.isShuffled, queue.repeatMode)
|
||||||
|
|
||||||
|
return songIds[currentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shuffleQueue(userId: string): Promise<any> {
|
||||||
|
const queue = await getUserQueue(userId)
|
||||||
|
const songIds = queue.songIds as string[] || []
|
||||||
|
|
||||||
|
if (songIds.length === 0) {
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle the array
|
||||||
|
const shuffledIds = [...songIds]
|
||||||
|
for (let i = shuffledIds.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffledIds[i], shuffledIds[j]] = [shuffledIds[j], shuffledIds[i]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find current song in shuffled array
|
||||||
|
const currentSongId = songIds[queue.currentIndex || 0]
|
||||||
|
const newCurrentIndex = shuffledIds.indexOf(currentSongId)
|
||||||
|
|
||||||
|
return updateUserQueue(
|
||||||
|
userId,
|
||||||
|
shuffledIds,
|
||||||
|
newCurrentIndex,
|
||||||
|
true,
|
||||||
|
queue.repeatMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unshuffleQueue(userId: string): Promise<any> {
|
||||||
|
const queue = await getUserQueue(userId)
|
||||||
|
const songIds = queue.songIds as string[] || []
|
||||||
|
|
||||||
|
return updateUserQueue(
|
||||||
|
userId,
|
||||||
|
songIds,
|
||||||
|
queue.currentIndex,
|
||||||
|
false,
|
||||||
|
queue.repeatMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
|
||||||
|
export async function indexEntity(
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
title: string,
|
||||||
|
content?: string,
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
): Promise<any> {
|
||||||
|
const searchIndex = await prisma.searchIndex.upsert({
|
||||||
|
where: {
|
||||||
|
entityType_entityId: {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
metadata: metadata ? JSON.stringify(metadata) : undefined,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
metadata: metadata ? JSON.stringify(metadata) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...searchIndex,
|
||||||
|
metadata: searchIndex.metadata ? JSON.parse(searchIndex.metadata) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEntities(
|
||||||
|
query: string,
|
||||||
|
entityType?: string,
|
||||||
|
limit: number = 20,
|
||||||
|
offset: number = 0
|
||||||
|
): Promise<{ results: any[]; total: number }> {
|
||||||
|
const whereClause: any = {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
title: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
contains: query,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityType) {
|
||||||
|
whereClause.entityType = entityType
|
||||||
|
}
|
||||||
|
|
||||||
|
const [results, total] = await Promise.all([
|
||||||
|
prisma.searchIndex.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
orderBy: [
|
||||||
|
{ updatedAt: 'desc' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
prisma.searchIndex.count({
|
||||||
|
where: whereClause,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Fetch full entity data based on type
|
||||||
|
const enrichedResults = await Promise.all(
|
||||||
|
results.map(async (result) => {
|
||||||
|
const baseResult = {
|
||||||
|
...result,
|
||||||
|
metadata: result.metadata ? JSON.parse(result.metadata) : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result.entityType) {
|
||||||
|
case 'song':
|
||||||
|
const song = await prisma.song.findUnique({
|
||||||
|
where: { id: result.entityId },
|
||||||
|
include: {
|
||||||
|
artist: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
album: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
coverUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ...baseResult, entity: song }
|
||||||
|
|
||||||
|
case 'album':
|
||||||
|
const album = await prisma.album.findUnique({
|
||||||
|
where: { id: result.entityId },
|
||||||
|
include: {
|
||||||
|
artist: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { songs: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ...baseResult, entity: album }
|
||||||
|
|
||||||
|
case 'artist':
|
||||||
|
const artist = await prisma.artist.findUnique({
|
||||||
|
where: { id: result.entityId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { songs: true, albums: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ...baseResult, entity: artist }
|
||||||
|
|
||||||
|
case 'playlist':
|
||||||
|
const playlist = await prisma.playlist.findUnique({
|
||||||
|
where: { id: result.entityId },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { songs: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ...baseResult, entity: playlist }
|
||||||
|
|
||||||
|
default:
|
||||||
|
return baseResult
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: enrichedResults,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSearchSuggestions(
|
||||||
|
query: string,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<string[]> {
|
||||||
|
const suggestions = await prisma.searchIndex.findMany({
|
||||||
|
where: {
|
||||||
|
title: {
|
||||||
|
contains: query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extract unique suggestions
|
||||||
|
const uniqueTitles = [...new Set(suggestions.map(s => s.title))]
|
||||||
|
return uniqueTitles.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeFromIndex(entityType: string, entityId: string): Promise<void> {
|
||||||
|
await prisma.searchIndex.delete({
|
||||||
|
where: {
|
||||||
|
entityType_entityId: {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reindexAll(): Promise<void> {
|
||||||
|
// Clear existing index
|
||||||
|
await prisma.searchIndex.deleteMany({})
|
||||||
|
|
||||||
|
// Reindex all songs
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
where: { isPublic: true },
|
||||||
|
include: {
|
||||||
|
artist: true,
|
||||||
|
album: true,
|
||||||
|
genres: {
|
||||||
|
include: { genre: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const song of songs) {
|
||||||
|
await indexEntity(
|
||||||
|
'song',
|
||||||
|
song.id,
|
||||||
|
song.title,
|
||||||
|
`${song.title} ${song.artist.name} ${song.album?.title || ''} ${song.genres.map(g => g.genre.name).join(' ')}`,
|
||||||
|
{
|
||||||
|
artist: song.artist.name,
|
||||||
|
album: song.album?.title,
|
||||||
|
genres: song.genres.map(g => g.genre.name),
|
||||||
|
duration: song.duration,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex all albums
|
||||||
|
const albums = await prisma.album.findMany({
|
||||||
|
include: { artist: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const album of albums) {
|
||||||
|
await indexEntity(
|
||||||
|
'album',
|
||||||
|
album.id,
|
||||||
|
album.title,
|
||||||
|
`${album.title} ${album.artist.name}`,
|
||||||
|
{
|
||||||
|
artist: album.artist.name,
|
||||||
|
releaseDate: album.releaseDate,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex all artists
|
||||||
|
const artists = await prisma.artist.findMany()
|
||||||
|
|
||||||
|
for (const artist of artists) {
|
||||||
|
await indexEntity(
|
||||||
|
'artist',
|
||||||
|
artist.id,
|
||||||
|
artist.name,
|
||||||
|
artist.bio || '',
|
||||||
|
{
|
||||||
|
verified: artist.verified,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reindex public playlists
|
||||||
|
const playlists = await prisma.playlist.findMany({
|
||||||
|
where: { isPublic: true },
|
||||||
|
include: { user: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const playlist of playlists) {
|
||||||
|
await indexEntity(
|
||||||
|
'playlist',
|
||||||
|
playlist.id,
|
||||||
|
playlist.title,
|
||||||
|
`${playlist.title} ${playlist.description || ''} ${playlist.user.displayName || playlist.user.username}`,
|
||||||
|
{
|
||||||
|
author: playlist.user.displayName || playlist.user.username,
|
||||||
|
description: playlist.description,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
import { requireAuth } from './auth'
|
||||||
|
// Using type from Prisma for now
|
||||||
|
type UploadSession = any
|
||||||
|
|
||||||
|
const DEFAULT_CHUNK_SIZE = 1024 * 1024 * 5 // 5MB chunks
|
||||||
|
const UPLOAD_SESSION_EXPIRY = 24 * 60 * 60 * 1000 // 24 hours in ms
|
||||||
|
|
||||||
|
export async function createUploadSession(
|
||||||
|
userId: string,
|
||||||
|
fileName: string,
|
||||||
|
fileSize: number,
|
||||||
|
mimeType: string,
|
||||||
|
chunkSize?: number,
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
): Promise<any> {
|
||||||
|
const actualChunkSize = chunkSize || DEFAULT_CHUNK_SIZE
|
||||||
|
const totalChunks = Math.ceil(fileSize / actualChunkSize)
|
||||||
|
const expiresAt = new Date(Date.now() + UPLOAD_SESSION_EXPIRY)
|
||||||
|
|
||||||
|
const session = await prisma.uploadSession.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
mimeType,
|
||||||
|
chunkSize: actualChunkSize,
|
||||||
|
totalChunks,
|
||||||
|
metadata: metadata ? JSON.stringify(metadata) : undefined,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
uploadedChunks: session.uploadedChunks ? JSON.parse(session.uploadedChunks) : [],
|
||||||
|
metadata: session.metadata ? JSON.parse(session.metadata) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUploadSession(uploadId: string, userId: string): Promise<UploadSession | null> {
|
||||||
|
const session = await prisma.uploadSession.findFirst({
|
||||||
|
where: {
|
||||||
|
id: uploadId,
|
||||||
|
userId,
|
||||||
|
expiresAt: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
uploadedChunks: session.uploadedChunks ? JSON.parse(session.uploadedChunks) : [],
|
||||||
|
metadata: session.metadata ? JSON.parse(session.metadata) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markChunkUploaded(uploadId: string, chunkIndex: number): Promise<any> {
|
||||||
|
const session = await prisma.uploadSession.findUnique({
|
||||||
|
where: { id: uploadId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('Upload session not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedChunks = session.uploadedChunks ? JSON.parse(session.uploadedChunks) : []
|
||||||
|
|
||||||
|
if (!uploadedChunks.includes(chunkIndex)) {
|
||||||
|
uploadedChunks.push(chunkIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSession = await prisma.uploadSession.update({
|
||||||
|
where: { id: uploadId },
|
||||||
|
data: {
|
||||||
|
uploadedChunks: JSON.stringify(uploadedChunks),
|
||||||
|
status: uploadedChunks.length >= session.totalChunks ? 'completed' : 'uploading',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...updatedSession,
|
||||||
|
uploadedChunks: JSON.parse(updatedSession.uploadedChunks || '[]'),
|
||||||
|
metadata: updatedSession.metadata ? JSON.parse(updatedSession.metadata) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeUploadSession(uploadId: string, fileId: string): Promise<any> {
|
||||||
|
const session = await prisma.uploadSession.update({
|
||||||
|
where: { id: uploadId },
|
||||||
|
data: {
|
||||||
|
status: 'completed',
|
||||||
|
fileId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
uploadedChunks: session.uploadedChunks ? JSON.parse(session.uploadedChunks) : [],
|
||||||
|
metadata: session.metadata ? JSON.parse(session.metadata) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function failUploadSession(uploadId: string): Promise<void> {
|
||||||
|
await prisma.uploadSession.update({
|
||||||
|
where: { id: uploadId },
|
||||||
|
data: {
|
||||||
|
status: 'failed',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePresignedUrl(
|
||||||
|
fileName: string,
|
||||||
|
mimeType: string,
|
||||||
|
expiresIn: number = 3600
|
||||||
|
): Promise<{ url: string; key: string }> {
|
||||||
|
// This is a placeholder for actual S3/CloudStorage presigned URL generation
|
||||||
|
// In a real implementation, you would use AWS SDK, Google Cloud Storage SDK, etc.
|
||||||
|
|
||||||
|
const key = `uploads/${Date.now()}-${fileName}`
|
||||||
|
|
||||||
|
// For now, return a mock URL - in production, generate a real presigned URL
|
||||||
|
const url = `${process.env.NEXT_PUBLIC_APP_URL}/api/upload/presigned-upload?key=${encodeURIComponent(key)}`
|
||||||
|
|
||||||
|
return { url, key }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupExpiredSessions(): Promise<void> {
|
||||||
|
await prisma.uploadSession.deleteMany({
|
||||||
|
where: {
|
||||||
|
expiresAt: {
|
||||||
|
lt: new Date(),
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
in: ['pending', 'uploading'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to validate file type
|
||||||
|
export function validateFileType(mimeType: string, allowedTypes: string[]): boolean {
|
||||||
|
return allowedTypes.some(type => {
|
||||||
|
if (type.endsWith('/*')) {
|
||||||
|
return mimeType.startsWith(type.slice(0, -1))
|
||||||
|
}
|
||||||
|
return mimeType === type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to validate file size
|
||||||
|
export function validateFileSize(fileSize: number, maxSize: number): boolean {
|
||||||
|
return fileSize <= maxSize
|
||||||
|
}
|
||||||
|
|
@ -22,14 +22,21 @@ model User {
|
||||||
role String @default("user") // user, artist, label, admin
|
role String @default("user") // user, artist, label, admin
|
||||||
resetToken String?
|
resetToken String?
|
||||||
resetExpires DateTime?
|
resetExpires DateTime?
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
emailToken String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
artist Artist?
|
artist Artist?
|
||||||
label Label?
|
label Label?
|
||||||
playlists Playlist[]
|
playlists Playlist[]
|
||||||
shares Share[]
|
shares Share[]
|
||||||
|
refreshTokens RefreshToken[]
|
||||||
|
sessions Session[]
|
||||||
|
playHistory PlayHistory[]
|
||||||
|
queue Queue?
|
||||||
|
uploadSessions UploadSession[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
@ -136,10 +143,11 @@ model Song {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||||||
album Album? @relation(fields: [albumId], references: [id], onDelete: SetNull)
|
album Album? @relation(fields: [albumId], references: [id], onDelete: SetNull)
|
||||||
genres SongGenre[]
|
genres SongGenre[]
|
||||||
playlists PlaylistSong[]
|
playlists PlaylistSong[]
|
||||||
|
playHistory PlayHistory[]
|
||||||
|
|
||||||
@@unique([artistId, slug])
|
@@unique([artistId, slug])
|
||||||
@@map("songs")
|
@@map("songs")
|
||||||
|
|
@ -229,3 +237,118 @@ model Share {
|
||||||
@@index([targetId, type])
|
@@index([targetId, type])
|
||||||
@@map("shares")
|
@@map("shares")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshToken model - for JWT refresh token rotation
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
token String @unique
|
||||||
|
userId String
|
||||||
|
expiresAt DateTime
|
||||||
|
isRevoked Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("refresh_tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session model - for tracking active user sessions
|
||||||
|
model Session {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
token String @unique
|
||||||
|
deviceInfo String? // JSON string
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
lastActivity DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([token])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayHistory model - for tracking user playback history
|
||||||
|
model PlayHistory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
songId String
|
||||||
|
playedAt DateTime @default(now())
|
||||||
|
playedDuration Int? // seconds actually played
|
||||||
|
completed Boolean @default(false)
|
||||||
|
source String? // where playback was initiated
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([songId])
|
||||||
|
@@index([playedAt])
|
||||||
|
@@map("play_history")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue model - for user playback queues
|
||||||
|
model Queue {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String @unique
|
||||||
|
songIds String // JSON array of song IDs in order
|
||||||
|
currentIndex Int @default(0)
|
||||||
|
isShuffled Boolean @default(false)
|
||||||
|
repeatMode String @default("none") // none, one, all
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("queues")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadSession model - for chunked file uploads
|
||||||
|
model UploadSession {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
fileName String
|
||||||
|
fileSize Int
|
||||||
|
mimeType String
|
||||||
|
chunkSize Int
|
||||||
|
totalChunks Int
|
||||||
|
uploadedChunks String? // JSON array of uploaded chunk numbers
|
||||||
|
status String @default("pending") // pending, uploading, completed, failed, expired
|
||||||
|
fileId String?
|
||||||
|
metadata String? // JSON string for additional metadata
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("upload_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchIndex model - for full-text search indexing
|
||||||
|
model SearchIndex {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
entityType String // song, album, artist, playlist
|
||||||
|
entityId String
|
||||||
|
title String
|
||||||
|
content String? // full text content
|
||||||
|
metadata String? // JSON string for additional searchable metadata
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([entityType, entityId])
|
||||||
|
@@index([title])
|
||||||
|
@@unique([entityType, entityId])
|
||||||
|
@@map("search_index")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@
|
||||||
"action": "DESIGN_DOCUMENT_CREATED",
|
"action": "DESIGN_DOCUMENT_CREATED",
|
||||||
"timestamp": "2025-12-18T15:10:00",
|
"timestamp": "2025-12-18T15:10:00",
|
||||||
"details": "Complete design document with 91 entities created"
|
"details": "Complete design document with 91 entities created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "DESIGN_DOCUMENT_UPDATED",
|
||||||
|
"timestamp": "2025-12-20T22:10:00",
|
||||||
|
"details": "Added Phase 1 critical features: Enhanced authentication, advanced audio playback, file upload system, search implementation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -96,6 +101,76 @@
|
||||||
"table_name": "shares",
|
"table_name": "shares",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"file_path": "prisma/schema.prisma"
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_refresh_token",
|
||||||
|
"name": "RefreshToken",
|
||||||
|
"table_name": "refresh_tokens",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_session",
|
||||||
|
"name": "Session",
|
||||||
|
"table_name": "sessions",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_play_history",
|
||||||
|
"name": "PlayHistory",
|
||||||
|
"table_name": "play_histories",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_queue",
|
||||||
|
"name": "Queue",
|
||||||
|
"table_name": "queues",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_player_state",
|
||||||
|
"name": "PlayerState",
|
||||||
|
"table_name": "player_states",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_audio_file",
|
||||||
|
"name": "AudioFile",
|
||||||
|
"table_name": "audio_files",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_upload_session",
|
||||||
|
"name": "UploadSession",
|
||||||
|
"table_name": "upload_sessions",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_search_index",
|
||||||
|
"name": "SearchIndex",
|
||||||
|
"table_name": "search_indices",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_search_query",
|
||||||
|
"name": "SearchQuery",
|
||||||
|
"table_name": "search_queries",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "model_search_suggestion",
|
||||||
|
"name": "SearchSuggestion",
|
||||||
|
"table_name": "search_suggestions",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "prisma/schema.prisma"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"api_endpoints": [
|
"api_endpoints": [
|
||||||
|
|
@ -418,6 +493,350 @@
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"file_path": "app/api/share/[token]/click/route.ts"
|
"file_path": "app/api/share/[token]/click/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_refresh_token",
|
||||||
|
"name": "Refresh Token",
|
||||||
|
"path": "/api/auth/refresh",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/refresh/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_logout",
|
||||||
|
"name": "Logout",
|
||||||
|
"path": "/api/auth/logout",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/logout/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_sessions",
|
||||||
|
"name": "Get Sessions",
|
||||||
|
"path": "/api/auth/sessions",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/sessions/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_revoke_session",
|
||||||
|
"name": "Revoke Session",
|
||||||
|
"path": "/api/auth/sessions/:id",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/sessions/[id]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_verify_email",
|
||||||
|
"name": "Verify Email",
|
||||||
|
"path": "/api/auth/verify-email",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/verify-email/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_confirm_email",
|
||||||
|
"name": "Confirm Email",
|
||||||
|
"path": "/api/auth/confirm-email",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/confirm-email/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_change_password",
|
||||||
|
"name": "Change Password",
|
||||||
|
"path": "/api/auth/change-password",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/auth/change-password/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_queue",
|
||||||
|
"name": "Get Queue",
|
||||||
|
"path": "/api/player/queue",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/queue/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_update_queue",
|
||||||
|
"name": "Update Queue",
|
||||||
|
"path": "/api/player/queue",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/queue/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_add_to_queue",
|
||||||
|
"name": "Add to Queue",
|
||||||
|
"path": "/api/player/queue/add",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/queue/add/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_remove_from_queue",
|
||||||
|
"name": "Remove from Queue",
|
||||||
|
"path": "/api/player/queue/remove",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/queue/remove/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_reorder_queue",
|
||||||
|
"name": "Reorder Queue",
|
||||||
|
"path": "/api/player/queue/reorder",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/queue/reorder/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_clear_queue",
|
||||||
|
"name": "Clear Queue",
|
||||||
|
"path": "/api/player/queue/clear",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/queue/clear/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_player_state",
|
||||||
|
"name": "Get Player State",
|
||||||
|
"path": "/api/player/state",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/state/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_update_player_state",
|
||||||
|
"name": "Update Player State",
|
||||||
|
"path": "/api/player/state",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/state/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_player_play",
|
||||||
|
"name": "Player Play",
|
||||||
|
"path": "/api/player/play",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/play/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_player_pause",
|
||||||
|
"name": "Player Pause",
|
||||||
|
"path": "/api/player/pause",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/pause/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_player_next",
|
||||||
|
"name": "Player Next",
|
||||||
|
"path": "/api/player/next",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/next/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_player_previous",
|
||||||
|
"name": "Player Previous",
|
||||||
|
"path": "/api/player/previous",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/previous/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_player_seek",
|
||||||
|
"name": "Player Seek",
|
||||||
|
"path": "/api/player/seek",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/seek/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_player_shuffle",
|
||||||
|
"name": "Player Shuffle",
|
||||||
|
"path": "/api/player/shuffle",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/shuffle/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_player_repeat",
|
||||||
|
"name": "Player Repeat",
|
||||||
|
"path": "/api/player/repeat",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/repeat/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_get_play_history",
|
||||||
|
"name": "Get Play History",
|
||||||
|
"path": "/api/player/history",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/history/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_record_play",
|
||||||
|
"name": "Record Play",
|
||||||
|
"path": "/api/player/history",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/history/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_analyze_audio",
|
||||||
|
"name": "Analyze Audio",
|
||||||
|
"path": "/api/player/analyze/:songId",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/player/analyze/[songId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_initiate_upload",
|
||||||
|
"name": "Initiate Upload",
|
||||||
|
"path": "/api/upload/initiate",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/initiate/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_upload_chunk",
|
||||||
|
"name": "Upload Chunk",
|
||||||
|
"path": "/api/upload/chunk/:sessionId",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/chunk/[sessionId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_complete_upload",
|
||||||
|
"name": "Complete Upload",
|
||||||
|
"path": "/api/upload/complete/:sessionId",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/complete/[sessionId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_upload_status",
|
||||||
|
"name": "Upload Status",
|
||||||
|
"path": "/api/upload/status/:sessionId",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/status/[sessionId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_abort_upload",
|
||||||
|
"name": "Abort Upload",
|
||||||
|
"path": "/api/upload/abort/:sessionId",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/abort/[sessionId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_process_audio",
|
||||||
|
"name": "Process Audio",
|
||||||
|
"path": "/api/upload/process/:fileId",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/process/[fileId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_audio_preview",
|
||||||
|
"name": "Audio Preview",
|
||||||
|
"path": "/api/upload/preview/:fileId",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/preview/[fileId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_storage_usage",
|
||||||
|
"name": "Storage Usage",
|
||||||
|
"path": "/api/upload/storage/usage",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/storage/usage/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_list_files",
|
||||||
|
"name": "List Files",
|
||||||
|
"path": "/api/upload/files",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/files/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_delete_file",
|
||||||
|
"name": "Delete File",
|
||||||
|
"path": "/api/upload/files/:fileId",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/upload/files/[fileId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_search_suggestions",
|
||||||
|
"name": "Search Suggestions",
|
||||||
|
"path": "/api/search/suggestions",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/suggestions/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_trending_searches",
|
||||||
|
"name": "Trending Searches",
|
||||||
|
"path": "/api/search/trending",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/trending/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_recent_searches",
|
||||||
|
"name": "Recent Searches",
|
||||||
|
"path": "/api/search/recent/:userId",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/recent/[userId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_save_search",
|
||||||
|
"name": "Save Search",
|
||||||
|
"path": "/api/search/save/:userId",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/save/[userId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_index_search",
|
||||||
|
"name": "Index Search",
|
||||||
|
"path": "/api/search/index",
|
||||||
|
"method": "POST",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/index/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_update_search_index",
|
||||||
|
"name": "Update Search Index",
|
||||||
|
"path": "/api/search/index/:entityType/:entityId",
|
||||||
|
"method": "PUT",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/index/[entityType]/[entityId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_remove_search_index",
|
||||||
|
"name": "Remove Search Index",
|
||||||
|
"path": "/api/search/index/:entityType/:entityId",
|
||||||
|
"method": "DELETE",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/index/[entityType]/[entityId]/route.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "api_search_analytics",
|
||||||
|
"name": "Search Analytics",
|
||||||
|
"path": "/api/search/analytics",
|
||||||
|
"method": "GET",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/api/search/analytics/route.ts"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"pages": [
|
"pages": [
|
||||||
|
|
@ -511,6 +930,27 @@
|
||||||
"path": "/s/:token",
|
"path": "/s/:token",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"file_path": "app/s/[token]/page.tsx"
|
"file_path": "app/s/[token]/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_verify_email",
|
||||||
|
"name": "Email Verification",
|
||||||
|
"path": "/auth/verify-email",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/auth/verify-email/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_security_settings",
|
||||||
|
"name": "Security Settings",
|
||||||
|
"path": "/settings/security",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/settings/security/page.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "page_connected_accounts",
|
||||||
|
"name": "Connected Accounts",
|
||||||
|
"path": "/settings/connected-accounts",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "app/settings/connected-accounts/page.tsx"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
|
|
@ -687,51 +1127,234 @@
|
||||||
"name": "SharedContentDisplay",
|
"name": "SharedContentDisplay",
|
||||||
"status": "PENDING",
|
"status": "PENDING",
|
||||||
"file_path": "components/SharedContentDisplay.tsx"
|
"file_path": "components/SharedContentDisplay.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_advanced_audio_player",
|
||||||
|
"name": "AdvancedAudioPlayer",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AdvancedAudioPlayer.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_queue_manager",
|
||||||
|
"name": "QueueManager",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/QueueManager.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_audio_visualizer",
|
||||||
|
"name": "AudioVisualizer",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AudioVisualizer.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_mini_player",
|
||||||
|
"name": "MiniPlayer",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/MiniPlayer.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_play_history",
|
||||||
|
"name": "PlayHistory",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/PlayHistory.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_advanced_upload_form",
|
||||||
|
"name": "AdvancedUploadForm",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AdvancedUploadForm.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_upload_progress",
|
||||||
|
"name": "UploadProgress",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/UploadProgress.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_file_uploader",
|
||||||
|
"name": "FileUploader",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/FileUploader.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_audio_preview",
|
||||||
|
"name": "AudioPreview",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AudioPreview.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_advanced_search_bar",
|
||||||
|
"name": "AdvancedSearchBar",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/AdvancedSearchBar.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_search_filters",
|
||||||
|
"name": "SearchFilters",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SearchFilters.tsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "component_search_analytics",
|
||||||
|
"name": "SearchAnalytics",
|
||||||
|
"status": "PENDING",
|
||||||
|
"file_path": "components/SearchAnalytics.tsx"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"component_to_page": {
|
"component_to_page": {
|
||||||
"component_auth_form": ["page_login", "page_register", "page_forgot_password"],
|
"component_auth_form": [
|
||||||
"component_song_card": ["page_home", "page_artist_profile", "page_search", "page_genre_browse", "page_album_detail", "page_playlist_detail"],
|
"page_login",
|
||||||
"component_genre_badge": ["page_home"],
|
"page_register",
|
||||||
"component_section_header": ["page_home"],
|
"page_forgot_password"
|
||||||
"component_artist_header": ["page_artist_profile"],
|
],
|
||||||
"component_album_card": ["page_artist_profile", "page_search"],
|
"component_song_card": [
|
||||||
"component_social_links": ["page_artist_profile"],
|
"page_home",
|
||||||
"component_album_header": ["page_album_detail"],
|
"page_artist_profile",
|
||||||
"component_track_list": ["page_album_detail", "page_playlist_detail"],
|
"page_search",
|
||||||
"component_upload_form": ["page_upload"],
|
"page_genre_browse",
|
||||||
"component_waveform_display": ["page_upload"],
|
"page_album_detail",
|
||||||
"component_playlist_card": ["page_playlists"],
|
"page_playlist_detail"
|
||||||
"component_create_playlist_modal": ["page_playlists"],
|
],
|
||||||
"component_playlist_header": ["page_playlist_detail"],
|
"component_genre_badge": [
|
||||||
"component_profile_form": ["page_profile"],
|
"page_home"
|
||||||
"component_avatar_upload": ["page_profile"],
|
],
|
||||||
"component_search_bar": ["page_search"],
|
"component_section_header": [
|
||||||
"component_search_results": ["page_search"],
|
"page_home"
|
||||||
"component_artist_card": ["page_search"],
|
],
|
||||||
"component_genre_header": ["page_genre_browse"]
|
"component_artist_header": [
|
||||||
|
"page_artist_profile"
|
||||||
|
],
|
||||||
|
"component_album_card": [
|
||||||
|
"page_artist_profile",
|
||||||
|
"page_search"
|
||||||
|
],
|
||||||
|
"component_social_links": [
|
||||||
|
"page_artist_profile"
|
||||||
|
],
|
||||||
|
"component_album_header": [
|
||||||
|
"page_album_detail"
|
||||||
|
],
|
||||||
|
"component_track_list": [
|
||||||
|
"page_album_detail",
|
||||||
|
"page_playlist_detail"
|
||||||
|
],
|
||||||
|
"component_upload_form": [
|
||||||
|
"page_upload"
|
||||||
|
],
|
||||||
|
"component_waveform_display": [
|
||||||
|
"page_upload"
|
||||||
|
],
|
||||||
|
"component_playlist_card": [
|
||||||
|
"page_playlists"
|
||||||
|
],
|
||||||
|
"component_create_playlist_modal": [
|
||||||
|
"page_playlists"
|
||||||
|
],
|
||||||
|
"component_playlist_header": [
|
||||||
|
"page_playlist_detail"
|
||||||
|
],
|
||||||
|
"component_profile_form": [
|
||||||
|
"page_profile"
|
||||||
|
],
|
||||||
|
"component_avatar_upload": [
|
||||||
|
"page_profile"
|
||||||
|
],
|
||||||
|
"component_search_bar": [
|
||||||
|
"page_search"
|
||||||
|
],
|
||||||
|
"component_search_results": [
|
||||||
|
"page_search"
|
||||||
|
],
|
||||||
|
"component_artist_card": [
|
||||||
|
"page_search"
|
||||||
|
],
|
||||||
|
"component_genre_header": [
|
||||||
|
"page_genre_browse"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"api_to_component": {
|
"api_to_component": {
|
||||||
"api_login": ["component_auth_form"],
|
"api_login": [
|
||||||
"api_register": ["component_auth_form"],
|
"component_auth_form"
|
||||||
"api_forgot_password": ["component_auth_form"],
|
],
|
||||||
"api_upload_song": ["component_upload_form"],
|
"api_register": [
|
||||||
"api_increment_play_count": ["component_audio_player"],
|
"component_auth_form"
|
||||||
"api_search": ["component_search_bar"],
|
],
|
||||||
"api_create_playlist": ["component_create_playlist_modal"],
|
"api_forgot_password": [
|
||||||
"api_update_current_user": ["component_profile_form"]
|
"component_auth_form"
|
||||||
|
],
|
||||||
|
"api_upload_song": [
|
||||||
|
"component_upload_form"
|
||||||
|
],
|
||||||
|
"api_increment_play_count": [
|
||||||
|
"component_audio_player"
|
||||||
|
],
|
||||||
|
"api_search": [
|
||||||
|
"component_search_bar"
|
||||||
|
],
|
||||||
|
"api_create_playlist": [
|
||||||
|
"component_create_playlist_modal"
|
||||||
|
],
|
||||||
|
"api_update_current_user": [
|
||||||
|
"component_profile_form"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"table_to_api": {
|
"table_to_api": {
|
||||||
"model_user": ["api_register", "api_login", "api_forgot_password", "api_reset_password", "api_get_current_user", "api_update_current_user"],
|
"model_user": [
|
||||||
"model_artist": ["api_create_artist_profile", "api_get_artist", "api_update_artist", "api_get_artist_songs", "api_get_artist_albums"],
|
"api_register",
|
||||||
"model_song": ["api_upload_song", "api_get_song", "api_update_song", "api_delete_song", "api_increment_play_count", "api_get_artist_songs", "api_get_trending_songs", "api_get_new_releases", "api_search"],
|
"api_login",
|
||||||
"model_album": ["api_create_album", "api_get_album", "api_update_album", "api_delete_album", "api_get_artist_albums", "api_search"],
|
"api_forgot_password",
|
||||||
"model_playlist": ["api_create_playlist", "api_get_user_playlists", "api_get_playlist", "api_update_playlist", "api_delete_playlist"],
|
"api_reset_password",
|
||||||
"model_playlist_song": ["api_add_song_to_playlist", "api_remove_song_from_playlist", "api_reorder_playlist_songs"],
|
"api_get_current_user",
|
||||||
"model_genre": ["api_get_genres", "api_get_songs_by_genre"],
|
"api_update_current_user"
|
||||||
"model_label": ["api_create_label_profile", "api_get_label_artists"]
|
],
|
||||||
|
"model_artist": [
|
||||||
|
"api_create_artist_profile",
|
||||||
|
"api_get_artist",
|
||||||
|
"api_update_artist",
|
||||||
|
"api_get_artist_songs",
|
||||||
|
"api_get_artist_albums"
|
||||||
|
],
|
||||||
|
"model_song": [
|
||||||
|
"api_upload_song",
|
||||||
|
"api_get_song",
|
||||||
|
"api_update_song",
|
||||||
|
"api_delete_song",
|
||||||
|
"api_increment_play_count",
|
||||||
|
"api_get_artist_songs",
|
||||||
|
"api_get_trending_songs",
|
||||||
|
"api_get_new_releases",
|
||||||
|
"api_search"
|
||||||
|
],
|
||||||
|
"model_album": [
|
||||||
|
"api_create_album",
|
||||||
|
"api_get_album",
|
||||||
|
"api_update_album",
|
||||||
|
"api_delete_album",
|
||||||
|
"api_get_artist_albums",
|
||||||
|
"api_search"
|
||||||
|
],
|
||||||
|
"model_playlist": [
|
||||||
|
"api_create_playlist",
|
||||||
|
"api_get_user_playlists",
|
||||||
|
"api_get_playlist",
|
||||||
|
"api_update_playlist",
|
||||||
|
"api_delete_playlist"
|
||||||
|
],
|
||||||
|
"model_playlist_song": [
|
||||||
|
"api_add_song_to_playlist",
|
||||||
|
"api_remove_song_from_playlist",
|
||||||
|
"api_reorder_playlist_songs"
|
||||||
|
],
|
||||||
|
"model_genre": [
|
||||||
|
"api_get_genres",
|
||||||
|
"api_get_songs_by_genre"
|
||||||
|
],
|
||||||
|
"model_label": [
|
||||||
|
"api_create_label_profile",
|
||||||
|
"api_get_label_artists"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue