mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-29 16:07:40 +01:00
a whole bunch of final todos
Co-authored-by: William Oldham <github@binaryoverload.co.uk> Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
415419f3ef
commit
5a5f3e8b8c
53 changed files with 484 additions and 346 deletions
|
@ -44,7 +44,7 @@ module.exports = {
|
|||
"react/destructuring-assignment": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-console": "off",
|
||||
"no-console": ["error", { allow: ["warn", "error"] }],
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import classNames from "classnames";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { UserIcon } from "@/components/UserIcon";
|
||||
import { AccountProfile } from "@/pages/parts/auth/AccountCreatePart";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
@ -48,3 +49,21 @@ export function UserAvatar(props: {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoUserAvatar(props: {
|
||||
sizeClass?: string;
|
||||
iconClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<div
|
||||
className={classNames(
|
||||
props.sizeClass ?? "w-[2rem] h-[2rem]",
|
||||
"rounded-full overflow-hidden flex items-center justify-center text-type-dimmed hover:text-type-secondary bg-pill-background bg-opacity-50 hover:bg-opacity-100 transition-colors duration-100"
|
||||
)}
|
||||
>
|
||||
<Icon className={props.iconClass ?? "text-xl"} icon={Icons.MENU} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ export function Dropdown(props: DropdownProps) {
|
|||
<Listbox value={props.selectedItem} onChange={props.setSelectedItem}>
|
||||
{() => (
|
||||
<>
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-dropdown-background py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-bink-500 focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-bink-300">
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-dropdown-background py-3 pl-3 pr-10 text-left text-white shadow-md focus:outline-none tabbable">
|
||||
<span className="flex gap-4 items-center truncate">
|
||||
{props.selectedItem.leftIcon
|
||||
? props.selectedItem.leftIcon
|
||||
|
@ -41,12 +41,14 @@ export function Dropdown(props: DropdownProps) {
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute left-0 right-0 top-10 z-[1] mt-4 max-h-60 overflow-auto rounded-md bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 focus:outline-none sm:top-10">
|
||||
<Listbox.Options className="absolute left-0 right-0 top-10 z-[1] mt-4 max-h-60 overflow-auto rounded-md bg-dropdown-background py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 scrollbar-thin scrollbar-track-background-secondary scrollbar-thumb-type-secondary focus:outline-none sm:top-10">
|
||||
{props.options.map((opt) => (
|
||||
<Listbox.Option
|
||||
className={({ active }) =>
|
||||
`flex gap-4 items-center relative cursor-default select-none py-3 pl-4 pr-4 ${
|
||||
active ? "bg-denim-400 text-bink-700" : "text-white"
|
||||
active
|
||||
? "bg-background-secondaryHover text-type-link"
|
||||
: "text-white"
|
||||
}`
|
||||
}
|
||||
key={opt.id}
|
||||
|
|
|
@ -56,6 +56,7 @@ export enum Icons {
|
|||
SETTINGS = "settings",
|
||||
COINS = "coins",
|
||||
LOGOUT = "logout",
|
||||
MENU = "menu",
|
||||
}
|
||||
|
||||
export interface IconProps {
|
||||
|
@ -119,6 +120,7 @@ const iconList: Record<Icons, string> = {
|
|||
settings: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-settings"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
|
||||
coins: `<svg width="1em" height="1em" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.8125 7.69742V7.21875C15.8125 5.06344 12.5615 3.4375 8.25 3.4375C3.93852 3.4375 0.6875 5.06344 0.6875 7.21875V10.6562C0.6875 12.4515 2.94336 13.878 6.1875 14.3052V14.7812C6.1875 16.9366 9.43852 18.5625 13.75 18.5625C18.0615 18.5625 21.3125 16.9366 21.3125 14.7812V11.3438C21.3125 9.56484 19.128 8.13656 15.8125 7.69742ZM4.8125 12.6216C3.12898 12.1516 2.0625 11.3773 2.0625 10.6562V9.44711C2.76375 9.94383 3.70305 10.3443 4.8125 10.6133V12.6216ZM11.6875 10.6133C12.797 10.3443 13.7362 9.94383 14.4375 9.44711V10.6562C14.4375 11.3773 13.371 12.1516 11.6875 12.6216V10.6133ZM10.3125 16.7466C8.62898 16.2766 7.5625 15.5023 7.5625 14.7812V14.4229C7.78852 14.4315 8.01711 14.4375 8.25 14.4375C8.58344 14.4375 8.90914 14.4263 9.22883 14.4074C9.58397 14.5346 9.94572 14.6424 10.3125 14.7305V16.7466ZM10.3125 12.9121C9.62964 13.013 8.94027 13.0633 8.25 13.0625C7.55973 13.0633 6.87036 13.013 6.1875 12.9121V10.8677C6.87137 10.9568 7.56035 11.001 8.25 11C8.93965 11.001 9.62863 10.9568 10.3125 10.8677V12.9121ZM15.8125 17.0371C14.4448 17.2376 13.0552 17.2376 11.6875 17.0371V14.9875C12.3712 15.0794 13.0602 15.1253 13.75 15.125C14.4397 15.126 15.1286 15.0818 15.8125 14.9927V17.0371ZM19.9375 14.7812C19.9375 15.5023 18.871 16.2766 17.1875 16.7466V14.7383C18.297 14.4693 19.2362 14.0688 19.9375 13.5721V14.7812Z" fill="currentColor"/></svg>`,
|
||||
logout: `<svg style="transform: scaleX(-1);" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-log-out"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>`,
|
||||
menu: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-menu"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>`,
|
||||
};
|
||||
|
||||
function ChromeCastButton() {
|
||||
|
|
|
@ -29,6 +29,7 @@ function GoToLink(props: {
|
|||
|
||||
return (
|
||||
<a
|
||||
tabIndex={0}
|
||||
href={props.href}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
|
@ -100,7 +101,6 @@ export function LinksDropdown(props: { children: React.ReactNode }) {
|
|||
}, []);
|
||||
|
||||
const toggleOpen = useCallback(() => {
|
||||
console.log("yay");
|
||||
setOpen((s) => !s);
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
|
@ -22,7 +22,7 @@ export function EditButton(props: EditButtonProps) {
|
|||
return (
|
||||
<ButtonControl
|
||||
onClick={onClick}
|
||||
className="flex h-12 items-center overflow-hidden rounded-full bg-denim-400 px-4 py-2 text-white transition-[background-color,transform] hover:bg-denim-500 active:scale-105"
|
||||
className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105"
|
||||
>
|
||||
<span ref={parent}>
|
||||
{props.editing ? (
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -12,13 +12,13 @@ export interface IconPatchProps {
|
|||
|
||||
export function IconPatch(props: IconPatchProps) {
|
||||
const clickableClasses = props.clickable
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||
? "cursor-pointer hover:scale-110 hover:bg-pill-backgroundHover hover:text-white active:scale-125"
|
||||
: "";
|
||||
const transparentClasses = props.transparent
|
||||
? "bg-opacity-0 hover:bg-opacity-50"
|
||||
: "";
|
||||
const activeClasses = props.active
|
||||
? "border-bink-600 bg-bink-100 text-bink-600"
|
||||
? "bg-pill-backgroundHover text-white"
|
||||
: "";
|
||||
const sizeClasses = props.downsized ? "h-10 w-10" : "h-12 w-12";
|
||||
|
||||
|
|
|
@ -13,10 +13,10 @@ export function BrandPill(props: {
|
|||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center space-x-2 rounded-full px-4 py-2 text-bink-600",
|
||||
"flex items-center space-x-2 rounded-full px-4 py-2 text-type-logo",
|
||||
props.backgroundClass ?? "bg-pill-background bg-opacity-50",
|
||||
props.clickable
|
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-400 hover:text-bink-700 active:scale-95"
|
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-pill-backgroundHover hover:text-type-logo active:scale-95"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
|
|
|
@ -5,7 +5,7 @@ export function IconPill(props: { icon: Icons; children?: React.ReactNode }) {
|
|||
<div className="bg-pill-background bg-opacity-50 px-4 py-2 rounded-full text-white flex justify-center items-center">
|
||||
<Icon
|
||||
icon={props.icon ?? Icons.WAND}
|
||||
className="mr-3 text-xl text-bink-600"
|
||||
className="mr-3 text-xl text-type-link"
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
|
|
|
@ -8,10 +8,10 @@ export function Loading(props: LoadingProps) {
|
|||
<div className={props.className}>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="flex h-12 items-center justify-center">
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:150ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:300ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-denim-300 [animation-delay:450ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-[#211D30]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-[#211D30] [animation-delay:150ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-[#211D30] [animation-delay:300ms]" />
|
||||
<div className="mx-1 h-2 w-2 animate-loading-pin rounded-full bg-[#211D30] [animation-delay:450ms]" />
|
||||
</div>
|
||||
{props.text && props.text.length ? (
|
||||
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import classNames from "classnames";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { UserAvatar } from "@/components/Avatar";
|
||||
import { NoUserAvatar, UserAvatar } from "@/components/Avatar";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { LinksDropdown } from "@/components/LinksDropdown";
|
||||
|
@ -101,7 +101,7 @@ export function Navigation(props: NavigationProps) {
|
|||
</div>
|
||||
<div className="relative">
|
||||
<LinksDropdown>
|
||||
{loggedIn ? <UserAvatar /> : <p>Not logged in</p>}
|
||||
{loggedIn ? <UserAvatar /> : <NoUserAvatar />}
|
||||
</LinksDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -14,7 +14,7 @@ export function ProgressRing(props: Props) {
|
|||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${
|
||||
className={`fill-transparent stroke-type-text stroke-[15] opacity-25 ${
|
||||
props.backingRingClassname ?? ""
|
||||
}`}
|
||||
r={radius}
|
||||
|
|
|
@ -13,7 +13,7 @@ export function SectionHeading(props: SectionHeadingProps) {
|
|||
return (
|
||||
<div className={props.className}>
|
||||
<div className="mb-5 flex items-center">
|
||||
<p className="flex flex-1 items-center font-bold uppercase text-denim-700">
|
||||
<p className="flex flex-1 items-center font-bold uppercase text-type-text">
|
||||
{props.icon ? (
|
||||
<span className="mr-2 text-xl">
|
||||
<Icon icon={props.icon} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -23,7 +23,7 @@ export function AutoPlayStart() {
|
|||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="group pointer-events-auto flex h-16 w-16 cursor-pointer items-center justify-center rounded-full bg-denim-400 text-white transition-[background-color,transform] hover:scale-125 hover:bg-denim-500 active:scale-100"
|
||||
className="group pointer-events-auto flex h-16 w-16 cursor-pointer items-center justify-center rounded-full text-white transition-[background-color,transform] hover:scale-125 active:scale-100"
|
||||
>
|
||||
<Icon
|
||||
icon={Icons.PLAY}
|
||||
|
|
|
@ -20,20 +20,18 @@ export function VideoClickTarget(props: { showingControls: boolean }) {
|
|||
|
||||
const togglePause = useCallback(
|
||||
(e: PointerEvent<HTMLDivElement>) => {
|
||||
setTimeout(() => {
|
||||
// pause on mouse click
|
||||
if (e.pointerType === "mouse") {
|
||||
if (e.button !== 0) return;
|
||||
if (isPaused) display?.play();
|
||||
else display?.pause();
|
||||
return;
|
||||
}
|
||||
// pause on mouse click
|
||||
if (e.pointerType === "mouse") {
|
||||
if (e.button !== 0) return;
|
||||
if (isPaused) display?.play();
|
||||
else display?.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// toggle on other types of clicks
|
||||
if (hovering !== PlayerHoverState.MOBILE_TAPPED)
|
||||
updateInterfaceHovering(PlayerHoverState.MOBILE_TAPPED);
|
||||
else updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
|
||||
}, 10); // TODO this is dirty workaround, without this, tapping on something where a button will be will trigger it immediately
|
||||
// toggle on other types of clicks
|
||||
if (hovering !== PlayerHoverState.MOBILE_TAPPED)
|
||||
updateInterfaceHovering(PlayerHoverState.MOBILE_TAPPED);
|
||||
else updateInterfaceHovering(PlayerHoverState.NOT_HOVERING);
|
||||
},
|
||||
[display, isPaused, hovering, updateInterfaceHovering]
|
||||
);
|
||||
|
|
|
@ -27,7 +27,7 @@ export function ArrowLink(props: ArrowLinkProps) {
|
|||
const isExternal = !!(props as IArrowLinkPropsExternal).url;
|
||||
const isInternal = !!(props as IArrowLinkPropsInternal).to;
|
||||
const content = (
|
||||
<span className="group mt-1 inline-flex cursor-pointer items-center space-x-1 pr-1 font-bold text-bink-600 hover:text-bink-700 active:scale-95">
|
||||
<span className="group mt-1 inline-flex cursor-pointer items-center space-x-1 pr-1 font-bold text-type-link hover:text-type-linkHover active:scale-95">
|
||||
{direction === "left" ? (
|
||||
<span className="text-xl transition-transform group-hover:-translate-x-1">
|
||||
<Icon icon={Icons.ARROW_LEFT} />
|
||||
|
|
|
@ -5,7 +5,7 @@ export interface DotListProps {
|
|||
|
||||
export function DotList(props: DotListProps) {
|
||||
return (
|
||||
<p className={`font-semibold text-denim-700 ${props.className || ""}`}>
|
||||
<p className={`font-semibold text-type-secondary ${props.className || ""}`}>
|
||||
{props.content.map((item, index) => (
|
||||
<span key={item}>
|
||||
{index !== 0 ? (
|
||||
|
|
|
@ -22,7 +22,7 @@ export function Link(props: LinkProps) {
|
|||
const isExternal = !!(props as ILinkPropsExternal).url;
|
||||
const isInternal = !!(props as ILinkPropsInternal).to;
|
||||
const content = (
|
||||
<span className="cursor-pointer font-bold text-bink-600 hover:text-bink-700">
|
||||
<span className="cursor-pointer font-bold text-type-link hover:text-type-linkHover">
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
import { SessionResponse } from "@/backend/accounts/auth";
|
||||
import { bookmarkMediaToInput } from "@/backend/accounts/bookmarks";
|
||||
import {
|
||||
bytesToBase64,
|
||||
|
@ -16,7 +17,13 @@ import {
|
|||
registerAccount,
|
||||
} from "@/backend/accounts/register";
|
||||
import { removeSession } from "@/backend/accounts/sessions";
|
||||
import { getBookmarks, getProgress, getUser } from "@/backend/accounts/user";
|
||||
import { getSettings } from "@/backend/accounts/settings";
|
||||
import {
|
||||
UserResponse,
|
||||
getBookmarks,
|
||||
getProgress,
|
||||
getUser,
|
||||
} from "@/backend/accounts/user";
|
||||
import { useAuthData } from "@/hooks/auth/useAuthData";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { AccountWithToken, useAuthStore } from "@/stores/auth";
|
||||
|
@ -90,7 +97,7 @@ export function useAuth() {
|
|||
} catch {
|
||||
// we dont care about failing to delete session
|
||||
}
|
||||
userDataLogout();
|
||||
await userDataLogout();
|
||||
}, [userDataLogout, backendUrl, currentAccount]);
|
||||
|
||||
const register = useCallback(
|
||||
|
@ -148,18 +155,32 @@ export function useAuth() {
|
|||
[backendUrl]
|
||||
);
|
||||
|
||||
const restore = useCallback(async () => {
|
||||
if (!currentAccount) {
|
||||
return;
|
||||
}
|
||||
const restore = useCallback(
|
||||
async (account: AccountWithToken) => {
|
||||
let user: { user: UserResponse; session: SessionResponse };
|
||||
try {
|
||||
user = await getUser(backendUrl, account.token);
|
||||
} catch (err) {
|
||||
const anyError: any = err;
|
||||
if (
|
||||
anyError?.response?.status === 401 ||
|
||||
anyError?.response?.status === 403
|
||||
) {
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// TODO if fail to get user, log them out
|
||||
const user = await getUser(backendUrl, currentAccount.token);
|
||||
const bookmarks = await getBookmarks(backendUrl, currentAccount);
|
||||
const progress = await getProgress(backendUrl, currentAccount);
|
||||
const bookmarks = await getBookmarks(backendUrl, account);
|
||||
const progress = await getProgress(backendUrl, account);
|
||||
const settings = await getSettings(backendUrl, account);
|
||||
|
||||
syncData(user.user, user.session, progress, bookmarks);
|
||||
}, [backendUrl, currentAccount, syncData]);
|
||||
syncData(user.user, user.session, progress, bookmarks, settings);
|
||||
},
|
||||
[backendUrl, syncData, logout]
|
||||
);
|
||||
|
||||
return {
|
||||
loggedIn,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
import { LoginResponse, SessionResponse } from "@/backend/accounts/auth";
|
||||
import { SettingsResponse } from "@/backend/accounts/settings";
|
||||
import {
|
||||
BookmarkResponse,
|
||||
ProgressResponse,
|
||||
|
@ -10,7 +11,10 @@ import {
|
|||
} from "@/backend/accounts/user";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useBookmarkStore } from "@/stores/bookmarks";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { useProgressStore } from "@/stores/progress";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
|
||||
export function useAuthData() {
|
||||
const loggedIn = !!useAuthStore((s) => s.account);
|
||||
|
@ -18,6 +22,9 @@ export function useAuthData() {
|
|||
const removeAccount = useAuthStore((s) => s.removeAccount);
|
||||
const clearBookmarks = useBookmarkStore((s) => s.clear);
|
||||
const clearProgress = useProgressStore((s) => s.clear);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
const setAppLanguage = useLanguageStore((s) => s.setLanguage);
|
||||
const setCaptionLanguage = useSubtitleStore((s) => s.setLanguage);
|
||||
|
||||
const replaceBookmarks = useBookmarkStore((s) => s.replaceBookmarks);
|
||||
const replaceItems = useProgressStore((s) => s.replaceItems);
|
||||
|
@ -47,7 +54,6 @@ export function useAuthData() {
|
|||
removeAccount();
|
||||
clearBookmarks();
|
||||
clearProgress();
|
||||
// TODO clear settings
|
||||
}, [removeAccount, clearBookmarks, clearProgress]);
|
||||
|
||||
const syncData = useCallback(
|
||||
|
@ -55,13 +61,31 @@ export function useAuthData() {
|
|||
_user: UserResponse,
|
||||
_session: SessionResponse,
|
||||
progress: ProgressResponse[],
|
||||
bookmarks: BookmarkResponse[]
|
||||
bookmarks: BookmarkResponse[],
|
||||
settings: SettingsResponse
|
||||
) => {
|
||||
// TODO sync user settings
|
||||
replaceBookmarks(bookmarkResponsesToEntries(bookmarks));
|
||||
replaceItems(progressResponsesToEntries(progress));
|
||||
|
||||
if (settings.applicationLanguage) {
|
||||
setAppLanguage(settings.applicationLanguage);
|
||||
}
|
||||
|
||||
if (settings.defaultSubtitleLanguage) {
|
||||
setCaptionLanguage(settings.defaultSubtitleLanguage);
|
||||
}
|
||||
|
||||
if (settings.applicationTheme) {
|
||||
setTheme(settings.applicationTheme);
|
||||
}
|
||||
},
|
||||
[replaceBookmarks, replaceItems]
|
||||
[
|
||||
replaceBookmarks,
|
||||
replaceItems,
|
||||
setAppLanguage,
|
||||
setCaptionLanguage,
|
||||
setTheme,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -2,20 +2,22 @@ import { useRef } from "react";
|
|||
import { useAsync, useInterval } from "react-use";
|
||||
|
||||
import { useAuth } from "@/hooks/auth/useAuth";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const AUTH_CHECK_INTERVAL = 12 * 60 * 60 * 1000;
|
||||
|
||||
export function useAuthRestore() {
|
||||
const { account } = useAuthStore();
|
||||
const { restore } = useAuth();
|
||||
const hasRestored = useRef(false);
|
||||
|
||||
useInterval(() => {
|
||||
restore();
|
||||
if (account) restore(account);
|
||||
}, AUTH_CHECK_INTERVAL);
|
||||
|
||||
const result = useAsync(async () => {
|
||||
if (hasRestored.current) return;
|
||||
await restore().finally(() => {
|
||||
if (hasRestored.current || !account) return;
|
||||
await restore(account).finally(() => {
|
||||
hasRestored.current = true;
|
||||
});
|
||||
}, []); // no deps because we don't want to it ever rerun after the first time
|
||||
|
|
79
src/hooks/useSettingsState.ts
Normal file
79
src/hooks/useSettingsState.ts
Normal file
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -3,7 +3,7 @@ import "./stores/__old/imports";
|
|||
import "@/setup/ga";
|
||||
import "@/setup/index.css";
|
||||
|
||||
import React, { Suspense } from "react";
|
||||
import React, { Suspense, useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
|
@ -11,15 +11,22 @@ import { BrowserRouter, HashRouter } from "react-router-dom";
|
|||
import { useAsync } from "react-use";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import { Button } from "@/components/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { useAuthRestore } from "@/hooks/auth/useAuthRestore";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { ErrorBoundary } from "@/pages/errors/ErrorBoundary";
|
||||
import { MigrationPart } from "@/pages/parts/migrations/MigrationPart";
|
||||
import { LargeTextPart } from "@/pages/parts/util/LargeTextPart";
|
||||
import App from "@/setup/App";
|
||||
import { conf } from "@/setup/config";
|
||||
import i18n from "@/setup/i18n";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { BookmarkSyncer } from "@/stores/bookmarks/BookmarkSyncer";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { ProgressSyncer } from "@/stores/progress/ProgressSyncer";
|
||||
import { SettingsSyncer } from "@/stores/subtitles/SettingsSyncer";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
|
||||
import { initializeChromecast } from "./setup/chromecast";
|
||||
|
@ -37,15 +44,52 @@ registerSW({
|
|||
});
|
||||
|
||||
function LoadingScreen(props: { type: "user" | "lazy" }) {
|
||||
return <p>Loading: {props.type}</p>;
|
||||
return (
|
||||
<LargeTextPart iconSlot={<Loading />}>Loading {props.type}</LargeTextPart>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorScreen(props: {
|
||||
children: ReactNode;
|
||||
showResetButton?: boolean;
|
||||
}) {
|
||||
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
|
||||
const resetBackend = useCallback(() => {
|
||||
setBackendUrl(null);
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
location.reload();
|
||||
}, [setBackendUrl]);
|
||||
|
||||
return (
|
||||
<LargeTextPart
|
||||
iconSlot={
|
||||
<Icon className="text-type-danger text-2xl" icon={Icons.WARNING} />
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
{props.showResetButton ? (
|
||||
<div className="mt-6">
|
||||
<Button theme="secondary" onClick={resetBackend}>
|
||||
Reset back-end
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</LargeTextPart>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthWrapper() {
|
||||
const status = useAuthRestore();
|
||||
const backendUrl = conf().BACKEND_URL;
|
||||
const userBackendUrl = useBackendUrl();
|
||||
|
||||
// TODO what to do when failing to load user data?
|
||||
if (status.loading) return <LoadingScreen type="user" />;
|
||||
if (status.error) return <p>Failed to fetch user data</p>;
|
||||
if (status.error)
|
||||
return (
|
||||
<ErrorScreen showResetButton={backendUrl !== userBackendUrl}>
|
||||
Failed to fetch user data. Try resetting the backend URL.
|
||||
</ErrorScreen>
|
||||
);
|
||||
return <App />;
|
||||
}
|
||||
|
||||
|
@ -56,7 +100,8 @@ function MigrationRunner() {
|
|||
}, []);
|
||||
|
||||
if (status.loading) return <MigrationPart />;
|
||||
if (status.error) return <p>Failed to migrate</p>;
|
||||
if (status.error)
|
||||
return <ErrorScreen>Failed to migrate your data.</ErrorScreen>;
|
||||
return <AuthWrapper />;
|
||||
}
|
||||
|
||||
|
@ -82,6 +127,7 @@ ReactDOM.render(
|
|||
<ThemeProvider>
|
||||
<ProgressSyncer />
|
||||
<BookmarkSyncer />
|
||||
<SettingsSyncer />
|
||||
<TheRouter>
|
||||
<MigrationRunner />
|
||||
</TheRouter>
|
||||
|
|
|
@ -3,28 +3,32 @@ import { useEffect } from "react";
|
|||
import { useAsyncFn } from "react-use";
|
||||
|
||||
import { getSessions } from "@/backend/accounts/sessions";
|
||||
import { Button } from "@/components/Button";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { AccountActionsPart } from "@/pages/settings/AccountActionsPart";
|
||||
import { AccountEditPart } from "@/pages/settings/AccountEditPart";
|
||||
import { CaptionsPart } from "@/pages/settings/CaptionsPart";
|
||||
import { DeviceListPart } from "@/pages/settings/DeviceListPart";
|
||||
import { RegisterCalloutPart } from "@/pages/settings/RegisterCalloutPart";
|
||||
import { SidebarPart } from "@/pages/settings/SidebarPart";
|
||||
import { ThemePart } from "@/pages/settings/ThemePart";
|
||||
import { useSettingsState } from "@/hooks/useSettingsState";
|
||||
import { AccountActionsPart } from "@/pages/parts/settings/AccountActionsPart";
|
||||
import { AccountEditPart } from "@/pages/parts/settings/AccountEditPart";
|
||||
import { CaptionsPart } from "@/pages/parts/settings/CaptionsPart";
|
||||
import { DeviceListPart } from "@/pages/parts/settings/DeviceListPart";
|
||||
import { RegisterCalloutPart } from "@/pages/parts/settings/RegisterCalloutPart";
|
||||
import { SidebarPart } from "@/pages/parts/settings/SidebarPart";
|
||||
import { ThemePart } from "@/pages/parts/settings/ThemePart";
|
||||
import { AccountWithToken, useAuthStore } from "@/stores/auth";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { useSubtitleStore } from "@/stores/subtitles";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
|
||||
import { SubPageLayout } from "./layouts/SubPageLayout";
|
||||
import { LocalePart } from "./settings/LocalePart";
|
||||
import { LocalePart } from "./parts/settings/LocalePart";
|
||||
|
||||
function SettingsLayout(props: { children: React.ReactNode }) {
|
||||
const { isMobile } = useIsMobile();
|
||||
|
||||
return (
|
||||
<WideContainer ultraWide>
|
||||
<WideContainer ultraWide classNames="overflow-visible">
|
||||
<div
|
||||
className={classNames(
|
||||
"grid gap-12",
|
||||
|
@ -65,8 +69,22 @@ export function AccountSettings(props: { account: AccountWithToken }) {
|
|||
export function SettingsPage() {
|
||||
const activeTheme = useThemeStore((s) => s.theme);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
|
||||
const appLanguage = useLanguageStore((s) => s.language);
|
||||
|
||||
const subStyling = useSubtitleStore((s) => s.styling);
|
||||
|
||||
const deviceName = useAuthStore((s) => s.account?.deviceName);
|
||||
|
||||
const user = useAuthStore();
|
||||
|
||||
const state = useSettingsState(
|
||||
activeTheme,
|
||||
appLanguage,
|
||||
subStyling,
|
||||
deviceName
|
||||
);
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<SettingsLayout>
|
||||
|
@ -81,15 +99,31 @@ export function SettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
<div id="settings-locale" className="mt-48">
|
||||
<LocalePart />
|
||||
<LocalePart
|
||||
language={state.appLanguage.state}
|
||||
setLanguage={state.appLanguage.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-appearance" className="mt-48">
|
||||
<ThemePart active={activeTheme} setTheme={setTheme} />
|
||||
<ThemePart active={state.theme.state} setTheme={state.theme.set} />
|
||||
</div>
|
||||
<div id="settings-captions" className="mt-48">
|
||||
<CaptionsPart />
|
||||
<CaptionsPart
|
||||
styling={state.subtitleStyling.state}
|
||||
setStyling={(s) => s}
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
|
||||
{state.changed ? (
|
||||
<div className="bg-settings-saveBar-background border-t border-settings-card-border/50 py-4 w-full fixed bottom-0 flex justify-between px-8 items-center">
|
||||
<p className="text-type-danger">You have unsaved changes</p>
|
||||
<div className="space-x-6">
|
||||
<Button theme="secondary">Reset</Button>
|
||||
<Button theme="purple">Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
|||
|
||||
await importData(account, progressItems, bookmarkItems);
|
||||
|
||||
await restore();
|
||||
await restore(account);
|
||||
|
||||
props.onLogin?.();
|
||||
},
|
||||
|
|
|
@ -73,7 +73,7 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
|
|||
applicationTheme: applicationTheme ?? undefined,
|
||||
});
|
||||
|
||||
await restore();
|
||||
await restore(account);
|
||||
|
||||
props.onNext?.();
|
||||
},
|
||||
|
|
|
@ -17,10 +17,6 @@ export function MigrationPart() {
|
|||
Please hold, we are migrating your data. This shouldn't take long.
|
||||
Also, fuck you.
|
||||
</p>
|
||||
<div className="w-[8rem] h-1 rounded-full bg-progress-background bg-opacity-25 mb-2">
|
||||
<div className="w-1/4 h-full bg-progress-filled rounded-full" />
|
||||
</div>
|
||||
<p>25%</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,9 @@ function SearchSuffix(props: { failed?: boolean; results?: number }) {
|
|||
<div className="mb-24 mt-40 flex flex-col items-center justify-center space-y-3 text-center">
|
||||
<IconPatch
|
||||
icon={icon}
|
||||
className={`text-xl ${props.failed ? "text-red-400" : "text-bink-600"}`}
|
||||
className={`text-xl ${
|
||||
props.failed ? "text-red-400" : "text-type-logo"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* standard suffix */}
|
||||
|
|
|
@ -2,30 +2,37 @@ import { UserAvatar } from "@/components/Avatar";
|
|||
import { Button } from "@/components/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SettingsCard } from "@/components/layout/SettingsCard";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { AuthInputBox } from "@/components/text-inputs/AuthInputBox";
|
||||
import { useAuth } from "@/hooks/auth/useAuth";
|
||||
import { ProfileEditModal } from "@/pages/parts/settings/ProfileEditModal";
|
||||
|
||||
export function AccountEditPart() {
|
||||
const { logout } = useAuth();
|
||||
const profileEditModal = useModal("profile-edit");
|
||||
|
||||
return (
|
||||
<SettingsCard paddingClass="px-8 py-10" className="!mt-8">
|
||||
<ProfileEditModal id={profileEditModal.id} />
|
||||
<div className="grid lg:grid-cols-[auto,1fr] gap-8">
|
||||
<div>
|
||||
<UserAvatar
|
||||
iconClass="text-5xl"
|
||||
sizeClass="w-32 h-32"
|
||||
bottom={
|
||||
<div className="text-xs flex gap-2 items-center bg-editBadge-bg text-editBadge-text hover:bg-editBadge-bgHover py-1 px-3 rounded-full cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
className="tabbable text-xs flex gap-2 items-center bg-editBadge-bg text-editBadge-text hover:bg-editBadge-bgHover py-1 px-3 rounded-full cursor-pointer"
|
||||
onClick={profileEditModal.show}
|
||||
>
|
||||
<Icon icon={Icons.EDIT} />
|
||||
Edit
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-8 max-w-xs">
|
||||
<AuthInputBox label="Account name" placeholder="Muad'Dib" />
|
||||
<AuthInputBox label="Device name" placeholder="Fremen tablet" />
|
||||
<div className="flex space-x-3">
|
||||
<Button theme="purple">Save account</Button>
|
|
@ -62,10 +62,11 @@ export function CaptionPreview(props: {
|
|||
);
|
||||
}
|
||||
|
||||
export function CaptionsPart() {
|
||||
const styling = useSubtitleStore((s) => s.styling);
|
||||
export function CaptionsPart(props: {
|
||||
styling: SubtitleStyling;
|
||||
setStyling: (s: SubtitleStyling) => void;
|
||||
}) {
|
||||
const [fullscreenPreview, setFullscreenPreview] = useState(false);
|
||||
const updateStyling = useSubtitleStore((s) => s.updateStyling);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -76,8 +77,10 @@ export function CaptionsPart() {
|
|||
label="Background opacity"
|
||||
max={100}
|
||||
min={0}
|
||||
onChange={(v) => updateStyling({ backgroundOpacity: v / 100 })}
|
||||
value={styling.backgroundOpacity * 100}
|
||||
onChange={(v) =>
|
||||
props.setStyling({ ...props.styling, backgroundOpacity: v / 100 })
|
||||
}
|
||||
value={props.styling.backgroundOpacity * 100}
|
||||
textTransformer={(s) => `${s}%`}
|
||||
/>
|
||||
<CaptionSetting
|
||||
|
@ -85,17 +88,21 @@ export function CaptionsPart() {
|
|||
max={200}
|
||||
min={1}
|
||||
textTransformer={(s) => `${s}%`}
|
||||
onChange={(v) => updateStyling({ size: v / 100 })}
|
||||
value={styling.size * 100}
|
||||
onChange={(v) =>
|
||||
props.setStyling({ ...props.styling, size: v / 100 })
|
||||
}
|
||||
value={props.styling.size * 100}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<Menu.FieldTitle>Color</Menu.FieldTitle>
|
||||
<div className="flex justify-center items-center">
|
||||
{colors.map((v) => (
|
||||
<ColorOption
|
||||
onClick={() => updateStyling({ color: v })}
|
||||
onClick={() =>
|
||||
props.setStyling({ ...props.styling, color: v })
|
||||
}
|
||||
color={v}
|
||||
active={styling.color === v}
|
||||
active={props.styling.color === v}
|
||||
key={v}
|
||||
/>
|
||||
))}
|
||||
|
@ -104,13 +111,13 @@ export function CaptionsPart() {
|
|||
</div>
|
||||
<CaptionPreview
|
||||
show
|
||||
styling={styling}
|
||||
styling={props.styling}
|
||||
onToggle={() => setFullscreenPreview((s) => !s)}
|
||||
/>
|
||||
<CaptionPreview
|
||||
show={fullscreenPreview}
|
||||
fullscreen
|
||||
styling={styling}
|
||||
styling={props.styling}
|
||||
onToggle={() => setFullscreenPreview((s) => !s)}
|
||||
/>
|
||||
</div>
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo } from "react";
|
||||
import { useAsyncFn } from "react-use";
|
||||
|
||||
import { SessionResponse } from "@/backend/accounts/auth";
|
||||
|
@ -50,7 +51,25 @@ export function DeviceListPart(props: {
|
|||
onChange?: () => void;
|
||||
}) {
|
||||
const seed = useAuthStore((s) => s.account?.seed);
|
||||
const sessions = props.sessions;
|
||||
const currentSessionId = useAuthStore((s) => s.account?.sessionId);
|
||||
const deviceListSorted = useMemo(() => {
|
||||
if (!seed) return [];
|
||||
let list = sessions.map((session) => {
|
||||
const decryptedName = decryptData(session.device, base64ToBuffer(seed));
|
||||
return {
|
||||
current: session.id === currentSessionId,
|
||||
id: session.id,
|
||||
name: decryptedName,
|
||||
};
|
||||
});
|
||||
list = list.sort((a, b) => {
|
||||
if (a.current) return -1;
|
||||
if (b.current) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return list;
|
||||
}, [seed, sessions, currentSessionId]);
|
||||
if (!seed) return null;
|
||||
|
||||
return (
|
||||
|
@ -64,21 +83,15 @@ export function DeviceListPart(props: {
|
|||
<Loading />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{props.sessions.map((session) => {
|
||||
const decryptedName = decryptData(
|
||||
session.device,
|
||||
base64ToBuffer(seed)
|
||||
);
|
||||
return (
|
||||
<Device
|
||||
name={decryptedName}
|
||||
id={session.id}
|
||||
key={session.id}
|
||||
isCurrent={session.id === currentSessionId}
|
||||
onRemove={props.onChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{deviceListSorted.map((session) => (
|
||||
<Device
|
||||
name={session.name}
|
||||
id={session.id}
|
||||
key={session.id}
|
||||
isCurrent={session.current}
|
||||
onRemove={props.onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
|
@ -2,12 +2,13 @@ import { Dropdown } from "@/components/Dropdown";
|
|||
import { FlagIcon } from "@/components/FlagIcon";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { appLanguageOptions } from "@/setup/i18n";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { sortLangCodes } from "@/utils/sortLangCodes";
|
||||
|
||||
export function LocalePart() {
|
||||
export function LocalePart(props: {
|
||||
language: string;
|
||||
setLanguage: (l: string) => void;
|
||||
}) {
|
||||
const sorted = sortLangCodes(appLanguageOptions.map((t) => t.id));
|
||||
const { language, setLanguage } = useLanguageStore();
|
||||
|
||||
const options = appLanguageOptions
|
||||
.sort((a, b) => sorted.indexOf(a.id) - sorted.indexOf(b.id))
|
||||
|
@ -17,7 +18,7 @@ export function LocalePart() {
|
|||
leftIcon: <FlagIcon countryCode={opt.id} />,
|
||||
}));
|
||||
|
||||
const selected = options.find((t) => t.id === language);
|
||||
const selected = options.find((t) => t.id === props.language);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -29,7 +30,7 @@ export function LocalePart() {
|
|||
<Dropdown
|
||||
options={options}
|
||||
selectedItem={selected || options[0]}
|
||||
setSelectedItem={(opt) => setLanguage(opt.id)}
|
||||
setSelectedItem={(opt) => props.setLanguage(opt.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
15
src/pages/parts/settings/ProfileEditModal.tsx
Normal file
15
src/pages/parts/settings/ProfileEditModal.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -11,7 +11,7 @@ export function RegisterCalloutPart() {
|
|||
<div>
|
||||
<SolidSettingsCard
|
||||
paddingClass="px-6 py-12"
|
||||
className="grid grid-cols-2 gap-12"
|
||||
className="grid grid-cols-2 gap-12 mt-5"
|
||||
>
|
||||
<div>
|
||||
<Heading3>Sync to the cloud</Heading3>
|
|
@ -28,6 +28,7 @@ export function SidebarPart() {
|
|||
const windowHeight =
|
||||
window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
// TODO this detection does not work
|
||||
const viewList = settingLinks
|
||||
.map((link) => {
|
||||
const el = document.getElementById(link.id);
|
23
src/pages/parts/util/LargeTextPart.tsx
Normal file
23
src/pages/parts/util/LargeTextPart.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
html,
|
||||
body {
|
||||
@apply bg-background-main font-open-sans text-denim-700 overflow-x-hidden;
|
||||
@apply bg-background-main font-open-sans text-type-text overflow-x-hidden;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
position: relative;
|
||||
|
|
1
src/stores/__old/DONT_TOUCH_THIS_FOLDER
Normal file
1
src/stores/__old/DONT_TOUCH_THIS_FOLDER
Normal file
|
@ -0,0 +1 @@
|
|||
just dont, it's old stuff that needs to stay for legacy localstorage
|
|
@ -26,6 +26,7 @@ interface AuthStore {
|
|||
setAccount(acc: AccountWithToken): void;
|
||||
updateDeviceName(deviceName: string): void;
|
||||
updateAccount(acc: Account): void;
|
||||
setBackendUrl(url: null | string): void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create(
|
||||
|
@ -44,6 +45,11 @@ export const useAuthStore = create(
|
|||
s.account = null;
|
||||
});
|
||||
},
|
||||
setBackendUrl(v) {
|
||||
set((s) => {
|
||||
s.backendUrl = v;
|
||||
});
|
||||
},
|
||||
updateAccount(acc) {
|
||||
set((s) => {
|
||||
if (!s.account) return;
|
||||
|
|
|
@ -17,7 +17,7 @@ async function syncBookmarks(
|
|||
// complete it beforehand so it doesn't get handled while in progress
|
||||
finish(item.id);
|
||||
|
||||
if (!account) return; // not logged in, dont sync to server
|
||||
if (!account) continue; // not logged in, dont sync to server
|
||||
|
||||
try {
|
||||
if (item.action === "delete") {
|
||||
|
|
|
@ -80,7 +80,9 @@ export const useBookmarkStore = create(
|
|||
});
|
||||
},
|
||||
clear() {
|
||||
this.replaceBookmarks({});
|
||||
set((s) => {
|
||||
s.bookmarks = {};
|
||||
});
|
||||
},
|
||||
clearUpdateQueue() {
|
||||
set((s) => {
|
||||
|
|
|
@ -21,7 +21,7 @@ async function syncProgress(
|
|||
// complete it beforehand so it doesn't get handled while in progress
|
||||
finish(item.id);
|
||||
|
||||
if (!account) return; // not logged in, dont sync to server
|
||||
if (!account) continue; // not logged in, dont sync to server
|
||||
|
||||
try {
|
||||
if (item.action === "delete") {
|
||||
|
|
|
@ -159,7 +159,9 @@ export const useProgressStore = create(
|
|||
});
|
||||
},
|
||||
clear() {
|
||||
this.replaceItems({});
|
||||
set((s) => {
|
||||
s.items = {};
|
||||
});
|
||||
},
|
||||
clearUpdateQueue() {
|
||||
set((s) => {
|
||||
|
|
38
src/stores/subtitles/SettingsSyncer.tsx
Normal file
38
src/stores/subtitles/SettingsSyncer.tsx
Normal file
|
@ -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;
|
||||
}
|
|
@ -20,6 +20,9 @@ export interface SubtitleStyling {
|
|||
}
|
||||
|
||||
export interface SubtitleStore {
|
||||
lastSync: {
|
||||
lastSelectedLanguage: string | null;
|
||||
};
|
||||
enabled: boolean;
|
||||
lastSelectedLanguage: string | null;
|
||||
styling: SubtitleStyling;
|
||||
|
@ -37,6 +40,9 @@ export const useSubtitleStore = create(
|
|||
persist(
|
||||
immer<SubtitleStore>((set) => ({
|
||||
enabled: false,
|
||||
lastSync: {
|
||||
lastSelectedLanguage: null,
|
||||
},
|
||||
lastSelectedLanguage: null,
|
||||
overrideCasing: false,
|
||||
delay: 0,
|
||||
|
@ -80,6 +86,7 @@ export const useSubtitleStore = create(
|
|||
importSubtitleLanguage(lang) {
|
||||
set((s) => {
|
||||
s.lastSelectedLanguage = lang;
|
||||
s.lastSync.lastSelectedLanguage = lang;
|
||||
});
|
||||
},
|
||||
})),
|
||||
|
|
|
@ -8,31 +8,6 @@ const config: Config = {
|
|||
safelist: safeThemeList,
|
||||
theme: {
|
||||
extend: {
|
||||
// TODO remove old colors
|
||||
/* colors */
|
||||
colors: {
|
||||
"bink-100": "#432449",
|
||||
"bink-200": "#412B57",
|
||||
"bink-300": "#533670",
|
||||
"bink-400": "#714C97",
|
||||
"bink-500": "#8D66B5",
|
||||
"bink-600": "#A87FD1",
|
||||
"bink-700": "#CD97D6",
|
||||
"denim-100": "#120F1D",
|
||||
"denim-200": "#191526",
|
||||
"denim-300": "#211D30",
|
||||
"denim-400": "#2B263D",
|
||||
"denim-500": "#38334A",
|
||||
"denim-600": "#504B64",
|
||||
"denim-700": "#7A758F",
|
||||
"ash-600": "#817998",
|
||||
"ash-500": "#9C93B5",
|
||||
"ash-400": "#3D394D",
|
||||
"ash-300": "#2C293A",
|
||||
"ash-200": "#2B2836",
|
||||
"ash-100": "#1E1C26"
|
||||
},
|
||||
|
||||
/* fonts */
|
||||
fontFamily: {
|
||||
"open-sans": "'Open Sans'"
|
||||
|
|
|
@ -10,26 +10,28 @@ export const defaultTheme = {
|
|||
// Branding
|
||||
pill: {
|
||||
background: "#1C1C36",
|
||||
backgroundHover: "#1C1C36",
|
||||
highlight: "#714C97",
|
||||
},
|
||||
|
||||
|
||||
// meta data for the theme itself
|
||||
global: {
|
||||
accentA: "#505DBD",
|
||||
accentB: "#3440A1",
|
||||
},
|
||||
|
||||
|
||||
// light bar
|
||||
lightBar: {
|
||||
light: "#2A2A71",
|
||||
},
|
||||
|
||||
|
||||
// Buttons
|
||||
buttons: {
|
||||
toggle: "#8D44D6",
|
||||
toggleDisabled: "#202836",
|
||||
danger: "#792131",
|
||||
dangerHover: "#8a293b",
|
||||
|
||||
|
||||
secondary: "#161F25",
|
||||
secondaryText: "#8EA3B0",
|
||||
secondaryHover: "#1B262E",
|
||||
|
@ -41,22 +43,27 @@ export const defaultTheme = {
|
|||
cancel: "#252533",
|
||||
cancelHover: "#3C3C4A",
|
||||
},
|
||||
|
||||
|
||||
// only used for body colors/textures
|
||||
background: {
|
||||
main: "#0A0A10",
|
||||
secondary: "#151529",
|
||||
secondaryHover: "#252542",
|
||||
accentA: "#6E3B80",
|
||||
accentB: "#1F1F50",
|
||||
},
|
||||
|
||||
|
||||
// typography
|
||||
type: {
|
||||
logo: "#A87FD1",
|
||||
emphasis: "#FFFFFF",
|
||||
text: "#73739D",
|
||||
dimmed: "#926CAD",
|
||||
divider: "#262632",
|
||||
secondary: "#64647B",
|
||||
danger: "#F46E6E",
|
||||
link: "#A87FD1",
|
||||
linkHover: "#A87FD1",
|
||||
},
|
||||
|
||||
// search bar
|
||||
|
@ -127,6 +134,10 @@ export const defaultTheme = {
|
|||
background: "#29243D",
|
||||
altBackground: "#29243D",
|
||||
},
|
||||
|
||||
saveBar: {
|
||||
background: "#0F0E17"
|
||||
}
|
||||
},
|
||||
|
||||
utils: {
|
||||
|
|
Loading…
Reference in a new issue