mirror of
https://github.com/usememos/memos.git
synced 2025-12-18 22:59:24 +08:00
fix: implement tag suggestions functionality
Backend changes: - Fix ListAllUserStats to calculate and return tag statistics - Previously only returned name and timestamps, missing TagCount - Now properly aggregates tags, pinned memos, and memo type stats Frontend changes: - Initialize user stats on app startup to populate tag data - Show all tags when typing just '#' (fix empty Fuse.js search) - Auto-refresh stats after creating/updating/deleting memos - Fix Checkbox component ref warning with forwardRef 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7d4d1e8517
commit
ef9eee19d6
5 changed files with 68 additions and 8 deletions
|
|
@ -49,14 +49,59 @@ func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUser
|
||||||
|
|
||||||
userMemoStatMap := make(map[int32]*v1pb.UserStats)
|
userMemoStatMap := make(map[int32]*v1pb.UserStats)
|
||||||
for _, memo := range memos {
|
for _, memo := range memos {
|
||||||
|
// Initialize user stats if not exists
|
||||||
|
if _, exists := userMemoStatMap[memo.CreatorID]; !exists {
|
||||||
|
userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{
|
||||||
|
Name: fmt.Sprintf("users/%d/stats", memo.CreatorID),
|
||||||
|
TagCount: make(map[string]int32),
|
||||||
|
MemoDisplayTimestamps: []*timestamppb.Timestamp{},
|
||||||
|
PinnedMemos: []string{},
|
||||||
|
MemoTypeStats: &v1pb.UserStats_MemoTypeStats{
|
||||||
|
LinkCount: 0,
|
||||||
|
CodeCount: 0,
|
||||||
|
TodoCount: 0,
|
||||||
|
UndoCount: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := userMemoStatMap[memo.CreatorID]
|
||||||
|
|
||||||
|
// Add display timestamp
|
||||||
displayTs := memo.CreatedTs
|
displayTs := memo.CreatedTs
|
||||||
if instanceMemoRelatedSetting.DisplayWithUpdateTime {
|
if instanceMemoRelatedSetting.DisplayWithUpdateTime {
|
||||||
displayTs = memo.UpdatedTs
|
displayTs = memo.UpdatedTs
|
||||||
}
|
}
|
||||||
userMemoStatMap[memo.CreatorID] = &v1pb.UserStats{
|
stats.MemoDisplayTimestamps = append(stats.MemoDisplayTimestamps, timestamppb.New(time.Unix(displayTs, 0)))
|
||||||
Name: fmt.Sprintf("users/%d/stats", memo.CreatorID),
|
|
||||||
|
// Count memo stats
|
||||||
|
stats.TotalMemoCount++
|
||||||
|
|
||||||
|
// Count tags and other properties
|
||||||
|
if memo.Payload != nil {
|
||||||
|
for _, tag := range memo.Payload.Tags {
|
||||||
|
stats.TagCount[tag]++
|
||||||
|
}
|
||||||
|
if memo.Payload.Property != nil {
|
||||||
|
if memo.Payload.Property.HasLink {
|
||||||
|
stats.MemoTypeStats.LinkCount++
|
||||||
|
}
|
||||||
|
if memo.Payload.Property.HasCode {
|
||||||
|
stats.MemoTypeStats.CodeCount++
|
||||||
|
}
|
||||||
|
if memo.Payload.Property.HasTaskList {
|
||||||
|
stats.MemoTypeStats.TodoCount++
|
||||||
|
}
|
||||||
|
if memo.Payload.Property.HasIncompleteTasks {
|
||||||
|
stats.MemoTypeStats.UndoCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pinned memos
|
||||||
|
if memo.Pinned {
|
||||||
|
stats.PinnedMemos = append(stats.PinnedMemos, fmt.Sprintf("users/%d/memos/%d", memo.CreatorID, memo.ID))
|
||||||
}
|
}
|
||||||
userMemoStatMap[memo.CreatorID].MemoDisplayTimestamps = append(userMemoStatMap[memo.CreatorID].MemoDisplayTimestamps, timestamppb.New(time.Unix(displayTs, 0)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userMemoStats := []*v1pb.UserStats{}
|
userMemoStats := []*v1pb.UserStats{}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ const TagSuggestions = observer(({ editorRef, editorActions }: Props) => {
|
||||||
const suggestionsRef = useRef<string[]>([]);
|
const suggestionsRef = useRef<string[]>([]);
|
||||||
suggestionsRef.current = (() => {
|
suggestionsRef.current = (() => {
|
||||||
const search = getCurrentWord()[0].slice(1).toLowerCase();
|
const search = getCurrentWord()[0].slice(1).toLowerCase();
|
||||||
|
if (search === "") {
|
||||||
|
return tags; // Show all tags when no search term
|
||||||
|
}
|
||||||
const fuse = new Fuse(tags);
|
const fuse = new Fuse(tags);
|
||||||
return fuse.search(search).map((result) => result.item);
|
return fuse.search(search).map((result) => result.item);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@ import { CheckIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer border-border data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow disabled:cursor-not-allowed disabled:opacity-50",
|
"peer border-border data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
|
@ -18,6 +22,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
Checkbox.displayName = "Checkbox";
|
||||||
|
|
||||||
export { Checkbox };
|
export { Checkbox };
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { makeAutoObservable } from "mobx";
|
||||||
import { memoServiceClient } from "@/grpcweb";
|
import { memoServiceClient } from "@/grpcweb";
|
||||||
import { CreateMemoRequest, ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service";
|
import { CreateMemoRequest, ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service";
|
||||||
import { RequestDeduplicator, createRequestKey, StoreError } from "./store-utils";
|
import { RequestDeduplicator, createRequestKey, StoreError } from "./store-utils";
|
||||||
|
import userStore from "./user";
|
||||||
|
|
||||||
class LocalState {
|
class LocalState {
|
||||||
stateId: string = uniqueId();
|
stateId: string = uniqueId();
|
||||||
|
|
@ -112,6 +113,8 @@ const memoStore = (() => {
|
||||||
stateId: uniqueId(),
|
stateId: uniqueId(),
|
||||||
memoMapByName: memoMap,
|
memoMapByName: memoMap,
|
||||||
});
|
});
|
||||||
|
// Refresh user stats to update tag counts
|
||||||
|
userStore.fetchUserStats().catch(console.error);
|
||||||
return memo;
|
return memo;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -142,6 +145,8 @@ const memoStore = (() => {
|
||||||
stateId: uniqueId(),
|
stateId: uniqueId(),
|
||||||
memoMapByName: confirmedMemoMap,
|
memoMapByName: confirmedMemoMap,
|
||||||
});
|
});
|
||||||
|
// Refresh user stats to update tag counts
|
||||||
|
userStore.fetchUserStats().catch(console.error);
|
||||||
return memo;
|
return memo;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Rollback on error
|
// Rollback on error
|
||||||
|
|
@ -166,6 +171,8 @@ const memoStore = (() => {
|
||||||
stateId: uniqueId(),
|
stateId: uniqueId(),
|
||||||
memoMapByName: memoMap,
|
memoMapByName: memoMap,
|
||||||
});
|
});
|
||||||
|
// Refresh user stats to update tag counts
|
||||||
|
userStore.fetchUserStats().catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -343,10 +343,10 @@ export const initialUserStore = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 3: Fetch user settings
|
// Step 3: Fetch user settings and stats
|
||||||
// CRITICAL: This must happen after currentUser is set in step 2
|
// CRITICAL: This must happen after currentUser is set in step 2
|
||||||
// The fetchUserSettings() method checks state.currentUser internally
|
// The fetchUserSettings() and fetchUserStats() methods check state.currentUser internally
|
||||||
await userStore.fetchUserSettings();
|
await Promise.all([userStore.fetchUserSettings(), userStore.fetchUserStats()]);
|
||||||
|
|
||||||
// Step 4: Apply user preferences to instance
|
// Step 4: Apply user preferences to instance
|
||||||
// CRITICAL: This must happen after fetchUserSettings() completes
|
// CRITICAL: This must happen after fetchUserSettings() completes
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue