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"`
|
IgnoreUpgrade bool `json:"ignoreUpgrade"`
|
||||||
// Disable public memos.
|
// Disable public memos.
|
||||||
DisablePublicMemos bool `json:"disablePublicMemos"`
|
DisablePublicMemos bool `json:"disablePublicMemos"`
|
||||||
|
// Max upload size.
|
||||||
|
MaxUploadSizeMiB int `json:"maxUploadSizeMiB"`
|
||||||
// Additional style.
|
// Additional style.
|
||||||
AdditionalStyle string `json:"additionalStyle"`
|
AdditionalStyle string `json:"additionalStyle"`
|
||||||
// Additional script.
|
// Additional script.
|
||||||
|
@ -2,7 +2,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"golang.org/x/exp/slices"
|
"golang.org/x/exp/slices"
|
||||||
@ -21,6 +20,8 @@ const (
|
|||||||
SystemSettingIgnoreUpgradeName SystemSettingName = "ignore-upgrade"
|
SystemSettingIgnoreUpgradeName SystemSettingName = "ignore-upgrade"
|
||||||
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
|
// SystemSettingDisablePublicMemosName is the name of disable public memos setting.
|
||||||
SystemSettingDisablePublicMemosName SystemSettingName = "disable-public-memos"
|
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 is the name of additional style.
|
||||||
SystemSettingAdditionalStyleName SystemSettingName = "additional-style"
|
SystemSettingAdditionalStyleName SystemSettingName = "additional-style"
|
||||||
// SystemSettingAdditionalScriptName is the name of additional script.
|
// SystemSettingAdditionalScriptName is the name of additional script.
|
||||||
@ -68,6 +69,8 @@ func (key SystemSettingName) String() string {
|
|||||||
return "ignore-upgrade"
|
return "ignore-upgrade"
|
||||||
case SystemSettingDisablePublicMemosName:
|
case SystemSettingDisablePublicMemosName:
|
||||||
return "disable-public-memos"
|
return "disable-public-memos"
|
||||||
|
case SystemSettingMaxUploadSizeMiBName:
|
||||||
|
return "max-upload-size-mib"
|
||||||
case SystemSettingAdditionalStyleName:
|
case SystemSettingAdditionalStyleName:
|
||||||
return "additional-style"
|
return "additional-style"
|
||||||
case SystemSettingAdditionalScriptName:
|
case SystemSettingAdditionalScriptName:
|
||||||
@ -97,40 +100,50 @@ type SystemSettingUpsert struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const systemSettingUnmarshalError = `failed to unmarshal value from system setting "%v"`
|
||||||
|
|
||||||
func (upsert SystemSettingUpsert) Validate() error {
|
func (upsert SystemSettingUpsert) Validate() error {
|
||||||
if upsert.Name == SystemSettingServerIDName {
|
switch settingName := upsert.Name; settingName {
|
||||||
return errors.New("update server id is not allowed")
|
case SystemSettingServerIDName:
|
||||||
} else if upsert.Name == SystemSettingAllowSignUpName {
|
return fmt.Errorf("updating %v is not allowed", settingName)
|
||||||
value := false
|
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
case SystemSettingAllowSignUpName:
|
||||||
if err != nil {
|
var value bool
|
||||||
return fmt.Errorf("failed to unmarshal system setting allow signup value")
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
}
|
}
|
||||||
} else if upsert.Name == SystemSettingIgnoreUpgradeName {
|
|
||||||
value := false
|
case SystemSettingIgnoreUpgradeName:
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
var value bool
|
||||||
if err != nil {
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal system setting ignore upgrade value")
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
}
|
}
|
||||||
} else if upsert.Name == SystemSettingDisablePublicMemosName {
|
|
||||||
value := false
|
case SystemSettingDisablePublicMemosName:
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
var value bool
|
||||||
if err != nil {
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal system setting disable public memos value")
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
}
|
}
|
||||||
} else if upsert.Name == SystemSettingAdditionalStyleName {
|
|
||||||
value := ""
|
case SystemSettingMaxUploadSizeMiBName:
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
var value int
|
||||||
if err != nil {
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal system setting additional style value")
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
}
|
}
|
||||||
} else if upsert.Name == SystemSettingAdditionalScriptName {
|
|
||||||
value := ""
|
case SystemSettingAdditionalStyleName:
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
var value string
|
||||||
if err != nil {
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal system setting additional script value")
|
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{
|
customizedProfile := CustomizedProfile{
|
||||||
Name: "memos",
|
Name: "memos",
|
||||||
LogoURL: "",
|
LogoURL: "",
|
||||||
@ -139,36 +152,37 @@ func (upsert SystemSettingUpsert) Validate() error {
|
|||||||
Appearance: "system",
|
Appearance: "system",
|
||||||
ExternalURL: "",
|
ExternalURL: "",
|
||||||
}
|
}
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &customizedProfile)
|
|
||||||
if err != nil {
|
if err := json.Unmarshal([]byte(upsert.Value), &customizedProfile); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal system setting customized profile value")
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
}
|
}
|
||||||
if !slices.Contains(UserSettingLocaleValue, customizedProfile.Locale) {
|
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) {
|
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
|
value := DatabaseStorage
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
return fmt.Errorf("failed to unmarshal system setting storage service id value")
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
} else if upsert.Name == SystemSettingLocalStoragePathName {
|
|
||||||
|
case SystemSettingLocalStoragePathName:
|
||||||
value := ""
|
value := ""
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
return fmt.Errorf("failed to unmarshal system setting local storage path value")
|
|
||||||
}
|
}
|
||||||
} else if upsert.Name == SystemSettingOpenAIConfigName {
|
|
||||||
|
case SystemSettingOpenAIConfigName:
|
||||||
value := OpenAIConfig{}
|
value := OpenAIConfig{}
|
||||||
err := json.Unmarshal([]byte(upsert.Value), &value)
|
if err := json.Unmarshal([]byte(upsert.Value), &value); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf(systemSettingUnmarshalError, settingName)
|
||||||
return fmt.Errorf("failed to unmarshal system setting openai api config value")
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
default:
|
||||||
return fmt.Errorf("invalid system setting name")
|
return fmt.Errorf("invalid system setting name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,8 +25,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// The max file size is 32MB.
|
// The upload memory buffer is 32 MiB.
|
||||||
maxFileSize = 32 << 20
|
// 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}\}`)
|
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")
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.Request().ParseMultipartForm(maxFileSize); err != nil {
|
maxUploadSetting := s.Store.GetSystemSettingValueOrDefault(&ctx, api.SystemSettingMaxUploadSizeMiBName, "0")
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "Upload file overload max size").SetInternal(err)
|
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")
|
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)
|
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")
|
filetype := file.Header.Get("Content-Type")
|
||||||
size := file.Size
|
size := file.Size
|
||||||
sourceFile, err := file.Open()
|
sourceFile, err := file.Open()
|
||||||
|
@ -44,6 +44,7 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
|||||||
AllowSignUp: false,
|
AllowSignUp: false,
|
||||||
IgnoreUpgrade: false,
|
IgnoreUpgrade: false,
|
||||||
DisablePublicMemos: false,
|
DisablePublicMemos: false,
|
||||||
|
MaxUploadSizeMiB: 32,
|
||||||
AdditionalStyle: "",
|
AdditionalStyle: "",
|
||||||
AdditionalScript: "",
|
AdditionalScript: "",
|
||||||
CustomizedProfile: api.CustomizedProfile{
|
CustomizedProfile: api.CustomizedProfile{
|
||||||
@ -74,27 +75,40 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if systemSetting.Name == api.SystemSettingAllowSignUpName {
|
switch systemSetting.Name {
|
||||||
|
case api.SystemSettingAllowSignUpName:
|
||||||
systemStatus.AllowSignUp = baseValue.(bool)
|
systemStatus.AllowSignUp = baseValue.(bool)
|
||||||
} else if systemSetting.Name == api.SystemSettingIgnoreUpgradeName {
|
|
||||||
|
case api.SystemSettingIgnoreUpgradeName:
|
||||||
systemStatus.IgnoreUpgrade = baseValue.(bool)
|
systemStatus.IgnoreUpgrade = baseValue.(bool)
|
||||||
} else if systemSetting.Name == api.SystemSettingDisablePublicMemosName {
|
|
||||||
|
case api.SystemSettingDisablePublicMemosName:
|
||||||
systemStatus.DisablePublicMemos = baseValue.(bool)
|
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)
|
systemStatus.AdditionalStyle = baseValue.(string)
|
||||||
} else if systemSetting.Name == api.SystemSettingAdditionalScriptName {
|
|
||||||
|
case api.SystemSettingAdditionalScriptName:
|
||||||
systemStatus.AdditionalScript = baseValue.(string)
|
systemStatus.AdditionalScript = baseValue.(string)
|
||||||
} else if systemSetting.Name == api.SystemSettingCustomizedProfileName {
|
|
||||||
|
case api.SystemSettingCustomizedProfileName:
|
||||||
customizedProfile := api.CustomizedProfile{}
|
customizedProfile := api.CustomizedProfile{}
|
||||||
err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile)
|
if err := json.Unmarshal([]byte(systemSetting.Value), &customizedProfile); err != nil {
|
||||||
if err != nil {
|
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting customized profile value").SetInternal(err)
|
||||||
}
|
}
|
||||||
systemStatus.CustomizedProfile = customizedProfile
|
systemStatus.CustomizedProfile = customizedProfile
|
||||||
} else if systemSetting.Name == api.SystemSettingStorageServiceIDName {
|
|
||||||
|
case api.SystemSettingStorageServiceIDName:
|
||||||
systemStatus.StorageServiceID = int(baseValue.(float64))
|
systemStatus.StorageServiceID = int(baseValue.(float64))
|
||||||
} else if systemSetting.Name == api.SystemSettingLocalStoragePathName {
|
|
||||||
|
case api.SystemSettingLocalStoragePathName:
|
||||||
systemStatus.LocalStoragePath = baseValue.(string)
|
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
|
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) {
|
func upsertSystemSetting(ctx context.Context, tx *sql.Tx, upsert *api.SystemSettingUpsert) (*systemSettingRaw, error) {
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO system_setting (
|
INSERT INTO system_setting (
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
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 * as api from "@/helpers/api";
|
||||||
import { UNKNOWN_ID } from "@/helpers/consts";
|
import { UNKNOWN_ID } from "@/helpers/consts";
|
||||||
import { absolutifyLink } from "@/helpers/utils";
|
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 { confirmCallback, destroy, identityProvider } = props;
|
||||||
const [basicInfo, setBasicInfo] = useState({
|
const [basicInfo, setBasicInfo] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@ -121,7 +122,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
|
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
|
||||||
const [seletedTemplate, setSelectedTemplate] = useState<string>("GitHub");
|
const [selectedTemplate, setSelectedTemplate] = useState<string>("GitHub");
|
||||||
const isCreating = identityProvider === undefined;
|
const isCreating = identityProvider === undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -143,7 +144,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = templateList.find((t) => t.name === seletedTemplate);
|
const template = templateList.find((t) => t.name === selectedTemplate);
|
||||||
if (template) {
|
if (template) {
|
||||||
setBasicInfo({
|
setBasicInfo({
|
||||||
name: template.name,
|
name: template.name,
|
||||||
@ -155,7 +156,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
|||||||
setOAuth2Scopes(template.config.oauth2Config.scopes.join(" "));
|
setOAuth2Scopes(template.config.oauth2Config.scopes.join(" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [seletedTemplate]);
|
}, [selectedTemplate]);
|
||||||
|
|
||||||
const handleCloseBtnClick = () => {
|
const handleCloseBtnClick = () => {
|
||||||
destroy();
|
destroy();
|
||||||
@ -229,37 +230,34 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="dialog-header-container">
|
<div className="dialog-header-container">
|
||||||
<p className="title-text">{t("setting.sso-section." + (isCreating ? "create" : "update") + "-sso")}</p>
|
<p className="title-text ml-auto">{t("setting.sso-section." + (isCreating ? "create" : "update") + "-sso")}</p>
|
||||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
<button className="btn close-btn ml-auto" onClick={handleCloseBtnClick}>
|
||||||
<Icon.X />
|
<Icon.X />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-content-container w-full max-w-[24rem] min-w-[25rem]">
|
<div className="dialog-content-container min-w-[19rem]">
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
<>
|
<>
|
||||||
<Typography className="!mb-1" level="body2">
|
<Typography className="!mb-1" level="body2">
|
||||||
{t("common.type")}
|
{t("common.type")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<RadioGroup className="mb-2" value={type}>
|
<Select className="w-full mb-4" value={type} onChange={(_, e) => setType(e ?? type)}>
|
||||||
<div className="mt-2 w-full flex flex-row space-x-4">
|
{identityProviderTypes.map((kind) => (
|
||||||
<Radio value="OAUTH2" label="OAuth 2.0" />
|
<Option key={kind} value={kind}>
|
||||||
</div>
|
{kind}
|
||||||
</RadioGroup>
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
<Typography className="mb-2" level="body2">
|
<Typography className="mb-2" level="body2">
|
||||||
{t("setting.sso-section.template")}
|
{t("setting.sso-section.template")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<RadioGroup className="mb-2" value={seletedTemplate}>
|
<Select className="mb-1 h-auto w-full" value={selectedTemplate} onChange={(_, e) => setSelectedTemplate(e ?? selectedTemplate)}>
|
||||||
<div className="mt-2 w-full flex flex-row space-x-4">
|
|
||||||
{templateList.map((template) => (
|
{templateList.map((template) => (
|
||||||
<Radio
|
<Option key={template.name} value={template.name}>
|
||||||
key={template.name}
|
{template.name}
|
||||||
value={template.name}
|
</Option>
|
||||||
label={template.name}
|
|
||||||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Select>
|
||||||
</RadioGroup>
|
|
||||||
<Divider className="!my-2" />
|
<Divider className="!my-2" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -6,7 +6,7 @@ import * as api from "@/helpers/api";
|
|||||||
import { generateDialog } from "./Dialog";
|
import { generateDialog } from "./Dialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import RequiredBadge from "./RequiredBadge";
|
import RequiredBadge from "./RequiredBadge";
|
||||||
import LearnMore from "./LearnMore";
|
import HelpButton from "./kit/HelpButton";
|
||||||
|
|
||||||
interface Props extends DialogProps {
|
interface Props extends DialogProps {
|
||||||
storage?: ObjectStorage;
|
storage?: ObjectStorage;
|
||||||
@ -106,15 +106,12 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="dialog-header-container">
|
<div className="dialog-header-container">
|
||||||
<p className="title-text">
|
<span className="title-text ml-auto">{t("setting.storage-section." + (isCreating ? "create" : "update") + "-storage")}</span>
|
||||||
{t("setting.storage-section." + (isCreating ? "create" : "update") + "-storage")}
|
<button className="btn close-btn ml-auto" onClick={handleCloseBtnClick}>
|
||||||
<LearnMore className="ml-2" url="https://usememos.com/docs/storage" />
|
|
||||||
</p>
|
|
||||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
|
||||||
<Icon.X />
|
<Icon.X />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-content-container">
|
<div className="dialog-content-container min-w-[19rem]">
|
||||||
<Typography className="!mb-1" level="body2">
|
<Typography className="!mb-1" level="body2">
|
||||||
{t("common.name")}
|
{t("common.name")}
|
||||||
<RequiredBadge />
|
<RequiredBadge />
|
||||||
@ -186,13 +183,12 @@ const CreateStorageServiceDialog: React.FC<Props> = (props: Props) => {
|
|||||||
onChange={(e) => setPartialS3Config({ bucket: e.target.value })}
|
onChange={(e) => setPartialS3Config({ bucket: e.target.value })}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<div className="flex flex-row">
|
||||||
<Typography className="!mb-1" level="body2">
|
<Typography className="!mb-1" level="body2">
|
||||||
{t("setting.storage-section.path")}
|
{t("setting.storage-section.path")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className="!mb-1" level="body2">
|
<HelpButton text={t("setting.storage-section.path-description")} url="https://usememos.com/docs/local-storage" />
|
||||||
<p className="text-sm text-gray-400 ml-1">{t("setting.storage-section.path-description")}</p>
|
</div>
|
||||||
<LearnMore className="ml-2" url="https://usememos.com/docs/local-storage" />
|
|
||||||
</Typography>
|
|
||||||
<Input
|
<Input
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
placeholder={t("setting.storage-section.path-placeholder") + "/{year}/{month}/{filename}"}
|
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 React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGlobalStore, useUserStore } from "@/store/module";
|
import { useGlobalStore, useUserStore } from "@/store/module";
|
||||||
@ -53,18 +53,18 @@ const PreferencesSection = () => {
|
|||||||
<div className="section-container preferences-section-container">
|
<div className="section-container preferences-section-container">
|
||||||
<p className="title-text">{t("common.basic")}</p>
|
<p className="title-text">{t("common.basic")}</p>
|
||||||
<div className="form-label selector">
|
<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} />
|
<LocaleSelect value={locale} onChange={handleLocaleSelectChange} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-label selector">
|
<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} />
|
<AppearanceSelect value={appearance} onChange={handleAppearanceSelectChange} />
|
||||||
</div>
|
</div>
|
||||||
<p className="title-text">{t("setting.preference")}</p>
|
<p className="title-text">{t("setting.preference")}</p>
|
||||||
<div className="form-label selector">
|
<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
|
<Select
|
||||||
className="!min-w-[10rem] w-auto text-sm"
|
className="!min-w-fit"
|
||||||
value={setting.memoVisibility}
|
value={setting.memoVisibility}
|
||||||
onChange={(_, visibility) => {
|
onChange={(_, visibility) => {
|
||||||
if (visibility) {
|
if (visibility) {
|
||||||
@ -73,18 +73,18 @@ const PreferencesSection = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{visibilitySelectorItems.map((item) => (
|
{visibilitySelectorItems.map((item) => (
|
||||||
<Option key={item.value} value={item.value} className="whitespace-nowrap">
|
<Option key={item.value} value={item.value}>
|
||||||
{item.text}
|
{item.text}
|
||||||
</Option>
|
</Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-label selector">
|
<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">
|
<span className="w-auto inline-flex">
|
||||||
<Select
|
<Select
|
||||||
placeholder="hh"
|
placeholder="hh"
|
||||||
className="!min-w-[4rem] w-auto text-sm"
|
className="!min-w-fit"
|
||||||
value={localSetting.dailyReviewTimeOffset}
|
value={localSetting.dailyReviewTimeOffset}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
@ -110,7 +110,7 @@ const PreferencesSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="form-label selector">
|
<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} />
|
<Switch className="ml-2" checked={localSetting.enableDoubleClickEditing} onChange={handleDoubleClickEnabledChanged} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
@ -2,9 +2,11 @@ import { useEffect, useState } from "react";
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as api from "@/helpers/api";
|
import * as api from "@/helpers/api";
|
||||||
|
import { Divider } from "@mui/joy";
|
||||||
import showCreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
|
import showCreateIdentityProviderDialog from "../CreateIdentityProviderDialog";
|
||||||
import Dropdown from "../kit/Dropdown";
|
import Dropdown from "../kit/Dropdown";
|
||||||
import { showCommonDialog } from "../Dialog/CommonDialog";
|
import { showCommonDialog } from "../Dialog/CommonDialog";
|
||||||
|
import HelpButton from "../kit/HelpButton";
|
||||||
|
|
||||||
const SSOSection = () => {
|
const SSOSection = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -41,8 +43,9 @@ const SSOSection = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-container">
|
<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>
|
<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
|
<button
|
||||||
className="btn-normal px-2 py-0 leading-7"
|
className="btn-normal px-2 py-0 leading-7"
|
||||||
onClick={() => showCreateIdentityProviderDialog(undefined, fetchIdentityProviderList)}
|
onClick={() => showCreateIdentityProviderDialog(undefined, fetchIdentityProviderList)}
|
||||||
@ -50,9 +53,14 @@ const SSOSection = () => {
|
|||||||
{t("common.create")}
|
{t("common.create")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 w-full flex flex-col">
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
{identityProviderList.map((identityProvider) => (
|
{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
|
||||||
|
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">
|
<div className="flex flex-row items-center">
|
||||||
<p className="ml-2">
|
<p className="ml-2">
|
||||||
{identityProvider.name}
|
{identityProvider.name}
|
||||||
@ -83,7 +91,6 @@ const SSOSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import showCreateStorageServiceDialog from "../CreateStorageServiceDialog";
|
|||||||
import showUpdateLocalStorageDialog from "../UpdateLocalStorageDialog";
|
import showUpdateLocalStorageDialog from "../UpdateLocalStorageDialog";
|
||||||
import Dropdown from "../kit/Dropdown";
|
import Dropdown from "../kit/Dropdown";
|
||||||
import { showCommonDialog } from "../Dialog/CommonDialog";
|
import { showCommonDialog } from "../Dialog/CommonDialog";
|
||||||
|
import HelpButton from "../kit/HelpButton";
|
||||||
|
|
||||||
const StorageSection = () => {
|
const StorageSection = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -77,12 +78,17 @@ const StorageSection = () => {
|
|||||||
<Divider />
|
<Divider />
|
||||||
<div className="mt-4 mb-2 w-full flex flex-row justify-start items-center">
|
<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>
|
<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)}>
|
<button className="btn-normal px-2 py-0 leading-7" onClick={() => showCreateStorageServiceDialog(undefined, fetchStorageList)}>
|
||||||
{t("common.create")}
|
{t("common.create")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 w-full flex flex-col">
|
<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">
|
<div className="flex flex-row items-center">
|
||||||
<p className="ml-2">{t("setting.storage-section.type-local")}</p>
|
<p className="ml-2">{t("setting.storage-section.type-local")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { formatBytes } from "@/helpers/utils";
|
||||||
import { useGlobalStore } from "@/store/module";
|
import { useGlobalStore } from "@/store/module";
|
||||||
import * as api from "@/helpers/api";
|
import * as api from "@/helpers/api";
|
||||||
import Icon from "../Icon";
|
import HelpButton from "../kit/HelpButton";
|
||||||
import showUpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
|
import showUpdateCustomizedProfileDialog from "../UpdateCustomizedProfileDialog";
|
||||||
import "@/less/settings/system-section.less";
|
import "@/less/settings/system-section.less";
|
||||||
|
|
||||||
@ -16,6 +16,7 @@ interface State {
|
|||||||
disablePublicMemos: boolean;
|
disablePublicMemos: boolean;
|
||||||
additionalStyle: string;
|
additionalStyle: string;
|
||||||
additionalScript: string;
|
additionalScript: string;
|
||||||
|
maxUploadSizeMiB: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SystemSection = () => {
|
const SystemSection = () => {
|
||||||
@ -29,6 +30,7 @@ const SystemSection = () => {
|
|||||||
additionalStyle: systemStatus.additionalStyle,
|
additionalStyle: systemStatus.additionalStyle,
|
||||||
additionalScript: systemStatus.additionalScript,
|
additionalScript: systemStatus.additionalScript,
|
||||||
disablePublicMemos: systemStatus.disablePublicMemos,
|
disablePublicMemos: systemStatus.disablePublicMemos,
|
||||||
|
maxUploadSizeMiB: systemStatus.maxUploadSizeMiB,
|
||||||
});
|
});
|
||||||
const [openAIConfig, setOpenAIConfig] = useState<OpenAIConfig>({
|
const [openAIConfig, setOpenAIConfig] = useState<OpenAIConfig>({
|
||||||
key: "",
|
key: "",
|
||||||
@ -56,6 +58,7 @@ const SystemSection = () => {
|
|||||||
additionalStyle: systemStatus.additionalStyle,
|
additionalStyle: systemStatus.additionalStyle,
|
||||||
additionalScript: systemStatus.additionalScript,
|
additionalScript: systemStatus.additionalScript,
|
||||||
disablePublicMemos: systemStatus.disablePublicMemos,
|
disablePublicMemos: systemStatus.disablePublicMemos,
|
||||||
|
maxUploadSizeMiB: systemStatus.maxUploadSizeMiB,
|
||||||
});
|
});
|
||||||
}, [systemStatus]);
|
}, [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 (
|
return (
|
||||||
<div className="section-container system-section-container">
|
<div className="section-container system-section-container">
|
||||||
<p className="title-text">{t("common.basic")}</p>
|
<p className="title-text">{t("common.basic")}</p>
|
||||||
@ -185,7 +212,7 @@ const SystemSection = () => {
|
|||||||
<Button onClick={handleUpdateCustomizedProfileButtonClick}>{t("common.edit")}</Button>
|
<Button onClick={handleUpdateCustomizedProfileButtonClick}>{t("common.edit")}</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-label">
|
<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>
|
{t("setting.system-section.database-file-size")}: <span className="font-mono font-bold">{formatBytes(state.dbSize)}</span>
|
||||||
</span>
|
</span>
|
||||||
<Button onClick={handleVacuumBtnClick}>{t("common.vacuum")}</Button>
|
<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>
|
<span className="normal-text">{t("setting.system-section.disable-public-memos")}</span>
|
||||||
<Switch checked={state.disablePublicMemos} onChange={(event) => handleDisablePublicMemosChanged(event.target.checked)} />
|
<Switch checked={state.disablePublicMemos} onChange={(event) => handleDisablePublicMemosChanged(event.target.checked)} />
|
||||||
</div>
|
</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" />
|
<Divider className="!mt-3 !my-4" />
|
||||||
<div className="form-label">
|
<div className="form-label">
|
||||||
|
<div className="flex flex-row">
|
||||||
<span className="normal-text">{t("setting.system-section.openai-api-key")}</span>
|
<span className="normal-text">{t("setting.system-section.openai-api-key")}</span>
|
||||||
<Typography className="!mb-1" level="body2">
|
<HelpButton hint={t("setting.system-section.openai-api-key-description")} url="https://platform.openai.com/account/api-keys" />
|
||||||
<a
|
</div>
|
||||||
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>
|
|
||||||
<Button onClick={handleSaveOpenAIConfig}>{t("common.save")}</Button>
|
<Button onClick={handleSaveOpenAIConfig}>{t("common.save")}</Button>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
|
@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useGlobalStore } from "@/store/module";
|
import { useGlobalStore } from "@/store/module";
|
||||||
import * as api from "@/helpers/api";
|
import * as api from "@/helpers/api";
|
||||||
|
import Textarea from "@mui/joy/Textarea/Textarea";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { generateDialog } from "./Dialog";
|
import { generateDialog } from "./Dialog";
|
||||||
import LocaleSelect from "./LocaleSelect";
|
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({
|
setPartialState({
|
||||||
description: e.target.value as string,
|
description: e.target.value as string,
|
||||||
});
|
});
|
||||||
@ -97,7 +98,7 @@ const UpdateCustomizedProfileDialog: React.FC<Props> = ({ destroy }: Props) => {
|
|||||||
<Icon.X />
|
<Icon.X />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-content-container !w-80">
|
<div className="dialog-content-container min-w-[16rem]">
|
||||||
<p className="text-sm mb-1">
|
<p className="text-sm mb-1">
|
||||||
{t("setting.system-section.server-name")}
|
{t("setting.system-section.server-name")}
|
||||||
<span className="text-sm text-gray-400 ml-1">({t("setting.system-section.customize-server.default")})</span>
|
<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>
|
<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} />
|
<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>
|
<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>
|
<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} />
|
<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>
|
<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 * as api from "@/helpers/api";
|
||||||
import { generateDialog } from "./Dialog";
|
import { generateDialog } from "./Dialog";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import LearnMore from "./LearnMore";
|
import HelpButton from "./kit/HelpButton";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends DialogProps {
|
interface Props extends DialogProps {
|
||||||
@ -49,13 +49,13 @@ const UpdateLocalStorageDialog: React.FC<Props> = (props: Props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-content-container max-w-xs">
|
<div className="dialog-content-container max-w-xs">
|
||||||
<p className="text-sm break-words mb-1">
|
<p className="text-sm break-words mb-1">{t("setting.storage-section.update-local-path-description")}</p>
|
||||||
{t("setting.storage-section.update-local-path-description")}
|
<div className="flex flex-row">
|
||||||
<LearnMore className="ml-1" url="https://usememos.com/docs/local-storage" />
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-400 mb-2 break-all">
|
<p className="text-sm text-gray-400 mb-2 break-all">
|
||||||
{t("common.e.g")} {"assets/{timestamp}_{filename}"}
|
{t("common.e.g")} {"assets/{timestamp}_{filename}"}
|
||||||
</p>
|
</p>
|
||||||
|
<HelpButton hint={t("common.learn-more")} url="https://usememos.com/docs/local-storage" />
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
placeholder={t("setting.storage-section.local-storage-path")}
|
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",
|
"visibility": "Visibility",
|
||||||
"learn-more": "Learn more",
|
"learn-more": "Learn more",
|
||||||
"e.g": "e.g.",
|
"e.g": "e.g.",
|
||||||
"beta": "Beta"
|
"beta": "Beta",
|
||||||
|
"dialog": {
|
||||||
|
"error": "Error",
|
||||||
|
"help": "Help",
|
||||||
|
"info": "Information",
|
||||||
|
"success": "Success",
|
||||||
|
"warning": "Warning"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"router": {
|
"router": {
|
||||||
"back-to-home": "Back to Home"
|
"back-to-home": "Back to Home"
|
||||||
@ -191,7 +198,7 @@
|
|||||||
"auto-collapse": "Auto Collapse"
|
"auto-collapse": "Auto Collapse"
|
||||||
},
|
},
|
||||||
"storage-section": {
|
"storage-section": {
|
||||||
"current-storage": "Current storage",
|
"current-storage": "Current object storage",
|
||||||
"type-database": "Database",
|
"type-database": "Database",
|
||||||
"type-local": "Local",
|
"type-local": "Local",
|
||||||
"storage-services-list": "Storage service list",
|
"storage-services-list": "Storage service list",
|
||||||
@ -243,6 +250,8 @@
|
|||||||
"allow-user-signup": "Allow user signup",
|
"allow-user-signup": "Allow user signup",
|
||||||
"ignore-version-upgrade": "Ignore version upgrade",
|
"ignore-version-upgrade": "Ignore version upgrade",
|
||||||
"disable-public-memos": "Disable public memos",
|
"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-style": "Additional style",
|
||||||
"additional-script": "Additional script",
|
"additional-script": "Additional script",
|
||||||
"additional-style-placeholder": "Additional CSS code",
|
"additional-style-placeholder": "Additional CSS code",
|
||||||
@ -349,7 +358,9 @@
|
|||||||
"succeed-update-customized-profile": "Profile successfully customized.",
|
"succeed-update-customized-profile": "Profile successfully customized.",
|
||||||
"succeed-update-additional-script": "Additional script updated successfully.",
|
"succeed-update-additional-script": "Additional script updated successfully.",
|
||||||
"update-succeed": "Update succeeded",
|
"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": {
|
"days": {
|
||||||
"mon": "Mon",
|
"mon": "Mon",
|
||||||
|
@ -60,7 +60,14 @@
|
|||||||
"visibility": "Visibilidade",
|
"visibility": "Visibilidade",
|
||||||
"learn-more": "Saiba mais",
|
"learn-more": "Saiba mais",
|
||||||
"e.g": "ex.",
|
"e.g": "ex.",
|
||||||
"beta": "Beta"
|
"beta": "Beta",
|
||||||
|
"dialog": {
|
||||||
|
"error": "Erro",
|
||||||
|
"help": "Ajuda",
|
||||||
|
"info": "Informação",
|
||||||
|
"success": "Sucesso",
|
||||||
|
"warning": "Aviso"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"router": {
|
"router": {
|
||||||
"back-to-home": "Voltar ao início"
|
"back-to-home": "Voltar ao início"
|
||||||
@ -191,7 +198,7 @@
|
|||||||
"auto-collapse": "Recolher automaticamente"
|
"auto-collapse": "Recolher automaticamente"
|
||||||
},
|
},
|
||||||
"storage-section": {
|
"storage-section": {
|
||||||
"current-storage": "Armazenamento atual",
|
"current-storage": "Armazenamento de objetos atual",
|
||||||
"type-database": "Banco de dados",
|
"type-database": "Banco de dados",
|
||||||
"type-local": "Local",
|
"type-local": "Local",
|
||||||
"storage-services-list": "Lista de serviços de armazenamento",
|
"storage-services-list": "Lista de serviços de armazenamento",
|
||||||
@ -216,7 +223,7 @@
|
|||||||
"bucket-placeholder": "Nome do bucket",
|
"bucket-placeholder": "Nome do bucket",
|
||||||
"path": "Caminho do armazenamento",
|
"path": "Caminho do armazenamento",
|
||||||
"path-description": "Você pode usar as mesmas variáveis dinâmicas do armazenamento local, como {filename}",
|
"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": "Prefixo da URL",
|
||||||
"url-prefix-placeholder": "Prefixo personalizado da URL, opcional",
|
"url-prefix-placeholder": "Prefixo personalizado da URL, opcional",
|
||||||
"url-suffix": "Sufixo da URL",
|
"url-suffix": "Sufixo da URL",
|
||||||
@ -243,6 +250,8 @@
|
|||||||
"allow-user-signup": "Permitir registro de usuário",
|
"allow-user-signup": "Permitir registro de usuário",
|
||||||
"ignore-version-upgrade": "Ignorar atualização de versão",
|
"ignore-version-upgrade": "Ignorar atualização de versão",
|
||||||
"disable-public-memos": "Desabilitar memos públicos",
|
"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-style": "Estilo adicional",
|
||||||
"additional-script": "Script adicional",
|
"additional-script": "Script adicional",
|
||||||
"additional-style-placeholder": "Código CSS adicional",
|
"additional-style-placeholder": "Código CSS adicional",
|
||||||
@ -349,7 +358,9 @@
|
|||||||
"succeed-update-customized-profile": "Perfil personalizado com êxito.",
|
"succeed-update-customized-profile": "Perfil personalizado com êxito.",
|
||||||
"succeed-update-additional-script": "Script adicional atualizado com êxito.",
|
"succeed-update-additional-script": "Script adicional atualizado com êxito.",
|
||||||
"update-succeed": "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": {
|
"days": {
|
||||||
"mon": "Seg",
|
"mon": "Seg",
|
||||||
|
@ -13,6 +13,7 @@ export const initialGlobalState = async () => {
|
|||||||
allowSignUp: false,
|
allowSignUp: false,
|
||||||
ignoreUpgrade: false,
|
ignoreUpgrade: false,
|
||||||
disablePublicMemos: false,
|
disablePublicMemos: false,
|
||||||
|
maxUploadSizeMiB: 0,
|
||||||
additionalStyle: "",
|
additionalStyle: "",
|
||||||
additionalScript: "",
|
additionalScript: "",
|
||||||
customizedProfile: {
|
customizedProfile: {
|
||||||
|
@ -2,8 +2,8 @@ import * as api from "@/helpers/api";
|
|||||||
import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts";
|
import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts";
|
||||||
import store, { useAppSelector } from "../";
|
import store, { useAppSelector } from "../";
|
||||||
import { patchResource, setResources, deleteResource, upsertResources } from "../reducer/resource";
|
import { patchResource, setResources, deleteResource, upsertResources } from "../reducer/resource";
|
||||||
|
import { useGlobalStore } from "./global";
|
||||||
const MAX_FILE_SIZE = 32 << 20;
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const convertResponseModelResource = (resource: Resource): Resource => {
|
const convertResponseModelResource = (resource: Resource): Resource => {
|
||||||
return {
|
return {
|
||||||
@ -15,6 +15,9 @@ const convertResponseModelResource = (resource: Resource): Resource => {
|
|||||||
|
|
||||||
export const useResourceStore = () => {
|
export const useResourceStore = () => {
|
||||||
const state = useAppSelector((state) => state.resource);
|
const state = useAppSelector((state) => state.resource);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const globalStore = useGlobalStore();
|
||||||
|
const maxUploadSizeMiB = globalStore.state.systemStatus.maxUploadSizeMiB;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
@ -46,8 +49,8 @@ export const useResourceStore = () => {
|
|||||||
},
|
},
|
||||||
async createResourceWithBlob(file: File): Promise<Resource> {
|
async createResourceWithBlob(file: File): Promise<Resource> {
|
||||||
const { name: filename, size } = file;
|
const { name: filename, size } = file;
|
||||||
if (size > MAX_FILE_SIZE) {
|
if (size > maxUploadSizeMiB * 1024 * 1024) {
|
||||||
return Promise.reject("overload max size: 32MB");
|
return Promise.reject(t("message.maximum-upload-size-is", { size: maxUploadSizeMiB }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -62,8 +65,8 @@ export const useResourceStore = () => {
|
|||||||
let newResourceList: Array<Resource> = [];
|
let newResourceList: Array<Resource> = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const { name: filename, size } = file;
|
const { name: filename, size } = file;
|
||||||
if (size > MAX_FILE_SIZE) {
|
if (size > maxUploadSizeMiB * 1024 * 1024) {
|
||||||
return Promise.reject(`${filename} overload max size: 32MB`);
|
return Promise.reject(t("message.file-exceeds-upload-limit-of", { file: filename, size: maxUploadSizeMiB }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
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;
|
allowSignUp: boolean;
|
||||||
ignoreUpgrade: boolean;
|
ignoreUpgrade: boolean;
|
||||||
disablePublicMemos: boolean;
|
disablePublicMemos: boolean;
|
||||||
|
maxUploadSizeMiB: number;
|
||||||
additionalStyle: string;
|
additionalStyle: string;
|
||||||
additionalScript: string;
|
additionalScript: string;
|
||||||
customizedProfile: CustomizedProfile;
|
customizedProfile: CustomizedProfile;
|
||||||
|
Reference in New Issue
Block a user