diff --git a/.eslintrc.js b/.eslintrc.js index ba418e3c..7f458a53 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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", diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 6968dfba..2e6cd99e 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -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: { ); } + +export function NoUserAvatar(props: { + sizeClass?: string; + iconClass?: string; +}) { + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 122c3e5e..d3cb027b 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -21,7 +21,7 @@ export function Dropdown(props: DropdownProps) { {() => ( <> - + {props.selectedItem.leftIcon ? props.selectedItem.leftIcon @@ -41,12 +41,14 @@ export function Dropdown(props: DropdownProps) { leaveFrom="opacity-100" leaveTo="opacity-0" > - + {props.options.map((opt) => ( `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} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index c23d3047..b4fa1c10 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -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 = { settings: ``, coins: ``, logout: ``, + menu: ``, }; function ChromeCastButton() { diff --git a/src/components/LinksDropdown.tsx b/src/components/LinksDropdown.tsx index 7ba4737a..f8679c4f 100644 --- a/src/components/LinksDropdown.tsx +++ b/src/components/LinksDropdown.tsx @@ -29,6 +29,7 @@ function GoToLink(props: { return ( { evt.preventDefault(); @@ -100,7 +101,6 @@ export function LinksDropdown(props: { children: React.ReactNode }) { }, []); const toggleOpen = useCallback(() => { - console.log("yay"); setOpen((s) => !s); }, []); diff --git a/src/components/buttons/DropdownButton.tsx b/src/components/buttons/DropdownButton.tsx deleted file mode 100644 index 8e252231..00000000 --- a/src/components/buttons/DropdownButton.tsx +++ /dev/null @@ -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; -} - -export interface OptionProps { - option: OptionItem; - onClick: MouseEventHandler; - tabIndex?: number; -} - -function Option({ option, onClick, tabIndex }: OptionProps) { - return ( -
- - - -
- ); -} - -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; - - 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 ( -
-
- props.setOpen(false)} - {...backdropProps} - > - - - {selectedItem.name} - - -
- {props.options - .filter((opt) => opt.id !== delayedSelectedId) - .map((opt) => ( -
-
-
-
- ); -}); diff --git a/src/components/buttons/EditButton.tsx b/src/components/buttons/EditButton.tsx index 4571af10..b46504a0 100644 --- a/src/components/buttons/EditButton.tsx +++ b/src/components/buttons/EditButton.tsx @@ -22,7 +22,7 @@ export function EditButton(props: EditButtonProps) { return ( {props.editing ? ( diff --git a/src/components/buttons/IconButton.tsx b/src/components/buttons/IconButton.tsx deleted file mode 100644 index 0a33a878..00000000 --- a/src/components/buttons/IconButton.tsx +++ /dev/null @@ -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 ( - - - {props.children} - - ); -} diff --git a/src/components/buttons/IconPatch.tsx b/src/components/buttons/IconPatch.tsx index 89a4a23f..302d14b2 100644 --- a/src/components/buttons/IconPatch.tsx +++ b/src/components/buttons/IconPatch.tsx @@ -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"; diff --git a/src/components/layout/BrandPill.tsx b/src/components/layout/BrandPill.tsx index 929b6d18..27a10ecc 100644 --- a/src/components/layout/BrandPill.tsx +++ b/src/components/layout/BrandPill.tsx @@ -13,10 +13,10 @@ export function BrandPill(props: { return (
diff --git a/src/components/layout/IconPill.tsx b/src/components/layout/IconPill.tsx index 70656617..6530e773 100644 --- a/src/components/layout/IconPill.tsx +++ b/src/components/layout/IconPill.tsx @@ -5,7 +5,7 @@ export function IconPill(props: { icon: Icons; children?: React.ReactNode }) {
{props.children}
diff --git a/src/components/layout/Loading.tsx b/src/components/layout/Loading.tsx index cff6a503..4c4c62bd 100644 --- a/src/components/layout/Loading.tsx +++ b/src/components/layout/Loading.tsx @@ -8,10 +8,10 @@ export function Loading(props: LoadingProps) {
-
-
-
-
+
+
+
+
{props.text && props.text.length ? (

{props.text}

diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index 0bb28a0b..ed666e53 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -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) {
- {loggedIn ? :

Not logged in

} + {loggedIn ? : }
diff --git a/src/components/layout/Paper.tsx b/src/components/layout/Paper.tsx deleted file mode 100644 index a87895ae..00000000 --- a/src/components/layout/Paper.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { ReactNode } from "react"; - -export interface PaperProps { - children?: ReactNode; - className?: string; -} - -export function Paper(props: PaperProps) { - return ( -
- {props.children} -
- ); -} diff --git a/src/components/layout/ProgressRing.tsx b/src/components/layout/ProgressRing.tsx index 6e3f93ac..d0e680a9 100644 --- a/src/components/layout/ProgressRing.tsx +++ b/src/components/layout/ProgressRing.tsx @@ -14,7 +14,7 @@ export function ProgressRing(props: Props) { viewBox="0 0 100 100" >
-

+

{props.icon ? ( diff --git a/src/components/media/EpisodeButton.tsx b/src/components/media/EpisodeButton.tsx deleted file mode 100644 index 76e38c85..00000000 --- a/src/components/media/EpisodeButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -export interface EpisodeProps { - progress?: number; - episodeNumber: number; - onClick?: () => void; - active?: boolean; -} - -export function Episode(props: EpisodeProps) { - return ( -

-
- {props.episodeNumber} -
- ); -} diff --git a/src/components/player/atoms/AutoPlayStart.tsx b/src/components/player/atoms/AutoPlayStart.tsx index a0019391..b5d02f29 100644 --- a/src/components/player/atoms/AutoPlayStart.tsx +++ b/src/components/player/atoms/AutoPlayStart.tsx @@ -23,7 +23,7 @@ export function AutoPlayStart() { return (
) => { - 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] ); diff --git a/src/components/text/ArrowLink.tsx b/src/components/text/ArrowLink.tsx index ad51319e..3690c1fc 100644 --- a/src/components/text/ArrowLink.tsx +++ b/src/components/text/ArrowLink.tsx @@ -27,7 +27,7 @@ export function ArrowLink(props: ArrowLinkProps) { const isExternal = !!(props as IArrowLinkPropsExternal).url; const isInternal = !!(props as IArrowLinkPropsInternal).to; const content = ( - + {direction === "left" ? ( diff --git a/src/components/text/DotList.tsx b/src/components/text/DotList.tsx index cda5be09..bb328a6d 100644 --- a/src/components/text/DotList.tsx +++ b/src/components/text/DotList.tsx @@ -5,7 +5,7 @@ export interface DotListProps { export function DotList(props: DotListProps) { return ( -

+

{props.content.map((item, index) => ( {index !== 0 ? ( diff --git a/src/components/text/Link.tsx b/src/components/text/Link.tsx index 1451114e..a4e6b2e8 100644 --- a/src/components/text/Link.tsx +++ b/src/components/text/Link.tsx @@ -22,7 +22,7 @@ export function Link(props: LinkProps) { const isExternal = !!(props as ILinkPropsExternal).url; const isInternal = !!(props as ILinkPropsInternal).to; const content = ( - + {props.children} ); diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index ddc44a2a..b0af9b25 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -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, diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts index 9fef5839..993e25c2 100644 --- a/src/hooks/auth/useAuthData.ts +++ b/src/hooks/auth/useAuthData.ts @@ -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 { diff --git a/src/hooks/auth/useAuthRestore.ts b/src/hooks/auth/useAuthRestore.ts index b3d67ed7..f0c8bb02 100644 --- a/src/hooks/auth/useAuthRestore.ts +++ b/src/hooks/auth/useAuthRestore.ts @@ -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 diff --git a/src/hooks/useSettingsState.ts b/src/hooks/useSettingsState.ts new file mode 100644 index 00000000..d2058ab8 --- /dev/null +++ b/src/hooks/useSettingsState.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { SubtitleStyling } from "@/stores/subtitles"; + +export function useDerived( + initial: T +): [T, (v: T) => void, () => void, boolean] { + const [overwrite, setOverwrite] = useState(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, + }, + }; +} diff --git a/src/index.tsx b/src/index.tsx index a5886a41..5eebd773 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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

Loading: {props.type}

; + return ( + }>Loading {props.type} + ); +} + +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 ( + + } + > + {props.children} + {props.showResetButton ? ( +
+ +
+ ) : null} +
+ ); } 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 ; - if (status.error) return

Failed to fetch user data

; + if (status.error) + return ( + + Failed to fetch user data. Try resetting the backend URL. + + ); return ; } @@ -56,7 +100,8 @@ function MigrationRunner() { }, []); if (status.loading) return ; - if (status.error) return

Failed to migrate

; + if (status.error) + return Failed to migrate your data.; return ; } @@ -82,6 +127,7 @@ ReactDOM.render( + diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 15f94e2c..e046a168 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 ( - +
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 ( @@ -81,15 +99,31 @@ export function SettingsPage() { )}
- +
- +
- + s} + />
+ + {state.changed ? ( +
+

You have unsaved changes

+
+ + +
+
+ ) : null} ); } diff --git a/src/pages/parts/auth/LoginFormPart.tsx b/src/pages/parts/auth/LoginFormPart.tsx index 6e603f0e..90898c05 100644 --- a/src/pages/parts/auth/LoginFormPart.tsx +++ b/src/pages/parts/auth/LoginFormPart.tsx @@ -40,7 +40,7 @@ export function LoginFormPart(props: LoginFormPartProps) { await importData(account, progressItems, bookmarkItems); - await restore(); + await restore(account); props.onLogin?.(); }, diff --git a/src/pages/parts/auth/VerifyPassphrasePart.tsx b/src/pages/parts/auth/VerifyPassphrasePart.tsx index b7458741..3f5e017a 100644 --- a/src/pages/parts/auth/VerifyPassphrasePart.tsx +++ b/src/pages/parts/auth/VerifyPassphrasePart.tsx @@ -73,7 +73,7 @@ export function VerifyPassphrase(props: VerifyPassphraseProps) { applicationTheme: applicationTheme ?? undefined, }); - await restore(); + await restore(account); props.onNext?.(); }, diff --git a/src/pages/parts/migrations/MigrationPart.tsx b/src/pages/parts/migrations/MigrationPart.tsx index da8c027e..fcf7ccbf 100644 --- a/src/pages/parts/migrations/MigrationPart.tsx +++ b/src/pages/parts/migrations/MigrationPart.tsx @@ -17,10 +17,6 @@ export function MigrationPart() { Please hold, we are migrating your data. This shouldn't take long. Also, fuck you.

-
-
-
-

25%

); } diff --git a/src/pages/parts/search/SearchListPart.tsx b/src/pages/parts/search/SearchListPart.tsx index a4be8231..5fff2484 100644 --- a/src/pages/parts/search/SearchListPart.tsx +++ b/src/pages/parts/search/SearchListPart.tsx @@ -21,7 +21,9 @@ function SearchSuffix(props: { failed?: boolean; results?: number }) {
{/* standard suffix */} diff --git a/src/pages/settings/AccountActionsPart.tsx b/src/pages/parts/settings/AccountActionsPart.tsx similarity index 100% rename from src/pages/settings/AccountActionsPart.tsx rename to src/pages/parts/settings/AccountActionsPart.tsx diff --git a/src/pages/settings/AccountEditPart.tsx b/src/pages/parts/settings/AccountEditPart.tsx similarity index 68% rename from src/pages/settings/AccountEditPart.tsx rename to src/pages/parts/settings/AccountEditPart.tsx index 35f9e645..754821de 100644 --- a/src/pages/settings/AccountEditPart.tsx +++ b/src/pages/parts/settings/AccountEditPart.tsx @@ -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 ( +
+
+ } />
-
diff --git a/src/pages/settings/CaptionsPart.tsx b/src/pages/parts/settings/CaptionsPart.tsx similarity index 83% rename from src/pages/settings/CaptionsPart.tsx rename to src/pages/parts/settings/CaptionsPart.tsx index 43e3b351..e7afba30 100644 --- a/src/pages/settings/CaptionsPart.tsx +++ b/src/pages/parts/settings/CaptionsPart.tsx @@ -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 (
@@ -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}%`} /> `${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} />
Color
{colors.map((v) => ( 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() {
setFullscreenPreview((s) => !s)} /> setFullscreenPreview((s) => !s)} />
diff --git a/src/pages/settings/DeviceListPart.tsx b/src/pages/parts/settings/DeviceListPart.tsx similarity index 70% rename from src/pages/settings/DeviceListPart.tsx rename to src/pages/parts/settings/DeviceListPart.tsx index 1b0b48c9..1fc1ca68 100644 --- a/src/pages/settings/DeviceListPart.tsx +++ b/src/pages/parts/settings/DeviceListPart.tsx @@ -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: { ) : (
- {props.sessions.map((session) => { - const decryptedName = decryptData( - session.device, - base64ToBuffer(seed) - ); - return ( - - ); - })} + {deviceListSorted.map((session) => ( + + ))}
)}
diff --git a/src/pages/settings/LocalePart.tsx b/src/pages/parts/settings/LocalePart.tsx similarity index 78% rename from src/pages/settings/LocalePart.tsx rename to src/pages/parts/settings/LocalePart.tsx index b1a1951e..93e9f7e9 100644 --- a/src/pages/settings/LocalePart.tsx +++ b/src/pages/parts/settings/LocalePart.tsx @@ -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: , })); - const selected = options.find((t) => t.id === language); + const selected = options.find((t) => t.id === props.language); return (
@@ -29,7 +30,7 @@ export function LocalePart() { setLanguage(opt.id)} + setSelectedItem={(opt) => props.setLanguage(opt.id)} />
); diff --git a/src/pages/parts/settings/ProfileEditModal.tsx b/src/pages/parts/settings/ProfileEditModal.tsx new file mode 100644 index 00000000..b292a259 --- /dev/null +++ b/src/pages/parts/settings/ProfileEditModal.tsx @@ -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 ( + + + Edit profile? +

I am existing

+ +
+
+ ); +} diff --git a/src/pages/settings/RegisterCalloutPart.tsx b/src/pages/parts/settings/RegisterCalloutPart.tsx similarity index 94% rename from src/pages/settings/RegisterCalloutPart.tsx rename to src/pages/parts/settings/RegisterCalloutPart.tsx index 790fbdd1..9744876d 100644 --- a/src/pages/settings/RegisterCalloutPart.tsx +++ b/src/pages/parts/settings/RegisterCalloutPart.tsx @@ -11,7 +11,7 @@ export function RegisterCalloutPart() {
Sync to the cloud diff --git a/src/pages/settings/SidebarPart.tsx b/src/pages/parts/settings/SidebarPart.tsx similarity index 98% rename from src/pages/settings/SidebarPart.tsx rename to src/pages/parts/settings/SidebarPart.tsx index 3cc73049..d955cc78 100644 --- a/src/pages/settings/SidebarPart.tsx +++ b/src/pages/parts/settings/SidebarPart.tsx @@ -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); diff --git a/src/pages/settings/ThemePart.tsx b/src/pages/parts/settings/ThemePart.tsx similarity index 100% rename from src/pages/settings/ThemePart.tsx rename to src/pages/parts/settings/ThemePart.tsx diff --git a/src/pages/parts/util/LargeTextPart.tsx b/src/pages/parts/util/LargeTextPart.tsx new file mode 100644 index 00000000..e537916b --- /dev/null +++ b/src/pages/parts/util/LargeTextPart.tsx @@ -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 ( +
+ {/* Overlayed elements */} + +
+ +
+ + {/* Content */} + {props.iconSlot ? props.iconSlot : null} +
+ {props.children} +
+
+ ); +} diff --git a/src/setup/index.css b/src/setup/index.css index 6317bc34..3cfffd78 100644 --- a/src/setup/index.css +++ b/src/setup/index.css @@ -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; diff --git a/src/stores/__old/DONT_TOUCH_THIS_FOLDER b/src/stores/__old/DONT_TOUCH_THIS_FOLDER new file mode 100644 index 00000000..fd7bd968 --- /dev/null +++ b/src/stores/__old/DONT_TOUCH_THIS_FOLDER @@ -0,0 +1 @@ +just dont, it's old stuff that needs to stay for legacy localstorage diff --git a/src/stores/auth/index.ts b/src/stores/auth/index.ts index 164a8a0f..aa908ac0 100644 --- a/src/stores/auth/index.ts +++ b/src/stores/auth/index.ts @@ -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; diff --git a/src/stores/bookmarks/BookmarkSyncer.tsx b/src/stores/bookmarks/BookmarkSyncer.tsx index bd471f45..a37e8207 100644 --- a/src/stores/bookmarks/BookmarkSyncer.tsx +++ b/src/stores/bookmarks/BookmarkSyncer.tsx @@ -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") { diff --git a/src/stores/bookmarks/index.ts b/src/stores/bookmarks/index.ts index b3825a10..94bef5a1 100644 --- a/src/stores/bookmarks/index.ts +++ b/src/stores/bookmarks/index.ts @@ -80,7 +80,9 @@ export const useBookmarkStore = create( }); }, clear() { - this.replaceBookmarks({}); + set((s) => { + s.bookmarks = {}; + }); }, clearUpdateQueue() { set((s) => { diff --git a/src/stores/progress/ProgressSyncer.tsx b/src/stores/progress/ProgressSyncer.tsx index 974b00e3..ee4fae87 100644 --- a/src/stores/progress/ProgressSyncer.tsx +++ b/src/stores/progress/ProgressSyncer.tsx @@ -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") { diff --git a/src/stores/progress/index.ts b/src/stores/progress/index.ts index 359d1d9f..941d75b4 100644 --- a/src/stores/progress/index.ts +++ b/src/stores/progress/index.ts @@ -159,7 +159,9 @@ export const useProgressStore = create( }); }, clear() { - this.replaceItems({}); + set((s) => { + s.items = {}; + }); }, clearUpdateQueue() { set((s) => { diff --git a/src/stores/subtitles/SettingsSyncer.tsx b/src/stores/subtitles/SettingsSyncer.tsx new file mode 100644 index 00000000..bd21e0dd --- /dev/null +++ b/src/stores/subtitles/SettingsSyncer.tsx @@ -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; +} diff --git a/src/stores/subtitles/index.ts b/src/stores/subtitles/index.ts index 6bf93737..1652ee93 100644 --- a/src/stores/subtitles/index.ts +++ b/src/stores/subtitles/index.ts @@ -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((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; }); }, })), diff --git a/tailwind.config.ts b/tailwind.config.ts index 70e4454a..83da3298 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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'" diff --git a/themes/default.ts b/themes/default.ts index be3755ff..b682c971 100644 --- a/themes/default.ts +++ b/themes/default.ts @@ -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: {