diff --git a/proto/api/v1/user_service.proto b/proto/api/v1/user_service.proto index 2e00601b1..f8b658bce 100644 --- a/proto/api/v1/user_service.proto +++ b/proto/api/v1/user_service.proto @@ -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. diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index 80291da17..4a82a3243 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -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" + diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index ad86bc3a9..ba6dedd4c 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -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 diff --git a/proto/gen/store/user_setting.pb.go b/proto/gen/store/user_setting.pb.go index cda71bd47..5f55c0827 100644 --- a/proto/gen/store/user_setting.pb.go +++ b/proto/gen/store/user_setting.pb.go @@ -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" + diff --git a/proto/store/user_setting.proto b/proto/store/user_setting.proto index 87c8657f4..167ce4e80 100644 --- a/proto/store/user_setting.proto +++ b/proto/store/user_setting.proto @@ -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 { diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index 207427611..d341623cf 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -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 { diff --git a/web/src/components/LeafletMap.tsx b/web/src/components/LeafletMap.tsx index e1facc54c..7fd78ed09 100644 --- a/web/src/components/LeafletMap.tsx +++ b/web/src/components/LeafletMap.tsx @@ -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 ( - + {}} /> ); diff --git a/web/src/components/MapTileLayerProviderSelect.tsx b/web/src/components/MapTileLayerProviderSelect.tsx new file mode 100644 index 000000000..0666ac794 --- /dev/null +++ b/web/src/components/MapTileLayerProviderSelect.tsx @@ -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(""); + const inputRef = useRef(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) => { + 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 ( + <> + + + + + + {t("setting.preference-section.map-config.title")} + {t("setting.preference-section.map-config.description")} + + +
+
+ +
+ {Object.entries(BUILTIN_TEMPLATES).map(([key, template]) => ( + +
+ {template.requiresToken && ( +
{t("setting.preference-section.map-config.requires-api-key")}
+ )} +
+ + ))} +
+ + +
+ +