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 ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
||||
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 ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
||||
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
||||
@@ -146,6 +147,11 @@ const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
||||
path: '/api/spotify/token',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({
|
||||
id: '/api/spotify/search',
|
||||
path: '/api/spotify/search',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
||||
id: '/api/spotify/resume',
|
||||
path: '/api/spotify/resume',
|
||||
@@ -310,6 +316,7 @@ export interface FileServerRoutesByFullPath {
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
@@ -322,6 +329,7 @@ export interface FileServerRoutesByTo {
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
@@ -335,6 +343,7 @@ export interface FileServerRoutesById {
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
@@ -349,6 +358,7 @@ export interface FileServerRouteTypes {
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
@@ -361,6 +371,7 @@ export interface FileServerRouteTypes {
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
@@ -373,6 +384,7 @@ export interface FileServerRouteTypes {
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
@@ -386,6 +398,7 @@ export interface RootServerRouteChildren {
|
||||
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
||||
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
||||
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
||||
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
|
||||
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
||||
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||
@@ -538,6 +551,13 @@ declare module '@tanstack/react-start/server' {
|
||||
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
||||
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': {
|
||||
id: '/api/spotify/resume'
|
||||
path: '/api/spotify/resume'
|
||||
@@ -651,6 +671,7 @@ const rootServerRouteChildren: RootServerRouteChildren = {
|
||||
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
||||
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
||||
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
||||
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute,
|
||||
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
||||
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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 = {
|
||||
...authState,
|
||||
currentTrack,
|
||||
@@ -415,6 +433,8 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
capturePlaybackState,
|
||||
resumePlaybackState,
|
||||
clearCapturedState,
|
||||
// Search
|
||||
searchTracks,
|
||||
};
|
||||
|
||||
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> = {
|
||||
initialValues: {
|
||||
name: initialValues?.name || "",
|
||||
primary_color: initialValues?.primary_color,
|
||||
accent_color: initialValues?.accent_color,
|
||||
// primary_color: initialValues?.primary_color,
|
||||
// accent_color: initialValues?.accent_color,
|
||||
song_id: initialValues?.song_id,
|
||||
song_name: initialValues?.song_name,
|
||||
song_artist: initialValues?.song_artist,
|
||||
song_album: initialValues?.song_album,
|
||||
song_year: initialValues?.song_year,
|
||||
song_start: initialValues?.song_start,
|
||||
song_end: initialValues?.song_end,
|
||||
song_image_url: initialValues?.song_image_url,
|
||||
logo: undefined,
|
||||
players: initialValues?.players || []
|
||||
players: initialValues?.players || [],
|
||||
},
|
||||
onSubmitPreventDefault: "always",
|
||||
validate: {
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
const { mutate: createTournament, isPending: createPending } = useCreateTeam();
|
||||
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(teamId!);
|
||||
const { mutate: createTournament, isPending: createPending } =
|
||||
useCreateTeam();
|
||||
const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam(
|
||||
teamId!
|
||||
);
|
||||
|
||||
const isPending = createPending || updatePending;
|
||||
|
||||
@@ -67,9 +98,6 @@ const TeamForm = ({
|
||||
const { logo, ...teamData } = values;
|
||||
|
||||
const mutation = isEditMode ? updateTournament : createTournament;
|
||||
const successMessage = isEditMode
|
||||
? "Team updated successfully!"
|
||||
: "Team created successfully!";
|
||||
const errorMessage = isEditMode
|
||||
? "Failed to update team"
|
||||
: "Failed to create team";
|
||||
@@ -102,8 +130,6 @@ const TeamForm = ({
|
||||
tournamentKeys.details(result.team!.id),
|
||||
result.team
|
||||
);
|
||||
|
||||
toast.success(successMessage);
|
||||
} catch (error: any) {
|
||||
const logoErrorMessage = isEditMode
|
||||
? `Team updated but logo upload failed: ${error.message}`
|
||||
@@ -112,16 +138,12 @@ const TeamForm = ({
|
||||
logger.error("Team logo upload error", error);
|
||||
}
|
||||
} else {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
close();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`${errorMessage}: ${error.message}`);
|
||||
logger.error(
|
||||
`Team ${isEditMode ? "update" : "create"} error`,
|
||||
error
|
||||
);
|
||||
logger.error(`Team ${isEditMode ? "update" : "create"} error`, error);
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -130,7 +152,7 @@ const TeamForm = ({
|
||||
|
||||
return (
|
||||
<SlidePanel
|
||||
onSubmit={form.onSubmit((values) => console.log(values))}
|
||||
onSubmit={form.onSubmit(handleSubmit)}
|
||||
onCancel={close}
|
||||
submitText={isEditMode ? "Update Team" : "Create Team"}
|
||||
cancelText="Cancel"
|
||||
@@ -140,6 +162,7 @@ const TeamForm = ({
|
||||
<TextInput
|
||||
label="Name"
|
||||
withAsterisk
|
||||
disabled={isEditMode}
|
||||
key={form.key("name")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
@@ -152,114 +175,52 @@ const TeamForm = ({
|
||||
{...form.getInputProps("logo")}
|
||||
/>
|
||||
|
||||
{
|
||||
tournamentId && (
|
||||
<PlayersPicker
|
||||
tournamentId={tournamentId}
|
||||
key={form.key("players")}
|
||||
{...form.getInputProps("players")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<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,
|
||||
})
|
||||
}
|
||||
{tournamentId && (
|
||||
<PlayersPicker
|
||||
tournamentId={tournamentId}
|
||||
key={form.key("players")}
|
||||
{...form.getInputProps("players")}
|
||||
disabled={isEditMode}
|
||||
/>
|
||||
)}
|
||||
*/}
|
||||
|
||||
<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>
|
||||
</SlidePanel>
|
||||
);
|
||||
|
||||
@@ -42,18 +42,20 @@ const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }:
|
||||
<Autocomplete
|
||||
label="Player 1"
|
||||
data={options}
|
||||
disabled={!isAdmin || disabled}
|
||||
disabled
|
||||
value={playerOne}
|
||||
onChange={setPlayerOne}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
<Autocomplete
|
||||
label="Player 2"
|
||||
data={options}
|
||||
disabled={disabled && !isAdmin}
|
||||
disabled={disabled}
|
||||
value={playerTwo}
|
||||
onChange={setPlayerTwo}
|
||||
withAsterisk
|
||||
error={error}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,56 +1,149 @@
|
||||
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 { 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 {
|
||||
song_id: string;
|
||||
song_name: string;
|
||||
song_artist: string;
|
||||
song_album: string;
|
||||
song_year?: number;
|
||||
song_start?: number;
|
||||
song_end?: number;
|
||||
song_image_url: string;
|
||||
}
|
||||
|
||||
interface SongPickerProps {
|
||||
form: UseFormReturnType<TeamInput>
|
||||
form: UseFormReturnType<TeamInput>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SongPicker = ({ form }: SongPickerProps) => {
|
||||
const [song, setSong] = useState<Song>();
|
||||
|
||||
useEffect(() => {
|
||||
const SongPicker = ({ form, error }: SongPickerProps) => {
|
||||
const currentSong = useMemo((): Song | undefined => {
|
||||
const values = form.getValues();
|
||||
|
||||
setSong({
|
||||
song_id: values.song_id || "",
|
||||
song_name: values.song_name || "",
|
||||
song_artist: values.song_artist || "",
|
||||
song_album: values.song_album || "",
|
||||
song_year: values.song_year,
|
||||
song_start: values.song_start,
|
||||
song_end: values.song_end,
|
||||
song_image_url: values.song_image_url || "",
|
||||
})
|
||||
}, []);
|
||||
if (values.song_id && values.song_name) {
|
||||
return {
|
||||
song_id: values.song_id,
|
||||
song_name: values.song_name,
|
||||
song_artist: values.song_artist || "",
|
||||
song_album: values.song_album || "",
|
||||
song_start: values.song_start,
|
||||
song_end: values.song_end,
|
||||
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 (
|
||||
<SlidePanelField
|
||||
key={"song-picker"}
|
||||
value={""}
|
||||
onChange={console.log}
|
||||
Component={() => (
|
||||
<Stack>
|
||||
<Text>Song picker</Text>
|
||||
</Stack>
|
||||
)}
|
||||
value={currentSong}
|
||||
formatValue={(song) => <SongSummary song={song} />}
|
||||
onChange={(updatedSong: Song) => {
|
||||
if (updatedSong) {
|
||||
form.setValues({
|
||||
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"}
|
||||
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;
|
||||
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_artist: string;
|
||||
song_album: string;
|
||||
song_year: number;
|
||||
song_start: number;
|
||||
song_end: number;
|
||||
song_image_url: string;
|
||||
@@ -47,7 +46,6 @@ export const teamInputSchema = z
|
||||
song_name: z.string().max(255).optional(),
|
||||
song_artist: 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_end: z.number().int().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_artist: 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_end: z.number().int().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 { useAuth } from "@/contexts/auth-context";
|
||||
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 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 {
|
||||
tournamentId: string;
|
||||
@@ -16,7 +19,20 @@ const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
||||
|
||||
const hasTeams = useMemo(() => !!user?.teams?.length, [user?.teams]);
|
||||
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 { 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 => ({
|
||||
@@ -26,11 +42,34 @@ const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
||||
[user?.teams]
|
||||
);
|
||||
|
||||
const handleSelect = useCallback((teamId: string | undefined) => {
|
||||
setSelectedTeamId(teamId);
|
||||
setShowTeamForm(true);
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedTeamId(undefined);
|
||||
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 (
|
||||
<>
|
||||
@@ -38,9 +77,14 @@ const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
|
||||
Enroll Your Team
|
||||
</Button>
|
||||
|
||||
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={!isOpen} onChange={toggle}>
|
||||
<Sheet title={showTeamForm ? "Team Details" : "Enroll Team"} opened={isOpen} onChange={toggle}>
|
||||
{showTeamForm ? (
|
||||
<TeamForm close={console.log} tournamentId={tournamentId} />
|
||||
<TeamForm
|
||||
close={handleBack}
|
||||
tournamentId={tournamentId}
|
||||
initialValues={initialValues}
|
||||
teamId={selectedTeamId}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TeamSelectionView
|
||||
|
||||
@@ -11,9 +11,8 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
||||
options,
|
||||
onSelect
|
||||
}) => {
|
||||
const [value, setValue] = useState<string>();
|
||||
const selectedOption = useMemo(() => options.find(option => option.label === value), [value])
|
||||
|
||||
const [value, setValue] = useState<string>('');
|
||||
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
|
||||
|
||||
const handleCreateNewTeamClicked = () => onSelect(undefined);
|
||||
const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
|
||||
@@ -34,7 +33,7 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
||||
<Stack gap="sm">
|
||||
<Autocomplete
|
||||
placeholder="Select one of your existing teams"
|
||||
value={selectedOption?.label || ''}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
data={options.map(option => option.label)}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
|
||||
@@ -107,7 +107,6 @@ export function transformTeam(record: any): Team {
|
||||
song_name: record.song_name,
|
||||
song_artist: record.song_artist,
|
||||
song_album: record.song_album,
|
||||
song_year: record.song_year,
|
||||
song_start: record.song_start,
|
||||
song_end: 0,
|
||||
song_image_url: record.song_image_url,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SpotifyPlaybackState,
|
||||
SpotifyPlaybackSnapshot,
|
||||
SpotifyError,
|
||||
SpotifyTrack,
|
||||
} from './types';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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 {
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ export interface SpotifyContextType extends SpotifyAuthState {
|
||||
capturePlaybackState: () => Promise<void>;
|
||||
resumePlaybackState: () => Promise<void>;
|
||||
clearCapturedState: () => void;
|
||||
|
||||
searchTracks: (query: string) => Promise<SpotifyTrack[]>;
|
||||
}
|
||||
|
||||
export interface PKCEState {
|
||||
|
||||
Reference in New Issue
Block a user