mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
add theme color customization
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
55
src/components/Settings/ProjectSettings/Customization.tsx
Normal file
55
src/components/Settings/ProjectSettings/Customization.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
223
src/components/Settings/ThemeColorPicker.tsx
Normal file
223
src/components/Settings/ThemeColorPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
35
src/components/Settings/UserSettings/Account.tsx
Normal file
35
src/components/Settings/UserSettings/Account.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/Settings/UserSettings/Personalization.tsx
Normal file
55
src/components/Settings/UserSettings/Personalization.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
src/components/Settings/UserSettings/Theme.tsx
Normal file
39
src/components/Settings/UserSettings/Theme.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 doesn’t 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 doesn’t 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user