Deploy update

This commit is contained in:
mazemaze 2025-12-20 23:08:57 +09:00
parent 9fcf5246e8
commit 1af4fd3df6
49 changed files with 9767 additions and 537 deletions

View File

@ -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"
}
}

View File

@ -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

View File

@ -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: []

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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'

View File

@ -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

View File

@ -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"]
}
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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 }
)
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

167
app/types/api.ts Normal file
View File

@ -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;

View File

@ -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()

253
lib/player.ts Normal file
View File

@ -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
)
}

287
lib/search.ts Normal file
View File

@ -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,
}
)
}
}

160
lib/upload.ts Normal file
View File

@ -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
}

View File

@ -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")
}

View File

@ -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"
]
}
}
}
}