working team update/create (still need enroll)

This commit is contained in:
yohlo
2025-09-16 13:24:39 -05:00
parent cde74a04d5
commit c170e1e1fe
16 changed files with 845 additions and 175 deletions

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

View File

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

View 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' },
}
)
}
},
})

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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