mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
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
This commit is contained in:
@ -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")}
|
||||
|
283
web/src/components/kit/HelpButton.tsx
Normal file
283
web/src/components/kit/HelpButton.tsx
Normal file
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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();
|
||||
|
1
web/src/types/modules/system.d.ts
vendored
1
web/src/types/modules/system.d.ts
vendored
@ -25,6 +25,7 @@ interface SystemStatus {
|
||||
allowSignUp: boolean;
|
||||
ignoreUpgrade: boolean;
|
||||
disablePublicMemos: boolean;
|
||||
maxUploadSizeMiB: number;
|
||||
additionalStyle: string;
|
||||
additionalScript: string;
|
||||
customizedProfile: CustomizedProfile;
|
||||
|
Reference in New Issue
Block a user