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:
mrjvs 2023-11-24 17:11:00 +01:00
parent 415419f3ef
commit 5a5f3e8b8c
53 changed files with 484 additions and 346 deletions

View File

@ -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",

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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() {

View File

@ -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);
}, []);

View File

@ -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>
);
});

View File

@ -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 ? (

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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"
: ""
)}
>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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} />

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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]
);

View File

@ -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} />

View File

@ -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 ? (

View File

@ -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>
);

View File

@ -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,

View File

@ -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 {

View File

@ -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

View 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,
},
};
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -40,7 +40,7 @@ export function LoginFormPart(props: LoginFormPartProps) {
await importData(account, progressItems, bookmarkItems);
await restore();
await restore(account);
props.onLogin?.();
},

View File

@ -73,7 +73,7 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) {
applicationTheme: applicationTheme ?? undefined,
});
await restore();
await restore(account);
props.onNext?.();
},

View File

@ -17,10 +17,6 @@ export function MigrationPart() {
Please hold, we are migrating your data. This shouldn&apos;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>
);
}

View File

@ -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 */}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
);

View 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>
);
}

View File

@ -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>

View File

@ -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);

View 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>
);
}

View File

@ -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;

View File

@ -0,0 +1 @@
just dont, it's old stuff that needs to stay for legacy localstorage

View File

@ -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;

View File

@ -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") {

View File

@ -80,7 +80,9 @@ export const useBookmarkStore = create(
});
},
clear() {
this.replaceBookmarks({});
set((s) => {
s.bookmarks = {};
});
},
clearUpdateQueue() {
set((s) => {

View File

@ -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") {

View File

@ -159,7 +159,9 @@ export const useProgressStore = create(
});
},
clear() {
this.replaceItems({});
set((s) => {
s.items = {};
});
},
clearUpdateQueue() {
set((s) => {

View 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;
}

View File

@ -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;
});
},
})),

View File

@ -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'"

View File

@ -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: {