feat: add max upload size setting to UI & UI improvements (#1646)

* Add preliminar Windows support for both
development and production environments.

Default profile.Data will be set to "C:\ProgramData\memos" on Windows.
Folder will be created if it does not exist, as this behavior is
expected for Windows applications.

System service installation can be achieved with third-party tools,
explained in docs/windows-service.md.

Not sure if it's worth using https://github.com/kardianos/service
to make service support built-in.

This could be a nice addition alongside #1583 (add Windows artifacts)

* feat: improve Windows support

- Fix local file storage path handling on Windows

- Improve Windows dev script

* feat: add max upload size setting to UI & more

- feat: add max upload size setting to UI

- feat: max upload size setting is checked on UI during upload,
but also enforced by the server

- fix: overflowing mobile layout for Create SSO, Create Storage
and other Settings dialogs

- feat: add HelpButton component with some links to docs were appropriate

- remove LearnMore component in favor of HelpButton

- refactor: change some if/else to switch statements

- refactor: inline some err == nil checks

! Existing databases without the new setting 'max-upload-size-mib'
will show an upload error, but this can be user-fixed by simply
setting the value on system settings UI.

* improvements requested by @boojack
pull/1656/head
Lincoln Nogueira 2 years ago committed by GitHub
parent 5c5199920e
commit 96021e518a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -14,6 +14,8 @@ type SystemStatus struct {
IgnoreUpgrade bool `json:"ignoreUpgrade"`
// Disable public memos.
DisablePublicMemos bool `json:"disablePublicMemos"`
// Max upload size.
MaxUploadSizeMiB int `json:"maxUploadSizeMiB"`
// Additional style.
AdditionalStyle string `json:"additionalStyle"`
// Additional script.

@ -2,7 +2,6 @@ package api
import (
"encoding/json"
"errors"
"fmt"
"golang.org/x/exp/slices"
@ -21,6 +20,8 @@ const (
SystemSettingIgnoreUpgradeName SystemSettingName = "ignore-upgrade"
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
// SystemSettingMaxUploadSizeMiBName is the name of max upload size setting.
SystemSettingMaxUploadSizeMiBName SystemSettingName = "max-upload-size-mib"
// SystemSettingAdditionalStyleName is the name of additional style.
SystemSettingAdditionalStyleName SystemSettingName = "additional-style"
// SystemSettingAdditionalScriptName is the name of additional script.
@ -68,6 +69,8 @@ func (key SystemSettingName) String() string {
return "ignore-upgrade"
case SystemSettingDisablePublicMemosName:
return "disable-public-memos"
case SystemSettingMaxUploadSizeMiBName:
return "max-upload-size-mib"
case SystemSettingAdditionalStyleName:
return "additional-style"
case SystemSettingAdditionalScriptName:
@ -97,40 +100,50 @@ type SystemSettingUpsert struct {
Description string `json:"description"`
}
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
func (upsert SystemSettingUpsert) Validate() error {
if upsert.Name == SystemSettingServerIDName {
return errors.New("update server id is not allowed")
} else if upsert.Name == SystemSettingAllowSignUpName {
value := false
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting allow signup value")
switch settingName := upsert.Name; settingName {
case SystemSettingServerIDName:
return fmt.Errorf("updating %v is not allowed", settingName)
case SystemSettingAllowSignUpName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
} else if upsert.Name == SystemSettingIgnoreUpgradeName {
value := false
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting ignore upgrade value")
case SystemSettingIgnoreUpgradeName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
} else if upsert.Name == SystemSettingDisablePublicMemosName {
value := false
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting disable public memos value")
case SystemSettingDisablePublicMemosName:
var value bool
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
} else if upsert.Name == SystemSettingAdditionalStyleName {
value := ""
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting additional style value")
case SystemSettingMaxUploadSizeMiBName:
var value int
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
} else if upsert.Name == SystemSettingAdditionalScriptName {
value := ""
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting additional script value")
case SystemSettingAdditionalStyleName:
var value string
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
} else if upsert.Name == SystemSettingCustomizedProfileName {
case SystemSettingAdditionalScriptName:
var value string
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
case SystemSettingCustomizedProfileName:
customizedProfile := CustomizedProfile{
Name: "memos",
LogoURL: "",
@ -139,36 +152,37 @@ func (upsert SystemSettingUpsert) Validate() error {
Appearance: "system",
ExternalURL: "",
}
err := json.Unmarshal([]byte(upsert.Value), &customizedProfile)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting customized profile value")
if err := json.Unmarshal([]byte(upsert.Value), &customizedProfile); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
if !slices.Contains(UserSettingLocaleValue, customizedProfile.Locale) {
return fmt.Errorf("invalid locale value")
return fmt.Errorf(`invalid locale value for system setting "%v"`, settingName)
}
if !slices.Contains(UserSettingAppearanceValue, customizedProfile.Appearance) {
return fmt.Errorf("invalid appearance value")
return fmt.Errorf(`invalid appearance value for system setting "%v"`, settingName)
}
} else if upsert.Name == SystemSettingStorageServiceIDName {
case SystemSettingStorageServiceIDName:
value := DatabaseStorage
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting storage service id value")
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
return nil
} else if upsert.Name == SystemSettingLocalStoragePathName {
case SystemSettingLocalStoragePathName:
value := ""
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting local storage path value")
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
} else if upsert.Name == SystemSettingOpenAIConfigName {
case SystemSettingOpenAIConfigName:
value := OpenAIConfig{}
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting openai api config value")
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
return fmt.Errorf(systemSettingUnmarshalError, settingName)
}
} else {
default:
return fmt.Errorf("invalid system setting name")
}

@ -25,8 +25,11 @@ import (
)
const (
// The max file size is 32MB.
maxFileSize = 32 << 20
// The upload memory buffer is 32 MiB.
// It should be kept low, so RAM usage doesn't get out of control.
// This is unrelated to maximum upload size limit, which is now set through system setting.
maxUploadBufferSizeBytes = 32 << 20
MebiByte = 1024 * 1024
)
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
@ -67,8 +70,13 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
if err := c.Request().ParseMultipartForm(maxFileSize); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file overload max size").SetInternal(err)
maxUploadSetting := s.Store.GetSystemSettingValueOrDefault(&ctx, api.SystemSettingMaxUploadSizeMiBName, "0")
var settingMaxUploadSizeBytes int
if settingMaxUploadSizeMiB, err := strconv.Atoi(maxUploadSetting); err == nil {
settingMaxUploadSizeBytes = settingMaxUploadSizeMiB * MebiByte
} else {
log.Warn("Failed to parse max upload size", zap.Error(err))
settingMaxUploadSizeBytes = 0
}
file, err := c.FormFile("file")
@ -79,6 +87,14 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusBadRequest, "Upload file not found").SetInternal(err)
}
if file.Size > int64(settingMaxUploadSizeBytes) {
message := fmt.Sprintf("File size exceeds allowed limit of %d MiB", settingMaxUploadSizeBytes/MebiByte)
return echo.NewHTTPError(http.StatusBadRequest, message).SetInternal(err)
}
if err := c.Request().ParseMultipartForm(maxUploadBufferSizeBytes); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse upload data").SetInternal(err)
}
filetype := file.Header.Get("Content-Type")
size := file.Size
sourceFile, err := file.Open()

@ -44,6 +44,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
AllowSignUp: false,
IgnoreUpgrade: false,
DisablePublicMemos: false,
MaxUploadSizeMiB: 32,
AdditionalStyle: "",
AdditionalScript: "",
CustomizedProfile: api.CustomizedProfile{
@ -74,27 +75,40 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
continue
}
if systemSetting.Name == api.SystemSettingAllowSignUpName {
switch systemSetting.Name {
case api.SystemSettingAllowSignUpName:
systemStatus.AllowSignUp = baseValue.(bool)
} else if systemSetting.Name == api.SystemSettingIgnoreUpgradeName {
case api.SystemSettingIgnoreUpgradeName:
systemStatus.IgnoreUpgrade = baseValue.(bool)
} else if systemSetting.Name == api.SystemSettingDisablePublicMemosName {
case api.SystemSettingDisablePublicMemosName:
systemStatus.DisablePublicMemos = baseValue.(bool)
} else if systemSetting.Name == api.SystemSettingAdditionalStyleName {
case api.SystemSettingMaxUploadSizeMiBName:
systemStatus.MaxUploadSizeMiB = int(baseValue.(float64))
case api.SystemSettingAdditionalStyleName:
systemStatus.AdditionalStyle = baseValue.(string)
} else if systemSetting.Name == api.SystemSettingAdditionalScriptName {
case api.SystemSettingAdditionalScriptName:
systemStatus.AdditionalScript = baseValue.(string)
} else if systemSetting.Name == api.SystemSettingCustomizedProfileName {
case api.SystemSettingCustomizedProfileName:
customizedProfile := api.CustomizedProfile{}
err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile)
if err != nil {
if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
}
systemStatus.CustomizedProfile = customizedProfile
} else if systemSetting.Name == api.SystemSettingStorageServiceIDName {
case api.SystemSettingStorageServiceIDName:
systemStatus.StorageServiceID = int(baseValue.(float64))
} else if systemSetting.Name == api.SystemSettingLocalStoragePathName {
case api.SystemSettingLocalStoragePathName:
systemStatus.LocalStoragePath = baseValue.(string)
default:
log.Warn("Unknown system setting name", zap.String("setting name", systemSetting.Name.String()))
}
}

@ -94,6 +94,15 @@ func (s *Store) FindSystemSetting(ctx context.Context, find *api.SystemSettingFi
return systemSettingRaw.toSystemSetting(), nil
}
func (s *Store) GetSystemSettingValueOrDefault(ctx *context.Context, find api.SystemSettingName, defaultValue string) string {
if setting, err := s.FindSystemSetting(*ctx, &api.SystemSettingFind{
Name: find,
}); err == nil {
return setting.Value
}
return defaultValue
}
func upsertSystemSetting(ctx context.Context, tx *sql.Tx, upsert *api.SystemSettingUpsert) (*systemSettingRaw, error) {
query := `
INSERT INTO system_setting (
@ -127,7 +136,7 @@ func findSystemSettingList(ctx context.Context, tx *sql.Tx, find *api.SystemSett
query := `
SELECT
name,
value,
value,
description
FROM system_setting
WHERE ` + strings.Join(where, " AND ")

@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button, Divider, Input, Radio, RadioGroup, Typography } from "@mui/joy";
import { Button, Divider, Input, Option, Select, Typography } from "@mui/joy";
import * as api from "@/helpers/api";
import { UNKNOWN_ID } from "@/helpers/consts";
import { absolutifyLink } from "@/helpers/utils";
@ -101,6 +101,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
},
},
];
const identityProviderTypes = [...new Set(templateList.map((t) => t.type))];
const { confirmCallback, destroy, identityProvider } = props;
const [basicInfo, setBasicInfo] = useState({
name: "",
@ -121,7 +122,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
},
});
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
const [seletedTemplate, setSelectedTemplate] = useState<string>("GitHub");
const [selectedTemplate, setSelectedTemplate] = useState<string>("GitHub");
const isCreating = identityProvider === undefined;
useEffect(() => {
@ -143,7 +144,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
return;
}
const template = templateList.find((t) => t.name === seletedTemplate);
const template = templateList.find((t) => t.name === selectedTemplate);
if (template) {
setBasicInfo({
name: template.name,
@ -155,7 +156,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
setOAuth2Scopes(template.config.oauth2Config.scopes.join(" "));
}
}
}, [seletedTemplate]);
}, [selectedTemplate]);
const handleCloseBtnClick = () => {
destroy();
@ -229,37 +230,34 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">{t("setting.sso-section." + (isCreating ? "create" : "update") + "-sso")}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<p className="title-text ml-auto">{t("setting.sso-section." + (isCreating ? "create" : "update") + "-sso")}</p>
<button className="btn close-btn ml-auto" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container w-full max-w-[24rem] min-w-[25rem]">
<div className="dialog-content-container min-w-[19rem]">
{isCreating && (
<>
<Typography className="!mb-1" level="body2">
{t("common.type")}
</Typography>
<RadioGroup className="mb-2" value={type}>
<div className="mt-2 w-full flex flex-row space-x-4">
<Radio value="OAUTH2" label="OAuth 2.0" />
</div>
</RadioGroup>
<Select className="w-full mb-4" value={type} onChange={(_, e) => setType(e ?? type)}>
{identityProviderTypes.map((kind) => (
<Option key={kind} value={kind}>
{kind}
</Option>
))}
</Select>
<Typography className="mb-2" level="body2">
{t("setting.sso-section.template")}
</Typography>
<RadioGroup className="mb-2" value={seletedTemplate}>
<div className="mt-2 w-full flex flex-row space-x-4">
{templateList.map((template) => (
<Radio
key={template.name}
value={template.name}
label={template.name}
onChange={(e) => setSelectedTemplate(e.target.value)}
/>
))}
</div>
</RadioGroup>
<Select className="mb-1 h-auto w-full" value={selectedTemplate} onChange={(_, e) => setSelectedTemplate(e ?? selectedTemplate)}>
{templateList.map((template) => (
<Option key={template.name} value={template.name}>
{template.name}
</Option>
))}
</Select>
<Divider className="!my-2" />
</>
)}

@ -6,7 +6,7 @@ import * as api from "@/helpers/api";
import { generateDialog } from "./Dialog";
import Icon from "./Icon";
import RequiredBadge from "./RequiredBadge";
import LearnMore from "./LearnMore";
import HelpButton from "./kit/HelpButton";
interface Props extends DialogProps {
storage?: ObjectStorage;
@ -106,15 +106,12 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
return (
<>
<div className="dialog-header-container">
<p className="title-text">
{t("setting.storage-section." + (isCreating ? "create" : "update") + "-storage")}
<LearnMore className="ml-2" url="https://usememos.com/docs/storage" />
</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<span className="title-text ml-auto">{t("setting.storage-section." + (isCreating ? "create" : "update") + "-storage")}</span>
<button className="btn close-btn ml-auto" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container">
<div className="dialog-content-container min-w-[19rem]">
<Typography className="!mb-1" level="body2">
{t("common.name")}
<RequiredBadge />
@ -186,13 +183,12 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
onChange={(e) => setPartialS3Config({ bucket: e.target.value })}
fullWidth
/>
<Typography className="!mb-1" level="body2">
{t("setting.storage-section.path")}
</Typography>
<Typography className="!mb-1" level="body2">
<p className="text-sm text-gray-400 ml-1">{t("setting.storage-section.path-description")}</p>
<LearnMore className="ml-2" url="https://usememos.com/docs/local-storage" />
</Typography>
<div className="flex flex-row">
<Typography className="!mb-1" level="body2">
{t("setting.storage-section.path")}
</Typography>
<HelpButton text={t("setting.storage-section.path-description")} url="https://usememos.com/docs/local-storage" />
</div>
<Input
className="mb-2"
placeholder={t("setting.storage-section.path-placeholder") + "/{year}/{month}/{filename}"}

@ -1,21 +0,0 @@
import { useTranslation } from "react-i18next";
import Icon from "./Icon";
interface Props {
url: string;
className?: string;
}
const LearnMore = (props: Props) => {
const { url, className } = props;
const { t } = useTranslation();
return (
<a className={`${className || ""} text-sm text-blue-600 hover:opacity-80 hover:underline`} href={url} target="_blank">
{t("common.learn-more")}
<Icon.ExternalLink className="inline -mt-1 ml-1 w-4 h-auto opacity-80" />
</a>
);
};
export default LearnMore;

@ -1,4 +1,4 @@
import { Select, Switch, Option } from "@mui/joy";
import { Switch, Option, Select } from "@mui/joy";
import React from "react";
import { useTranslation } from "react-i18next";
import { useGlobalStore, useUserStore } from "@/store/module";
@ -53,18 +53,18 @@ const PreferencesSection = () => {
<div className="section-container preferences-section-container">
<p className="title-text">{t("common.basic")}</p>
<div className="form-label selector">
<span className="normal-text">{t("common.language")}</span>
<span className="text-sm">{t("common.language")}</span>
<LocaleSelect value={locale} onChange={handleLocaleSelectChange} />
</div>
<div className="form-label selector">
<span className="normal-text">{t("setting.preference-section.theme")}</span>
<span className="text-sm">{t("setting.preference-section.theme")}</span>
<AppearanceSelect value={appearance} onChange={handleAppearanceSelectChange} />
</div>
<p className="title-text">{t("setting.preference")}</p>
<div className="form-label selector">
<span className="normal-text">{t("setting.preference-section.default-memo-visibility")}</span>
<span className="text-sm break-keep text-ellipsis overflow-hidden">{t("setting.preference-section.default-memo-visibility")}</span>
<Select
className="!min-w-[10rem] w-auto text-sm"
className="!min-w-fit"
value={setting.memoVisibility}
onChange={(_, visibility) => {
if (visibility) {
@ -73,18 +73,18 @@ const PreferencesSection = () => {
}}
>
{visibilitySelectorItems.map((item) => (
<Option key={item.value} value={item.value} className="whitespace-nowrap">
<Option key={item.value} value={item.value}>
{item.text}
</Option>
))}
</Select>
</div>
<div className="form-label selector">
<span className="normal-text">{t("setting.preference-section.daily-review-time-offset")}</span>
<span className="text-sm break-keep text-ellipsis overflow-hidden">{t("setting.preference-section.daily-review-time-offset")}</span>
<span className="w-auto inline-flex">
<Select
placeholder="hh"
className="!min-w-[4rem] w-auto text-sm"
className="!min-w-fit"
value={localSetting.dailyReviewTimeOffset}
onChange={(_, value) => {
if (value !== null) {
@ -110,7 +110,7 @@ const PreferencesSection = () => {
</div>
<label className="form-label selector">
<span className="normal-text">{t("setting.preference-section.enable-double-click")}</span>
<span className="text-sm break-keep">{t("setting.preference-section.enable-double-click")}</span>
<Switch className="ml-2" checked={localSetting.enableDoubleClickEditing} onChange={handleDoubleClickEnabledChanged} />
</label>

@ -2,9 +2,11 @@ import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import * as api from "@/helpers/api";
import { Divider } from "@mui/joy";
import showCreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
import Dropdown from "../kit/Dropdown";
import { showCommonDialog } from "../Dialog/CommonDialog";
import HelpButton from "../kit/HelpButton";
const SSOSection = () => {
const { t } = useTranslation();
@ -41,8 +43,9 @@ const SSOSection = () => {
return (
<div className="section-container">
<div className="mt-4 mb-2 w-full flex flex-row justify-start items-center">
<div className="mb-2 w-full flex flex-row justify-start items-center">
<span className="font-mono text-sm text-gray-400 mr-2">{t("setting.sso-section.sso-list")}</span>
<HelpButton icon="help" url="https://usememos.com/docs/keycloak" />
<button
className="btn-normal px-2 py-0 leading-7"
onClick={() => showCreateIdentityProviderDialog(undefined, fetchIdentityProviderList)}
@ -50,39 +53,43 @@ const SSOSection = () => {
{t("common.create")}
</button>
</div>
<div className="mt-2 w-full flex flex-col">
{identityProviderList.map((identityProvider) => (
<div key={identityProvider.id} className="py-2 w-full border-t last:border-b flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<p className="ml-2">
{identityProvider.name}
<span className="text-sm ml-1 opacity-40">({identityProvider.type})</span>
</p>
</div>
<div className="flex flex-row items-center">
<Dropdown
actionsClassName="!w-28"
actions={
<>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => showCreateIdentityProviderDialog(identityProvider, fetchIdentityProviderList)}
>
{t("common.edit")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleDeleteIdentityProvider(identityProvider)}
>
{t("common.delete")}
</button>
</>
}
/>
</div>
<Divider />
{identityProviderList.map((identityProvider) => (
<div
key={identityProvider.id}
className="py-2 w-full border-t last:border-b dark:border-zinc-700 flex flex-row items-center justify-between"
>
<div className="flex flex-row items-center">
<p className="ml-2">
{identityProvider.name}
<span className="text-sm ml-1 opacity-40">({identityProvider.type})</span>
</p>
</div>
))}
</div>
<div className="flex flex-row items-center">
<Dropdown
actionsClassName="!w-28"
actions={
<>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => showCreateIdentityProviderDialog(identityProvider, fetchIdentityProviderList)}
>
{t("common.edit")}
</button>
<button
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
onClick={() => handleDeleteIdentityProvider(identityProvider)}
>
{t("common.delete")}
</button>
</>
}
/>
</div>
</div>
))}
</div>
);
};

@ -8,6 +8,7 @@ import showCreateStorageServiceDialog from "../CreateStorageServiceDialog";
import showUpdateLocalStorageDialog from "../UpdateLocalStorageDialog";
import Dropdown from "../kit/Dropdown";
import { showCommonDialog } from "../Dialog/CommonDialog";
import HelpButton from "../kit/HelpButton";
const StorageSection = () => {
const { t } = useTranslation();
@ -77,12 +78,17 @@ const StorageSection = () => {
<Divider />
<div className="mt-4 mb-2 w-full flex flex-row justify-start items-center">
<span className="font-mono text-sm text-gray-400 mr-2">{t("setting.storage-section.storage-services-list")}</span>
<HelpButton className="btn" icon="info" url="https://usememos.com/docs/storage" />
<button className="btn-normal px-2 py-0 leading-7" onClick={() => showCreateStorageServiceDialog(undefined, fetchStorageList)}>
{t("common.create")}
</button>
</div>
<div className="mt-2 w-full flex flex-col">
<div className="py-2 w-full border-t dark:border-zinc-700 flex flex-row items-center justify-between">
<div
className={
storageServiceId !== -1 ? "hidden" : "py-2 w-full border-t dark:border-zinc-700 flex flex-row items-center justify-between"
}
>
<div className="flex flex-row items-center">
<p className="ml-2">{t("setting.storage-section.type-local")}</p>
</div>

@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Button, Divider, Input, Switch, Textarea, Typography } from "@mui/joy";
import { Button, Divider, Input, Switch, Textarea } from "@mui/joy";
import { formatBytes } from "@/helpers/utils";
import { useGlobalStore } from "@/store/module";
import * as api from "@/helpers/api";
import Icon from "../Icon";
import HelpButton from "../kit/HelpButton";
import showUpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
import "@/less/settings/system-section.less";
@ -16,6 +16,7 @@ interface State {
disablePublicMemos: boolean;
additionalStyle: string;
additionalScript: string;
maxUploadSizeMiB: number;
}
const SystemSection = () => {
@ -29,6 +30,7 @@ const SystemSection = () => {
additionalStyle: systemStatus.additionalStyle,
additionalScript: systemStatus.additionalScript,
disablePublicMemos: systemStatus.disablePublicMemos,
maxUploadSizeMiB: systemStatus.maxUploadSizeMiB,
});
const [openAIConfig, setOpenAIConfig] = useState<OpenAIConfig>({
key: "",
@ -56,6 +58,7 @@ const SystemSection = () => {
additionalStyle: systemStatus.additionalStyle,
additionalScript: systemStatus.additionalScript,
disablePublicMemos: systemStatus.disablePublicMemos,
maxUploadSizeMiB: systemStatus.maxUploadSizeMiB,
});
}, [systemStatus]);
@ -175,6 +178,30 @@ const SystemSection = () => {
});
};
const handleMaxUploadSizeChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
// fixes cursor skipping position on mobile
event.target.selectionEnd = event.target.value.length;
let num = parseInt(event.target.value);
if (Number.isNaN(num)) {
num = 0;
}
setState({
...state,
maxUploadSizeMiB: num,
});
event.target.value = num.toString();
globalStore.setSystemStatus({ maxUploadSizeMiB: num });
await api.upsertSystemSetting({
name: "max-upload-size-mib",
value: JSON.stringify(num),
});
};
const handleMaxUploadSizeFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
};
return (
<div className="section-container system-section-container">
<p className="title-text">{t("common.basic")}</p>
@ -185,7 +212,7 @@ const SystemSection = () => {
<Button onClick={handleUpdateCustomizedProfileButtonClick}>{t("common.edit")}</Button>
</div>
<div className="form-label">
<span className="normal-text">
<span className="text-sm">
{t("setting.system-section.database-file-size")}: <span className="font-mono font-bold">{formatBytes(state.dbSize)}</span>
</span>
<Button onClick={handleVacuumBtnClick}>{t("common.vacuum")}</Button>
@ -203,19 +230,27 @@ const SystemSection = () => {
<span className="normal-text">{t("setting.system-section.disable-public-memos")}</span>
<Switch checked={state.disablePublicMemos} onChange={(event) => handleDisablePublicMemosChanged(event.target.checked)} />
</div>
<div className="form-label">
<div className="flex flex-row">
<span className="normal-text">{t("setting.system-section.max-upload-size")}</span>
<HelpButton icon="info" hint={t("setting.system-section.max-upload-size-hint")} hintPlacement="left" />
</div>
<Input
className="w-16"
sx={{
fontFamily: "monospace",
}}
defaultValue={state.maxUploadSizeMiB}
onFocus={handleMaxUploadSizeFocus}
onChange={handleMaxUploadSizeChanged}
/>
</div>
<Divider className="!mt-3 !my-4" />
<div className="form-label">
<span className="normal-text">{t("setting.system-section.openai-api-key")}</span>
<Typography className="!mb-1" level="body2">
<a
className="ml-2 text-sm text-blue-600 hover:opacity-80 hover:underline"
href="https://platform.openai.com/account/api-keys"
target="_blank"
>
{t("setting.system-section.openai-api-key-description")}
<Icon.ExternalLink className="inline -mt-1 ml-1 w-4 h-auto opacity-80" />
</a>
</Typography>
<div className="flex flex-row">
<span className="normal-text">{t("setting.system-section.openai-api-key")}</span>
<HelpButton hint={t("setting.system-section.openai-api-key-description")} url="https://platform.openai.com/account/api-keys" />
</div>
<Button onClick={handleSaveOpenAIConfig}>{t("common.save")}</Button>
</div>
<Input

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "react-hot-toast";
import { useGlobalStore } from "@/store/module";
import * as api from "@/helpers/api";
import Textarea from "@mui/joy/Textarea/Textarea";
import Icon from "./Icon";
import { generateDialog } from "./Dialog";
import LocaleSelect from "./LocaleSelect";
@ -40,7 +41,7 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
});
};
const handleDescriptionChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleDescriptionChanged = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPartialState({
description: e.target.value as string,
});
@ -97,7 +98,7 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
<Icon.X />
</button>
</div>
<div className="dialog-content-container !w-80">
<div className="dialog-content-container min-w-[16rem]">
<p className="text-sm mb-1">
{t("setting.system-section.server-name")}
<span className="text-sm text-gray-400 ml-1">({t("setting.system-section.customize-server.default")})</span>
@ -106,7 +107,7 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.icon-url")}</p>
<input type="text" className="input-text" value={state.logoUrl} onChange={handleLogoUrlChanged} />
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.description")}</p>
<input type="text" className="input-text" value={state.description} onChange={handleDescriptionChanged} />
<Textarea minRows="2" maxRows="4" className="!input-text" value={state.description} onChange={handleDescriptionChanged} />
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.locale")}</p>
<LocaleSelect className="!w-full" value={state.locale} onChange={handleLocaleSelectChange} />
<p className="text-sm mb-1 mt-2">{t("setting.system-section.customize-server.appearance")}</p>

@ -5,7 +5,7 @@ import { useGlobalStore } from "@/store/module";
import * as api from "@/helpers/api";
import { generateDialog } from "./Dialog";
import Icon from "./Icon";
import LearnMore from "./LearnMore";
import HelpButton from "./kit/HelpButton";
import { useTranslation } from "react-i18next";
interface Props extends DialogProps {
@ -49,13 +49,13 @@ const UpdateLocalStorageDialog: React.FC<Props> = (props: Props) => {
</button>
</div>
<div className="dialog-content-container max-w-xs">
<p className="text-sm break-words mb-1">
{t("setting.storage-section.update-local-path-description")}
<LearnMore className="ml-1" url="https://usememos.com/docs/local-storage" />
</p>
<p className="text-sm text-gray-400 mb-2 break-all">
{t("common.e.g")} {"assets/{timestamp}_{filename}"}
</p>
<p className="text-sm break-words mb-1">{t("setting.storage-section.update-local-path-description")}</p>
<div className="flex flex-row">
<p className="text-sm text-gray-400 mb-2 break-all">
{t("common.e.g")} {"assets/{timestamp}_{filename}"}
</p>
<HelpButton hint={t("common.learn-more")} url="https://usememos.com/docs/local-storage" />
</div>
<Input
className="mb-2"
placeholder={t("setting.storage-section.local-storage-path")}

@ -0,0 +1,283 @@
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button, IconButton, Tooltip } from "@mui/joy";
import { generateDialog } from "../Dialog";
import Icon from "../Icon";
const openUrl = (url?: string) => {
window.open(url, "_blank");
};
/** Options for {@link HelpButton} */
interface HelpProps {
/**
* Plain text to show in the dialog.
*
* If the text contains "\n", it will be split to multiple paragraphs.
*/
text?: string;
/**
* The title of the dialog.
*
* If not provided, the title will be set according to the `icon` prop.
*/
title?: string;
/**
* External documentation URL.
*
* If provided, this will be shown as a link button in the bottom of the dialog.
*
* If provided alone, the button will just open the URL in a new tab.
*
* @param {string} url - External URL to the documentation.
*/
url?: string;
/**
* The tooltip of the button.
*/
hint?: string | "none";
/**
* The placement of the hovering hint.
* @defaultValue "top"
*/
hintPlacement?: "top" | "bottom" | "left" | "right";
/**
* The icon to show in the button.
*
* Also used to infer `title` and `hint`, if they are not provided.
*
* @defaultValue Icon.HelpCircle
* @see {@link Icon.LucideIcon}
*/
icon?: Icon.LucideIcon | "link" | "info" | "help" | "alert" | "warn";
/**
* The className for the button.
* @defaultValue `!-mt-2` (aligns the button vertically with nearby text)
*/
className?: string;
/**
* The color of the button.
* @defaultValue "neutral"
*/
color?: "primary" | "neutral" | "danger" | "info" | "success" | "warning";
/**
* The variant of the button.
* @defaultValue "plain"
*/
variant?: "plain" | "outlined" | "soft" | "solid";
/**
* The size of the button.
* @defaultValue "md"
*/
size?: "sm" | "md" | "lg";
/**
* `ReactNode` HTML content to show in the dialog.
*
* If provided, will be shown before `text`.
*
* You'll probably want to use `text` instead.
*/
children?: ReactNode | undefined;
}
interface HelpDialogProps extends HelpProps, DialogProps {}
const HelpfulDialog: React.FC<HelpDialogProps> = (props: HelpDialogProps) => {
const { t } = useTranslation();
const { children, destroy, icon } = props;
const LucideIcon = icon as Icon.LucideIcon;
const handleCloseBtnClick = () => {
destroy();
};
return (
<>
<div className="dialog-header-container">
<LucideIcon size="24" />
<p className="title-text text-left">{props.title}</p>
<button className="btn close-btn" onClick={handleCloseBtnClick}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container max-w-sm">
{children}
{props.text
? props.text.split(/\n|\\n/).map((text) => {
return (
<p key={text} className="mt-2 break-words text-justify">
{text}
</p>
);
})
: null}
<div className="mt-2 w-full flex flex-row justify-end space-x-2">
{props.url ? (
<Button className="btn-normal" variant="outlined" color={props.color} onClick={() => openUrl(props.url)}>
{t("common.learn-more")}
<Icon.ExternalLink className="ml-1 w-4 h-4 opacity-80" />
</Button>
) : null}
<Button className="btn-normal" variant="outlined" color={props.color} onClick={handleCloseBtnClick}>
{t("common.close")}
</Button>
</div>
</div>
</>
);
};
function showHelpDialog(props: HelpProps) {
generateDialog(
{
className: "help-dialog",
dialogName: "help-dialog",
clickSpaceDestroy: true,
},
HelpfulDialog,
props
);
}
/**
* Show a helpful `IconButton` that behaves differently depending on the props.
*
* The main purpose of this component is to avoid UI clutter.
*
* Use the property `icon` to set the icon and infer the title and hint automatically.
*
* Use cases:
* - Button with just a hover hint
* - Button with a hover hint and link
* - Button with a hover hint that opens a dialog with text and a link.
*
* @example
* <Helpful hint="Hint" />
* <Helpful hint="This is a hint with a link" url="https://usememos.com/" />
* <Helpful icon="warn" text={t("i18n.key.long-dialog-text")} url="https://usememos.com/" />
* <Helpful />
*
* <div className="flex flex-row">
* <span className="ml-2">Sample alignment</span>
* <Helpful hint="Button with hint" />
* </div>
* @param props.title - The title of the dialog. Defaults to "Learn more" i18n key.
* @param props.text - Plain text to show in the dialog. Line breaks are supported.
* @param props.url - External memos documentation URL.
* @param props.hint - The hint when hovering the button.
* @param props.hintPlacement - The placement of the hovering hint. Defaults to "top".
* @param props.icon - The icon to show in the button.
* @param props.className - The class name for the button.
* @param {HelpProps} props - See {@link HelpDialogProps} for all exposed props.
*/
const HelpButton = (props: HelpProps): JSX.Element => {
const { t } = useTranslation();
const color = props.color ?? "neutral";
const variant = props.variant ?? "plain";
const className = props.className ?? "!-mt-1";
const hintPlacement = props.hintPlacement ?? "top";
const iconButtonSize = "sm";
const dialogAvailable = props.text || props.children;
const clickActionAvailable = props.url || dialogAvailable;
const onlyUrlAvailable = props.url && !dialogAvailable;
let LucideIcon = (() => {
switch (props.icon) {
case "info":
return Icon.Info;
case "help":
return Icon.HelpCircle;
case "warn":
case "alert":
return Icon.AlertTriangle;
case "link":
return Icon.ExternalLink;
default:
return Icon.HelpCircle;
}
})() as Icon.LucideIcon;
const hint = (() => {
switch (props.hint) {
case undefined:
return t(
(() => {
if (!dialogAvailable) {
LucideIcon = Icon.ExternalLink;
}
switch (LucideIcon) {
case Icon.Info:
return "common.dialog.info";
case Icon.AlertTriangle:
return "common.dialog.warning";
case Icon.ExternalLink:
return "common.learn-more";
case Icon.HelpCircle:
default:
return "common.dialog.help";
}
})()
);
case "":
case "none":
case "false":
case "disabled":
return undefined;
default:
return props.hint;
}
})();
const sizePx = (() => {
switch (props.size) {
case "sm":
return 16;
case "lg":
return 48;
case "md":
default:
return 24;
}
})();
if (!dialogAvailable && !clickActionAvailable && !props.hint) {
return (
<IconButton className={className} color={color} variant={variant} size={iconButtonSize}>
<LucideIcon size={sizePx} />
</IconButton>
);
}
const wrapInTooltip = (element: JSX.Element) => {
if (!hint) {
return element;
}
return (
<Tooltip placement={hintPlacement} title={hint} color={color} variant={variant} size={props.size}>
{element}
</Tooltip>
);
};
if (clickActionAvailable) {
props = { ...props, title: props.title ?? hint, hint: hint, color: color, variant: variant, icon: LucideIcon };
const clickAction = () => {
dialogAvailable ? showHelpDialog(props) : openUrl(props.url);
};
LucideIcon = dialogAvailable || onlyUrlAvailable ? LucideIcon : Icon.ExternalLink;
return wrapInTooltip(
<IconButton className={className} color={color} variant={variant} size={iconButtonSize} onClick={clickAction}>
<LucideIcon size={sizePx} />
</IconButton>
);
}
return wrapInTooltip(
<IconButton className={className} color={color} variant={variant} size={iconButtonSize}>
<LucideIcon size={sizePx} />
</IconButton>
);
};
export default HelpButton;

@ -60,7 +60,14 @@
"visibility": "Visibility",
"learn-more": "Learn more",
"e.g": "e.g.",
"beta": "Beta"
"beta": "Beta",
"dialog": {
"error": "Error",
"help": "Help",
"info": "Information",
"success": "Success",
"warning": "Warning"
}
},
"router": {
"back-to-home": "Back to Home"
@ -191,7 +198,7 @@
"auto-collapse": "Auto Collapse"
},
"storage-section": {
"current-storage": "Current storage",
"current-storage": "Current object storage",
"type-database": "Database",
"type-local": "Local",
"storage-services-list": "Storage service list",
@ -243,6 +250,8 @@
"allow-user-signup": "Allow user signup",
"ignore-version-upgrade": "Ignore version upgrade",
"disable-public-memos": "Disable public memos",
"max-upload-size": "Maximum upload size (MiB)",
"max-upload-size-hint": "Recommended value is 32 MiB.",
"additional-style": "Additional style",
"additional-script": "Additional script",
"additional-style-placeholder": "Additional CSS code",
@ -349,7 +358,9 @@
"succeed-update-customized-profile": "Profile successfully customized.",
"succeed-update-additional-script": "Additional script updated successfully.",
"update-succeed": "Update succeeded",
"page-not-found": "404 - Page Not Found 😥"
"page-not-found": "404 - Page Not Found 😥",
"maximum-upload-size-is": "Maximum allowed upload size is {{size}} MiB",
"file-exceeds-upload-limit-of": "File {{file}} exceeds upload limit of {{size}} MiB"
},
"days": {
"mon": "Mon",
@ -387,4 +398,4 @@
"powered-by": "Powered by",
"other-projects": "Other Projects"
}
}
}

@ -60,7 +60,14 @@
"visibility": "Visibilidade",
"learn-more": "Saiba mais",
"e.g": "ex.",
"beta": "Beta"
"beta": "Beta",
"dialog": {
"error": "Erro",
"help": "Ajuda",
"info": "Informação",
"success": "Sucesso",
"warning": "Aviso"
}
},
"router": {
"back-to-home": "Voltar ao início"
@ -191,7 +198,7 @@
"auto-collapse": "Recolher automaticamente"
},
"storage-section": {
"current-storage": "Armazenamento atual",
"current-storage": "Armazenamento de objetos atual",
"type-database": "Banco de dados",
"type-local": "Local",
"storage-services-list": "Lista de serviços de armazenamento",
@ -216,7 +223,7 @@
"bucket-placeholder": "Nome do bucket",
"path": "Caminho do armazenamento",
"path-description": "Você pode usar as mesmas variáveis dinâmicas do armazenamento local, como {filename}",
"path-placeholder": "caminho/personalizado",
"path-placeholder": "caminho",
"url-prefix": "Prefixo da URL",
"url-prefix-placeholder": "Prefixo personalizado da URL, opcional",
"url-suffix": "Sufixo da URL",
@ -243,6 +250,8 @@
"allow-user-signup": "Permitir registro de usuário",
"ignore-version-upgrade": "Ignorar atualização de versão",
"disable-public-memos": "Desabilitar memos públicos",
"max-upload-size": "Tamanho máximo de upload (MiB)",
"max-upload-size-hint": "O valor recomendado é 32 MiB.",
"additional-style": "Estilo adicional",
"additional-script": "Script adicional",
"additional-style-placeholder": "Código CSS adicional",
@ -349,7 +358,9 @@
"succeed-update-customized-profile": "Perfil personalizado com êxito.",
"succeed-update-additional-script": "Script adicional atualizado com êxito.",
"update-succeed": "Atualizado com êxito",
"page-not-found": "404 - Página não encontrada 😥"
"page-not-found": "404 - Página não encontrada 😥",
"maximum-upload-size-is": "O tamanho máximo permitido para upload é {{size}} MiB",
"file-exceeds-upload-limit-of": "O arquivo {{file}} excede o limite de upload de {{size}} MiB"
},
"days": {
"mon": "Seg",
@ -387,4 +398,4 @@
"powered-by": "Provido por",
"other-projects": "Outros projetos"
}
}
}

@ -13,6 +13,7 @@ export const initialGlobalState = async () => {
allowSignUp: false,
ignoreUpgrade: false,
disablePublicMemos: false,
maxUploadSizeMiB: 0,
additionalStyle: "",
additionalScript: "",
customizedProfile: {

@ -2,8 +2,8 @@ import * as api from "@/helpers/api";
import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts";
import store, { useAppSelector } from "../";
import { patchResource, setResources, deleteResource, upsertResources } from "../reducer/resource";
const MAX_FILE_SIZE = 32 << 20;
import { useGlobalStore } from "./global";
import { useTranslation } from "react-i18next";
const convertResponseModelResource = (resource: Resource): Resource => {
return {
@ -15,6 +15,9 @@ const convertResponseModelResource = (resource: Resource): Resource => {
export const useResourceStore = () => {
const state = useAppSelector((state) => state.resource);
const { t } = useTranslation();
const globalStore = useGlobalStore();
const maxUploadSizeMiB = globalStore.state.systemStatus.maxUploadSizeMiB;
return {
state,
@ -46,8 +49,8 @@ export const useResourceStore = () => {
},
async createResourceWithBlob(file: File): Promise<Resource> {
const { name: filename, size } = file;
if (size > MAX_FILE_SIZE) {
return Promise.reject("overload max size: 32MB");
if (size > maxUploadSizeMiB * 1024 * 1024) {
return Promise.reject(t("message.maximum-upload-size-is", { size: maxUploadSizeMiB }));
}
const formData = new FormData();
@ -62,8 +65,8 @@ export const useResourceStore = () => {
let newResourceList: Array<Resource> = [];
for (const file of files) {
const { name: filename, size } = file;
if (size > MAX_FILE_SIZE) {
return Promise.reject(`${filename} overload max size: 32MB`);
if (size > maxUploadSizeMiB * 1024 * 1024) {
return Promise.reject(t("message.file-exceeds-upload-limit-of", { file: filename, size: maxUploadSizeMiB }));
}
const formData = new FormData();

@ -25,6 +25,7 @@ interface SystemStatus {
allowSignUp: boolean;
ignoreUpgrade: boolean;
disablePublicMemos: boolean;
maxUploadSizeMiB: number;
additionalStyle: string;
additionalScript: string;
customizedProfile: CustomizedProfile;

Loading…
Cancel
Save