pull/5049/merge
Mo xi 2 weeks ago committed by GitHub
commit a5ed2ce3c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -405,6 +405,8 @@ message UserSetting {
// This references a CSS file in the web/public/themes/ directory.
// If not set, the default theme will be used.
string theme = 4 [(google.api.field_behavior) = OPTIONAL];
// The user's map tile layer provider.
string map_tile_layer_provider = 5 [(google.api.field_behavior) = OPTIONAL];
}
// User authentication sessions configuration.

@ -2244,9 +2244,11 @@ type UserSetting_GeneralSetting struct {
// The preferred theme of the user.
// This references a CSS file in the web/public/themes/ directory.
// If not set, the default theme will be used.
Theme string `protobuf:"bytes,4,opt,name=theme,proto3" json:"theme,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
Theme string `protobuf:"bytes,4,opt,name=theme,proto3" json:"theme,omitempty"`
// The user's map tile layer provider.
MapTileLayerProvider string `protobuf:"bytes,5,opt,name=map_tile_layer_provider,json=mapTileLayerProvider,proto3" json:"map_tile_layer_provider,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UserSetting_GeneralSetting) Reset() {
@ -2300,6 +2302,13 @@ func (x *UserSetting_GeneralSetting) GetTheme() string {
return ""
}
func (x *UserSetting_GeneralSetting) GetMapTileLayerProvider() string {
if x != nil {
return x.MapTileLayerProvider
}
return ""
}
// User authentication sessions configuration.
type UserSetting_SessionsSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -2604,17 +2613,18 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x11memos.api.v1/UserR\x04name\"\x19\n" +
"\x17ListAllUserStatsRequest\"I\n" +
"\x18ListAllUserStatsResponse\x12-\n" +
"\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xb3\a\n" +
"\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xf0\a\n" +
"\vUserSetting\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12S\n" +
"\x0fgeneral_setting\x18\x02 \x01(\v2(.memos.api.v1.UserSetting.GeneralSettingH\x00R\x0egeneralSetting\x12V\n" +
"\x10sessions_setting\x18\x03 \x01(\v2).memos.api.v1.UserSetting.SessionsSettingH\x00R\x0fsessionsSetting\x12c\n" +
"\x15access_tokens_setting\x18\x04 \x01(\v2-.memos.api.v1.UserSetting.AccessTokensSettingH\x00R\x13accessTokensSetting\x12V\n" +
"\x10webhooks_setting\x18\x05 \x01(\v2).memos.api.v1.UserSetting.WebhooksSettingH\x00R\x0fwebhooksSetting\x1av\n" +
"\x10webhooks_setting\x18\x05 \x01(\v2).memos.api.v1.UserSetting.WebhooksSettingH\x00R\x0fwebhooksSetting\x1a\xb2\x01\n" +
"\x0eGeneralSetting\x12\x1b\n" +
"\x06locale\x18\x01 \x01(\tB\x03\xe0A\x01R\x06locale\x12,\n" +
"\x0fmemo_visibility\x18\x03 \x01(\tB\x03\xe0A\x01R\x0ememoVisibility\x12\x19\n" +
"\x05theme\x18\x04 \x01(\tB\x03\xe0A\x01R\x05theme\x1aH\n" +
"\x05theme\x18\x04 \x01(\tB\x03\xe0A\x01R\x05theme\x12:\n" +
"\x17map_tile_layer_provider\x18\x05 \x01(\tB\x03\xe0A\x01R\x14mapTileLayerProvider\x1aH\n" +
"\x0fSessionsSetting\x125\n" +
"\bsessions\x18\x01 \x03(\v2\x19.memos.api.v1.UserSessionR\bsessions\x1aY\n" +
"\x13AccessTokensSetting\x12B\n" +

@ -3459,6 +3459,9 @@ components:
theme:
type: string
description: "The preferred theme of the user.\r\n This references a CSS file in the web/public/themes/ directory.\r\n If not set, the default theme will be used."
mapTileLayerProvider:
type: string
description: The user's map tile layer provider.
description: General user settings configuration.
UserSetting_SessionsSetting:
type: object

@ -239,9 +239,11 @@ type GeneralUserSetting struct {
MemoVisibility string `protobuf:"bytes,2,opt,name=memo_visibility,json=memoVisibility,proto3" json:"memo_visibility,omitempty"`
// The user's theme preference.
// This references a CSS file in the web/public/themes/ directory.
Theme string `protobuf:"bytes,3,opt,name=theme,proto3" json:"theme,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
Theme string `protobuf:"bytes,3,opt,name=theme,proto3" json:"theme,omitempty"`
// The user's map tile layer provider.
MapTileLayerProvider string `protobuf:"bytes,4,opt,name=map_tile_layer_provider,json=mapTileLayerProvider,proto3" json:"map_tile_layer_provider,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GeneralUserSetting) Reset() {
@ -295,6 +297,13 @@ func (x *GeneralUserSetting) GetTheme() string {
return ""
}
func (x *GeneralUserSetting) GetMapTileLayerProvider() string {
if x != nil {
return x.MapTileLayerProvider
}
return ""
}
type SessionsUserSetting struct {
state protoimpl.MessageState `protogen:"open.v1"`
Sessions []*SessionsUserSetting_Session `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"`
@ -823,11 +832,12 @@ const file_store_user_setting_proto_rawDesc = "" +
"\rACCESS_TOKENS\x10\x03\x12\r\n" +
"\tSHORTCUTS\x10\x04\x12\f\n" +
"\bWEBHOOKS\x10\x05B\a\n" +
"\x05value\"k\n" +
"\x05value\"\xa2\x01\n" +
"\x12GeneralUserSetting\x12\x16\n" +
"\x06locale\x18\x01 \x01(\tR\x06locale\x12'\n" +
"\x0fmemo_visibility\x18\x02 \x01(\tR\x0ememoVisibility\x12\x14\n" +
"\x05theme\x18\x03 \x01(\tR\x05theme\"\xf3\x03\n" +
"\x05theme\x18\x03 \x01(\tR\x05theme\x125\n" +
"\x17map_tile_layer_provider\x18\x04 \x01(\tR\x14mapTileLayerProvider\"\xf3\x03\n" +
"\x13SessionsUserSetting\x12D\n" +
"\bsessions\x18\x01 \x03(\v2(.memos.store.SessionsUserSetting.SessionR\bsessions\x1a\xfd\x01\n" +
"\aSession\x12\x1d\n" +

@ -41,6 +41,8 @@ message GeneralUserSetting {
// The user's theme preference.
// This references a CSS file in the web/public/themes/ directory.
string theme = 3;
// The user's map tile layer provider.
string map_tile_layer_provider = 4;
}
message SessionsUserSetting {

@ -305,9 +305,10 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
func getDefaultUserGeneralSetting() *v1pb.UserSetting_GeneralSetting {
return &v1pb.UserSetting_GeneralSetting{
Locale: "en",
MemoVisibility: "PRIVATE",
Theme: "",
Locale: "en",
MemoVisibility: "PRIVATE",
Theme: "",
MapTileLayerProvider: "",
}
}
@ -390,9 +391,10 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
}
updatedGeneral := &v1pb.UserSetting_GeneralSetting{
MemoVisibility: generalSetting.GetMemoVisibility(),
Locale: generalSetting.GetLocale(),
Theme: generalSetting.GetTheme(),
MemoVisibility: generalSetting.GetMemoVisibility(),
Locale: generalSetting.GetLocale(),
Theme: generalSetting.GetTheme(),
MapTileLayerProvider: generalSetting.GetMapTileLayerProvider(),
}
// Apply updates for fields specified in the update mask
@ -405,6 +407,8 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
updatedGeneral.Theme = incomingGeneral.Theme
case "locale":
updatedGeneral.Locale = incomingGeneral.Locale
case "mapTileLayerProvider":
updatedGeneral.MapTileLayerProvider = incomingGeneral.MapTileLayerProvider
default:
// Ignore unsupported fields
}
@ -1166,9 +1170,10 @@ func convertUserSettingFromStore(storeSetting *storepb.UserSetting, userID int32
if general := storeSetting.GetGeneral(); general != nil {
setting.Value = &v1pb.UserSetting_GeneralSetting_{
GeneralSetting: &v1pb.UserSetting_GeneralSetting{
Locale: general.Locale,
MemoVisibility: general.MemoVisibility,
Theme: general.Theme,
Locale: general.Locale,
MemoVisibility: general.MemoVisibility,
Theme: general.Theme,
MapTileLayerProvider: general.MapTileLayerProvider,
},
}
} else {
@ -1254,9 +1259,10 @@ func convertUserSettingToStore(apiSetting *v1pb.UserSetting, userID int32, key s
if general := apiSetting.GetGeneralSetting(); general != nil {
storeSetting.Value = &storepb.UserSetting_General{
General: &storepb.GeneralUserSetting{
Locale: general.Locale,
MemoVisibility: general.MemoVisibility,
Theme: general.Theme,
Locale: general.Locale,
MemoVisibility: general.MemoVisibility,
Theme: general.Theme,
MapTileLayerProvider: general.MapTileLayerProvider,
},
}
} else {

@ -3,6 +3,7 @@ import { MapPinIcon } from "lucide-react";
import { useEffect, useState } from "react";
import ReactDOMServer from "react-dom/server";
import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet";
import { userStore } from "@/store";
const markerIcon = new DivIcon({
className: "relative border-none",
@ -48,11 +49,22 @@ interface MapProps {
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
// Create a mapping for common map tile providers. If the user-supplied mapTileLayerProvider is not in the map, use it directly as the tile layer URL.
const generalSetting = userStore.state.userGeneralSetting;
const LeafletMap = (props: MapProps) => {
const position = props.latlng || DEFAULT_CENTER_LAT_LNG;
// Default to OpenStreetMap if not set, otherwise use the set value as the URL
let tileLayerUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const tileLayerProvider = generalSetting?.mapTileLayerProvider;
if (tileLayerProvider && tileLayerProvider.trim() !== "") {
tileLayerUrl = tileLayerProvider;
}
return (
<MapContainer className="w-full h-72" center={position} zoom={13} scrollWheelZoom={false}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<TileLayer url={tileLayerUrl} />
<LocationMarker position={position} readonly={props.readonly} onChange={props.onChange ? props.onChange : () => {}} />
</MapContainer>
);

@ -0,0 +1,213 @@
import { ExternalLinkIcon, Settings2Icon } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslate } from "@/utils/i18n";
interface Props {
value: string;
onValueChange: (value: string) => void;
className?: string;
}
// 内置地图模板配置
const BUILTIN_TEMPLATES = {
openstreetmap: {
name: "OpenStreetMap",
url: "",
wiki: "https://wiki.openstreetmap.org/wiki/Tiles",
requiresToken: false,
},
cartodb: {
name: "CartoDB",
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
wiki: "https://carto.com/help/building-maps/basemap-list/",
requiresToken: false,
},
google: {
name: "Google Maps",
url: "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}&key={your_token}",
wiki: "https://developers.google.com/maps/documentation/tile/overview",
requiresToken: true,
},
apple: {
name: "Apple Maps",
url: "https://c.tile.openstreetmap.org/{z}/{x}/{y}.png",
wiki: "https://developer.apple.com/maps/",
requiresToken: true,
},
bing: {
name: "Bing Maps",
url: "https://ecn.t3.tiles.virtualearth.net/tiles/a{q}.jpeg?g=1&key={your_token}",
wiki: "https://docs.microsoft.com/en-us/bingmaps/rest-services/imagery/get-imagery-metadata",
requiresToken: true,
},
mapbox: {
name: "Mapbox",
url: "https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token={your_token}",
wiki: "https://docs.mapbox.com/api/maps/",
requiresToken: true,
},
};
const MapTileLayerProviderSelect = (props: Props) => {
const { value, onValueChange, className } = props;
const [isCustomDialogOpen, setIsCustomDialogOpen] = useState(false);
const [customUrl, setCustomUrl] = useState("");
const [selectedTemplate, setSelectedTemplate] = useState<string>("");
const inputRef = useRef<HTMLTextAreaElement>(null);
const t = useTranslate();
const handleEditClick = () => {
setIsCustomDialogOpen(true);
};
const handleCustomUrlSubmit = () => {
onValueChange(customUrl.trim());
setIsCustomDialogOpen(false);
setCustomUrl("");
setSelectedTemplate("");
};
const handleTemplateSelect = (templateKey: string) => {
const template = BUILTIN_TEMPLATES[templateKey as keyof typeof BUILTIN_TEMPLATES];
if (template) {
setCustomUrl(template.url);
setSelectedTemplate(templateKey);
}
};
const handleUrlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setCustomUrl(newValue);
// If user manually modifies the URL, clear the template selection
if (selectedTemplate) {
const template = BUILTIN_TEMPLATES[selectedTemplate as keyof typeof BUILTIN_TEMPLATES];
if (template && newValue !== template.url) {
setSelectedTemplate("");
}
}
};
// Auto-resize input based on content
useEffect(() => {
if (inputRef.current) {
const input = inputRef.current;
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
}
}, [customUrl]);
const getDisplayName = () => {
if (value === "") return "OpenStreetMap";
if (Object.keys(BUILTIN_TEMPLATES).includes(value)) {
const template = BUILTIN_TEMPLATES[value as keyof typeof BUILTIN_TEMPLATES];
return template ? template.name : "Custom";
}
return "Custom";
};
return (
<>
<Button variant="outline" onClick={handleEditClick} className={className || "min-w-fit justify-between"}>
<span>{getDisplayName()}</span>
<Settings2Icon className="h-4 w-4 ml-2" />
</Button>
<Dialog open={isCustomDialogOpen} onOpenChange={setIsCustomDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("setting.preference-section.map-config.title")}</DialogTitle>
<DialogDescription>{t("setting.preference-section.map-config.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-3">
<Label>{t("setting.preference-section.map-config.quick-templates")}</Label>
<div className="grid grid-cols-2 gap-2">
{Object.entries(BUILTIN_TEMPLATES).map(([key, template]) => (
<Button
key={key}
variant={selectedTemplate === key ? "default" : "outline"}
size="sm"
onClick={() => handleTemplateSelect(key)}
className="justify-start text-left h-auto py-3 px-3"
>
<div className="w-full">
<div className="flex items-center justify-between mb-1">
<div className="font-medium">{template.name}</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
window.open(template.wiki, "_blank");
}}
>
<ExternalLinkIcon className="h-3 w-3" />
</Button>
</div>
{template.requiresToken && (
<div className="text-xs text-orange-600 mt-1">{t("setting.preference-section.map-config.requires-api-key")}</div>
)}
</div>
</Button>
))}
</div>
</div>
<div className="space-y-3">
<Label htmlFor="custom-url">{t("setting.preference-section.map-config.tile-server-url")}</Label>
<Textarea
id="custom-url"
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
value={customUrl}
onChange={handleUrlChange}
className="mt-2 min-h-[40px] resize-none"
ref={inputRef}
/>
</div>
<div className="bg-muted p-3 rounded-md">
<h4 className="font-medium mb-2">{t("setting.preference-section.map-config.parameters")}</h4>
<ul className="text-sm space-y-1 text-muted-foreground">
<li>
<code className="bg-background px-1 rounded">{"{z}"}</code> {t("setting.preference-section.map-config.zoom-level")}
</li>
<li>
<code className="bg-background px-1 rounded">{"{x}"}</code> {t("setting.preference-section.map-config.tile-x-coordinate")}
</li>
<li>
<code className="bg-background px-1 rounded">{"{y}"}</code> {t("setting.preference-section.map-config.tile-y-coordinate")}
</li>
<li>
<code className="bg-background px-1 rounded">{"{s}"}</code> {t("setting.preference-section.map-config.subdomain")}
</li>
<li>
<code className="bg-background px-1 rounded">{"{r}"}</code> {t("setting.preference-section.map-config.retina-resolution")}
</li>
<li>
<code className="bg-background px-1 rounded">{"{your_token}"}</code>{" "}
{t("setting.preference-section.map-config.api-token-placeholder")}
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCustomDialogOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleCustomUrlSubmit}>{t("setting.preference-section.map-config.apply")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default MapTileLayerProviderSelect;

@ -7,6 +7,7 @@ import { UserSetting_GeneralSetting } from "@/types/proto/api/v1/user_service";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
import LocaleSelect from "../LocaleSelect";
import MapTileLayerProviderSelect from "../MapTileLayerProviderSelect";
import ThemeSelect from "../ThemeSelect";
import VisibilityIcon from "../VisibilityIcon";
import WebhookSection from "./WebhookSection";
@ -27,11 +28,16 @@ const PreferencesSection = observer(() => {
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
};
const handleMapApiProviderChange = async (mapApiProvider: string) => {
await userStore.updateUserGeneralSetting({ mapTileLayerProvider: mapApiProvider }, ["mapTileLayerProvider"]);
};
// Provide default values if setting is not loaded yet
const setting: UserSetting_GeneralSetting = generalSetting || {
locale: "en",
memoVisibility: "PRIVATE",
theme: "default",
mapTileLayerProvider: "",
};
return (
@ -71,6 +77,11 @@ const PreferencesSection = observer(() => {
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center mt-2">
<span className="truncate">{t("setting.preference-section.map-tile-layer-provider")}</span>
<MapTileLayerProviderSelect value={setting.mapTileLayerProvider} onValueChange={handleMapApiProviderChange} />
</div>
<Separator className="my-3" />
<WebhookSection />

@ -307,7 +307,24 @@
"preference-section": {
"default-memo-sort-option": "Memo display time",
"default-memo-visibility": "Default memo visibility",
"theme": "Theme"
"theme": "Theme",
"map-tile-layer-provider": "Map Tile Layer Provider",
"map-tile-layer-provider-custom-url": "Custom Map Tile Layer Provider URL",
"map-config": {
"title": "Map Tile Server Configuration",
"description": "Choose a built-in template or enter a custom URL template. Use Leaflet placeholders like {z}, {x}, {y} for zoom level and tile coordinates. Replace <your_token> with your actual API key when needed.",
"quick-templates": "Quick Templates",
"tile-server-url": "Tile Server URL Template",
"parameters": "Leaflet URL Template Parameters:",
"zoom-level": "Zoom level (0-18)",
"tile-x-coordinate": "Tile X coordinate",
"tile-y-coordinate": "Tile Y coordinate",
"subdomain": "Subdomain (a, b, c) for load balancing",
"retina-resolution": "Retina resolution (@2x)",
"api-token-placeholder": "API token/key placeholder (edit directly in URL)",
"requires-api-key": "Requires API Key",
"apply": "Apply"
}
},
"sso": "SSO",
"sso-section": {

@ -306,7 +306,24 @@
"preference-section": {
"default-memo-sort-option": "备忘录显示时间",
"default-memo-visibility": "默认备忘录可见性",
"theme": "主题"
"theme": "主题",
"map-tile-layer-provider": "地图图层提供商",
"map-tile-layer-provider-custom-url": "自定义地图图层提供商URL",
"map-config": {
"title": "地图图层服务器配置",
"description": "选择内置模板或输入自定义URL模板。使用Leaflet占位符如 {z}、{x}、{y} 表示缩放级别和图块坐标。需要时请将 <your_token> 替换为您的实际API密钥。",
"quick-templates": "快速模板",
"tile-server-url": "图层服务器URL模板",
"parameters": "Leaflet URL模板参数",
"zoom-level": "缩放级别 (0-18)",
"tile-x-coordinate": "图块X坐标",
"tile-y-coordinate": "图块Y坐标",
"subdomain": "子域名 (a, b, c) 用于负载均衡",
"retina-resolution": "视网膜分辨率 (@2x)",
"api-token-placeholder": "API令牌/密钥占位符直接在URL中编辑",
"requires-api-key": "需要API密钥",
"apply": "应用"
}
},
"sso": "单点登录",
"sso-section": {

@ -327,6 +327,8 @@ export interface UserSetting_GeneralSetting {
* If not set, the default theme will be used.
*/
theme: string;
/** The user's map tile layer provider. */
mapTileLayerProvider: string;
}
/** User authentication sessions configuration. */
@ -1718,7 +1720,7 @@ export const UserSetting: MessageFns<UserSetting> = {
};
function createBaseUserSetting_GeneralSetting(): UserSetting_GeneralSetting {
return { locale: "", memoVisibility: "", theme: "" };
return { locale: "", memoVisibility: "", theme: "", mapTileLayerProvider: "" };
}
export const UserSetting_GeneralSetting: MessageFns<UserSetting_GeneralSetting> = {
@ -1732,6 +1734,9 @@ export const UserSetting_GeneralSetting: MessageFns<UserSetting_GeneralSetting>
if (message.theme !== "") {
writer.uint32(34).string(message.theme);
}
if (message.mapTileLayerProvider !== "") {
writer.uint32(42).string(message.mapTileLayerProvider);
}
return writer;
},
@ -1766,6 +1771,14 @@ export const UserSetting_GeneralSetting: MessageFns<UserSetting_GeneralSetting>
message.theme = reader.string();
continue;
}
case 5: {
if (tag !== 42) {
break;
}
message.mapTileLayerProvider = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@ -1783,6 +1796,7 @@ export const UserSetting_GeneralSetting: MessageFns<UserSetting_GeneralSetting>
message.locale = object.locale ?? "";
message.memoVisibility = object.memoVisibility ?? "";
message.theme = object.theme ?? "";
message.mapTileLayerProvider = object.mapTileLayerProvider ?? "";
return message;
},
};

Loading…
Cancel
Save