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:
parent
0dd73eec54
commit
d8913bb2b7
28 changed files with 945 additions and 448 deletions
|
@ -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",
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
@ -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(".");
|
||||||
|
|
32
src/backend/accounts/sessions.ts
Normal file
32
src/backend/accounts/sessions.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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),
|
||||||
|
|
37
src/components/layout/SettingsCard.tsx
Normal file
37
src/components/layout/SettingsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
45
src/components/layout/Sidebar.tsx
Normal file
45
src/components/layout/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
3
src/components/text/SecondaryLabel.tsx
Normal file
3
src/components/text/SecondaryLabel.tsx
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export function SecondaryLabel(props: { children: React.ReactNode }) {
|
||||||
|
return <p className="text-type-text">{props.children}</p>;
|
||||||
|
}
|
|
@ -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";
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
49
src/pages/settings/AccountActionsPart.tsx
Normal file
49
src/pages/settings/AccountActionsPart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
9
src/pages/settings/AccountEditPart.tsx
Normal file
9
src/pages/settings/AccountEditPart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
86
src/pages/settings/DeviceListPart.tsx
Normal file
86
src/pages/settings/DeviceListPart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
31
src/pages/settings/RegisterCalloutPart.tsx
Normal file
31
src/pages/settings/RegisterCalloutPart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
42
src/pages/settings/SidebarPart.tsx
Normal file
42
src/pages/settings/SidebarPart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
141
src/pages/settings/ThemePart.tsx
Normal file
141
src/pages/settings/ThemePart.tsx
Normal 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
24
src/stores/theme/index.ts
Normal 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",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
|
@ -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
66
tailwind.config.ts
Normal 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
11
themes/all.ts
Normal 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
190
themes/default.ts
Normal 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
9
themes/index.ts
Normal 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
19
themes/list/blue.ts
Normal 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
19
themes/list/gray.ts
Normal 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
19
themes/list/red.ts
Normal 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
19
themes/list/teal.ts
Normal 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
15
themes/types.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue