working team update/create (still need enroll)
This commit is contained in:
55
pb_migrations/1758042930_updated_teams.js
Normal file
55
pb_migrations/1758042930_updated_teams.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1568971955")
|
||||||
|
|
||||||
|
// remove field
|
||||||
|
collection.fields.removeById("number3356599746")
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(9, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1329349942",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "song_end",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1568971955")
|
||||||
|
|
||||||
|
// add field
|
||||||
|
collection.fields.addAt(8, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3356599746",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "song_year",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
// update field
|
||||||
|
collection.fields.addAt(10, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1329349942",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "song_duration",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -31,6 +31,7 @@ import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authe
|
|||||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
||||||
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
||||||
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
||||||
|
import { ServerRoute as ApiSpotifySearchServerRouteImport } from './routes/api/spotify/search'
|
||||||
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
||||||
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
||||||
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
||||||
@@ -146,6 +147,11 @@ const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
|||||||
path: '/api/spotify/token',
|
path: '/api/spotify/token',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => rootServerRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({
|
||||||
|
id: '/api/spotify/search',
|
||||||
|
path: '/api/spotify/search',
|
||||||
|
getParentRoute: () => rootServerRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
||||||
id: '/api/spotify/resume',
|
id: '/api/spotify/resume',
|
||||||
path: '/api/spotify/resume',
|
path: '/api/spotify/resume',
|
||||||
@@ -310,6 +316,7 @@ export interface FileServerRoutesByFullPath {
|
|||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
@@ -322,6 +329,7 @@ export interface FileServerRoutesByTo {
|
|||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
@@ -335,6 +343,7 @@ export interface FileServerRoutesById {
|
|||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
@@ -349,6 +358,7 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/teams/upload-logo'
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
@@ -361,6 +371,7 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/teams/upload-logo'
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
@@ -373,6 +384,7 @@ export interface FileServerRouteTypes {
|
|||||||
| '/api/spotify/capture'
|
| '/api/spotify/capture'
|
||||||
| '/api/spotify/playback'
|
| '/api/spotify/playback'
|
||||||
| '/api/spotify/resume'
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
| '/api/spotify/token'
|
| '/api/spotify/token'
|
||||||
| '/api/teams/upload-logo'
|
| '/api/teams/upload-logo'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
@@ -386,6 +398,7 @@ export interface RootServerRouteChildren {
|
|||||||
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
||||||
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
||||||
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
||||||
|
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
|
||||||
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
||||||
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
||||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||||
@@ -538,6 +551,13 @@ declare module '@tanstack/react-start/server' {
|
|||||||
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
||||||
parentRoute: typeof rootServerRouteImport
|
parentRoute: typeof rootServerRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/spotify/search': {
|
||||||
|
id: '/api/spotify/search'
|
||||||
|
path: '/api/spotify/search'
|
||||||
|
fullPath: '/api/spotify/search'
|
||||||
|
preLoaderRoute: typeof ApiSpotifySearchServerRouteImport
|
||||||
|
parentRoute: typeof rootServerRouteImport
|
||||||
|
}
|
||||||
'/api/spotify/resume': {
|
'/api/spotify/resume': {
|
||||||
id: '/api/spotify/resume'
|
id: '/api/spotify/resume'
|
||||||
path: '/api/spotify/resume'
|
path: '/api/spotify/resume'
|
||||||
@@ -651,6 +671,7 @@ const rootServerRouteChildren: RootServerRouteChildren = {
|
|||||||
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
||||||
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
||||||
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
||||||
|
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute,
|
||||||
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
||||||
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
||||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||||
|
|||||||
81
src/app/routes/api/spotify/search.ts
Normal file
81
src/app/routes/api/spotify/search.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
|
// Function to get Client Credentials access token
|
||||||
|
async function getClientCredentialsToken(): Promise<string> {
|
||||||
|
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID
|
||||||
|
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error('Missing Spotify client credentials')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
||||||
|
},
|
||||||
|
body: 'grant_type=client_credentials',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to get Spotify access token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return data.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({
|
||||||
|
GET: async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const query = url.searchParams.get('q')
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Query parameter q is required' }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client credentials access token
|
||||||
|
const accessToken = await getClientCredentialsToken()
|
||||||
|
|
||||||
|
// Search using Spotify API directly
|
||||||
|
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`
|
||||||
|
|
||||||
|
const searchResponse = await fetch(searchUrl, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!searchResponse.ok) {
|
||||||
|
throw new Error('Spotify search request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResult = await searchResponse.json()
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ tracks: searchResult.tracks.items }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -389,6 +389,24 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
setCapturedState(null);
|
setCapturedState(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const searchTracks = useCallback(async (query: string): Promise<SpotifyTrack[]> => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(query)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Search failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.tracks || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to search tracks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const contextValue: SpotifyContextType = {
|
const contextValue: SpotifyContextType = {
|
||||||
...authState,
|
...authState,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
@@ -415,6 +433,8 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
capturePlaybackState,
|
capturePlaybackState,
|
||||||
resumePlaybackState,
|
resumePlaybackState,
|
||||||
clearCapturedState,
|
clearCapturedState,
|
||||||
|
// Search
|
||||||
|
searchTracks,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
|
|||||||
202
src/features/teams/components/team-form/duration-picker.tsx
Normal file
202
src/features/teams/components/team-form/duration-picker.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Stack, Text, Group, RangeSlider, Divider } from "@mantine/core";
|
||||||
|
|
||||||
|
interface DurationPickerProps {
|
||||||
|
songDurationMs: number;
|
||||||
|
initialStart?: number;
|
||||||
|
initialEnd?: number;
|
||||||
|
onChange: (startSeconds: number, endSeconds: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DurationPicker = ({
|
||||||
|
songDurationMs,
|
||||||
|
initialStart = 0,
|
||||||
|
initialEnd = 15,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: DurationPickerProps) => {
|
||||||
|
const songDurationSeconds = Math.floor(songDurationMs / 1000);
|
||||||
|
const [range, setRange] = useState<[number, number]>([
|
||||||
|
initialStart,
|
||||||
|
initialEnd,
|
||||||
|
]);
|
||||||
|
const [isValid, setIsValid] = useState(true);
|
||||||
|
|
||||||
|
const formatTime = useCallback((seconds: number) => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateRange = useCallback(
|
||||||
|
(start: number, end: number) => {
|
||||||
|
const duration = end - start;
|
||||||
|
const withinBounds = start >= 0 && end <= songDurationSeconds;
|
||||||
|
const validDuration = duration >= 10 && duration <= 15;
|
||||||
|
return withinBounds && validDuration;
|
||||||
|
},
|
||||||
|
[songDurationSeconds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getValidationMessage = useCallback((start: number, end: number) => {
|
||||||
|
const duration = end - start;
|
||||||
|
if (start < 0 || end > songDurationSeconds) {
|
||||||
|
return "Segment must be within song duration";
|
||||||
|
}
|
||||||
|
if (duration < 10) {
|
||||||
|
return "Segment must be at least 10 seconds";
|
||||||
|
}
|
||||||
|
if (duration > 15) {
|
||||||
|
return "Segment must be no more than 15 seconds";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [songDurationSeconds]);
|
||||||
|
|
||||||
|
const handleRangeChange = useCallback(
|
||||||
|
(newRange: [number, number]) => {
|
||||||
|
setRange(newRange);
|
||||||
|
const [start, end] = newRange;
|
||||||
|
const valid = validateRange(start, end);
|
||||||
|
setIsValid(valid);
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
onChange(start, end);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, validateRange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRangeChangeEnd = useCallback(
|
||||||
|
(newRange: [number, number]) => {
|
||||||
|
let [start, end] = newRange;
|
||||||
|
let duration = end - start;
|
||||||
|
|
||||||
|
if (duration < 10) {
|
||||||
|
if (start < songDurationSeconds / 2) {
|
||||||
|
end = Math.min(start + 10, songDurationSeconds);
|
||||||
|
} else {
|
||||||
|
start = Math.max(end - 10, 0);
|
||||||
|
}
|
||||||
|
duration = end - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration > 15) {
|
||||||
|
const startDiff = Math.abs(start - range[0]);
|
||||||
|
const endDiff = Math.abs(end - range[1]);
|
||||||
|
|
||||||
|
if (startDiff > endDiff) {
|
||||||
|
end = start + 15;
|
||||||
|
if (end > songDurationSeconds) {
|
||||||
|
end = songDurationSeconds;
|
||||||
|
start = end - 15;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
start = end - 15;
|
||||||
|
if (start < 0) {
|
||||||
|
start = 0;
|
||||||
|
end = start + 15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start = Math.max(0, start);
|
||||||
|
end = Math.min(songDurationSeconds, end);
|
||||||
|
|
||||||
|
const finalRange: [number, number] = [start, end];
|
||||||
|
setRange(finalRange);
|
||||||
|
setIsValid(validateRange(start, end));
|
||||||
|
onChange(start, end);
|
||||||
|
},
|
||||||
|
[range, songDurationSeconds, onChange, validateRange]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!validateRange(initialStart, initialEnd)) {
|
||||||
|
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
|
||||||
|
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
|
||||||
|
const defaultRange: [number, number] = [defaultStart, defaultEnd];
|
||||||
|
setRange(defaultRange);
|
||||||
|
onChange(defaultStart, defaultEnd);
|
||||||
|
}
|
||||||
|
}, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]);
|
||||||
|
|
||||||
|
const segmentDuration = range[1] - range[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md" opacity={disabled ? 0.5 : 1}>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb="xs" c={disabled ? "dimmed" : undefined}>
|
||||||
|
Start and End
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mb="md">
|
||||||
|
{disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RangeSlider
|
||||||
|
min={0}
|
||||||
|
max={songDurationSeconds}
|
||||||
|
step={1}
|
||||||
|
value={range}
|
||||||
|
onChange={disabled ? undefined : handleRangeChange}
|
||||||
|
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
|
||||||
|
marks={[
|
||||||
|
{ value: 0, label: "0:00" },
|
||||||
|
{
|
||||||
|
value: songDurationSeconds,
|
||||||
|
label: formatTime(songDurationSeconds),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
size="lg"
|
||||||
|
m='xs'
|
||||||
|
color={disabled ? "gray" : (isValid ? "blue" : "red")}
|
||||||
|
thumbSize={20}
|
||||||
|
label={disabled ? undefined : (value) => formatTime(value)}
|
||||||
|
disabled={disabled}
|
||||||
|
styles={{
|
||||||
|
track: { height: 8 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Stack gap={2} align="center">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Start
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{formatTime(range[0])}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap={2} align="center">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Duration
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
|
||||||
|
{segmentDuration}s
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap={2} align="center">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
End
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{formatTime(range[1])}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!isValid && (
|
||||||
|
<Text size="xs" c="red" ta="center">
|
||||||
|
{getValidationMessage(range[0], range[1])}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DurationPicker;
|
||||||
@@ -34,31 +34,62 @@ const TeamForm = ({
|
|||||||
const config: UseFormInput<TeamInput> = {
|
const config: UseFormInput<TeamInput> = {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: initialValues?.name || "",
|
name: initialValues?.name || "",
|
||||||
primary_color: initialValues?.primary_color,
|
// primary_color: initialValues?.primary_color,
|
||||||
accent_color: initialValues?.accent_color,
|
// accent_color: initialValues?.accent_color,
|
||||||
song_id: initialValues?.song_id,
|
song_id: initialValues?.song_id,
|
||||||
song_name: initialValues?.song_name,
|
song_name: initialValues?.song_name,
|
||||||
song_artist: initialValues?.song_artist,
|
song_artist: initialValues?.song_artist,
|
||||||
song_album: initialValues?.song_album,
|
song_album: initialValues?.song_album,
|
||||||
song_year: initialValues?.song_year,
|
|
||||||
song_start: initialValues?.song_start,
|
song_start: initialValues?.song_start,
|
||||||
song_end: initialValues?.song_end,
|
song_end: initialValues?.song_end,
|
||||||
song_image_url: initialValues?.song_image_url,
|
song_image_url: initialValues?.song_image_url,
|
||||||
logo: undefined,
|
logo: undefined,
|
||||||
players: initialValues?.players || []
|
players: initialValues?.players || [],
|
||||||
},
|
},
|
||||||
onSubmitPreventDefault: "always",
|
onSubmitPreventDefault: "always",
|
||||||
validate: {
|
validate: {
|
||||||
name: isNotEmpty("Name is required"),
|
name: isNotEmpty("Name is required"),
|
||||||
players: (value: string[]) => value.length > 1 && value[1] !== '' ? undefined : "Players are required"
|
players: (value: string[]) =>
|
||||||
|
value.length > 1 && value[1] !== ""
|
||||||
|
? undefined
|
||||||
|
: "Players are required",
|
||||||
|
song_name: isNotEmpty("Song is required"),
|
||||||
|
song_start: (value, values) => {
|
||||||
|
if (values.song_name && values.song_id) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return "Song start time is required";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
song_end: (value, values) => {
|
||||||
|
if (values.song_name && values.song_id) {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return "Song end time is required";
|
||||||
|
}
|
||||||
|
if (values.song_start !== undefined && value !== undefined) {
|
||||||
|
const duration = value - values.song_start;
|
||||||
|
if (duration < 10) {
|
||||||
|
return "Song segment must be at least 10 seconds";
|
||||||
|
}
|
||||||
|
if (duration > 15) {
|
||||||
|
return "Song segment must be no more than 15 seconds";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = useForm(config);
|
const form = useForm(config);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { mutate: createTournament, isPending: createPending } = useCreateTeam();
|
const { mutate: createTournament, isPending: createPending } =
|
||||||
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(teamId!);
|
useCreateTeam();
|
||||||
|
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(
|
||||||
|
teamId!
|
||||||
|
);
|
||||||
|
|
||||||
const isPending = createPending || updatePending;
|
const isPending = createPending || updatePending;
|
||||||
|
|
||||||
@@ -67,9 +98,6 @@ const TeamForm = ({
|
|||||||
const { logo, ...teamData } = values;
|
const { logo, ...teamData } = values;
|
||||||
|
|
||||||
const mutation = isEditMode ? updateTournament : createTournament;
|
const mutation = isEditMode ? updateTournament : createTournament;
|
||||||
const successMessage = isEditMode
|
|
||||||
? "Team updated successfully!"
|
|
||||||
: "Team created successfully!";
|
|
||||||
const errorMessage = isEditMode
|
const errorMessage = isEditMode
|
||||||
? "Failed to update team"
|
? "Failed to update team"
|
||||||
: "Failed to create team";
|
: "Failed to create team";
|
||||||
@@ -102,8 +130,6 @@ const TeamForm = ({
|
|||||||
tournamentKeys.details(result.team!.id),
|
tournamentKeys.details(result.team!.id),
|
||||||
result.team
|
result.team
|
||||||
);
|
);
|
||||||
|
|
||||||
toast.success(successMessage);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const logoErrorMessage = isEditMode
|
const logoErrorMessage = isEditMode
|
||||||
? `Team updated but logo upload failed: ${error.message}`
|
? `Team updated but logo upload failed: ${error.message}`
|
||||||
@@ -112,16 +138,12 @@ const TeamForm = ({
|
|||||||
logger.error("Team logo upload error", error);
|
logger.error("Team logo upload error", error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.success(successMessage);
|
|
||||||
}
|
}
|
||||||
close();
|
close();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(`${errorMessage}: ${error.message}`);
|
toast.error(`${errorMessage}: ${error.message}`);
|
||||||
logger.error(
|
logger.error(`Team ${isEditMode ? "update" : "create"} error`, error);
|
||||||
`Team ${isEditMode ? "update" : "create"} error`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -130,7 +152,7 @@ const TeamForm = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SlidePanel
|
<SlidePanel
|
||||||
onSubmit={form.onSubmit((values) => console.log(values))}
|
onSubmit={form.onSubmit(handleSubmit)}
|
||||||
onCancel={close}
|
onCancel={close}
|
||||||
submitText={isEditMode ? "Update Team" : "Create Team"}
|
submitText={isEditMode ? "Update Team" : "Create Team"}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
@@ -140,6 +162,7 @@ const TeamForm = ({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
|
disabled={isEditMode}
|
||||||
key={form.key("name")}
|
key={form.key("name")}
|
||||||
{...form.getInputProps("name")}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
@@ -152,114 +175,52 @@ const TeamForm = ({
|
|||||||
{...form.getInputProps("logo")}
|
{...form.getInputProps("logo")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{tournamentId && (
|
||||||
tournamentId && (
|
<PlayersPicker
|
||||||
<PlayersPicker
|
tournamentId={tournamentId}
|
||||||
tournamentId={tournamentId}
|
key={form.key("players")}
|
||||||
key={form.key("players")}
|
{...form.getInputProps("players")}
|
||||||
{...form.getInputProps("players")}
|
disabled={isEditMode}
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<SlidePanelField
|
|
||||||
key={form.key("primary_color")}
|
|
||||||
{...form.getInputProps("primary_color")}
|
|
||||||
Component={TeamColorPicker}
|
|
||||||
title="Select Primary Color"
|
|
||||||
placeholder="Select Primary Color"
|
|
||||||
label="Primary Color"
|
|
||||||
formatValue={(value) => (
|
|
||||||
<Group>
|
|
||||||
<Badge variant="filled" radius="sm" color={value} />
|
|
||||||
{value}
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SlidePanelField
|
|
||||||
key={form.key("accent_color")}
|
|
||||||
{...form.getInputProps("accent_color")}
|
|
||||||
Component={TeamColorPicker}
|
|
||||||
title="Select Accent Color"
|
|
||||||
placeholder="Select Accent Color"
|
|
||||||
label="Accent Color"
|
|
||||||
formatValue={(value) => (
|
|
||||||
<Group>
|
|
||||||
<Badge variant="filled" radius="sm" color={value} />
|
|
||||||
{value}
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SongPicker form={form} />
|
|
||||||
|
|
||||||
|
|
||||||
{/*
|
|
||||||
<SlidePanelField
|
|
||||||
key={form.key("start_time")}
|
|
||||||
{...form.getInputProps("start_time")}
|
|
||||||
Component={DateTimePicker}
|
|
||||||
title="Select Start Date"
|
|
||||||
label="Start Date"
|
|
||||||
withAsterisk
|
|
||||||
formatValue={(date) =>
|
|
||||||
!date ? 'Select a time' :
|
|
||||||
new Date(date).toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SlidePanelField
|
|
||||||
key={form.key("enroll_time")}
|
|
||||||
{...form.getInputProps("enroll_time")}
|
|
||||||
Component={DateTimePicker}
|
|
||||||
title="Select Enrollment Due Date"
|
|
||||||
label="Enrollment Due"
|
|
||||||
withAsterisk
|
|
||||||
formatValue={(date) =>
|
|
||||||
!date ? 'Select a time' :
|
|
||||||
new Date(date).toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isEditMode && (
|
|
||||||
<SlidePanelField
|
|
||||||
key={form.key("end_time")}
|
|
||||||
{...form.getInputProps("end_time")}
|
|
||||||
Component={DateTimePicker}
|
|
||||||
title="Select End Date"
|
|
||||||
label="End Date (Optional)"
|
|
||||||
formatValue={(date) =>
|
|
||||||
!date ? 'Select a time' :
|
|
||||||
new Date(date).toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
*/}
|
|
||||||
|
<SongPicker
|
||||||
|
form={form}
|
||||||
|
error={form.errors.song_name as string}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("primary_color")}
|
||||||
|
{...form.getInputProps("primary_color")}
|
||||||
|
Component={TeamColorPicker}
|
||||||
|
title="Select Primary Color"
|
||||||
|
placeholder="Select Primary Color"
|
||||||
|
label="Primary Color"
|
||||||
|
formatValue={(value) => (
|
||||||
|
<Group>
|
||||||
|
<Badge variant="filled" radius="sm" color={value} />
|
||||||
|
{value}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlidePanelField
|
||||||
|
key={form.key("accent_color")}
|
||||||
|
{...form.getInputProps("accent_color")}
|
||||||
|
Component={TeamColorPicker}
|
||||||
|
title="Select Accent Color"
|
||||||
|
placeholder="Select Accent Color"
|
||||||
|
label="Accent Color"
|
||||||
|
formatValue={(value) => (
|
||||||
|
<Group>
|
||||||
|
<Badge variant="filled" radius="sm" color={value} />
|
||||||
|
{value}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
*/}
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</SlidePanel>
|
</SlidePanel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,18 +42,20 @@ const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }:
|
|||||||
<Autocomplete
|
<Autocomplete
|
||||||
label="Player 1"
|
label="Player 1"
|
||||||
data={options}
|
data={options}
|
||||||
disabled={!isAdmin || disabled}
|
disabled
|
||||||
value={playerOne}
|
value={playerOne}
|
||||||
onChange={setPlayerOne}
|
onChange={setPlayerOne}
|
||||||
|
comboboxProps={{ withinPortal: false }}
|
||||||
/>
|
/>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
label="Player 2"
|
label="Player 2"
|
||||||
data={options}
|
data={options}
|
||||||
disabled={disabled && !isAdmin}
|
disabled={disabled}
|
||||||
value={playerTwo}
|
value={playerTwo}
|
||||||
onChange={setPlayerTwo}
|
onChange={setPlayerTwo}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
error={error}
|
error={error}
|
||||||
|
comboboxProps={{ withinPortal: false }}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,56 +1,149 @@
|
|||||||
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||||
import { Stack, Text } from "@mantine/core";
|
import { Stack, Text, Group, Avatar } from "@mantine/core";
|
||||||
import { UseFormReturnType } from "@mantine/form";
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
import { TeamInput } from "../../types";
|
import { TeamInput } from "../../types";
|
||||||
import { useEffect, useState } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||||
|
import SongSearch from "./song-search";
|
||||||
|
import DurationPicker from "./duration-picker";
|
||||||
|
import SongSummary from "./song-summary";
|
||||||
|
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
song_id: string;
|
song_id: string;
|
||||||
song_name: string;
|
song_name: string;
|
||||||
song_artist: string;
|
song_artist: string;
|
||||||
song_album: string;
|
song_album: string;
|
||||||
song_year?: number;
|
|
||||||
song_start?: number;
|
song_start?: number;
|
||||||
song_end?: number;
|
song_end?: number;
|
||||||
song_image_url: string;
|
song_image_url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SongPickerProps {
|
interface SongPickerProps {
|
||||||
form: UseFormReturnType<TeamInput>
|
form: UseFormReturnType<TeamInput>;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SongPicker = ({ form }: SongPickerProps) => {
|
const SongPicker = ({ form, error }: SongPickerProps) => {
|
||||||
const [song, setSong] = useState<Song>();
|
const currentSong = useMemo((): Song | undefined => {
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const values = form.getValues();
|
const values = form.getValues();
|
||||||
|
if (values.song_id && values.song_name) {
|
||||||
setSong({
|
return {
|
||||||
song_id: values.song_id || "",
|
song_id: values.song_id,
|
||||||
song_name: values.song_name || "",
|
song_name: values.song_name,
|
||||||
song_artist: values.song_artist || "",
|
song_artist: values.song_artist || "",
|
||||||
song_album: values.song_album || "",
|
song_album: values.song_album || "",
|
||||||
song_year: values.song_year,
|
song_start: values.song_start,
|
||||||
song_start: values.song_start,
|
song_end: values.song_end,
|
||||||
song_end: values.song_end,
|
song_image_url: values.song_image_url || "",
|
||||||
song_image_url: values.song_image_url || "",
|
};
|
||||||
})
|
}
|
||||||
}, []);
|
return undefined;
|
||||||
|
}, [form.values.song_id, form.values.song_name, form.values.song_artist, form.values.song_album, form.values.song_image_url, form.values.song_start, form.values.song_end]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlidePanelField
|
<SlidePanelField
|
||||||
key={"song-picker"}
|
key={"song-picker"}
|
||||||
value={""}
|
value={currentSong}
|
||||||
onChange={console.log}
|
formatValue={(song) => <SongSummary song={song} />}
|
||||||
Component={() => (
|
onChange={(updatedSong: Song) => {
|
||||||
<Stack>
|
if (updatedSong) {
|
||||||
<Text>Song picker</Text>
|
form.setValues({
|
||||||
</Stack>
|
song_id: updatedSong.song_id,
|
||||||
)}
|
song_name: updatedSong.song_name,
|
||||||
|
song_artist: updatedSong.song_artist,
|
||||||
|
song_album: updatedSong.song_album,
|
||||||
|
song_start: updatedSong.song_start,
|
||||||
|
song_end: updatedSong.song_end,
|
||||||
|
song_image_url: updatedSong.song_image_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error={error}
|
||||||
|
Component={SongPickerComponent}
|
||||||
|
componentProps={{ formValues: form.getValues() }}
|
||||||
title={"Select Song"}
|
title={"Select Song"}
|
||||||
label={"Song"}
|
label={"Walkout Song"}
|
||||||
|
placeholder={"Select your walkout song"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface SongPickerComponentProps {
|
||||||
|
value: Song | undefined;
|
||||||
|
onChange: (song: Song) => void;
|
||||||
|
formValues: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => {
|
||||||
|
const handleSongSelect = (track: SpotifyTrack) => {
|
||||||
|
const defaultStart = 0;
|
||||||
|
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
|
||||||
|
|
||||||
|
const newSong: Song = {
|
||||||
|
song_id: track.id,
|
||||||
|
song_name: track.name,
|
||||||
|
song_artist: track.artists.map(a => a.name).join(', '),
|
||||||
|
song_album: track.album.name,
|
||||||
|
song_image_url: track.album.images[0]?.url || '',
|
||||||
|
song_start: defaultStart,
|
||||||
|
song_end: defaultEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange(newSong);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDurationChange = (startSeconds: number, endSeconds: number) => {
|
||||||
|
if (song) {
|
||||||
|
onChange({
|
||||||
|
...song,
|
||||||
|
song_start: startSeconds,
|
||||||
|
song_end: endSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack styles={{ root: { width: '100%' }}}>
|
||||||
|
<Text size="lg" fw={500}>Search for a Song</Text>
|
||||||
|
|
||||||
|
<SongSearch onChange={handleSongSelect} />
|
||||||
|
|
||||||
|
<Stack gap="lg" mt="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Avatar
|
||||||
|
src={song?.song_image_url || null}
|
||||||
|
size={60}
|
||||||
|
radius="md"
|
||||||
|
bg="transparent"
|
||||||
|
>
|
||||||
|
{!song?.song_image_url && <MusicNote size={24} color="var(--mantine-color-dimmed)" />}
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
|
||||||
|
{song?.song_name || "No song selected"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{song?.song_artist || "Select a song to see details"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{song?.song_album || ""}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap="xs">
|
||||||
|
<DurationPicker
|
||||||
|
songDurationMs={180000}
|
||||||
|
initialStart={song?.song_start || 0}
|
||||||
|
initialEnd={song?.song_end || 15}
|
||||||
|
onChange={handleDurationChange}
|
||||||
|
disabled={!song?.song_id}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default SongPicker;
|
export default SongPicker;
|
||||||
121
src/features/teams/components/team-form/song-search.tsx
Normal file
121
src/features/teams/components/team-form/song-search.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
|
||||||
|
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||||
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
|
||||||
|
interface SongSearchProps {
|
||||||
|
onChange: (track: SpotifyTrack) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const combobox = useCombobox();
|
||||||
|
|
||||||
|
// Standalone search function that doesn't require Spotify context
|
||||||
|
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/spotify/search?q=${encodeURIComponent(query)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Search failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.tracks || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to search tracks:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await searchSpotifyTracks(query);
|
||||||
|
setSearchResults(results);
|
||||||
|
combobox.openDropdown();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
debouncedSearch(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSongSelect = (track: SpotifyTrack) => {
|
||||||
|
onChange(track);
|
||||||
|
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
||||||
|
combobox.closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = searchResults.map((track) => (
|
||||||
|
<Combobox.Option value={track.id} key={track.id}>
|
||||||
|
<Group gap="sm">
|
||||||
|
{track.album.images[2] && (
|
||||||
|
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{track.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Combobox.Option>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
store={combobox}
|
||||||
|
onOptionSubmit={(value) => {
|
||||||
|
const track = searchResults.find(t => t.id === value);
|
||||||
|
if (track) handleSongSelect(track);
|
||||||
|
}}
|
||||||
|
width='100%'
|
||||||
|
zIndex={9999}
|
||||||
|
withinPortal={false}
|
||||||
|
>
|
||||||
|
<Combobox.Target>
|
||||||
|
<InputBase
|
||||||
|
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||||
|
onClick={() => combobox.openDropdown()}
|
||||||
|
onFocus={() => combobox.openDropdown()}
|
||||||
|
onBlur={() => combobox.closeDropdown()}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Options>
|
||||||
|
{options.length > 0 ? options :
|
||||||
|
<Combobox.Empty>
|
||||||
|
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
||||||
|
</Combobox.Empty>
|
||||||
|
}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SongSearch;
|
||||||
67
src/features/teams/components/team-form/song-summary.tsx
Normal file
67
src/features/teams/components/team-form/song-summary.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Group, Avatar, Text, Box } from "@mantine/core";
|
||||||
|
import { MusicNoteIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
interface Song {
|
||||||
|
song_id: string;
|
||||||
|
song_name: string;
|
||||||
|
song_artist: string;
|
||||||
|
song_album: string;
|
||||||
|
song_start?: number;
|
||||||
|
song_end?: number;
|
||||||
|
song_image_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SongSummaryProps {
|
||||||
|
song: Song | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SongSummary = ({ song }: SongSummaryProps) => {
|
||||||
|
// Format time helper
|
||||||
|
const formatTime = (seconds: number | undefined) => {
|
||||||
|
if (seconds === undefined) return null;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If no song selected
|
||||||
|
if (!song?.song_name) {
|
||||||
|
return (
|
||||||
|
<Group gap="xs" c="dimmed">
|
||||||
|
<MusicNoteIcon size={16} />
|
||||||
|
<Text size="sm" c="dimmed">No song selected</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTimeSegment = song.song_start !== undefined && song.song_end !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
{song.song_image_url && (
|
||||||
|
<Avatar
|
||||||
|
src={song.song_image_url}
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{song.song_name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||||
|
{song.song_artist}
|
||||||
|
</Text>
|
||||||
|
{hasTimeSegment && (
|
||||||
|
<Text size="xs" c="dimmed" fw={700}>
|
||||||
|
{formatTime(song.song_start)} - {formatTime(song.song_end)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SongSummary;
|
||||||
@@ -12,7 +12,6 @@ export interface Team {
|
|||||||
song_name: string;
|
song_name: string;
|
||||||
song_artist: string;
|
song_artist: string;
|
||||||
song_album: string;
|
song_album: string;
|
||||||
song_year: number;
|
|
||||||
song_start: number;
|
song_start: number;
|
||||||
song_end: number;
|
song_end: number;
|
||||||
song_image_url: string;
|
song_image_url: string;
|
||||||
@@ -47,7 +46,6 @@ export const teamInputSchema = z
|
|||||||
song_name: z.string().max(255).optional(),
|
song_name: z.string().max(255).optional(),
|
||||||
song_artist: z.string().max(255).optional(),
|
song_artist: z.string().max(255).optional(),
|
||||||
song_album: z.string().max(255).optional(),
|
song_album: z.string().max(255).optional(),
|
||||||
song_year: z.number().int().optional(),
|
|
||||||
song_start: z.number().int().optional(),
|
song_start: z.number().int().optional(),
|
||||||
song_end: z.number().int().optional(),
|
song_end: z.number().int().optional(),
|
||||||
song_image_url: z.url("Invalid song image URL").optional(),
|
song_image_url: z.url("Invalid song image URL").optional(),
|
||||||
@@ -79,7 +77,6 @@ export const teamUpdateSchema = z
|
|||||||
song_name: z.string().max(255).optional(),
|
song_name: z.string().max(255).optional(),
|
||||||
song_artist: z.string().max(255).optional(),
|
song_artist: z.string().max(255).optional(),
|
||||||
song_album: z.string().max(255).optional(),
|
song_album: z.string().max(255).optional(),
|
||||||
song_year: z.number().int().optional(),
|
|
||||||
song_start: z.number().int().optional(),
|
song_start: z.number().int().optional(),
|
||||||
song_end: z.number().int().optional(),
|
song_end: z.number().int().optional(),
|
||||||
song_image_url: z.url("Invalid song image URL").optional(),
|
song_image_url: z.url("Invalid song image URL").optional(),
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import Button from "@/components/button";
|
|||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { useMemo, useState, useCallback } from "react";
|
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||||
import TeamSelectionView from "./team-selection-view";
|
import TeamSelectionView from "./team-selection-view";
|
||||||
import TeamForm from "@/features/teams/components/team-form";
|
import TeamForm from "@/features/teams/components/team-form";
|
||||||
|
import { teamQueries } from "@/features/teams/queries";
|
||||||
|
import { Team } from "@/features/teams/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
interface EnrollTeamProps {
|
interface EnrollTeamProps {
|
||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
@@ -13,12 +16,25 @@ interface EnrollTeamProps {
|
|||||||
const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
|
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState<string>();
|
const [selectedTeamId, setSelectedTeamId] = useState<string>();
|
||||||
const [showTeamForm, setShowTeamForm] = useState<boolean>(!!hasTeams);
|
const [showTeamForm, setShowTeamForm] = useState<boolean>(!hasTeams);
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||||
|
|
||||||
const teamOptions = useMemo(() =>
|
const { data: teamData } = useQuery({
|
||||||
|
...teamQueries.details(selectedTeamId!),
|
||||||
|
enabled: !!selectedTeamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (teamData?.success) {
|
||||||
|
setSelectedTeam(teamData.data);
|
||||||
|
setShowTeamForm(true);
|
||||||
|
}
|
||||||
|
}, [teamData]);
|
||||||
|
|
||||||
|
const teamOptions = useMemo(() =>
|
||||||
user?.teams?.map(team => ({
|
user?.teams?.map(team => ({
|
||||||
value: team.id,
|
value: team.id,
|
||||||
label: team.name
|
label: team.name
|
||||||
@@ -26,11 +42,34 @@ const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
|||||||
[user?.teams]
|
[user?.teams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback((teamId: string | undefined) => {
|
const handleBack = useCallback(() => {
|
||||||
setSelectedTeamId(teamId);
|
setSelectedTeamId(undefined);
|
||||||
setShowTeamForm(true);
|
setSelectedTeam(null);
|
||||||
|
setShowTeamForm(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((teamId: string | undefined) => {
|
||||||
|
setSelectedTeamId(teamId);
|
||||||
|
if (!teamId) {
|
||||||
|
setShowTeamForm(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initialValues = useMemo(() => {
|
||||||
|
if (!selectedTeam) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: selectedTeam.name,
|
||||||
|
song_id: selectedTeam.song_id,
|
||||||
|
song_name: selectedTeam.song_name,
|
||||||
|
song_artist: selectedTeam.song_artist,
|
||||||
|
song_album: selectedTeam.song_album,
|
||||||
|
song_start: selectedTeam.song_start,
|
||||||
|
song_end: selectedTeam.song_end,
|
||||||
|
song_image_url: selectedTeam.song_image_url,
|
||||||
|
players: selectedTeam.players.map(player => player.id),
|
||||||
|
};
|
||||||
|
}, [selectedTeam]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -38,9 +77,14 @@ const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
|||||||
Enroll Your Team
|
Enroll Your Team
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={!isOpen} onChange={toggle}>
|
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
|
||||||
{showTeamForm ? (
|
{showTeamForm ? (
|
||||||
<TeamForm close={console.log} tournamentId={tournamentId} />
|
<TeamForm
|
||||||
|
close={handleBack}
|
||||||
|
tournamentId={tournamentId}
|
||||||
|
initialValues={initialValues}
|
||||||
|
teamId={selectedTeamId}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TeamSelectionView
|
<TeamSelectionView
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
options,
|
options,
|
||||||
onSelect
|
onSelect
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState<string>();
|
const [value, setValue] = useState<string>('');
|
||||||
const selectedOption = useMemo(() => options.find(option => option.label === value), [value])
|
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
|
||||||
|
|
||||||
|
|
||||||
const handleCreateNewTeamClicked = () => onSelect(undefined);
|
const handleCreateNewTeamClicked = () => onSelect(undefined);
|
||||||
const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
|
const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
|
||||||
@@ -28,19 +27,19 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
>
|
>
|
||||||
Create New Team
|
Create New Team
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Divider my="sm" label="or" />
|
<Divider my="sm" label="or" />
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
placeholder="Select one of your existing teams"
|
placeholder="Select one of your existing teams"
|
||||||
value={selectedOption?.label || ''}
|
value={value}
|
||||||
onChange={setValue}
|
onChange={setValue}
|
||||||
data={options.map(option => option.label)}
|
data={options.map(option => option.label)}
|
||||||
comboboxProps={{ withinPortal: false }}
|
comboboxProps={{ withinPortal: false }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSelectExistingTeam}
|
onClick={handleSelectExistingTeam}
|
||||||
disabled={!selectedOption}
|
disabled={!selectedOption}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ export function transformTeam(record: any): Team {
|
|||||||
song_name: record.song_name,
|
song_name: record.song_name,
|
||||||
song_artist: record.song_artist,
|
song_artist: record.song_artist,
|
||||||
song_album: record.song_album,
|
song_album: record.song_album,
|
||||||
song_year: record.song_year,
|
|
||||||
song_start: record.song_start,
|
song_start: record.song_start,
|
||||||
song_end: 0,
|
song_end: 0,
|
||||||
song_image_url: record.song_image_url,
|
song_image_url: record.song_image_url,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
SpotifyPlaybackState,
|
SpotifyPlaybackState,
|
||||||
SpotifyPlaybackSnapshot,
|
SpotifyPlaybackSnapshot,
|
||||||
SpotifyError,
|
SpotifyError,
|
||||||
|
SpotifyTrack,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
|
const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
|
||||||
@@ -123,6 +124,11 @@ export class SpotifyWebApiClient {
|
|||||||
return this.request<{ id: string; display_name: string }>('/me');
|
return this.request<{ id: string; display_name: string }>('/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchTracks(query: string, limit: number = 20): Promise<{ tracks: { items: SpotifyTrack[] } }> {
|
||||||
|
const encodedQuery = encodeURIComponent(query);
|
||||||
|
return this.request<{ tracks: { items: SpotifyTrack[] } }>(`/search?q=${encodedQuery}&type=track&limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
updateAccessToken(accessToken: string): void {
|
updateAccessToken(accessToken: string): void {
|
||||||
this.accessToken = accessToken;
|
this.accessToken = accessToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export interface SpotifyContextType extends SpotifyAuthState {
|
|||||||
capturePlaybackState: () => Promise<void>;
|
capturePlaybackState: () => Promise<void>;
|
||||||
resumePlaybackState: () => Promise<void>;
|
resumePlaybackState: () => Promise<void>;
|
||||||
clearCapturedState: () => void;
|
clearCapturedState: () => void;
|
||||||
|
|
||||||
|
searchTracks: (query: string) => Promise<SpotifyTrack[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PKCEState {
|
export interface PKCEState {
|
||||||
|
|||||||
Reference in New Issue
Block a user