chore: update user setting appearance (#654)

pull/656/head
boojack 2 years ago committed by GitHub
parent 5451fd2d2c
commit 14f9f29348
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -10,6 +10,8 @@ type UserSettingKey string
const ( const (
// UserSettingLocaleKey is the key type for user locale. // UserSettingLocaleKey is the key type for user locale.
UserSettingLocaleKey UserSettingKey = "locale" UserSettingLocaleKey UserSettingKey = "locale"
// UserSettingAppearanceKey is the key type for user appearance.
UserSettingAppearanceKey UserSettingKey = "appearance"
// UserSettingMemoVisibilityKey is the key type for user preference memo default visibility. // UserSettingMemoVisibilityKey is the key type for user preference memo default visibility.
UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility" UserSettingMemoVisibilityKey UserSettingKey = "memoVisibility"
// UserSettingMemoDisplayTsOptionKey is the key type for memo display ts option. // UserSettingMemoDisplayTsOptionKey is the key type for memo display ts option.
@ -21,6 +23,8 @@ func (key UserSettingKey) String() string {
switch key { switch key {
case UserSettingLocaleKey: case UserSettingLocaleKey:
return "locale" return "locale"
case UserSettingAppearanceKey:
return "appearance"
case UserSettingMemoVisibilityKey: case UserSettingMemoVisibilityKey:
return "memoVisibility" return "memoVisibility"
case UserSettingMemoDisplayTsOptionKey: case UserSettingMemoDisplayTsOptionKey:
@ -31,8 +35,8 @@ func (key UserSettingKey) String() string {
var ( var (
UserSettingLocaleValue = []string{"en", "zh", "vi", "fr"} UserSettingLocaleValue = []string{"en", "zh", "vi", "fr"}
UserSettingAppearanceValue = []string{"light", "dark"}
UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public} UserSettingMemoVisibilityValue = []Visibility{Private, Protected, Public}
UserSettingEditorFontStyleValue = []string{"normal", "mono"}
UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"} UserSettingMemoDisplayTsOptionKeyValue = []string{"created_ts", "updated_ts"}
) )
@ -67,6 +71,23 @@ func (upsert UserSettingUpsert) Validate() error {
if invalid { if invalid {
return fmt.Errorf("invalid user setting locale value") return fmt.Errorf("invalid user setting locale value")
} }
} else if upsert.Key == UserSettingAppearanceKey {
appearanceValue := "light"
err := json.Unmarshal([]byte(upsert.Value), &appearanceValue)
if err != nil {
return fmt.Errorf("failed to unmarshal user setting appearance value")
}
invalid := true
for _, value := range UserSettingAppearanceValue {
if appearanceValue == value {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid user setting appearance value")
}
} else if upsert.Key == UserSettingMemoVisibilityKey { } else if upsert.Key == UserSettingMemoVisibilityKey {
memoVisibilityValue := Private memoVisibilityValue := Private
err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue) err := json.Unmarshal([]byte(upsert.Value), &memoVisibilityValue)

@ -6,12 +6,12 @@ import { useAppSelector } from "./store";
import Loading from "./pages/Loading"; import Loading from "./pages/Loading";
import router from "./router"; import router from "./router";
import * as storage from "./helpers/storage"; import * as storage from "./helpers/storage";
import useAppearance from "./hooks/useAppearance"; import { useColorScheme } from "@mui/joy";
function App() { function App() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const { locale, systemStatus } = useAppSelector((state) => state.global); const { appearance, locale, systemStatus } = useAppSelector((state) => state.global);
useAppearance(); const { setMode } = useColorScheme();
useEffect(() => { useEffect(() => {
locationService.updateStateWithLocation(); locationService.updateStateWithLocation();
@ -42,6 +42,19 @@ function App() {
}); });
}, [locale]); }, [locale]);
useEffect(() => {
const root = document.documentElement;
if (appearance === "light") {
root.classList.remove("dark");
} else if (appearance === "dark") {
root.classList.add("dark");
}
setMode(appearance);
storage.set({
appearance: appearance,
});
}, [appearance]);
return ( return (
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<RouterProvider router={router} /> <RouterProvider router={router} />

@ -1,12 +1,13 @@
import { Option, Select } from "@mui/joy"; import { Option, Select } from "@mui/joy";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { globalService } from "../services"; import { globalService, userService } from "../services";
import { useAppSelector } from "../store"; import { useAppSelector } from "../store";
import Icon from "./Icon"; import Icon from "./Icon";
const appearanceList = ["system", "light", "dark"]; const appearanceList = ["light", "dark"];
const AppearanceSelect = () => { const AppearanceSelect = () => {
const user = useAppSelector((state) => state.user.user);
const appearance = useAppSelector((state) => state.global.appearance); const appearance = useAppSelector((state) => state.global.appearance);
const { t } = useTranslation(); const { t } = useTranslation();
@ -16,12 +17,13 @@ const AppearanceSelect = () => {
return <Icon.Sun className={className} />; return <Icon.Sun className={className} />;
} else if (apperance === "dark") { } else if (apperance === "dark") {
return <Icon.Moon className={className} />; return <Icon.Moon className={className} />;
} else {
return <Icon.Smile className={className} />;
} }
}; };
const handleSelectChange = (appearance: Appearance) => { const handleSelectChange = async (appearance: Appearance) => {
if (user) {
await userService.upsertUserSetting("appearance", appearance);
}
globalService.setAppearance(appearance); globalService.setAppearance(appearance);
}; };

@ -43,7 +43,6 @@ const DailyReviewDialog: React.FC<Props> = (props: Props) => {
toggleShowDatePicker(false); toggleShowDatePicker(false);
toImage(memosElRef.current, { toImage(memosElRef.current, {
backgroundColor: "#ffffff",
pixelRatio: window.devicePixelRatio * 2, pixelRatio: window.devicePixelRatio * 2,
}) })
.then((url) => { .then((url) => {

@ -1,9 +1,9 @@
import { Select, Switch, Option } from "@mui/joy";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Switch from "@mui/joy/Switch";
import { globalService, userService } from "../../services"; import { globalService, userService } from "../../services";
import { useAppSelector } from "../../store"; import { useAppSelector } from "../../store";
import { VISIBILITY_SELECTOR_ITEMS, MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS } from "../../helpers/consts"; import { VISIBILITY_SELECTOR_ITEMS, MEMO_DISPLAY_TS_OPTION_SELECTOR_ITEMS } from "../../helpers/consts";
import Selector from "../common/Selector"; import Icon from "../Icon";
import AppearanceSelect from "../AppearanceSelect"; import AppearanceSelect from "../AppearanceSelect";
import "../../less/settings/preferences-section.less"; import "../../less/settings/preferences-section.less";
@ -63,32 +63,65 @@ const PreferencesSection = () => {
return ( return (
<div className="section-container preferences-section-container"> <div className="section-container preferences-section-container">
<p className="title-text">{t("common.basic")}</p> <p className="title-text">{t("common.basic")}</p>
<label className="form-label selector"> <div className="form-label selector">
<span className="normal-text">{t("common.language")}</span> <span className="normal-text">{t("common.language")}</span>
<Selector className="ml-2 w-32" value={setting.locale} dataSource={localeSelectorItems} handleValueChanged={handleLocaleChanged} /> <Select
</label> className="!min-w-[10rem] w-auto text-sm"
<label className="form-label selector"> value={setting.locale}
onChange={(_, locale) => {
if (locale) {
handleLocaleChanged(locale);
}
}}
startDecorator={<Icon.Globe className="w-4 h-auto" />}
>
{localeSelectorItems.map((item) => (
<Option key={item.value} value={item.value} className="whitespace-nowrap">
{item.text}
</Option>
))}
</Select>
</div>
<div className="form-label selector">
<span className="normal-text">Theme</span> <span className="normal-text">Theme</span>
<AppearanceSelect /> <AppearanceSelect />
</label> </div>
<p className="title-text">{t("setting.preference")}</p> <p className="title-text">{t("setting.preference")}</p>
<label className="form-label selector"> <div className="form-label selector">
<span className="normal-text">{t("setting.preference-section.default-memo-visibility")}</span> <span className="normal-text">{t("setting.preference-section.default-memo-visibility")}</span>
<Selector <Select
className="ml-2 w-32" className="!min-w-[10rem] w-auto text-sm"
value={setting.memoVisibility} value={setting.memoVisibility}
dataSource={visibilitySelectorItems} onChange={(_, visibility) => {
handleValueChanged={handleDefaultMemoVisibilityChanged} if (visibility) {
/> handleDefaultMemoVisibilityChanged(visibility);
</label> }
}}
>
{visibilitySelectorItems.map((item) => (
<Option key={item.value} value={item.value} className="whitespace-nowrap">
{item.text}
</Option>
))}
</Select>
</div>
<label className="form-label selector"> <label className="form-label selector">
<span className="normal-text">{t("setting.preference-section.default-memo-sort-option")}</span> <span className="normal-text">{t("setting.preference-section.default-memo-sort-option")}</span>
<Selector <Select
className="ml-2 w-32" className="!min-w-[10rem] w-auto text-sm"
value={setting.memoDisplayTsOption} value={setting.memoDisplayTsOption}
dataSource={memoDisplayTsOptionSelectorItems} onChange={(_, value) => {
handleValueChanged={handleMemoDisplayTsOptionChanged} if (value) {
/> handleMemoDisplayTsOptionChanged(value);
}
}}
>
{memoDisplayTsOptionSelectorItems.map((item) => (
<Option key={item.value} value={item.value} className="whitespace-nowrap">
{item.text}
</Option>
))}
</Select>
</label> </label>
<label className="form-label selector"> <label className="form-label selector">
<span className="normal-text">{t("setting.preference-section.enable-folding-memo")}</span> <span className="normal-text">{t("setting.preference-section.enable-folding-memo")}</span>

@ -14,7 +14,6 @@ import toastHelper from "./Toast";
import MemoContent from "./MemoContent"; import MemoContent from "./MemoContent";
import MemoResources from "./MemoResources"; import MemoResources from "./MemoResources";
import Selector from "./common/Selector"; import Selector from "./common/Selector";
import useAppearance from "../hooks/useAppearance";
import "../less/share-memo-image-dialog.less"; import "../less/share-memo-image-dialog.less";
interface Props extends DialogProps { interface Props extends DialogProps {
@ -36,7 +35,6 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
shortcutImgUrl: "", shortcutImgUrl: "",
memoVisibility: propsMemo.visibility, memoVisibility: propsMemo.visibility,
}); });
const [appearance] = useAppearance();
const loadingState = useLoading(); const loadingState = useLoading();
const memoElRef = useRef<HTMLDivElement>(null); const memoElRef = useRef<HTMLDivElement>(null);
const memo = { const memo = {
@ -72,7 +70,6 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
} }
toImage(memoElRef.current, { toImage(memoElRef.current, {
backgroundColor: appearance === "light" ? "#f4f4f5" : "#27272a",
pixelRatio: window.devicePixelRatio * 2, pixelRatio: window.devicePixelRatio * 2,
}) })
.then((url) => { .then((url) => {
@ -147,7 +144,7 @@ const ShareMemoImageDialog: React.FC<Props> = (props: Props) => {
<div className="userinfo-container"> <div className="userinfo-container">
<span className="name-text">{user.nickname || user.username}</span> <span className="name-text">{user.nickname || user.username}</span>
<span className="usage-text"> <span className="usage-text">
{createdDays} DAYS / {state.memoAmount} MEMOS {state.memoAmount} MEMOS / {createdDays} DAYS
</span> </span>
</div> </div>
<img className="logo-img" src="/logo.webp" alt="" /> <img className="logo-img" src="/logo.webp" alt="" />

@ -10,6 +10,8 @@ interface StorageData {
editingMemoVisibilityCache: Visibility; editingMemoVisibilityCache: Visibility;
// locale // locale
locale: Locale; locale: Locale;
// appearance
appearance: Appearance;
// local setting // local setting
localSetting: LocalSetting; localSetting: LocalSetting;
// skipped version // skipped version

@ -140,3 +140,11 @@ export function absolutifyLink(rel: string): string {
anchor.setAttribute("href", rel); anchor.setAttribute("href", rel);
return anchor.href; return anchor.href;
} }
export function getSystemColorScheme() {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
} else {
return "light";
}
}

@ -1,65 +0,0 @@
import { useEffect } from "react";
import { useColorScheme } from "@mui/joy/styles";
import { useAppSelector } from "../store";
import { globalService } from "../services";
const getSystemColorScheme = () => {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
} else {
return "light";
}
};
const useAppearance = () => {
const user = useAppSelector((state) => state.user.user);
const appearance = useAppSelector((state) => state.global.appearance);
const { mode, setMode } = useColorScheme();
useEffect(() => {
if (user) {
globalService.setAppearance(user.setting.appearance);
}
}, [user]);
useEffect(() => {
let mode = appearance;
if (appearance === "system") {
mode = getSystemColorScheme();
}
setMode(mode);
}, [appearance]);
useEffect(() => {
const colorSchemeChangeHandler = (event: MediaQueryListEvent) => {
const newColorScheme = event.matches ? "dark" : "light";
if (globalService.getState().appearance === "system") {
setMode(newColorScheme);
}
};
if (appearance !== "system") {
window.matchMedia("(prefers-color-scheme: dark)").removeEventListener("change", colorSchemeChangeHandler);
return;
}
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", colorSchemeChangeHandler);
return () => {
window.matchMedia("(prefers-color-scheme: dark)").removeEventListener("change", colorSchemeChangeHandler);
};
}, [appearance]);
useEffect(() => {
const root = document.documentElement;
if (mode === "dark") {
root.classList.add("dark");
} else if (mode === "light") {
root.classList.remove("dark");
}
}, [mode]);
return [appearance, globalService.setAppearance] as const;
};
export default useAppearance;

@ -1,21 +0,0 @@
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => {
setMatches(media.matches);
};
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [query, matches]);
return matches;
};
export default useMediaQuery;

@ -1,13 +0,0 @@
// A custom hook that builds on useLocation to parse
import React from "react";
import { useLocation } from "react-router-dom";
// the query string for you.
const useQuery = () => {
const { search } = useLocation();
return React.useMemo(() => new URLSearchParams(search), [search]);
};
export default useQuery;

@ -2,7 +2,7 @@
@apply p-0 sm:py-16; @apply p-0 sm:py-16;
> .dialog-container { > .dialog-container {
@apply w-full sm:w-112 max-w-full grow sm:grow-0 p-0 rounded-none sm:rounded-lg; @apply w-full sm:w-112 max-w-full grow sm:grow-0 p-0 pb-4 rounded-none sm:rounded-lg;
> .dialog-header-container { > .dialog-header-container {
@apply relative flex flex-row justify-between items-center w-full p-6 pb-0 mb-0; @apply relative flex flex-row justify-between items-center w-full p-6 pb-0 mb-0;
@ -33,7 +33,7 @@
} }
> .dialog-content-container { > .dialog-content-container {
@apply w-full h-auto flex flex-col justify-start items-start p-6 pb-0; @apply w-full h-auto flex flex-col justify-start items-start p-6 pb-0 bg-white dark:bg-zinc-800;
> .date-card-container { > .date-card-container {
@apply flex flex-col justify-center items-center m-auto pb-6 select-none; @apply flex flex-col justify-center items-center m-auto pb-6 select-none;

@ -1,6 +1,6 @@
.share-memo-image-dialog { .share-memo-image-dialog {
> .dialog-container { > .dialog-container {
@apply w-96 p-0 bg-zinc-100; @apply w-96 p-0 bg-white dark:bg-zinc-800;
> .dialog-header-container { > .dialog-header-container {
@apply py-2 pt-4 px-4 pl-6 mb-0 rounded-t-lg; @apply py-2 pt-4 px-4 pl-6 mb-0 rounded-t-lg;
@ -35,7 +35,7 @@
} }
> .memo-container { > .memo-container {
@apply w-96 max-w-full h-auto select-none relative flex flex-col justify-start items-start; @apply w-96 max-w-full h-auto select-none relative flex flex-col justify-start items-start bg-white dark:bg-zinc-800;
> .memo-shortcut-img { > .memo-shortcut-img {
@apply absolute top-0 left-0 w-full h-auto z-10; @apply absolute top-0 left-0 w-full h-auto z-10;
@ -50,7 +50,7 @@
} }
> .images-container { > .images-container {
@apply w-full h-auto flex flex-col justify-start items-start px-6 pb-2 bg-white; @apply w-full h-auto flex flex-col justify-start items-start px-6 pb-2;
> img { > img {
@apply w-full h-auto mb-2 rounded; @apply w-full h-auto mb-2 rounded;
@ -58,30 +58,22 @@
} }
> .watermark-container { > .watermark-container {
@apply flex flex-row justify-between items-center w-full dark:bg-zinc-900 py-2 px-6; @apply flex flex-row justify-between items-center w-full bg-gray-100 dark:bg-zinc-700 py-2 px-6;
> .normal-text {
@apply w-full flex flex-row justify-start items-center text-sm leading-6 text-gray-500;
> .name-text {
@apply text-black;
}
}
> .userinfo-container { > .userinfo-container {
@apply w-64 flex flex-col justify-center items-start; @apply w-64 flex flex-col justify-center items-start;
> .name-text { > .name-text {
@apply text-lg truncate font-medium text-gray-600; @apply text-sm truncate font-bold text-gray-600 dark:text-gray-300;
} }
> .usage-text { > .usage-text {
@apply -mt-1 text-sm text-gray-400; @apply text-xs text-gray-400;
} }
} }
> .logo-img { > .logo-img {
@apply h-12 w-auto; @apply h-10 w-auto;
} }
} }
} }

@ -11,7 +11,7 @@ const globalService = {
initialState: async () => { initialState: async () => {
const defaultGlobalState = { const defaultGlobalState = {
locale: "en" as Locale, locale: "en" as Locale,
appearance: "system" as Appearance, appearance: "light" as Appearance,
systemStatus: { systemStatus: {
allowSignUp: false, allowSignUp: false,
additionalStyle: "", additionalStyle: "",
@ -19,10 +19,13 @@ const globalService = {
} as SystemStatus, } as SystemStatus,
}; };
const { locale: storageLocale } = storage.get(["locale"]); const { locale: storageLocale, appearance: storageAppearance } = storage.get(["locale", "appearance"]);
if (storageLocale) { if (storageLocale) {
defaultGlobalState.locale = storageLocale; defaultGlobalState.locale = storageLocale;
} }
if (storageAppearance) {
defaultGlobalState.appearance = storageAppearance;
}
try { try {
const { data } = (await api.getSystemStatus()).data; const { data } = (await api.getSystemStatus()).data;

@ -3,12 +3,12 @@ import * as api from "../helpers/api";
import * as storage from "../helpers/storage"; import * as storage from "../helpers/storage";
import { UNKNOWN_ID } from "../helpers/consts"; import { UNKNOWN_ID } from "../helpers/consts";
import store from "../store"; import store from "../store";
import { setLocale } from "../store/modules/global";
import { setUser, patchUser, setHost, setOwner } from "../store/modules/user"; import { setUser, patchUser, setHost, setOwner } from "../store/modules/user";
import { getSystemColorScheme } from "../helpers/utils";
const defaultSetting: Setting = { const defaultSetting: Setting = {
locale: "en", locale: "en",
appearance: "system", appearance: getSystemColorScheme(),
memoVisibility: "PRIVATE", memoVisibility: "PRIVATE",
memoDisplayTsOption: "created_ts", memoDisplayTsOption: "created_ts",
}; };
@ -61,11 +61,15 @@ const userService = {
} }
} }
const { data: user } = (await api.getMyselfUser()).data; const { data } = (await api.getMyselfUser()).data;
if (user) { if (data) {
store.dispatch(setUser(convertResponseModelUser(user))); const user = convertResponseModelUser(data);
store.dispatch(setUser(user));
if (user.setting.locale) { if (user.setting.locale) {
store.dispatch(setLocale(user.setting.locale)); globalService.setLocale(user.setting.locale);
}
if (user.setting.appearance) {
globalService.setAppearance(user.setting.appearance);
} }
} }
}, },

@ -10,7 +10,7 @@ const globalSlice = createSlice({
name: "global", name: "global",
initialState: { initialState: {
locale: "en", locale: "en",
appearance: "system", appearance: "light",
systemStatus: { systemStatus: {
host: undefined, host: undefined,
profile: { profile: {

@ -1,4 +1,4 @@
type Appearance = "light" | "dark" | "system"; type Appearance = "light" | "dark";
interface Setting { interface Setting {
locale: Locale; locale: Locale;

Loading…
Cancel
Save