mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +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:prettier/recommended"
|
||||
],
|
||||
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts", "/plugins/*.ts"],
|
||||
ignorePatterns: [
|
||||
"public/*",
|
||||
"dist/*",
|
||||
"/*.js",
|
||||
"/*.ts",
|
||||
"/plugins/*.ts",
|
||||
"/themes/**/*.ts"
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { ofetch } from "ofetch";
|
||||
|
||||
import { UserResponse } from "@/backend/accounts/user";
|
||||
|
||||
export interface SessionResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
@ -35,15 +33,3 @@ export async function accountLogin(
|
|||
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;
|
||||
}
|
||||
|
||||
export async function decryptData(
|
||||
data: `${string}.${string}.${string}`,
|
||||
secret: Uint8Array
|
||||
) {
|
||||
export function decryptData(data: string, secret: Uint8Array) {
|
||||
if (secret.byteLength !== 32) throw new Error("Secret must be 256-bit");
|
||||
|
||||
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) {
|
||||
return ofetch<BookmarkResponse[]>(`/users/${account.userId}/bookmarks`, {
|
||||
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 { removeSession } from "@/backend/accounts/auth";
|
||||
import {
|
||||
bytesToBase64,
|
||||
bytesToBase64Url,
|
||||
|
@ -13,6 +12,7 @@ import {
|
|||
getRegisterChallengeToken,
|
||||
registerAccount,
|
||||
} from "@/backend/accounts/register";
|
||||
import { removeSession } from "@/backend/accounts/sessions";
|
||||
import { getBookmarks, getProgress, getUser } from "@/backend/accounts/user";
|
||||
import { useAuthData } from "@/hooks/auth/useAuthData";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
|
|
|
@ -16,6 +16,7 @@ import i18n from "@/setup/i18n";
|
|||
import "@/setup/ga";
|
||||
import "@/setup/index.css";
|
||||
import { useLanguageStore } from "@/stores/language";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
|
||||
import { initializeChromecast } from "./setup/chromecast";
|
||||
import "./stores/__old/imports";
|
||||
|
@ -63,14 +64,23 @@ function TheRouter(props: { children: ReactNode }) {
|
|||
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(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<HelmetProvider>
|
||||
<Suspense fallback={<LoadingScreen type="lazy" />}>
|
||||
<TheRouter>
|
||||
<MigrationRunner />
|
||||
</TheRouter>
|
||||
<ThemeProvider>
|
||||
<TheRouter>
|
||||
<MigrationRunner />
|
||||
</TheRouter>
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
</HelmetProvider>
|
||||
</ErrorBoundary>
|
||||
|
|
|
@ -1,216 +1,73 @@
|
|||
import classNames from "classnames";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Sticky from "react-stickynode";
|
||||
import { useEffect } from "react";
|
||||
import { useAsyncFn } from "react-use";
|
||||
|
||||
import { Button } from "@/components/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { getSessions } from "@/backend/accounts/sessions";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Heading1, Heading2, Heading3 } from "@/components/utils/Text";
|
||||
import { conf } from "@/setup/config";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
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";
|
||||
|
||||
// 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 }) {
|
||||
return (
|
||||
<WideContainer ultraWide>
|
||||
<div className="grid grid-cols-[260px,1fr] gap-12">
|
||||
<SettingsSidebar />
|
||||
<SidebarPart />
|
||||
<div className="space-y-16">{props.children}</div>
|
||||
</div>
|
||||
</WideContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryLabel(props: { children: React.ReactNode }) {
|
||||
return <p className="text-type-text">{props.children}</p>;
|
||||
}
|
||||
export function AccountSettings(props: { account: AccountWithToken }) {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<>
|
||||
<AccountEditPart />
|
||||
<DeviceListPart
|
||||
error={!!sessionsResult.error}
|
||||
loading={sessionsResult.loading}
|
||||
sessions={sessionsResult.value ?? []}
|
||||
onChange={execSessions}
|
||||
/>
|
||||
<AccountActionsPart />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const activeTheme = useThemeStore((s) => s.theme);
|
||||
const setTheme = useThemeStore((s) => s.setTheme);
|
||||
const user = useAuthStore();
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<SettingsLayout>
|
||||
<AccountSection />
|
||||
<DevicesSection />
|
||||
<ActionsSection />
|
||||
<Heading1 border className="!mb-0">
|
||||
Account
|
||||
</Heading1>
|
||||
{user.account ? (
|
||||
<AccountSettings account={user.account} />
|
||||
) : (
|
||||
<RegisterCalloutPart />
|
||||
)}
|
||||
<ThemePart active={activeTheme} setTheme={setTheme} />
|
||||
</SettingsLayout>
|
||||
</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