1
0
Fork 0
mirror of https://github.com/sussy-code/smov.git synced 2024-12-29 16:07:40 +01:00

theme system + device list + device logout + delete account + register callout + split up settings page components

This commit is contained in:
mrjvs 2023-11-18 17:28:10 +01:00
parent 0dd73eec54
commit d8913bb2b7
28 changed files with 945 additions and 448 deletions

View file

@ -16,7 +16,14 @@ module.exports = {
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended" "plugin:prettier/recommended"
], ],
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts", "/plugins/*.ts"], ignorePatterns: [
"public/*",
"dist/*",
"/*.js",
"/*.ts",
"/plugins/*.ts",
"/themes/**/*.ts"
],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
parserOptions: { parserOptions: {
project: "./tsconfig.json", project: "./tsconfig.json",

View file

@ -1,7 +1,5 @@
import { ofetch } from "ofetch"; import { ofetch } from "ofetch";
import { UserResponse } from "@/backend/accounts/user";
export interface SessionResponse { export interface SessionResponse {
id: string; id: string;
userId: string; userId: string;
@ -35,15 +33,3 @@ export async function accountLogin(
baseURL: url, baseURL: url,
}); });
} }
export async function removeSession(
url: string,
token: string,
sessionId: string
): Promise<UserResponse> {
return ofetch<UserResponse>(`/sessions/${sessionId}`, {
method: "DELETE",
headers: getAuthHeaders(token),
baseURL: url,
});
}

View file

@ -108,10 +108,7 @@ export async function encryptData(data: string, secret: Uint8Array) {
)}.${stringBufferToBase64(tag)}` as const; )}.${stringBufferToBase64(tag)}` as const;
} }
export async function decryptData( export function decryptData(data: string, secret: Uint8Array) {
data: `${string}.${string}.${string}`,
secret: Uint8Array
) {
if (secret.byteLength !== 32) throw new Error("Secret must be 256-bit"); if (secret.byteLength !== 32) throw new Error("Secret must be 256-bit");
const [iv, encryptedData, tag] = data.split("."); const [iv, encryptedData, tag] = data.split(".");

View file

@ -0,0 +1,32 @@
import { ofetch } from "ofetch";
import { getAuthHeaders } from "@/backend/accounts/auth";
import { AccountWithToken } from "@/stores/auth";
export interface SessionResponse {
id: string;
userId: string;
createdAt: string;
accessedAt: string;
device: string;
userAgent: string;
}
export async function getSessions(url: string, account: AccountWithToken) {
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
headers: getAuthHeaders(account.token),
baseURL: url,
});
}
export async function removeSession(
url: string,
token: string,
sessionId: string
) {
return ofetch<SessionResponse[]>(`/sessions/${sessionId}`, {
method: "DELETE",
headers: getAuthHeaders(token),
baseURL: url,
});
}

View file

@ -113,6 +113,16 @@ export async function getUser(
}); });
} }
export async function deleteUser(
url: string,
account: AccountWithToken
): Promise<UserResponse> {
return ofetch<UserResponse>(`/users/${account.userId}`, {
headers: getAuthHeaders(account.token),
baseURL: url,
});
}
export async function getBookmarks(url: string, account: AccountWithToken) { export async function getBookmarks(url: string, account: AccountWithToken) {
return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, { return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
headers: getAuthHeaders(account.token), headers: getAuthHeaders(account.token),

View file

@ -0,0 +1,37 @@
import classNames from "classnames";
export function SettingsCard(props: {
children: React.ReactNode;
className?: string;
paddingClass?: string;
}) {
return (
<div
className={classNames(
"w-full rounded-lg bg-settings-card-background bg-opacity-[0.15] border border-settings-card-border",
props.paddingClass ?? "px-8 py-6",
props.className
)}
>
{props.children}
</div>
);
}
export function SolidSettingsCard(props: {
children: React.ReactNode;
className?: string;
paddingClass?: string;
}) {
return (
<div
className={classNames(
"w-full rounded-lg bg-settings-card-altBackground bg-opacity-50",
props.paddingClass ?? "px-8 py-6",
props.className
)}
>
{props.children}
</div>
);
}

View file

@ -0,0 +1,45 @@
import classNames from "classnames";
import { Icon, Icons } from "@/components/Icon";
export function SidebarSection(props: {
title: string;
children: React.ReactNode;
}) {
return (
<section>
<p className="text-sm font-bold uppercase text-settings-sidebar-type-secondary mb-2">
{props.title}
</p>
{props.children}
</section>
);
}
export function SidebarLink(props: {
children: React.ReactNode;
icon: Icons;
active?: boolean;
onClick?: () => void;
}) {
return (
<div
onClick={props.onClick}
className={classNames(
"w-full px-3 py-2 flex items-center space-x-3 cursor-pointer rounded my-2",
props.active
? "bg-settings-sidebar-activeLink text-settings-sidebar-type-activated"
: null
)}
>
<Icon
className={classNames(
"text-2xl text-settings-sidebar-type-icon",
props.active ? "text-settings-sidebar-type-iconActivated" : null
)}
icon={props.icon}
/>
<span>{props.children}</span>
</div>
);
}

View file

@ -0,0 +1,3 @@
export function SecondaryLabel(props: { children: React.ReactNode }) {
return <p className="text-type-text">{props.children}</p>;
}

View file

@ -1,6 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { removeSession } from "@/backend/accounts/auth";
import { import {
bytesToBase64, bytesToBase64,
bytesToBase64Url, bytesToBase64Url,
@ -13,6 +12,7 @@ import {
getRegisterChallengeToken, getRegisterChallengeToken,
registerAccount, registerAccount,
} from "@/backend/accounts/register"; } from "@/backend/accounts/register";
import { removeSession } from "@/backend/accounts/sessions";
import { getBookmarks, getProgress, getUser } from "@/backend/accounts/user"; import { getBookmarks, getProgress, getUser } from "@/backend/accounts/user";
import { useAuthData } from "@/hooks/auth/useAuthData"; import { useAuthData } from "@/hooks/auth/useAuthData";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl";

View file

@ -16,6 +16,7 @@ import i18n from "@/setup/i18n";
import "@/setup/ga"; import "@/setup/ga";
import "@/setup/index.css"; import "@/setup/index.css";
import { useLanguageStore } from "@/stores/language"; import { useLanguageStore } from "@/stores/language";
import { useThemeStore } from "@/stores/theme";
import { initializeChromecast } from "./setup/chromecast"; import { initializeChromecast } from "./setup/chromecast";
import "./stores/__old/imports"; import "./stores/__old/imports";
@ -63,14 +64,23 @@ function TheRouter(props: { children: ReactNode }) {
return <HashRouter>{props.children}</HashRouter>; return <HashRouter>{props.children}</HashRouter>;
} }
function ThemeProvider(props: { children: ReactNode }) {
const theme = useThemeStore((s) => s.theme);
const themeSelector = theme ? `theme-${theme}` : undefined;
return <div className={themeSelector}>{props.children}</div>;
}
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary> <ErrorBoundary>
<HelmetProvider> <HelmetProvider>
<Suspense fallback={<LoadingScreen type="lazy" />}> <Suspense fallback={<LoadingScreen type="lazy" />}>
<TheRouter> <ThemeProvider>
<MigrationRunner /> <TheRouter>
</TheRouter> <MigrationRunner />
</TheRouter>
</ThemeProvider>
</Suspense> </Suspense>
</HelmetProvider> </HelmetProvider>
</ErrorBoundary> </ErrorBoundary>

View file

@ -1,216 +1,73 @@
import classNames from "classnames"; import { useEffect } from "react";
import { useHistory } from "react-router-dom"; import { useAsyncFn } from "react-use";
import Sticky from "react-stickynode";
import { Button } from "@/components/Button"; import { getSessions } from "@/backend/accounts/sessions";
import { Icon, Icons } from "@/components/Icon";
import { WideContainer } from "@/components/layout/WideContainer"; import { WideContainer } from "@/components/layout/WideContainer";
import { Divider } from "@/components/utils/Divider"; import { Heading1 } from "@/components/utils/Text";
import { Heading1, Heading2, Heading3 } from "@/components/utils/Text"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { conf } from "@/setup/config"; import { AccountActionsPart } from "@/pages/settings/AccountActionsPart";
import { AccountEditPart } from "@/pages/settings/AccountEditPart";
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 { AccountWithToken, useAuthStore } from "@/stores/auth";
import { useThemeStore } from "@/stores/theme";
import { SubPageLayout } from "./layouts/SubPageLayout"; import { SubPageLayout } from "./layouts/SubPageLayout";
// TODO Put all of this not here (when I'm done writing them)
function SidebarSection(props: { title: string; children: React.ReactNode }) {
return (
<section>
<p className="text-sm font-bold uppercase text-settings-sidebar-type-secondary mb-2">
{props.title}
</p>
{props.children}
</section>
);
}
function SidebarLink(props: {
children: React.ReactNode;
icon: Icons;
active?: boolean;
}) {
const history = useHistory();
const goToPage = (link: string) => {
history.push(link);
};
return (
<a
onClick={() => goToPage("/settings")}
className={classNames(
"w-full px-3 py-2 flex items-center space-x-3 cursor-pointer rounded my-2",
props.active
? "bg-settings-sidebar-activeLink text-settings-sidebar-type-activated"
: null
)}
>
<Icon
className={classNames(
"text-2xl text-settings-sidebar-type-icon",
props.active ? "text-settings-sidebar-type-iconActivated" : null
)}
icon={props.icon}
/>
<span>{props.children}</span>
</a>
);
}
function SettingsSidebar() {
// eslint-disable-next-line no-restricted-globals
const hostname = location.hostname;
const rem = 16;
return (
<div>
<Sticky
enabled
top={10 * rem} // 10rem
className="text-settings-sidebar-type-inactive"
>
<SidebarSection title="Settings">
{/* I looked over at my bookshelf to come up with these links */}
<SidebarLink icon={Icons.WAND}>A war in my name!</SidebarLink>
<SidebarLink active icon={Icons.COMPRESS}>
TANSTAAFL
</SidebarLink>
<SidebarLink icon={Icons.AIRPLAY}>We all float down here</SidebarLink>
<SidebarLink icon={Icons.BOOKMARK}>My skin is not my own</SidebarLink>
</SidebarSection>
<Divider />
<SidebarSection title="App information">
<div className="flex justify-between items-center space-x-3">
<span>Version</span>
<span>{conf().APP_VERSION}</span>
</div>
<div className="flex justify-between items-center space-x-3">
<span>Domain</span>
<span className="text-right">{hostname}</span>
</div>
</SidebarSection>
</Sticky>
</div>
);
}
function SettingsLayout(props: { children: React.ReactNode }) { function SettingsLayout(props: { children: React.ReactNode }) {
return ( return (
<WideContainer ultraWide> <WideContainer ultraWide>
<div className="grid grid-cols-[260px,1fr] gap-12"> <div className="grid grid-cols-[260px,1fr] gap-12">
<SettingsSidebar /> <SidebarPart />
<div className="space-y-16">{props.children}</div> <div className="space-y-16">{props.children}</div>
</div> </div>
</WideContainer> </WideContainer>
); );
} }
function SecondaryLabel(props: { children: React.ReactNode }) { export function AccountSettings(props: { account: AccountWithToken }) {
return <p className="text-type-text">{props.children}</p>; const url = useBackendUrl();
} const { account } = props;
const [sessionsResult, execSessions] = useAsyncFn(() => {
return getSessions(url, account);
}, [account, url]);
useEffect(() => {
execSessions();
}, [execSessions]);
function Card(props: {
children: React.ReactNode;
className?: string;
paddingClass?: string;
}) {
return ( return (
<div <>
className={classNames( <AccountEditPart />
"w-full rounded-lg bg-settings-card-background bg-opacity-[0.15] border border-settings-card-border", <DeviceListPart
props.paddingClass ?? "px-8 py-6", error={!!sessionsResult.error}
props.className loading={sessionsResult.loading}
)} sessions={sessionsResult.value ?? []}
> onChange={execSessions}
{props.children} />
</div> <AccountActionsPart />
); </>
}
function AltCard(props: {
children: React.ReactNode;
className?: string;
paddingClass?: string;
}) {
return (
<div
className={classNames(
"w-full rounded-lg bg-settings-card-altBackground bg-opacity-50",
props.paddingClass ?? "px-8 py-6",
props.className
)}
>
{props.children}
</div>
);
}
function AccountSection() {
return (
<div>
<Heading1 border>Account</Heading1>
<Card>Beep beep</Card>
</div>
);
}
function DevicesSection() {
const devices = [
"Jip's iPhone",
"Muad'Dib's Nintendo Switch",
"Oppenheimer's old-ass phone",
];
return (
<div>
<Heading2 border className="mt-0 mb-9">
Devices
</Heading2>
<div className="space-y-5">
{devices.map((deviceName) => (
<Card
className="flex justify-between items-center"
paddingClass="px-6 py-4"
key={deviceName}
>
<div className="font-medium">
<SecondaryLabel>Device name</SecondaryLabel>
<p className="text-white">{deviceName}</p>
</div>
<Button theme="danger">Remove</Button>
</Card>
))}
</div>
</div>
);
}
function ActionsSection() {
return (
<div>
<Heading2 border>Actions</Heading2>
<AltCard paddingClass="px-6 py-12" className="grid grid-cols-2 gap-12">
<div>
<Heading3>Delete account</Heading3>
<p className="text-type-text">
This action is irreversible. All data will be deleted and nothing
can be recovered.
</p>
</div>
<div className="flex justify-end items-center">
<Button theme="danger">Delete account</Button>
</div>
</AltCard>
</div>
); );
} }
export function SettingsPage() { export function SettingsPage() {
const activeTheme = useThemeStore((s) => s.theme);
const setTheme = useThemeStore((s) => s.setTheme);
const user = useAuthStore();
return ( return (
<SubPageLayout> <SubPageLayout>
<SettingsLayout> <SettingsLayout>
<AccountSection /> <Heading1 border className="!mb-0">
<DevicesSection /> Account
<ActionsSection /> </Heading1>
{user.account ? (
<AccountSettings account={user.account} />
) : (
<RegisterCalloutPart />
)}
<ThemePart active={activeTheme} setTheme={setTheme} />
</SettingsLayout> </SettingsLayout>
</SubPageLayout> </SubPageLayout>
); );

View file

@ -0,0 +1,49 @@
import { useAsyncFn } from "react-use";
import { deleteUser } from "@/backend/accounts/user";
import { Button } from "@/components/Button";
import { SolidSettingsCard } from "@/components/layout/SettingsCard";
import { Heading2, Heading3 } from "@/components/utils/Text";
import { useAuthData } from "@/hooks/auth/useAuthData";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useAuthStore } from "@/stores/auth";
export function AccountActionsPart() {
const url = useBackendUrl();
const account = useAuthStore((s) => s.account);
const { logout } = useAuthData();
const [deleteResult, deleteExec] = useAsyncFn(async () => {
if (!account) return;
await deleteUser(url, account);
logout();
}, [logout, account, url]);
if (!account) return null;
return (
<div>
<Heading2 border>Actions</Heading2>
<SolidSettingsCard
paddingClass="px-6 py-12"
className="grid grid-cols-2 gap-12"
>
<div>
<Heading3>Delete account</Heading3>
<p className="text-type-text">
This action is irreversible. All data will be deleted and nothing
can be recovered.
</p>
</div>
<div className="flex justify-end items-center">
<Button
theme="danger"
onClick={deleteExec}
loading={deleteResult.loading}
>
Delete account
</Button>
</div>
</SolidSettingsCard>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { SettingsCard } from "@/components/layout/SettingsCard";
export function AccountEditPart() {
return (
<SettingsCard className="!mt-8">
<p>Account editing will go here</p>
</SettingsCard>
);
}

View file

@ -0,0 +1,86 @@
import { useAsyncFn } from "react-use";
import { SessionResponse } from "@/backend/accounts/auth";
import { base64ToBuffer, decryptData } from "@/backend/accounts/crypto";
import { removeSession } from "@/backend/accounts/sessions";
import { Button } from "@/components/Button";
import { Loading } from "@/components/layout/Loading";
import { SettingsCard } from "@/components/layout/SettingsCard";
import { SecondaryLabel } from "@/components/text/SecondaryLabel";
import { Heading2 } from "@/components/utils/Text";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useAuthStore } from "@/stores/auth";
export function Device(props: {
name: string;
id: string;
isCurrent?: boolean;
onRemove?: () => void;
}) {
const url = useBackendUrl();
const token = useAuthStore((s) => s.account?.token);
const [result, exec] = useAsyncFn(async () => {
if (!token) throw new Error("No token present");
await removeSession(url, token, props.id);
props.onRemove?.();
}, [url, token, props.id]);
return (
<SettingsCard
className="flex justify-between items-center"
paddingClass="px-6 py-4"
>
<div className="font-medium">
<SecondaryLabel>Device name</SecondaryLabel>
<p className="text-white">{props.name}</p>
</div>
{!props.isCurrent ? (
<Button theme="danger" loading={result.loading} onClick={exec}>
Remove
</Button>
) : null}
</SettingsCard>
);
}
export function DeviceListPart(props: {
loading?: boolean;
error?: boolean;
sessions: SessionResponse[];
onChange?: () => void;
}) {
const seed = useAuthStore((s) => s.account?.seed);
const currentSessionId = useAuthStore((s) => s.account?.sessionId);
if (!seed) return null;
return (
<div>
<Heading2 border className="mt-0 mb-9">
Devices
</Heading2>
{props.error ? (
<p>Failed to load sessions</p>
) : props.loading ? (
<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}
/>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,31 @@
import { useHistory } from "react-router-dom";
import { Button } from "@/components/Button";
import { SolidSettingsCard } from "@/components/layout/SettingsCard";
import { Heading3 } from "@/components/utils/Text";
export function RegisterCalloutPart() {
const history = useHistory();
return (
<div>
<SolidSettingsCard
paddingClass="px-6 py-12"
className="grid grid-cols-2 gap-12"
>
<div>
<Heading3>Sync to the cloud</Heading3>
<p className="text-type-text">
Instantly share your watch progress between devices and keep them
synced.
</p>
</div>
<div className="flex justify-end items-center">
<Button theme="purple" onClick={() => history.push("/register")}>
Get started
</Button>
</div>
</SolidSettingsCard>
</div>
);
}

View file

@ -0,0 +1,42 @@
import Sticky from "react-stickynode";
import { Icons } from "@/components/Icon";
import { SidebarLink, SidebarSection } from "@/components/layout/Sidebar";
import { Divider } from "@/components/utils/Divider";
import { conf } from "@/setup/config";
export function SidebarPart() {
// eslint-disable-next-line no-restricted-globals
const hostname = location.hostname;
const rem = 16;
return (
<div>
<Sticky
enabled
top={10 * rem} // 10rem
className="text-settings-sidebar-type-inactive"
>
<SidebarSection title="Settings">
<SidebarLink icon={Icons.WAND}>A war in my name!</SidebarLink>
<SidebarLink active icon={Icons.COMPRESS}>
TANSTAAFL
</SidebarLink>
<SidebarLink icon={Icons.AIRPLAY}>We all float down here</SidebarLink>
<SidebarLink icon={Icons.BOOKMARK}>My skin is not my own</SidebarLink>
</SidebarSection>
<Divider />
<SidebarSection title="App information">
<div className="flex justify-between items-center space-x-3">
<span>Version</span>
<span>{conf().APP_VERSION}</span>
</div>
<div className="flex justify-between items-center space-x-3">
<span>Domain</span>
<span className="text-right">{hostname}</span>
</div>
</SidebarSection>
</Sticky>
</div>
);
}

View file

@ -0,0 +1,141 @@
import classNames from "classnames";
import { Icon, Icons } from "@/components/Icon";
import { Heading2 } from "@/components/utils/Text";
const availableThemes = [
{
id: "blue",
name: "Blue",
},
{
id: "teal",
name: "Teal",
},
{
id: "red",
name: "Red",
},
{
id: "gray",
name: "Gray",
},
];
function ThemePreview(props: {
selector?: string;
active?: boolean;
name: string;
onClick?: () => void;
}) {
return (
<div
className={classNames(props.selector, "cursor-pointer group")}
onClick={props.onClick}
>
{/* Little card thing */}
<div
className={classNames(
"w-full h-32 relative rounded-lg border bg-gradient-to-br from-themePreview-primary/20 to-themePreview-secondary/10 bg-clip-content transition-colors duration-150",
props.active
? "border-themePreview-primary"
: "border-transparent group-hover:border-white/20"
)}
>
{/* Dots */}
<div className="absolute top-2 left-2">
<div className="h-5 w-5 bg-themePreview-primary rounded-full" />
<div className="h-5 w-5 bg-themePreview-secondary rounded-full -mt-2" />
</div>
{/* Active check */}
<Icon
icon={Icons.CHECKMARK}
className={classNames(
"absolute top-3 right-3 text-xs text-white transition-opacity duration-150",
props.active ? "opacity-100" : "opacity-0"
)}
/>
{/* Mini movie-web. So Kawaiiiii! */}
{/* ^ can we keep this comment in forever please? - Jip */}
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-3/5 h-4/5 rounded-t-lg -mb-px bg-background-main overflow-hidden">
<div className="relative w-full h-full">
{/* Background color */}
<div className="bg-themePreview-primary/50 w-[130%] h-10 absolute left-1/2 -top-5 blur-xl transform -translate-x-1/2 rounded-[100%]" />
{/* Navbar */}
<div className="p-2 flex justify-between items-center">
<div className="flex space-x-1">
<div className="bg-themePreview-ghost bg-opacity-10 w-4 h-2 rounded-full" />
<div className="bg-themePreview-ghost bg-opacity-10 w-2 h-2 rounded-full" />
<div className="bg-themePreview-ghost bg-opacity-10 w-2 h-2 rounded-full" />
</div>
<div className="bg-themePreview-ghost bg-opacity-10 w-2 h-2 rounded-full" />
</div>
{/* Hero */}
<div className="mt-1 flex items-center flex-col gap-1">
{/* Title and subtitle */}
<div className="bg-themePreview-ghost bg-opacity-20 w-8 h-0.5 rounded-full" />
<div className="bg-themePreview-ghost bg-opacity-20 w-6 h-0.5 rounded-full" />
{/* Search bar */}
<div className="bg-themePreview-ghost bg-opacity-10 w-16 h-2 mt-1 rounded-full" />
</div>
{/* Media grid */}
<div className="mt-5 px-3">
{/* Title */}
<div className="flex gap-1 items-center">
<div className="bg-themePreview-ghost bg-opacity-20 w-2 h-2 rounded-full" />
<div className="bg-themePreview-ghost bg-opacity-20 w-8 h-0.5 rounded-full" />
</div>
{/* Blocks */}
<div className="flex w-full gap-1 mt-1">
<div className="bg-themePreview-ghost bg-opacity-10 w-full h-20 rounded" />
<div className="bg-themePreview-ghost bg-opacity-10 w-full h-20 rounded" />
<div className="bg-themePreview-ghost bg-opacity-10 w-full h-20 rounded" />
<div className="bg-themePreview-ghost bg-opacity-10 w-full h-20 rounded" />
</div>
</div>
</div>
</div>
</div>
<div className="mt-2 flex justify-between items-center">
<span className="font-medium text-white">{props.name}</span>
<span
className={classNames(
"inline-block px-3 text-sm transition-opacity duration-150 rounded-full bg-[#27182F] text-white",
props.active ? "opacity-100" : "opacity-0 pointer-events-none"
)}
>
Active
</span>
</div>
</div>
);
}
export function ThemePart(props: {
active: string | null;
setTheme: (theme: string | null) => void;
}) {
return (
<div>
<Heading2 border>Themes</Heading2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-6 max-w-[700px]">
{/* default theme */}
<ThemePreview
name="Default"
selector="theme-default"
active={props.active === null}
onClick={() => props.setTheme(null)}
/>
{availableThemes.map((v) => (
<ThemePreview
selector={`theme-${v.id}`}
active={props.active === v.id}
name={v.name}
key={v.id}
onClick={() => props.setTheme(v.id)}
/>
))}
</div>
</div>
);
}

24
src/stores/theme/index.ts Normal file
View file

@ -0,0 +1,24 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
export interface ThemeStore {
theme: string | null;
setTheme(v: string | null): void;
}
export const useThemeStore = create(
persist(
immer<ThemeStore>((set) => ({
theme: null,
setTheme(v) {
set((s) => {
s.theme = v;
});
},
})),
{
name: "__MW::theme",
}
)
);

View file

@ -1,236 +0,0 @@
const themer = require("tailwindcss-themer");
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
/* 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'"
},
/* animations */
keyframes: {
"loading-pin": {
"0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" },
"20%": { height: "1em", "background-color": "white" }
}
},
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
}
},
plugins: [
require("tailwind-scrollbar"),
themer({
defaultTheme: {
extend: {
colors: {
// Branding
pill: {
background: "#1C1C36"
},
// 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",
primary: "#fff",
primaryText: "#000",
primaryHover: "#dedede",
purple: "#6b298a",
purpleHover: "#7f35a1",
cancel: "#252533",
cancelHover: "#3C3C4A"
},
// only used for body colors/textures
background: {
main: "#0A0A10",
accentA: "#6E3B80",
accentB: "#1F1F50"
},
// typography
type: {
emphasis: "#FFFFFF",
text: "#73739D",
dimmed: "#926CAD",
divider: "#262632",
secondary: "#64647B"
},
// search bar
search: {
background: "#1E1E33",
focused: "#24243C",
placeholder: "#4A4A71",
icon: "#545476",
text: "#FFFFFF"
},
// media cards
mediaCard: {
hoverBackground: "#161622",
hoverAccent: "#4D79A8",
hoverShadow: "#0A0A10",
shadow: "#161622",
barColor: "#4B4B63",
barFillColor: "#BA7FD6",
badge: "#151522",
badgeText: "#5F5F7A"
},
// Large card
largeCard: {
background: "#171728",
icon: "#6741A5"
},
// Passphrase
authentication: {
border: "#393954",
inputBg: "#171728",
wordBackground: "#171728",
copyText: "#58587A",
copyTextHover: "#8888AA",
errorText: "#DB3D62"
},
// Settings page
settings: {
sidebar: {
activeLink: "#171728",
type: {
secondary: "#4B395F",
inactive: "#8D68A9",
icon: "#926CAD",
iconActivated: "#6942A8",
activated: "#CBA1E8"
}
},
card: {
border: "#2A243E",
background: "#29243D",
altBackground: "#29243D"
}
},
utils: {
divider: "#353549"
},
// Error page
errors: {
card: "#12121B",
border: "#252534",
type: {
secondary: "#62627D"
}
},
// About page
about: {
circle: "#262632",
circleText: "#9A9AC3"
},
progress: {
background: "#8787A8",
preloaded: "#8787A8",
filled: "#A75FC9"
},
// video player
video: {
buttonBackground: "#444B5C",
scraping: {
card: "#161620",
error: "#E44F4F",
success: "#40B44B",
loading: "#B759D8",
noresult: "#64647B"
},
audio: {
set: "#A75FC9"
},
context: {
background: "#0C1216",
light: "#4D79A8",
border: "#1d252b",
hoverColor: "#1E2A32",
buttonFocus: "#202836",
flagBg: "#202836",
inputBg: "#202836",
buttonOverInputHover: "#283040",
inputPlaceholder: "#374A56",
cardBorder: "#1B262E",
slider: "#8787A8",
sliderFilled: "#A75FC9",
error: "#E44F4F",
buttons: {
list: "#161C26",
active: "#0D1317"
},
type: {
main: "#617A8A",
secondary: "#374A56",
accent: "#A570FA"
}
}
}
}
}
}
})
]
};

66
tailwind.config.ts Normal file
View file

@ -0,0 +1,66 @@
import { allThemes, defaultTheme, safeThemeList } from "./themes";
import type { Config } from "tailwindcss"
const themer = require("tailwindcss-themer");
const config: Config = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
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'"
},
/* animations */
keyframes: {
"loading-pin": {
"0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" },
"20%": { height: "1em", "background-color": "white" }
}
},
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
}
},
plugins: [
require("tailwind-scrollbar"),
themer({
defaultTheme: defaultTheme,
themes: [
{
name: "default",
selectors: [".theme-default"],
...defaultTheme,
},
...allThemes]
})
]
};
export default config;

