diff --git a/.eslintrc.js b/.eslintrc.js index a2da2b2a..ba418e3c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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", diff --git a/src/backend/accounts/auth.ts b/src/backend/accounts/auth.ts index d2caddd9..2237be58 100644 --- a/src/backend/accounts/auth.ts +++ b/src/backend/accounts/auth.ts @@ -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 { - return ofetch(`/sessions/${sessionId}`, { - method: "DELETE", - headers: getAuthHeaders(token), - baseURL: url, - }); -} diff --git a/src/backend/accounts/crypto.ts b/src/backend/accounts/crypto.ts index ff847522..00c53a08 100644 --- a/src/backend/accounts/crypto.ts +++ b/src/backend/accounts/crypto.ts @@ -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("."); diff --git a/src/backend/accounts/sessions.ts b/src/backend/accounts/sessions.ts new file mode 100644 index 00000000..745dc35f --- /dev/null +++ b/src/backend/accounts/sessions.ts @@ -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(`/users/${account.userId}/sessions`, { + headers: getAuthHeaders(account.token), + baseURL: url, + }); +} + +export async function removeSession( + url: string, + token: string, + sessionId: string +) { + return ofetch(`/sessions/${sessionId}`, { + method: "DELETE", + headers: getAuthHeaders(token), + baseURL: url, + }); +} diff --git a/src/backend/accounts/user.ts b/src/backend/accounts/user.ts index 560337f9..65ad63e7 100644 --- a/src/backend/accounts/user.ts +++ b/src/backend/accounts/user.ts @@ -113,6 +113,16 @@ export async function getUser( }); } +export async function deleteUser( + url: string, + account: AccountWithToken +): Promise { + return ofetch(`/users/${account.userId}`, { + headers: getAuthHeaders(account.token), + baseURL: url, + }); +} + export async function getBookmarks(url: string, account: AccountWithToken) { return ofetch(`/users/${account.userId}/bookmarks`, { headers: getAuthHeaders(account.token), diff --git a/src/components/layout/SettingsCard.tsx b/src/components/layout/SettingsCard.tsx new file mode 100644 index 00000000..a8d78eca --- /dev/null +++ b/src/components/layout/SettingsCard.tsx @@ -0,0 +1,37 @@ +import classNames from "classnames"; + +export function SettingsCard(props: { + children: React.ReactNode; + className?: string; + paddingClass?: string; +}) { + return ( +
+ {props.children} +
+ ); +} + +export function SolidSettingsCard(props: { + children: React.ReactNode; + className?: string; + paddingClass?: string; +}) { + return ( +
+ {props.children} +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 00000000..5f7a4e0b --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,45 @@ +import classNames from "classnames"; + +import { Icon, Icons } from "@/components/Icon"; + +export function SidebarSection(props: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {props.title} +

+ {props.children} +
+ ); +} + +export function SidebarLink(props: { + children: React.ReactNode; + icon: Icons; + active?: boolean; + onClick?: () => void; +}) { + return ( +
+ + {props.children} +
+ ); +} diff --git a/src/components/text/SecondaryLabel.tsx b/src/components/text/SecondaryLabel.tsx new file mode 100644 index 00000000..ebf586f3 --- /dev/null +++ b/src/components/text/SecondaryLabel.tsx @@ -0,0 +1,3 @@ +export function SecondaryLabel(props: { children: React.ReactNode }) { + return

{props.children}

; +} diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 8fb184c0..9bb63e1e 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -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"; diff --git a/src/index.tsx b/src/index.tsx index 38cddcbb..293dc9a5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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 {props.children}; } +function ThemeProvider(props: { children: ReactNode }) { + const theme = useThemeStore((s) => s.theme); + const themeSelector = theme ? `theme-${theme}` : undefined; + + return
{props.children}
; +} + ReactDOM.render( }> - - - + + + + + diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 27f92201..38bfe8c2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 ( -
-

- {props.title} -

- {props.children} -
- ); -} - -function SidebarLink(props: { - children: React.ReactNode; - icon: Icons; - active?: boolean; -}) { - const history = useHistory(); - - const goToPage = (link: string) => { - history.push(link); - }; - - return ( - 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 - )} - > - - {props.children} - - ); -} - -function SettingsSidebar() { - // eslint-disable-next-line no-restricted-globals - const hostname = location.hostname; - const rem = 16; - - return ( -
- - - {/* I looked over at my bookshelf to come up with these links */} - A war in my name! - - TANSTAAFL - - We all float down here - My skin is not my own - - - -
- Version - {conf().APP_VERSION} -
-
- Domain - {hostname} -
-
-
-
- ); -} - function SettingsLayout(props: { children: React.ReactNode }) { return (
- +
{props.children}
); } -function SecondaryLabel(props: { children: React.ReactNode }) { - return

{props.children}

; -} +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 ( -
- {props.children} -
- ); -} - -function AltCard(props: { - children: React.ReactNode; - className?: string; - paddingClass?: string; -}) { - return ( -
- {props.children} -
- ); -} - -function AccountSection() { - return ( -
- Account - Beep beep -
- ); -} - -function DevicesSection() { - const devices = [ - "Jip's iPhone", - "Muad'Dib's Nintendo Switch", - "Oppenheimer's old-ass phone", - ]; - return ( -
- - Devices - -
- {devices.map((deviceName) => ( - -
- Device name -

{deviceName}

-
- -
- ))} -
-
- ); -} - -function ActionsSection() { - return ( -
- Actions - -
- Delete account -

- This action is irreversible. All data will be deleted and nothing - can be recovered. -

-
-
- -
-
-
+ <> + + + + ); } export function SettingsPage() { + const activeTheme = useThemeStore((s) => s.theme); + const setTheme = useThemeStore((s) => s.setTheme); + const user = useAuthStore(); + return ( - - - + + Account + + {user.account ? ( + + ) : ( + + )} + ); diff --git a/src/pages/settings/AccountActionsPart.tsx b/src/pages/settings/AccountActionsPart.tsx new file mode 100644 index 00000000..26449ee8 --- /dev/null +++ b/src/pages/settings/AccountActionsPart.tsx @@ -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 ( +
+ Actions + +
+ Delete account +

+ This action is irreversible. All data will be deleted and nothing + can be recovered. +

+
+
+ +
+
+
+ ); +} diff --git a/src/pages/settings/AccountEditPart.tsx b/src/pages/settings/AccountEditPart.tsx new file mode 100644 index 00000000..d2765e30 --- /dev/null +++ b/src/pages/settings/AccountEditPart.tsx @@ -0,0 +1,9 @@ +import { SettingsCard } from "@/components/layout/SettingsCard"; + +export function AccountEditPart() { + return ( + +

Account editing will go here

+
+ ); +} diff --git a/src/pages/settings/DeviceListPart.tsx b/src/pages/settings/DeviceListPart.tsx new file mode 100644 index 00000000..1b0b48c9 --- /dev/null +++ b/src/pages/settings/DeviceListPart.tsx @@ -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 ( + +
+ Device name +

{props.name}

+
+ {!props.isCurrent ? ( + + ) : null} +
+ ); +} + +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 ( +
+ + Devices + + {props.error ? ( +

Failed to load sessions

+ ) : props.loading ? ( + + ) : ( +
+ {props.sessions.map((session) => { + const decryptedName = decryptData( + session.device, + base64ToBuffer(seed) + ); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/pages/settings/RegisterCalloutPart.tsx b/src/pages/settings/RegisterCalloutPart.tsx new file mode 100644 index 00000000..790fbdd1 --- /dev/null +++ b/src/pages/settings/RegisterCalloutPart.tsx @@ -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 ( +
+ +
+ Sync to the cloud +

+ Instantly share your watch progress between devices and keep them + synced. +

+
+
+ +
+
+
+ ); +} diff --git a/src/pages/settings/SidebarPart.tsx b/src/pages/settings/SidebarPart.tsx new file mode 100644 index 00000000..dfa82f72 --- /dev/null +++ b/src/pages/settings/SidebarPart.tsx @@ -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 ( +
+ + + A war in my name! + + TANSTAAFL + + We all float down here + My skin is not my own + + + +
+ Version + {conf().APP_VERSION} +
+
+ Domain + {hostname} +
+
+
+
+ ); +} diff --git a/src/pages/settings/ThemePart.tsx b/src/pages/settings/ThemePart.tsx new file mode 100644 index 00000000..90cdc01b --- /dev/null +++ b/src/pages/settings/ThemePart.tsx @@ -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 ( +
+ {/* Little card thing */} +
+ {/* Dots */} +
+
+
+
+ {/* Active check */} + + {/* Mini movie-web. So Kawaiiiii! */} + {/* ^ can we keep this comment in forever please? - Jip */} +
+
+ {/* Background color */} +
+ {/* Navbar */} +
+
+
+
+
+
+
+
+ {/* Hero */} +
+ {/* Title and subtitle */} +
+
+ {/* Search bar */} +
+
+ {/* Media grid */} +
+ {/* Title */} +
+
+
+
+ {/* Blocks */} +
+
+
+
+
+
+
+
+
+
+
+ {props.name} + + Active + +
+
+ ); +} + +export function ThemePart(props: { + active: string | null; + setTheme: (theme: string | null) => void; +}) { + return ( +
+ Themes +
+ {/* default theme */} + props.setTheme(null)} + /> + {availableThemes.map((v) => ( + props.setTheme(v.id)} + /> + ))} +
+
+ ); +} diff --git a/src/stores/theme/index.ts b/src/stores/theme/index.ts new file mode 100644 index 00000000..13339a8d --- /dev/null +++ b/src/stores/theme/index.ts @@ -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((set) => ({ + theme: null, + setTheme(v) { + set((s) => { + s.theme = v; + }); + }, + })), + { + name: "__MW::theme", + } + ) +); diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 84240765..00000000 --- a/tailwind.config.js +++ /dev/null @@ -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" - } - } - } - } - } - } - }) - ] -}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 00000000..70e4454a --- /dev/null +++ b/tailwind.config.ts @@ -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; diff --git a/themes/all.ts b/themes/all.ts new file mode 100644 index 00000000..f59aac81 --- /dev/null +++ b/themes/all.ts @@ -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 +] diff --git a/themes/default.ts b/themes/default.ts new file mode 100644 index 00000000..7dffe5d4 --- /dev/null +++ b/themes/default.ts @@ -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" + } + } + } + } + } +} diff --git a/themes/index.ts b/themes/index.ts new file mode 100644 index 00000000..04b121b1 --- /dev/null +++ b/themes/index.ts @@ -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 diff --git a/themes/list/blue.ts b/themes/list/blue.ts new file mode 100644 index 00000000..90bd3c7e --- /dev/null +++ b/themes/list/blue.ts @@ -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", + }, + } + } +}) diff --git a/themes/list/gray.ts b/themes/list/gray.ts new file mode 100644 index 00000000..1dc92a1d --- /dev/null +++ b/themes/list/gray.ts @@ -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" + }, + } + } +}) diff --git a/themes/list/red.ts b/themes/list/red.ts new file mode 100644 index 00000000..dfab4554 --- /dev/null +++ b/themes/list/red.ts @@ -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" + }, + } + } +}) diff --git a/themes/list/teal.ts b/themes/list/teal.ts new file mode 100644 index 00000000..681b63da --- /dev/null +++ b/themes/list/teal.ts @@ -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", + }, + } + } +}) diff --git a/themes/types.ts b/themes/types.ts new file mode 100644 index 00000000..5311b957 --- /dev/null +++ b/themes/types.ts @@ -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 + } +}