From 5206fb4f88ef0b87759ccf28336a9bc3fee0892f Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Fri, 9 Jul 2021 22:59:21 +0000 Subject: [PATCH] Implemented theme through user preferences --- components/Theme.tsx | 19 ++++ pages/[user].tsx | 28 +++--- pages/[user]/[type].tsx | 6 +- pages/[user]/book.tsx | 32 +++--- pages/api/user/profile.ts | 24 +++-- pages/settings/profile.tsx | 97 +++++++++++++------ pages/success.tsx | 48 +++++---- .../migration.sql | 2 + prisma/schema.prisma | 1 + tailwind.config.js | 2 +- 10 files changed, 161 insertions(+), 98 deletions(-) create mode 100644 components/Theme.tsx create mode 100644 prisma/migrations/20210709231256_add_user_theme/migration.sql diff --git a/components/Theme.tsx b/components/Theme.tsx new file mode 100644 index 00000000..78350999 --- /dev/null +++ b/components/Theme.tsx @@ -0,0 +1,19 @@ +import {useEffect, useState} from "react"; + +const Theme = (theme?: string) => { + const [isReady, setIsReady] = useState(false); + useEffect( () => { + if (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.add(theme); + } + setIsReady(true); + }, []); + + return { + isReady + } +}; + +export default Theme; diff --git a/pages/[user].tsx b/pages/[user].tsx index bdb78725..960e90a0 100644 --- a/pages/[user].tsx +++ b/pages/[user].tsx @@ -1,10 +1,14 @@ import { GetServerSideProps } from "next"; import Head from "next/head"; import Link from "next/link"; -import prisma from "../lib/prisma"; +import prisma, {whereAndSelect} from "@lib/prisma"; import Avatar from "../components/Avatar"; +import Theme from "@components/Theme"; export default function User(props): User { + + const { isReady } = Theme(props.user.theme); + const eventTypes = props.eventTypes.map((type) => (
  • )); - return ( + return isReady && (
    {props.user.name || props.user.username} | Calendso @@ -50,21 +54,13 @@ export default function User(props): User { } export const getServerSideProps: GetServerSideProps = async (context) => { - const user = await prisma.user.findFirst({ - where: { - username: context.query.user.toLowerCase(), - }, - select: { - id: true, - username: true, - email: true, - name: true, - bio: true, - avatar: true, - eventTypes: true, - }, - }); + const user = await whereAndSelect(prisma.user.findFirst, { + username: context.query.user.toLowerCase(), + }, [ + "id", "username", "email", "name", "bio", "avatar", "eventTypes", "theme" + ] + ); if (!user) { return { notFound: true, diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 52e0773b..d8ba07bf 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -13,12 +13,15 @@ import Avatar from "../../components/Avatar"; import { timeZone } from "../../lib/clock"; import DatePicker from "../../components/booking/DatePicker"; import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; +import Theme from "@components/Theme"; export default function Type(props): Type { // Get router variables const router = useRouter(); const { rescheduleUid } = router.query; + const { isReady } = Theme(props.user.theme); + const [selectedDate, setSelectedDate] = useState(); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [timeFormat, setTimeFormat] = useState("h:mma"); @@ -44,7 +47,7 @@ export default function Type(props): Type { setTimeFormat(is24hClock ? "HH:mm" : "h:mma"); }; - return ( + return isReady && (
    @@ -174,6 +177,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { "weekStart", "availability", "hideBranding", + "theme", ] ); diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index ec348b69..fe305e2c 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -2,7 +2,7 @@ import Head from "next/head"; import Link from "next/link"; import { useRouter } from "next/router"; import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid"; -import prisma from "../../lib/prisma"; +import prisma, {whereAndSelect} from "../../lib/prisma"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry"; import { useEffect, useState } from "react"; import dayjs from "dayjs"; @@ -14,6 +14,7 @@ import { LocationType } from "../../lib/location"; import Avatar from "../../components/Avatar"; import Button from "../../components/ui/Button"; import { EventTypeCustomInputType } from "../../lib/eventTypeInput"; +import Theme from "@components/Theme"; dayjs.extend(utc); dayjs.extend(timezone); @@ -32,7 +33,10 @@ export default function Book(props: any): JSX.Element { const [selectedLocation, setSelectedLocation] = useState<LocationType>( locations.length === 1 ? locations[0].type : "" ); + + const { isReady } = Theme(props.user.theme); const telemetry = useTelemetry(); + useEffect(() => { setPreferredTimeZone(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()); setIs24h(!!localStorage.getItem("timeOption.is24hClock")); @@ -137,7 +141,7 @@ export default function Book(props: any): JSX.Element { book(); }; - return ( + return isReady && ( <div> <Head> <title> @@ -366,19 +370,19 @@ export default function Book(props: any): JSX.Element { } export async function getServerSideProps(context) { - const user = await prisma.user.findFirst({ - where: { + + const user = await whereAndSelect(prisma.user.findFirst, { username: context.query.user, - }, - select: { - username: true, - name: true, - email: true, - bio: true, - avatar: true, - eventTypes: true, - }, - }); + }, [ + "username", + "name", + "email", + "bio", + "avatar", + "eventTypes", + "theme", + ] + ); const eventType = await prisma.eventType.findUnique({ where: { diff --git a/pages/api/user/profile.ts b/pages/api/user/profile.ts index dfb13315..0cfcab43 100644 --- a/pages/api/user/profile.ts +++ b/pages/api/user/profile.ts @@ -1,6 +1,6 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { getSession } from 'next-auth/client'; -import prisma from '../../../lib/prisma'; +import prisma, {whereAndSelect} from '@lib/prisma'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getSession({req: req}); @@ -11,15 +11,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } // Get user - const user = await prisma.user.findUnique({ - where: { - email: session.user.email, + const user = await whereAndSelect(prisma.user.findUnique, { + id: session.user.id, }, - select: { - id: true, - password: true - } - }); + [ "id", "password" ] + ); if (!user) { res.status(404).json({message: 'User not found'}); return; } @@ -42,6 +38,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const timeZone = req.body.timeZone; const weekStart = req.body.weekStart; const hideBranding = req.body.hideBranding; + const theme = req.body.theme; const updateUser = await prisma.user.update({ where: { @@ -52,11 +49,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) name, avatar, bio: description, - timeZone: timeZone, - weekStart: weekStart, - hideBranding: hideBranding, + timeZone, + weekStart, + hideBranding, + theme, }, }); return res.status(200).json({message: 'Profile updated successfully'}); -} \ No newline at end of file +} diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index 8278a55c..e103b61c 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -1,12 +1,13 @@ import { GetServerSideProps } from "next"; import Head from "next/head"; -import { useRef, useState } from "react"; -import prisma from "../../lib/prisma"; +import {useEffect, useRef, useState} from "react"; +import prisma, {whereAndSelect} from "@lib/prisma"; import Modal from "../../components/Modal"; import Shell from "../../components/Shell"; import SettingsShell from "../../components/Settings"; import Avatar from "../../components/Avatar"; import { getSession } from "next-auth/client"; +import Select from "react-select"; import TimezoneSelect from "react-timezone-select"; import { UsernameInput } from "../../components/ui/UsernameInput"; import ErrorAlert from "../../components/ui/alerts/Error"; @@ -18,12 +19,23 @@ export default function Settings(props) { const descriptionRef = useRef<HTMLTextAreaElement>(); const avatarRef = useRef<HTMLInputElement>(); const hideBrandingRef = useRef<HTMLInputElement>(); + const [selectedTheme, setSelectedTheme] = useState({ value: "" }); const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone }); - const [selectedWeekStartDay, setSelectedWeekStartDay] = useState(props.user.weekStart || "Sunday"); + const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ value: "" }); const [hasErrors, setHasErrors] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + const themeOptions = [ + {value: 'light', label: 'Light'}, + {value: 'dark', label: 'Dark'} + ]; + + useEffect( () => { + setSelectedTheme(props.user.theme ? themeOptions.find( (theme) => theme.value === props.user.theme ) : null); + setSelectedWeekStartDay({ value: props.user.weekStart, label: props.user.weekStart }); + }, []); + const closeSuccessModal = () => { setSuccessModalOpen(false); }; @@ -43,7 +55,7 @@ export default function Settings(props) { const enteredDescription = descriptionRef.current.value; const enteredAvatar = avatarRef.current.value; const enteredTimeZone = selectedTimeZone.value; - const enteredWeekStartDay = selectedWeekStartDay; + const enteredWeekStartDay = selectedWeekStartDay.value; const enteredHideBranding = hideBrandingRef.current.checked; // TODO: Add validation @@ -58,6 +70,7 @@ export default function Settings(props) { timeZone: enteredTimeZone, weekStart: enteredWeekStartDay, hideBranding: enteredHideBranding, + theme: selectedTheme ? selectedTheme.value : null, }), headers: { "Content-Type": "application/json", @@ -124,8 +137,8 @@ export default function Settings(props) { name="about" placeholder="A little something about yourself." rows={3} + defaultValue={props.user.bio} className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"> - {props.user.bio} </textarea> </div> </div> @@ -147,14 +160,46 @@ export default function Settings(props) { First Day of Week </label> <div className="mt-1"> - <select + <Select id="weekStart" value={selectedWeekStartDay} - onChange={(e) => setSelectedWeekStartDay(e.target.value)} - className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"> - <option value="Sunday">Sunday</option> - <option value="Monday">Monday</option> - </select> + onChange={setSelectedWeekStartDay} + className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + options={[ + { value:'Sunday', label:'Sunday' }, + { value:'Monday', label:'Monday' } + ]} /> + </div> + </div> + <div> + <label htmlFor="theme" className="block text-sm font-medium text-gray-700"> + Single Theme + </label> + <div className="my-1"> + <Select + id="theme" + isDisabled={!selectedTheme} + defaultValue={selectedTheme || themeOptions[0]} + onChange={setSelectedTheme} + className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md" + options={themeOptions} /> + </div> + <div className="relative flex items-start"> + <div className="flex items-center h-5"> + <input + id="theme-adjust-os" + name="theme-adjust-os" + type="checkbox" + onChange={(e) => setSelectedTheme(e.target.checked ? null : themeOptions[0])} + defaultChecked={!selectedTheme} + className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" + /> + </div> + <div className="ml-3 text-sm"> + <label htmlFor="theme-adjust-os" className="font-medium text-gray-700"> + Automatically adjust theme based on invitee preferences + </label> + </div> </div> </div> <div> @@ -257,22 +302,20 @@ export const getServerSideProps: GetServerSideProps = async (context) => { return { redirect: { permanent: false, destination: "/auth/login" } }; } - const user = await prisma.user.findFirst({ - where: { - email: session.user.email, - }, - select: { - id: true, - username: true, - name: true, - email: true, - bio: true, - avatar: true, - timeZone: true, - weekStart: true, - hideBranding: true, - }, - }); + const user = await whereAndSelect(prisma.user.findFirst, { + id: session.user.id, + }, [ + "id", + "username", + "name", + "email", + "bio", + "avatar", + "timeZone", + "weekStart", + "hideBranding", + "theme" + ]); return { props: { user }, // will be passed to the page component as props diff --git a/pages/success.tsx b/pages/success.tsx index 286897b9..20d9e7c3 100644 --- a/pages/success.tsx +++ b/pages/success.tsx @@ -1,6 +1,6 @@ import Head from "next/head"; import Link from "next/link"; -import prisma from "../lib/prisma"; +import prisma, {whereAndSelect} from "../lib/prisma"; import { useEffect, useState } from "react"; import { useRouter } from "next/router"; import { CheckIcon } from "@heroicons/react/outline"; @@ -11,6 +11,7 @@ import toArray from "dayjs/plugin/toArray"; import timezone from "dayjs/plugin/timezone"; import { createEvent } from "ics"; import { getEventName } from "../lib/event"; +import Theme from "@components/Theme"; dayjs.extend(utc); dayjs.extend(toArray); @@ -22,6 +23,7 @@ export default function Success(props) { const [is24h, setIs24h] = useState(false); const [date, setDate] = useState(dayjs.utc(router.query.date)); + const { isReady } = Theme(props.user.theme); useEffect(() => { setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess())); @@ -56,7 +58,7 @@ export default function Success(props) { return encodeURIComponent(event.value); } - return ( + return isReady && ( <div> <Head> <title>Booking Confirmed | {eventName} | Calendso @@ -212,32 +214,26 @@ export default function Success(props) { } export async function getServerSideProps(context) { - const user = await prisma.user.findFirst({ - where: { - username: context.query.user, - }, - select: { - username: true, - name: true, - bio: true, - avatar: true, - eventTypes: true, - hideBranding: true, - }, - }); - const eventType = await prisma.eventType.findUnique({ - where: { + const user = (context.query.user) ? await whereAndSelect(prisma.user.findFirst, { + username: context.query.user, + }, [ + "username", "name", "bio", "avatar", "eventTypes", "hideBranding", "theme" + ] + ) : null; + + if (!user) { + return { + notFound: true, + }; + } + + const eventType = await whereAndSelect(prisma.eventType.findUnique, { id: parseInt(context.query.type), - }, - select: { - id: true, - title: true, - description: true, - length: true, - eventName: true, - }, - }); + }, [ + "id", "title", "description", "length", "eventName" + ] + ); return { props: { diff --git a/prisma/migrations/20210709231256_add_user_theme/migration.sql b/prisma/migrations/20210709231256_add_user_theme/migration.sql new file mode 100644 index 00000000..75875267 --- /dev/null +++ b/prisma/migrations/20210709231256_add_user_theme/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "theme" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ddaf7d81..f0838096 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { endTime Int @default(1440) bufferTime Int @default(0) hideBranding Boolean @default(false) + theme String? createdDate DateTime @default(now()) @map(name: "created") eventTypes EventType[] credentials Credential[] diff --git a/tailwind.config.js b/tailwind.config.js index 24bfaf0f..c14305a9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,7 @@ module.exports = { mode: "jit", purge: ["./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"], - darkMode: "media", + darkMode: "class", theme: { extend: { colors: {