add theme color customization

This commit is contained in:
Sidney Alcantara
2021-09-07 12:37:42 +10:00
parent bfe0b1bb3e
commit d46ce96d81
19 changed files with 691 additions and 208 deletions

View File

@@ -12,7 +12,6 @@ import EmptyState from "components/EmptyState";
import Loading from "components/Loading";
import Navigation from "components/Navigation";
import Logo from "assets/Logo";
import MigrateToV2 from "components/Settings/MigrateToV2";
import { SnackProvider } from "contexts/SnackContext";
import ConfirmationProvider from "components/ConfirmationDialog/Provider";
@@ -165,8 +164,6 @@ export default function App() {
)}
/>
</Switch>
<MigrateToV2 />
</RowyContextProvider>
)}
/>

View File

@@ -70,9 +70,9 @@ export default function EmptyState({
<Grid
item
sx={{
maxWidth: "25em",
width: (theme) => `calc(100% - ${theme.spacing(2 * 2)})`,
px: 2,
maxWidth: "25em !important",
width: (theme) => `calc(100% - ${theme.spacing(1 * 2)})`,
px: 1,
typography: "body2",
"& .icon": {

View File

@@ -7,6 +7,7 @@ import {
} from "@material-ui/core";
import SearchIcon from "@material-ui/icons/Search";
import SlideTransition from "components/Modal/SlideTransition";
import { APP_BAR_HEIGHT } from "components/Navigation";
export interface IFloatingSearchProps extends Partial<FilledTextFieldProps> {
@@ -22,67 +23,70 @@ export default function FloatingSearch({
const trigger = useScrollTrigger({ disableHysteresis: true, threshold: 0 });
return (
<Paper
elevation={trigger ? 8 : 1}
sx={{
position: "sticky",
top: (theme) => theme.spacing(APP_BAR_HEIGHT / 8 + 1),
zIndex: "appBar",
...paperSx,
}}
>
<TextField
label={label}
placeholder={label}
hiddenLabel
fullWidth
type="search"
id="user-management-search"
size="medium"
autoFocus
InputProps={{
startAdornment: (
<InputAdornment
position="start"
sx={{ px: 0.5, pointerEvents: "none" }}
>
<SearchIcon />
</InputAdornment>
),
}}
<SlideTransition in timeout={50}>
<Paper
elevation={trigger ? 8 : 1}
sx={{
"& .MuiInputLabel-root": {
height: "0px",
m: 0,
p: 0,
pointerEvents: "none",
opacity: 0,
},
"& .MuiFilledInput-root": {
borderRadius: 2,
boxShadow: (theme) =>
`0 -1px 0 0 ${theme.palette.text.disabled} inset`,
"&:hover": {
boxShadow: (theme) =>
`0 -1px 0 0 ${theme.palette.text.primary} inset`,
},
"&.Mui-focused, &.Mui-focused:hover": {
boxShadow: (theme) =>
`0 -2px 0 0 ${theme.palette.primary.main} inset`,
},
"&::after": {
width: (theme) =>
`calc(100% - ${
(theme.shape.borderRadius as number) * 2 * 2
}px)`,
left: (theme) => (theme.shape.borderRadius as number) * 2,
},
},
position: "sticky",
top: (theme) => theme.spacing(APP_BAR_HEIGHT / 8 + 1),
zIndex: "appBar",
height: 48,
...paperSx,
}}
{...props}
/>
</Paper>
>
<TextField
label={label}
placeholder={label}
hiddenLabel
fullWidth
type="search"
id="user-management-search"
size="medium"
autoFocus
InputProps={{
startAdornment: (
<InputAdornment
position="start"
sx={{ px: 0.5, pointerEvents: "none" }}
>
<SearchIcon />
</InputAdornment>
),
}}
sx={{
"& .MuiInputLabel-root": {
height: "0px",
m: 0,
p: 0,
pointerEvents: "none",
opacity: 0,
},
"& .MuiFilledInput-root": {
borderRadius: 2,
boxShadow: (theme) =>
`0 -1px 0 0 ${theme.palette.text.disabled} inset`,
"&:hover": {
boxShadow: (theme) =>
`0 -1px 0 0 ${theme.palette.text.primary} inset`,
},
"&.Mui-focused, &.Mui-focused:hover": {
boxShadow: (theme) =>
`0 -2px 0 0 ${theme.palette.primary.main} inset`,
},
"&::after": {
width: (theme) =>
`calc(100% - ${
(theme.shape.borderRadius as number) * 2 * 2
}px)`,
left: (theme) => (theme.shape.borderRadius as number) * 2,
},
},
}}
{...props}
/>
</Paper>
</SlideTransition>
);
}

View File

@@ -47,23 +47,13 @@ export default function UserMenu(props: IconButtonProps) {
);
const changeTheme = (option: "system" | "light" | "dark") => {
switch (option) {
case "system":
setThemeOverridden(false);
return;
case "light":
setTheme("light");
break;
case "dark":
setTheme("dark");
break;
default:
break;
if (option === "system") {
setThemeOverridden(false);
} else {
setTheme(option);
setThemeOverridden(true);
}
setThemeOverridden(true);
setThemeSubMenu(null);
setOpen(false);
};
@@ -159,13 +149,21 @@ export default function UserMenu(props: IconButtonProps) {
</Menu>
)}
<MenuItem component={Link} to={routes.userSettings} disabled>
<MenuItem
component={Link}
to={routes.userSettings}
onClick={() => setOpen(false)}
>
Settings
</MenuItem>
<Divider variant="middle" />
<MenuItem component={Link} to={routes.signOut}>
<MenuItem
component={Link}
to={routes.signOut}
onClick={() => setOpen(false)}
>
Sign out
</MenuItem>
</Menu>

View File

@@ -92,7 +92,12 @@ export default function Navigation({
<Grow in key={title}>
<Box sx={{ flex: 1, userSelect: "none" }}>
{titleComponent || (
<Typography variant="h6" component="h1" textAlign="center">
<Typography
variant="h6"
component="h1"
textAlign="center"
sx={{ typography: { sm: "h5" } }}
>
{title}
</Typography>
)}

View File

@@ -0,0 +1,55 @@
import { lazy, Suspense, useState } from "react";
import { IProjectSettingsChildProps } from "pages/Settings/ProjectSettings";
import { FormControlLabel, Checkbox, Collapse } from "@material-ui/core";
import Loading from "components/Loading";
// prettier-ignore
const ThemeColorPicker = lazy(() => import("components/Settings/ThemeColorPicker") /* webpackChunkName: "Settings/ThemeColorPicker" */);
export default function Customization({
publicSettings,
updatePublicSettings,
}: IProjectSettingsChildProps) {
const [customizedThemeColor, setCustomizedThemeColor] = useState(
publicSettings.theme?.light?.palette?.primary?.main ||
publicSettings.theme?.dark?.palette?.primary?.main
);
const handleSave = ({ light, dark }: { light: string; dark: string }) => {
updatePublicSettings({
theme: {
light: { palette: { primary: { main: light } } },
dark: { palette: { primary: { main: dark } } },
},
});
};
return (
<>
<FormControlLabel
control={
<Checkbox
checked={customizedThemeColor}
onChange={(e) => {
setCustomizedThemeColor(e.target.checked);
if (!e.target.checked) updatePublicSettings({ theme: {} });
}}
/>
}
label="Customize theme colors for all users"
sx={{ my: -10 / 8 }}
/>
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
<Suspense fallback={<Loading />}>
<ThemeColorPicker
currentLight={publicSettings.theme?.light?.palette?.primary?.main}
currentDark={publicSettings.theme?.dark?.palette?.primary?.main}
handleSave={handleSave}
/>
</Suspense>
</Collapse>
</>
);
}

View File

@@ -0,0 +1,223 @@
import { useState } from "react";
import { ChromePicker } from "react-color";
import { Grid, Typography, Stack, Box, Button } from "@material-ui/core";
import PassIcon from "@material-ui/icons/Check";
import FailIcon from "@material-ui/icons/Error";
import { PRIMARY, DARK_PRIMARY } from "theme/colors";
import themes from "theme";
import { colord, extend } from "colord";
import a11yPlugin from "colord/plugins/a11y";
import mixPlugin from "colord/plugins/mix";
extend([a11yPlugin, mixPlugin]);
export interface IThemeColorPickerProps {
currentLight?: string;
currentDark?: string;
handleSave: ({ light, dark }: { light: string; dark: string }) => void;
}
export default function ThemeColorPicker({
currentLight = PRIMARY,
currentDark = DARK_PRIMARY,
handleSave,
}: IThemeColorPickerProps) {
const [light, setLight] = useState(currentLight);
const [dark, setDark] = useState(currentDark);
const lightTheme = themes.light({});
const darkTheme = themes.dark({});
return (
<>
<Grid container spacing={2} style={{ marginTop: 0 }}>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" component="h3" gutterBottom>
Light Theme
</Typography>
<Box
sx={{
"& .chrome-picker": {
width: "100% !important",
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset !important`,
},
}}
>
<ChromePicker
color={light}
onChangeComplete={(c) => setLight(c.hex)}
/>
</Box>
<Stack
spacing={0}
sx={{ mt: 2, borderRadius: 1, overflow: "hidden" }}
>
<Swatch
backgroundColor={light}
textColor={lightTheme.palette.getContrastText(light)}
/>
<Swatch
backgroundColor={lightTheme.palette.background.default}
textColor={light}
/>
<Swatch
backgroundColor={lightTheme.palette.background.paper}
textColor={light}
/>
<Swatch
backgroundColor={colord(lightTheme.palette.background.default)
.mix(light, lightTheme.palette.action.hoverOpacity)
.alpha(1)
.toHslString()}
textColor={light}
/>
<Swatch
backgroundColor={colord(lightTheme.palette.background.default)
.mix(light, lightTheme.palette.action.selectedOpacity)
.alpha(1)
.toHslString()}
textColor={lightTheme.palette.text.primary}
/>
</Stack>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" component="h3" gutterBottom>
Dark Theme
</Typography>
<Box
sx={{
"& .chrome-picker": {
width: "100% !important",
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.divider} inset !important`,
},
}}
>
<ChromePicker
color={dark}
onChangeComplete={(c) => setDark(c.hex)}
/>
</Box>
<Stack
spacing={0}
sx={{ mt: 2, borderRadius: 1, overflow: "hidden" }}
>
<Swatch
backgroundColor={dark}
textColor={darkTheme.palette.getContrastText(dark)}
/>
<Swatch
backgroundColor={darkTheme.palette.background.default}
textColor={dark}
/>
<Swatch
backgroundColor={darkTheme.palette.background.paper}
textColor={dark}
/>
<Swatch
backgroundColor={colord(darkTheme.palette.background.paper)
.mix("#fff", 0.16)
.mix(dark, darkTheme.palette.action.hoverOpacity)
.alpha(1)
.toHslString()}
textColor={dark}
/>
<Swatch
backgroundColor={colord(darkTheme.palette.background.paper)
.mix("#fff", 0.16)
.mix(dark, darkTheme.palette.action.selectedOpacity)
.alpha(1)
.toHslString()}
textColor={darkTheme.palette.text.primary}
/>
</Stack>
</Grid>
</Grid>
<Box
sx={{
mt: 2,
position: "sticky",
bottom: (theme) => theme.spacing(2),
borderRadius: 1,
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
bgcolor: "background.paper",
boxShadow: (theme) => `0 0 0 16px ${theme.palette.background.paper}`,
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
<Button
variant="contained"
color="primary"
size="large"
fullWidth
onClick={() => handleSave({ light, dark })}
disabled={light === currentLight && dark === currentDark}
>
Save
</Button>
</Box>
</>
);
}
function Swatch({ backgroundColor, textColor }: Record<string, string>) {
if (colord(textColor).alpha() < 1)
textColor = colord(backgroundColor)
.mix(textColor, colord(textColor).alpha())
.alpha(1)
.toHslString();
const contrast = colord(backgroundColor).contrast(textColor);
const AAA = colord(backgroundColor).isReadable(textColor, { level: "AAA" });
const AA = colord(backgroundColor).isReadable(textColor, { level: "AA" });
return (
<Box
sx={{
bgcolor: backgroundColor,
color: textColor,
p: 1,
pl: 1.5,
typography: "button",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontVariantNumeric: "tabular-nums",
}}
>
{contrast}
<Box
component="span"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
width: 64,
height: 24,
px: 1,
borderRadius: 0.5,
bgcolor: AAA || AA ? "white" : "error.main",
color: AAA || AA ? "black" : "error.contrastText",
"& svg": {
fontSize: "1.125rem",
ml: 0.5,
mr: -0.5,
},
}}
>
{AAA ? "AAA" : AA ? "AA" : "FAIL"}
{AAA || AA ? <PassIcon /> : <FailIcon />}
</Box>
</Box>
);
}

View File

@@ -2,9 +2,7 @@ import { useState } from "react";
import { Link } from "react-router-dom";
import {
Tooltip,
Zoom,
Fab,
Button,
DialogContentText,
Link as MuiLink,
TextField,
@@ -20,29 +18,16 @@ export default function InviteUser() {
return (
<>
<Tooltip title="Invite User">
<Zoom in>
<Fab
aria-label="Invite User"
onClick={() => setOpen(true)}
color="secondary"
sx={{
zIndex: "speedDial",
position: "fixed",
bottom: (theme) => ({
xs: theme.spacing(2),
sm: theme.spacing(3),
}),
right: (theme) => ({
xs: theme.spacing(2),
sm: theme.spacing(3),
}),
}}
>
<AddIcon />
</Fab>
</Zoom>
</Tooltip>
<Button
aria-label="Invite User"
onClick={() => setOpen(true)}
variant="text"
color="primary"
startIcon={<AddIcon />}
sx={{ "&&": { mb: -0.5 } }}
>
Invite User
</Button>
{open && (
<Modal

View File

@@ -30,6 +30,7 @@ export default function UserItem({
overflowX: "hidden",
"& > *": { userSelect: "all" },
}}
primaryTypographyProps={{ variant: "body1" }}
/>
</>
}
@@ -40,6 +41,7 @@ export default function UserItem({
value={["ADMIN"]}
options={["ADMIN"]}
onChange={console.log}
freeText
TextFieldProps={{
fullWidth: false,

View File

@@ -0,0 +1,35 @@
import { IUserSettingsChildProps } from "pages/Settings/UserSettings";
import { Link } from "react-router-dom";
import { Grid, Avatar, Typography, Button } from "@material-ui/core";
import routes from "constants/routes";
export default function Account({ settings }: IUserSettingsChildProps) {
return (
<Grid container spacing={2} alignItems="center">
<Grid item>
<Avatar src={settings.user.photoURL} />
</Grid>
<Grid item xs>
<Typography variant="body1" style={{ userSelect: "all" }}>
{settings.user.displayName}
</Typography>
<Typography
variant="body2"
color="textSecondary"
style={{ userSelect: "all" }}
>
{settings.user.email}
</Typography>
</Grid>
<Grid item>
<Button component={Link} to={routes.signOut}>
Sign Out
</Button>
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,55 @@
import { lazy, Suspense, useState } from "react";
import { IUserSettingsChildProps } from "pages/Settings/UserSettings";
import { FormControlLabel, Checkbox, Collapse } from "@material-ui/core";
import Loading from "components/Loading";
// prettier-ignore
const ThemeColorPicker = lazy(() => import("components/Settings/ThemeColorPicker") /* webpackChunkName: "Settings/ThemeColorPicker" */);
export default function Personalization({
settings,
updateSettings,
}: IUserSettingsChildProps) {
const [customizedThemeColor, setCustomizedThemeColor] = useState(
settings.theme?.light?.palette?.primary?.main ||
settings.theme?.dark?.palette?.primary?.main
);
const handleSave = ({ light, dark }: { light: string; dark: string }) => {
updateSettings({
theme: {
light: { palette: { primary: { main: light } } },
dark: { palette: { primary: { main: dark } } },
},
});
};
return (
<>
<FormControlLabel
control={
<Checkbox
checked={customizedThemeColor}
onChange={(e) => {
setCustomizedThemeColor(e.target.checked);
if (!e.target.checked) updateSettings({ theme: {} });
}}
/>
}
label="Customize theme colors"
sx={{ my: -10 / 8 }}
/>
<Collapse in={customizedThemeColor} style={{ marginTop: 0 }}>
<Suspense fallback={<Loading />}>
<ThemeColorPicker
currentLight={settings.theme?.light?.palette?.primary?.main}
currentDark={settings.theme?.dark?.palette?.primary?.main}
handleSave={handleSave}
/>
</Suspense>
</Collapse>
</>
);
}

View File

@@ -0,0 +1,39 @@
import {
FormControl,
RadioGroup,
FormControlLabel,
Radio,
} from "@material-ui/core";
import { useAppContext } from "contexts/AppContext";
export default function Theme() {
const { theme, themeOverridden, setTheme, setThemeOverridden } =
useAppContext();
return (
<FormControl component="fieldset" variant="standard" sx={{ my: -10 / 8 }}>
<legend style={{ fontSize: 0 }}>Theme</legend>
<RadioGroup
value={themeOverridden ? theme : "system"}
onChange={(e) => {
if (e.target.value === "system") {
setThemeOverridden(false);
} else {
setTheme(e.target.value as typeof theme);
setThemeOverridden(true);
}
}}
>
<FormControlLabel
control={<Radio />}
value="system"
label="Match system theme"
/>
<FormControlLabel control={<Radio />} value="light" label="Light" />
<FormControlLabel control={<Radio />} value="dark" label="Dark" />
</RadioGroup>
</FormControl>
);
}

View File

@@ -1,20 +1,18 @@
import React, { useEffect, useState, useContext } from "react";
import { projectId, auth, db } from "@src/firebase";
import React, { useEffect, useState, useContext, useMemo } from "react";
import firebase from "firebase/app";
import useDoc from "hooks/useDoc";
import createPersistedState from "use-persisted-state";
import { analytics } from "analytics";
import {
useMediaQuery,
ThemeProvider,
ThemeOptions,
CssBaseline,
} from "@material-ui/core";
import themes from "theme";
import _merge from "lodash/merge";
import { useMediaQuery, ThemeProvider, CssBaseline } from "@material-ui/core";
import ErrorBoundary from "components/ErrorBoundary";
import { projectId, auth, db } from "@src/firebase";
import useDoc from "hooks/useDoc";
import { name } from "@root/package.json";
import { USERS } from "config/dbPaths";
import { PUBLIC_SETTINGS, USERS } from "config/dbPaths";
import { analytics } from "analytics";
import themes from "theme";
const useThemeState = createPersistedState("__ROWY__THEME");
const useThemeOverriddenState = createPersistedState(
@@ -56,6 +54,11 @@ export const AppProvider: React.FC = ({ children }) => {
document.title = `${projectId}${name}`;
}, []);
const [publicSettings] = useDoc(
{ path: PUBLIC_SETTINGS },
{ createIfMissing: true }
);
// Store matching userDoc
const [userDoc, dispatchUserDoc] = useDoc({});
// Get userDoc
@@ -67,6 +70,18 @@ export const AppProvider: React.FC = ({ children }) => {
}
}, [currentUser]);
// Set userDoc if it doesnt exist
useEffect(() => {
if (!userDoc.doc && !userDoc.loading && userDoc.path && currentUser) {
const userFields = ["email", "displayName", "photoURL", "phoneNumber"];
const user = userFields.reduce((acc, curr) => {
if (currentUser[curr]) return { ...acc, [curr]: currentUser[curr] };
return acc;
}, {});
db.doc(userDoc.path).set({ user }, { merge: true });
}
}, [userDoc, currentUser]);
// Infer theme based on system settings
const prefersDarkTheme = useMediaQuery("(prefers-color-scheme: dark)", {
noSsr: true,
@@ -83,34 +98,29 @@ export const AppProvider: React.FC = ({ children }) => {
if (prefersDarkTheme && theme !== "dark") setTheme("dark");
if (!prefersDarkTheme && theme !== "light") setTheme("light");
}, [prefersDarkTheme, themeOverridden]);
// Customize theme from project public settings & user settings
const customizedThemes = useMemo(() => {
const lightCustomizations = _merge(
{},
publicSettings.doc?.theme?.base,
userDoc.doc?.theme?.base,
publicSettings.doc?.theme?.light,
userDoc.doc?.theme?.light
);
const darkCustomizations = _merge(
{},
publicSettings.doc?.theme?.base,
userDoc.doc?.theme?.base,
publicSettings.doc?.theme?.dark,
userDoc.doc?.theme?.dark
);
// Store themeCustomization from userDoc
const [themeCustomization, setThemeCustomization] = useState<ThemeOptions>(
{}
);
const generatedTheme = themes[theme](themeCustomization);
useEffect(() => {
if (userDoc.doc) {
// Set theme customizations from user doc
setThemeCustomization(userDoc.doc.theme);
} else if (
!userDoc.doc &&
!userDoc.loading &&
userDoc.path &&
currentUser
) {
// Set userDoc if it doesnt exist
const userFields = ["email", "displayName", "photoURL", "phoneNumber"];
const userData = userFields.reduce((acc, curr) => {
if (currentUser[curr]) {
return { ...acc, [curr]: currentUser[curr] };
}
return acc;
}, {});
db.doc(userDoc.path).set({ tables: {}, user: userData }, { merge: true });
}
}, [userDoc]);
return {
light: themes.light(lightCustomizations),
dark: themes.dark(darkCustomizations),
};
}, [userDoc.doc, publicSettings.doc]);
console.log(customizedThemes);
return (
<AppContext.Provider
@@ -123,7 +133,7 @@ export const AppProvider: React.FC = ({ children }) => {
setThemeOverridden,
}}
>
<ThemeProvider theme={generatedTheme}>
<ThemeProvider theme={customizedThemes[theme]}>
<CssBaseline />
<ErrorBoundary>{children}</ErrorBoundary>
</ThemeProvider>

View File

@@ -1,3 +1,6 @@
import { useEffect } from "react";
import { useDebouncedCallback } from "use-debounce";
import { Container, Stack, Fade } from "@material-ui/core";
import SettingsSkeleton from "components/Settings/SettingsSkeleton";
@@ -5,13 +8,12 @@ import SettingsSection from "components/Settings/SettingsSection";
import About from "components/Settings/ProjectSettings/About";
import CloudRun from "@src/components/Settings/ProjectSettings/CloudRun";
import Authentication from "components/Settings/ProjectSettings/Authentication";
import Customization from "components/Settings/ProjectSettings/Customization";
import { useSnackContext } from "contexts/SnackContext";
import { SETTINGS, PUBLIC_SETTINGS } from "config/dbPaths";
import useDoc from "hooks/useDoc";
import { db } from "@src/firebase";
import { useSnackContext } from "contexts/SnackContext";
import { useDebouncedCallback } from "use-debounce";
import { useEffect } from "react";
import { name } from "@root/package.json";
export interface IProjectSettingsChildProps {
@@ -53,13 +55,6 @@ export default function ProjectSettingsPage() {
1000
);
const childProps: IProjectSettingsChildProps = {
settings,
updateSettings,
publicSettings,
updatePublicSettings,
};
useEffect(
() => () => {
callPending();
@@ -68,29 +63,37 @@ export default function ProjectSettingsPage() {
[]
);
const childProps: IProjectSettingsChildProps = {
settings,
updateSettings,
publicSettings,
updatePublicSettings,
};
const sections = [
{ title: "About", Component: About },
{ title: `${name} Run`, Component: CloudRun, props: childProps },
{ title: "Authentication", Component: Authentication, props: childProps },
{ title: "Customization", Component: Customization, props: childProps },
];
return (
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
{settingsState.loading || publicSettingsState.loading ? (
<Fade in style={{ transitionDelay: "1s" }} unmountOnExit>
<Stack spacing={4}>
<SettingsSkeleton />
<SettingsSkeleton />
<SettingsSkeleton />
{new Array(sections.length).fill(null).map((_, i) => (
<SettingsSkeleton key={i} />
))}
</Stack>
</Fade>
) : (
<Stack spacing={4}>
<SettingsSection title="About" transitionTimeout={100}>
<About />
</SettingsSection>
<SettingsSection title={`${name} Run`} transitionTimeout={200}>
<CloudRun {...childProps} />
</SettingsSection>
<SettingsSection title="Authentication" transitionTimeout={300}>
<Authentication {...childProps} />
</SettingsSection>
{sections.map(({ title, Component, props }, i) => (
<SettingsSection title={title} transitionTimeout={(i + 1) * 100}>
<Component {...(props as any)} />
</SettingsSection>
))}
</Stack>
)}
</Container>

View File

@@ -1,3 +1,5 @@
import { TransitionGroup } from "react-transition-group";
import {
Container,
Stack,
@@ -5,6 +7,7 @@ import {
Paper,
List,
Fade,
Collapse,
} from "@material-ui/core";
import FloatingSearch from "components/FloatingSearch";
@@ -51,26 +54,24 @@ export default function UserManagementPage() {
direction="row"
spacing={2}
justifyContent="space-between"
alignItems="baseline"
sx={{ mt: 4, mx: 1, mb: 0.5, cursor: "default" }}
alignItems="flex-end"
sx={{ mt: 4, ml: 1, mb: 0.5, cursor: "default" }}
>
<Typography variant="subtitle1" component="h2">
{!loading && query
? `${results.length} of ${usersState.documents.length}`
: usersState.documents.length}{" "}
Users
</Typography>
{!loading && (
<Typography variant="button" component="div">
{query
? `${results.length} of ${usersState.documents.length}`
: usersState.documents.length}
</Typography>
)}
<InviteUser />
</Stack>
</SlideTransition>
{loading || (query === "" && results.length === 0) ? (
<Fade in style={{ transitionDelay: "1s" }} unmountOnExit>
<Paper>
<List>
<List sx={{ py: { xs: 0, sm: 1.5 }, px: { xs: 0, sm: 1 } }}>
<UserSkeleton />
<UserSkeleton />
<UserSkeleton />
@@ -80,16 +81,18 @@ export default function UserManagementPage() {
) : (
<SlideTransition in timeout={100 + 50}>
<Paper>
<List>
{results.map((user) => (
<UserItem key={user.id} {...user} />
))}
<List sx={{ py: { xs: 0, sm: 1.5 }, px: { xs: 0, sm: 1 } }}>
<TransitionGroup>
{results.map((user) => (
<Collapse key={user.id}>
<UserItem {...user} />
</Collapse>
))}
</TransitionGroup>
</List>
</Paper>
</SlideTransition>
)}
<InviteUser />
</Container>
);
}

View File

@@ -1,13 +1,78 @@
import { Container, Stack } from "@material-ui/core";
import { useEffect } from "react";
import { useDebouncedCallback } from "use-debounce";
import { Container, Stack, Fade } from "@material-ui/core";
import SettingsSkeleton from "components/Settings/SettingsSkeleton";
import SettingsSection from "components/Settings/SettingsSection";
import Account from "components/Settings/UserSettings/Account";
import Theme from "components/Settings/UserSettings/Theme";
import Personalization from "components/Settings/UserSettings/Personalization";
import { useAppContext } from "@src/contexts/AppContext";
import { useSnackContext } from "contexts/SnackContext";
import { USERS } from "config/dbPaths";
import useDoc from "hooks/useDoc";
import { db } from "@src/firebase";
export interface IUserSettingsChildProps {
settings: Record<string, any>;
updateSettings: (data: Record<string, any>) => void;
}
export default function UserSettingsPage() {
const { currentUser } = useAppContext();
const snack = useSnackContext();
const path = `${USERS}/${currentUser?.uid}`;
const [settingsState] = useDoc({ path }, { createIfMissing: true });
const settings = settingsState.doc;
const [updateSettings, , callPending] = useDebouncedCallback(
(data: Record<string, any>) =>
db
.doc(path)
.update(data)
.then(() =>
snack.open({ message: "Saved", variant: "success", duration: 3000 })
),
1000
);
useEffect(
() => () => {
callPending();
},
[]
);
const childProps: IUserSettingsChildProps = { settings, updateSettings };
const sections = [
{ title: "Account", Component: Account, props: childProps },
{ title: "Theme", Component: Theme },
{ title: "Personalization", Component: Personalization, props: childProps },
];
return (
<Container maxWidth="sm" sx={{ px: 1, pt: 1, pb: 7 + 3 + 3 }}>
<Stack spacing={4}>
<SettingsSection title="Your Account">TODO:</SettingsSection>
</Stack>
{!currentUser || settingsState.loading ? (
<Fade in style={{ transitionDelay: "1s" }} unmountOnExit>
<Stack spacing={4}>
{new Array(sections.length).fill(null).map((_, i) => (
<SettingsSkeleton key={i} />
))}
</Stack>
</Fade>
) : (
<Stack spacing={4}>
{sections.map(({ title, Component, props }, i) => (
<SettingsSection title={title} transitionTimeout={(i + 1) * 100}>
<Component {...(props as any)} />
</SettingsSection>
))}
</Stack>
)}
</Container>
);
}

View File

@@ -14,16 +14,7 @@ declare module "@material-ui/core/styles/createPalette" {
}
export const PRIMARY = "#4200FF";
// export const PRIMARY = "#ED4747";
// export const PRIMARY = "#FA0";
// export const PRIMARY = "#0F0";
// export const PRIMARY = "#F15A29";
// export const PRIMARY = "#c4492c";
// export const PRIMARY = "#0070EB";
// export const PRIMARY = "#015FB8";
// export const PRIMARY = "#E8016D";
export const ERROR = "#B00020"; // https://material.io/design/color/dark-theme.html#ui-application
export const DARK_PRIMARY = "#B0B6FD"; // l: 75, c: 65, h: 275
export const colorsLight = (

View File

@@ -40,6 +40,18 @@ export const components = (theme: Theme): ThemeOptions => {
easing: { strong: transitionEasingStrong },
},
components: {
MuiCssBaseline: {
styleOverrides: {
".chrome-picker": {
colorScheme: "light",
boxShadow: theme.shadows[1] + " !important",
borderRadius: theme.shape.borderRadius + "px !important",
"& input, & label": theme.typography.body2,
},
},
},
MuiContainer: {
defaultProps: { maxWidth: "xl" },
},
@@ -123,7 +135,7 @@ export const components = (theme: Theme): ThemeOptions => {
MuiSnackbarContent: {
styleOverrides: {
root: {
borderRadius: (theme.shape.borderRadius as number) * 2,
borderRadius: (theme.shape.borderRadius as number) * 1.5,
backgroundColor: theme.palette.secondary.main,
},
},
@@ -347,7 +359,7 @@ export const components = (theme: Theme): ThemeOptions => {
paddingRight: theme.spacing(22 / 8),
},
fontSize: "1rem",
borderRadius: (theme.shape.borderRadius as number) * (16 / 14),
borderRadius: (theme.shape.borderRadius as number) * (48 / 32),
"& .MuiButton-iconSizeLarge > *:nth-of-type(1)": { fontSize: 24 },
},
@@ -660,6 +672,7 @@ export const components = (theme: Theme): ThemeOptions => {
},
styleOverrides: {
root: {
display: "flex",
"& .MuiSwitch-root": { marginRight: theme.spacing(1) },
},
labelPlacementStart: {

View File

@@ -10,7 +10,7 @@ export const customizableLightTheme = (customization: ThemeOptions) => {
_merge(
{},
typography((customization?.typography as any) ?? {}),
colorsLight()
colorsLight((customization?.palette?.primary as any)?.main)
)
);
@@ -29,7 +29,7 @@ export const customizableDarkTheme = (customization: ThemeOptions) => {
_merge(
{},
typography((customization?.typography as any) ?? {}),
colorsDark()
colorsDark((customization?.palette?.primary as any)?.main)
)
);