11
themes/all.ts Normal file
View file

@ -0,0 +1,11 @@
import teal from "./list/teal";
import blue from "./list/blue";
import red from "./list/red";
import gray from "./list/gray";
export const allThemes = [
teal,
blue,
gray,
red
]

190
themes/default.ts Normal file
View file

@ -0,0 +1,190 @@
export const defaultTheme = {
extend: {
colors: {
themePreview: {
primary: "#505DBD",
secondary: "#73739D",
ghost: "white"
},
// Branding
pill: {
background: "#1C1C36"
},
// 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",
primary: "#fff",
primaryText: "#000",
primaryHover: "#dedede",
purple: "#6b298a",
purpleHover: "#7f35a1",
cancel: "#252533",
cancelHover: "#3C3C4A"
},
// only used for body colors/textures
background: {
main: "#0A0A10",
accentA: "#6E3B80",
accentB: "#1F1F50"
},
// typography
type: {
emphasis: "#FFFFFF",
text: "#73739D",
dimmed: "#926CAD",
divider: "#262632",
secondary: "#64647B"
},
// search bar
search: {
background: "#1E1E33",
focused: "#24243C",
placeholder: "#4A4A71",
icon: "#545476",
text: "#FFFFFF"
},
// media cards
mediaCard: {
hoverBackground: "#161622",
hoverAccent: "#4D79A8",
hoverShadow: "#0A0A10",
shadow: "#161622",
barColor: "#4B4B63",
barFillColor: "#BA7FD6",
badge: "#151522",
badgeText: "#5F5F7A"
},
// Large card
largeCard: {
background: "#171728",
icon: "#6741A5"
},
// Passphrase
authentication: {
border: "#393954",
inputBg: "#171728",
wordBackground: "#171728",
copyText: "#58587A",
copyTextHover: "#8888AA",
errorText: "#DB3D62"
},
// Settings page
settings: {
sidebar: {
activeLink: "#171728",
type: {
secondary: "#4B395F",
inactive: "#8D68A9",
icon: "#926CAD",
iconActivated: "#6942A8",
activated: "#CBA1E8"
}
},
card: {
border: "#2A243E",
background: "#29243D",
altBackground: "#29243D"
}
},
utils: {
divider: "#353549"
},
// Error page
errors: {
card: "#12121B",
border: "#252534",
type: {
secondary: "#62627D"
}
},
// About page
about: {
circle: "#262632",
circleText: "#9A9AC3"
},
progress: {
background: "#8787A8",
preloaded: "#8787A8",
filled: "#A75FC9"
},
// video player
video: {
buttonBackground: "#444B5C",
scraping: {
card: "#161620",
error: "#E44F4F",
success: "#40B44B",
loading: "#B759D8",
noresult: "#64647B"
},
audio: {
set: "#A75FC9"
},
context: {
background: "#0C1216",
light: "#4D79A8",
border: "#1d252b",
hoverColor: "#1E2A32",
buttonFocus: "#202836",
flagBg: "#202836",
inputBg: "#202836",
buttonOverInputHover: "#283040",
inputPlaceholder: "#374A56",
cardBorder: "#1B262E",
slider: "#8787A8",
sliderFilled: "#A75FC9",
error: "#E44F4F",
buttons: {
list: "#161C26",
active: "#0D1317"
},
type: {
main: "#617A8A",
secondary: "#374A56",
accent: "#A570FA"
}
}
}
}
}
}

