Merge pull request #354 from emrysal/feature/user-theme

Implemented theme through user preferences
This commit is contained in:
Bailey Pumfleet 2021-07-12 14:01:08 +01:00 committed by GitHub
commit 6ed9bfde7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 647 additions and 577 deletions

17
components/Theme.tsx Normal file
View file

@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export default function 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,
};
}

View file

@ -1,10 +1,13 @@
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import prisma from "../lib/prisma"; import prisma, { whereAndSelect } from "@lib/prisma";
import Avatar from "../components/Avatar"; import Avatar from "../components/Avatar";
import Theme from "@components/Theme";
export default function User(props): User { export default function User(props): User {
const { isReady } = Theme(props.user.theme);
const eventTypes = props.eventTypes.map((type) => ( const eventTypes = props.eventTypes.map((type) => (
<li <li
key={type.id} key={type.id}
@ -21,6 +24,7 @@ export default function User(props): User {
</li> </li>
)); ));
return ( return (
isReady && (
<div> <div>
<Head> <Head>
<title>{props.user.name || props.user.username} | Calendso</title> <title>{props.user.name || props.user.username} | Calendso</title>
@ -46,25 +50,18 @@ export default function User(props): User {
</div> </div>
</main> </main>
</div> </div>
)
); );
} }
export const getServerSideProps: GetServerSideProps = async (context) => { export const getServerSideProps: GetServerSideProps = async (context) => {
const user = await prisma.user.findFirst({ const user = await whereAndSelect(
where: { prisma.user.findFirst,
{
username: context.query.user.toLowerCase(), username: context.query.user.toLowerCase(),
}, },
select: { ["id", "username", "email", "name", "bio", "avatar", "eventTypes", "theme"]
id: true, );
username: true,
email: true,
name: true,
bio: true,
avatar: true,
eventTypes: true,
},
});
if (!user) { if (!user) {
return { return {
notFound: true, notFound: true,

View file

@ -13,12 +13,15 @@ import Avatar from "../../components/Avatar";
import { timeZone } from "../../lib/clock"; import { timeZone } from "../../lib/clock";
import DatePicker from "../../components/booking/DatePicker"; import DatePicker from "../../components/booking/DatePicker";
import PoweredByCalendso from "../../components/ui/PoweredByCalendso"; import PoweredByCalendso from "../../components/ui/PoweredByCalendso";
import Theme from "@components/Theme";
export default function Type(props): Type { export default function Type(props): Type {
// Get router variables // Get router variables
const router = useRouter(); const router = useRouter();
const { rescheduleUid } = router.query; const { rescheduleUid } = router.query;
const { isReady } = Theme(props.user.theme);
const [selectedDate, setSelectedDate] = useState<Dayjs>(); const [selectedDate, setSelectedDate] = useState<Dayjs>();
const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false);
const [timeFormat, setTimeFormat] = useState("h:mma"); const [timeFormat, setTimeFormat] = useState("h:mma");
@ -46,11 +49,12 @@ export default function Type(props): Type {
}; };
return ( return (
isReady && (
<div> <div>
<Head> <Head>
<title> <title>
{rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username} | {rescheduleUid && "Reschedule"} {props.eventType.title} | {props.user.name || props.user.username}{" "}
Calendso | Calendso
</title> </title>
<meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} /> <meta name="title" content={"Meet " + (props.user.name || props.user.username) + " via Calendso"} />
<meta name="description" content={props.eventType.description} /> <meta name="description" content={props.eventType.description} />
@ -152,6 +156,7 @@ export default function Type(props): Type {
{!props.user.hideBranding && <PoweredByCalendso />} {!props.user.hideBranding && <PoweredByCalendso />}
</main> </main>
</div> </div>
)
); );
} }
@ -175,6 +180,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
"weekStart", "weekStart",
"availability", "availability",
"hideBranding", "hideBranding",
"theme",
] ]
); );

View file

@ -2,7 +2,7 @@ import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CalendarIcon, ClockIcon, ExclamationIcon, LocationMarkerIcon } from "@heroicons/react/solid"; 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 { collectPageParameters, telemetryEventTypes, useTelemetry } from "../../lib/telemetry";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -14,6 +14,7 @@ import { LocationType } from "../../lib/location";
import Avatar from "../../components/Avatar"; import Avatar from "../../components/Avatar";
import Button from "../../components/ui/Button"; import Button from "../../components/ui/Button";
import { EventTypeCustomInputType } from "../../lib/eventTypeInput"; import { EventTypeCustomInputType } from "../../lib/eventTypeInput";
import Theme from "@components/Theme";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -32,7 +33,10 @@ export default function Book(props: any): JSX.Element {
const [selectedLocation, setSelectedLocation] = useState<LocationType>( const [selectedLocation, setSelectedLocation] = useState<LocationType>(
locations.length === 1 ? locations[0].type : "" locations.length === 1 ? locations[0].type : ""
); );
const { isReady } = Theme(props.user.theme);
const telemetry = useTelemetry(); const telemetry = useTelemetry();
useEffect(() => { useEffect(() => {
setPreferredTimeZone(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()); setPreferredTimeZone(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess());
setIs24h(!!localStorage.getItem("timeOption.is24hClock")); setIs24h(!!localStorage.getItem("timeOption.is24hClock"));
@ -138,6 +142,7 @@ export default function Book(props: any): JSX.Element {
}; };
return ( return (
isReady && (
<div> <div>
<Head> <Head>
<title> <title>
@ -194,7 +199,9 @@ export default function Book(props: any): JSX.Element {
</div> </div>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium dark:text-white text-gray-700"> <label
htmlFor="email"
className="block text-sm font-medium dark:text-white text-gray-700">
Email address Email address
</label> </label>
<div className="mt-1"> <div className="mt-1">
@ -300,7 +307,9 @@ export default function Book(props: any): JSX.Element {
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2" className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded mr-2"
placeholder="" placeholder=""
/> />
<label htmlFor={input.label} className="block text-sm font-medium text-gray-700"> <label
htmlFor={input.label}
className="block text-sm font-medium text-gray-700">
{input.label} {input.label}
</label> </label>
</div> </div>
@ -362,23 +371,18 @@ export default function Book(props: any): JSX.Element {
</div> </div>
</main> </main>
</div> </div>
)
); );
} }
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({ const user = await whereAndSelect(
where: { prisma.user.findFirst,
{
username: context.query.user, username: context.query.user,
}, },
select: { ["username", "name", "email", "bio", "avatar", "eventTypes", "theme"]
username: true, );
name: true,
email: true,
bio: true,
avatar: true,
eventTypes: true,
},
});
const eventType = await prisma.eventType.findUnique({ const eventType = await prisma.eventType.findUnique({
where: { where: {

View file

@ -1,27 +1,28 @@
import type { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from 'next-auth/client'; 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) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getSession({req: req}); const session = await getSession({ req: req });
if (!session) { if (!session) {
res.status(401).json({message: "Not authenticated"}); res.status(401).json({ message: "Not authenticated" });
return; return;
} }
// Get user // Get user
const user = await prisma.user.findUnique({ const user = await whereAndSelect(
where: { prisma.user.findUnique,
email: session.user.email, {
id: session.user.id,
}, },
select: { ["id", "password"]
id: true, );
password: true
}
});
if (!user) { res.status(404).json({message: 'User not found'}); return; } if (!user) {
res.status(404).json({ message: "User not found" });
return;
}
const username = req.body.username; const username = req.body.username;
// username is changed: username is optional but it is necessary to be unique, enforce here // username is changed: username is optional but it is necessary to be unique, enforce here
@ -29,10 +30,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const userConflict = await prisma.user.findFirst({ const userConflict = await prisma.user.findFirst({
where: { where: {
username, username,
} },
}); });
if (userConflict) { if (userConflict) {
return res.status(409).json({ message: 'Username already taken' }); return res.status(409).json({ message: "Username already taken" });
} }
} }
@ -42,8 +43,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const timeZone = req.body.timeZone; const timeZone = req.body.timeZone;
const weekStart = req.body.weekStart; const weekStart = req.body.weekStart;
const hideBranding = req.body.hideBranding; const hideBranding = req.body.hideBranding;
const theme = req.body.theme;
const updateUser = await prisma.user.update({ await prisma.user.update({
where: { where: {
id: user.id, id: user.id,
}, },
@ -52,11 +54,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
name, name,
avatar, avatar,
bio: description, bio: description,
timeZone: timeZone, timeZone,
weekStart: weekStart, weekStart,
hideBranding: hideBranding, hideBranding,
theme,
}, },
}); });
return res.status(200).json({message: 'Profile updated successfully'}); return res.status(200).json({ message: "Profile updated successfully" });
} }

View file

@ -1,12 +1,13 @@
import { GetServerSideProps } from "next"; import { GetServerSideProps } from "next";
import Head from "next/head"; import Head from "next/head";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import prisma from "../../lib/prisma"; import prisma, { whereAndSelect } from "@lib/prisma";
import Modal from "../../components/Modal"; import Modal from "../../components/Modal";
import Shell from "../../components/Shell"; import Shell from "../../components/Shell";
import SettingsShell from "../../components/Settings"; import SettingsShell from "../../components/Settings";
import Avatar from "../../components/Avatar"; import Avatar from "../../components/Avatar";
import { getSession } from "next-auth/client"; import { getSession } from "next-auth/client";
import Select from "react-select";
import TimezoneSelect from "react-timezone-select"; import TimezoneSelect from "react-timezone-select";
import { UsernameInput } from "../../components/ui/UsernameInput"; import { UsernameInput } from "../../components/ui/UsernameInput";
import ErrorAlert from "../../components/ui/alerts/Error"; import ErrorAlert from "../../components/ui/alerts/Error";
@ -18,12 +19,25 @@ export default function Settings(props) {
const descriptionRef = useRef<HTMLTextAreaElement>(); const descriptionRef = useRef<HTMLTextAreaElement>();
const avatarRef = useRef<HTMLInputElement>(); const avatarRef = useRef<HTMLInputElement>();
const hideBrandingRef = useRef<HTMLInputElement>(); const hideBrandingRef = useRef<HTMLInputElement>();
const [selectedTheme, setSelectedTheme] = useState({ value: "" });
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone }); 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 [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState(""); 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 = () => { const closeSuccessModal = () => {
setSuccessModalOpen(false); setSuccessModalOpen(false);
}; };
@ -43,7 +57,7 @@ export default function Settings(props) {
const enteredDescription = descriptionRef.current.value; const enteredDescription = descriptionRef.current.value;
const enteredAvatar = avatarRef.current.value; const enteredAvatar = avatarRef.current.value;
const enteredTimeZone = selectedTimeZone.value; const enteredTimeZone = selectedTimeZone.value;
const enteredWeekStartDay = selectedWeekStartDay; const enteredWeekStartDay = selectedWeekStartDay.value;
const enteredHideBranding = hideBrandingRef.current.checked; const enteredHideBranding = hideBrandingRef.current.checked;
// TODO: Add validation // TODO: Add validation
@ -58,6 +72,7 @@ export default function Settings(props) {
timeZone: enteredTimeZone, timeZone: enteredTimeZone,
weekStart: enteredWeekStartDay, weekStart: enteredWeekStartDay,
hideBranding: enteredHideBranding, hideBranding: enteredHideBranding,
theme: selectedTheme ? selectedTheme.value : null,
}), }),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -124,9 +139,8 @@ export default function Settings(props) {
name="about" name="about"
placeholder="A little something about yourself." placeholder="A little something about yourself."
rows={3} rows={3}
className="shadow-sm focus:ring-blue-500 focus:border-blue-500 mt-1 block w-full sm:text-sm border-gray-300 rounded-md"> defaultValue={props.user.bio}
{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"></textarea>
</textarea>
</div> </div>
</div> </div>
<div> <div>
@ -147,14 +161,48 @@ export default function Settings(props) {
First Day of Week First Day of Week
</label> </label>
<div className="mt-1"> <div className="mt-1">
<select <Select
id="weekStart" id="weekStart"
value={selectedWeekStartDay} value={selectedWeekStartDay}
onChange={(e) => setSelectedWeekStartDay(e.target.value)} 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"> 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> options={[
<option value="Monday">Monday</option> { value: "Sunday", label: "Sunday" },
</select> { 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> </div>
<div> <div>
@ -257,22 +305,13 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
return { redirect: { permanent: false, destination: "/auth/login" } }; return { redirect: { permanent: false, destination: "/auth/login" } };
} }
const user = await prisma.user.findFirst({ const user = await whereAndSelect(
where: { prisma.user.findFirst,
email: session.user.email, {
id: session.user.id,
}, },
select: { ["id", "username", "name", "email", "bio", "avatar", "timeZone", "weekStart", "hideBranding", "theme"]
id: true, );
username: true,
name: true,
email: true,
bio: true,
avatar: true,
timeZone: true,
weekStart: true,
hideBranding: true,
},
});
return { return {
props: { user }, // will be passed to the page component as props props: { user }, // will be passed to the page component as props

View file

@ -1,6 +1,6 @@
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import prisma from "../lib/prisma"; import prisma, { whereAndSelect } from "../lib/prisma";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CheckIcon } from "@heroicons/react/outline"; import { CheckIcon } from "@heroicons/react/outline";
@ -11,6 +11,7 @@ import toArray from "dayjs/plugin/toArray";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import { createEvent } from "ics"; import { createEvent } from "ics";
import { getEventName } from "../lib/event"; import { getEventName } from "../lib/event";
import Theme from "@components/Theme";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(toArray); dayjs.extend(toArray);
@ -22,6 +23,7 @@ export default function Success(props) {
const [is24h, setIs24h] = useState(false); const [is24h, setIs24h] = useState(false);
const [date, setDate] = useState(dayjs.utc(router.query.date)); const [date, setDate] = useState(dayjs.utc(router.query.date));
const { isReady } = Theme(props.user.theme);
useEffect(() => { useEffect(() => {
setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess())); setDate(date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess()));
@ -31,7 +33,7 @@ export default function Success(props) {
const eventName = getEventName(name, props.eventType.title, props.eventType.eventName); const eventName = getEventName(name, props.eventType.title, props.eventType.eventName);
function eventLink(): string { function eventLink(): string {
let optional = {}; const optional = {};
if (location) { if (location) {
optional["location"] = location; optional["location"] = location;
} }
@ -57,6 +59,7 @@ export default function Success(props) {
} }
return ( return (
isReady && (
<div> <div>
<Head> <Head>
<title>Booking Confirmed | {eventName} | Calendso</title> <title>Booking Confirmed | {eventName} | Calendso</title>
@ -208,36 +211,34 @@ export default function Success(props) {
</div> </div>
</main> </main>
</div> </div>
)
); );
} }
export async function getServerSideProps(context) { export async function getServerSideProps(context) {
const user = await prisma.user.findFirst({ const user = context.query.user
where: { ? await whereAndSelect(
prisma.user.findFirst,
{
username: context.query.user, username: context.query.user,
}, },
select: { ["username", "name", "bio", "avatar", "eventTypes", "hideBranding", "theme"]
username: true, )
name: true, : null;
bio: true,
avatar: true,
eventTypes: true,
hideBranding: true,
},
});
const eventType = await prisma.eventType.findUnique({ if (!user) {
where: { return {
notFound: true,
};
}
const eventType = await whereAndSelect(
prisma.eventType.findUnique,
{
id: parseInt(context.query.type), id: parseInt(context.query.type),
}, },
select: { ["id", "title", "description", "length", "eventName"]
id: true, );
title: true,
description: true,
length: true,
eventName: true,
},
});
return { return {
props: { props: {

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "theme" TEXT;

View file

@ -50,6 +50,7 @@ model User {
endTime Int @default(1440) endTime Int @default(1440)
bufferTime Int @default(0) bufferTime Int @default(0)
hideBranding Boolean @default(false) hideBranding Boolean @default(false)
theme String?
createdDate DateTime @default(now()) @map(name: "created") createdDate DateTime @default(now()) @map(name: "created")
eventTypes EventType[] eventTypes EventType[]
credentials Credential[] credentials Credential[]

View file

@ -1,7 +1,7 @@
module.exports = { module.exports = {
mode: "jit", mode: "jit",
purge: ["./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"], purge: ["./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}"],
darkMode: "media", darkMode: "class",
theme: { theme: {
extend: { extend: {
colors: { colors: {