diff --git a/convex/constants.ts b/convex/constants.ts index c9165f19..a7ed91a6 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -53,3 +53,5 @@ export enum JURISDICTIONS { WV = "West Virginia", WY = "Wyoming", } + +export type Theme = "system" | "light" | "dark"; diff --git a/convex/schema.ts b/convex/schema.ts index ea554346..74aeb7e5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -7,6 +7,12 @@ export const jurisdictions = v.union( ...Object.keys(JURISDICTIONS).map((jurisdiction) => v.literal(jurisdiction)), ); +export const themes = v.union( + v.literal("system"), + v.literal("light"), + v.literal("dark"), +); + export default defineSchema({ ...authTables, @@ -96,6 +102,7 @@ export default defineSchema({ * @param emailVerificationTime - Time in ms since epoch when the user verified their email. * @param isAnonymous - Denotes anonymous/unauthenticated users. * @param isMinor - Denotes users under 18. + * @param preferredTheme - The user's preferred color scheme. */ users: defineTable({ name: v.optional(v.string()), @@ -104,6 +111,7 @@ export default defineSchema({ emailVerificationTime: v.optional(v.number()), isAnonymous: v.optional(v.boolean()), isMinor: v.optional(v.boolean()), + theme: v.optional(themes), }).index("email", ["email"]), /** diff --git a/convex/users.ts b/convex/users.ts index 4124ba70..0f8af3fa 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,6 +1,7 @@ import { getAuthUserId } from "@convex-dev/auth/server"; import { v } from "convex/values"; import { mutation, query } from "./_generated/server"; +import { themes } from "./schema"; // TODO: Add `returns` value validation // https://docs.convex.dev/functions/validation @@ -39,6 +40,17 @@ export const setCurrentUserIsMinor = mutation({ }, }); +export const setUserTheme = mutation({ + args: { + theme: themes, + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (userId === null) throw new Error("Not authenticated"); + await ctx.db.patch(userId, { theme: args.theme }); + }, +}); + export const deleteCurrentUser = mutation({ args: {}, handler: async (ctx) => { diff --git a/src/components/shared/ListBox.tsx b/src/components/shared/ListBox.tsx index fa4a03a1..ba2eabd8 100644 --- a/src/components/shared/ListBox.tsx +++ b/src/components/shared/ListBox.tsx @@ -42,7 +42,7 @@ export const itemStyles = tv({ true: "bg-blue-9 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] [&:has(+[data-selected])]:rounded-b-none [&+[data-selected]]:rounded-t-none -outline-offset-4 outline-white dark:outline-white forced-colors:outline-[HighlightText]", }, isDisabled: { - true: "text-gray-3 dark:text-gray-6 forced-colors:text-[GrayText]", + true: "text-gray-7 dark:text-graydark-7 forced-colors:text-[GrayText]", }, }, }); @@ -71,7 +71,7 @@ export const dropdownItemStyles = tv({ true: "text-gray-dim forced-colors:text-[GrayText]", }, isFocused: { - true: "bg-purple-action text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", + true: "bg-purple-9 dark:bg-purpledark-9 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", }, }, compoundVariants: [ diff --git a/src/components/shared/RadioGroup.tsx b/src/components/shared/RadioGroup.tsx index dec20c4a..28321d62 100644 --- a/src/components/shared/RadioGroup.tsx +++ b/src/components/shared/RadioGroup.tsx @@ -38,18 +38,18 @@ export function RadioGroup(props: RadioGroupProps) { const styles = tv({ extend: focusRing, - base: "w-5 h-5 rounded-full border-2 bg-white dark:bg-gray-12 transition-all", + base: "w-5 h-5 rounded-full border bg-white dark:bg-gray-12 transition-all cursor-pointer", variants: { isSelected: { false: - "border-gray-4 dark:border-gray-4 group-pressed:border-gray-5 dark:group-pressed:border-gray-3", - true: "border-[7px] border-gray-9 dark:border-gray-3 forced-colors:!border-[Highlight] group-pressed:border-gray-10 dark:group-pressed:border-gray-2", + "border-gray-5 dark:border-graydark-5 group-pressed:border-gray-6 dark:group-pressed:border-graydark-6", + true: "border-[7px] dark:bg-white border-purple-9 dark:border-purpledark-9 forced-colors:!border-[Highlight] group-pressed:border-gray-10 dark:group-pressed:border-graydark-10", }, isInvalid: { - true: "border-red-10 dark:border-red-9 group-pressed:border-red-11 dark:group-pressed:border-red-10 forced-colors:!border-[Mark]", + true: "border-red-9 dark:border-reddark-9 group-pressed:border-red-11 dark:group-pressed:border-reddark-11 forced-colors:!border-[Mark]", }, isDisabled: { - true: "border-gray-2 dark:border-gray-8 forced-colors:!border-[GrayText]", + true: "border-gray-2 dark:border-gray-8 cursor-default forced-colors:!border-[GrayText]", }, }, }); @@ -60,7 +60,7 @@ export function Radio(props: RadioProps) { {...props} className={composeTailwindRenderProps( props.className, - "flex gap-2 items-center group text-gray-10 disabled:text-gray-3 dark:text-gray-2 dark:disabled:text-gray-6 forced-colors:disabled:text-[GrayText] text-sm transition", + "flex gap-2 items-center group text-gray-default disabled:opacity-50 forced-colors:disabled:text-[GrayText] text-sm transition", )} > {(renderProps) => ( diff --git a/src/routes/settings/index.tsx b/src/routes/settings/index.tsx index 781f454c..c74965a3 100644 --- a/src/routes/settings/index.tsx +++ b/src/routes/settings/index.tsx @@ -2,14 +2,18 @@ import { useAuthActions } from "@convex-dev/auth/react"; import { RiCheckLine, RiLoader4Line } from "@remixicon/react"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; +import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import { api } from "../../../convex/_generated/api"; +import type { Theme } from "../../../convex/constants"; import { Button, Container, Modal, PageHeader, + Radio, + RadioGroup, Switch, TextField, } from "../../components/shared"; @@ -27,17 +31,19 @@ export const Route = createFileRoute("/settings/")({ function SettingsRoute() { const { signOut } = useAuthActions(); + const { setTheme } = useTheme(); const user = useQuery(api.users.getCurrentUser); // Name change field // TODO: Extract all this debounce logic + field as a component for reuse const updateName = useMutation(api.users.setCurrentUserName); - const [name, setName] = useState(user?.name); + const [name, setName] = useState(user?.name ?? ""); const [isUpdatingName, setIsUpdatingName] = useState(false); const [didUpdateName, setDidUpdateName] = useState(false); useEffect(() => { - setName(user?.name); + if (!user?.name) return; + setName(user.name); }, [user]); let timeout: NodeJS.Timeout | null = null; @@ -78,6 +84,14 @@ function SettingsRoute() { // Is minor switch const updateIsMinor = useMutation(api.users.setCurrentUserIsMinor); + // Theme change + const updateTheme = useMutation(api.users.setUserTheme); + + const handleUpdateTheme = (value: string) => { + updateTheme({ theme: value as Theme }); + setTheme(value); + }; + // Account deletion const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const deleteAccount = useMutation(api.users.deleteCurrentUser); @@ -106,6 +120,15 @@ function SettingsRoute() { > Is minor + + System + Light + Dark +