spotify controls
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
import { AuthProvider } from "@/contexts/auth-context"
|
||||
import { SpotifyProvider } from "@/contexts/spotify-context"
|
||||
import MantineProvider from "@/lib/mantine/mantine-provider"
|
||||
import { Toaster } from "sonner"
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<MantineProvider>
|
||||
<Toaster position='top-center' />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
<SpotifyProvider>
|
||||
<MantineProvider>
|
||||
<Toaster position='top-center' />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</SpotifyProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
2
src/features/spotify/components/index.ts
Normal file
2
src/features/spotify/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SpotifyControlsBar } from './spotify-controls-bar';
|
||||
export { default as SpotifySheet } from './spotify-sheet';
|
||||
179
src/features/spotify/components/spotify-controls-bar.tsx
Normal file
179
src/features/spotify/components/spotify-controls-bar.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { ActionIcon, Box, Group, Loader, Text, Tooltip } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
SkipBackIcon,
|
||||
SkipForwardIcon,
|
||||
GearIcon,
|
||||
SpotifyLogoIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useSpotify } from '@/lib/spotify/hooks';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import SpotifySheet from './spotify-sheet';
|
||||
|
||||
const SpotifyControlsBar = () => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false);
|
||||
|
||||
const {
|
||||
isAuthenticated,
|
||||
playbackState,
|
||||
currentTrack,
|
||||
isLoading,
|
||||
error,
|
||||
play,
|
||||
pause,
|
||||
skipNext,
|
||||
skipPrevious,
|
||||
activeDevice,
|
||||
} = useSpotify();
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Box py="md" mb="md">
|
||||
<Group justify="center" gap="sm">
|
||||
<SpotifyLogoIcon size={24} color="var(--mantine-color-green-6)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Connect Spotify to control music during tournaments
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="lg"
|
||||
onClick={openSheet}
|
||||
loading={isLoading}
|
||||
>
|
||||
<GearIcon size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<SpotifySheet opened={sheetOpened} onClose={closeSheet} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isPlaying = playbackState?.is_playing || false;
|
||||
const hasActiveDevice = !!activeDevice;
|
||||
|
||||
return (
|
||||
<Box py="md" mb="md">
|
||||
<Group justify="center" gap="md" align="center">
|
||||
{currentTrack && (
|
||||
<Group gap="sm" style={{ maxWidth: 400 }}>
|
||||
{currentTrack.album.images[2] && (
|
||||
<img
|
||||
src={currentTrack.album.images[2].url}
|
||||
alt={currentTrack.album.name}
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 4,
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{currentTrack.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentTrack.artists.map(a => a.name).join(', ')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentTrack.album.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
<Tooltip label="Previous track">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="lg"
|
||||
onClick={skipPrevious}
|
||||
disabled={!hasActiveDevice || isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
<SkipBackIcon size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={isPlaying ? 'Pause' : 'Play'}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="green"
|
||||
size="xl"
|
||||
onClick={() => isPlaying ? pause() : play()}
|
||||
disabled={!hasActiveDevice || isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isPlaying ? <PauseIcon size={24} /> : <PlayIcon size={24} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Next track">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="lg"
|
||||
onClick={skipNext}
|
||||
disabled={!hasActiveDevice || isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
<SkipForwardIcon size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
{activeDevice && (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
Playing on {activeDevice.name}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Tooltip label="Spotify settings">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="green"
|
||||
size="lg"
|
||||
onClick={openSheet}
|
||||
>
|
||||
<GearIcon size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{isLoading && (
|
||||
<Loader size="sm" color="green" />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Group justify="center" mt="xs">
|
||||
<Text size="xs" c="red">
|
||||
{error}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{isAuthenticated && !hasActiveDevice && !isLoading && (
|
||||
<Group justify="center" mt="xs">
|
||||
<Text size="xs" c="orange">
|
||||
No active device. Please select a device in settings.
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<SpotifySheet opened={sheetOpened} onClose={closeSheet} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotifyControlsBar;
|
||||
197
src/features/spotify/components/spotify-sheet.tsx
Normal file
197
src/features/spotify/components/spotify-sheet.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
NativeSelect,
|
||||
Stack,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
SpotifyLogoIcon,
|
||||
DevicesIcon,
|
||||
SignOutIcon,
|
||||
ArrowsClockwiseIcon
|
||||
} from '@phosphor-icons/react';
|
||||
import { useSpotify } from '@/lib/spotify/hooks';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import Sheet from '@/components/sheet/sheet';
|
||||
|
||||
interface SpotifySheetProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
|
||||
const {
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
devices,
|
||||
activeDevice,
|
||||
currentTrack,
|
||||
getDevices,
|
||||
setActiveDevice,
|
||||
isLoading,
|
||||
error,
|
||||
} = useSpotify();
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const handleDeviceChange = (deviceId: string) => {
|
||||
if (deviceId && deviceId !== activeDevice?.id) {
|
||||
setActiveDevice(deviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDevices = () => {
|
||||
getDevices();
|
||||
};
|
||||
|
||||
const handleChange = (opened: boolean) => {
|
||||
if (!opened) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
opened={opened}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Group gap="sm" justify="center">
|
||||
<SpotifyLogoIcon size={32} color="var(--mantine-color-green-6)" />
|
||||
<Title order={3}>Spotify Controls</Title>
|
||||
</Group>
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<>
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Connect your Spotify account
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
leftSection={<SpotifyLogoIcon size={20} />}
|
||||
color="green"
|
||||
size="lg"
|
||||
onClick={login}
|
||||
loading={isLoading}
|
||||
fullWidth
|
||||
>
|
||||
Connect with Spotify
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Current track display */}
|
||||
{currentTrack && (
|
||||
<>
|
||||
<Box>
|
||||
<Title order={5} mb="xs">Now Playing</Title>
|
||||
<Group gap="md">
|
||||
{currentTrack.album.images[2] && (
|
||||
<img
|
||||
src={currentTrack.album.images[2].url}
|
||||
alt={currentTrack.album.name}
|
||||
style={{ width: 64, height: 64, borderRadius: 4 }}
|
||||
/>
|
||||
)}
|
||||
<Box flex={1}>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{currentTrack.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentTrack.artists.map(a => a.name).join(', ')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentTrack.album.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Title order={5}>
|
||||
<Group gap="xs">
|
||||
<DevicesIcon size={20} />
|
||||
Select Device
|
||||
</Group>
|
||||
</Title>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={refreshDevices}
|
||||
loading={isLoading}
|
||||
>
|
||||
<ArrowsClockwiseIcon size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{devices.length > 0 ? (
|
||||
<NativeSelect
|
||||
value={activeDevice?.id || ''}
|
||||
onChange={(event) => handleDeviceChange(event.currentTarget.value)}
|
||||
data={[
|
||||
{ value: '', label: 'Select a device...' },
|
||||
...devices.map(device => ({
|
||||
value: device.id,
|
||||
label: `${device.name} ${device.is_active ? '(Active)' : ''} - ${device.type}`,
|
||||
})),
|
||||
]}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No devices found.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{activeDevice && (
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Active device: {activeDevice.name}
|
||||
{activeDevice.volume_percent !== null &&
|
||||
` (Volume: ${activeDevice.volume_percent}%)`
|
||||
}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box>
|
||||
<Text size="sm" c="red">
|
||||
{error}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Button
|
||||
leftSection={<SignOutIcon size={18} />}
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={logout}
|
||||
fullWidth
|
||||
>
|
||||
Disconnect Spotify
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotifySheet;
|
||||
@@ -6,11 +6,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const listTeams = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(() => pbAdmin.listTeams())
|
||||
);
|
||||
|
||||
export const listTeamInfos = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
@@ -40,22 +35,10 @@ export const createTeam = createServerFn()
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
|
||||
// Check if user is trying to create a team with themselves as a player
|
||||
if (!isAdmin && !data.players.includes(userId)) {
|
||||
throw new Error("You can only create teams that include yourself as a player");
|
||||
}
|
||||
|
||||
// Additional validation: ensure user is not already on another team
|
||||
if (!isAdmin) {
|
||||
const userTeams = await pbAdmin.listTeams();
|
||||
const existingTeam = userTeams.find(team =>
|
||||
team.players.some(player => player.id === userId)
|
||||
);
|
||||
if (existingTeam) {
|
||||
throw new Error(`You are already a member of team "${existingTeam.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Creating team", { name: data.name, userId, isAdmin });
|
||||
return pbAdmin.createTeam(data);
|
||||
})
|
||||
@@ -88,10 +71,3 @@ export const updateTeam = createServerFn()
|
||||
return pbAdmin.updateTeam(id, updates);
|
||||
})
|
||||
);
|
||||
|
||||
export const deleteTeam = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.deleteTeam(teamId))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user