9
themes/index.ts Normal file
View file

@ -0,0 +1,9 @@
import { allThemes } from "./all";
export { defaultTheme } from "./default";
export { allThemes } from "./all";
export const safeThemeList = allThemes
.flatMap(v=>v.selectors)
.filter(v=>v.startsWith("."))
.map(v=>v.slice(1)); // remove dot from selector

19
themes/list/blue.ts Normal file
View file

@ -0,0 +1,19 @@
import { createTheme } from "../types";
export default createTheme({
name: "blue",
extend: {
colors: {
themePreview: {
primary: "#3A4FAA",
secondary: "#303487",
ghost: "white",
},
// light bar
lightBar: {
light: "#3A4FAA",
},
}
}
})

19
themes/list/gray.ts Normal file
View file

@ -0,0 +1,19 @@
import { createTheme } from "../types";
export default createTheme({
name: "gray",
extend: {
colors: {
themePreview: {
primary: "#343441",
secondary: "#0C0C16",
ghost: "white",
},
// light bar
lightBar: {
light: "#343441"
},
}
}
})

19
themes/list/red.ts Normal file
View file

@ -0,0 +1,19 @@
import { createTheme } from "../types";
export default createTheme({
name: "red",
extend: {
colors: {
themePreview: {
primary: "#A8335E",
secondary: "#6A2441",
ghost: "white",
},
// light bar
lightBar: {
light: "#A8335E"
},
}
}
})

19
themes/list/teal.ts Normal file
View file

@ -0,0 +1,19 @@
import { createTheme } from "../types";
export default createTheme({
name: "teal",
extend: {
colors: {
themePreview: {
primary: "#469c51",
secondary: "#1a3d2b",
ghost: "white",
},
// light bar
lightBar: {
light: "#469c51",
},
}
}
})

15
themes/types.ts Normal file
View file

@ -0,0 +1,15 @@
import { DeepPartial } from "vite-plugin-checker/dist/esm/types";
import { defaultTheme } from "./default";
export interface Theme {
name: string;
extend: DeepPartial<(typeof defaultTheme)["extend"]>
}
export function createTheme(theme: Theme) {
return {
name: theme.name,
selectors: [`.theme-${theme.name}`],
extend: theme.extend
}
}