feat(spaces): support custom icons and emoji (#6287)
This commit is contained in:
parent
92a95a262f
commit
34dbb83948
41 changed files with 1542 additions and 130 deletions
7
internal/markdown/parser/emoji.go
Normal file
7
internal/markdown/parser/emoji.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package parser
|
||||
|
||||
// IsEmoji reports whether value is one fully qualified Unicode emoji sequence.
|
||||
func IsEmoji(value string) bool {
|
||||
_, ok := emoji17FullyQualified[value]
|
||||
return ok
|
||||
}
|
||||
|
|
@ -157,6 +157,19 @@ message Space {
|
|||
// Output only. The number of accepted members in this space. Pending
|
||||
// invitations are excluded. Zero when this Space is exposed as metadata-only.
|
||||
int32 member_count = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
|
||||
// Optional. Display icon. Omit when updating with the "icon" mask to reset
|
||||
// to the default Space mark.
|
||||
Icon icon = 6 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
message Icon {
|
||||
oneof value {
|
||||
// A Unicode emoji sequence.
|
||||
string emoji = 1;
|
||||
// A canonical Lucide icon name, such as "leaf".
|
||||
string lucide = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SpaceMember is a user's membership and governance role in a space.
|
||||
|
|
|
|||
|
|
@ -87,7 +87,10 @@ type Space struct {
|
|||
CurrentUserRole SpaceMember_Role `protobuf:"varint,4,opt,name=current_user_role,json=currentUserRole,proto3,enum=memos.api.v1.SpaceMember_Role" json:"current_user_role,omitempty"`
|
||||
// Output only. The number of accepted members in this space. Pending
|
||||
// invitations are excluded. Zero when this Space is exposed as metadata-only.
|
||||
MemberCount int32 `protobuf:"varint,5,opt,name=member_count,json=memberCount,proto3" json:"member_count,omitempty"`
|
||||
MemberCount int32 `protobuf:"varint,5,opt,name=member_count,json=memberCount,proto3" json:"member_count,omitempty"`
|
||||
// Optional. Display icon. Omit when updating with the "icon" mask to reset
|
||||
// to the default Space mark.
|
||||
Icon *Space_Icon `protobuf:"bytes,6,opt,name=icon,proto3" json:"icon,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
|
@ -157,6 +160,13 @@ func (x *Space) GetMemberCount() int32 {
|
|||
return 0
|
||||
}
|
||||
|
||||
func (x *Space) GetIcon() *Space_Icon {
|
||||
if x != nil {
|
||||
return x.Icon
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpaceMember is a user's membership and governance role in a space.
|
||||
type SpaceMember struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
|
|
@ -1324,17 +1334,106 @@ func (x *DeleteSpaceMemberRequest) GetName() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
type Space_Icon struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Value:
|
||||
//
|
||||
// *Space_Icon_Emoji
|
||||
// *Space_Icon_Lucide
|
||||
Value isSpace_Icon_Value `protobuf_oneof:"value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Space_Icon) Reset() {
|
||||
*x = Space_Icon{}
|
||||
mi := &file_api_v1_space_service_proto_msgTypes[23]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Space_Icon) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Space_Icon) ProtoMessage() {}
|
||||
|
||||
func (x *Space_Icon) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_v1_space_service_proto_msgTypes[23]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Space_Icon.ProtoReflect.Descriptor instead.
|
||||
func (*Space_Icon) Descriptor() ([]byte, []int) {
|
||||
return file_api_v1_space_service_proto_rawDescGZIP(), []int{0, 0}
|
||||
}
|
||||
|
||||
func (x *Space_Icon) GetValue() isSpace_Icon_Value {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Space_Icon) GetEmoji() string {
|
||||
if x != nil {
|
||||
if x, ok := x.Value.(*Space_Icon_Emoji); ok {
|
||||
return x.Emoji
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Space_Icon) GetLucide() string {
|
||||
if x != nil {
|
||||
if x, ok := x.Value.(*Space_Icon_Lucide); ok {
|
||||
return x.Lucide
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type isSpace_Icon_Value interface {
|
||||
isSpace_Icon_Value()
|
||||
}
|
||||
|
||||
type Space_Icon_Emoji struct {
|
||||
// A Unicode emoji sequence.
|
||||
Emoji string `protobuf:"bytes,1,opt,name=emoji,proto3,oneof"`
|
||||
}
|
||||
|
||||
type Space_Icon_Lucide struct {
|
||||
// A canonical Lucide icon name, such as "leaf".
|
||||
Lucide string `protobuf:"bytes,2,opt,name=lucide,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*Space_Icon_Emoji) isSpace_Icon_Value() {}
|
||||
|
||||
func (*Space_Icon_Lucide) isSpace_Icon_Value() {}
|
||||
|
||||
var File_api_v1_space_service_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_api_v1_space_service_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1aapi/v1/space_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\x99\x02\n" +
|
||||
"\x1aapi/v1/space_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\"\x8f\x03\n" +
|
||||
"\x05Space\x12\x17\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x19\n" +
|
||||
"\x05title\x18\x02 \x01(\tB\x03\xe0A\x02R\x05title\x12%\n" +
|
||||
"\vdescription\x18\x03 \x01(\tB\x03\xe0A\x01R\vdescription\x12O\n" +
|
||||
"\x11current_user_role\x18\x04 \x01(\x0e2\x1e.memos.api.v1.SpaceMember.RoleB\x03\xe0A\x03R\x0fcurrentUserRole\x12&\n" +
|
||||
"\fmember_count\x18\x05 \x01(\x05B\x03\xe0A\x03R\vmemberCount:<\xeaA9\n" +
|
||||
"\fmember_count\x18\x05 \x01(\x05B\x03\xe0A\x03R\vmemberCount\x121\n" +
|
||||
"\x04icon\x18\x06 \x01(\v2\x18.memos.api.v1.Space.IconB\x03\xe0A\x01R\x04icon\x1aA\n" +
|
||||
"\x04Icon\x12\x16\n" +
|
||||
"\x05emoji\x18\x01 \x01(\tH\x00R\x05emoji\x12\x18\n" +
|
||||
"\x06lucide\x18\x02 \x01(\tH\x00R\x06lucideB\a\n" +
|
||||
"\x05value:<\xeaA9\n" +
|
||||
"\x12memos.api.v1/Space\x12\x0espaces/{space}\x1a\x04name*\x06spaces2\x05space\"\xa2\x02\n" +
|
||||
"\vSpaceMember\x12\x17\n" +
|
||||
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12-\n" +
|
||||
|
|
@ -1456,7 +1555,7 @@ func file_api_v1_space_service_proto_rawDescGZIP() []byte {
|
|||
}
|
||||
|
||||
var file_api_v1_space_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_api_v1_space_service_proto_msgTypes = make([]protoimpl.MessageInfo, 23)
|
||||
var file_api_v1_space_service_proto_msgTypes = make([]protoimpl.MessageInfo, 24)
|
||||
var file_api_v1_space_service_proto_goTypes = []any{
|
||||
(SpaceMember_Role)(0), // 0: memos.api.v1.SpaceMember.Role
|
||||
(*Space)(nil), // 1: memos.api.v1.Space
|
||||
|
|
@ -1482,61 +1581,63 @@ var file_api_v1_space_service_proto_goTypes = []any{
|
|||
(*GetSpaceMemberRequest)(nil), // 21: memos.api.v1.GetSpaceMemberRequest
|
||||
(*UpdateSpaceMemberRequest)(nil), // 22: memos.api.v1.UpdateSpaceMemberRequest
|
||||
(*DeleteSpaceMemberRequest)(nil), // 23: memos.api.v1.DeleteSpaceMemberRequest
|
||||
(*fieldmaskpb.FieldMask)(nil), // 24: google.protobuf.FieldMask
|
||||
(*emptypb.Empty)(nil), // 25: google.protobuf.Empty
|
||||
(*Space_Icon)(nil), // 24: memos.api.v1.Space.Icon
|
||||
(*fieldmaskpb.FieldMask)(nil), // 25: google.protobuf.FieldMask
|
||||
(*emptypb.Empty)(nil), // 26: google.protobuf.Empty
|
||||
}
|
||||
var file_api_v1_space_service_proto_depIdxs = []int32{
|
||||
0, // 0: memos.api.v1.Space.current_user_role:type_name -> memos.api.v1.SpaceMember.Role
|
||||
0, // 1: memos.api.v1.SpaceMember.role:type_name -> memos.api.v1.SpaceMember.Role
|
||||
0, // 2: memos.api.v1.SpaceInvitation.role:type_name -> memos.api.v1.SpaceMember.Role
|
||||
1, // 3: memos.api.v1.SpaceInvitation.space:type_name -> memos.api.v1.Space
|
||||
1, // 4: memos.api.v1.CreateSpaceRequest.space:type_name -> memos.api.v1.Space
|
||||
1, // 5: memos.api.v1.ListSpacesResponse.spaces:type_name -> memos.api.v1.Space
|
||||
1, // 6: memos.api.v1.UpdateSpaceRequest.space:type_name -> memos.api.v1.Space
|
||||
24, // 7: memos.api.v1.UpdateSpaceRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
3, // 8: memos.api.v1.CreateSpaceInvitationRequest.space_invitation:type_name -> memos.api.v1.SpaceInvitation
|
||||
3, // 9: memos.api.v1.ListSpaceInvitationsResponse.space_invitations:type_name -> memos.api.v1.SpaceInvitation
|
||||
3, // 10: memos.api.v1.ListUserSpaceInvitationsResponse.space_invitations:type_name -> memos.api.v1.SpaceInvitation
|
||||
2, // 11: memos.api.v1.ListSpaceMembersResponse.space_members:type_name -> memos.api.v1.SpaceMember
|
||||
2, // 12: memos.api.v1.UpdateSpaceMemberRequest.space_member:type_name -> memos.api.v1.SpaceMember
|
||||
24, // 13: memos.api.v1.UpdateSpaceMemberRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
4, // 14: memos.api.v1.SpaceService.CreateSpace:input_type -> memos.api.v1.CreateSpaceRequest
|
||||
5, // 15: memos.api.v1.SpaceService.ListSpaces:input_type -> memos.api.v1.ListSpacesRequest
|
||||
7, // 16: memos.api.v1.SpaceService.GetSpace:input_type -> memos.api.v1.GetSpaceRequest
|
||||
8, // 17: memos.api.v1.SpaceService.UpdateSpace:input_type -> memos.api.v1.UpdateSpaceRequest
|
||||
9, // 18: memos.api.v1.SpaceService.DeleteSpace:input_type -> memos.api.v1.DeleteSpaceRequest
|
||||
10, // 19: memos.api.v1.SpaceService.CreateSpaceInvitation:input_type -> memos.api.v1.CreateSpaceInvitationRequest
|
||||
11, // 20: memos.api.v1.SpaceService.ListSpaceInvitations:input_type -> memos.api.v1.ListSpaceInvitationsRequest
|
||||
13, // 21: memos.api.v1.SpaceService.ListUserSpaceInvitations:input_type -> memos.api.v1.ListUserSpaceInvitationsRequest
|
||||
15, // 22: memos.api.v1.SpaceService.GetSpaceInvitation:input_type -> memos.api.v1.GetSpaceInvitationRequest
|
||||
16, // 23: memos.api.v1.SpaceService.DeleteSpaceInvitation:input_type -> memos.api.v1.DeleteSpaceInvitationRequest
|
||||
17, // 24: memos.api.v1.SpaceService.AcceptSpaceInvitation:input_type -> memos.api.v1.AcceptSpaceInvitationRequest
|
||||
18, // 25: memos.api.v1.SpaceService.DeclineSpaceInvitation:input_type -> memos.api.v1.DeclineSpaceInvitationRequest
|
||||
19, // 26: memos.api.v1.SpaceService.ListSpaceMembers:input_type -> memos.api.v1.ListSpaceMembersRequest
|
||||
21, // 27: memos.api.v1.SpaceService.GetSpaceMember:input_type -> memos.api.v1.GetSpaceMemberRequest
|
||||
22, // 28: memos.api.v1.SpaceService.UpdateSpaceMember:input_type -> memos.api.v1.UpdateSpaceMemberRequest
|
||||
23, // 29: memos.api.v1.SpaceService.DeleteSpaceMember:input_type -> memos.api.v1.DeleteSpaceMemberRequest
|
||||
1, // 30: memos.api.v1.SpaceService.CreateSpace:output_type -> memos.api.v1.Space
|
||||
6, // 31: memos.api.v1.SpaceService.ListSpaces:output_type -> memos.api.v1.ListSpacesResponse
|
||||
1, // 32: memos.api.v1.SpaceService.GetSpace:output_type -> memos.api.v1.Space
|
||||
1, // 33: memos.api.v1.SpaceService.UpdateSpace:output_type -> memos.api.v1.Space
|
||||
25, // 34: memos.api.v1.SpaceService.DeleteSpace:output_type -> google.protobuf.Empty
|
||||
3, // 35: memos.api.v1.SpaceService.CreateSpaceInvitation:output_type -> memos.api.v1.SpaceInvitation
|
||||
12, // 36: memos.api.v1.SpaceService.ListSpaceInvitations:output_type -> memos.api.v1.ListSpaceInvitationsResponse
|
||||
14, // 37: memos.api.v1.SpaceService.ListUserSpaceInvitations:output_type -> memos.api.v1.ListUserSpaceInvitationsResponse
|
||||
3, // 38: memos.api.v1.SpaceService.GetSpaceInvitation:output_type -> memos.api.v1.SpaceInvitation
|
||||
25, // 39: memos.api.v1.SpaceService.DeleteSpaceInvitation:output_type -> google.protobuf.Empty
|
||||
2, // 40: memos.api.v1.SpaceService.AcceptSpaceInvitation:output_type -> memos.api.v1.SpaceMember
|
||||
25, // 41: memos.api.v1.SpaceService.DeclineSpaceInvitation:output_type -> google.protobuf.Empty
|
||||
20, // 42: memos.api.v1.SpaceService.ListSpaceMembers:output_type -> memos.api.v1.ListSpaceMembersResponse
|
||||
2, // 43: memos.api.v1.SpaceService.GetSpaceMember:output_type -> memos.api.v1.SpaceMember
|
||||
2, // 44: memos.api.v1.SpaceService.UpdateSpaceMember:output_type -> memos.api.v1.SpaceMember
|
||||
25, // 45: memos.api.v1.SpaceService.DeleteSpaceMember:output_type -> google.protobuf.Empty
|
||||
30, // [30:46] is the sub-list for method output_type
|
||||
14, // [14:30] is the sub-list for method input_type
|
||||
14, // [14:14] is the sub-list for extension type_name
|
||||
14, // [14:14] is the sub-list for extension extendee
|
||||
0, // [0:14] is the sub-list for field type_name
|
||||
24, // 1: memos.api.v1.Space.icon:type_name -> memos.api.v1.Space.Icon
|
||||
0, // 2: memos.api.v1.SpaceMember.role:type_name -> memos.api.v1.SpaceMember.Role
|
||||
0, // 3: memos.api.v1.SpaceInvitation.role:type_name -> memos.api.v1.SpaceMember.Role
|
||||
1, // 4: memos.api.v1.SpaceInvitation.space:type_name -> memos.api.v1.Space
|
||||
1, // 5: memos.api.v1.CreateSpaceRequest.space:type_name -> memos.api.v1.Space
|
||||
1, // 6: memos.api.v1.ListSpacesResponse.spaces:type_name -> memos.api.v1.Space
|
||||
1, // 7: memos.api.v1.UpdateSpaceRequest.space:type_name -> memos.api.v1.Space
|
||||
25, // 8: memos.api.v1.UpdateSpaceRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
3, // 9: memos.api.v1.CreateSpaceInvitationRequest.space_invitation:type_name -> memos.api.v1.SpaceInvitation
|
||||
3, // 10: memos.api.v1.ListSpaceInvitationsResponse.space_invitations:type_name -> memos.api.v1.SpaceInvitation
|
||||
3, // 11: memos.api.v1.ListUserSpaceInvitationsResponse.space_invitations:type_name -> memos.api.v1.SpaceInvitation
|
||||
2, // 12: memos.api.v1.ListSpaceMembersResponse.space_members:type_name -> memos.api.v1.SpaceMember
|
||||
2, // 13: memos.api.v1.UpdateSpaceMemberRequest.space_member:type_name -> memos.api.v1.SpaceMember
|
||||
25, // 14: memos.api.v1.UpdateSpaceMemberRequest.update_mask:type_name -> google.protobuf.FieldMask
|
||||
4, // 15: memos.api.v1.SpaceService.CreateSpace:input_type -> memos.api.v1.CreateSpaceRequest
|
||||
5, // 16: memos.api.v1.SpaceService.ListSpaces:input_type -> memos.api.v1.ListSpacesRequest
|
||||
7, // 17: memos.api.v1.SpaceService.GetSpace:input_type -> memos.api.v1.GetSpaceRequest
|
||||
8, // 18: memos.api.v1.SpaceService.UpdateSpace:input_type -> memos.api.v1.UpdateSpaceRequest
|
||||
9, // 19: memos.api.v1.SpaceService.DeleteSpace:input_type -> memos.api.v1.DeleteSpaceRequest
|
||||
10, // 20: memos.api.v1.SpaceService.CreateSpaceInvitation:input_type -> memos.api.v1.CreateSpaceInvitationRequest
|
||||
11, // 21: memos.api.v1.SpaceService.ListSpaceInvitations:input_type -> memos.api.v1.ListSpaceInvitationsRequest
|
||||
13, // 22: memos.api.v1.SpaceService.ListUserSpaceInvitations:input_type -> memos.api.v1.ListUserSpaceInvitationsRequest
|
||||
15, // 23: memos.api.v1.SpaceService.GetSpaceInvitation:input_type -> memos.api.v1.GetSpaceInvitationRequest
|
||||
16, // 24: memos.api.v1.SpaceService.DeleteSpaceInvitation:input_type -> memos.api.v1.DeleteSpaceInvitationRequest
|
||||
17, // 25: memos.api.v1.SpaceService.AcceptSpaceInvitation:input_type -> memos.api.v1.AcceptSpaceInvitationRequest
|
||||
18, // 26: memos.api.v1.SpaceService.DeclineSpaceInvitation:input_type -> memos.api.v1.DeclineSpaceInvitationRequest
|
||||
19, // 27: memos.api.v1.SpaceService.ListSpaceMembers:input_type -> memos.api.v1.ListSpaceMembersRequest
|
||||
21, // 28: memos.api.v1.SpaceService.GetSpaceMember:input_type -> memos.api.v1.GetSpaceMemberRequest
|
||||
22, // 29: memos.api.v1.SpaceService.UpdateSpaceMember:input_type -> memos.api.v1.UpdateSpaceMemberRequest
|
||||
23, // 30: memos.api.v1.SpaceService.DeleteSpaceMember:input_type -> memos.api.v1.DeleteSpaceMemberRequest
|
||||
1, // 31: memos.api.v1.SpaceService.CreateSpace:output_type -> memos.api.v1.Space
|
||||
6, // 32: memos.api.v1.SpaceService.ListSpaces:output_type -> memos.api.v1.ListSpacesResponse
|
||||
1, // 33: memos.api.v1.SpaceService.GetSpace:output_type -> memos.api.v1.Space
|
||||
1, // 34: memos.api.v1.SpaceService.UpdateSpace:output_type -> memos.api.v1.Space
|
||||
26, // 35: memos.api.v1.SpaceService.DeleteSpace:output_type -> google.protobuf.Empty
|
||||
3, // 36: memos.api.v1.SpaceService.CreateSpaceInvitation:output_type -> memos.api.v1.SpaceInvitation
|
||||
12, // 37: memos.api.v1.SpaceService.ListSpaceInvitations:output_type -> memos.api.v1.ListSpaceInvitationsResponse
|
||||
14, // 38: memos.api.v1.SpaceService.ListUserSpaceInvitations:output_type -> memos.api.v1.ListUserSpaceInvitationsResponse
|
||||
3, // 39: memos.api.v1.SpaceService.GetSpaceInvitation:output_type -> memos.api.v1.SpaceInvitation
|
||||
26, // 40: memos.api.v1.SpaceService.DeleteSpaceInvitation:output_type -> google.protobuf.Empty
|
||||
2, // 41: memos.api.v1.SpaceService.AcceptSpaceInvitation:output_type -> memos.api.v1.SpaceMember
|
||||
26, // 42: memos.api.v1.SpaceService.DeclineSpaceInvitation:output_type -> google.protobuf.Empty
|
||||
20, // 43: memos.api.v1.SpaceService.ListSpaceMembers:output_type -> memos.api.v1.ListSpaceMembersResponse
|
||||
2, // 44: memos.api.v1.SpaceService.GetSpaceMember:output_type -> memos.api.v1.SpaceMember
|
||||
2, // 45: memos.api.v1.SpaceService.UpdateSpaceMember:output_type -> memos.api.v1.SpaceMember
|
||||
26, // 46: memos.api.v1.SpaceService.DeleteSpaceMember:output_type -> google.protobuf.Empty
|
||||
31, // [31:47] is the sub-list for method output_type
|
||||
15, // [15:31] is the sub-list for method input_type
|
||||
15, // [15:15] is the sub-list for extension type_name
|
||||
15, // [15:15] is the sub-list for extension extendee
|
||||
0, // [0:15] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_v1_space_service_proto_init() }
|
||||
|
|
@ -1544,13 +1645,17 @@ func file_api_v1_space_service_proto_init() {
|
|||
if File_api_v1_space_service_proto != nil {
|
||||
return
|
||||
}
|
||||
file_api_v1_space_service_proto_msgTypes[23].OneofWrappers = []any{
|
||||
(*Space_Icon_Emoji)(nil),
|
||||
(*Space_Icon_Lucide)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_space_service_proto_rawDesc), len(file_api_v1_space_service_proto_rawDesc)),
|
||||
NumEnums: 1,
|
||||
NumMessages: 23,
|
||||
NumMessages: 24,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4399,6 +4399,12 @@ components:
|
|||
Output only. The number of accepted members in this space. Pending
|
||||
invitations are excluded. Zero when this Space is exposed as metadata-only.
|
||||
format: int32
|
||||
icon:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Space_Icon'
|
||||
description: |-
|
||||
Optional. Display icon. Omit when updating with the "icon" mask to reset
|
||||
to the default Space mark.
|
||||
description: Space is a collaboration boundary for placed memos.
|
||||
SpaceInvitation:
|
||||
required:
|
||||
|
|
@ -4449,6 +4455,15 @@ components:
|
|||
description: Required. The member's role within this space.
|
||||
format: enum
|
||||
description: SpaceMember is a user's membership and governance role in a space.
|
||||
Space_Icon:
|
||||
type: object
|
||||
properties:
|
||||
emoji:
|
||||
type: string
|
||||
description: A Unicode emoji sequence.
|
||||
lucide:
|
||||
type: string
|
||||
description: A canonical Lucide icon name, such as "leaf".
|
||||
Status:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
219
proto/gen/store/space.pb.go
Normal file
219
proto/gen/store/space.pb.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.12
|
||||
// protoc (unknown)
|
||||
// source: store/space.proto
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
// SpacePayload stores optional Space display metadata.
|
||||
type SpacePayload struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Icon *SpacePayload_Icon `protobuf:"bytes,1,opt,name=icon,proto3" json:"icon,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SpacePayload) Reset() {
|
||||
*x = SpacePayload{}
|
||||
mi := &file_store_space_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SpacePayload) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SpacePayload) ProtoMessage() {}
|
||||
|
||||
func (x *SpacePayload) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_store_space_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SpacePayload.ProtoReflect.Descriptor instead.
|
||||
func (*SpacePayload) Descriptor() ([]byte, []int) {
|
||||
return file_store_space_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *SpacePayload) GetIcon() *SpacePayload_Icon {
|
||||
if x != nil {
|
||||
return x.Icon
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpacePayload_Icon struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Value:
|
||||
//
|
||||
// *SpacePayload_Icon_Emoji
|
||||
// *SpacePayload_Icon_Lucide
|
||||
Value isSpacePayload_Icon_Value `protobuf_oneof:"value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SpacePayload_Icon) Reset() {
|
||||
*x = SpacePayload_Icon{}
|
||||
mi := &file_store_space_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SpacePayload_Icon) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SpacePayload_Icon) ProtoMessage() {}
|
||||
|
||||
func (x *SpacePayload_Icon) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_store_space_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SpacePayload_Icon.ProtoReflect.Descriptor instead.
|
||||
func (*SpacePayload_Icon) Descriptor() ([]byte, []int) {
|
||||
return file_store_space_proto_rawDescGZIP(), []int{0, 0}
|
||||
}
|
||||
|
||||
func (x *SpacePayload_Icon) GetValue() isSpacePayload_Icon_Value {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SpacePayload_Icon) GetEmoji() string {
|
||||
if x != nil {
|
||||
if x, ok := x.Value.(*SpacePayload_Icon_Emoji); ok {
|
||||
return x.Emoji
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SpacePayload_Icon) GetLucide() string {
|
||||
if x != nil {
|
||||
if x, ok := x.Value.(*SpacePayload_Icon_Lucide); ok {
|
||||
return x.Lucide
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type isSpacePayload_Icon_Value interface {
|
||||
isSpacePayload_Icon_Value()
|
||||
}
|
||||
|
||||
type SpacePayload_Icon_Emoji struct {
|
||||
// A Unicode emoji sequence.
|
||||
Emoji string `protobuf:"bytes,1,opt,name=emoji,proto3,oneof"`
|
||||
}
|
||||
|
||||
type SpacePayload_Icon_Lucide struct {
|
||||
// A canonical Lucide icon name, such as "leaf".
|
||||
Lucide string `protobuf:"bytes,2,opt,name=lucide,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*SpacePayload_Icon_Emoji) isSpacePayload_Icon_Value() {}
|
||||
|
||||
func (*SpacePayload_Icon_Lucide) isSpacePayload_Icon_Value() {}
|
||||
|
||||
var File_store_space_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_store_space_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x11store/space.proto\x12\vmemos.store\"\x85\x01\n" +
|
||||
"\fSpacePayload\x122\n" +
|
||||
"\x04icon\x18\x01 \x01(\v2\x1e.memos.store.SpacePayload.IconR\x04icon\x1aA\n" +
|
||||
"\x04Icon\x12\x16\n" +
|
||||
"\x05emoji\x18\x01 \x01(\tH\x00R\x05emoji\x12\x18\n" +
|
||||
"\x06lucide\x18\x02 \x01(\tH\x00R\x06lucideB\a\n" +
|
||||
"\x05valueB\x95\x01\n" +
|
||||
"\x0fcom.memos.storeB\n" +
|
||||
"SpaceProtoP\x01Z)github.com/usememos/memos/proto/gen/store\xa2\x02\x03MSX\xaa\x02\vMemos.Store\xca\x02\vMemos\\Store\xe2\x02\x17Memos\\Store\\GPBMetadata\xea\x02\fMemos::Storeb\x06proto3"
|
||||
|
||||
var (
|
||||
file_store_space_proto_rawDescOnce sync.Once
|
||||
file_store_space_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_store_space_proto_rawDescGZIP() []byte {
|
||||
file_store_space_proto_rawDescOnce.Do(func() {
|
||||
file_store_space_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_store_space_proto_rawDesc), len(file_store_space_proto_rawDesc)))
|
||||
})
|
||||
return file_store_space_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_store_space_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
|
||||
var file_store_space_proto_goTypes = []any{
|
||||
(*SpacePayload)(nil), // 0: memos.store.SpacePayload
|
||||
(*SpacePayload_Icon)(nil), // 1: memos.store.SpacePayload.Icon
|
||||
}
|
||||
var file_store_space_proto_depIdxs = []int32{
|
||||
1, // 0: memos.store.SpacePayload.icon:type_name -> memos.store.SpacePayload.Icon
|
||||
1, // [1:1] is the sub-list for method output_type
|
||||
1, // [1:1] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_store_space_proto_init() }
|
||||
func file_store_space_proto_init() {
|
||||
if File_store_space_proto != nil {
|
||||
return
|
||||
}
|
||||
file_store_space_proto_msgTypes[1].OneofWrappers = []any{
|
||||
(*SpacePayload_Icon_Emoji)(nil),
|
||||
(*SpacePayload_Icon_Lucide)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_store_space_proto_rawDesc), len(file_store_space_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 2,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
GoTypes: file_store_space_proto_goTypes,
|
||||
DependencyIndexes: file_store_space_proto_depIdxs,
|
||||
MessageInfos: file_store_space_proto_msgTypes,
|
||||
}.Build()
|
||||
File_store_space_proto = out.File
|
||||
file_store_space_proto_goTypes = nil
|
||||
file_store_space_proto_depIdxs = nil
|
||||
}
|
||||
19
proto/store/space.proto
Normal file
19
proto/store/space.proto
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package memos.store;
|
||||
|
||||
option go_package = "gen/store";
|
||||
|
||||
// SpacePayload stores optional Space display metadata.
|
||||
message SpacePayload {
|
||||
Icon icon = 1;
|
||||
|
||||
message Icon {
|
||||
oneof value {
|
||||
// A Unicode emoji sequence.
|
||||
string emoji = 1;
|
||||
// A canonical Lucide icon name, such as "leaf".
|
||||
string lucide = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
89
server/router/api/v1/space_icon_test.go
Normal file
89
server/router/api/v1/space_icon_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/fieldmaskpb"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func TestSpaceIconLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newIntegrationService(t)
|
||||
owner := createSpaceTestUser(ctx, t, service, "icon-owner", store.RoleUser)
|
||||
member := createSpaceTestUser(ctx, t, service, "icon-member", store.RoleUser)
|
||||
ownerCtx := userCtx(ctx, owner.ID)
|
||||
emoji := &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Emoji{Emoji: "🌱"}}
|
||||
space, err := service.CreateSpace(ownerCtx, &v1pb.CreateSpaceRequest{
|
||||
SpaceId: "icon-space", Space: &v1pb.Space{Title: "Garden", Icon: emoji},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, proto.Equal(emoji, space.Icon))
|
||||
|
||||
invitation := inviteSpaceTestUser(ctx, t, service, owner, member, space, v1pb.SpaceMember_USER)
|
||||
require.True(t, proto.Equal(emoji, invitation.Space.Icon), "invitation metadata includes the icon")
|
||||
_, err = service.AcceptSpaceInvitation(userCtx(ctx, member.ID), &v1pb.AcceptSpaceInvitationRequest{Name: invitation.Name})
|
||||
require.NoError(t, err)
|
||||
|
||||
space, err = service.UpdateSpace(ownerCtx, &v1pb.UpdateSpaceRequest{
|
||||
Space: &v1pb.Space{Name: space.Name, Title: "Renamed"}, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"title"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, proto.Equal(emoji, space.Icon), "unmasked icon remains unchanged")
|
||||
lucide := &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Lucide{Lucide: "leaf"}}
|
||||
update := &v1pb.UpdateSpaceRequest{
|
||||
Space: &v1pb.Space{Name: space.Name, Icon: lucide}, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"icon"}},
|
||||
}
|
||||
_, err = service.UpdateSpace(userCtx(ctx, member.ID), update)
|
||||
require.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
space, err = service.UpdateSpace(ownerCtx, update)
|
||||
require.NoError(t, err)
|
||||
require.True(t, proto.Equal(lucide, space.Icon))
|
||||
require.Equal(t, "Renamed", space.Title)
|
||||
fetched, err := service.GetSpace(ownerCtx, &v1pb.GetSpaceRequest{Name: space.Name})
|
||||
require.NoError(t, err)
|
||||
require.True(t, proto.Equal(lucide, fetched.Icon))
|
||||
listed, err := service.ListSpaces(ownerCtx, &v1pb.ListSpacesRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, listed.Spaces, 1)
|
||||
require.True(t, proto.Equal(lucide, listed.Spaces[0].Icon))
|
||||
|
||||
update.Space.Icon = &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Emoji{Emoji: "not an emoji"}}
|
||||
_, err = service.UpdateSpace(ownerCtx, update)
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
fetched, err = service.GetSpace(ownerCtx, &v1pb.GetSpaceRequest{Name: space.Name})
|
||||
require.NoError(t, err)
|
||||
require.True(t, proto.Equal(lucide, fetched.Icon), "invalid updates do not change the stored icon")
|
||||
|
||||
update.Space.Icon = nil
|
||||
space, err = service.UpdateSpace(ownerCtx, update)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, space.Icon)
|
||||
fetched, err = service.GetSpace(ownerCtx, &v1pb.GetSpaceRequest{Name: space.Name})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, fetched.Icon)
|
||||
}
|
||||
|
||||
func TestCreateSpaceRejectsInvalidIcon(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service := newIntegrationService(t)
|
||||
owner := createSpaceTestUser(ctx, t, service, "invalid-icon-owner", store.RoleUser)
|
||||
for _, icon := range []*v1pb.Space_Icon{
|
||||
{},
|
||||
{Value: &v1pb.Space_Icon_Emoji{Emoji: "🌱🌱"}},
|
||||
{Value: &v1pb.Space_Icon_Lucide{Lucide: "<svg>"}},
|
||||
} {
|
||||
_, err := service.CreateSpace(userCtx(ctx, owner.ID), &v1pb.CreateSpaceRequest{Space: &v1pb.Space{Title: "Invalid", Icon: icon}})
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
}
|
||||
spaces, err := service.ListSpaces(userCtx(ctx, owner.ID), &v1pb.ListSpacesRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, spaces.Spaces)
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@ import (
|
|||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
|
|
@ -98,10 +100,15 @@ func (s *APIV1Service) CreateSpace(ctx context.Context, request *v1pb.CreateSpac
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
icon := convertSpaceIconToStore(request.Space.Icon)
|
||||
if err := store.ValidateSpaceIcon(icon); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
created, err := s.Store.CreateSpace(ctx, &store.Space{
|
||||
UID: uid,
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(request.Space.Description),
|
||||
Payload: &storepb.SpacePayload{Icon: icon},
|
||||
}, currentUser.ID)
|
||||
if err != nil {
|
||||
return nil, mapSpaceMutationError(err, "failed to create space")
|
||||
|
|
@ -201,6 +208,16 @@ func (s *APIV1Service) UpdateSpace(ctx context.Context, request *v1pb.UpdateSpac
|
|||
case "description":
|
||||
description := strings.TrimSpace(request.Space.Description)
|
||||
update.Description = &description
|
||||
case "icon":
|
||||
icon := convertSpaceIconToStore(request.Space.Icon)
|
||||
if err := store.ValidateSpaceIcon(icon); err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
update.Payload = &storepb.SpacePayload{}
|
||||
if space.Payload != nil {
|
||||
update.Payload = proto.CloneOf(space.Payload)
|
||||
}
|
||||
update.Payload.Icon = icon
|
||||
default:
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unsupported update mask path: %s", path)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package v1
|
|||
|
||||
import (
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
|
|
@ -35,6 +36,36 @@ func convertSpaceMetadataFromStore(space *store.Space) *v1pb.Space {
|
|||
Name: buildSpaceName(space.UID),
|
||||
Title: space.Title,
|
||||
Description: space.Description,
|
||||
Icon: convertSpaceIconFromStore(space.Payload.GetIcon()),
|
||||
}
|
||||
}
|
||||
|
||||
func convertSpaceIconFromStore(icon *storepb.SpacePayload_Icon) *v1pb.Space_Icon {
|
||||
if icon == nil {
|
||||
return nil
|
||||
}
|
||||
switch value := icon.Value.(type) {
|
||||
case *storepb.SpacePayload_Icon_Emoji:
|
||||
return &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Emoji{Emoji: value.Emoji}}
|
||||
case *storepb.SpacePayload_Icon_Lucide:
|
||||
return &v1pb.Space_Icon{Value: &v1pb.Space_Icon_Lucide{Lucide: value.Lucide}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func convertSpaceIconToStore(icon *v1pb.Space_Icon) *storepb.SpacePayload_Icon {
|
||||
if icon == nil {
|
||||
return nil
|
||||
}
|
||||
switch value := icon.Value.(type) {
|
||||
case *v1pb.Space_Icon_Emoji:
|
||||
return &storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Emoji{Emoji: value.Emoji}}
|
||||
case *v1pb.Space_Icon_Lucide:
|
||||
return &storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: value.Lucide}}
|
||||
default:
|
||||
// Preserve an empty icon so validation rejects it instead of treating it as a reset.
|
||||
return &storepb.SpacePayload_Icon{}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import (
|
|||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
|
|
@ -23,9 +25,17 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int
|
|||
if err := lockMySQLActiveUser(ctx, tx, creatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := []string{"uid", "title", "description"}
|
||||
values := []string{"?", "?", "?"}
|
||||
args := []any{create.UID, create.Title, create.Description}
|
||||
payload := "{}"
|
||||
if create.Payload != nil {
|
||||
data, err := protojson.Marshal(create.Payload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal space payload")
|
||||
}
|
||||
payload = string(data)
|
||||
}
|
||||
fields := []string{"uid", "title", "description", "payload"}
|
||||
values := []string{"?", "?", "?", "?"}
|
||||
args := []any{create.UID, create.Title, create.Description, payload}
|
||||
result, err := tx.ExecContext(ctx, "INSERT INTO space ("+strings.Join(fields, ", ")+") VALUES ("+strings.Join(values, ", ")+")", args...)
|
||||
if err != nil {
|
||||
if isMySQLUniqueViolation(err) {
|
||||
|
|
@ -55,7 +65,7 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int
|
|||
|
||||
func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Space, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
selectFields := "space.id, space.uid, space.title, space.description"
|
||||
selectFields := "space.id, space.uid, space.title, space.description, space.payload"
|
||||
joins := ""
|
||||
groupBy := ""
|
||||
if find.ID != nil {
|
||||
|
|
@ -119,6 +129,13 @@ func (d *DB) UpdateSpace(ctx context.Context, update *store.UpdateSpace, actorUs
|
|||
if update.Description != nil {
|
||||
sets, args = append(sets, "description = ?"), append(args, *update.Description)
|
||||
}
|
||||
if update.Payload != nil {
|
||||
data, err := protojson.Marshal(update.Payload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal space payload")
|
||||
}
|
||||
sets, args = append(sets, "payload = ?"), append(args, string(data))
|
||||
}
|
||||
args = append(args, update.ID)
|
||||
if _, err := tx.ExecContext(ctx, "UPDATE space SET "+strings.Join(sets, ", ")+" WHERE id = ?", args...); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -434,14 +451,20 @@ func (d *DB) DeleteSpaceMember(ctx context.Context, delete *store.DeleteSpaceMem
|
|||
type mysqlRowScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanMySQLSpace(row mysqlRowScanner, space *store.Space) error {
|
||||
return row.Scan(&space.ID, &space.UID, &space.Title, &space.Description)
|
||||
return scanMySQLSpaceWithSummary(row, space, false)
|
||||
}
|
||||
|
||||
func scanMySQLSpaceWithSummary(row mysqlRowScanner, space *store.Space, withSummary bool) error {
|
||||
if !withSummary {
|
||||
return scanMySQLSpace(row, space)
|
||||
var payloadBytes []byte
|
||||
targets := []any{&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes}
|
||||
if withSummary {
|
||||
targets = append(targets, &space.CurrentUserRole, &space.MemberCount)
|
||||
}
|
||||
return row.Scan(&space.ID, &space.UID, &space.Title, &space.Description, &space.CurrentUserRole, &space.MemberCount)
|
||||
if err := row.Scan(targets...); err != nil {
|
||||
return err
|
||||
}
|
||||
space.Payload = &storepb.SpacePayload{}
|
||||
return errors.Wrap(protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload), "failed to unmarshal space payload")
|
||||
}
|
||||
|
||||
func scanMySQLSpaceSummary(row mysqlRowScanner, space *store.Space) error {
|
||||
|
|
@ -462,7 +485,7 @@ func populateMySQLSpaceSummary(ctx context.Context, tx *sql.Tx, space *store.Spa
|
|||
|
||||
func getMySQLSpace(ctx context.Context, tx *sql.Tx, id int32) (*store.Space, error) {
|
||||
space := &store.Space{}
|
||||
return space, scanMySQLSpace(tx.QueryRowContext(ctx, `SELECT id, uid, title, description FROM space WHERE id = ?`, id), space)
|
||||
return space, scanMySQLSpace(tx.QueryRowContext(ctx, `SELECT id, uid, title, description, payload FROM space WHERE id = ?`, id), space)
|
||||
}
|
||||
|
||||
func getMySQLSpaceMember(ctx context.Context, tx *sql.Tx, spaceID, userID int32) (*store.SpaceMember, error) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import (
|
|||
|
||||
"github.com/lib/pq"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
|
|
@ -23,16 +25,29 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int
|
|||
if err := lockPostgresActiveUser(ctx, tx, creatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := []string{"uid", "title", "description"}
|
||||
args := []any{create.UID, create.Title, create.Description}
|
||||
payload := "{}"
|
||||
if create.Payload != nil {
|
||||
data, err := protojson.Marshal(create.Payload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal space payload")
|
||||
}
|
||||
payload = string(data)
|
||||
}
|
||||
fields := []string{"uid", "title", "description", "payload"}
|
||||
args := []any{create.UID, create.Title, create.Description, payload}
|
||||
space := &store.Space{}
|
||||
query := "INSERT INTO space (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, uid, title, description"
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description); err != nil {
|
||||
var payloadBytes []byte
|
||||
query := "INSERT INTO space (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, uid, title, description, payload"
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes); err != nil {
|
||||
if isPostgresUniqueViolation(err) {
|
||||
return nil, store.ErrSpaceAlreadyExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
space.Payload = &storepb.SpacePayload{}
|
||||
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal space payload")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO space_member (space_id, user_id, status, role) VALUES ($1, $2, $3, $4)", space.ID, creatorID, store.SpaceMemberStatusActive, store.SpaceMemberRoleAdmin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -46,7 +61,7 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int
|
|||
|
||||
func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Space, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
selectFields := "space.id, space.uid, space.title, space.description"
|
||||
selectFields := "space.id, space.uid, space.title, space.description, space.payload"
|
||||
joins := ""
|
||||
groupBy := ""
|
||||
add := func(condition string, value any) {
|
||||
|
|
@ -75,7 +90,7 @@ func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Sp
|
|||
JOIN "user" active_user ON active_user.id = active_member.user_id AND active_user.row_status = 'NORMAL'`
|
||||
add("viewer_member.user_id = %s", *find.MemberUserID)
|
||||
where = append(where, "viewer_member.status = 'ACTIVE'", "viewer_member.role IN ('ADMIN', 'USER')", "viewer_user.row_status = 'NORMAL'")
|
||||
groupBy = " GROUP BY space.id, space.uid, space.title, space.description, viewer_member.role"
|
||||
groupBy = " GROUP BY space.id, space.uid, space.title, space.description, space.payload, viewer_member.role"
|
||||
}
|
||||
query := "SELECT " + selectFields + " FROM space" + joins + " WHERE " + strings.Join(where, " AND ") + groupBy + " ORDER BY space.id DESC"
|
||||
query = appendPostgresLimit(query, find.Limit, find.Offset)
|
||||
|
|
@ -87,13 +102,18 @@ func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Sp
|
|||
spaces := []*store.Space{}
|
||||
for rows.Next() {
|
||||
space := &store.Space{}
|
||||
scanTargets := []any{&space.ID, &space.UID, &space.Title, &space.Description}
|
||||
var payloadBytes []byte
|
||||
scanTargets := []any{&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes}
|
||||
if find.MemberUserID != nil {
|
||||
scanTargets = append(scanTargets, &space.CurrentUserRole, &space.MemberCount)
|
||||
}
|
||||
if err := rows.Scan(scanTargets...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
space.Payload = &storepb.SpacePayload{}
|
||||
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal space payload")
|
||||
}
|
||||
spaces = append(spaces, space)
|
||||
}
|
||||
return spaces, rows.Err()
|
||||
|
|
@ -122,12 +142,24 @@ func (d *DB) UpdateSpace(ctx context.Context, update *store.UpdateSpace, actorUs
|
|||
if update.Description != nil {
|
||||
add("description", *update.Description)
|
||||
}
|
||||
if update.Payload != nil {
|
||||
data, err := protojson.Marshal(update.Payload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal space payload")
|
||||
}
|
||||
add("payload", string(data))
|
||||
}
|
||||
args = append(args, update.ID)
|
||||
space := &store.Space{}
|
||||
query := "UPDATE space SET " + strings.Join(sets, ", ") + " WHERE id = " + placeholder(len(args)) + " RETURNING id, uid, title, description"
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description); err != nil {
|
||||
var payloadBytes []byte
|
||||
query := "UPDATE space SET " + strings.Join(sets, ", ") + " WHERE id = " + placeholder(len(args)) + " RETURNING id, uid, title, description, payload"
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
space.Payload = &storepb.SpacePayload{}
|
||||
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal space payload")
|
||||
}
|
||||
if err := populatePostgresSpaceSummary(ctx, tx, space, actorUserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
msqlite "modernc.org/sqlite"
|
||||
sqlite3 "modernc.org/sqlite/lib"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
|
|
@ -23,17 +25,30 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int
|
|||
return nil, err
|
||||
}
|
||||
|
||||
fields := []string{"uid", "title", "description"}
|
||||
values := []string{"?", "?", "?"}
|
||||
args := []any{create.UID, create.Title, create.Description}
|
||||
query := "INSERT INTO space (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(values, ", ") + ") RETURNING id, uid, title, description"
|
||||
payload := "{}"
|
||||
if create.Payload != nil {
|
||||
data, err := protojson.Marshal(create.Payload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal space payload")
|
||||
}
|
||||
payload = string(data)
|
||||
}
|
||||
fields := []string{"uid", "title", "description", "payload"}
|
||||
values := []string{"?", "?", "?", "?"}
|
||||
args := []any{create.UID, create.Title, create.Description, payload}
|
||||
query := "INSERT INTO space (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(values, ", ") + ") RETURNING id, uid, title, description, payload"
|
||||
space := &store.Space{}
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description); err != nil {
|
||||
var payloadBytes []byte
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes); err != nil {
|
||||
if isSQLiteUniqueViolation(err) {
|
||||
return nil, store.ErrSpaceAlreadyExists
|
||||
}
|
||||
return nil, errors.Wrap(err, "failed to create space")
|
||||
}
|
||||
space.Payload = &storepb.SpacePayload{}
|
||||
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal space payload")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO space_member (space_id, user_id, status, role) VALUES (?, ?, ?, ?)", space.ID, creatorID, store.SpaceMemberStatusActive, store.SpaceMemberRoleAdmin); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create initial space admin")
|
||||
}
|
||||
|
|
@ -47,7 +62,7 @@ func (d *DB) CreateSpace(ctx context.Context, create *store.Space, creatorID int
|
|||
|
||||
func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Space, error) {
|
||||
where, args := []string{"1 = 1"}, []any{}
|
||||
selectFields := "space.id, space.uid, space.title, space.description"
|
||||
selectFields := "space.id, space.uid, space.title, space.description, space.payload"
|
||||
joins := ""
|
||||
groupBy := ""
|
||||
if find.ID != nil {
|
||||
|
|
@ -72,7 +87,7 @@ func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Sp
|
|||
JOIN user active_user ON active_user.id = active_member.user_id AND active_user.row_status = 'NORMAL'`
|
||||
where = append(where, "viewer_member.user_id = ?", "viewer_member.status = 'ACTIVE'", "viewer_member.role IN ('ADMIN', 'USER')", "viewer_user.row_status = 'NORMAL'")
|
||||
args = append(args, *find.MemberUserID)
|
||||
groupBy = " GROUP BY space.id, space.uid, space.title, space.description, viewer_member.role"
|
||||
groupBy = " GROUP BY space.id, space.uid, space.title, space.description, space.payload, viewer_member.role"
|
||||
}
|
||||
query := "SELECT " + selectFields + " FROM space" + joins + " WHERE " + strings.Join(where, " AND ") + groupBy + " ORDER BY space.id DESC"
|
||||
query = appendSQLiteLimit(query, find.Limit, find.Offset)
|
||||
|
|
@ -84,13 +99,18 @@ func (d *DB) ListSpaces(ctx context.Context, find *store.FindSpace) ([]*store.Sp
|
|||
spaces := []*store.Space{}
|
||||
for rows.Next() {
|
||||
space := &store.Space{}
|
||||
scanTargets := []any{&space.ID, &space.UID, &space.Title, &space.Description}
|
||||
var payloadBytes []byte
|
||||
scanTargets := []any{&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes}
|
||||
if find.MemberUserID != nil {
|
||||
scanTargets = append(scanTargets, &space.CurrentUserRole, &space.MemberCount)
|
||||
}
|
||||
if err := rows.Scan(scanTargets...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
space.Payload = &storepb.SpacePayload{}
|
||||
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal space payload")
|
||||
}
|
||||
spaces = append(spaces, space)
|
||||
}
|
||||
return spaces, rows.Err()
|
||||
|
|
@ -112,13 +132,25 @@ func (d *DB) UpdateSpace(ctx context.Context, update *store.UpdateSpace, actorUs
|
|||
if update.Description != nil {
|
||||
sets, args = append(sets, "description = ?"), append(args, *update.Description)
|
||||
}
|
||||
if update.Payload != nil {
|
||||
data, err := protojson.Marshal(update.Payload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal space payload")
|
||||
}
|
||||
sets, args = append(sets, "payload = ?"), append(args, string(data))
|
||||
}
|
||||
args = append(args, update.ID)
|
||||
query := `UPDATE space SET ` + strings.Join(sets, ", ") + ` WHERE id = ?
|
||||
RETURNING id, uid, title, description`
|
||||
RETURNING id, uid, title, description, payload`
|
||||
space := &store.Space{}
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description); err != nil {
|
||||
var payloadBytes []byte
|
||||
if err := tx.QueryRowContext(ctx, query, args...).Scan(&space.ID, &space.UID, &space.Title, &space.Description, &payloadBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
space.Payload = &storepb.SpacePayload{}
|
||||
if err := protojsonUnmarshaler.Unmarshal(payloadBytes, space.Payload); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal space payload")
|
||||
}
|
||||
if err := populateSQLiteSpaceSummary(ctx, tx, space, actorUserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
3
store/migration/mysql/0.31/06__space_payload.sql
Normal file
3
store/migration/mysql/0.31/06__space_payload.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE space ADD COLUMN payload JSON;
|
||||
UPDATE space SET payload = '{}';
|
||||
ALTER TABLE space MODIFY COLUMN payload JSON NOT NULL;
|
||||
|
|
@ -33,7 +33,8 @@ CREATE TABLE `space` (
|
|||
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
`uid` VARCHAR(256) NOT NULL UNIQUE,
|
||||
`title` TEXT NOT NULL,
|
||||
`description` TEXT NOT NULL
|
||||
`description` TEXT NOT NULL,
|
||||
`payload` JSON NOT NULL
|
||||
);
|
||||
|
||||
-- space membership
|
||||
|
|
|
|||
1
store/migration/postgres/0.31/06__space_payload.sql
Normal file
1
store/migration/postgres/0.31/06__space_payload.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE space ADD COLUMN payload JSONB NOT NULL DEFAULT '{}';
|
||||
|
|
@ -33,7 +33,8 @@ CREATE TABLE space (
|
|||
id SERIAL PRIMARY KEY,
|
||||
uid TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
payload JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- space membership
|
||||
|
|
|
|||
1
store/migration/sqlite/0.31/06__space_payload.sql
Normal file
1
store/migration/sqlite/0.31/06__space_payload.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE space ADD COLUMN payload TEXT NOT NULL DEFAULT '{}';
|
||||
|
|
@ -34,7 +34,8 @@ CREATE TABLE space (
|
|||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
payload TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- space membership
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/usememos/memos/internal/base"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
)
|
||||
|
||||
// ErrLastSpaceAdmin indicates that a membership mutation would leave an active
|
||||
|
|
@ -71,6 +72,7 @@ type Space struct {
|
|||
UID string
|
||||
Title string
|
||||
Description string
|
||||
Payload *storepb.SpacePayload
|
||||
CurrentUserRole SpaceMemberRole
|
||||
MemberCount int32
|
||||
}
|
||||
|
|
@ -92,6 +94,7 @@ type UpdateSpace struct {
|
|||
ID int32
|
||||
Title *string
|
||||
Description *string
|
||||
Payload *storepb.SpacePayload
|
||||
}
|
||||
|
||||
// DeleteSpace identifies a Space to hard-delete.
|
||||
|
|
@ -181,6 +184,9 @@ func (s *Store) CreateSpace(ctx context.Context, create *Space, creatorID int32)
|
|||
if strings.TrimSpace(create.Title) == "" {
|
||||
return nil, errors.New("space title is required")
|
||||
}
|
||||
if err := ValidateSpaceIcon(create.Payload.GetIcon()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.driver.CreateSpace(ctx, create, creatorID)
|
||||
}
|
||||
|
||||
|
|
@ -209,7 +215,10 @@ func (s *Store) UpdateSpace(ctx context.Context, update *UpdateSpace, actorUserI
|
|||
if update.Title != nil && strings.TrimSpace(*update.Title) == "" {
|
||||
return nil, errors.New("space title is required")
|
||||
}
|
||||
if update.Title == nil && update.Description == nil {
|
||||
if err := ValidateSpaceIcon(update.Payload.GetIcon()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if update.Title == nil && update.Description == nil && update.Payload == nil {
|
||||
return nil, errors.New("space update requires at least one field")
|
||||
}
|
||||
return s.driver.UpdateSpace(ctx, update, actorUserID)
|
||||
|
|
|
|||
41
store/space_icon.go
Normal file
41
store/space_icon.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/usememos/memos/internal/markdown/parser"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
)
|
||||
|
||||
// ValidateSpaceIcon validates display metadata without depending on a client's
|
||||
// installed Lucide catalog. A nil icon requests the default Space mark.
|
||||
func ValidateSpaceIcon(icon *storepb.SpacePayload_Icon) error {
|
||||
if icon == nil {
|
||||
return nil
|
||||
}
|
||||
switch value := icon.Value.(type) {
|
||||
case *storepb.SpacePayload_Icon_Emoji:
|
||||
if !parser.IsEmoji(value.Emoji) {
|
||||
return errors.New("space icon must contain one fully qualified emoji")
|
||||
}
|
||||
case *storepb.SpacePayload_Icon_Lucide:
|
||||
if len(value.Lucide) == 0 || len(value.Lucide) > 128 {
|
||||
return errors.New("space icon name must contain 1 to 128 bytes")
|
||||
}
|
||||
for _, part := range strings.Split(value.Lucide, "-") {
|
||||
if part == "" {
|
||||
return errors.New("space icon name must use lowercase kebab-case")
|
||||
}
|
||||
for _, char := range part {
|
||||
if (char < 'a' || char > 'z') && (char < '0' || char > '9') {
|
||||
return errors.New("space icon name must use lowercase kebab-case")
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return errors.New("space icon must specify emoji or lucide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
store/space_icon_test.go
Normal file
27
store/space_icon_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
)
|
||||
|
||||
func TestValidateSpaceIcon(t *testing.T) {
|
||||
require.NoError(t, ValidateSpaceIcon(nil))
|
||||
require.Error(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{}))
|
||||
for _, emoji := range []string{"🌱", "👩🏽🌾", "🇸🇬", "❤️", "1️⃣"} {
|
||||
require.NoError(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Emoji{Emoji: emoji}}), emoji)
|
||||
}
|
||||
for _, emoji := range []string{"", "leaf", "🌱🌱", " 🌱", "🏻", "\xff"} {
|
||||
require.Error(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Emoji{Emoji: emoji}}), emoji)
|
||||
}
|
||||
for _, name := range []string{"leaf", "book-open", "house-plus", "circle-1", "future-icon"} {
|
||||
require.NoError(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: name}}), name)
|
||||
}
|
||||
for _, name := range []string{"", "LeafIcon", "book_open", "-leaf", "leaf-", "book--open", "<svg>", "🌱", strings.Repeat("a", 129)} {
|
||||
require.Error(t, ValidateSpaceIcon(&storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: name}}), name)
|
||||
}
|
||||
}
|
||||
|
|
@ -62,11 +62,11 @@ func TestFreshInstall(t *testing.T) {
|
|||
// The fresh schema supports memo-local Space placement without adding a
|
||||
// canonical thread shape. COMMENT remains an ordinary relation row.
|
||||
driver := getDriverFromEnv()
|
||||
insertSpace := "INSERT INTO space (id, uid, title, description) VALUES (?, ?, ?, ?)"
|
||||
insertSpace := "INSERT INTO space (id, uid, title, description, payload) VALUES (?, ?, ?, ?, '{}')"
|
||||
insertMemo := "INSERT INTO memo (id, uid, creator_id, content, visibility, payload, space_id) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
insertRelation := "INSERT INTO memo_relation (memo_id, related_memo_id, type) VALUES (?, ?, ?)"
|
||||
if driver == "postgres" {
|
||||
insertSpace = "INSERT INTO space (id, uid, title, description) VALUES ($1, $2, $3, $4)"
|
||||
insertSpace = "INSERT INTO space (id, uid, title, description, payload) VALUES ($1, $2, $3, $4, '{}')"
|
||||
insertMemo = "INSERT INTO memo (id, uid, creator_id, content, visibility, payload, space_id) VALUES ($1, $2, $3, $4, $5, $6, $7)"
|
||||
insertRelation = "INSERT INTO memo_relation (memo_id, related_memo_id, type) VALUES ($1, $2, $3)"
|
||||
}
|
||||
|
|
@ -307,6 +307,8 @@ func TestMigrationSpaceMemberStatusBackfillsActive(t *testing.T) {
|
|||
db := ts.GetDriver().GetDB()
|
||||
_, err = db.ExecContext(ctx, "ALTER TABLE space_member DROP COLUMN status")
|
||||
require.NoError(t, err)
|
||||
_, err = db.ExecContext(ctx, "ALTER TABLE space DROP COLUMN payload")
|
||||
require.NoError(t, err)
|
||||
|
||||
basicSetting, err := ts.GetInstanceBasicSetting(ctx)
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
85
store/test/space_payload_test.go
Normal file
85
store/test/space_payload_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
func TestSpacePayloadRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
defer ts.Close()
|
||||
owner, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
space, err := ts.CreateSpace(ctx, &store.Space{
|
||||
UID: "icon-space", Title: "Garden",
|
||||
Payload: &storepb.SpacePayload{Icon: &storepb.SpacePayload_Icon{
|
||||
Value: &storepb.SpacePayload_Icon_Emoji{Emoji: "👩🏽🌾"},
|
||||
}},
|
||||
}, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "👩🏽🌾", space.Payload.GetIcon().GetEmoji())
|
||||
|
||||
for _, find := range []*store.FindSpace{{ID: &space.ID}, {MemberUserID: &owner.ID}} {
|
||||
spaces, err := ts.ListSpaces(ctx, find)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, spaces, 1)
|
||||
require.Equal(t, "👩🏽🌾", spaces[0].Payload.GetIcon().GetEmoji())
|
||||
}
|
||||
title := "Renamed garden"
|
||||
space, err = ts.UpdateSpace(ctx, &store.UpdateSpace{ID: space.ID, Title: &title}, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "👩🏽🌾", space.Payload.GetIcon().GetEmoji())
|
||||
|
||||
space, err = ts.UpdateSpace(ctx, &store.UpdateSpace{ID: space.ID, Payload: &storepb.SpacePayload{
|
||||
Icon: &storepb.SpacePayload_Icon{Value: &storepb.SpacePayload_Icon_Lucide{Lucide: "leaf"}},
|
||||
}}, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "leaf", space.Payload.GetIcon().GetLucide())
|
||||
require.Equal(t, title, space.Title)
|
||||
var raw string
|
||||
require.NoError(t, ts.GetDriver().GetDB().QueryRowContext(ctx, "SELECT payload FROM space WHERE uid = 'icon-space'").Scan(&raw))
|
||||
require.JSONEq(t, `{"icon":{"lucide":"leaf"}}`, raw)
|
||||
|
||||
space, err = ts.UpdateSpace(ctx, &store.UpdateSpace{ID: space.ID, Payload: &storepb.SpacePayload{}}, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, space.Payload.GetIcon())
|
||||
require.NoError(t, ts.GetDriver().GetDB().QueryRowContext(ctx, "SELECT payload FROM space WHERE uid = 'icon-space'").Scan(&raw))
|
||||
require.JSONEq(t, `{}`, raw)
|
||||
}
|
||||
|
||||
func TestMigrationSpacePayloadBackfillsDefault(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
defer ts.Close()
|
||||
owner, err := createTestingHostUser(ctx, ts)
|
||||
require.NoError(t, err)
|
||||
space, err := ts.CreateSpace(ctx, &store.Space{UID: "legacy-space", Title: "Legacy"}, owner.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, space.Payload)
|
||||
require.Nil(t, space.Payload.Icon)
|
||||
_, err = ts.GetDriver().GetDB().ExecContext(ctx, "ALTER TABLE space DROP COLUMN payload")
|
||||
require.NoError(t, err)
|
||||
setting, err := ts.GetInstanceBasicSetting(ctx)
|
||||
require.NoError(t, err)
|
||||
setting.SchemaVersion = "0.31.6"
|
||||
_, err = ts.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{
|
||||
Key: storepb.InstanceSettingKey_BASIC,
|
||||
Value: &storepb.InstanceSetting_BasicSetting{BasicSetting: setting},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ts.Migrate(ctx))
|
||||
space, err = ts.GetSpace(ctx, &store.FindSpace{ID: &space.ID})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Legacy", space.Title)
|
||||
require.NotNil(t, space.Payload)
|
||||
require.Nil(t, space.Payload.Icon)
|
||||
var raw string
|
||||
require.NoError(t, ts.GetDriver().GetDB().QueryRowContext(ctx, "SELECT payload FROM space WHERE uid = 'legacy-space'").Scan(&raw))
|
||||
require.JSONEq(t, `{}`, raw)
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
|
|||
<span className={cn("flex min-w-0 items-center overflow-hidden", size === "header" ? "gap-2" : "gap-1.5")}>
|
||||
{selectedSpaceName ? (
|
||||
<>
|
||||
<SpaceMark size={spaceMarkSize} />
|
||||
<SpaceMark icon={selectedSpace?.icon} size={spaceMarkSize} />
|
||||
<span data-sidebar-label className="flex min-w-0 flex-1 flex-col justify-center overflow-hidden">
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -160,7 +160,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
|
|||
to={getSpaceSwitchPath(location, space.name)}
|
||||
ariaLabel={showUid && uid ? `${space.title} (${uid})` : space.title}
|
||||
>
|
||||
<SpaceMark size="sm" />
|
||||
<SpaceMark icon={space.icon} size="sm" />
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block max-w-full truncate font-medium">{space.title}</span>
|
||||
{showUid && uid ? (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ChevronDownIcon } from "lucide-react";
|
|||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import SpaceIconPicker from "@/components/SpaceIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -12,7 +13,7 @@ import useCurrentUser from "@/hooks/useCurrentUser";
|
|||
import { useCreateSpace } from "@/hooks/useSpaceQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Space } from "@/types/proto/api/v1/space_service_pb";
|
||||
import type { Space, Space_Icon } from "@/types/proto/api/v1/space_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const SPACE_UID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,34}[a-zA-Z0-9])?$/;
|
||||
|
|
@ -30,6 +31,7 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) {
|
|||
const createSpace = useCreateSpace(currentUserName);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState<Space_Icon>();
|
||||
const [spaceUid, setSpaceUid] = useState(() => uuidv4());
|
||||
const [showCustomId, setShowCustomId] = useState(false);
|
||||
const [spaceUidConflict, setSpaceUidConflict] = useState(false);
|
||||
|
|
@ -40,6 +42,7 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) {
|
|||
if (!open) {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setIcon(undefined);
|
||||
setSpaceUid(uuidv4());
|
||||
setShowCustomId(false);
|
||||
setSpaceUidConflict(false);
|
||||
|
|
@ -60,6 +63,7 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) {
|
|||
title: trimmedTitle,
|
||||
description: description.trim() || undefined,
|
||||
spaceId: spaceUid,
|
||||
...(icon ? { icon } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ConnectError && error.code === Code.AlreadyExists) {
|
||||
|
|
@ -92,14 +96,17 @@ function CreateSpaceDialog({ open, onOpenChange, onCreated, note }: Props) {
|
|||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="space-title">{t("common.name")}</Label>
|
||||
<Input
|
||||
id="space-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={t("space.name-placeholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<SpaceIconPicker value={icon} onChange={setIcon} disabled={createSpace.isPending} />
|
||||
<Input
|
||||
id="space-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder={t("space.name-placeholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="space-description">{t("common.description")}</Label>
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ function SpaceInvitationMessage({ notification }: Props) {
|
|||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<SpaceMark size="lg" />
|
||||
<SpaceMark icon={space.icon} size="lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-foreground/95">{space.title}</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AstroidIcon } from "lucide-react";
|
||||
import SpaceIcon from "@/components/SpaceIcon";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||
import { extractSpaceUidFromName, formatSpaceUidForDisplay } from "@/lib/space-display";
|
||||
|
|
@ -34,7 +34,7 @@ const MemoSpaceBadge = ({ spaceName }: MemoSpaceBadgeProps) => {
|
|||
showUid && uid ? "max-w-52 sm:max-w-64" : "max-w-36 sm:max-w-48",
|
||||
)}
|
||||
>
|
||||
<AstroidIcon aria-hidden="true" className="size-3 shrink-0" strokeWidth={1.8} />
|
||||
<SpaceIcon icon={knownSpace?.icon} className="size-3 text-xs" />
|
||||
<span className="flex min-w-0 items-baseline overflow-hidden">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{knownTitle && <span className="sr-only">{spaceLabel}: </span>}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import ConfirmDialog from "@/components/ConfirmDialog";
|
|||
import CreateSpaceDialog from "@/components/CreateSpaceDialog";
|
||||
import InviteSpaceMemberDialog from "@/components/Settings/InviteSpaceMemberDialog";
|
||||
import SettingSection from "@/components/Settings/SettingSection";
|
||||
import SpaceIconPicker from "@/components/SpaceIconPicker";
|
||||
import SpaceMark from "@/components/SpaceMark";
|
||||
import SpaceRoleBadge from "@/components/SpaceRoleBadge";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
|
|
@ -44,6 +45,7 @@ import { useUsersByUsernames } from "@/hooks/useUserQueries";
|
|||
import { handleError } from "@/lib/error";
|
||||
import { extractUsernameFromName } from "@/lib/resource-names";
|
||||
import { extractSpaceUidFromName } from "@/lib/space-display";
|
||||
import { spaceIconsEqual } from "@/lib/space-icons";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { type Space, type SpaceInvitation, type SpaceMember, SpaceMember_Role } from "@/types/proto/api/v1/space_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
|
@ -189,7 +191,7 @@ const SpacesSection = () => {
|
|||
return (
|
||||
<div key={invitation.name} className="flex min-w-0 flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<SpaceMark size="lg" />
|
||||
<SpaceMark icon={invitation.space?.icon} size="lg" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{title}</p>
|
||||
|
|
@ -279,7 +281,7 @@ const SpacesSection = () => {
|
|||
onClick={() => handleOpenSpace(space.name)}
|
||||
className="group flex w-full min-w-0 items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/45"
|
||||
>
|
||||
<SpaceMark size="lg" />
|
||||
<SpaceMark icon={space.icon} size="lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{space.title}</p>
|
||||
|
|
@ -349,6 +351,7 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
|
|||
const [tab, setTab] = useState<DetailTab>("general");
|
||||
const [title, setTitle] = useState(space.title);
|
||||
const [description, setDescription] = useState(space.description);
|
||||
const [icon, setIcon] = useState(space.icon);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [leaveOpen, setLeaveOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
|
@ -367,22 +370,24 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
|
|||
{ value: String(SpaceMember_Role.USER), label: t("setting.spaces.space-user") },
|
||||
{ value: String(SpaceMember_Role.ADMIN), label: t("setting.spaces.space-admin") },
|
||||
];
|
||||
const detailsChanged = title.trim() !== space.title || description.trim() !== space.description;
|
||||
const iconChanged = !spaceIconsEqual(icon, space.icon);
|
||||
const detailsChanged = title.trim() !== space.title || description.trim() !== space.description || iconChanged;
|
||||
const spaceUid = extractSpaceUidFromName(space.name);
|
||||
const disambiguatedSpaceTitle = `${space.title} (${spaceUid})`;
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(space.title);
|
||||
setDescription(space.description);
|
||||
}, [space.description, space.title]);
|
||||
setIcon(space.icon);
|
||||
}, [space.description, space.title, space.icon]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!trimmedTitle || !isAdmin || updateSpace.isPending) return;
|
||||
try {
|
||||
await updateSpace.mutateAsync({
|
||||
space: { name: space.name, title: trimmedTitle, description: description.trim() },
|
||||
updateMask: ["title", "description"],
|
||||
space: { name: space.name, title: trimmedTitle, description: description.trim(), ...(iconChanged ? { icon } : {}) },
|
||||
updateMask: ["title", "description", ...(iconChanged ? ["icon"] : [])],
|
||||
});
|
||||
toast.success(t("setting.spaces.save-success"));
|
||||
} catch (error) {
|
||||
|
|
@ -459,7 +464,7 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
|
|||
</button>
|
||||
|
||||
<header className="flex min-w-0 flex-col gap-3 border-b border-border/70 pb-4 sm:flex-row sm:items-center">
|
||||
<SpaceMark size="xl" />
|
||||
<SpaceMark icon={space.icon} size="xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-lg font-semibold tracking-tight">{space.title}</h3>
|
||||
|
|
@ -503,13 +508,20 @@ const SpaceDetail = ({ space, viewerName, onBack }: SpaceDetailProps) => {
|
|||
</div>
|
||||
<div className="grid gap-2 border-b border-border px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-center">
|
||||
<Label htmlFor="space-settings-title">{t("common.name")}</Label>
|
||||
<Input
|
||||
id="space-settings-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
readOnly={!isAdmin}
|
||||
className={!isAdmin ? "bg-muted/25" : undefined}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin ? (
|
||||
<SpaceIconPicker value={icon} onChange={setIcon} disabled={updateSpace.isPending} />
|
||||
) : (
|
||||
<SpaceMark icon={icon} size="lg" className="size-8" />
|
||||
)}
|
||||
<Input
|
||||
id="space-settings-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
readOnly={!isAdmin}
|
||||
className={!isAdmin ? "bg-muted/25" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 px-3 py-3 sm:grid-cols-[170px_1fr] sm:items-start">
|
||||
<div>
|
||||
|
|
|
|||
22
web/src/components/SpaceIcon.tsx
Normal file
22
web/src/components/SpaceIcon.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { getSpaceSymbol } from "@/lib/space-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb";
|
||||
|
||||
interface Props {
|
||||
icon?: Space_Icon;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SpaceIcon({ icon, className }: Props) {
|
||||
if (icon?.value.case === "emoji" && icon.value.value) {
|
||||
return (
|
||||
<span aria-hidden className={cn("inline-flex shrink-0 items-center justify-center leading-none", className)}>
|
||||
{icon.value.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const IconComponent = getSpaceSymbol(icon?.value.case === "lucide" ? icon.value.value : "astroid");
|
||||
return <IconComponent aria-hidden className={cn("shrink-0", className)} strokeWidth={1.8} />;
|
||||
}
|
||||
|
||||
export default SpaceIcon;
|
||||
205
web/src/components/SpaceIconPicker.tsx
Normal file
205
web/src/components/SpaceIconPicker.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { RotateCcwIcon, SearchIcon } from "lucide-react";
|
||||
import { type KeyboardEvent, useId, useRef, useState } from "react";
|
||||
import SpaceIcon from "@/components/SpaceIcon";
|
||||
import SpaceMark from "@/components/SpaceMark";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SPACE_EMOJI } from "@/lib/space-emoji";
|
||||
import { SPACE_SYMBOLS, spaceIconsEqual } from "@/lib/space-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type Space_Icon, Space_IconSchema } from "@/types/proto/api/v1/space_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { FULLY_QUALIFIED_EMOJI } from "@/utils/tag-unicode-data";
|
||||
|
||||
const emojiSet = new Set(FULLY_QUALIFIED_EMOJI);
|
||||
const symbolLabels: Record<string, string> = {
|
||||
astroid: "Space",
|
||||
"book-open": "Book",
|
||||
"chart-no-axes-combined": "Chart",
|
||||
"check-check": "Checklist",
|
||||
"flask-conical": "Science",
|
||||
"flower-2": "Flower",
|
||||
"graduation-cap": "Education",
|
||||
"map-pin": "Location",
|
||||
"notebook-pen": "Notebook",
|
||||
};
|
||||
const symbols = Object.keys(SPACE_SYMBOLS).map((name) => ({
|
||||
label: symbolLabels[name] ?? name.replaceAll("-", " ").replace(/^./, (char) => char.toUpperCase()),
|
||||
icon: create(Space_IconSchema, { value: { case: "lucide", value: name } }),
|
||||
}));
|
||||
const emojis = SPACE_EMOJI.map(([emoji, label]) => ({ label, icon: create(Space_IconSchema, { value: { case: "emoji", value: emoji } }) }));
|
||||
|
||||
interface Props {
|
||||
value?: Space_Icon;
|
||||
onChange: (icon: Space_Icon | undefined) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SpaceIconPicker({ value, onChange, disabled }: Props) {
|
||||
const t = useTranslate();
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState("icons");
|
||||
const [query, setQuery] = useState("");
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const search = query.trim().toLowerCase();
|
||||
const options = (tab === "icons" ? symbols : emojis).filter(
|
||||
(option) => option.label.toLowerCase().includes(search) || option.icon.value.value?.includes(search),
|
||||
);
|
||||
const customEmoji = query.trim() || (value?.value.case === "emoji" ? value.value.value : "");
|
||||
if (tab === "emoji" && emojiSet.has(customEmoji) && !options.some((option) => option.icon.value.value === customEmoji)) {
|
||||
options.unshift({
|
||||
label: t("space.icon.use-emoji", { emoji: customEmoji }),
|
||||
icon: create(Space_IconSchema, { value: { case: "emoji", value: customEmoji } }),
|
||||
});
|
||||
}
|
||||
|
||||
const select = (icon?: Space_Icon) => {
|
||||
onChange(icon);
|
||||
setOpen(false);
|
||||
};
|
||||
const changeTab = (next: string) => {
|
||||
setTab(next);
|
||||
setQuery("");
|
||||
setFocusedIndex(0);
|
||||
};
|
||||
const moveFocus = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const offsets: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -8, ArrowDown: 8 };
|
||||
let next = index;
|
||||
if (event.key in offsets) next = Math.max(0, Math.min(options.length - 1, index + offsets[event.key]));
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = options.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
setFocusedIndex(next);
|
||||
gridRef.current?.querySelectorAll<HTMLButtonElement>("button")[next]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) {
|
||||
changeTab(value?.value.case === "emoji" ? "emoji" : "icons");
|
||||
}
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 p-0"
|
||||
disabled={disabled}
|
||||
aria-label={t("space.icon.change")}
|
||||
title={t("space.icon.change")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SpaceMark icon={value} size="lg" className="size-full bg-transparent" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[290px] max-w-[calc(100vw-2rem)] p-2"
|
||||
aria-label={t("space.icon.change")}
|
||||
initialFocus={searchRef}
|
||||
>
|
||||
<Tabs value={tab} onValueChange={changeTab}>
|
||||
<TabsList className="mb-2 rounded-md bg-muted/60 p-0.5" aria-label={t("space.icon.kind")}>
|
||||
{(["icons", "emoji"] as const).map((name) => (
|
||||
<TabsTrigger
|
||||
key={name}
|
||||
id={`${id}-${name}`}
|
||||
value={name}
|
||||
aria-controls={`${id}-panel`}
|
||||
tabIndex={tab === name ? 0 : -1}
|
||||
className="flex-1 py-1 text-xs"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
const next = name === "icons" ? "emoji" : "icons";
|
||||
changeTab(next);
|
||||
document.getElementById(`${id}-${next}`)?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(`space.icon.${name}`)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${tab}`}>
|
||||
<div className="relative mb-2">
|
||||
<SearchIcon aria-hidden className="pointer-events-none absolute start-2 top-2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
className="ps-8 text-sm"
|
||||
aria-label={t(tab === "icons" ? "space.icon.search-icons" : "space.icon.search-emoji")}
|
||||
placeholder={t(tab === "icons" ? "space.icon.search-icons" : "space.icon.search-emoji")}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setFocusedIndex(0);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (options[0]) select(options[0].icon);
|
||||
} else if (event.key === "ArrowDown" && options.length) {
|
||||
event.preventDefault();
|
||||
setFocusedIndex(0);
|
||||
gridRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto overscroll-contain" ref={gridRef}>
|
||||
{options.length ? (
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{options.map((option, index) => (
|
||||
<Button
|
||||
key={`${option.icon.value.case}:${option.icon.value.value}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
aria-pressed={spaceIconsEqual(value, option.icon)}
|
||||
tabIndex={index === Math.min(focusedIndex, options.length - 1) ? 0 : -1}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
onKeyDown={(event) => moveFocus(event, index)}
|
||||
onClick={() => select(option.icon)}
|
||||
className={cn("rounded-md", spaceIconsEqual(value, option.icon) && "bg-accent ring-1 ring-inset ring-primary/40")}
|
||||
>
|
||||
<SpaceIcon icon={option.icon} className={tab === "emoji" ? "size-5 text-xl" : "size-4"} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p role="status" className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t("space.icon.no-results")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tab === "emoji" && <p className="px-1 pt-2 text-xs leading-4 text-muted-foreground">{t("space.icon.emoji-help")}</p>}
|
||||
</div>
|
||||
<div className="mt-2 border-t pt-1.5">
|
||||
<Button type="button" variant="quiet" size="sm" className="w-full justify-start" disabled={!value} onClick={() => select()}>
|
||||
<RotateCcwIcon aria-hidden className="size-3.5" />
|
||||
{t("space.icon.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpaceIconPicker;
|
||||
|
|
@ -1,21 +1,23 @@
|
|||
import { AstroidIcon } from "lucide-react";
|
||||
import SpaceIcon from "@/components/SpaceIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb";
|
||||
|
||||
const MARK_SCALE = {
|
||||
xl: { mark: "size-11 rounded-lg", icon: "size-6" },
|
||||
lg: { mark: "size-9 rounded-[7px]", icon: "size-5" },
|
||||
md: { mark: "size-7 rounded-[7px]", icon: "size-4" },
|
||||
xl: { mark: "size-11 rounded-lg", icon: "size-6", emoji: "text-2xl" },
|
||||
lg: { mark: "size-9 rounded-[7px]", icon: "size-5", emoji: "text-xl" },
|
||||
md: { mark: "size-7 rounded-[7px]", icon: "size-4", emoji: "text-base" },
|
||||
/** Primary chrome: kept in step with MemosLogo's header scale. */
|
||||
header: { mark: "size-6 rounded-[6px]", icon: "size-3.5" },
|
||||
sm: { mark: "size-5 rounded-[5px]", icon: "size-3" },
|
||||
header: { mark: "size-6 rounded-[6px]", icon: "size-3.5", emoji: "text-base" },
|
||||
sm: { mark: "size-5 rounded-[5px]", icon: "size-3", emoji: "text-sm" },
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
icon?: Space_Icon;
|
||||
size?: keyof typeof MARK_SCALE;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SpaceMark = ({ size = "md", className }: Props) => {
|
||||
const SpaceMark = ({ icon, size = "md", className }: Props) => {
|
||||
const scale = MARK_SCALE[size];
|
||||
|
||||
return (
|
||||
|
|
@ -23,7 +25,7 @@ const SpaceMark = ({ size = "md", className }: Props) => {
|
|||
aria-hidden
|
||||
className={cn("flex shrink-0 items-center justify-center bg-sidebar-accent text-sidebar-accent-foreground", scale.mark, className)}
|
||||
>
|
||||
<AstroidIcon className={scale.icon} strokeWidth={1.8} />
|
||||
<SpaceIcon icon={icon} className={cn(scale.icon, icon?.value.case === "emoji" && scale.emoji)} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -216,9 +216,9 @@ export function useCreateSpace(viewerName: string) {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ title, description, spaceId }: { title: string; description?: string; spaceId: string }) =>
|
||||
mutationFn: ({ title, description, spaceId, icon }: { title: string; description?: string; spaceId: string; icon?: Space["icon"] }) =>
|
||||
spaceServiceClient.createSpace({
|
||||
space: create(SpaceSchema, { title, description }),
|
||||
space: create(SpaceSchema, { title, description, icon }),
|
||||
spaceId,
|
||||
}),
|
||||
onSuccess: (space) => {
|
||||
|
|
|
|||
67
web/src/lib/space-emoji.ts
Normal file
67
web/src/lib/space-emoji.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// Common Space identities. Any supported Unicode emoji can also be pasted into the picker.
|
||||
export const SPACE_EMOJI = [
|
||||
["🌱", "Seedling plant garden growth"],
|
||||
["📚", "Books reading library"],
|
||||
["💼", "Briefcase work business"],
|
||||
["🏠", "House home family"],
|
||||
["💡", "Light bulb ideas"],
|
||||
["🚀", "Rocket launch project"],
|
||||
["🎨", "Artist palette design art"],
|
||||
["✍️", "Writing hand journal"],
|
||||
["📝", "Memo notes writing"],
|
||||
["🧠", "Brain learning knowledge"],
|
||||
["🎯", "Target goals"],
|
||||
["⭐", "Star favorites"],
|
||||
["❤️", "Heart love"],
|
||||
["🔥", "Fire energy"],
|
||||
["⚡", "Lightning bolt power"],
|
||||
["☀️", "Sun sunshine"],
|
||||
["🌙", "Moon night"],
|
||||
["🌈", "Rainbow colors"],
|
||||
["🌊", "Wave ocean water"],
|
||||
["🍀", "Four leaf clover luck"],
|
||||
["🌸", "Cherry blossom flower"],
|
||||
["🌻", "Sunflower"],
|
||||
["🌳", "Tree forest nature"],
|
||||
["🍄", "Mushroom"],
|
||||
["🐈", "Cat pet"],
|
||||
["🐕", "Dog pet"],
|
||||
["🦊", "Fox"],
|
||||
["🐼", "Panda"],
|
||||
["🦋", "Butterfly"],
|
||||
["🐝", "Bee honey"],
|
||||
["🐙", "Octopus"],
|
||||
["🐳", "Whale"],
|
||||
["☕", "Coffee drink cafe"],
|
||||
["🍵", "Tea cup"],
|
||||
["🍎", "Apple fruit food"],
|
||||
["🍋", "Lemon fruit"],
|
||||
["🥑", "Avocado"],
|
||||
["🍕", "Pizza cooking food"],
|
||||
["🍰", "Cake dessert baking"],
|
||||
["🍷", "Wine glass"],
|
||||
["🎵", "Music note"],
|
||||
["🎸", "Guitar music"],
|
||||
["🎬", "Clapper board movies film"],
|
||||
["📷", "Camera photography"],
|
||||
["🎮", "Game controller gaming"],
|
||||
["🎲", "Dice games"],
|
||||
["⚽", "Soccer football sport"],
|
||||
["🏀", "Basketball sport"],
|
||||
["🚲", "Bicycle cycling"],
|
||||
["🏆", "Trophy achievement"],
|
||||
["✈️", "Airplane travel"],
|
||||
["🏕️", "Camping tent outdoors"],
|
||||
["🏔️", "Mountain hiking"],
|
||||
["🏖️", "Beach holiday"],
|
||||
["🌍", "Earth world globe"],
|
||||
["🧭", "Compass exploration"],
|
||||
["🔬", "Microscope research science"],
|
||||
["🧪", "Test tube experiment"],
|
||||
["💻", "Laptop code technology"],
|
||||
["🛠️", "Tools building"],
|
||||
["🧩", "Puzzle piece"],
|
||||
["💎", "Gem diamond"],
|
||||
["🎓", "Graduation education study"],
|
||||
["🎉", "Party celebration"],
|
||||
] as const;
|
||||
88
web/src/lib/space-icons.ts
Normal file
88
web/src/lib/space-icons.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import {
|
||||
AstroidIcon,
|
||||
BookOpenIcon,
|
||||
BriefcaseIcon,
|
||||
CameraIcon,
|
||||
ChartNoAxesCombinedIcon,
|
||||
CheckCheckIcon,
|
||||
CodeIcon,
|
||||
CoffeeIcon,
|
||||
CompassIcon,
|
||||
EarthIcon,
|
||||
FeatherIcon,
|
||||
FlaskConicalIcon,
|
||||
Flower2Icon,
|
||||
FolderIcon,
|
||||
GemIcon,
|
||||
GraduationCapIcon,
|
||||
HeartIcon,
|
||||
HouseIcon,
|
||||
LeafIcon,
|
||||
LightbulbIcon,
|
||||
MapPinIcon,
|
||||
MountainIcon,
|
||||
MusicIcon,
|
||||
NotebookPenIcon,
|
||||
PaletteIcon,
|
||||
PencilIcon,
|
||||
PlaneIcon,
|
||||
RocketIcon,
|
||||
SproutIcon,
|
||||
StarIcon,
|
||||
SunIcon,
|
||||
TargetIcon,
|
||||
TentIcon,
|
||||
TrophyIcon,
|
||||
UsersIcon,
|
||||
UtensilsIcon,
|
||||
WrenchIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import type { Space_Icon } from "@/types/proto/api/v1/space_service_pb";
|
||||
|
||||
// Keep the catalog explicit so adding a picker does not bundle the entire icon library.
|
||||
export const SPACE_SYMBOLS = {
|
||||
astroid: AstroidIcon,
|
||||
"book-open": BookOpenIcon,
|
||||
briefcase: BriefcaseIcon,
|
||||
camera: CameraIcon,
|
||||
"chart-no-axes-combined": ChartNoAxesCombinedIcon,
|
||||
"check-check": CheckCheckIcon,
|
||||
code: CodeIcon,
|
||||
coffee: CoffeeIcon,
|
||||
compass: CompassIcon,
|
||||
earth: EarthIcon,
|
||||
feather: FeatherIcon,
|
||||
"flask-conical": FlaskConicalIcon,
|
||||
"flower-2": Flower2Icon,
|
||||
folder: FolderIcon,
|
||||
gem: GemIcon,
|
||||
"graduation-cap": GraduationCapIcon,
|
||||
heart: HeartIcon,
|
||||
house: HouseIcon,
|
||||
leaf: LeafIcon,
|
||||
lightbulb: LightbulbIcon,
|
||||
"map-pin": MapPinIcon,
|
||||
mountain: MountainIcon,
|
||||
music: MusicIcon,
|
||||
"notebook-pen": NotebookPenIcon,
|
||||
palette: PaletteIcon,
|
||||
pencil: PencilIcon,
|
||||
plane: PlaneIcon,
|
||||
rocket: RocketIcon,
|
||||
sprout: SproutIcon,
|
||||
star: StarIcon,
|
||||
sun: SunIcon,
|
||||
target: TargetIcon,
|
||||
tent: TentIcon,
|
||||
trophy: TrophyIcon,
|
||||
users: UsersIcon,
|
||||
utensils: UtensilsIcon,
|
||||
wrench: WrenchIcon,
|
||||
zap: ZapIcon,
|
||||
};
|
||||
|
||||
export const getSpaceSymbol = (name: string) =>
|
||||
Object.hasOwn(SPACE_SYMBOLS, name) ? SPACE_SYMBOLS[name as keyof typeof SPACE_SYMBOLS] : AstroidIcon;
|
||||
|
||||
export const spaceIconsEqual = (a?: Space_Icon, b?: Space_Icon) => a?.value.case === b?.value.case && a?.value.value === b?.value.value;
|
||||
|
|
@ -1083,6 +1083,18 @@
|
|||
}
|
||||
},
|
||||
"space": {
|
||||
"icon": {
|
||||
"change": "Sahə ikonunu dəyiş",
|
||||
"kind": "İkon növü",
|
||||
"icons": "İkonlar",
|
||||
"emoji": "Emoji",
|
||||
"search-icons": "İkon axtar…",
|
||||
"search-emoji": "Emoji axtar və ya yapışdır…",
|
||||
"no-results": "Uyğun nəticə yoxdur",
|
||||
"emoji-help": "Emoji seçin və ya öz emojinizi yapışdırın.",
|
||||
"reset": "Standarta qaytar",
|
||||
"use-emoji": "{{emoji}} istifadə et"
|
||||
},
|
||||
"unavailable": "Bu sahə mövcud deyil və ya ona giriş icazəniz yoxdur.",
|
||||
"back-to-memos": "Memos-a qayıt",
|
||||
"create": "Sahə yarat",
|
||||
|
|
|
|||
|
|
@ -1083,6 +1083,18 @@
|
|||
}
|
||||
},
|
||||
"space": {
|
||||
"icon": {
|
||||
"change": "Change space icon",
|
||||
"kind": "Icon type",
|
||||
"icons": "Icons",
|
||||
"emoji": "Emoji",
|
||||
"search-icons": "Search icons…",
|
||||
"search-emoji": "Search or paste emoji…",
|
||||
"no-results": "No matches",
|
||||
"emoji-help": "Choose a common emoji or paste your own.",
|
||||
"reset": "Reset to default",
|
||||
"use-emoji": "Use {{emoji}}"
|
||||
},
|
||||
"unavailable": "This space does not exist or you don’t have access to it.",
|
||||
"back-to-memos": "Back to Memos",
|
||||
"create": "Create space",
|
||||
|
|
|
|||
|
|
@ -968,6 +968,18 @@
|
|||
}
|
||||
},
|
||||
"space": {
|
||||
"icon": {
|
||||
"change": "更改空间图标",
|
||||
"kind": "图标类型",
|
||||
"icons": "图标",
|
||||
"emoji": "表情",
|
||||
"search-icons": "搜索图标…",
|
||||
"search-emoji": "搜索或粘贴表情…",
|
||||
"no-results": "没有匹配项",
|
||||
"emoji-help": "选择常用表情,或粘贴你喜欢的表情。",
|
||||
"reset": "恢复默认",
|
||||
"use-emoji": "使用 {{emoji}}"
|
||||
},
|
||||
"unavailable": "空间不存在或你没有访问权限。",
|
||||
"back-to-memos": "返回 Memos"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -59,6 +59,26 @@ describe("CreateSpaceDialog", () => {
|
|||
expect(mocks.mutateAsync).toHaveBeenCalledWith({ title: "Product", description: "Plans", spaceId: FIRST_SPACE_UID });
|
||||
});
|
||||
|
||||
it("keeps the icon in the draft until creating and clears it when the dialog closes", async () => {
|
||||
const view = render(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
|
||||
fireEvent.change(screen.getByLabelText("common.name"), { target: { value: "Garden" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Leaf" }));
|
||||
expect(mocks.mutateAsync).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.create" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.mutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
icon: expect.objectContaining({ value: { case: "lucide", value: "leaf" } }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
view.rerender(<CreateSpaceDialog open={false} onOpenChange={mocks.onOpenChange} />);
|
||||
view.rerender(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
|
||||
expect(await screen.findByRole("button", { name: "space.icon.reset" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("allows a valid custom Space UID and explains invalid values", async () => {
|
||||
render(<CreateSpaceDialog open onOpenChange={mocks.onOpenChange} />);
|
||||
|
||||
|
|
|
|||
103
web/tests/space-icon-picker.test.tsx
Normal file
103
web/tests/space-icon-picker.test.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import SpaceIcon from "@/components/SpaceIcon";
|
||||
import SpaceIconPicker from "@/components/SpaceIconPicker";
|
||||
import { SPACE_EMOJI } from "@/lib/space-emoji";
|
||||
import { type Space_Icon, Space_IconSchema } from "@/types/proto/api/v1/space_service_pb";
|
||||
import { FULLY_QUALIFIED_EMOJI } from "@/utils/tag-unicode-data";
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
||||
|
||||
const icon = (value: string, kind: "emoji" | "lucide" = "lucide") => create(Space_IconSchema, { value: { case: kind, value } });
|
||||
function Harness({ initial, onChange = vi.fn() }: { initial?: Space_Icon; onChange?: (value?: Space_Icon) => void }) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<SpaceIconPicker
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("SpaceIconPicker", () => {
|
||||
it("searches and selects a symbol with keyboard navigation and restores trigger focus", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness onChange={onChange} />);
|
||||
const trigger = screen.getByRole("button", { name: "space.icon.change" });
|
||||
fireEvent.click(trigger);
|
||||
const search = await screen.findByRole("textbox", { name: "space.icon.search-icons" });
|
||||
fireEvent.change(search, { target: { value: "book" } });
|
||||
fireEvent.keyDown(search, { key: "ArrowDown" });
|
||||
expect(screen.getByRole("button", { name: "Book" })).toHaveFocus();
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Book" }), { key: "ArrowRight" });
|
||||
expect(screen.getByRole("button", { name: "Notebook" })).toHaveFocus();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Notebook" }));
|
||||
expect(onChange).toHaveBeenCalledWith(icon("notebook-pen"));
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it("accepts a pasted joined emoji and can reset it", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "space.icon.emoji" }));
|
||||
const search = screen.getByRole("textbox", { name: "space.icon.search-emoji" });
|
||||
fireEvent.change(search, { target: { value: "👩🏽🌾" } });
|
||||
fireEvent.keyDown(search, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenLastCalledWith(icon("👩🏽🌾", "emoji"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
|
||||
expect(await screen.findByRole("tab", { name: "space.icon.emoji" })).toHaveAttribute("aria-selected", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.reset" }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it("keeps the selection unchanged when dismissed or when Enter completes IME composition", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness initial={icon("leaf")} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
|
||||
const search = await screen.findByRole("textbox", { name: "space.icon.search-icons" });
|
||||
expect(screen.getByRole("button", { name: "Leaf" })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.keyDown(search, { key: "Enter", isComposing: true });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
fireEvent.keyDown(search, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("textbox")).not.toBeInTheDocument());
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows empty search results without accepting invalid emoji text", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Harness onChange={onChange} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "space.icon.emoji" }));
|
||||
const search = screen.getByRole("textbox", { name: "space.icon.search-emoji" });
|
||||
fireEvent.change(search, { target: { value: "🌱🌱" } });
|
||||
expect(screen.getByRole("status")).toHaveTextContent("space.icon.no-results");
|
||||
fireEvent.keyDown(search, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables editing while a form is saving", () => {
|
||||
render(<SpaceIconPicker disabled onChange={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "space.icon.change" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("offers only emoji accepted by the shared Unicode catalog", () => {
|
||||
for (const [emoji] of SPACE_EMOJI) expect(FULLY_QUALIFIED_EMOJI).toContain(emoji);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpaceIcon", () => {
|
||||
it("renders symbols and emoji, with a fallback for unknown names", () => {
|
||||
const view = render(<SpaceIcon icon={icon("leaf")} />);
|
||||
expect(view.container.querySelector(".lucide-leaf")).not.toBeNull();
|
||||
view.rerender(<SpaceIcon icon={icon("🌱", "emoji")} />);
|
||||
expect(view.container).toHaveTextContent("🌱");
|
||||
view.rerender(<SpaceIcon icon={icon("future-symbol")} />);
|
||||
expect(view.container.querySelector(".lucide-astroid")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -255,6 +255,40 @@ describe("SpacesSection", () => {
|
|||
expect(within(joinedSection!).getByTitle("joined-product")).toHaveTextContent("joined-product");
|
||||
});
|
||||
|
||||
it("saves the icon through the icon update mask", async () => {
|
||||
state.spaces = [adminSpace];
|
||||
state.members = [adminMember];
|
||||
renderSection("/setting?space=spaces%2Fproduct#spaces");
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Leaf" }));
|
||||
expect(state.updateSpace).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.save-changes" }));
|
||||
await waitFor(() =>
|
||||
expect(state.updateSpace).toHaveBeenCalledWith({
|
||||
space: expect.objectContaining({
|
||||
name: adminSpace.name,
|
||||
icon: expect.objectContaining({ value: { case: "lucide", value: "leaf" } }),
|
||||
}),
|
||||
updateMask: ["title", "description", "icon"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a saved icon with an explicit update mask", async () => {
|
||||
state.spaces = [{ ...adminSpace, icon: { $typeName: "memos.api.v1.Space.Icon", value: { case: "emoji", value: "🌱" } } }];
|
||||
state.members = [adminMember];
|
||||
renderSection("/setting?space=spaces%2Fproduct#spaces");
|
||||
fireEvent.click(screen.getByRole("button", { name: "space.icon.change" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "space.icon.reset" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.save-changes" }));
|
||||
await waitFor(() =>
|
||||
expect(state.updateSpace).toHaveBeenCalledWith({
|
||||
space: expect.objectContaining({ name: adminSpace.name, icon: undefined }),
|
||||
updateMask: ["title", "description", "icon"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows governance controls to Space admins and cancels the exact pending invitation", async () => {
|
||||
state.spaces = [adminSpace];
|
||||
state.members = [adminMember, ordinaryMember];
|
||||
|
|
@ -301,6 +335,7 @@ describe("SpacesSection", () => {
|
|||
renderSection("/setting?space=spaces%2Fproduct#spaces");
|
||||
|
||||
expect(screen.getByLabelText("common.name")).toHaveAttribute("readonly");
|
||||
expect(screen.queryByRole("button", { name: "space.icon.change" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "setting.spaces.save-changes" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "setting.spaces.delete-space" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "setting.spaces.leave-space" })).toBeInTheDocument();
|
||||
|
|
|
|||
Loading…
Reference in a new issue