Commit 243ecf14 authored by Steven's avatar Steven

refactor(api): remove DeleteMemoTag and RenameMemoTag endpoints

BREAKING CHANGE: Removed DeleteMemoTag and RenameMemoTag API endpoints
for better API consistency. Tags should now be managed by updating memo
content directly via UpdateMemo endpoint.

Backend changes:
- Remove RenameMemoTag and DeleteMemoTag RPC methods from proto
- Remove backend implementations in memo_service.go
- Regenerate protocol buffers (Go, TypeScript, OpenAPI)

Frontend changes:
- Remove RenameTagDialog component
- Simplify TagsSection to remove rename/delete functionality
- Improve tag styling with active state highlighting
- Add smooth transitions and better hover interactions
- Polish TagTree component for consistency
- Tags now only support click-to-filter (no inline editing)

Style improvements:
- Active tags highlighted with primary color and font-medium
- Consistent hover states across flat and tree views
- Better spacing and visual hierarchy
- Improved empty state styling

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
parent d794c0bf
...@@ -46,22 +46,6 @@ service MemoService { ...@@ -46,22 +46,6 @@ service MemoService {
option (google.api.http) = {delete: "/api/v1/{name=memos/*}"}; option (google.api.http) = {delete: "/api/v1/{name=memos/*}"};
option (google.api.method_signature) = "name"; option (google.api.method_signature) = "name";
} }
// RenameMemoTag renames a tag for a memo.
rpc RenameMemoTag(RenameMemoTagRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {
patch: "/api/v1/{parent=memos/*}/tags:rename"
body: "*"
};
option (google.api.method_signature) = "parent,old_tag,new_tag";
}
// DeleteMemoTag deletes a tag for a memo.
rpc DeleteMemoTag(DeleteMemoTagRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {
post: "/api/v1/{parent=memos/*}/tags:delete"
body: "*"
};
option (google.api.method_signature) = "parent,tag";
}
// SetMemoAttachments sets attachments for a memo. // SetMemoAttachments sets attachments for a memo.
rpc SetMemoAttachments(SetMemoAttachmentsRequest) returns (google.protobuf.Empty) { rpc SetMemoAttachments(SetMemoAttachmentsRequest) returns (google.protobuf.Empty) {
option (google.api.http) = { option (google.api.http) = {
...@@ -262,12 +246,6 @@ message CreateMemoRequest { ...@@ -262,12 +246,6 @@ message CreateMemoRequest {
// Optional. The memo ID to use for this memo. // Optional. The memo ID to use for this memo.
// If empty, a unique ID will be generated. // If empty, a unique ID will be generated.
string memo_id = 2 [(google.api.field_behavior) = OPTIONAL]; string memo_id = 2 [(google.api.field_behavior) = OPTIONAL];
// Optional. If set, validate the request but don't actually create the memo.
bool validate_only = 3 [(google.api.field_behavior) = OPTIONAL];
// Optional. An idempotency token.
string request_id = 4 [(google.api.field_behavior) = OPTIONAL];
} }
message ListMemosRequest { message ListMemosRequest {
...@@ -308,9 +286,6 @@ message ListMemosResponse { ...@@ -308,9 +286,6 @@ message ListMemosResponse {
// A token that can be sent as `page_token` to retrieve the next page. // A token that can be sent as `page_token` to retrieve the next page.
// If this field is omitted, there are no subsequent pages. // If this field is omitted, there are no subsequent pages.
string next_page_token = 2; string next_page_token = 2;
// The total count of memos (may be approximate).
int32 total_size = 3;
} }
message GetMemoRequest { message GetMemoRequest {
...@@ -320,10 +295,6 @@ message GetMemoRequest { ...@@ -320,10 +295,6 @@ message GetMemoRequest {
(google.api.field_behavior) = REQUIRED, (google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Memo"} (google.api.resource_reference) = {type: "memos.api.v1/Memo"}
]; ];
// Optional. The fields to return in the response.
// If not specified, all fields are returned.
google.protobuf.FieldMask read_mask = 2 [(google.api.field_behavior) = OPTIONAL];
} }
message UpdateMemoRequest { message UpdateMemoRequest {
...@@ -333,9 +304,6 @@ message UpdateMemoRequest { ...@@ -333,9 +304,6 @@ message UpdateMemoRequest {
// Required. The list of fields to update. // Required. The list of fields to update.
google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = REQUIRED]; google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = REQUIRED];
// Optional. If set to true, allows updating sensitive fields.
bool allow_missing = 3 [(google.api.field_behavior) = OPTIONAL];
} }
message DeleteMemoRequest { message DeleteMemoRequest {
...@@ -350,36 +318,6 @@ message DeleteMemoRequest { ...@@ -350,36 +318,6 @@ message DeleteMemoRequest {
bool force = 2 [(google.api.field_behavior) = OPTIONAL]; bool force = 2 [(google.api.field_behavior) = OPTIONAL];
} }
message RenameMemoTagRequest {
// Required. The parent, who owns the tags.
// Format: memos/{memo}. Use "memos/-" to rename all tags.
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Memo"}
];
// Required. The old tag name to rename.
string old_tag = 2 [(google.api.field_behavior) = REQUIRED];
// Required. The new tag name.
string new_tag = 3 [(google.api.field_behavior) = REQUIRED];
}
message DeleteMemoTagRequest {
// Required. The parent, who owns the tags.
// Format: memos/{memo}. Use "memos/-" to delete all tags.
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Memo"}
];
// Required. The tag name to delete.
string tag = 2 [(google.api.field_behavior) = REQUIRED];
// Optional. Whether to delete related memos.
bool delete_related_memos = 3 [(google.api.field_behavior) = OPTIONAL];
}
message SetMemoAttachmentsRequest { message SetMemoAttachmentsRequest {
// Required. The resource name of the memo. // Required. The resource name of the memo.
// Format: memos/{memo} // Format: memos/{memo}
...@@ -413,9 +351,6 @@ message ListMemoAttachmentsResponse { ...@@ -413,9 +351,6 @@ message ListMemoAttachmentsResponse {
// A token for the next page of results. // A token for the next page of results.
string next_page_token = 2; string next_page_token = 2;
// The total count of attachments.
int32 total_size = 3;
} }
message MemoRelation { message MemoRelation {
...@@ -480,9 +415,6 @@ message ListMemoRelationsResponse { ...@@ -480,9 +415,6 @@ message ListMemoRelationsResponse {
// A token for the next page of results. // A token for the next page of results.
string next_page_token = 2; string next_page_token = 2;
// The total count of relations.
int32 total_size = 3;
} }
message CreateMemoCommentRequest { message CreateMemoCommentRequest {
......
This diff is collapsed.
This diff is collapsed.
...@@ -25,8 +25,6 @@ const ( ...@@ -25,8 +25,6 @@ const (
MemoService_GetMemo_FullMethodName = "/memos.api.v1.MemoService/GetMemo" MemoService_GetMemo_FullMethodName = "/memos.api.v1.MemoService/GetMemo"
MemoService_UpdateMemo_FullMethodName = "/memos.api.v1.MemoService/UpdateMemo" MemoService_UpdateMemo_FullMethodName = "/memos.api.v1.MemoService/UpdateMemo"
MemoService_DeleteMemo_FullMethodName = "/memos.api.v1.MemoService/DeleteMemo" MemoService_DeleteMemo_FullMethodName = "/memos.api.v1.MemoService/DeleteMemo"
MemoService_RenameMemoTag_FullMethodName = "/memos.api.v1.MemoService/RenameMemoTag"
MemoService_DeleteMemoTag_FullMethodName = "/memos.api.v1.MemoService/DeleteMemoTag"
MemoService_SetMemoAttachments_FullMethodName = "/memos.api.v1.MemoService/SetMemoAttachments" MemoService_SetMemoAttachments_FullMethodName = "/memos.api.v1.MemoService/SetMemoAttachments"
MemoService_ListMemoAttachments_FullMethodName = "/memos.api.v1.MemoService/ListMemoAttachments" MemoService_ListMemoAttachments_FullMethodName = "/memos.api.v1.MemoService/ListMemoAttachments"
MemoService_SetMemoRelations_FullMethodName = "/memos.api.v1.MemoService/SetMemoRelations" MemoService_SetMemoRelations_FullMethodName = "/memos.api.v1.MemoService/SetMemoRelations"
...@@ -52,10 +50,6 @@ type MemoServiceClient interface { ...@@ -52,10 +50,6 @@ type MemoServiceClient interface {
UpdateMemo(ctx context.Context, in *UpdateMemoRequest, opts ...grpc.CallOption) (*Memo, error) UpdateMemo(ctx context.Context, in *UpdateMemoRequest, opts ...grpc.CallOption) (*Memo, error)
// DeleteMemo deletes a memo. // DeleteMemo deletes a memo.
DeleteMemo(ctx context.Context, in *DeleteMemoRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) DeleteMemo(ctx context.Context, in *DeleteMemoRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// RenameMemoTag renames a tag for a memo.
RenameMemoTag(ctx context.Context, in *RenameMemoTagRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// DeleteMemoTag deletes a tag for a memo.
DeleteMemoTag(ctx context.Context, in *DeleteMemoTagRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// SetMemoAttachments sets attachments for a memo. // SetMemoAttachments sets attachments for a memo.
SetMemoAttachments(ctx context.Context, in *SetMemoAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) SetMemoAttachments(ctx context.Context, in *SetMemoAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// ListMemoAttachments lists attachments for a memo. // ListMemoAttachments lists attachments for a memo.
...@@ -134,26 +128,6 @@ func (c *memoServiceClient) DeleteMemo(ctx context.Context, in *DeleteMemoReques ...@@ -134,26 +128,6 @@ func (c *memoServiceClient) DeleteMemo(ctx context.Context, in *DeleteMemoReques
return out, nil return out, nil
} }
func (c *memoServiceClient) RenameMemoTag(ctx context.Context, in *RenameMemoTagRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, MemoService_RenameMemoTag_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *memoServiceClient) DeleteMemoTag(ctx context.Context, in *DeleteMemoTagRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, MemoService_DeleteMemoTag_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *memoServiceClient) SetMemoAttachments(ctx context.Context, in *SetMemoAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { func (c *memoServiceClient) SetMemoAttachments(ctx context.Context, in *SetMemoAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty) out := new(emptypb.Empty)
...@@ -258,10 +232,6 @@ type MemoServiceServer interface { ...@@ -258,10 +232,6 @@ type MemoServiceServer interface {
UpdateMemo(context.Context, *UpdateMemoRequest) (*Memo, error) UpdateMemo(context.Context, *UpdateMemoRequest) (*Memo, error)
// DeleteMemo deletes a memo. // DeleteMemo deletes a memo.
DeleteMemo(context.Context, *DeleteMemoRequest) (*emptypb.Empty, error) DeleteMemo(context.Context, *DeleteMemoRequest) (*emptypb.Empty, error)
// RenameMemoTag renames a tag for a memo.
RenameMemoTag(context.Context, *RenameMemoTagRequest) (*emptypb.Empty, error)
// DeleteMemoTag deletes a tag for a memo.
DeleteMemoTag(context.Context, *DeleteMemoTagRequest) (*emptypb.Empty, error)
// SetMemoAttachments sets attachments for a memo. // SetMemoAttachments sets attachments for a memo.
SetMemoAttachments(context.Context, *SetMemoAttachmentsRequest) (*emptypb.Empty, error) SetMemoAttachments(context.Context, *SetMemoAttachmentsRequest) (*emptypb.Empty, error)
// ListMemoAttachments lists attachments for a memo. // ListMemoAttachments lists attachments for a memo.
...@@ -305,12 +275,6 @@ func (UnimplementedMemoServiceServer) UpdateMemo(context.Context, *UpdateMemoReq ...@@ -305,12 +275,6 @@ func (UnimplementedMemoServiceServer) UpdateMemo(context.Context, *UpdateMemoReq
func (UnimplementedMemoServiceServer) DeleteMemo(context.Context, *DeleteMemoRequest) (*emptypb.Empty, error) { func (UnimplementedMemoServiceServer) DeleteMemo(context.Context, *DeleteMemoRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteMemo not implemented") return nil, status.Errorf(codes.Unimplemented, "method DeleteMemo not implemented")
} }
func (UnimplementedMemoServiceServer) RenameMemoTag(context.Context, *RenameMemoTagRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method RenameMemoTag not implemented")
}
func (UnimplementedMemoServiceServer) DeleteMemoTag(context.Context, *DeleteMemoTagRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteMemoTag not implemented")
}
func (UnimplementedMemoServiceServer) SetMemoAttachments(context.Context, *SetMemoAttachmentsRequest) (*emptypb.Empty, error) { func (UnimplementedMemoServiceServer) SetMemoAttachments(context.Context, *SetMemoAttachmentsRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetMemoAttachments not implemented") return nil, status.Errorf(codes.Unimplemented, "method SetMemoAttachments not implemented")
} }
...@@ -449,42 +413,6 @@ func _MemoService_DeleteMemo_Handler(srv interface{}, ctx context.Context, dec f ...@@ -449,42 +413,6 @@ func _MemoService_DeleteMemo_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _MemoService_RenameMemoTag_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RenameMemoTagRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MemoServiceServer).RenameMemoTag(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MemoService_RenameMemoTag_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MemoServiceServer).RenameMemoTag(ctx, req.(*RenameMemoTagRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MemoService_DeleteMemoTag_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteMemoTagRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(MemoServiceServer).DeleteMemoTag(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: MemoService_DeleteMemoTag_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(MemoServiceServer).DeleteMemoTag(ctx, req.(*DeleteMemoTagRequest))
}
return interceptor(ctx, in, info, handler)
}
func _MemoService_SetMemoAttachments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _MemoService_SetMemoAttachments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetMemoAttachmentsRequest) in := new(SetMemoAttachmentsRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
...@@ -674,14 +602,6 @@ var MemoService_ServiceDesc = grpc.ServiceDesc{ ...@@ -674,14 +602,6 @@ var MemoService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteMemo", MethodName: "DeleteMemo",
Handler: _MemoService_DeleteMemo_Handler, Handler: _MemoService_DeleteMemo_Handler,
}, },
{
MethodName: "RenameMemoTag",
Handler: _MemoService_RenameMemoTag_Handler,
},
{
MethodName: "DeleteMemoTag",
Handler: _MemoService_DeleteMemoTag_Handler,
},
{ {
MethodName: "SetMemoAttachments", MethodName: "SetMemoAttachments",
Handler: _MemoService_SetMemoAttachments_Handler, Handler: _MemoService_SetMemoAttachments_Handler,
......
...@@ -592,16 +592,6 @@ paths: ...@@ -592,16 +592,6 @@ paths:
If empty, a unique ID will be generated. If empty, a unique ID will be generated.
schema: schema:
type: string type: string
- name: validateOnly
in: query
description: Optional. If set, validate the request but don't actually create the memo.
schema:
type: boolean
- name: requestId
in: query
description: Optional. An idempotency token.
schema:
type: string
requestBody: requestBody:
content: content:
application/json: application/json:
...@@ -634,14 +624,6 @@ paths: ...@@ -634,14 +624,6 @@ paths:
required: true required: true
schema: schema:
type: string type: string
- name: readMask
in: query
description: |-
Optional. The fields to return in the response.
If not specified, all fields are returned.
schema:
type: string
format: field-mask
responses: responses:
"200": "200":
description: OK description: OK
...@@ -700,11 +682,6 @@ paths: ...@@ -700,11 +682,6 @@ paths:
schema: schema:
type: string type: string
format: field-mask format: field-mask
- name: allowMissing
in: query
description: Optional. If set to true, allows updating sensitive fields.
schema:
type: boolean
requestBody: requestBody:
content: content:
application/json: application/json:
...@@ -1000,64 +977,6 @@ paths: ...@@ -1000,64 +977,6 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Status' $ref: '#/components/schemas/Status'
/api/v1/memos/{memo}/tags:delete:
post:
tags:
- MemoService
description: DeleteMemoTag deletes a tag for a memo.
operationId: MemoService_DeleteMemoTag
parameters:
- name: memo
in: path
description: The memo id.
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DeleteMemoTagRequest'
required: true
responses:
"200":
description: OK
content: {}
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/memos/{memo}/tags:rename:
patch:
tags:
- MemoService
description: RenameMemoTag renames a tag for a memo.
operationId: MemoService_RenameMemoTag
parameters:
- name: memo
in: path
description: The memo id.
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RenameMemoTagRequest'
required: true
responses:
"200":
description: OK
content: {}
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/reactions/{reaction}: /api/v1/reactions/{reaction}:
delete: delete:
tags: tags:
...@@ -2269,23 +2188,6 @@ components: ...@@ -2269,23 +2188,6 @@ components:
Last time the session was accessed. Last time the session was accessed.
Used for sliding expiration calculation (last_accessed_time + 2 weeks). Used for sliding expiration calculation (last_accessed_time + 2 weeks).
format: date-time format: date-time
DeleteMemoTagRequest:
required:
- parent
- tag
type: object
properties:
parent:
type: string
description: |-
Required. The parent, who owns the tags.
Format: memos/{memo}. Use "memos/-" to delete all tags.
tag:
type: string
description: Required. The tag name to delete.
deleteRelatedMemos:
type: boolean
description: Optional. Whether to delete related memos.
FieldMapping: FieldMapping:
type: object type: object
properties: properties:
...@@ -2483,10 +2385,6 @@ components: ...@@ -2483,10 +2385,6 @@ components:
nextPageToken: nextPageToken:
type: string type: string
description: A token for the next page of results. description: A token for the next page of results.
totalSize:
type: integer
description: The total count of attachments.
format: int32
ListMemoCommentsResponse: ListMemoCommentsResponse:
type: object type: object
properties: properties:
...@@ -2528,10 +2426,6 @@ components: ...@@ -2528,10 +2426,6 @@ components:
nextPageToken: nextPageToken:
type: string type: string
description: A token for the next page of results. description: A token for the next page of results.
totalSize:
type: integer
description: The total count of relations.
format: int32
ListMemosResponse: ListMemosResponse:
type: object type: object
properties: properties:
...@@ -2545,10 +2439,6 @@ components: ...@@ -2545,10 +2439,6 @@ components:
description: |- description: |-
A token that can be sent as `page_token` to retrieve the next page. A token that can be sent as `page_token` to retrieve the next page.
If this field is omitted, there are no subsequent pages. If this field is omitted, there are no subsequent pages.
totalSize:
type: integer
description: The total count of memos (may be approximate).
format: int32
ListShortcutsResponse: ListShortcutsResponse:
type: object type: object
properties: properties:
...@@ -2833,24 +2723,6 @@ components: ...@@ -2833,24 +2723,6 @@ components:
type: string type: string
description: Output only. The creation timestamp. description: Output only. The creation timestamp.
format: date-time format: date-time
RenameMemoTagRequest:
required:
- parent
- oldTag
- newTag
type: object
properties:
parent:
type: string
description: |-
Required. The parent, who owns the tags.
Format: memos/{memo}. Use "memos/-" to rename all tags.
oldTag:
type: string
description: Required. The old tag name to rename.
newTag:
type: string
description: Required. The new tag name.
SetMemoAttachmentsRequest: SetMemoAttachmentsRequest:
required: required:
- name - name
......
...@@ -679,104 +679,6 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM ...@@ -679,104 +679,6 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM
return response, nil return response, nil
} }
func (s *APIV1Service) RenameMemoTag(ctx context.Context, request *v1pb.RenameMemoTagRequest) (*emptypb.Empty, error) {
user, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
memoFind := &store.FindMemo{
CreatorID: &user.ID,
Filters: []string{fmt.Sprintf("tag in [\"%s\"]", request.OldTag)},
ExcludeComments: true,
}
if (request.Parent) != "memos/-" {
memoUID, err := ExtractMemoUIDFromName(request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
memoFind.UID = &memoUID
}
memos, err := s.Store.ListMemos(ctx, memoFind)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memos")
}
for _, memo := range memos {
// Rename tag using goldmark
newContent, err := s.MarkdownService.RenameTag([]byte(memo.Content), request.OldTag, request.NewTag)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to rename tag: %v", err)
}
memo.Content = newContent
if err := memopayload.RebuildMemoPayload(memo, s.MarkdownService); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
}
if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{
ID: memo.ID,
Content: &memo.Content,
Payload: memo.Payload,
}); err != nil {
return nil, status.Errorf(codes.Internal, "failed to update memo: %v", err)
}
}
return &emptypb.Empty{}, nil
}
func (s *APIV1Service) DeleteMemoTag(ctx context.Context, request *v1pb.DeleteMemoTagRequest) (*emptypb.Empty, error) {
user, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user")
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
}
memoFind := &store.FindMemo{
CreatorID: &user.ID,
Filters: []string{fmt.Sprintf("tag in [\"%s\"]", request.Tag)},
ExcludeContent: true,
ExcludeComments: true,
}
if request.Parent != "memos/-" {
memoUID, err := ExtractMemoUIDFromName(request.Parent)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
}
memoFind.UID = &memoUID
}
memos, err := s.Store.ListMemos(ctx, memoFind)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to list memos")
}
for _, memo := range memos {
if request.DeleteRelatedMemos {
err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete memo")
}
} else {
archived := store.Archived
err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{
ID: memo.ID,
RowStatus: &archived,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to update memo")
}
}
}
return &emptypb.Empty{}, nil
}
func (s *APIV1Service) getContentLengthLimit(ctx context.Context) (int, error) { func (s *APIV1Service) getContentLengthLimit(ctx context.Context) (int, error) {
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx) workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
......
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react"; import { HashIcon, MoreVerticalIcon, TagsIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast";
import useLocalStorage from "react-use/lib/useLocalStorage"; import useLocalStorage from "react-use/lib/useLocalStorage";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { memoServiceClient } from "@/grpcweb";
import { useDialog } from "@/hooks/useDialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { userStore } from "@/store"; import { userStore } from "@/store";
import memoFilterStore, { MemoFilter } from "@/store/memoFilter"; import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import RenameTagDialog from "../RenameTagDialog";
import TagTree from "../TagTree"; import TagTree from "../TagTree";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface Props { interface Props {
...@@ -24,9 +17,6 @@ const TagsSection = observer((props: Props) => { ...@@ -24,9 +17,6 @@ const TagsSection = observer((props: Props) => {
const t = useTranslate(); const t = useTranslate();
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false); const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false); const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
const renameTagDialog = useDialog();
const [selectedTag, setSelectedTag] = useState<string>("");
const [deleteTagName, setDeleteTagName] = useState<string | undefined>(undefined);
const tags = Object.entries(userStore.state.tagCount) const tags = Object.entries(userStore.state.tagCount)
.sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1]); .sort((a, b) => b[1] - a[1]);
...@@ -43,38 +33,14 @@ const TagsSection = observer((props: Props) => { ...@@ -43,38 +33,14 @@ const TagsSection = observer((props: Props) => {
} }
}; };
const handleRenameTag = (tag: string) => {
setSelectedTag(tag);
renameTagDialog.open();
};
const handleRenameSuccess = () => {
// Refresh tags after rename
userStore.fetchUsers();
};
const handleDeleteTag = async (tag: string) => {
setDeleteTagName(tag);
};
const confirmDeleteTag = async () => {
if (!deleteTagName) return;
await memoServiceClient.deleteMemoTag({
parent: "memos/-",
tag: deleteTagName,
});
toast.success(t("tag.delete-success"));
setDeleteTagName(undefined);
};
return ( return (
<div className="flex flex-col justify-start items-start w-full mt-3 px-1 h-auto shrink-0 flex-nowrap hide-scrollbar"> <div className="w-full flex flex-col justify-start items-start mt-3 px-1 h-auto shrink-0 flex-nowrap hide-scrollbar">
<div className="flex flex-row justify-between items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none"> <div className="flex flex-row justify-between items-center w-full gap-1 mb-1 text-sm leading-6 text-muted-foreground select-none">
<span>{t("common.tags")}</span> <span>{t("common.tags")}</span>
{tags.length > 0 && ( {tags.length > 0 && (
<Popover> <Popover>
<PopoverTrigger> <PopoverTrigger>
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground" /> <MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" alignOffset={-12}> <PopoverContent align="end" alignOffset={-12}>
<div className="w-auto flex flex-row justify-between items-center gap-2 p-1"> <div className="w-auto flex flex-row justify-between items-center gap-2 p-1">
...@@ -93,66 +59,37 @@ const TagsSection = observer((props: Props) => { ...@@ -93,66 +59,37 @@ const TagsSection = observer((props: Props) => {
treeMode ? ( treeMode ? (
<TagTree tagAmounts={tags} expandSubTags={!!treeAutoExpand} /> <TagTree tagAmounts={tags} expandSubTags={!!treeAutoExpand} />
) : ( ) : (
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1"> <div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1.5">
{tags.map(([tag, amount]) => ( {tags.map(([tag, amount]) => {
<div const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
key={tag} return (
className="shrink-0 w-auto max-w-full text-sm rounded-md leading-6 flex flex-row justify-start items-center select-none hover:opacity-80 text-muted-foreground"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="shrink-0 group cursor-pointer">
<HashIcon className="group-hover:hidden w-4 h-auto shrink-0 text-muted-foreground" />
<MoreVerticalIcon className="hidden group-hover:block w-4 h-auto shrink-0 text-muted-foreground" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={2}>
<DropdownMenuItem onClick={() => handleRenameTag(tag)}>
<Edit3Icon className="w-4 h-auto" />
{t("common.rename")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteTag(tag)}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div <div
className={cn("inline-flex flex-nowrap ml-0.5 gap-0.5 cursor-pointer max-w-[calc(100%-16px)]")} key={tag}
className={cn(
"shrink-0 w-auto max-w-full text-sm rounded-md leading-6 flex flex-row justify-start items-center select-none cursor-pointer transition-colors",
"hover:opacity-80",
isActive ? "text-primary" : "text-muted-foreground",
)}
onClick={() => handleTagClick(tag)} onClick={() => handleTagClick(tag)}
> >
<span className="truncate opacity-80">{tag}</span> <HashIcon className="w-4 h-auto shrink-0" />
{amount > 1 && <span className="opacity-60 shrink-0">({amount})</span>} <div className="inline-flex flex-nowrap ml-0.5 gap-0.5 max-w-[calc(100%-16px)]">
<span className={cn("truncate", isActive ? "font-medium" : "")}>{tag}</span>
{amount > 1 && <span className="opacity-60 shrink-0">({amount})</span>}
</div>
</div> </div>
</div> );
))} })}
</div> </div>
) )
) : ( ) : (
!props.readonly && ( !props.readonly && (
<div className="p-2 border border-dashed rounded-md flex flex-row justify-start items-start gap-1 text-muted-foreground"> <div className="p-2 border border-dashed rounded-md flex flex-row justify-start items-start gap-2 text-muted-foreground">
<TagsIcon /> <TagsIcon className="w-5 h-5 shrink-0" />
<p className="mt-0.5 text-sm leading-snug italic">{t("tag.create-tags-guide")}</p> <p className="text-sm leading-snug italic">{t("tag.create-tags-guide")}</p>
</div> </div>
) )
)} )}
{/* Rename Tag Dialog */}
<RenameTagDialog
open={renameTagDialog.isOpen}
onOpenChange={renameTagDialog.setOpen}
tag={selectedTag}
onSuccess={handleRenameSuccess}
/>
<ConfirmDialog
open={!!deleteTagName}
onOpenChange={(open) => !open && setDeleteTagName(undefined)}
title={t("tag.delete-confirm")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteTag}
confirmVariant="destructive"
/>
</div> </div>
); );
}); });
......
import React, { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { memoServiceClient } from "@/grpcweb";
import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
tag: string;
onSuccess?: () => void;
}
function RenameTagDialog({ open, onOpenChange, tag, onSuccess }: Props) {
const t = useTranslate();
const [newName, setNewName] = useState(tag);
const requestState = useLoading(false);
const handleTagNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewName(e.target.value.trim());
};
const handleConfirm = async () => {
if (!newName || newName.includes(" ")) {
toast.error(t("tag.rename-error-empty"));
return;
}
if (newName === tag) {
toast.error(t("tag.rename-error-repeat"));
return;
}
try {
requestState.setLoading();
await memoServiceClient.renameMemoTag({
parent: "memos/-",
oldTag: tag,
newTag: newName,
});
toast.success(t("tag.rename-success"));
requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: any) {
console.error(error);
toast.error(error.details);
requestState.setError();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("tag.rename-tag")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="oldName">{t("tag.old-name")}</Label>
<Input id="oldName" readOnly disabled type="text" value={tag} />
</div>
<div className="grid gap-2">
<Label htmlFor="newName">{t("tag.new-name")}</Label>
<Input id="newName" type="text" placeholder="A new tag name" value={newName} onChange={handleTagNameInputChange} />
</div>
<div className="text-sm text-muted-foreground">
<ul className="list-disc list-inside">
<li>{t("tag.rename-tip")}</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
{t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default RenameTagDialog;
...@@ -115,26 +115,27 @@ const TagItemContainer = observer((props: TagItemContainerProps) => { ...@@ -115,26 +115,27 @@ const TagItemContainer = observer((props: TagItemContainerProps) => {
return ( return (
<> <>
<div className="relative flex flex-row justify-between items-center w-full leading-6 py-0 mt-px rounded-lg text-sm select-none shrink-0"> <div className="relative flex flex-row justify-between items-center w-full leading-6 py-0 mt-px text-sm select-none shrink-0">
<div <div
className={`flex flex-row justify-start items-center truncate shrink leading-5 mr-1 text-muted-foreground ${ className={`flex flex-row justify-start items-center truncate shrink leading-5 mr-1 cursor-pointer transition-colors ${
isActive && "text-primary!" isActive ? "text-primary" : "text-muted-foreground"
}`} }`}
onClick={handleTagClick}
> >
<div className="shrink-0"> <HashIcon className="w-4 h-auto shrink-0 mr-1" />
<HashIcon className="w-4 h-auto shrink-0 mr-1 text-muted-foreground" /> <span className={`truncate hover:opacity-80 ${isActive ? "font-medium" : ""}`}>
</div> {tag.key} {tag.amount > 1 && <span className="opacity-60">({tag.amount})</span>}
<span className="truncate cursor-pointer hover:opacity-80" onClick={handleTagClick}>
{tag.key} {tag.amount > 1 && `(${tag.amount})`}
</span> </span>
</div> </div>
<div className="flex flex-row justify-end items-center"> <div className="flex flex-row justify-end items-center">
{hasSubTags ? ( {hasSubTags ? (
<span <span
className={`flex flex-row justify-center items-center w-6 h-6 shrink-0 transition-all rotate-0 ${showSubTags && "rotate-90"}`} className={`flex flex-row justify-center items-center w-6 h-6 shrink-0 transition-all rotate-0 cursor-pointer ${
showSubTags && "rotate-90"
}`}
onClick={handleToggleBtnClick} onClick={handleToggleBtnClick}
> >
<ChevronRightIcon className="w-5 h-5 cursor-pointer text-muted-foreground" /> <ChevronRightIcon className="w-5 h-5 text-muted-foreground hover:text-foreground" />
</span> </span>
) : null} ) : null}
</div> </div>
...@@ -142,7 +143,7 @@ const TagItemContainer = observer((props: TagItemContainerProps) => { ...@@ -142,7 +143,7 @@ const TagItemContainer = observer((props: TagItemContainerProps) => {
{hasSubTags ? ( {hasSubTags ? (
<div <div
className={`w-[calc(100%-0.5rem)] flex flex-col justify-start items-start h-auto ml-2 pl-2 border-l-2 border-l-border ${ className={`w-[calc(100%-0.5rem)] flex flex-col justify-start items-start h-auto ml-2 pl-2 border-l-2 border-l-border ${
!showSubTags && "hidden!" !showSubTags && "hidden"
}`} }`}
> >
{tag.subTags.map((st, idx) => ( {tag.subTags.map((st, idx) => (
......
...@@ -111,8 +111,8 @@ const UserMenu = observer((props: Props) => { ...@@ -111,8 +111,8 @@ const UserMenu = observer((props: Props) => {
<DropdownMenuSubContent className="max-h-[90vh] overflow-y-auto"> <DropdownMenuSubContent className="max-h-[90vh] overflow-y-auto">
{locales.map((locale) => ( {locales.map((locale) => (
<DropdownMenuItem key={locale} onClick={() => handleLocaleChange(locale)}> <DropdownMenuItem key={locale} onClick={() => handleLocaleChange(locale)}>
{currentLocale === locale && <CheckIcon className="w-4 h-auto mr-2" />} {currentLocale === locale && <CheckIcon className="w-4 h-auto" />}
{currentLocale !== locale && <span className="w-4 mr-2" />} {currentLocale !== locale && <span className="w-4" />}
{getLocaleDisplayName(locale)} {getLocaleDisplayName(locale)}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
...@@ -126,8 +126,8 @@ const UserMenu = observer((props: Props) => { ...@@ -126,8 +126,8 @@ const UserMenu = observer((props: Props) => {
<DropdownMenuSubContent> <DropdownMenuSubContent>
{THEME_OPTIONS.map((option) => ( {THEME_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => handleThemeChange(option.value)}> <DropdownMenuItem key={option.value} onClick={() => handleThemeChange(option.value)}>
{currentTheme === option.value && <CheckIcon className="w-4 h-auto mr-2" />} {currentTheme === option.value && <CheckIcon className="w-4 h-auto" />}
{currentTheme !== option.value && <span className="w-4 mr-2" />} {currentTheme !== option.value && <span className="w-4" />}
{option.label} {option.label}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment