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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1 @@
|
||||
just dont, it's old stuff that needs to stay for legacy localstorage |
@ -0,0 +1,38 @@
@@ -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