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

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);
}, []);
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) {

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> = {
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>
);

View File

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

View File

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

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

View File

@@ -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;
@@ -13,12 +16,25 @@ interface EnrollTeamProps {
const EnrollTeam = ({ tournamentId }: EnrollTeamProps) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
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 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 => ({
value: team.id,
label: team.name
@@ -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

View File

@@ -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)
@@ -28,19 +27,19 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
>
Create New Team
</Button>
<Divider my="sm" label="or" />
<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 }}
/>
<Button
<Button
onClick={handleSelectExistingTeam}
disabled={!selectedOption}
fullWidth

View File

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

View File

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

View File

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