Skip to content

Commit

Permalink
feat: Add theme selector and store user theme preference (#56)
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker authored Sep 11, 2024
1 parent fb8d8da commit 6e3e40a
Show file tree
Hide file tree
Showing 7 changed files with 60 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/modern-jobs-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Allow users to toggle between system, light, and dark themes
2 changes: 2 additions & 0 deletions convex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@ export enum JURISDICTIONS {
WV = "West Virginia",
WY = "Wyoming",
}

export type Theme = "system" | "light" | "dark";
8 changes: 8 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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()),
Expand All @@ -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"]),

/**
Expand Down
12 changes: 12 additions & 0 deletions convex/users.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/shared/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
},
},
});
Expand Down Expand Up @@ -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: [
Expand Down
12 changes: 6 additions & 6 deletions src/components/shared/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
},
},
});
Expand All @@ -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) => (
Expand Down
27 changes: 25 additions & 2 deletions src/routes/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string>(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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -106,6 +120,15 @@ function SettingsRoute() {
>
Is minor
</Switch>
<RadioGroup
label="Theme"
value={user.theme}
onChange={handleUpdateTheme}
>
<Radio value="system">System</Radio>
<Radio value="light">Light</Radio>
<Radio value="dark">Dark</Radio>
</RadioGroup>
<Button onPress={signOut}>Sign out</Button>
<Button onPress={() => setIsDeleteModalOpen(true)}>
Delete account
Expand Down

0 comments on commit 6e3e40a

Please sign in to comment.