Browse Source
Co-authored-by: William Oldham <github@binaryoverload.co.uk> Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>pull/497/head
53 changed files with 484 additions and 346 deletions
@ -1,136 +0,0 @@ |
|||||||
import React, { |
|
||||||
MouseEventHandler, |
|
||||||
SyntheticEvent, |
|
||||||
useEffect, |
|
||||||
useState, |
|
||||||
} from "react"; |
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop"; |
|
||||||
|
|
||||||
import { ButtonControl, ButtonControlProps } from "./ButtonControl"; |
|
||||||
|
|
||||||
export interface OptionItem { |
|
||||||
id: string; |
|
||||||
name: string; |
|
||||||
icon: Icons; |
|
||||||
} |
|
||||||
|
|
||||||
interface DropdownButtonProps extends ButtonControlProps { |
|
||||||
icon: Icons; |
|
||||||
open: boolean; |
|
||||||
setOpen: (open: boolean) => void; |
|
||||||
selectedItem: string; |
|
||||||
setSelectedItem: (value: string) => void; |
|
||||||
options: Array<OptionItem>; |
|
||||||
} |
|
||||||
|
|
||||||
export interface OptionProps { |
|
||||||
option: OptionItem; |
|
||||||
onClick: MouseEventHandler<HTMLDivElement>; |
|
||||||
tabIndex?: number; |
|
||||||
} |
|
||||||
|
|
||||||
function Option({ option, onClick, tabIndex }: OptionProps) { |
|
||||||
return ( |
|
||||||
<div |
|
||||||
className="flex h-10 cursor-pointer items-center space-x-2 px-4 py-2 text-left text-denim-700 transition-colors hover:text-white" |
|
||||||
onClick={onClick} |
|
||||||
tabIndex={tabIndex} |
|
||||||
> |
|
||||||
<Icon icon={option.icon} /> |
|
||||||
<input type="radio" className="hidden" id={option.id} /> |
|
||||||
<label htmlFor={option.id} className="cursor-pointer "> |
|
||||||
<div className="item">{option.name}</div> |
|
||||||
</label> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export const DropdownButton = React.forwardRef< |
|
||||||
HTMLDivElement, |
|
||||||
DropdownButtonProps |
|
||||||
>((props: DropdownButtonProps, ref) => { |
|
||||||
const [setBackdrop, backdropProps, highlightedProps] = useBackdrop(); |
|
||||||
const [delayedSelectedId, setDelayedSelectedId] = useState( |
|
||||||
props.selectedItem |
|
||||||
); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
let id: ReturnType<typeof setTimeout>; |
|
||||||
|
|
||||||
if (props.open) { |
|
||||||
setDelayedSelectedId(props.selectedItem); |
|
||||||
} else { |
|
||||||
id = setTimeout(() => { |
|
||||||
setDelayedSelectedId(props.selectedItem); |
|
||||||
}, 200); |
|
||||||
} |
|
||||||
return () => { |
|
||||||
if (id) clearTimeout(id); |
|
||||||
}; |
|
||||||
/* eslint-disable-next-line */ |
|
||||||
}, [props.open]); |
|
||||||
|
|
||||||
const selectedItem: OptionItem = props.options.find( |
|
||||||
(opt) => opt.id === props.selectedItem |
|
||||||
) || { id: "movie", name: "movie", icon: Icons.ARROW_LEFT }; |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
setBackdrop(props.open); |
|
||||||
/* eslint-disable-next-line */ |
|
||||||
}, [props.open]); |
|
||||||
|
|
||||||
const onOptionClick = (e: SyntheticEvent, option: OptionItem) => { |
|
||||||
e.stopPropagation(); |
|
||||||
props.setSelectedItem(option.id); |
|
||||||
props.setOpen(false); |
|
||||||
}; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="w-full min-w-[140px] sm:w-auto"> |
|
||||||
<div |
|
||||||
ref={ref} |
|
||||||
className="relative w-full sm:w-auto" |
|
||||||
{...highlightedProps} |
|
||||||
> |
|
||||||
<BackdropContainer |
|
||||||
onClick={() => props.setOpen(false)} |
|
||||||
{...backdropProps} |
|
||||||
> |
|
||||||
<ButtonControl |
|
||||||
{...props} |
|
||||||
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-400 px-4 py-2 text-white hover:bg-bink-300" |
|
||||||
> |
|
||||||
<Icon icon={selectedItem.icon} /> |
|
||||||
<span className="flex-1">{selectedItem.name}</span> |
|
||||||
<Icon |
|
||||||
icon={Icons.CHEVRON_DOWN} |
|
||||||
className={`transition-transform ${ |
|
||||||
props.open ? "rotate-180" : "" |
|
||||||
}`}
|
|
||||||
/> |
|
||||||
</ButtonControl> |
|
||||||
<div |
|
||||||
className={`absolute top-0 z-10 w-full rounded-[20px] bg-denim-300 pt-[40px] transition-all duration-200 ${ |
|
||||||
props.open |
|
||||||
? "block max-h-60 opacity-100" |
|
||||||
: "invisible max-h-0 opacity-0" |
|
||||||
}`}
|
|
||||||
> |
|
||||||
{props.options |
|
||||||
.filter((opt) => opt.id !== delayedSelectedId) |
|
||||||
.map((opt) => ( |
|
||||||
<Option |
|
||||||
option={opt} |
|
||||||
key={opt.id} |
|
||||||
onClick={(e) => onOptionClick(e, opt)} |
|
||||||
tabIndex={props.open ? 0 : undefined} |
|
||||||
/> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
</BackdropContainer> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
}); |
|
@ -1,19 +0,0 @@ |
|||||||
import { Icon, Icons } from "@/components/Icon"; |
|
||||||
|
|
||||||
import { ButtonControl, ButtonControlProps } from "./ButtonControl"; |
|
||||||
|
|
||||||
export interface IconButtonProps extends ButtonControlProps { |
|
||||||
icon: Icons; |
|
||||||
} |
|
||||||
|
|
||||||
export function IconButton(props: IconButtonProps) { |
|
||||||
return ( |
|
||||||
<ButtonControl |
|
||||||
{...props} |
|
||||||
className="flex items-center space-x-2 rounded-full bg-bink-200 px-4 py-2 text-white hover:bg-bink-300" |
|
||||||
> |
|
||||||
<Icon icon={props.icon} /> |
|
||||||
<span>{props.children}</span> |
|
||||||
</ButtonControl> |
|
||||||
); |
|
||||||
} |
|
@ -1,16 +0,0 @@ |
|||||||
import { ReactNode } from "react"; |
|
||||||
|
|
||||||
export interface PaperProps { |
|
||||||
children?: ReactNode; |
|
||||||
className?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function Paper(props: PaperProps) { |
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={`bg-denim-200 px-4 py-6 sm:px-8 sm:py-8 md:px-12 md:py-12 lg:rounded-xl ${props.className}`} |
|
||||||
> |
|
||||||
{props.children} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
@ -1,25 +0,0 @@ |
|||||||
export interface EpisodeProps { |
|
||||||
progress?: number; |
|
||||||
episodeNumber: number; |
|
||||||
onClick?: () => void; |
|
||||||
active?: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
export function Episode(props: EpisodeProps) { |
|
||||||
return ( |
|
||||||
<div |
|
||||||
onClick={props.onClick} |
|
||||||
className={`transition-[background-color, transform, box-shadow] relative mb-3 mr-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded bg-denim-500 font-bold text-white hover:bg-denim-400 active:scale-110 ${ |
|
||||||
props.active ? "shadow-[inset_0_0_0_2px] shadow-bink-500" : "" |
|
||||||
}`}
|
|
||||||
> |
|
||||||
<div |
|
||||||
className="absolute bottom-0 left-0 top-0 bg-bink-500 bg-opacity-50" |
|
||||||
style={{ |
|
||||||
width: `${props.progress || 0}%`, |
|
||||||
}} |
|
||||||
/> |
|
||||||
<span className="relative">{props.episodeNumber}</span> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
@ -0,0 +1,79 @@ |
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"; |
||||||
|
|
||||||
|
import { SubtitleStyling } from "@/stores/subtitles"; |
||||||
|
|
||||||
|
export function useDerived<T>( |
||||||
|
initial: T |
||||||
|
): [T, (v: T) => void, () => void, boolean] { |
||||||
|
const [overwrite, setOverwrite] = useState<T | undefined>(undefined); |
||||||
|
useEffect(() => { |
||||||
|
setOverwrite(undefined); |
||||||
|
}, [initial]); |
||||||
|
|
||||||
|
const changed = overwrite !== initial && overwrite !== undefined; |
||||||
|
const data = overwrite === undefined ? initial : overwrite; |
||||||
|
|
||||||
|
const reset = useCallback(() => setOverwrite(undefined), [setOverwrite]); |
||||||
|
|
||||||
|
return [data, setOverwrite, reset, changed]; |
||||||
|
} |
||||||
|
|
||||||
|
export function useSettingsState( |
||||||
|
theme: string | null, |
||||||
|
appLanguage: string, |
||||||
|
subtitleStyling: SubtitleStyling, |
||||||
|
deviceName?: string |
||||||
|
) { |
||||||
|
const [themeState, setTheme, resetTheme, themeChanged] = useDerived(theme); |
||||||
|
const [ |
||||||
|
appLanguageState, |
||||||
|
setAppLanguage, |
||||||
|
resetAppLanguage, |
||||||
|
appLanguageChanged, |
||||||
|
] = useDerived(appLanguage); |
||||||
|
const [subStylingState, setSubStyling, resetSubStyling, subStylingChanged] = |
||||||
|
useDerived(subtitleStyling); |
||||||
|
const [ |
||||||
|
deviceNameState, |
||||||
|
setDeviceNameState, |
||||||
|
resetDeviceName, |
||||||
|
deviceNameChanged, |
||||||
|
] = useDerived(deviceName); |
||||||
|
|
||||||
|
function reset() { |
||||||
|
resetTheme(); |
||||||
|
resetAppLanguage(); |
||||||
|
resetSubStyling(); |
||||||
|
resetDeviceName(); |
||||||
|
} |
||||||
|
|
||||||
|
const changed = useMemo( |
||||||
|
() => |
||||||
|
themeChanged || |
||||||
|
appLanguageChanged || |
||||||
|
subStylingChanged || |
||||||
|
deviceNameChanged, |
||||||
|
[themeChanged, appLanguageChanged, subStylingChanged, deviceNameChanged] |
||||||
|
); |
||||||
|
|
||||||
|
return { |
||||||
|
reset, |
||||||
|
changed, |
||||||
|
theme: { |
||||||
|
state: themeState, |
||||||
|
set: setTheme, |
||||||
|
}, |
||||||
|
appLanguage: { |
||||||
|
state: appLanguageState, |
||||||
|
set: setAppLanguage, |
||||||
|
}, |
||||||
|
subtitleStyling: { |
||||||
|
state: subStylingState, |
||||||
|
set: setSubStyling, |
||||||
|
}, |
||||||
|
deviceName: { |
||||||
|
state: deviceNameState, |
||||||
|
set: setDeviceNameState, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
import { Button } from "@/components/Button"; |
||||||
|
import { Modal, ModalCard } from "@/components/overlays/Modal"; |
||||||
|
import { Heading2 } from "@/components/utils/Text"; |
||||||
|
|
||||||
|
export function ProfileEditModal(props: { id: string }) { |
||||||
|
return ( |
||||||
|
<Modal id={props.id}> |
||||||
|
<ModalCard> |
||||||
|
<Heading2 className="!mt-0">Edit profile?</Heading2> |
||||||
|
<p>I am existing</p> |
||||||
|
<Button theme="danger">Update</Button> |
||||||
|
</ModalCard> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
import { BrandPill } from "@/components/layout/BrandPill"; |
||||||
|
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; |
||||||
|
|
||||||
|
export function LargeTextPart(props: { |
||||||
|
iconSlot?: React.ReactNode; |
||||||
|
children: React.ReactNode; |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<div className="flex flex-col justify-center items-center h-screen text-center font-medium"> |
||||||
|
{/* Overlayed elements */} |
||||||
|
<BlurEllipsis /> |
||||||
|
<div className="right-[calc(2rem+env(safe-area-inset-right))] top-6 absolute"> |
||||||
|
<BrandPill /> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Content */} |
||||||
|
{props.iconSlot ? props.iconSlot : null} |
||||||
|
<div className="max-w-[19rem] mt-3 mb-12 text-type-secondary"> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1 @@ |
|||||||
|
just dont, it's old stuff that needs to stay for legacy localstorage |
@ -0,0 +1,38 @@ |
|||||||
|
import { useEffect } from "react"; |
||||||
|
|
||||||
|
import { updateSettings } from "@/backend/accounts/settings"; |
||||||
|
import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; |
||||||
|
import { useAuthStore } from "@/stores/auth"; |
||||||
|
import { useSubtitleStore } from "@/stores/subtitles"; |
||||||
|
|
||||||
|
const syncIntervalMs = 5 * 1000; |
||||||
|
|
||||||
|
export function SettingsSyncer() { |
||||||
|
const importSubtitleLanguage = useSubtitleStore( |
||||||
|
(s) => s.importSubtitleLanguage |
||||||
|
); |
||||||
|
const url = useBackendUrl(); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const interval = setInterval(() => { |
||||||
|
(async () => { |
||||||
|
const state = useSubtitleStore.getState(); |
||||||
|
const user = useAuthStore.getState(); |
||||||
|
if (state.lastSync.lastSelectedLanguage === state.lastSelectedLanguage) |
||||||
|
return; // only sync if there is a difference
|
||||||
|
if (!user.account) return; |
||||||
|
if (!state.lastSelectedLanguage) return; |
||||||
|
await updateSettings(url, user.account, { |
||||||
|
defaultSubtitleLanguage: state.lastSelectedLanguage, |
||||||
|
}); |
||||||
|
importSubtitleLanguage(state.lastSelectedLanguage); |
||||||
|
})(); |
||||||
|
}, syncIntervalMs); |
||||||
|
|
||||||
|
return () => { |
||||||
|
clearInterval(interval); |
||||||
|
}; |
||||||
|
}, [importSubtitleLanguage, url]); |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
Loading…
Reference in new issue