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'
|
||||
tasks_count: 0
|
||||
operations_count: 0
|
||||
latest_version: v004
|
||||
total_versions: 4
|
||||
- version: v005
|
||||
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 { prisma } from '@/lib/prisma'
|
||||
import { searchEntities } from '@/lib/search'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get('q')
|
||||
const type = searchParams.get('type')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const offset = parseInt(searchParams.get('offset') || '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()
|
||||
const songs = await prisma.song.findMany({
|
||||
where: {
|
||||
isPublic: true,
|
||||
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,
|
||||
})
|
||||
if (offset < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Offset must be non-negative' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Search artists
|
||||
const artists = await prisma.artist.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: lowerQuery,
|
||||
},
|
||||
},
|
||||
{
|
||||
bio: {
|
||||
contains: lowerQuery,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
songs: true,
|
||||
albums: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
})
|
||||
if (type && !['song', 'album', 'artist', 'playlist'].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid type. Must be one of: song, album, artist, playlist' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Search albums
|
||||
const albums = await prisma.album.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: lowerQuery,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: {
|
||||
contains: lowerQuery,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
artist: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
verified: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
songs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
})
|
||||
// Perform search using the search index
|
||||
const { results, total } = await searchEntities(
|
||||
query.trim(),
|
||||
type || undefined,
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
songs,
|
||||
artists,
|
||||
albums,
|
||||
})
|
||||
// Group results by entity type for easier frontend consumption
|
||||
const groupedResults = {
|
||||
songs: results.filter(r => r.entityType === 'song').map(r => r.entity),
|
||||
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) {
|
||||
console.error('Error searching:', error)
|
||||
return NextResponse.json({ error: 'Failed to perform search' }, { status: 500 })
|
||||
console.error('Search error:', error)
|
||||
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'
|
||||
|
||||
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
|
||||
|
||||
export interface JWTPayload {
|
||||
|
|
@ -12,6 +13,11 @@ export interface JWTPayload {
|
|||
role: string
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
userId: string
|
||||
tokenType: 'refresh'
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
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 {
|
||||
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 {
|
||||
|
|
@ -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() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('auth-token')?.value
|
||||
|
|
@ -97,6 +123,146 @@ export function generateResetToken(): string {
|
|||
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 {
|
||||
return text
|
||||
.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
|
||||
resetToken String?
|
||||
resetExpires DateTime?
|
||||
emailVerified Boolean @default(false)
|
||||
emailToken String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
artist Artist?
|
||||
label Label?
|
||||
playlists Playlist[]
|
||||
shares Share[]
|
||||
artist Artist?
|
||||
label Label?
|
||||
playlists Playlist[]
|
||||
shares Share[]
|
||||
refreshTokens RefreshToken[]
|
||||
sessions Session[]
|
||||
playHistory PlayHistory[]
|
||||
queue Queue?
|
||||
uploadSessions UploadSession[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
@ -136,10 +143,11 @@ model Song {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||||
album Album? @relation(fields: [albumId], references: [id], onDelete: SetNull)
|
||||
genres SongGenre[]
|
||||
playlists PlaylistSong[]
|
||||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||||
album Album? @relation(fields: [albumId], references: [id], onDelete: SetNull)
|
||||
genres SongGenre[]
|
||||
playlists PlaylistSong[]
|
||||
playHistory PlayHistory[]
|
||||
|
||||
@@unique([artistId, slug])
|
||||
@@map("songs")
|
||||
|
|
@ -229,3 +237,118 @@ model Share {
|
|||
@@index([targetId, type])
|
||||
@@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",
|
||||
"timestamp": "2025-12-18T15:10:00",
|
||||
"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",
|
||||
"status": "PENDING",
|
||||
"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": [
|
||||
|
|
@ -418,6 +493,350 @@
|
|||
"method": "POST",
|
||||
"status": "PENDING",
|
||||
"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": [
|
||||
|
|
@ -511,6 +930,27 @@
|
|||
"path": "/s/:token",
|
||||
"status": "PENDING",
|
||||
"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": [
|
||||
|
|
@ -687,51 +1127,234 @@
|
|||
"name": "SharedContentDisplay",
|
||||
"status": "PENDING",
|
||||
"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": {
|
||||
"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"]
|
||||
"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"]
|
||||
"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"]
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue