add initial files

This commit is contained in:
jeffvli
2022-07-25 19:40:16 -07:00
commit e8b612c974
283 changed files with 62820 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export * from './routes/LoginRoute';
@@ -0,0 +1,38 @@
import md5 from 'md5';
import { nanoid } from 'nanoid';
import { useMutation } from 'react-query';
import { authApi } from 'renderer/api/authApi';
import { useAuthStore } from 'renderer/store';
export const useLogin = (
serverUrl: string,
body: {
password: string;
username: string;
}
) => {
const login = useAuthStore((state) => state.login);
return useMutation({
mutationFn: () => authApi.login(serverUrl, body),
onSuccess: (res) => {
const key = md5(serverUrl);
login({ key, serverUrl });
if (!localStorage.getItem('device_id')) {
localStorage.setItem('device_id', nanoid());
}
localStorage.setItem(
'authentication',
JSON.stringify({
accessToken: res.data.accessToken,
isAuthenticated: true,
key,
refreshToken: res.data.refreshToken,
serverUrl,
})
);
},
});
};
@@ -0,0 +1,12 @@
import { useQuery } from 'react-query';
import { authApi } from 'renderer/api/authApi';
import { queryKeys } from 'renderer/api/queryKeys';
export const usePingServer = (server: string) => {
return useQuery({
enabled: !!server,
queryFn: () => authApi.ping(server),
queryKey: queryKeys.ping(server),
retry: false,
});
};
@@ -0,0 +1,19 @@
.container {
min-width: 400px;
max-width: 50%;
margin: auto;
padding: 3rem;
background: rgba(50, 50, 50, 0.4);
border-radius: 5px;
}
.button {
background: var(--primary-color);
&:hover,
&:active,
&:focus {
background: var(--primary-color);
filter: brightness(1.1);
}
}
@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import {
TextInput,
PasswordInput,
Button,
Stack,
Title,
Loader,
Alert,
} from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { AlertCircle, CircleCheck } from 'tabler-icons-react';
import { normalizeServerUrl } from 'renderer/utils';
import { useLogin } from '../queries/useLogin';
import { usePingServer } from '../queries/usePingServer';
import styles from './LoginRoute.module.scss';
export const LoginRoute = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [username, setUsername] = useState(searchParams.get('username') || '');
const [password, setPassword] = useState(searchParams.get('password') || '');
const [server, setServer] = useState(
searchParams.get('server') || 'http://localhost:9321'
);
const [debouncedServer] = useDebouncedValue(server, 500);
const {
mutate: handleLogin,
isLoading,
isError,
} = useLogin(normalizeServerUrl(server), {
password,
username,
});
const {
isLoading: isCheckingServer,
isSuccess: isValidServer,
isFetched,
} = usePingServer(normalizeServerUrl(debouncedServer));
return (
<div className={styles.container}>
<Title>{t('auth.login')}</Title>
<form
onSubmit={(e) => {
e.preventDefault();
handleLogin(undefined, {
onError: () => {},
onSuccess: () => {},
});
}}
>
<Stack spacing="md">
<TextInput
required
disabled={isLoading}
error={!isValidServer && isFetched}
label={`${t('auth.server.label')}`}
placeholder={`${t('auth.server.placeholder')}`}
rightSection={
isCheckingServer ? (
<Loader size="xs" />
) : isValidServer ? (
<CircleCheck />
) : null
}
value={server}
variant="default"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setServer(e.currentTarget.value)
}
/>
<TextInput
required
disabled={isLoading}
label={`${t('auth.username.label')}`}
placeholder={`${t('auth.username.placeholder')}`}
value={username}
variant="default"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setUsername(e.currentTarget.value)
}
/>
<PasswordInput
required
disabled={isLoading}
label={`${t('auth.password.label')}`}
placeholder={`${t('auth.password.placeholder')}`}
value={password}
variant="default"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setPassword(e.currentTarget.value)
}
/>
<Button
className={styles.button}
disabled={!isValidServer}
type="submit"
>
Login
</Button>
{isError && (
<Alert color="red" icon={<AlertCircle />} variant="outline">
{t('Invalid username or password.')}
</Alert>
)}
</Stack>
</form>
</div>
);
};
+1
View File
@@ -0,0 +1 @@
export * from './routes/DashboardRoute';
@@ -0,0 +1,3 @@
export const DashboardRoute = () => {
return <></>;
};
@@ -0,0 +1,35 @@
import { ReactNode } from 'react';
import { motion } from 'framer-motion';
import { Link, LinkProps } from 'react-router-dom';
import styled from 'styled-components';
import { fontGotham } from 'renderer/styles';
interface LibraryTabProps extends LinkProps {
children: ReactNode;
}
const StyledLibraryTab = styled(motion.div)`
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: rgba(150, 150, 150, 30%);
}
`;
const TabLink = styled(Link)`
${fontGotham(60)}
color: #fff;
font-size: 1.2em;
`;
const LibraryTab = ({ children, ...rest }: LibraryTabProps) => {
return (
<TabLink {...rest}>
<StyledLibraryTab>{children}</StyledLibraryTab>
</TabLink>
);
};
export default LibraryTab;
@@ -0,0 +1,24 @@
import { useInfiniteQuery, useQuery } from 'react-query';
import { queryKeys } from 'renderer/api/queryKeys';
import { albumsApi, AlbumsRequest } from '../../../api/albumsApi';
export const useAlbums = (params: AlbumsRequest) => {
return useQuery({
queryFn: () => albumsApi.getAlbums(params),
queryKey: queryKeys.albums(),
});
};
export const useAlbumsInfinite = (params: AlbumsRequest) => {
return useInfiniteQuery({
getNextPageParam: (lastPage) => {
return !!lastPage.pagination.nextPage;
},
getPreviousPageParam: (firstPage) => {
return !!firstPage.pagination.prevPage;
},
queryFn: ({ pageParam }) =>
albumsApi.getAlbums({ ...(pageParam || params) }),
queryKey: queryKeys.albums(params),
});
};
@@ -0,0 +1,58 @@
/* eslint-disable no-plusplus */
import { useRef } from 'react';
import InfiniteLoader from 'react-window-infinite-loader';
import { albumsApi } from 'renderer/api/albumsApi';
import { VirtualInfiniteGrid } from 'renderer/components/virtual-grid/VirtualInfiniteGrid';
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
import { AppRoute } from 'renderer/router/utils/routes';
import { Item } from 'types';
import { useAlbums } from '../queries/getAlbums';
export const LibraryAlbumsRoute = () => {
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const params = {
orderBy: 'asc',
sortBy: 'title',
};
const { data: albums } = useAlbums({
limit: 0,
page: 0,
...params,
});
return (
<AnimatedPage>
{albums && (
<VirtualInfiniteGrid
ref={infiniteLoaderRef}
cardControls={{
endpoint: albumsApi.getAlbum,
idProperty: 'id',
type: Item.Album,
}}
cardRows={[
{
align: 'center',
prop: 'name',
route: {
prop: 'id',
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
},
},
{
align: 'center',
prop: 'year',
},
]}
itemCount={albums.pagination.totalEntries}
itemGap={20}
itemSize={180}
query={albumsApi.getAlbums}
queryParams={params}
/>
)}
</AnimatedPage>
);
};
@@ -0,0 +1,22 @@
/* eslint-disable no-plusplus */
import { useRef } from 'react';
import InfiniteLoader from 'react-window-infinite-loader';
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
import { useAlbums } from '../queries/getAlbums';
export const LibraryArtistsRoute = () => {
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const params = {
orderBy: 'asc',
sortBy: 'title',
};
const { data: albums } = useAlbums({
limit: 0,
page: 0,
...params,
});
return <AnimatedPage />;
};
@@ -0,0 +1,24 @@
import styled from 'styled-components';
import { AnimatedPage } from 'renderer/features/shared/components/AnimatedPage';
import { AppRoute } from 'renderer/router/utils/routes';
import LibraryTab from '../components/LibraryTab';
const TabContainer = styled.div`
display: flex;
flex-flow: row wrap;
gap: 1rem;
`;
export const LibraryRoute = () => {
return (
<AnimatedPage>
<TabContainer>
<LibraryTab to={AppRoute.LIBRARY_ALBUMS}>Albums</LibraryTab>
<LibraryTab to={AppRoute.LIBRARY_ALBUMARTISTS}>
Album Artists
</LibraryTab>
<LibraryTab to={AppRoute.LIBRARY_ARTISTS}>Artists</LibraryTab>
</TabContainer>
</AnimatedPage>
);
};
@@ -0,0 +1,178 @@
import { useEffect, useMemo, useState } from 'react';
import format from 'format-duration';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import {
PlayerPause,
PlayerPlay,
PlayerSkipBack,
PlayerSkipForward,
PlayerTrackNext,
PlayerTrackPrev,
} from 'tabler-icons-react';
import { Text } from 'renderer/components';
import { usePlayerStore } from 'renderer/store';
import { Font } from 'renderer/styles';
import { PlaybackType, PlayerStatus } from 'types';
import { useCenterControls } from '../hooks/useCenterControls';
import { PlayerButton } from './PlayerButton';
import { Slider } from './Slider';
interface CenterControlsProps {
playersRef: any;
}
const ControlsContainer = styled.div`
display: flex;
justify-content: center;
height: 35px;
`;
const ButtonsContainer = styled.div`
display: flex;
gap: 0.5rem;
align-items: center;
`;
const SliderContainer = styled.div`
display: flex;
height: 20px;
`;
const SliderValueWrapper = styled.div<{ position: 'left' | 'right' }>`
flex: 1;
align-self: center;
max-width: 50px;
text-align: center;
`;
const SliderWrapper = styled.div`
display: flex;
flex: 6;
height: 100%;
`;
export const CenterControls = ({ playersRef }: CenterControlsProps) => {
const { t } = useTranslation();
const [isSeeking, setIsSeeking] = useState(false);
const playerData = usePlayerStore((state) => state.getPlayerData());
const player1 = playersRef?.current?.player1?.player;
const player2 = playersRef?.current?.player2?.player;
const { status, player } = usePlayerStore((state) => state.current);
const settings = usePlayerStore((state) => state.settings);
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime);
const {
handleNextTrack,
handlePlayPause,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
} = useCenterControls({ playersRef });
const currentTime = usePlayerStore((state) => state.current.time);
const currentPlayerRef = player === 1 ? player1 : player2;
const duration = useMemo(
() => format((playerData.queue.current?.duration || 0) * 1000),
[playerData.queue]
);
const formattedTime = useMemo(
() => format(currentTime * 1000 || 0),
[currentTime]
);
useEffect(() => {
let interval: any;
if (status === PlayerStatus.Playing && !isSeeking) {
if (settings.type === PlaybackType.Web) {
interval = setInterval(() => {
setCurrentTime(currentPlayerRef.getCurrentTime());
}, 1000);
}
} else {
clearInterval(interval);
}
return () => clearInterval(interval);
}, [currentPlayerRef, isSeeking, setCurrentTime, settings.type, status]);
return (
<>
<ControlsContainer onScroll={(e) => console.log(e)}>
<ButtonsContainer>
<PlayerButton
icon={<PlayerSkipBack size={15} />}
tooltip={{ label: `${t('player.prev')}` }}
variant="secondary"
onClick={handlePrevTrack}
/>
<PlayerButton
icon={<PlayerTrackPrev size={15} />}
tooltip={{ label: `${t('player.skipBack')}` }}
variant="secondary"
onClick={handleSkipBackward}
/>
<PlayerButton
icon={
status === PlayerStatus.Paused ? (
<PlayerPlay size={20} />
) : (
<PlayerPause size={20} />
)
}
tooltip={{
label:
status === PlayerStatus.Paused
? `${t('player.play')}`
: `${t('player.pause')}`,
}}
variant="main"
onClick={handlePlayPause}
/>
<PlayerButton
icon={<PlayerTrackNext size={15} />}
tooltip={{ label: `${t('player.skipForward')}` }}
variant="secondary"
onClick={handleSkipForward}
/>
<PlayerButton
icon={<PlayerSkipForward size={15} />}
tooltip={{ label: `${t('player.next')}` }}
variant="secondary"
onClick={handleNextTrack}
/>
</ButtonsContainer>
</ControlsContainer>
<SliderContainer>
<SliderValueWrapper position="left">
<Text noSelect secondary font={Font.POPPINS} size="xs" weight={600}>
{formattedTime}
</Text>
</SliderValueWrapper>
<SliderWrapper>
<Slider
height="100%"
max={playerData.queue.current?.duration}
min={0}
tooltipType="time"
value={currentTime}
onAfterChange={(e) => {
handleSeekSlider(e);
setIsSeeking(false);
}}
/>
</SliderWrapper>
<SliderValueWrapper position="right">
<Text noSelect secondary font={Font.POPPINS} size="xs" weight={600}>
{duration}
</Text>
</SliderValueWrapper>
</SliderContainer>
</>
);
};
@@ -0,0 +1,76 @@
import { LazyLoadImage as Image } from 'react-lazy-load-image-component';
import styled from 'styled-components';
import { Text } from 'renderer/components';
import { usePlayerStore } from 'renderer/store';
import { Font } from 'renderer/styles';
const LeftControlsContainer = styled.div`
display: flex;
width: 100%;
height: 100%;
`;
const ImageWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
`;
const MetadataStack = styled.div`
display: flex;
flex-direction: column;
gap: 0.1rem;
justify-content: center;
width: 100%;
overflow: hidden;
`;
export const LeftControls = () => {
const song = usePlayerStore((state) => state.current.song);
const title = song?.title;
const artists = song?.artist?.map((artist) => artist?.title).join(', ');
const album = song?.album;
return (
<LeftControlsContainer>
<ImageWrapper>
<Image height={60} src={song?.image} width={60} />
</ImageWrapper>
<MetadataStack>
<Text
font={Font.POPPINS}
link={!!title}
overflow="hidden"
size="sm"
to="/nowplaying"
weight={500}
>
{title || '—'}
</Text>
<Text
secondary
font={Font.POPPINS}
link={!!artists}
overflow="hidden"
size="sm"
to="/nowplaying"
weight={500}
>
{artists || '—'}
</Text>
<Text
secondary
font={Font.POPPINS}
link={!!album}
overflow="hidden"
size="sm"
to="/nowplaying"
weight={500}
>
{album || '—'}
</Text>
</MetadataStack>
</LeftControlsContainer>
);
};
@@ -0,0 +1,131 @@
import { ReactNode } from 'react';
import {
TooltipProps,
UnstyledButton,
UnstyledButtonProps,
} from '@mantine/core';
import { motion } from 'framer-motion';
import styled, { css } from 'styled-components';
import { Tooltip } from 'renderer/components';
interface PlayerButtonProps extends UnstyledButtonProps<'button'> {
icon: ReactNode;
tooltip?: Omit<TooltipProps, 'children'>;
variant: 'main' | 'secondary';
}
const WrapperMainVariant = css`
margin: 0 0.5rem;
`;
type MotionWrapperProps = { variant: PlayerButtonProps['variant'] };
const MotionWrapper = styled(motion.div)<MotionWrapperProps>`
display: flex;
align-items: center;
justify-content: center;
${({ variant }) => variant === 'main' && WrapperMainVariant};
`;
const ButtonMainVariant = css`
padding: 0.5rem;
background: var(--playerbar-btn-color);
border-radius: 50%;
svg {
display: flex;
fill: black;
stroke: black;
}
&:hover {
background: var(--playerbar-btn-color-hover);
}
&:focus-visible {
background: var(--playerbar-btn-color-hover);
}
`;
const ButtonSecondaryVariant = css`
padding: 0.5rem;
svg {
display: flex;
fill: var(--playerbar-btn-color);
stroke: var(--playerbar-btn-color);
}
&:hover {
svg {
fill: var(--playerbar-btn-color-hover);
stroke: var(--playerbar-btn-color-hover);
}
}
&:focus-visible {
svg {
fill: var(--playerbar-btn-color-hover);
stroke: var(--playerbar-btn-color-hover);
}
}
`;
type StyledPlayerButtonProps = Omit<PlayerButtonProps, 'icon'>;
const StyledPlayerButton = styled(UnstyledButton)<StyledPlayerButtonProps>`
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem;
overflow: visible;
all: unset;
cursor: default;
button {
display: flex;
}
&:focus-visible {
outline: 1px var(--primary-color) solid;
}
&:disabled {
opacity: 0.5;
}
${({ variant }) =>
variant === 'main' ? ButtonMainVariant : ButtonSecondaryVariant}
`;
export const PlayerButton = ({
tooltip,
variant,
icon,
...rest
}: PlayerButtonProps) => {
if (tooltip) {
return (
<Tooltip {...tooltip}>
<MotionWrapper variant={variant}>
<StyledPlayerButton variant={variant} {...rest}>
{icon}
</StyledPlayerButton>
</MotionWrapper>
</Tooltip>
);
}
return (
<MotionWrapper variant={variant}>
<StyledPlayerButton variant={variant} {...rest}>
{icon}
</StyledPlayerButton>
</MotionWrapper>
);
};
PlayerButton.defaultProps = {
tooltip: undefined,
};
@@ -0,0 +1,85 @@
import { useRef } from 'react';
import styled from 'styled-components';
import { AudioPlayer } from 'renderer/components';
import { usePlayerStore } from 'renderer/store';
import { PlaybackType } from 'types';
import { CenterControls } from './CenterControls';
import { LeftControls } from './LeftControls';
import { RightControls } from './RightControls';
const PlayerbarContainer = styled.div`
width: 100%;
height: 100%;
border-top: var(--playerbar-border-top);
`;
const PlayerbarControlsGrid = styled.div`
display: flex;
gap: 1rem;
height: 100%;
`;
const RightGridItem = styled.div`
align-self: center;
width: calc(100% / 3);
height: 100%;
overflow: hidden;
`;
const LeftGridItem = styled.div`
width: calc(100% / 3);
height: 100%;
overflow: hidden;
`;
const CenterGridItem = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
width: calc(100% / 3);
height: 100%;
overflow: hidden;
`;
export const Playerbar = () => {
const playersRef = useRef<any>();
const settings = usePlayerStore((state) => state.settings);
const volume = usePlayerStore((state) => state.settings.volume);
const player1 = usePlayerStore((state) => state.player1());
const player2 = usePlayerStore((state) => state.player2());
const status = usePlayerStore((state) => state.current.status);
const player = usePlayerStore((state) => state.current.player);
const autoNext = usePlayerStore((state) => state.autoNext);
return (
<PlayerbarContainer>
<PlayerbarControlsGrid>
<LeftGridItem>
<LeftControls />
</LeftGridItem>
<CenterGridItem>
<CenterControls playersRef={playersRef} />
</CenterGridItem>
<RightGridItem>
<RightControls />
</RightGridItem>
</PlayerbarControlsGrid>
{settings.type === PlaybackType.Web && (
<AudioPlayer
ref={playersRef}
autoNext={autoNext}
crossfadeDuration={settings.crossfadeDuration}
crossfadeStyle={settings.crossfadeStyle}
currentPlayer={player}
muted={settings.muted}
player1={player1}
player2={player2}
status={status}
style={settings.style}
volume={volume}
/>
)}
</PlayerbarContainer>
);
};
@@ -0,0 +1,69 @@
import styled from 'styled-components';
import { Volume2, Volume3 } from 'tabler-icons-react';
import { IconButton } from 'renderer/components';
import { usePlayerStore } from 'renderer/store';
import { useRightControls } from '../hooks/useRightControls';
import { Slider } from './Slider';
const RightControlsContainer = styled.div`
display: flex;
width: 100%;
height: 100%;
padding: 0.5rem;
`;
const VolumeSliderWrapper = styled.div`
display: flex;
gap: 0.3rem;
align-items: center;
width: 90px;
`;
const MetadataStack = styled.div`
display: flex;
flex-direction: column;
gap: 0.3rem;
align-items: flex-end;
justify-content: center;
width: 100%;
overflow: visible;
`;
export const RightControls = () => {
const volume = usePlayerStore((state) => state.settings.volume);
const muted = usePlayerStore((state) => state.settings.muted);
const { handleVolumeSlider, handleVolumeSliderState, handleMute } =
useRightControls();
return (
<RightControlsContainer>
<MetadataStack>
<VolumeSliderWrapper>
<IconButton
icon={
muted ? (
<Volume3 size={20} strokeWidth={1.5} />
) : (
<Volume2 size={20} strokeWidth={1.5} />
)
}
size={20}
tooltip={{ label: muted ? 'Muted' : volume }}
variant="transparent"
onClick={handleMute}
/>
<Slider
hasTooltip
height="100%"
max={100}
min={0}
value={volume}
onAfterChange={handleVolumeSliderState}
onChange={handleVolumeSlider}
/>
</VolumeSliderWrapper>
</MetadataStack>
</RightControlsContainer>
);
};
@@ -0,0 +1,141 @@
import { useMemo, useState } from 'react';
import format from 'format-duration';
import ReactSlider, { ReactSliderProps } from 'react-slider';
import styled from 'styled-components';
interface SliderProps extends ReactSliderProps {
hasTooltip?: boolean;
height: string;
tooltipType?: 'text' | 'time';
}
const StyledSlider = styled(ReactSlider)<SliderProps | any>`
width: 100%;
height: ${(props) => props.height};
outline: none;
.thumb {
top: 37%;
opacity: 1;
&::after {
position: absolute;
top: -25px;
left: -18px;
display: ${(props) =>
props.$isDragging && props.$hasToolTip ? 'block' : 'none'};
padding: 2px 6px;
color: var(--tooltip-text-color);
white-space: nowrap;
background: var(--tooltip-bg);
border-radius: 4px;
content: attr(data-tooltip);
}
&:focus-visible {
width: 13px;
height: 13px;
text-align: center;
background-color: #fff;
border: 1px var(--primary-color) solid;
border-radius: 100%;
outline: none;
transform: translate(-12px, -4px);
opacity: 1;
}
}
.track-0 {
background: ${(props) => props.$isDragging && 'var(--primary-color)'};
transition: background 0.2s ease-in-out;
}
.track {
top: 37%;
border-radius: 5px;
}
&:hover {
.track-0 {
background: var(--primary-color);
}
}
`;
const MemoizedThumb = ({ props, state, toolTipType }: any) => {
const { value } = state;
const formattedValue = useMemo(() => {
if (toolTipType === 'text') {
return value;
}
return format(value * 1000);
}, [toolTipType, value]);
return <div {...props} data-tooltip={formattedValue} />;
};
const StyledTrack = styled.div<any>`
top: 0;
bottom: 0;
height: 5px;
background: ${(props) =>
props.index === 1
? 'var(--playerbar-slider-track-bg)'
: 'var(--playerbar-slider-track-progress-bg)'};
`;
const Track = (props: any, state: any) => (
// eslint-disable-next-line react/destructuring-assignment
<StyledTrack {...props} index={state.index} />
);
const Thumb = (props: any, state: any, toolTipType: any) => (
<MemoizedThumb
key="slider"
props={props}
state={state}
tabIndex={0}
toolTipType={toolTipType}
/>
);
export const Slider = ({
height,
tooltipType: toolTipType,
hasTooltip: hasToolTip,
...rest
}: SliderProps) => {
const [isDragging, setIsDragging] = useState(false);
return (
<StyledSlider
{...rest}
$hasToolTip={hasToolTip}
$isDragging={isDragging}
className="player-slider"
defaultValue={0}
height={height}
renderThumb={(props: any, state: any) => {
return Thumb(props, state, toolTipType);
}}
renderTrack={Track}
onAfterChange={(e: number, index: number) => {
if (rest.onAfterChange) {
rest.onAfterChange(e, index);
}
setIsDragging(false);
}}
onBeforeChange={(e: number, index: number) => {
if (rest.onBeforeChange) {
rest.onBeforeChange(e, index);
}
setIsDragging(true);
}}
/>
);
};
Slider.defaultProps = {
hasTooltip: true,
tooltipType: 'text',
};
@@ -0,0 +1,180 @@
import { useCallback } from 'react';
import { usePlayerStore } from 'renderer/store';
import { PlaybackType, PlayerStatus } from '../../../../types';
import { mpvPlayer } from '../utils/mpvPlayer';
export const useCenterControls = (args: { playersRef: any }) => {
const { playersRef } = args;
const settings = usePlayerStore((state) => state.settings);
const play = usePlayerStore((state) => state.play);
const pause = usePlayerStore((state) => state.pause);
const prev = usePlayerStore((state) => state.prev);
const next = usePlayerStore((state) => state.next);
const queue = usePlayerStore((state) => state.queue.default);
const playerStatus = usePlayerStore((state) => state.current.status);
const currentPlayer = usePlayerStore((state) => state.current.player);
const currentTime = usePlayerStore((state) => state.current.time);
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime);
const player1Ref = playersRef?.current?.player1;
const player2Ref = playersRef?.current?.player2;
const currentPlayerRef = currentPlayer === 1 ? player1Ref : player2Ref;
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
const resetPlayers = useCallback(() => {
player1Ref.getInternalPlayer().currentTime = 0;
player2Ref.getInternalPlayer().currentTime = 0;
player1Ref.getInternalPlayer().pause();
player2Ref.getInternalPlayer().pause();
}, [player1Ref, player2Ref]);
const resetNextPlayer = useCallback(() => {
currentPlayerRef.getInternalPlayer().volume = 0.1;
nextPlayerRef.getInternalPlayer().currentTime = 0;
nextPlayerRef.getInternalPlayer().pause();
}, [currentPlayerRef, nextPlayerRef]);
const stopPlayback = () => {
player1Ref.getInternalPlayer().pause();
player2Ref.getInternalPlayer().pause();
resetPlayers();
};
const handlePlay = useCallback(() => {
if (settings.type === PlaybackType.Local) {
mpvPlayer.play();
} else {
currentPlayerRef.getInternalPlayer().play();
}
play();
}, [currentPlayerRef, play, settings]);
const handlePause = useCallback(() => {
if (settings.type === PlaybackType.Local) {
mpvPlayer.pause();
}
pause();
}, [pause, settings]);
const handleStop = () => {
if (settings.type === PlaybackType.Local) {
mpvPlayer.stop();
} else {
stopPlayback();
}
setCurrentTime(0);
pause();
};
const handleNextTrack = useCallback(() => {
const playerData = next();
if (settings.type === PlaybackType.Local) {
mpvPlayer.setQueue(playerData);
mpvPlayer.next();
} else {
resetPlayers();
}
setCurrentTime(0);
}, [next, resetPlayers, setCurrentTime, settings]);
const handlePrevTrack = useCallback(() => {
const playerData = prev();
if (settings.type === PlaybackType.Local) {
mpvPlayer.setQueue(playerData);
mpvPlayer.previous();
} else {
resetPlayers();
}
setCurrentTime(0);
}, [prev, resetPlayers, setCurrentTime, settings]);
const handlePlayPause = useCallback(() => {
if (queue) {
if (playerStatus === PlayerStatus.Paused) {
return handlePlay();
}
return handlePause();
}
return null;
}, [handlePause, handlePlay, playerStatus, queue]);
const handleSkipBackward = () => {
const skipBackwardSec = 5;
if (settings.type === PlaybackType.Local) {
const newTime = currentTime - skipBackwardSec;
mpvPlayer.seek(-skipBackwardSec);
setCurrentTime(newTime < 0 ? 0 : newTime);
} else {
const newTime = currentPlayerRef.getCurrentTime() - skipBackwardSec;
resetNextPlayer();
setCurrentTime(newTime);
currentPlayerRef.seekTo(newTime);
}
};
const handleSkipForward = () => {
const skipForwardSec = 5;
if (settings.type === PlaybackType.Local) {
const newTime = currentTime + skipForwardSec;
mpvPlayer.seek(skipForwardSec);
setCurrentTime(newTime);
} else {
const checkNewTime = currentPlayerRef.getCurrentTime() + skipForwardSec;
const songDuration = currentPlayerRef.player.player.duration;
const newTime =
checkNewTime >= songDuration ? songDuration - 1 : checkNewTime;
resetNextPlayer();
setCurrentTime(newTime);
currentPlayerRef.seekTo(newTime);
}
};
const handleSeekSlider = useCallback(
(e: number | any) => {
setCurrentTime(e);
if (settings.type === PlaybackType.Local) {
mpvPlayer.seekTo(e);
} else {
currentPlayerRef.seekTo(e);
}
},
[currentPlayerRef, setCurrentTime, settings]
);
// const handleVolumeSlider = useCallback(
// (e: number | any) => {
// // dispatch(setVolume(e));
// if (settings.type === PlaybackType.Local) {
// // playerApi.volume(currentTime, e);
// }
// setSettings({ volume: (e / 100) ** 2 });
// },
// [currentTime, setSettings, settings]
// );
return {
handleNextTrack,
handlePlayPause,
handlePrevTrack,
handleSeekSlider,
handleSkipBackward,
handleSkipForward,
handleStop,
};
};
@@ -0,0 +1,135 @@
import { useQueryClient } from 'react-query';
import { usePlayerStore } from 'renderer/store';
import {
getJellyfinStreamUrl,
getServerFolderAuth,
getSubsonicStreamUrl,
} from 'renderer/utils';
import { Item, Play } from 'types';
import { mpvPlayer } from '../utils/mpvPlayer';
const getEndpoint = (item: Item) => {
switch (item) {
case Item.Album:
return 'getAlbum';
case Item.Artist:
return 'getArtistSongs';
case Item.Playlist:
return 'getPlaylist';
default:
return 'getAlbum';
}
};
export const usePlayQueueHandler = () => {
const queryClient = useQueryClient();
const addQueue = usePlayerStore((state) => state.add);
// const dispatchSongsToQueue = useCallback(
// (entries: Song[], play: Play) => {
// const filteredSongs = filterPlayQueue(config.playback.filters, entries);
// if (play === Play.Play) {
// if (filteredSongs.entries.length > 0) {
// } else {
// }
// }
// if (play === Play.Next || play === Play.Later) {
// if (filteredSongs.entries.length > 0) {
// }
// }
// notifyToast(
// 'info',
// getPlayedSongsNotification({
// ...filteredSongs.count,
// type: play === Play.Play ? 'play' : 'add',
// })
// );
// },
// [config.playback.filters, dispatch]
// );
const handlePlayQueueAdd = async (options: {
byData?: any[];
byItemType?: {
endpoint: (params: Record<string, any>) => any;
id: string;
type: Item;
};
play: Play;
}) => {
if (options.byData) {
// dispatchSongsToQueue(options.byData, options.play);
}
if (options.byItemType) {
const deviceId = localStorage.getItem('device_id');
const { serverUrl } = JSON.parse(
localStorage.getItem('authentication') || '{}'
);
if (deviceId) {
const data = await options.byItemType.endpoint({
id: options.byItemType.id,
});
const songs = data.data.songs.map((song) => {
const auth = getServerFolderAuth(serverUrl, song.serverFolderId);
if (auth) {
const streamUrl =
auth.type === 'jellyfin'
? getJellyfinStreamUrl(auth, song, deviceId)
: getSubsonicStreamUrl(auth, song, deviceId);
return {
albumId: song.albumId,
artistName: song.artistName,
duration: song.duration,
id: song.id,
streamUrl,
title: song.name,
year: song.year,
};
}
return {
albumId: song.albumId,
artistName: song.artistName,
duration: song.duration,
id: song.id,
streamUrl: song.streamUrl,
title: song.name,
year: song.year,
};
});
const pData = addQueue(songs);
mpvPlayer.setQueue(pData);
}
// const data = await apiController({
// args: { id: options.byItemType.id, musicFolder: options.musicFolder },
// endpoint:
// options.byItemType.endpoint || getEndpoint(options.byItemType.item),
// serverType: config.serverType,
// });
// if (options.byItemType.item === Item.Album) {
// queryClient.setQueryData(['album', options.byItemType.id], data);
// } else if (options.byItemType.item === Item.Artist) {
// queryClient.setQueryData(['artistSongs', options.byItemType.id], data);
// } else if (options.byItemType.item === Item.Playlist) {
// queryClient.setQueryData(['playlist', options.byItemType.id], data);
// }
// if (data?.song) {
// dispatchSongsToQueue(data.song, options.play);
// } else {
// dispatchSongsToQueue(data, options.play);
// }
}
};
return { handlePlayQueueAdd };
};
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { usePlayerStore } from 'renderer/store';
import { mpvPlayer } from '../utils/mpvPlayer';
export const useRightControls = () => {
const setSettings = usePlayerStore((state) => state.setSettings);
const volume = usePlayerStore((state) => state.settings.volume);
const muted = usePlayerStore((state) => state.settings.muted);
// Ensure that the mpv player volume is set on startup
useEffect(() => {
mpvPlayer.volume(volume);
}, [volume]);
// Ensure that the mpv player mute status is set on startup
useEffect(() => {
if (muted) {
mpvPlayer.mute();
}
}, [muted]);
const handleVolumeSlider = (e: number) => {
mpvPlayer.volume(e);
};
const handleVolumeSliderState = (e: number) => {
setSettings({ volume: e });
};
const handleMute = () => {
setSettings({ muted: !muted });
mpvPlayer.mute();
};
return {
handleMute,
handleVolumeSlider,
handleVolumeSliderState,
};
};
+4
View File
@@ -0,0 +1,4 @@
export * from './components/CenterControls';
export * from './components/LeftControls';
export * from './components/Playerbar';
export * from './components/Slider';
@@ -0,0 +1,65 @@
import isElectron from 'is-electron';
import { PlayerData, usePlayerStore } from 'renderer/store';
const ipc = isElectron() ? window.electron.ipcRenderer : null;
const play = () => ipc?.PLAYER_PLAY();
const pause = () => ipc?.PLAYER_PAUSE();
const stop = () => ipc?.PLAYER_STOP();
const currentTime = () => ipc?.PLAYER_CURRENT_TIME();
const next = () => ipc?.PLAYER_NEXT();
const previous = () => ipc?.PLAYER_PREVIOUS();
const setQueue = (data: PlayerData) => ipc?.PLAYER_SET_QUEUE(data);
const setQueueNext = (data: PlayerData) => ipc?.PLAYER_SET_QUEUE_NEXT(data);
const seek = (seconds: number) => ipc?.PLAYER_SEEK(seconds);
const seekTo = (seconds: number) => ipc?.PLAYER_SEEK_TO(seconds);
const volume = (value: number) => ipc?.PLAYER_VOLUME(value);
const mute = () => ipc?.PLAYER_MUTE();
const {
setCurrentTime,
play: setPlay,
pause: setPause,
autoNext,
} = usePlayerStore.getState();
ipc?.RENDERER_PLAYER_PLAY(() => setPlay());
ipc?.RENDERER_PLAYER_PAUSE(() => setPause());
ipc?.RENDERER_PLAYER_STOP(() => setPause());
ipc?.RENDERER_PLAYER_CURRENT_TIME((_event, time) => setCurrentTime(time));
ipc?.RENDERER_PLAYER_SET_QUEUE_NEXT(() => {
const playerData = autoNext();
if (playerData.queue.next) {
setQueueNext(playerData);
}
});
export const mpvPlayer = {
currentTime,
mute,
next,
pause,
play,
previous,
seek,
seekTo,
setQueue,
setQueueNext,
stop,
volume,
};
@@ -0,0 +1,85 @@
import {
Button,
Checkbox,
Modal,
ModalProps,
PasswordInput,
SegmentedControl,
Stack,
TextInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'react-i18next';
import { useCreateServer, validateServer } from '../queries/useCreateServer';
export const AddServerModal = ({ ...rest }: ModalProps) => {
const { t } = useTranslation();
const form = useForm({
initialValues: {
legacyAuth: false,
name: '',
password: '',
serverType: 'jellyfin',
url: 'http://',
username: '',
},
});
const createServerMutation = useCreateServer();
return (
<Modal centered title={t('server.add.title')} {...rest}>
<form
onSubmit={form.onSubmit(async (values) => {
const res = await validateServer(values);
if (res?.token) {
createServerMutation.mutateAsync({
...values,
remoteUserId: res.userId,
token: res.token,
});
}
})}
>
<Stack>
<SegmentedControl
data={[
{ label: 'Jellyfin', value: 'jellyfin' },
{ label: 'Subsonic', value: 'subsonic' },
]}
{...form.getInputProps('serverType')}
/>
<TextInput
required
label={t('server.name')}
{...form.getInputProps('name')}
/>
<TextInput
required
label={t('server.url')}
{...form.getInputProps('url')}
/>
<TextInput
required
label={t('server.username')}
{...form.getInputProps('username')}
/>
<PasswordInput
required
label={t('server.password')}
{...form.getInputProps('password')}
/>
{form.getInputProps('serverType').value === 'subsonic' && (
<Checkbox
label={t('server.legacyauth')}
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
<Button type="submit">{t('server.submit')}</Button>
</Stack>
</form>
</Modal>
);
};
@@ -0,0 +1,63 @@
import {
Button,
Checkbox,
ModalProps,
PasswordInput,
SegmentedControl,
Stack,
TextInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'react-i18next';
import { ServerResponse } from 'renderer/api/types';
interface EditServerModalProps extends ModalProps {
server: ServerResponse | undefined;
}
export const EditServerModal = ({ server }: EditServerModalProps) => {
const { t } = useTranslation();
const form = useForm({
initialValues: {
legacyAuth: false,
name: server?.name,
password: '',
serverType: server?.serverType,
url: server?.url,
username: server?.username,
},
});
return (
<form onSubmit={form.onSubmit(async () => {})}>
<Stack>
<SegmentedControl
disabled
data={[
{ label: 'Jellyfin', value: 'jellyfin' },
{ label: 'Subsonic', value: 'subsonic' },
]}
{...form.getInputProps('serverType')}
/>
<TextInput label={t('server.name')} {...form.getInputProps('name')} />
<TextInput label={t('server.url')} {...form.getInputProps('url')} />
<TextInput
label={t('server.username')}
{...form.getInputProps('username')}
/>
<PasswordInput
label={t('server.password')}
{...form.getInputProps('password')}
/>
{form.getInputProps('serverType').value === 'subsonic' && (
<Checkbox
label={t('server.legacyauth')}
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
/>
)}
<Button type="submit">{t('server.submit')}</Button>
</Stack>
</form>
);
};
@@ -0,0 +1,8 @@
.item {
display: flex;
justify-content: space-between;
max-width: 50vw;
margin: 1rem;
padding: 1rem;
outline: 1px #fff solid;
}
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { EditCircle } from 'tabler-icons-react';
import { ServerResponse } from 'renderer/api/types';
import { IconButton } from 'renderer/components';
import { useServers } from '../queries/useServers';
import { EditServerModal } from './EditServerModal';
import styles from './ServerList.module.scss';
export const ServerList = () => {
const { data: servers } = useServers();
const [editServerModal, editServerHandlers] = useDisclosure(false);
const [opened, setOpened] = useState(false);
const [selectedServer, setSelectedServer] = useState<
ServerResponse | undefined
>();
const handleClickServer = (server: ServerResponse) => {
setSelectedServer(server);
editServerHandlers.open();
};
const handleKeyDownServer = (
e: React.KeyboardEvent<HTMLDivElement>,
server: ServerResponse
) => {
if (e.key === ' ' || e.key === 'Enter') {
setSelectedServer(server);
editServerHandlers.open();
}
};
const handleCloseModal = () => {
setOpened(false);
editServerHandlers.close();
};
useEffect(() => {
if (editServerModal === true) {
setTimeout(() => setOpened(true));
} else {
setTimeout(() => setSelectedServer(undefined), 100);
}
}, [editServerModal]);
return (
<>
{servers &&
servers.data.map((server) => (
<>
<div
className={styles.item}
role="button"
tabIndex={0}
onClick={() => handleClickServer(server)}
onKeyDown={(e) => handleKeyDownServer(e, server)}
>
<div>
{server.name}
<Text>Hello</Text>
</div>
<IconButton
icon={<EditCircle />}
onClick={() => editServerHandlers.toggle()}
>
Edit
</IconButton>
</div>
{selectedServer && (
<EditServerModal
opened={opened}
server={selectedServer}
onClose={handleCloseModal}
/>
)}
</>
))}
</>
);
};
+5
View File
@@ -0,0 +1,5 @@
export * from './routes/ServersRoute';
export * from './queries/useCreateServer';
export * from './queries/useServers';
export * from './components/AddServerModal';
export * from './components/ServerList';
@@ -0,0 +1,70 @@
import axios from 'axios';
import md5 from 'md5';
import { useMutation } from 'react-query';
import { serversApi } from 'renderer/api/serversApi';
import { randomString } from 'renderer/utils';
export const validateServer = async (options: {
legacyAuth: boolean;
password: string;
serverType: string;
url: string;
username: string;
}) => {
const { serverType, url, username, password, legacyAuth } = options;
const cleanServerUrl = url.replace(/\/$/, '');
try {
if (serverType === 'subsonic') {
let testConnection;
let token;
if (legacyAuth) {
token = `u=${username}&p=${password}`;
testConnection = await axios.get(
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=sonixd&f=json&${token}`
);
} else {
const salt = randomString();
const hash = md5(password + salt);
token = `u=${username}&s=${salt}&t=${hash}`;
testConnection = await axios.get(
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=sonixd&f=json&${token}`
);
}
if (testConnection.data['subsonic-response'].status === 'failed') {
return {
error: `${testConnection.data['subsonic-response'].error.message}`,
};
}
return { token, userId: '' };
}
const { data } = await axios.post(
`${cleanServerUrl}/users/authenticatebyname`,
{ pw: password, username },
{
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Sonixd", Device="PC", DeviceId="Sonixd", Version="1.0.0-alpha1"`,
},
}
);
return { token: data.AccessToken, userId: data.User.Id };
} catch (err) {
if (err instanceof Error) {
return { error: err.message };
}
}
return null;
};
export const useCreateServer = () => {
return useMutation({
mutationFn: serversApi.createServer,
onError: (e) => console.log(e),
onSuccess: (e) => console.log(e),
});
};
@@ -0,0 +1,68 @@
import md5 from 'md5';
import { useQuery } from 'react-query';
import { queryKeys } from 'renderer/api/queryKeys';
import { serversApi } from 'renderer/api/serversApi';
import { ServerFolderResponse } from 'renderer/api/types';
import { ServerFolderAuth } from 'types';
export const useServers = () => {
return useQuery({
onSuccess: (servers) => {
const { serverUrl } = JSON.parse(
localStorage.getItem('authentication') || '{}'
);
const storedServersKey = `servers_${md5(serverUrl)}`;
const serversFromLocalStorage = localStorage.getItem(storedServersKey);
// If a custom account/token is set for a server, use that instead of the default one
if (serversFromLocalStorage) {
const existingServers = JSON.parse(serversFromLocalStorage);
// The 'locked' property determines whether or not to skip updating the server auth
const skipped = existingServers.filter(
(server: ServerFolderAuth) => server.locked
);
const store = servers?.data?.flatMap((server) =>
server.serverFolder?.map((serverFolder: ServerFolderResponse) => {
if (skipped.includes(serverFolder.id)) {
return existingServers.find(
(s: ServerFolderAuth) => s.id === serverFolder.id
);
}
return {
id: serverFolder.id,
locked: false,
serverId: server.id,
token: server.token,
type: server.serverType,
url: server.url,
userId: server.remoteUserId,
username: server.username,
};
})
);
return localStorage.setItem(storedServersKey, JSON.stringify(store));
}
const store = servers?.data?.flatMap((server) =>
server.serverFolder?.map((serverFolder: ServerFolderResponse) => ({
id: serverFolder.id,
locked: false,
serverId: server.id,
token: server.token,
type: server.serverType,
url: server.url,
userId: server.remoteUserId,
username: server.username,
}))
);
return localStorage.setItem(storedServersKey, JSON.stringify(store));
},
queryFn: () => serversApi.getServers(),
queryKey: queryKeys.servers,
});
};
@@ -0,0 +1,11 @@
import { Title } from '@mantine/core';
import { ServerList } from '../components/ServerList';
export const ServersRoute = () => {
return (
<div>
<Title>Servers</Title>
<ServerList />
</div>
);
};
@@ -0,0 +1,37 @@
import isElectron from 'is-electron';
import { PlayerState } from 'renderer/store';
import {
CrossfadeStyle,
PlaybackStyle,
PlaybackType,
PlayerRepeat,
} from 'types';
export interface WebSettings {
player: PlayerSettings;
}
export type PlayerSettings = PlayerState['settings'];
const DEFAULT_SETTINGS: WebSettings = {
player: {
crossfadeDuration: 5,
crossfadeStyle: CrossfadeStyle.EqualPower,
muted: false,
repeat: PlayerRepeat.All,
shuffle: false,
style: PlaybackStyle.Gapless,
type: isElectron() ? PlaybackType.Local : PlaybackType.Web,
volume: 0.5,
},
};
export const useDefaultSettings = () => {
const currentSettings = localStorage.getItem('settings');
if (currentSettings) {
return JSON.parse(currentSettings);
}
return localStorage.setItem('settings', JSON.stringify(DEFAULT_SETTINGS));
};
+1
View File
@@ -0,0 +1 @@
export * from './hooks/useDefaultSettings';
@@ -0,0 +1,32 @@
import { ReactNode } from 'react';
import { motion } from 'framer-motion';
import styled from 'styled-components';
interface AnimatedPageProps {
children: ReactNode;
}
const StyledAnimatedPage = styled(motion.div)`
width: 100%;
height: 100%;
`;
const variants = {
animate: { opacity: 1 },
exit: { opacity: 0 },
initial: { opacity: 0 },
};
export const AnimatedPage = ({ children }: AnimatedPageProps) => {
return (
<StyledAnimatedPage
animate="animate"
exit="exit"
initial="initial"
transition={{ duration: 0.2, type: 'tween' }}
variants={variants}
>
{children}
</StyledAnimatedPage>
);
};
@@ -0,0 +1,51 @@
import { ReactNode } from 'react';
import { motion } from 'framer-motion';
import { Link, LinkProps } from 'react-router-dom';
import styled, { css } from 'styled-components';
import { fontInter } from 'renderer/styles';
interface ListItemProps {
children: ReactNode;
icon?: ReactNode;
}
const StyledListItem = styled(motion.div)`
${fontInter(600)}
display: flex;
align-items: center;
justify-content: space-between;
`;
const ItemStyle = css`
display: flex;
gap: 1rem;
align-items: center;
width: 100%;
padding: 0.5rem 1rem;
color: var(--sidebar-btn-color);
transition: color 0.2s ease-in-out;
&:hover {
color: var(--sidebar-btn-color-hover);
}
`;
const Box = styled.div`
${ItemStyle}
`;
const ItemLink = styled(Link)<LinkProps>`
${ItemStyle}
`;
export const ListItem = ({ icon, children, ...rest }: ListItemProps) => {
return <StyledListItem {...rest}>{children}</StyledListItem>;
};
ListItem.Box = Box;
ListItem.Link = ItemLink;
ListItem.defaultProps = {
icon: <></>,
};
@@ -0,0 +1,43 @@
import styled from 'styled-components';
import { Home, Notebook, Search } from 'tabler-icons-react';
import { UserMenu } from 'renderer/features/user-menu';
import { AppRoute } from 'renderer/router/utils/routes';
import { ListItem } from './ListItem';
const SidebarContainer = styled.div`
width: 100%;
height: 100%;
`;
const ItemGroup = styled.ul``;
const Item = styled.li``;
const StyledSidebar = styled.div``;
export const Sidebar = () => {
return (
<StyledSidebar>
<ListItem icon={<Home />}>
<ListItem.Link to={AppRoute.HOME}>
<Home />
Home
</ListItem.Link>
</ListItem>
<ListItem>
<ListItem.Link to={AppRoute.SEARCH}>
<Search />
Search
</ListItem.Link>
</ListItem>
<ListItem>
<ListItem.Link to={AppRoute.LIBRARY}>
<Notebook />
Your Library
</ListItem.Link>
</ListItem>
<UserMenu />
</StyledSidebar>
);
};
+1
View File
@@ -0,0 +1 @@
export * from './components/Sidebar';
@@ -0,0 +1,73 @@
import { ReactNode } from 'react';
import { Group } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import { ChevronLeft, ChevronRight } from 'tabler-icons-react';
import { IconButton } from 'renderer/components';
import { WindowControls } from 'renderer/features/window-controls';
interface TitlebarProps {
children?: ReactNode;
}
const TitlebarContainer = styled.div`
display: flex;
flex-direction: row;
gap: 1rem;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
-webkit-app-region: drag;
`;
const Left = styled.div`
height: 100%;
button {
-webkit-app-region: no-drag;
svg {
transform: scaleX(1);
}
}
`;
const Right = styled.div`
height: 100%;
-webkit-app-region: no-drag;
`;
export const Titlebar = ({ children }: TitlebarProps) => {
const navigate = useNavigate();
return (
<>
<TitlebarContainer>
<Left>
<Group spacing="xs">
<IconButton
icon={<ChevronLeft size={25} strokeWidth={1.5} />}
size={25}
onClick={() => navigate(-1)}
/>
<IconButton
icon={<ChevronRight size={25} strokeWidth={1.5} />}
size={25}
onClick={() => navigate(1)}
/>
</Group>
</Left>
<Right>
{children}
<WindowControls />
</Right>
</TitlebarContainer>
</>
);
};
Titlebar.defaultProps = {
children: <></>,
};
@@ -0,0 +1,47 @@
import { Button, Menu } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useNavigate } from 'react-router';
import { Logout, Server, Settings } from 'tabler-icons-react';
import { AddServerModal } from 'renderer/features/servers';
import { useAuthStore } from 'renderer/store';
export const UserMenu = () => {
const navigate = useNavigate();
const [addServerModal, addServerHandlers] = useDisclosure(false);
const logout = useAuthStore((state) => state.logout);
const handleLogout = () => {
logout();
localStorage.removeItem('authentication');
navigate('/login');
};
return (
<>
<Menu
control={
<Button radius="lg" size="xs" variant="default">
User
</Button>
}
position="bottom"
size="md"
>
<Menu.Item
icon={<Server size={20} />}
onClick={() => addServerHandlers.open()}
>
Servers
</Menu.Item>
<Menu.Item icon={<Settings size={20} />}>Settings</Menu.Item>
<Menu.Item icon={<Logout size={20} />} onClick={handleLogout}>
Logout
</Menu.Item>
</Menu>
<AddServerModal
opened={addServerModal}
onClose={() => addServerHandlers.close()}
/>
</>
);
};
+1
View File
@@ -0,0 +1 @@
export * from './components/Titlebar';
@@ -0,0 +1,9 @@
.root {
position: absolute;
top: 10px;
right: 150px;
}
.icon {
margin-right: 1rem;
}
@@ -0,0 +1,49 @@
import { Button, Menu } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useNavigate } from 'react-router';
import { Logout, Server, Settings } from 'tabler-icons-react';
import { useAuthStore } from 'renderer/store';
import { AddServerModal } from '../servers/components/AddServerModal';
import styles from './UserMenu.module.scss';
export const UserMenu = () => {
const navigate = useNavigate();
const [addServerModal, addServerHandlers] = useDisclosure(false);
const logout = useAuthStore((state) => state.logout);
const handleLogout = () => {
logout();
localStorage.removeItem('authentication');
navigate('/login');
};
return (
<>
<Menu
classNames={{ itemIcon: styles.icon, root: styles.root }}
control={
<Button radius="lg" size="xs" variant="default">
User
</Button>
}
position="bottom"
size="md"
>
<Menu.Item
icon={<Server size={20} />}
onClick={() => addServerHandlers.open()}
>
Servers
</Menu.Item>
<Menu.Item icon={<Settings size={20} />}>Settings</Menu.Item>
<Menu.Item icon={<Logout size={20} />} onClick={handleLogout}>
Logout
</Menu.Item>
</Menu>
<AddServerModal
opened={addServerModal}
onClose={() => addServerHandlers.close()}
/>
</>
);
};
+1
View File
@@ -0,0 +1 @@
export * from './UserMenu';
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,85 @@
import { useState } from 'react';
import isElectron from 'is-electron';
import styled from 'styled-components';
import windowsClose from '../assets/close-w-10.png';
import windowsMax from '../assets/max-w-10.png';
import windowsMin from '../assets/min-w-10.png';
interface WindowControlsProps {
style?: 'macos' | 'windows' | 'linux';
}
const WindowControlsContainer = styled.div`
height: 100%;
`;
const WindowsButtonGroup = styled.div`
display: flex;
width: 130px;
height: 100%;
-webkit-app-region: no-drag;
`;
const WindowsButton = styled.div`
flex-grow: 1;
height: 100%;
text-align: center;
-webkit-app-region: no-drag;
&:hover {
background: rgba(125, 125, 125, 30%);
}
`;
const close = () => window.electron.ipcRenderer.windowClose();
const minimize = () => window.electron.ipcRenderer.windowMinimize();
const maximize = () => window.electron.ipcRenderer.windowMaximize();
const unmaximize = () => window.electron.ipcRenderer.windowUnmaximize();
export const WindowControls = ({ style }: WindowControlsProps) => {
const [max, setMax] = useState(false);
const handleMinimize = () => minimize();
const handleMaximize = () => {
if (max) {
unmaximize();
} else {
maximize();
}
setMax(!max);
};
const handleClose = () => close();
return (
<WindowControlsContainer>
{isElectron() && (
<>
{style === 'windows' && (
<>
<WindowsButtonGroup>
<WindowsButton role="button" onClick={handleMinimize}>
<img alt="minimize" src={windowsMin} />
</WindowsButton>
<WindowsButton role="button" onClick={handleMaximize}>
<img alt="maximize" src={windowsMax} />
</WindowsButton>
<WindowsButton role="button" onClick={handleClose}>
<img alt="exit" src={windowsClose} />
</WindowsButton>
</WindowsButtonGroup>
</>
)}
</>
)}
</WindowControlsContainer>
);
};
WindowControls.defaultProps = {
style: 'windows',
};
@@ -0,0 +1 @@
export * from './components/WindowControls';