Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note_extension
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note_extension
Commits
30891fb7
Commit
30891fb7
authored
Feb 22, 2026
by
Hoanganhvu123
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
update: latest changes
parent
97cb2112
Changes
42
Show whitespace changes
Inline
Side-by-side
Showing
42 changed files
with
989 additions
and
594 deletions
+989
-594
controller.py
backend/agent/controller.py
+4
-9
memo_retrieval_tool.py
backend/agent/tools/memo_retrieval_tool.py
+1
-0
memo_routes.py
backend/api/memos/memo_routes.py
+15
-7
canifa_api.py
backend/common/canifa_api.py
+0
-103
clerk_auth.py
backend/common/clerk_auth.py
+2
-0
jeager_log.py
backend/common/jeager_log.py
+0
-0
services.py
backend/common/memos_core/services.py
+99
-42
run.py
backend/run.py
+0
-171
server.py
backend/server.py
+8
-5
test_redis.py
backend/test_redis.py
+0
-29
package-lock.json
frontend/package-lock.json
+113
-0
package.json
frontend/package.json
+3
-1
CalendarCell.tsx
frontend/src/components/ActivityCalendar/CalendarCell.tsx
+5
-4
MonthCalendar.tsx
frontend/src/components/ActivityCalendar/MonthCalendar.tsx
+20
-6
constants.ts
frontend/src/components/ActivityCalendar/constants.ts
+8
-7
AttachmentIcon.tsx
frontend/src/components/AttachmentIcon.tsx
+1
-0
AuthFooter.tsx
frontend/src/components/AuthFooter.tsx
+1
-1
ChatbotWidget.tsx
frontend/src/components/ChatbotWidget.tsx
+2
-2
CommandPalette.tsx
frontend/src/components/CommandPalette.tsx
+257
-0
CreateUserDialog.tsx
frontend/src/components/CreateUserDialog.tsx
+1
-1
hooks.ts
frontend/src/components/MemoActionMenu/hooks.ts
+37
-10
CodeBlock.tsx
frontend/src/components/MemoContent/CodeBlock.tsx
+18
-13
MermaidBlock.tsx
frontend/src/components/MemoContent/MermaidBlock.tsx
+6
-8
index.tsx
frontend/src/components/MemoEditor/index.tsx
+3
-1
BookmarksSection.tsx
frontend/src/components/MemoExplorer/BookmarksSection.tsx
+20
-18
MemoHoverCard.tsx
frontend/src/components/MemoHoverCard.tsx
+58
-0
MemoSkeleton.tsx
frontend/src/components/MemoSkeleton.tsx
+44
-0
MemoView.tsx
frontend/src/components/MemoView/MemoView.tsx
+7
-4
Navigation.tsx
frontend/src/components/Navigation.tsx
+132
-103
PagedMemoList.tsx
frontend/src/components/PagedMemoList/PagedMemoList.tsx
+2
-2
PinnedSection.tsx
frontend/src/components/PinnedSection/PinnedSection.tsx
+1
-3
PreviewImageDialog.tsx
frontend/src/components/PreviewImageDialog.tsx
+2
-7
Skeleton.tsx
frontend/src/components/Skeleton.tsx
+27
-10
MonthNavigator.tsx
frontend/src/components/StatisticsView/MonthNavigator.tsx
+9
-6
StatisticsView.tsx
frontend/src/components/StatisticsView/StatisticsView.tsx
+20
-4
AuthContext.tsx
frontend/src/contexts/AuthContext.tsx
+4
-0
index.css
frontend/src/index.css
+7
-0
RootLayout.tsx
frontend/src/layouts/RootLayout.tsx
+4
-2
converters.ts
frontend/src/service/converters.ts
+2
-2
memoService.ts
frontend/src/service/memoService.ts
+23
-6
types.ts
frontend/src/service/types.ts
+2
-0
clerk.ts
frontend/src/utils/clerk.ts
+21
-7
No files found.
backend/agent/controller.py
View file @
30891fb7
...
...
@@ -14,14 +14,12 @@ from langchain_core.runnables import RunnableConfig
from
common.cache
import
redis_cache
from
common.conversation_manager
import
get_conversation_manager
from
common.langfuse_client
import
get_callback_handler
from
common.llm_factory
import
create_llm
from
config
import
DEFAULT_MODEL
,
REDIS_CACHE_TURN_ON
from
langfuse
import
propagate_attributes
from
.graph
import
build_graph
from
.helper
import
extract_product_ids
,
handle_post_chat_async
,
parse_ai_response
from
.models
import
AgentState
,
get_config
from
.tools.get_tools
import
get_all_tools
from
.models
import
AgentState
logger
=
logging
.
getLogger
(
__name__
)
...
...
@@ -72,12 +70,9 @@ async def chat_controller(
# ====================== NORMAL LLM FLOW ======================
logger
.
info
(
"chat_controller: proceed with live LLM call"
)
config
=
get_config
()
config
.
model_name
=
model_name
llm
=
create_llm
(
model_name
=
model_name
,
streaming
=
False
,
json_mode
=
True
)
tools
=
get_all_tools
()
graph
=
build_graph
(
config
,
llm
=
llm
,
tools
=
tools
)
# Graph is a singleton — built once and reused across requests.
# LLM instances are cached by LLMFactory, tools are static.
graph
=
build_graph
()
# Init ConversationManager (Singleton)
memory
=
await
get_conversation_manager
()
...
...
backend/agent/tools/memo_retrieval_tool.py
View file @
30891fb7
...
...
@@ -211,6 +211,7 @@ async def memo_retrieval_tool(
"memos"
:
memos
,
},
ensure_ascii
=
False
,
default
=
str
,
)
except
Exception
as
exc
:
...
...
backend/api/memos/memo_routes.py
View file @
30891fb7
...
...
@@ -36,6 +36,7 @@ async def list_memos(
request
:
Request
,
tag
:
str
|
None
=
Query
(
default
=
None
),
filter
:
str
|
None
=
Query
(
default
=
None
),
row_status
:
str
|
None
=
Query
(
default
=
None
,
description
=
"Filter by status: NORMAL or ARCHIVED"
),
start_date
:
str
|
None
=
Query
(
default
=
None
,
description
=
"ISO format date (YYYY-MM-DD)"
),
end_date
:
str
|
None
=
Query
(
default
=
None
,
description
=
"ISO format date (YYYY-MM-DD)"
),
memo_service
=
Depends
(
get_memo_service
),
...
...
@@ -43,6 +44,9 @@ async def list_memos(
"""List memos for the current user (or anonymous if not logged in)."""
try
:
user_id
=
get_current_user_id
(
request
)
is_auth
=
getattr
(
request
.
state
,
"is_authenticated"
,
False
)
logger
.
warning
(
"🔍 LIST_MEMOS: user_id=
%
s, is_authenticated=
%
s, has_auth_header=
%
s"
,
user_id
,
is_auth
,
bool
(
request
.
headers
.
get
(
"authorization"
)))
# Parse date range from query params or filter string
dt_start
,
dt_end
=
parse_date_range
(
...
...
@@ -57,6 +61,7 @@ async def list_memos(
return
await
memo_service
.
list_memos
(
user_id
=
user_id
,
tag
=
tag
,
row_status
=
row_status
,
start_date
=
dt_start
,
end_date
=
dt_end
)
...
...
@@ -352,12 +357,7 @@ async def list_memo_comments(
try
:
user_id
=
get_current_user_id
(
request
)
# 1. Verify memo exists and user has access
memo
=
await
memo_service
.
get_memo
(
memo_id
,
user_id
=
user_id
)
if
not
memo
:
raise
HTTPException
(
status_code
=
404
,
detail
=
f
"Memo {memo_id} not found"
)
# 2. Get the actual _id of the memo for relation query and get memo owner
# 1. Verify memo exists and get raw doc in ONE query
memo_doc
=
None
if
ObjectId
.
is_valid
(
memo_id
):
memo_doc
=
await
mongodb_client
.
memos
.
find_one
({
"_id"
:
ObjectId
(
memo_id
)})
...
...
@@ -367,8 +367,16 @@ async def list_memo_comments(
if
not
memo_doc
:
raise
HTTPException
(
status_code
=
404
,
detail
=
f
"Memo {memo_id} not found"
)
# Access check: owner, PUBLIC, or PROTECTED (logged-in user)
memo_visibility
=
memo_doc
.
get
(
"visibility"
,
"PRIVATE"
)
memo_creator
=
memo_doc
.
get
(
"creator_id"
,
"anonymous"
)
if
memo_visibility
==
"PRIVATE"
and
memo_creator
!=
user_id
:
raise
HTTPException
(
status_code
=
404
,
detail
=
f
"Memo {memo_id} not found"
)
if
memo_visibility
==
"PROTECTED"
and
(
not
user_id
or
user_id
==
"anonymous"
):
raise
HTTPException
(
status_code
=
403
,
detail
=
"Access denied: authentication required"
)
related_memo_id
=
str
(
memo_doc
[
"_id"
])
memo_owner_id
=
memo_
doc
.
get
(
"creator_id"
)
memo_owner_id
=
memo_
creator
# 3. Query MemoRelations with related_memo_id and type = COMMENT (like memos original)
relations
=
await
memo_relation_service
.
list_relations
(
...
...
backend/common/canifa_api.py
deleted
100644 → 0
View file @
97cb2112
"""
Canifa API Service
Xử lý các logic liên quan đến API của Canifa (Magento)
"""
import
logging
import
httpx
from
typing
import
Optional
,
Dict
,
Any
logger
=
logging
.
getLogger
(
__name__
)
# URL API Canifa
CANIFA_CUSTOMER_API
=
"https://canifa.com/v1/magento/customer"
# GraphQL Query Body giả lập (để lấy User Info)
CANIFA_QUERY_BODY
=
[
{
"customer"
:
"customer-custom-query"
,
"metadata"
:
{
"fields"
:
"
\n
customer {
\n
gender
\n
customer_id
\n
phone_number
\n
date_of_birth
\n
default_billing
\n
default_shipping
\n
email
\n
firstname
\n
is_subscribed
\n
lastname
\n
middlename
\n
prefix
\n
suffix
\n
taxvat
\n
addresses {
\n
city
\n
country_code
\n
default_billing
\n
default_shipping
\n
extension_attributes {
\n
attribute_code
\n
value
\n
}
\n
custom_attributes {
\n
attribute_code
\n
value
\n
}
\n
firstname
\n
id
\n
lastname
\n
postcode
\n
prefix
\n
region {
\n
region_code
\n
region_id
\n
region
\n
}
\n
street
\n
suffix
\n
telephone
\n
vat_id
\n
}
\n
is_subscribed
\n
}
\n
"
}
},
{}
]
async
def
verify_canifa_token
(
token
:
str
)
->
Optional
[
Dict
[
str
,
Any
]]:
"""
Verify token với API Canifa (Magento).
Dùng token làm cookie `vsf-customer` để gọi API lấy thông tin customer.
Args:
token: Giá trị của cookie vsf-customer (lấy từ Header Authorization)
Returns:
Dict info user hoặc None nếu lỗi
"""
if
not
token
:
return
None
headers
=
{
"accept"
:
"application/json, text/plain, */*"
,
"content-type"
:
"application/json"
,
"Cookie"
:
f
"vsf-customer={token}"
# Quan trọng: Gửi token dưới dạng Cookie
}
try
:
async
with
httpx
.
AsyncClient
(
timeout
=
10.0
)
as
client
:
response
=
await
client
.
post
(
CANIFA_CUSTOMER_API
,
json
=
CANIFA_QUERY_BODY
,
headers
=
headers
)
if
response
.
status_code
==
200
:
data
=
response
.
json
()
logger
.
debug
(
f
"Canifa API Raw Response: {data}"
)
# Response format: {"data": {"customer": {...}}, "loading": false, ...}
if
isinstance
(
data
,
dict
):
# Trả về toàn bộ data để extract_user_id xử lý
return
data
# Nếu Canifa trả list (batch request)
return
data
else
:
logger
.
warning
(
f
"Canifa API Failed: {response.status_code} - {response.text}"
)
return
None
except
Exception
as
e
:
logger
.
error
(
f
"Error calling Canifa API: {e}"
)
return
None
async
def
extract_user_id_from_canifa_response
(
data
:
Any
)
->
Optional
[
str
]:
"""
Bóc customer_id từ response data của Canifa.
"""
if
not
data
:
return
None
try
:
# Dự phòng các format data trả về khác nhau
customer
=
None
# Format 1: data['customer']
if
isinstance
(
data
,
dict
):
customer
=
data
.
get
(
'customer'
)
or
data
.
get
(
'data'
,
{})
.
get
(
'customer'
)
# Format 2: data là list (nếu query batch)
elif
isinstance
(
data
,
list
)
and
len
(
data
)
>
0
:
item
=
data
[
0
]
if
isinstance
(
item
,
dict
):
customer
=
item
.
get
(
'result'
,
{})
.
get
(
'customer'
)
or
item
.
get
(
'data'
,
{})
.
get
(
'customer'
)
if
customer
and
isinstance
(
customer
,
dict
):
user_id
=
customer
.
get
(
'customer_id'
)
or
customer
.
get
(
'id'
)
if
user_id
:
return
str
(
user_id
)
return
None
except
Exception
as
e
:
logger
.
error
(
f
"Error parsing user_id from Canifa response: {e}"
)
return
None
backend/common/clerk_auth.py
View file @
30891fb7
...
...
@@ -35,11 +35,13 @@ def verify_clerk_jwt(token: str) -> dict[str, Any]:
signing_key
=
_jwks_client
()
.
get_signing_key_from_jwt
(
token
)
.
key
# Clerk tokens are typically RS256.
# leeway=60 tolerates up to 60s clock skew between Clerk server and this machine
payload
=
jwt
.
decode
(
token
,
signing_key
,
algorithms
=
[
"RS256"
],
issuer
=
CLERK_ISSUER
,
leeway
=
60
,
options
=
{
"verify_aud"
:
False
,
# allow multiple audiences in dev
},
...
...
backend/common/jeager_log.py
deleted
100644 → 0
View file @
97cb2112
backend/common/memos_core/services.py
View file @
30891fb7
...
...
@@ -6,6 +6,7 @@ Full features: memos, attachments, relations, reactions, embeddings, inbox, sett
from
__future__
import
annotations
import
json
import
logging
import
os
from
datetime
import
datetime
,
timezone
from
typing
import
Any
,
List
...
...
@@ -190,22 +191,16 @@ class MemoService:
# Viewing own profile: show all
pass
else
:
# Viewing others: show PUBLIC and PROTECTED (if logged in)
if
user_id
and
user_id
!=
"anonymous"
:
query
[
"visibility"
]
=
{
"$in"
:
[
"PUBLIC"
,
"PROTECTED"
]}
else
:
query
[
"visibility"
]
=
"PUBLIC"
# Viewing others' profile: blocked — each user only sees their own
query
[
"creator_id"
]
=
"__none__"
elif
user_id
and
user_id
!=
"anonymous"
:
# Feed view (no specific creator): user's own memos + PUBLIC + PROTECTED memos
query
[
"$or"
]
=
[
{
"creator_id"
:
user_id
},
{
"visibility"
:
"PUBLIC"
},
{
"visibility"
:
"PROTECTED"
},
]
# Feed view (no specific creator): show ONLY user's own memos
# Other users' PUBLIC/PROTECTED memos belong on the Explore page, not the home feed
query
[
"creator_id"
]
=
user_id
else
:
# Guest
feed: only show public memos
query
[
"
visibility"
]
=
"PUBLIC
"
# Guest
/ anonymous: no memos — each user only sees their own
query
[
"
creator_id"
]
=
"__none__
"
# Apply filters
if
visibility
:
...
...
@@ -251,11 +246,14 @@ class MemoService:
cursor
=
mongodb_client
.
memos
.
find
(
query
)
.
sort
(
"created_at"
,
-
1
)
docs
=
await
cursor
.
to_list
(
length
=
100
)
# Batch fetch comment counts in 1 query (instead of N+1)
memo_ids
=
[
str
(
doc
[
"_id"
])
for
doc
in
docs
]
comment_counts
=
await
self
.
_get_comment_counts_batch
(
memo_ids
,
user_id
)
memos
:
list
[
schemas
.
MemoResponse
]
=
[]
for
doc
in
docs
:
memo_response
=
self
.
_doc_to_response
(
doc
)
doc_visibility
=
doc
.
get
(
"visibility"
,
"PRIVATE"
)
memo_response
.
comment_count
=
await
self
.
_get_comment_count
(
str
(
doc
[
"_id"
]),
doc_visibility
,
user_id
)
memo_response
.
comment_count
=
comment_counts
.
get
(
str
(
doc
[
"_id"
]),
0
)
memos
.
append
(
memo_response
)
return
memos
...
...
@@ -346,6 +344,35 @@ class MemoService:
# PRIVATE: only owner can access (handled above)
raise
ValueError
(
f
"Access denied to memo {memo_id}"
)
async
def
_get_comment_counts_batch
(
self
,
memo_ids
:
list
[
str
],
user_id
:
str
|
None
=
None
)
->
dict
[
str
,
int
]:
"""Get comment counts for multiple memos in a single aggregation query.
Returns a dict mapping memo_id -> comment_count.
This replaces the N+1 pattern of calling _get_comment_count per memo.
"""
if
not
memo_ids
:
return
{}
pipeline
=
[
{
"$match"
:
{
"parent"
:
{
"$in"
:
memo_ids
},
"row_status"
:
{
"$ne"
:
"ARCHIVED"
},
}
},
{
"$group"
:
{
"_id"
:
"$parent"
,
"count"
:
{
"$sum"
:
1
},
}
},
]
cursor
=
mongodb_client
.
memos
.
aggregate
(
pipeline
)
results
=
await
cursor
.
to_list
(
length
=
len
(
memo_ids
))
return
{
doc
[
"_id"
]:
doc
[
"count"
]
for
doc
in
results
}
async
def
_get_comment_count
(
self
,
memo_id
:
str
,
memo_visibility
:
str
,
user_id
:
str
|
None
=
None
)
->
int
:
"""Get comment count for a memo based on visibility and user access."""
query
:
dict
[
str
,
Any
]
=
{
...
...
@@ -418,8 +445,9 @@ class MemoService:
raise
ValueError
(
f
"Memo {memo_id} not found"
)
memo_creator
=
doc
.
get
(
"creator_id"
,
"anonymous"
)
logging
.
info
(
f
"🔍 update_memo ownership check: memo_id={memo_id}, memo_creator='{memo_creator}', user_id='{user_id}', match={memo_creator == user_id}"
)
if
memo_creator
!=
user_id
:
raise
ValueError
(
f
"Access denied: you don't own memo {memo_id}"
)
raise
ValueError
(
f
"Access denied: you don't own memo {memo_id}
(creator={memo_creator}, you={user_id})
"
)
update_fields
:
dict
[
str
,
Any
]
=
{
"updated_at"
:
utc_now
()}
...
...
@@ -472,8 +500,9 @@ class MemoService:
raise
ValueError
(
f
"Memo {memo_id} not found"
)
memo_creator
=
doc
.
get
(
"creator_id"
,
"anonymous"
)
logging
.
info
(
f
"🔍 delete_memo ownership check: memo_id={memo_id}, memo_creator='{memo_creator}', user_id='{user_id}', match={memo_creator == user_id}"
)
if
memo_creator
!=
user_id
:
raise
ValueError
(
f
"Access denied: you don't own memo {memo_id}"
)
raise
ValueError
(
f
"Access denied: you don't own memo {memo_id}
(creator={memo_creator}, you={user_id})
"
)
await
mongodb_client
.
memos
.
delete_one
({
"_id"
:
doc
[
"_id"
]})
await
mongodb_client
.
memo_embeddings
.
delete_many
({
"memo_id"
:
str
(
doc
[
"_id"
])})
...
...
@@ -969,39 +998,67 @@ class MemoEmbeddingService:
top_k
:
int
=
5
,
user_id
:
str
|
None
=
None
,
)
->
list
[
dict
]:
"""Search memos by embedding similarity."""
cursor
=
mongodb_client
.
memo_embeddings
.
find
({})
docs
=
await
cursor
.
to_list
(
length
=
1000
)
if
not
docs
:
"""Search memos by embedding similarity using server-side aggregation."""
q
=
np
.
array
(
query_embedding
,
dtype
=
float
)
q_norm
=
float
(
np
.
linalg
.
norm
(
q
))
if
q_norm
==
0
:
return
[]
q
=
np
.
array
(
query_embedding
,
dtype
=
float
)
results
:
list
[
dict
[
str
,
Any
]]
=
[]
# Normalize query vector once (for cosine similarity
)
q_normalized
=
(
q
/
q_norm
)
.
tolist
()
for
doc
in
docs
:
emb
=
doc
.
get
(
"embedding"
,
[])
if
not
emb
:
continue
# Build match filter — only load this user's embeddings
match_filter
:
dict
[
str
,
Any
]
=
{}
if
user_id
and
user_id
!=
"anonymous"
:
match_filter
[
"creator_id"
]
=
user_id
# Use aggregation pipeline to compute cosine similarity server-side
pipeline
:
list
[
dict
[
str
,
Any
]]
=
[]
v
=
np
.
array
(
emb
,
dtype
=
float
)
if
v
.
shape
!=
q
.
shape
:
continue
if
match_filter
:
pipeline
.
append
({
"$match"
:
match_filter
})
denom
=
np
.
linalg
.
norm
(
q
)
*
np
.
linalg
.
norm
(
v
)
sim
=
float
(
np
.
dot
(
q
,
v
)
/
denom
)
if
denom
!=
0
else
0.0
# Filter docs that have embeddings
pipeline
.
append
({
"$match"
:
{
"embedding"
:
{
"$exists"
:
True
,
"$ne"
:
[]}}})
results
.
append
(
# Compute dot product with normalized query (= cosine sim if embeddings are normalized)
# Using $reduce to compute dot product server-side
pipeline
.
extend
([
{
"memo_id"
:
doc
.
get
(
"memo_id"
),
"content"
:
doc
.
get
(
"content"
,
""
),
"tags"
:
doc
.
get
(
"tags"
,
[]),
"score"
:
sim
,
"$addFields"
:
{
"score"
:
{
"$reduce"
:
{
"input"
:
{
"$zip"
:
{
"inputs"
:
[
"$embedding"
,
q_normalized
]}},
"initialValue"
:
0
,
"in"
:
{
"$add"
:
[
"$$value"
,
{
"$multiply"
:
[
{
"$arrayElemAt"
:
[
"$$this"
,
0
]},
{
"$arrayElemAt"
:
[
"$$this"
,
1
]},
]},
]
},
}
)
}
}
},
{
"$sort"
:
{
"score"
:
-
1
}},
{
"$limit"
:
top_k
},
{
"$project"
:
{
"_id"
:
0
,
"memo_id"
:
1
,
"content"
:
1
,
"tags"
:
1
,
"score"
:
1
,
}
},
])
results
.
sort
(
key
=
lambda
r
:
r
[
"score"
],
reverse
=
True
)
return
results
[:
top_k
]
cursor
=
mongodb_client
.
memo_embeddings
.
aggregate
(
pipeline
)
results
=
await
cursor
.
to_list
(
length
=
top_k
)
return
results
class
InboxService
:
...
...
backend/run.py
deleted
100644 → 0
View file @
97cb2112
"""
CANIFA Fashion Agent - Terminal Tester
Test agent với full flow: LLM + Tools + MongoDB Memory (No Postgres Checkpoint)
"""
import
asyncio
import
platform
import
sys
from
pathlib
import
Path
# Add parent dir to sys.path
sys
.
path
.
insert
(
0
,
str
(
Path
(
__file__
)
.
parent
))
from
langchain_core.messages
import
AIMessage
,
HumanMessage
# from langfuse import get_client, observe, propagate_attributes # TẮT TẠM
# from langfuse.langchain import CallbackHandler # TẮT TẠM
from
agent.graph
import
build_graph
from
agent.models
import
get_config
async
def
main
():
import
logging
# Tắt log rác của LangChain để terminal sạch sẽ
logging
.
getLogger
(
"langchain"
)
.
setLevel
(
logging
.
ERROR
)
logging
.
getLogger
(
"langgraph"
)
.
setLevel
(
logging
.
ERROR
)
logging
.
getLogger
(
"langfuse"
)
.
setLevel
(
logging
.
ERROR
)
# TẮT Langfuse logs
# 📝 Enable INFO logs for Data Retrieval & Embedding tools (Timing & Debug)
logging
.
basicConfig
(
level
=
logging
.
INFO
,
format
=
"
%(message)
s"
)
# Bật INFO để thấy tool calls
logging
.
getLogger
(
"agent.tools.data_retrieval_tool"
)
.
setLevel
(
logging
.
INFO
)
logging
.
getLogger
(
"agent.tools.product_search_helpers"
)
.
setLevel
(
logging
.
INFO
)
print
(
"
\n
"
+
"="
*
50
)
print
(
"🚀 CANIFA FASHION AGENT - TERMINAL TESTER (MEM V2)"
)
print
(
"="
*
50
)
try
:
# 1. Init Langfuse (Monitor tool) - TẮT TẠM ĐỂ TRÁNH RATE LIMIT
# from langfuse import Langfuse
# Langfuse() # Initialize singleton
# 2. Build Graph
config
=
get_config
()
graph
=
build_graph
(
config
)
print
(
"✅ System: Graph & MongoDB Memory Ready!"
)
except
Exception
as
e
:
print
(
f
"❌ System Error: {e}"
)
import
traceback
traceback
.
print_exc
()
return
print
(
"
\n
💬 CiCi: 'Em chào anh/chị ạ! Em là CiCi, stylist riêng của mình đây. Anh/chị cần em tư vấn gì không ạ?'"
)
print
(
"(Gõ 'q' để thoát, 'image' để giả lập gửi ảnh)"
)
conversation_id
=
"test_terminal_session_v2"
user_id
=
"tester_01"
while
True
:
query
=
input
(
"
\n
[User]: "
)
.
strip
()
if
query
.
lower
()
in
[
"exit"
,
"q"
]:
break
if
not
query
:
continue
# Giả lập gửi ảnh
if
query
.
lower
()
==
"image"
:
print
(
"📸 [System]: Đã giả lập gửi 1 ảnh (base64)."
)
query
=
"Mẫu này chất liệu gì vậy em?"
print
(
f
"[User]: {query}"
)
# ⚙️ STREAMING MODE - Bật/Tắt
ENABLE_STREAMING
=
True
print
(
"⏳ CiCi is thinking..."
)
# 🎯 Call wrapped function - each call = 1 trace
await
run_single_query
(
graph
=
graph
,
query
=
query
,
conversation_id
=
conversation_id
,
user_id
=
user_id
,
enable_streaming
=
ENABLE_STREAMING
,
)
print
(
"
\n
👋 CiCi: 'Cảm ơn anh/chị đã ghé thăm CANIFA. Hẹn gặp lại nhé!'"
)
# @observe() # TẮT TẠM - Tránh Langfuse rate limit
async
def
run_single_query
(
graph
,
query
:
str
,
conversation_id
:
str
,
user_id
:
str
,
enable_streaming
:
bool
=
True
):
"""Run single query - Langfuse DISABLED"""
import
logging
logger
=
logging
.
getLogger
(
__name__
)
# Load History từ MongoDB
history
=
[]
current_human_msg
=
HumanMessage
(
content
=
query
)
input_state
=
{
"messages"
:
[
current_human_msg
],
"history"
:
history
,
"user_id"
:
user_id
,
}
# TẮT Langfuse callback
# langfuse_handler = CallbackHandler()
config_runnable
=
{
"configurable"
:
{
"conversation_id"
:
conversation_id
,
"user_id"
:
user_id
,
"transient_images"
:
[]},
# "callbacks": [langfuse_handler], # TẮT
}
final_ai_message
=
None
ai_content
=
""
try
:
# Chạy Stream
if
enable_streaming
:
print
(
"
\n
👸 CiCi: "
,
end
=
""
,
flush
=
True
)
async
for
event
in
graph
.
astream
(
input_state
,
config
=
config_runnable
,
stream_mode
=
"values"
):
if
"messages"
in
event
:
msg
=
event
[
"messages"
][
-
1
]
# 🔍 LOG TOOL CALLS
if
hasattr
(
msg
,
"tool_calls"
)
and
msg
.
tool_calls
:
logger
.
info
(
f
"
\n
🛠️ TOOL CALLED: {[tc['name'] for tc in msg.tool_calls]}"
)
if
isinstance
(
msg
,
AIMessage
)
and
msg
.
content
:
final_ai_message
=
msg
# STREAMING MODE: In từng đoạn content
if
enable_streaming
and
msg
.
content
!=
ai_content
:
new_content
=
msg
.
content
[
len
(
ai_content
)
:]
print
(
new_content
,
end
=
""
,
flush
=
True
)
ai_content
=
msg
.
content
# Nếu không stream, in toàn bộ
if
not
enable_streaming
and
final_ai_message
:
print
(
f
"
\n
👸 CiCi: {final_ai_message.content}"
)
else
:
print
()
# Xuống dòng
if
final_ai_message
:
# Lưu History mới
new_history
=
[
*
history
,
current_human_msg
,
final_ai_message
]
# TẮT Langfuse update
# langfuse = get_client()
# langfuse.update_current_trace(...)
print
(
"[System]: ✅ Response complete"
)
except
Exception
as
e
:
print
(
f
"
\n
❌ Error during execution: {e}"
)
import
traceback
traceback
.
print_exc
()
if
__name__
==
"__main__"
:
if
platform
.
system
()
==
"Windows"
:
asyncio
.
set_event_loop_policy
(
asyncio
.
WindowsSelectorEventLoopPolicy
())
asyncio
.
run
(
main
())
backend/server.py
View file @
30891fb7
...
...
@@ -29,11 +29,7 @@ logging.basicConfig(
)
logger
=
logging
.
getLogger
(
__name__
)
langfuse_client
=
get_langfuse_client
()
if
langfuse_client
:
logger
.
info
(
"Langfuse client ready (lazy loading)"
)
else
:
logger
.
warning
(
"Langfuse client not available (missing keys or disabled)"
)
# Langfuse client initialized in startup_event (not at import time)
app
=
FastAPI
(
title
=
"Contract AI Service"
,
...
...
@@ -60,6 +56,13 @@ async def startup_event():
await
init_mongodb
()
logger
.
info
(
"✅ MongoDB connection initialized"
)
# Langfuse initialization (optional - lazy loaded, just triggers auth check)
langfuse_client
=
get_langfuse_client
()
if
langfuse_client
:
logger
.
info
(
"✅ Langfuse client ready"
)
else
:
logger
.
warning
(
"⚠️ Langfuse client not available (missing keys or disabled)"
)
@
app
.
on_event
(
"shutdown"
)
async
def
shutdown_event
():
...
...
backend/test_redis.py
deleted
100644 → 0
View file @
97cb2112
"""Simple Redis connection test."""
import
asyncio
import
redis.asyncio
as
aioredis
async
def
test_redis
():
try
:
client
=
aioredis
.
Redis
(
host
=
'redis-14473.c93.us-east-1-3.ec2.cloud.redislabs.com'
,
port
=
14473
,
password
=
'4kCCXXaJXXv7k358eG69p1lDBQtHTbQ1'
,
username
=
'default'
,
decode_responses
=
True
,
socket_connect_timeout
=
10
,
)
result
=
await
client
.
ping
()
print
(
f
"✅ Redis PING: {result}"
)
# Test set/get
await
client
.
set
(
"test_key"
,
"hello_opennotion"
)
value
=
await
client
.
get
(
"test_key"
)
print
(
f
"✅ Redis SET/GET: {value}"
)
await
client
.
close
()
print
(
"✅ Redis connection successful!"
)
except
Exception
as
e
:
print
(
f
"❌ Redis error: {e}"
)
if
__name__
==
"__main__"
:
asyncio
.
run
(
test_redis
())
frontend/package-lock.json
View file @
30891fb7
...
...
@@ -17,6 +17,7 @@
"@radix-ui/react-checkbox"
:
"^1.3.3"
,
"@radix-ui/react-dialog"
:
"^1.1.15"
,
"@radix-ui/react-dropdown-menu"
:
"^2.1.16"
,
"@radix-ui/react-hover-card"
:
"^1.1.15"
,
"@radix-ui/react-label"
:
"^2.1.8"
,
"@radix-ui/react-popover"
:
"^1.1.15"
,
"@radix-ui/react-radio-group"
:
"^1.3.8"
,
...
...
@@ -30,6 +31,7 @@
"@tanstack/react-query-devtools"
:
"^5.91.1"
,
"class-variance-authority"
:
"^0.7.1"
,
"clsx"
:
"^2.1.1"
,
"cmdk"
:
"^1.1.1"
,
"copy-to-clipboard"
:
"^3.3.3"
,
"dayjs"
:
"^1.11.19"
,
"fuse.js"
:
"^7.1.0"
,
...
...
@@ -70,6 +72,7 @@
"devDependencies"
:
{
"@biomejs/biome"
:
"^2.3.7"
,
"@bufbuild/protobuf"
:
"^2.10.1"
,
"@playwright/test"
:
"^1.48.0"
,
"@types/d3"
:
"^7.4.3"
,
"@types/hast"
:
"^3.0.4"
,
"@types/katex"
:
"^0.16.7"
,
...
...
@@ -3621,6 +3624,22 @@
"integrity"
:
"sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
,
"license"
:
"MIT"
},
"node_modules/@playwright/test"
:
{
"version"
:
"1.58.2"
,
"resolved"
:
"https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz"
,
"integrity"
:
"sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="
,
"dev"
:
true
,
"license"
:
"Apache-2.0"
,
"dependencies"
:
{
"playwright"
:
"1.58.2"
},
"bin"
:
{
"playwright"
:
"cli.js"
},
"engines"
:
{
"node"
:
">=18"
}
},
"node_modules/@protobuf-ts/protoc"
:
{
"version"
:
"2.11.1"
,
"resolved"
:
"https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz"
,
...
...
@@ -3934,6 +3953,37 @@
}
}
},
"node_modules/@radix-ui/react-hover-card"
:
{
"version"
:
"1.1.15"
,
"resolved"
:
"https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz"
,
"integrity"
:
"sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="
,
"license"
:
"MIT"
,
"dependencies"
:
{
"@radix-ui/primitive"
:
"1.1.3"
,
"@radix-ui/react-compose-refs"
:
"1.1.2"
,
"@radix-ui/react-context"
:
"1.1.2"
,
"@radix-ui/react-dismissable-layer"
:
"1.1.11"
,
"@radix-ui/react-popper"
:
"1.2.8"
,
"@radix-ui/react-portal"
:
"1.1.9"
,
"@radix-ui/react-presence"
:
"1.1.5"
,
"@radix-ui/react-primitive"
:
"2.1.3"
,
"@radix-ui/react-use-controllable-state"
:
"1.2.2"
},
"peerDependencies"
:
{
"@types/react"
:
"*"
,
"@types/react-dom"
:
"*"
,
"react"
:
"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
,
"react-dom"
:
"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta"
:
{
"@types/react"
:
{
"optional"
:
true
},
"@types/react-dom"
:
{
"optional"
:
true
}
}
},
"node_modules/@radix-ui/react-id"
:
{
"version"
:
"1.1.1"
,
"resolved"
:
"https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz"
,
...
...
@@ -6738,6 +6788,22 @@
"node"
:
">=6"
}
},
"node_modules/cmdk"
:
{
"version"
:
"1.1.1"
,
"resolved"
:
"https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz"
,
"integrity"
:
"sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="
,
"license"
:
"MIT"
,
"dependencies"
:
{
"@radix-ui/react-compose-refs"
:
"^1.1.1"
,
"@radix-ui/react-dialog"
:
"^1.1.6"
,
"@radix-ui/react-id"
:
"^1.1.0"
,
"@radix-ui/react-primitive"
:
"^2.0.2"
},
"peerDependencies"
:
{
"react"
:
"^18 || ^19 || ^19.0.0-rc"
,
"react-dom"
:
"^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert"
:
{
"version"
:
"2.0.1"
,
"resolved"
:
"https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
,
...
...
@@ -11433,6 +11499,53 @@
"pathe"
:
"^2.0.1"
}
},
"node_modules/playwright"
:
{
"version"
:
"1.58.2"
,
"resolved"
:
"https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz"
,
"integrity"
:
"sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="
,
"dev"
:
true
,
"license"
:
"Apache-2.0"
,
"dependencies"
:
{
"playwright-core"
:
"1.58.2"
},
"bin"
:
{
"playwright"
:
"cli.js"
},
"engines"
:
{
"node"
:
">=18"
},
"optionalDependencies"
:
{
"fsevents"
:
"2.3.2"
}
},
"node_modules/playwright-core"
:
{
"version"
:
"1.58.2"
,
"resolved"
:
"https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz"
,
"integrity"
:
"sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="
,
"dev"
:
true
,
"license"
:
"Apache-2.0"
,
"bin"
:
{
"playwright-core"
:
"cli.js"
},
"engines"
:
{
"node"
:
">=18"
}
},
"node_modules/playwright/node_modules/fsevents"
:
{
"version"
:
"2.3.2"
,
"resolved"
:
"https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
,
"integrity"
:
"sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="
,
"dev"
:
true
,
"hasInstallScript"
:
true
,
"license"
:
"MIT"
,
"optional"
:
true
,
"os"
:
[
"darwin"
],
"engines"
:
{
"node"
:
"^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/points-on-curve"
:
{
"version"
:
"0.2.0"
,
"resolved"
:
"https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz"
,
...
...
frontend/package.json
View file @
30891fb7
...
...
@@ -25,6 +25,7 @@
"@radix-ui/react-checkbox"
:
"^1.3.3"
,
"@radix-ui/react-dialog"
:
"^1.1.15"
,
"@radix-ui/react-dropdown-menu"
:
"^2.1.16"
,
"@radix-ui/react-hover-card"
:
"^1.1.15"
,
"@radix-ui/react-label"
:
"^2.1.8"
,
"@radix-ui/react-popover"
:
"^1.1.15"
,
"@radix-ui/react-radio-group"
:
"^1.3.8"
,
...
...
@@ -38,6 +39,7 @@
"@tanstack/react-query-devtools"
:
"^5.91.1"
,
"class-variance-authority"
:
"^0.7.1"
,
"clsx"
:
"^2.1.1"
,
"cmdk"
:
"^1.1.1"
,
"copy-to-clipboard"
:
"^3.3.3"
,
"dayjs"
:
"^1.11.19"
,
"fuse.js"
:
"^7.1.0"
,
...
...
@@ -78,6 +80,7 @@
"devDependencies"
:
{
"@biomejs/biome"
:
"^2.3.7"
,
"@bufbuild/protobuf"
:
"^2.10.1"
,
"@playwright/test"
:
"^1.48.0"
,
"@types/d3"
:
"^7.4.3"
,
"@types/hast"
:
"^3.0.4"
,
"@types/katex"
:
"^0.16.7"
,
...
...
@@ -92,7 +95,6 @@
"@types/unist"
:
"^3.0.3"
,
"@types/uuid"
:
"^10.0.0"
,
"@vitejs/plugin-react"
:
"^4.7.0"
,
"@playwright/test"
:
"^1.48.0"
,
"long"
:
"^5.3.2"
,
"terser"
:
"^5.44.1"
,
"tw-animate-css"
:
"^1.4.0"
,
...
...
frontend/src/components/ActivityCalendar/CalendarCell.tsx
View file @
30891fb7
...
...
@@ -35,7 +35,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
const
ariaLabel
=
day
.
isSelected
?
`
${
tooltipText
}
(selected)`
:
tooltipText
;
if
(
!
day
.
isCurrentMonth
)
{
return
<
div
className=
{
cn
(
baseClasses
,
"text-muted-foreground/
3
0 bg-transparent cursor-default"
)
}
>
{
day
.
label
}
</
div
>;
return
<
div
className=
{
cn
(
baseClasses
,
"text-muted-foreground/
2
0 bg-transparent cursor-default"
)
}
>
{
day
.
label
}
</
div
>;
}
const
intensityClass
=
getCellIntensityClass
(
day
,
maxCount
);
...
...
@@ -43,9 +43,10 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
const
buttonClasses
=
cn
(
baseClasses
,
intensityClass
,
day
.
isToday
&&
"ring-2 ring-primary/30 ring-offset-1 font-semibold z-10"
,
day
.
isSelected
&&
"ring-2 ring-primary ring-offset-1 font-bold z-10"
,
isInteractive
?
"cursor-pointer hover:scale-110 hover:shadow-md hover:z-20"
:
"cursor-default"
,
day
.
isToday
&&
"ring-2 ring-amber-500/50 ring-offset-1 ring-offset-background font-bold z-10"
,
day
.
isSelected
&&
"ring-2 ring-amber-500 ring-offset-1 ring-offset-background font-bold z-10"
,
day
.
isWeekend
&&
day
.
count
===
0
&&
"text-rose-400/70 dark:text-rose-400/50"
,
isInteractive
?
"cursor-pointer hover:scale-110 hover:shadow-md hover:z-20 active:scale-95"
:
"cursor-default"
,
);
const
button
=
(
...
...
frontend/src/components/ActivityCalendar/MonthCalendar.tsx
View file @
30891fb7
...
...
@@ -32,14 +32,28 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => {
return
(
<
div
className=
{
cn
(
"flex flex-col gap-2 relative group"
,
className
)
}
>
<
div
className=
{
cn
(
"grid grid-cols-7"
,
sizeConfig
.
gap
,
"text-muted-foreground mb-1"
,
size
===
"small"
?
"text-[10px]"
:
"text-xs"
)
}
>
{
rotatedWeekDays
.
map
((
label
,
index
)
=>
(
<
div
key=
{
index
}
className=
"flex h-4 items-center justify-center text-muted-foreground/60 font-medium"
>
{
/* Weekday header */
}
<
div
className=
{
cn
(
"grid grid-cols-7"
,
sizeConfig
.
gap
,
"mb-1"
,
size
===
"small"
?
"text-[10px]"
:
"text-xs"
)
}
>
{
rotatedWeekDays
.
map
((
label
,
index
)
=>
{
// Highlight weekend headers
const
isWeekendHeader
=
index
===
0
||
index
===
6
;
return
(
<
div
key=
{
`weekday-${label}`
}
className=
{
cn
(
"flex h-5 items-center justify-center font-semibold uppercase tracking-wider"
,
isWeekendHeader
?
"text-rose-400/60 dark:text-rose-400/40"
:
"text-muted-foreground/50"
,
)
}
>
{
label
}
</
div
>
))
}
);
})
}
</
div
>
{
/* Calendar grid */
}
<
div
className=
{
cn
(
"grid grid-cols-7"
,
sizeConfig
.
gap
)
}
>
{
weeks
.
map
((
week
,
weekIndex
)
=>
week
.
days
.
map
((
day
,
dayIndex
)
=>
{
...
...
frontend/src/components/ActivityCalendar/constants.ts
View file @
30891fb7
...
...
@@ -13,23 +13,24 @@ export const INTENSITY_THRESHOLDS = {
MINIMAL
:
0
,
}
as
const
;
// Year of the Horse 🐎 — Warm amber/gold palette
export
const
CELL_STYLES
=
{
HIGH
:
"bg-
primary text-primary-foreground shadow-sm
"
,
MEDIUM
:
"bg-
primary/80 text-primary-foreground shadow-sm
"
,
LOW
:
"bg-
primary/60 text-primary-foreground shadow-sm
"
,
MINIMAL
:
"bg-
primary/40 text-foreground
"
,
EMPTY
:
"bg-secondary/
30 text-muted-foreground hover:bg-secondary/5
0"
,
HIGH
:
"bg-
gradient-to-br from-amber-500 to-orange-600 text-white shadow-sm shadow-amber-500/30 font-semibold
"
,
MEDIUM
:
"bg-
gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm shadow-amber-400/20
"
,
LOW
:
"bg-
amber-400/70 text-amber-950
"
,
MINIMAL
:
"bg-
amber-300/40 text-amber-900 dark:bg-amber-400/20 dark:text-amber-200
"
,
EMPTY
:
"bg-secondary/
40 text-muted-foreground hover:bg-secondary/6
0"
,
}
as
const
;
export
const
SMALL_CELL_SIZE
=
{
font
:
"text-xs"
,
dimensions
:
"w-8 h-8 mx-auto"
,
borderRadius
:
"rounded-
md
"
,
borderRadius
:
"rounded-
lg
"
,
gap
:
"gap-1"
,
}
as
const
;
export
const
DEFAULT_CELL_SIZE
=
{
font
:
"text-xs"
,
borderRadius
:
"rounded-
md
"
,
borderRadius
:
"rounded-
lg
"
,
gap
:
"gap-1.5"
,
}
as
const
;
frontend/src/components/AttachmentIcon.tsx
View file @
30891fb7
...
...
@@ -49,6 +49,7 @@ const AttachmentIcon = (props: Props) => {
<
img
className=
"min-w-full min-h-full object-cover"
src=
{
getAttachmentThumbnailUrl
(
attachment
)
}
alt=
{
attachment
.
filename
||
"Attachment image"
}
onClick=
{
handleImageClick
}
onError=
{
(
e
)
=>
{
// Fallback to original image if thumbnail fails
...
...
frontend/src/components/AuthFooter.tsx
View file @
30891fb7
...
...
@@ -13,7 +13,7 @@ interface Props {
const
AuthFooter
=
({
className
}:
Props
)
=>
{
const
{
i18n
:
i18nInstance
}
=
useTranslation
();
const
currentLocale
=
i18nInstance
.
language
as
Locale
;
const
[
currentTheme
,
setCurrentTheme
]
=
useState
(
getInitialTheme
());
const
[
currentTheme
,
setCurrentTheme
]
=
useState
(
()
=>
getInitialTheme
());
const
handleLocaleChange
=
(
locale
:
Locale
)
=>
{
loadLocale
(
locale
);
...
...
frontend/src/components/ChatbotWidget.tsx
View file @
30891fb7
...
...
@@ -188,8 +188,8 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
useEffect
(()
=>
{
if
(
isDragging
)
{
window
.
addEventListener
(
"touchmove"
,
handleTouchMove
,
{
passive
:
fals
e
});
window
.
addEventListener
(
"touchend"
,
handleTouchEnd
);
window
.
addEventListener
(
"touchmove"
,
handleTouchMove
,
{
passive
:
tru
e
});
window
.
addEventListener
(
"touchend"
,
handleTouchEnd
,
{
passive
:
true
}
);
return
()
=>
{
window
.
removeEventListener
(
"touchmove"
,
handleTouchMove
);
window
.
removeEventListener
(
"touchend"
,
handleTouchEnd
);
...
...
frontend/src/components/CommandPalette.tsx
0 → 100644
View file @
30891fb7
import
{
Command
}
from
"cmdk"
;
import
{
ArchiveIcon
,
BookOpenIcon
,
EarthIcon
,
FileTextIcon
,
InboxIcon
,
InfoIcon
,
PaperclipIcon
,
PlusIcon
,
SearchIcon
,
SettingsIcon
,
}
from
"lucide-react"
;
import
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
useNavigate
}
from
"react-router-dom"
;
import
{
memoServiceClient
}
from
"@/service"
;
import
{
create
}
from
"@bufbuild/protobuf"
;
import
{
ListMemosRequestSchema
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
ROUTES
}
from
"@/router/routes"
;
// Helper to extract plain text snippet from memo content
function
getSnippet
(
content
:
string
,
maxLen
=
80
):
string
{
const
plain
=
content
.
replace
(
/
[
#*_~`>
\[\]
()!
]
/g
,
""
)
.
replace
(
/
\n
+/g
,
" "
)
.
trim
();
return
plain
.
length
>
maxLen
?
plain
.
slice
(
0
,
maxLen
)
+
"…"
:
plain
;
}
const
CommandPalette
=
()
=>
{
const
[
open
,
setOpen
]
=
useState
(
false
);
const
[
query
,
setQuery
]
=
useState
(
""
);
const
[
memos
,
setMemos
]
=
useState
<
Array
<
{
name
:
string
;
snippet
:
string
}
>>
([]);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
navigate
=
useNavigate
();
const
currentUser
=
useCurrentUser
();
const
debounceRef
=
useRef
<
ReturnType
<
typeof
setTimeout
>>
();
// Global ⌘K / Ctrl+K shortcut
useEffect
(()
=>
{
const
handler
=
(
e
:
KeyboardEvent
)
=>
{
if
((
e
.
metaKey
||
e
.
ctrlKey
)
&&
e
.
key
===
"k"
)
{
e
.
preventDefault
();
setOpen
((
prev
)
=>
!
prev
);
}
};
document
.
addEventListener
(
"keydown"
,
handler
);
return
()
=>
document
.
removeEventListener
(
"keydown"
,
handler
);
},
[]);
// Search memos when query changes
const
searchMemos
=
useCallback
(
async
(
searchQuery
:
string
)
=>
{
if
(
!
searchQuery
.
trim
()
||
!
currentUser
)
{
setMemos
([]);
return
;
}
setLoading
(
true
);
try
{
const
response
=
await
memoServiceClient
.
listMemos
(
create
(
ListMemosRequestSchema
,
{
pageSize
:
8
,
filter
:
`creator == "
${
currentUser
.
name
}
" && content_search == ["
${
searchQuery
.
trim
()}
"]`
,
}),
);
setMemos
(
response
.
memos
.
map
((
m
)
=>
({
name
:
m
.
name
,
snippet
:
getSnippet
(
m
.
content
),
})),
);
}
catch
{
setMemos
([]);
}
finally
{
setLoading
(
false
);
}
},
[
currentUser
],
);
// Debounced search
useEffect
(()
=>
{
if
(
debounceRef
.
current
)
clearTimeout
(
debounceRef
.
current
);
debounceRef
.
current
=
setTimeout
(()
=>
searchMemos
(
query
),
250
);
return
()
=>
{
if
(
debounceRef
.
current
)
clearTimeout
(
debounceRef
.
current
);
};
},
[
query
,
searchMemos
]);
const
goTo
=
(
path
:
string
)
=>
{
navigate
(
path
);
setOpen
(
false
);
setQuery
(
""
);
};
if
(
!
open
)
return
null
;
return
(
<
div
className=
"fixed inset-0 z-[999]"
onClick=
{
()
=>
setOpen
(
false
)
}
>
<
div
className=
"fixed inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in duration-150"
/>
<
div
className=
"fixed inset-0 flex items-start justify-center pt-[20vh]"
>
<
div
className=
"w-full max-w-lg animate-in slide-in-from-top-4 fade-in duration-200"
onClick=
{
(
e
)
=>
e
.
stopPropagation
()
}
>
<
Command
className=
"rounded-xl border border-border bg-background shadow-2xl overflow-hidden"
shouldFilter=
{
false
}
>
{
/* Search Input */
}
<
div
className=
"flex items-center gap-2 border-b border-border px-4 py-3"
>
<
SearchIcon
className=
"w-5 h-5 text-muted-foreground shrink-0"
/>
<
Command
.
Input
value=
{
query
}
onValueChange=
{
setQuery
}
placeholder=
"Search memos, navigate pages..."
className=
"flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
autoFocus
/>
<
kbd
className=
"hidden sm:inline-flex h-5 items-center gap-1 rounded border border-border bg-muted px-1.5 text-[10px] font-medium text-muted-foreground"
>
ESC
</
kbd
>
</
div
>
<
Command
.
List
className=
"max-h-80 overflow-y-auto p-2"
>
{
/* No results */
}
<
Command
.
Empty
className=
"py-8 text-center text-sm text-muted-foreground"
>
{
loading
?
(
<
span
className=
"flex items-center justify-center gap-2"
>
<
span
className=
"w-4 h-4 border-2 border-amber-500 border-t-transparent rounded-full animate-spin"
/>
Searching...
</
span
>
)
:
query
?
(
"No results found."
)
:
(
"Type to search memos..."
)
}
</
Command
.
Empty
>
{
/* Memo results */
}
{
memos
.
length
>
0
&&
(
<
Command
.
Group
heading=
"Memos"
>
{
memos
.
map
((
memo
)
=>
{
const
memoPath
=
memo
.
name
.
startsWith
(
"memos/"
)
?
memo
.
name
:
`memos/${memo.name}`
;
return
(
<
Command
.
Item
key=
{
memo
.
name
}
value=
{
memo
.
name
}
onSelect=
{
()
=>
goTo
(
`/app/${memoPath}`
)
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
FileTextIcon
className=
"w-4 h-4 text-amber-500 shrink-0"
/>
<
span
className=
"truncate text-foreground"
>
{
memo
.
snippet
}
</
span
>
</
Command
.
Item
>
);
})
}
</
Command
.
Group
>
)
}
{
/* Navigation */
}
{
!
query
&&
(
<>
<
Command
.
Group
heading=
"Navigate"
>
<
Command
.
Item
onSelect=
{
()
=>
goTo
(
ROUTES
.
ROOT
)
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
BookOpenIcon
className=
"w-4 h-4 text-muted-foreground"
/>
<
span
>
Home
</
span
>
<
kbd
className=
"ml-auto text-[10px] text-muted-foreground"
>
→
</
kbd
>
</
Command
.
Item
>
<
Command
.
Item
onSelect=
{
()
=>
goTo
(
ROUTES
.
EXPLORE
)
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
EarthIcon
className=
"w-4 h-4 text-muted-foreground"
/>
<
span
>
Explore
</
span
>
</
Command
.
Item
>
<
Command
.
Item
onSelect=
{
()
=>
goTo
(
ROUTES
.
INBOX
)
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
InboxIcon
className=
"w-4 h-4 text-muted-foreground"
/>
<
span
>
Inbox
</
span
>
</
Command
.
Item
>
<
Command
.
Item
onSelect=
{
()
=>
goTo
(
ROUTES
.
ARCHIVED
)
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
ArchiveIcon
className=
"w-4 h-4 text-muted-foreground"
/>
<
span
>
Archived
</
span
>
</
Command
.
Item
>
<
Command
.
Item
onSelect=
{
()
=>
goTo
(
ROUTES
.
ATTACHMENTS
)
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
PaperclipIcon
className=
"w-4 h-4 text-muted-foreground"
/>
<
span
>
Attachments
</
span
>
</
Command
.
Item
>
<
Command
.
Item
onSelect=
{
()
=>
goTo
(
ROUTES
.
SETTING
)
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
SettingsIcon
className=
"w-4 h-4 text-muted-foreground"
/>
<
span
>
Settings
</
span
>
</
Command
.
Item
>
<
Command
.
Item
onSelect=
{
()
=>
goTo
(
ROUTES
.
ABOUT
)
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
InfoIcon
className=
"w-4 h-4 text-muted-foreground"
/>
<
span
>
About
</
span
>
</
Command
.
Item
>
</
Command
.
Group
>
<
Command
.
Group
heading=
"Quick Actions"
>
<
Command
.
Item
onSelect=
{
()
=>
{
goTo
(
ROUTES
.
ROOT
);
}
}
className=
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm cursor-pointer select-none transition-colors data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground hover:bg-accent/50"
>
<
PlusIcon
className=
"w-4 h-4 text-amber-500"
/>
<
span
>
New Memo
</
span
>
<
kbd
className=
"ml-auto text-[10px] text-muted-foreground"
>
Ctrl+N
</
kbd
>
</
Command
.
Item
>
</
Command
.
Group
>
</>
)
}
</
Command
.
List
>
{
/* Footer */
}
<
div
className=
"border-t border-border px-4 py-2 flex items-center justify-between text-[11px] text-muted-foreground"
>
<
div
className=
"flex items-center gap-3"
>
<
span
className=
"flex items-center gap-1"
>
<
kbd
className=
"px-1 py-0.5 rounded border border-border bg-muted text-[10px]"
>
↑↓
</
kbd
>
navigate
</
span
>
<
span
className=
"flex items-center gap-1"
>
<
kbd
className=
"px-1 py-0.5 rounded border border-border bg-muted text-[10px]"
>
↵
</
kbd
>
select
</
span
>
</
div
>
<
span
className=
"flex items-center gap-1"
>
<
kbd
className=
"px-1 py-0.5 rounded border border-border bg-muted text-[10px]"
>
Ctrl K
</
kbd
>
toggle
</
span
>
</
div
>
</
Command
>
</
div
>
</
div
>
</
div
>
);
};
export
default
CommandPalette
;
frontend/src/components/CreateUserDialog.tsx
View file @
30891fb7
...
...
@@ -22,7 +22,7 @@ interface Props {
function
CreateUserDialog
({
open
,
onOpenChange
,
user
:
initialUser
,
onSuccess
}:
Props
)
{
const
t
=
useTranslate
();
const
[
user
,
setUser
]
=
useState
(
create
(
UserSchema
,
initialUser
?
{
username
:
initialUser
.
username
,
role
:
initialUser
.
role
}
:
{}));
const
[
user
,
setUser
]
=
useState
(
()
=>
create
(
UserSchema
,
initialUser
?
{
username
:
initialUser
.
username
,
role
:
initialUser
.
role
}
:
{}));
const
requestState
=
useLoading
(
false
);
const
isCreating
=
!
initialUser
;
...
...
frontend/src/components/MemoActionMenu/hooks.ts
View file @
30891fb7
...
...
@@ -4,7 +4,7 @@ import { useCallback } from "react";
import
toast
from
"react-hot-toast"
;
import
{
useLocation
}
from
"react-router-dom"
;
import
{
useInstance
}
from
"@/contexts/InstanceContext"
;
import
{
useDeleteMemo
,
useUpdateMemo
}
from
"@/hooks/useMemoQueries"
;
import
{
memoKeys
,
useDeleteMemo
,
useUpdateMemo
}
from
"@/hooks/useMemoQueries"
;
import
useNavigateTo
from
"@/hooks/useNavigateTo"
;
import
{
userKeys
}
from
"@/hooks/useUserQueries"
;
import
{
handleError
}
from
"@/lib/error"
;
...
...
@@ -55,31 +55,58 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
const
handleToggleMemoStatusClick
=
useCallback
(
async
()
=>
{
const
isArchiving
=
memo
.
state
!==
State
.
ARCHIVED
;
const
state
=
memo
.
state
===
State
.
ARCHIVED
?
State
.
NORMAL
:
State
.
ARCHIVED
;
const
message
=
memo
.
state
===
State
.
ARCHIVED
?
t
(
"message.restored-successfully"
)
:
t
(
"message.archiv
ed-successfully"
);
const
newState
=
isArchiving
?
State
.
ARCHIVED
:
State
.
NORMAL
;
const
message
=
isArchiving
?
t
(
"message.archived-successfully"
)
:
t
(
"message.restor
ed-successfully"
);
// Optimistic: remove memo from current list cache immediately
const
listQueries
=
queryClient
.
getQueriesData
<
{
pages
?:
Array
<
{
memos
:
Memo
[]
}
>
;
memos
?:
Memo
[]
}
>
({
queryKey
:
memoKeys
.
lists
()
});
const
snapshots
:
Array
<
{
queryKey
:
readonly
unknown
[];
data
:
unknown
}
>
=
[];
for
(
const
[
queryKey
,
data
]
of
listQueries
)
{
snapshots
.
push
({
queryKey
,
data
});
if
(
data
&&
"pages"
in
data
&&
data
.
pages
)
{
// Infinite query format
queryClient
.
setQueryData
(
queryKey
,
{
...
data
,
pages
:
data
.
pages
.
map
((
page
)
=>
({
...
page
,
memos
:
page
.
memos
.
filter
((
m
)
=>
m
.
name
!==
memo
.
name
),
})),
});
}
}
// Show success toast immediately
toast
.
success
(
message
);
// Navigate if on detail page
if
(
isInMemoDetailPage
)
{
navigateTo
(
isArchiving
?
"/"
:
"/archived"
);
}
// Fire API call in the background
try
{
await
updateMemo
({
update
:
{
name
:
memo
.
name
,
state
,
state
:
newState
,
},
updateMask
:
[
"state"
],
});
toast
.
success
(
message
);
}
catch
(
error
:
unknown
)
{
handleError
(
error
,
toast
.
error
,
{
// Rollback: restore previous cache
for
(
const
{
queryKey
,
data
}
of
snapshots
)
{
queryClient
.
setQueryData
(
queryKey
,
data
);
}
toast
.
error
(
`Failed to
${
isArchiving
?
"archive"
:
"restore"
}
memo`
);
handleError
(
error
,
()
=>
{
},
{
context
:
`
${
isArchiving
?
"Archive"
:
"Restore"
}
memo`
,
fallbackMessage
:
"An error occurred"
,
});
return
;
}
if
(
isInMemoDetailPage
)
{
navigateTo
(
memo
.
state
===
State
.
ARCHIVED
?
"/"
:
"/archived"
);
}
memoUpdatedCallback
();
},
[
memo
.
name
,
memo
.
state
,
t
,
isInMemoDetailPage
,
navigateTo
,
memoUpdatedCallback
,
updateMemo
]);
},
[
memo
.
name
,
memo
.
state
,
t
,
isInMemoDetailPage
,
navigateTo
,
memoUpdatedCallback
,
updateMemo
,
queryClient
]);
const
handleCopyLink
=
useCallback
(()
=>
{
// Always use frontend URL (window.location.origin) instead of backend URL
...
...
frontend/src/components/MemoContent/CodeBlock.tsx
View file @
30891fb7
...
...
@@ -21,17 +21,7 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
const
codeClassName
=
codeElement
?.
props
?.
className
||
""
;
const
codeContent
=
extractCodeContent
(
children
);
const
language
=
extractLanguage
(
codeClassName
);
// If it's a mermaid block, render with MermaidBlock component
if
(
language
===
"mermaid"
)
{
return
(
<
pre
className=
"relative"
>
<
MermaidBlock
className=
{
cn
(
className
)
}
{
...
props
}
>
{
children
}
</
MermaidBlock
>
</
pre
>
);
}
const
isMermaid
=
language
===
"mermaid"
;
const
theme
=
getThemeWithFallback
(
userGeneralSetting
?.
theme
);
const
resolvedTheme
=
resolveTheme
(
theme
);
...
...
@@ -39,6 +29,8 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
// Dynamically load highlight.js theme based on app theme
useEffect
(()
=>
{
if
(
isMermaid
)
return
;
const
dynamicImportStyle
=
async
()
=>
{
// Remove any existing highlight.js style
const
existingStyle
=
document
.
querySelector
(
"style[data-hljs-theme]"
);
...
...
@@ -62,10 +54,12 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
};
dynamicImportStyle
();
},
[
resolvedTheme
,
isDarkTheme
]);
},
[
resolvedTheme
,
isDarkTheme
,
isMermaid
]);
// Highlight code using highlight.js
const
highlightedCode
=
useMemo
(()
=>
{
if
(
isMermaid
)
return
""
;
try
{
const
lang
=
hljs
.
getLanguage
(
language
);
if
(
lang
)
{
...
...
@@ -81,7 +75,18 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
return
Object
.
assign
(
document
.
createElement
(
"span"
),
{
textContent
:
codeContent
,
}).
innerHTML
;
},
[
language
,
codeContent
]);
},
[
language
,
codeContent
,
isMermaid
]);
// If it's a mermaid block, render with MermaidBlock component (after all hooks)
if
(
isMermaid
)
{
return
(
<
pre
className=
"relative"
>
<
MermaidBlock
className=
{
cn
(
className
)
}
{
...
props
}
>
{
children
}
</
MermaidBlock
>
</
pre
>
);
}
const
handleCopy
=
async
()
=>
{
try
{
...
...
frontend/src/components/MemoContent/MermaidBlock.tsx
View file @
30891fb7
...
...
@@ -17,8 +17,7 @@ const getMermaidTheme = (appTheme: string): "default" | "dark" => {
export
const
MermaidBlock
=
({
children
,
className
}:
MermaidBlockProps
)
=>
{
const
{
userGeneralSetting
}
=
useAuth
();
const
containerRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
[
svg
,
setSvg
]
=
useState
<
string
>
(
""
);
const
[
error
,
setError
]
=
useState
<
string
>
(
""
);
const
[
renderResult
,
setRenderResult
]
=
useState
<
{
svg
:
string
;
error
:
string
}
>
({
svg
:
""
,
error
:
""
});
const
[
systemThemeChange
,
setSystemThemeChange
]
=
useState
(
0
);
const
codeContent
=
extractCodeContent
(
children
);
...
...
@@ -60,11 +59,10 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
});
const
{
svg
:
renderedSvg
}
=
await
mermaid
.
render
(
id
,
codeContent
);
setSvg
(
renderedSvg
);
setError
(
""
);
setRenderResult
({
svg
:
renderedSvg
,
error
:
""
});
}
catch
(
err
)
{
console
.
error
(
"Failed to render mermaid diagram:"
,
err
);
set
Error
(
err
instanceof
Error
?
err
.
message
:
"Failed to render diagram"
);
set
RenderResult
({
svg
:
""
,
error
:
err
instanceof
Error
?
err
.
message
:
"Failed to render diagram"
}
);
}
};
...
...
@@ -72,10 +70,10 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
},
[
codeContent
,
currentTheme
]);
// If there's an error, fall back to showing the code
if
(
error
)
{
if
(
renderResult
.
error
)
{
return
(
<
div
className=
"w-full"
>
<
div
className=
"text-sm text-destructive mb-2"
>
Mermaid Error:
{
error
}
</
div
>
<
div
className=
"text-sm text-destructive mb-2"
>
Mermaid Error:
{
renderResult
.
error
}
</
div
>
<
pre
className=
{
className
}
>
<
code
className=
"language-mermaid"
>
{
codeContent
}
</
code
>
</
pre
>
...
...
@@ -87,7 +85,7 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
<
div
ref=
{
containerRef
}
className=
{
cn
(
"mermaid-diagram w-full flex justify-center items-center my-4 overflow-x-auto"
,
className
)
}
dangerouslySetInnerHTML=
{
{
__html
:
svg
}
}
dangerouslySetInnerHTML=
{
{
__html
:
renderResult
.
svg
}
}
/>
);
};
frontend/src/components/MemoEditor/index.tsx
View file @
30891fb7
...
...
@@ -20,7 +20,7 @@ import { EditorProvider, useEditorContext } from "./state";
import
type
{
MemoEditorProps
}
from
"./types"
;
const
MemoEditor
=
(
props
:
MemoEditorProps
)
=>
{
const
{
className
,
cacheKey
,
memoName
,
parentMemoName
,
autoFocus
,
placeholder
,
onConfirm
,
onCancel
}
=
props
;
const
{
className
,
cacheKey
,
memoName
,
parentMemoName
,
autoFocus
,
placeholder
,
onConfirm
,
onCancel
,
anonymousId
,
anonymousName
}
=
props
;
return
(
<
EditorProvider
>
...
...
@@ -33,6 +33,8 @@ const MemoEditor = (props: MemoEditorProps) => {
placeholder=
{
placeholder
}
onConfirm=
{
onConfirm
}
onCancel=
{
onCancel
}
anonymousId=
{
anonymousId
}
anonymousName=
{
anonymousName
}
/>
</
EditorProvider
>
);
...
...
frontend/src/components/MemoExplorer/BookmarksSection.tsx
View file @
30891fb7
...
...
@@ -5,6 +5,7 @@ import { BookmarkIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import
type
{
Memo
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
useTranslate
}
from
"@/utils/i18n"
;
import
{
cn
}
from
"@/lib/utils"
;
import
MemoHoverCard
from
"@/components/MemoHoverCard"
;
import
{
useInfiniteMemos
}
from
"@/hooks/useMemoQueries"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
State
}
from
"@/types/proto/api/v1/common_pb"
;
...
...
@@ -110,8 +111,8 @@ const BookmarksSection = ({ className }: BookmarksSectionProps) => {
const
time
=
formatTime
(
memo
);
return
(
<
MemoHoverCard
key=
{
memo
.
name
}
content=
{
memo
.
content
||
""
}
>
<
Link
key=
{
memo
.
name
}
to=
{
`/${memoPath}`
}
className=
{
cn
(
"flex items-center justify-between"
,
...
...
@@ -129,6 +130,7 @@ const BookmarksSection = ({ className }: BookmarksSectionProps) => {
</
span
>
)
}
</
Link
>
</
MemoHoverCard
>
);
})
}
...
...
frontend/src/components/MemoHoverCard.tsx
0 → 100644
View file @
30891fb7
import
*
as
HoverCardPrimitive
from
"@radix-ui/react-hover-card"
;
import
{
FileTextIcon
}
from
"lucide-react"
;
import
{
cn
}
from
"@/lib/utils"
;
interface
Props
{
children
:
React
.
ReactNode
;
content
:
string
;
className
?:
string
;
}
function
getSnippet
(
content
:
string
,
maxLen
=
120
):
string
{
const
plain
=
content
.
replace
(
/
[
#*_~`>
\[\]
()!
]
/g
,
""
)
.
replace
(
/
\n
+/g
,
" "
)
.
trim
();
return
plain
.
length
>
maxLen
?
plain
.
slice
(
0
,
maxLen
)
+
"…"
:
plain
;
}
const
MemoHoverCard
=
({
children
,
content
,
className
}:
Props
)
=>
{
if
(
!
content
)
return
<>
{
children
}
</>;
return
(
<
HoverCardPrimitive
.
Root
openDelay=
{
300
}
closeDelay=
{
100
}
>
<
HoverCardPrimitive
.
Trigger
asChild
>
{
children
}
</
HoverCardPrimitive
.
Trigger
>
<
HoverCardPrimitive
.
Portal
>
<
HoverCardPrimitive
.
Content
side=
"right"
align=
"start"
sideOffset=
{
8
}
className=
{
cn
(
"z-50 w-72 rounded-xl border border-border bg-background/95 backdrop-blur-md p-4 shadow-lg"
,
"data-[state=open]:animate-in data-[state=closed]:animate-out"
,
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0"
,
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95"
,
"data-[side=right]:slide-in-from-left-2"
,
"data-[side=left]:slide-in-from-right-2"
,
"data-[side=bottom]:slide-in-from-top-2"
,
"data-[side=top]:slide-in-from-bottom-2"
,
className
,
)
}
>
<
div
className=
"flex items-start gap-3"
>
<
div
className=
"mt-0.5 p-1.5 rounded-lg bg-amber-500/10"
>
<
FileTextIcon
className=
"w-4 h-4 text-amber-500"
/>
</
div
>
<
div
className=
"flex-1 min-w-0"
>
<
p
className=
"text-xs font-medium text-muted-foreground mb-1"
>
Memo Preview
</
p
>
<
p
className=
"text-sm text-foreground leading-relaxed"
>
{
getSnippet
(
content
)
}
</
p
>
</
div
>
</
div
>
<
HoverCardPrimitive
.
Arrow
className=
"fill-border"
/>
</
HoverCardPrimitive
.
Content
>
</
HoverCardPrimitive
.
Portal
>
</
HoverCardPrimitive
.
Root
>
);
};
export
default
MemoHoverCard
;
frontend/src/components/MemoSkeleton.tsx
0 → 100644
View file @
30891fb7
import
{
cn
}
from
"@/lib/utils"
;
interface
Props
{
count
?:
number
;
className
?:
string
;
}
const
MemoSkeleton
=
({
count
=
3
,
className
}:
Props
)
=>
{
return
(
<
div
className=
{
cn
(
"w-full space-y-4"
,
className
)
}
>
{
Array
.
from
({
length
:
count
}).
map
((
_
,
i
)
=>
(
<
div
key=
{
i
}
className=
"rounded-xl border border-border bg-card p-4 space-y-3 animate-pulse"
>
{
/* Header: avatar + name + time */
}
<
div
className=
"flex items-center gap-3"
>
<
div
className=
"w-8 h-8 rounded-full bg-muted"
/>
<
div
className=
"space-y-1.5 flex-1"
>
<
div
className=
"h-3 w-24 rounded-md bg-muted"
/>
<
div
className=
"h-2.5 w-16 rounded-md bg-muted/60"
/>
</
div
>
</
div
>
{
/* Content lines */
}
<
div
className=
"space-y-2"
>
<
div
className=
"h-3 w-full rounded-md bg-muted"
/>
<
div
className=
"h-3 w-4/5 rounded-md bg-muted/80"
/>
<
div
className=
"h-3 w-3/5 rounded-md bg-muted/60"
/>
</
div
>
{
/* Footer: action buttons */
}
<
div
className=
"flex items-center gap-4 pt-1"
>
<
div
className=
"h-3 w-10 rounded-md bg-muted/50"
/>
<
div
className=
"h-3 w-10 rounded-md bg-muted/50"
/>
<
div
className=
"h-3 w-10 rounded-md bg-muted/50"
/>
</
div
>
</
div
>
))
}
</
div
>
);
};
export
default
MemoSkeleton
;
frontend/src/components/MemoView/MemoView.tsx
View file @
30891fb7
...
...
@@ -22,10 +22,13 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const
isAnonymousCreator
=
memoData
.
creator
.
startsWith
(
"users/anonymous_"
);
const
creator
=
useUser
(
memoData
.
creator
,
{
enabled
:
!
isAnonymousCreator
}).
data
;
const
isArchived
=
memoData
.
state
===
State
.
ARCHIVED
;
// In production, only the memo owner (or superuser) can edit/pin.
// When currentUser is undefined (still loading), default to false to allow owner actions
// The backend will validate permissions anyway when actions are taken.
const
readonly
=
currentUser
?
(
memoData
.
creator
!==
currentUser
.
name
&&
!
isSuperUser
(
currentUser
))
:
false
;
// Compare raw user IDs (strip "users/" prefix) for reliable ownership check.
// This avoids format mismatches between memoData.creator and currentUser.name.
const
getMemoUserId
=
(
name
:
string
)
=>
name
.
replace
(
/^users
\/
/
,
""
);
const
memoOwnerId
=
getMemoUserId
(
memoData
.
creator
);
const
currentUserId
=
currentUser
?
getMemoUserId
(
currentUser
.
name
)
:
null
;
console
.
log
(
"🔍 MemoView readonly check:"
,
{
memoCreator
:
memoData
.
creator
,
memoOwnerId
,
currentUserName
:
currentUser
?.
name
,
currentUserId
,
match
:
memoOwnerId
===
currentUserId
});
const
readonly
=
currentUserId
?
(
memoOwnerId
!==
currentUserId
&&
!
isSuperUser
(
currentUser
!
))
:
true
;
const
parentPage
=
parentPageProp
||
"/"
;
const
{
nsfw
,
showNSFWContent
,
toggleNsfwVisibility
}
=
useNsfwContent
(
memoData
,
props
.
showNsfwContent
);
...
...
frontend/src/components/Navigation.tsx
View file @
30891fb7
...
...
@@ -2,7 +2,6 @@ import { SignedIn, SignedOut } from "@clerk/clerk-react";
import
{
ArchiveIcon
,
BellIcon
,
BookOpenIcon
,
EarthIcon
,
LibraryIcon
,
PaperclipIcon
,
SettingsIcon
,
User2Icon
}
from
"lucide-react"
;
import
{
NavLink
}
from
"react-router-dom"
;
import
{
Tooltip
,
TooltipContent
,
TooltipProvider
,
TooltipTrigger
}
from
"@/components/ui/tooltip"
;
import
useCurrentUser
from
"@/hooks/useCurrentUser"
;
import
{
useNotifications
}
from
"@/hooks/useUserQueries"
;
import
{
cn
}
from
"@/lib/utils"
;
import
{
Routes
}
from
"@/router"
;
...
...
@@ -27,97 +26,80 @@ interface Props {
const
Navigation
=
(
props
:
Props
)
=>
{
const
{
collapsed
,
className
}
=
props
;
const
t
=
useTranslate
();
const
currentUser
=
useCurrentUser
();
const
{
data
:
notifications
=
[]
}
=
useNotifications
();
const
homeNavLink
:
NavLinkItem
=
{
const
unreadCount
=
notifications
.
filter
((
n
)
=>
n
.
status
===
UserNotification_Status
.
UNREAD
).
length
;
const
mainNavLinks
:
NavLinkItem
[]
=
[
{
id
:
"header-memos"
,
path
:
Routes
.
ROOT
,
title
:
t
(
"common.memos"
),
icon
:
<
LibraryIcon
className=
"w-6 h-auto
shrink-0"
/>,
};
const
exploreNavLink
:
NavLinkItem
=
{
icon
:
<
LibraryIcon
className=
"w-5 h-5
shrink-0"
/>,
},
{
id
:
"header-explore"
,
path
:
Routes
.
EXPLORE
,
title
:
t
(
"common.explore"
),
icon
:
<
EarthIcon
className=
"w-6 h-auto
shrink-0"
/>,
};
const
attachmentsNavLink
:
NavLinkItem
=
{
icon
:
<
EarthIcon
className=
"w-5 h-5
shrink-0"
/>,
},
{
id
:
"header-attachments"
,
path
:
Routes
.
ATTACHMENTS
,
title
:
t
(
"common.attachments"
),
icon
:
<
PaperclipIcon
className=
"w-6 h-auto
shrink-0"
/>,
icon
:
<
PaperclipIcon
className=
"w-5 h-5
shrink-0"
/>,
requiresAuth
:
true
,
};
const
unreadCount
=
notifications
.
filter
((
n
)
=>
n
.
status
===
UserNotification_Status
.
UNREAD
).
length
;
const
inboxNavLink
:
NavLinkItem
=
{
},
{
id
:
"header-inbox"
,
path
:
Routes
.
INBOX
,
title
:
t
(
"common.inbox"
),
icon
:
(
<
div
className=
"relative"
>
<
BellIcon
className=
"w-6 h-auto
shrink-0"
/>
<
BellIcon
className=
"w-5 h-5
shrink-0"
/>
{
unreadCount
>
0
&&
(
<
span
className=
"absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center bg-primary text-primary-foreground text-[10px] font-semibold rounded-full border-2 border-background
"
>
<
span
className=
"absolute -top-1.5 -right-1.5 min-w-[16px] h-[16px] px-0.5 flex items-center justify-center bg-amber-500 text-white text-[9px] font-bold rounded-full border-2 border-background animate-pulse
"
>
{
unreadCount
>
99
?
"99+"
:
unreadCount
}
</
span
>
)
}
</
div
>
),
requiresAuth
:
true
,
};
const
archivedNavLink
:
NavLinkItem
=
{
},
{
id
:
"header-archived"
,
path
:
Routes
.
ARCHIVED
,
title
:
t
(
"common.archived"
),
icon
:
<
ArchiveIcon
className=
"w-6 h-auto
shrink-0"
/>,
icon
:
<
ArchiveIcon
className=
"w-5 h-5
shrink-0"
/>,
requiresAuth
:
true
,
};
const
settingsNavLink
:
NavLinkItem
=
{
},
];
const
systemNavLinks
:
NavLinkItem
[]
=
[
{
id
:
"header-setting"
,
path
:
Routes
.
SETTING
,
title
:
t
(
"common.settings"
),
icon
:
<
SettingsIcon
className=
"w-6 h-auto
shrink-0"
/>,
icon
:
<
SettingsIcon
className=
"w-5 h-5
shrink-0"
/>,
requiresAuth
:
true
,
};
const
aboutNavLink
:
NavLinkItem
=
{
},
{
id
:
"header-about"
,
path
:
Routes
.
ABOUT
,
title
:
t
(
"common.about"
),
icon
:
<
BookOpenIcon
className=
"w-6 h-auto shrink-0"
/>,
};
const
navLinks
:
NavLinkItem
[]
=
[
homeNavLink
,
exploreNavLink
,
attachmentsNavLink
,
inboxNavLink
,
archivedNavLink
,
settingsNavLink
,
aboutNavLink
,
icon
:
<
BookOpenIcon
className=
"w-5 h-5 shrink-0"
/>,
},
];
return
(
<
header
className=
{
cn
(
"w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar"
,
className
)
}
>
<
div
className=
"w-full px-1 py-1 flex flex-col justify-start items-start space-y-2 overflow-auto overflow-x-hidden hide-scrollbar shrink"
>
<
SignedIn
>
<
NavLink
className=
"mb-3 cursor-default"
to=
{
Routes
.
ROOT
}
>
<
MemosLogo
collapsed=
{
collapsed
}
/>
</
NavLink
>
</
SignedIn
>
<
SignedOut
>
<
NavLink
className=
"mb-3 cursor-default"
to=
{
Routes
.
EXPLORE
}
>
<
MemosLogo
collapsed=
{
collapsed
}
/>
</
NavLink
>
</
SignedOut
>
{
navLinks
.
map
((
navLink
)
=>
(
const
renderNavLink
=
(
navLink
:
NavLinkItem
)
=>
(
<
NavLink
className=
{
({
isActive
})
=>
cn
(
"px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors
"
,
collapsed
?
""
:
"w-full px-4
"
,
"relative px-2 py-2 rounded-xl flex flex-row items-center text-sm text-sidebar-foreground transition-all duration-200 active:scale-[0.98]
"
,
collapsed
?
""
:
"w-full px-3
"
,
isActive
?
"bg-sidebar-accent text-sidebar-accent-foreground border-sidebar-accent-border drop-shadow
"
:
"border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80
"
,
?
"bg-sidebar-accent text-sidebar-accent-foreground font-semibold
"
:
"opacity-70 hover:opacity-100 hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground
"
,
)
}
key=
{
navLink
.
id
}
...
...
@@ -125,6 +107,12 @@ const Navigation = (props: Props) => {
id=
{
navLink
.
id
}
viewTransition
>
{
({
isActive
})
=>
(
<>
{
/* Active accent bar */
}
{
isActive
&&
(
<
div
className=
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 rounded-full bg-amber-500"
/>
)
}
{
props
.
collapsed
?
(
<
TooltipProvider
>
<
Tooltip
>
...
...
@@ -140,9 +128,50 @@ const Navigation = (props: Props) => {
navLink
.
icon
)
}
{
!
props
.
collapsed
&&
<
span
className=
"ml-3 truncate"
>
{
navLink
.
title
}
</
span
>
}
</>
)
}
</
NavLink
>
);
const
sectionLabel
=
(
label
:
string
)
=>
!
collapsed
?
(
<
div
className=
"px-3 pt-3 pb-1"
>
<
span
className=
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/40 select-none"
>
{
label
}
</
span
>
</
div
>
)
:
(
<
div
className=
"mx-auto my-1 w-4 border-t border-border/30"
/>
);
return
(
<
header
className=
{
cn
(
"w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar"
,
className
)
}
>
<
div
className=
"w-full px-1 py-1 flex flex-col justify-start items-start space-y-1 overflow-auto overflow-x-hidden hide-scrollbar shrink"
style=
{
{
maskImage
:
"linear-gradient(to bottom, black 85%, transparent 100%)"
,
WebkitMaskImage
:
"linear-gradient(to bottom, black 85%, transparent 100%)"
,
}
}
>
<
SignedIn
>
<
NavLink
className=
"mb-3 cursor-default"
to=
{
Routes
.
ROOT
}
>
<
MemosLogo
collapsed=
{
collapsed
}
/>
</
NavLink
>
))
}
</
SignedIn
>
<
SignedOut
>
<
NavLink
className=
"mb-3 cursor-default"
to=
{
Routes
.
EXPLORE
}
>
<
MemosLogo
collapsed=
{
collapsed
}
/>
</
NavLink
>
</
SignedOut
>
{
/* Main section */
}
{
mainNavLinks
.
map
(
renderNavLink
)
}
{
/* System section */
}
{
sectionLabel
(
"System"
)
}
{
systemNavLinks
.
map
(
renderNavLink
)
}
</
div
>
<
div
className=
{
cn
(
"w-full flex flex-col justify-end"
,
props
.
collapsed
?
"items-center"
:
"items-start pl-3"
)
}
>
<
SignedIn
>
<
UserMenu
collapsed=
{
collapsed
}
/>
...
...
@@ -151,8 +180,8 @@ const Navigation = (props: Props) => {
<
NavLink
to=
{
Routes
.
AUTH
}
className=
{
cn
(
"
px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-8
0"
,
collapsed
?
""
:
"w-full px-
4
"
,
"
relative px-2 py-2 rounded-xl flex flex-row items-center text-sm text-sidebar-foreground transition-all duration-200 active:scale-[0.98] opacity-70 hover:opacity-100 hover:bg-sidebar-accent/5
0"
,
collapsed
?
""
:
"w-full px-
3
"
,
)
}
>
{
collapsed
?
(
...
...
@@ -160,7 +189,7 @@ const Navigation = (props: Props) => {
<
Tooltip
>
<
TooltipTrigger
asChild
>
<
div
>
<
User2Icon
className=
"w-
6 h-auto
shrink-0"
/>
<
User2Icon
className=
"w-
5 h-5
shrink-0"
/>
</
div
>
</
TooltipTrigger
>
<
TooltipContent
side=
"right"
>
...
...
@@ -170,7 +199,7 @@ const Navigation = (props: Props) => {
</
TooltipProvider
>
)
:
(
<>
<
User2Icon
className=
"w-
6 h-auto
shrink-0"
/>
<
User2Icon
className=
"w-
5 h-5
shrink-0"
/>
<
span
className=
"ml-3 truncate"
>
{
t
(
"common.sign-in"
)
}
</
span
>
</>
)
}
...
...
frontend/src/components/PagedMemoList/PagedMemoList.tsx
View file @
30891fb7
...
...
@@ -141,7 +141,7 @@ const PagedMemoList = (props: Props) => {
}
};
window
.
addEventListener
(
"scroll"
,
handleScroll
);
window
.
addEventListener
(
"scroll"
,
handleScroll
,
{
passive
:
true
}
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
},
[
hasNextPage
,
isFetchingNextPage
,
fetchNextPage
]);
...
...
@@ -202,7 +202,7 @@ const BackToTop = () => {
setIsVisible
(
shouldShow
);
};
window
.
addEventListener
(
"scroll"
,
handleScroll
);
window
.
addEventListener
(
"scroll"
,
handleScroll
,
{
passive
:
true
}
);
return
()
=>
window
.
removeEventListener
(
"scroll"
,
handleScroll
);
},
[]);
...
...
frontend/src/components/PinnedSection/PinnedSection.tsx
View file @
30891fb7
...
...
@@ -31,9 +31,7 @@ const PinnedSection = ({ creatorName, className }: PinnedSectionProps) => {
});
// Add pinned filter
const
pinnedFilter
=
useMemo
(()
=>
{
return
baseFilter
?
`
${
baseFilter
}
&& pinned`
:
"pinned"
;
},
[
baseFilter
]);
const
pinnedFilter
=
baseFilter
?
`
${
baseFilter
}
&& pinned`
:
"pinned"
;
// Fetch only pinned memos
const
{
data
:
pinnedData
,
isLoading
}
=
useInfiniteMemos
({
...
...
frontend/src/components/PreviewImageDialog.tsx
View file @
30891fb7
import
{
X
}
from
"lucide-react"
;
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
React
,
{
useEffect
}
from
"react"
;
import
{
Button
}
from
"@/components/ui/button"
;
import
{
Dialog
,
DialogContent
}
from
"@/components/ui/dialog"
;
...
...
@@ -11,12 +11,7 @@ interface Props {
}
function
PreviewImageDialog
({
open
,
onOpenChange
,
imgUrls
,
initialIndex
=
0
}:
Props
)
{
const
[
currentIndex
,
setCurrentIndex
]
=
useState
(
initialIndex
);
// Update current index when initialIndex prop changes
useEffect
(()
=>
{
setCurrentIndex
(
initialIndex
);
},
[
initialIndex
]);
const
currentIndex
=
initialIndex
;
// Handle keyboard navigation
useEffect
(()
=>
{
...
...
frontend/src/components/Skeleton.tsx
View file @
30891fb7
...
...
@@ -5,44 +5,61 @@ interface Props {
count
?:
number
;
}
// Shimmer overlay for a more premium loading effect
const
shimmerClass
=
"relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent"
;
// Memo card skeleton component for list loading states
const
MemoCardSkeleton
=
({
showCreator
=
false
,
index
=
0
}:
{
showCreator
?:
boolean
;
index
?:
number
})
=>
(
<
div
className=
"relative flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 rounded-lg border border-border animate-pulse"
>
<
div
className=
{
cn
(
"relative flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 rounded-lg border border-border"
,
shimmerClass
,
)
}
>
{
/* Header section */
}
<
div
className=
"w-full flex flex-row justify-between items-center gap-2"
>
<
div
className=
"w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center"
>
{
showCreator
?
(
<
div
className=
"w-full flex flex-row justify-start items-center gap-2"
>
<
div
className=
"w-8 h-8 rounded-full bg-muted shrink-0"
/>
<
div
className=
"w-8 h-8 rounded-full bg-muted
animate-pulse
shrink-0"
/>
<
div
className=
"w-full flex flex-col justify-center items-start gap-1"
>
<
div
className=
"h-4 w-24 bg-muted rounded"
/>
<
div
className=
"h-3 w-16 bg-muted rounded"
/>
<
div
className=
"h-4 w-24 bg-muted
animate-pulse
rounded"
/>
<
div
className=
"h-3 w-16 bg-muted
/70 animate-pulse
rounded"
/>
</
div
>
</
div
>
)
:
(
<
div
className=
"h-4 w-32 bg-muted rounded"
/>
<
div
className=
"h-4 w-32 bg-muted
animate-pulse
rounded"
/>
)
}
</
div
>
{
/* Action buttons skeleton */
}
<
div
className=
"flex flex-row gap-2"
>
<
div
className=
"w-4 h-4 bg-muted rounded"
/>
<
div
className=
"w-4 h-4 bg-muted rounded"
/>
<
div
className=
"w-4 h-4 bg-muted
animate-pulse
rounded"
/>
<
div
className=
"w-4 h-4 bg-muted
animate-pulse
rounded"
/>
</
div
>
</
div
>
{
/* Content section */
}
<
div
className=
"w-full flex flex-col gap-2"
>
<
div
className=
"space-y-2"
>
<
div
className=
{
cn
(
"h-4 bg-muted rounded"
,
index
%
3
===
0
?
"w-full"
:
index
%
3
===
1
?
"w-4/5"
:
"w-5/6"
)
}
/>
<
div
className=
{
cn
(
"h-4 bg-muted rounded"
,
index
%
2
===
0
?
"w-3/4"
:
"w-4/5"
)
}
/>
{
index
%
2
===
0
&&
<
div
className=
"h-4 w-2/3 bg-muted rounded"
/>
}
<
div
className=
{
cn
(
"h-4 bg-muted
animate-pulse
rounded"
,
index
%
3
===
0
?
"w-full"
:
index
%
3
===
1
?
"w-4/5"
:
"w-5/6"
)
}
/>
<
div
className=
{
cn
(
"h-4 bg-muted
/80 animate-pulse
rounded"
,
index
%
2
===
0
?
"w-3/4"
:
"w-4/5"
)
}
/>
{
index
%
2
===
0
&&
<
div
className=
"h-4 w-2/3 bg-muted
/60 animate-pulse
rounded"
/>
}
</
div
>
</
div
>
{
/* Footer: reactions/actions */
}
<
div
className=
"w-full flex flex-row gap-3 pt-1"
>
<
div
className=
"h-3 w-8 bg-muted/40 animate-pulse rounded"
/>
<
div
className=
"h-3 w-8 bg-muted/40 animate-pulse rounded"
/>
<
div
className=
"h-3 w-8 bg-muted/40 animate-pulse rounded"
/>
</
div
>
</
div
>
);
/**
* Skeleton loading state for memo lists.
* Features a shimmer gradient overlay for a premium feel.
* Use this for initial memo list loading and pagination.
* For generic page/route loading, use Spinner instead.
*/
...
...
frontend/src/components/StatisticsView/MonthNavigator.tsx
View file @
30891fb7
...
...
@@ -30,12 +30,15 @@ export const MonthNavigator = ({ visibleMonth, onMonthChange, activityStats }: M
};
return
(
<
div
className=
"w-full mb-
2 flex flex-row justify-between items-center gap-1
"
>
<
div
className=
"w-full mb-
3 flex flex-row justify-between items-center
"
>
<
Dialog
open=
{
isOpen
}
onOpenChange=
{
setIsOpen
}
>
<
DialogTrigger
asChild
>
<
button
className=
"px-2 py-1 -ml-2 rounded-md hover:bg-secondary/50 text-sm text-foreground font-semibold transition-colors flex items-center gap-1 select-none group"
>
<
button
className=
"px-2.5 py-1.5 -ml-2 rounded-lg hover:bg-amber-500/10 dark:hover:bg-amber-400/10 text-sm text-foreground font-semibold transition-all duration-200 flex items-center gap-2 select-none group"
>
<
span
className=
"text-base leading-none opacity-80 group-hover:opacity-100 transition-opacity"
>
🐎
</
span
>
<
span
className=
"group-hover:text-amber-700 dark:group-hover:text-amber-400 transition-colors"
>
{
currentMonth
.
toLocaleString
(
i18n
.
language
,
{
year
:
"numeric"
,
month
:
"long"
})
}
<
ChevronDownIcon
className=
"w-3.5 h-3.5 text-muted-foreground group-hover:text-foreground transition-colors"
/>
</
span
>
<
ChevronDownIcon
className=
"w-3.5 h-3.5 text-muted-foreground group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors"
/>
</
button
>
</
DialogTrigger
>
<
DialogContent
className=
"p-0 border-none bg-background md:max-w-4xl"
size=
"2xl"
showCloseButton=
{
false
}
>
...
...
@@ -45,14 +48,14 @@ export const MonthNavigator = ({ visibleMonth, onMonthChange, activityStats }: M
</
Dialog
>
<
div
className=
"flex justify-end items-center shrink-0 gap-0.5"
>
<
button
className=
"p-1
rounded-md hover:bg-secondary/50 text-muted-foreground hover:text-foreground transition-all
"
className=
"p-1
.5 rounded-lg hover:bg-amber-500/10 dark:hover:bg-amber-400/10 text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400 transition-all duration-200 active:scale-90
"
onClick=
{
handlePrevMonth
}
aria
-
label=
"Previous month"
>
<
ChevronLeftIcon
className=
"w-4 h-4"
/>
</
button
>
<
button
className=
"p-1
rounded-md hover:bg-secondary/50 text-muted-foreground hover:text-foreground transition-all
"
className=
"p-1
.5 rounded-lg hover:bg-amber-500/10 dark:hover:bg-amber-400/10 text-muted-foreground hover:text-amber-700 dark:hover:text-amber-400 transition-all duration-200 active:scale-90
"
onClick=
{
handleNextMonth
}
aria
-
label=
"Next month"
>
...
...
frontend/src/components/StatisticsView/StatisticsView.tsx
View file @
30891fb7
...
...
@@ -13,7 +13,7 @@ const StatisticsView = (props: Props) => {
const
{
statisticsData
}
=
props
;
const
{
activityStats
}
=
statisticsData
;
const
navigateToDateFilter
=
useDateFilterNavigation
();
const
[
visibleMonthString
,
setVisibleMonthString
]
=
useState
(
dayjs
().
format
(
"YYYY-MM"
));
const
[
visibleMonthString
,
setVisibleMonthString
]
=
useState
(
()
=>
dayjs
().
format
(
"YYYY-MM"
));
const
maxCount
=
useMemo
(()
=>
{
const
counts
=
Object
.
values
(
activityStats
);
...
...
@@ -22,11 +22,27 @@ const StatisticsView = (props: Props) => {
return
(
<
div
className=
"group w-full mt-2 flex flex-col text-muted-foreground animate-fade-in"
>
{
/* Calendar card with subtle glass effect */
}
<
div
className=
"rounded-xl border border-border/50 bg-card/50 backdrop-blur-sm p-3 shadow-sm hover:shadow-md transition-shadow duration-300"
>
<
MonthNavigator
visibleMonth=
{
visibleMonthString
}
onMonthChange=
{
setVisibleMonthString
}
activityStats=
{
activityStats
}
/>
<
div
className=
"w-full animate-scale-in"
>
<
MonthCalendar
month=
{
visibleMonthString
}
data=
{
activityStats
}
maxCount=
{
maxCount
}
onClick=
{
navigateToDateFilter
}
/>
</
div
>
{
/* Activity legend */
}
<
div
className=
"mt-3 pt-2 border-t border-border/30 flex items-center justify-between text-[10px] text-muted-foreground/60"
>
<
span
>
Less
</
span
>
<
div
className=
"flex items-center gap-1"
>
<
div
className=
"w-3 h-3 rounded bg-secondary/40"
/>
<
div
className=
"w-3 h-3 rounded bg-amber-300/40"
/>
<
div
className=
"w-3 h-3 rounded bg-amber-400/70"
/>
<
div
className=
"w-3 h-3 rounded bg-gradient-to-br from-amber-400 to-orange-500"
/>
<
div
className=
"w-3 h-3 rounded bg-gradient-to-br from-amber-500 to-orange-600"
/>
</
div
>
<
span
>
More
</
span
>
</
div
>
</
div
>
</
div
>
);
};
...
...
frontend/src/contexts/AuthContext.tsx
View file @
30891fb7
...
...
@@ -4,6 +4,7 @@ import toast from "react-hot-toast";
import
{
clearAccessToken
,
getAccessToken
}
from
"@/auth-state"
;
import
{
authServiceClient
,
shortcutServiceClient
,
userServiceClient
}
from
"@/service"
;
import
{
userKeys
}
from
"@/hooks/useUserQueries"
;
import
{
memoKeys
}
from
"@/hooks/useMemoQueries"
;
import
{
getClerkSessionToken
}
from
"@/utils/clerk"
;
import
type
{
Shortcut
}
from
"@/types/proto/api/v1/shortcut_service_pb"
;
import
type
{
User
,
UserSetting_GeneralSetting
,
UserSetting_WebhooksSetting
}
from
"@/types/proto/api/v1/user_service_pb"
;
...
...
@@ -103,6 +104,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Pre-populate React Query cache
queryClient
.
setQueryData
(
userKeys
.
currentUser
(),
currentUser
);
queryClient
.
setQueryData
(
userKeys
.
detail
(
currentUser
.
name
),
currentUser
);
// Invalidate memo queries so they refetch with the valid auth token.
// This fixes the race condition where queries fire before Clerk is ready.
queryClient
.
invalidateQueries
({
queryKey
:
memoKeys
.
all
});
}
catch
(
error
)
{
// Silently handle 401/403 - user is just not logged in
const
errorMessage
=
error
instanceof
Error
?
error
.
message
:
String
(
error
);
...
...
frontend/src/index.css
View file @
30891fb7
...
...
@@ -4,6 +4,13 @@
@theme
{
--default-transition-duration
:
150ms
;
--animate-shimmer
:
shimmer
1.5s
infinite
;
}
@keyframes
shimmer
{
100
%
{
transform
:
translateX
(
100%
);
}
}
@layer
base
{
...
...
frontend/src/layouts/RootLayout.tsx
View file @
30891fb7
import
{
Suspense
,
useEffect
,
useMemo
}
from
"react"
;
import
{
Suspense
,
useEffect
}
from
"react"
;
import
{
Outlet
,
useLocation
}
from
"react-router-dom"
;
import
usePrevious
from
"react-use/lib/usePrevious"
;
import
Navigation
from
"@/components/Navigation"
;
import
ChatbotWidget
from
"@/components/ChatbotWidget"
;
import
CommandPalette
from
"@/components/CommandPalette"
;
import
Spinner
from
"@/components/Spinner"
;
import
{
useMemoFilterContext
}
from
"@/contexts/MemoFilterContext"
;
import
useMediaQuery
from
"@/hooks/useMediaQuery"
;
...
...
@@ -13,7 +14,7 @@ const RootLayout = () => {
const
location
=
useLocation
();
const
sm
=
useMediaQuery
(
"sm"
);
const
{
removeFilter
}
=
useMemoFilterContext
();
const
pathname
=
useMemo
(()
=>
location
.
pathname
,
[
location
.
pathname
])
;
const
pathname
=
location
.
pathname
;
const
prevPathname
=
usePrevious
(
pathname
);
useEffect
(()
=>
{
...
...
@@ -53,6 +54,7 @@ const RootLayout = () => {
</
Suspense
>
</
main
>
<
ChatbotWidget
/>
<
CommandPalette
/>
<
FestiveCorner
/>
</
div
>
);
...
...
frontend/src/service/converters.ts
View file @
30891fb7
...
...
@@ -74,12 +74,12 @@ export const memoFromApi = (raw: ApiMemo): Memo => {
const
result
=
{
name
:
memoName
,
state
:
State
.
NORMAL
,
state
:
raw
.
row_status
===
"ARCHIVED"
?
State
.
ARCHIVED
:
State
.
NORMAL
,
creator
:
`users/
${
raw
.
creator_id
??
1
}
`,
content,
visibility: visibilityFromApi(raw.visibility),
tags: Array.isArray(raw.tags) ? raw.tags : [],
pinned: false,
pinned:
raw.pinned ??
false,
attachments: [],
relations,
reactions: [],
...
...
frontend/src/service/memoService.ts
View file @
30891fb7
import
type
{
Memo
,
Reaction
}
from
"@/types/proto/api/v1/memo_service_pb"
;
import
{
State
}
from
"@/types/proto/api/v1/common_pb"
;
import
{
fetchJson
,
parseResourceId
}
from
"./apiClient"
;
import
{
applyMemoFilter
,
extractTagFilter
,
memoFromApi
,
visibilityToApi
}
from
"./converters"
;
import
type
{
ApiMemo
}
from
"./types"
;
export
const
memoServiceClient
=
{
async
listMemos
(
request
:
{
filter
?:
string
}
=
{}):
Promise
<
{
memos
:
Memo
[];
nextPageToken
:
string
}
>
{
async
listMemos
(
request
:
{
filter
?:
string
;
state
?:
number
}
=
{}):
Promise
<
{
memos
:
Memo
[];
nextPageToken
:
string
}
>
{
const
tag
=
extractTagFilter
(
request
.
filter
);
const
params
=
new
URLSearchParams
();
if
(
tag
)
params
.
append
(
"tag"
,
tag
);
...
...
@@ -12,6 +13,11 @@ export const memoServiceClient = {
console
.
log
(
"DEBUG listMemos filter:"
,
request
.
filter
);
params
.
append
(
"filter"
,
request
.
filter
);
}
// Pass state as row_status query param for archive filtering
if
(
request
.
state
!==
undefined
)
{
const
rowStatus
=
request
.
state
===
State
.
ARCHIVED
?
"ARCHIVED"
:
"NORMAL"
;
params
.
append
(
"row_status"
,
rowStatus
);
}
const
query
=
params
.
toString
()
?
`?
${
params
.
toString
()}
`
:
""
;
console
.
log
(
"DEBUG listMemos URL:"
,
`/memos
${
query
}
`
);
const
data
=
await
fetchJson
<
ApiMemo
[]
>
(
`/memos
${
query
}
`
,
{
method
:
"GET"
});
...
...
@@ -40,22 +46,33 @@ export const memoServiceClient = {
const
data
=
await
fetchJson
<
ApiMemo
>
(
"/memos"
,
{
method
:
"POST"
,
body
:
payload
});
return
memoFromApi
(
data
);
},
async
updateMemo
(
request
:
{
memo
?:
Memo
}):
Promise
<
Memo
>
{
async
updateMemo
(
request
:
{
memo
?:
Memo
;
updateMask
?:
{
paths
?:
string
[]
}
}):
Promise
<
Memo
>
{
const
memo
=
request
.
memo
;
if
(
!
memo
?.
name
)
{
throw
new
Error
(
"Missing memo name"
);
}
const
memoId
=
parseResourceId
(
memo
.
name
);
const
payload
:
{
content
?:
string
;
visibility
?:
string
;
tags
?:
string
[]
}
=
{};
if
(
memo
.
content
!==
undefined
)
{
const
payload
:
{
content
?:
string
;
visibility
?:
string
;
tags
?:
string
[];
pinned
?:
boolean
;
row_status
?:
string
}
=
{};
// Use updateMask to determine which fields to send (prevents proto default values from overwriting data)
const
paths
=
request
.
updateMask
?.
paths
??
[];
const
hasMask
=
paths
.
length
>
0
;
if
((
!
hasMask
&&
memo
.
content
!==
undefined
)
||
(
hasMask
&&
paths
.
includes
(
"content"
)))
{
payload
.
content
=
memo
.
content
;
}
if
(
memo
.
visibility
!==
undefined
)
{
if
(
(
!
hasMask
&&
memo
.
visibility
!==
undefined
)
||
(
hasMask
&&
paths
.
includes
(
"visibility"
))
)
{
payload
.
visibility
=
visibilityToApi
(
memo
.
visibility
);
}
if
(
memo
.
tags
)
{
if
(
(
!
hasMask
&&
memo
.
tags
)
||
(
hasMask
&&
paths
.
includes
(
"tags"
))
)
{
payload
.
tags
=
memo
.
tags
;
}
if
((
!
hasMask
&&
memo
.
pinned
!==
undefined
)
||
(
hasMask
&&
paths
.
includes
(
"pinned"
)))
{
payload
.
pinned
=
memo
.
pinned
;
}
if
((
!
hasMask
&&
memo
.
state
!==
undefined
)
||
(
hasMask
&&
paths
.
includes
(
"state"
)))
{
payload
.
row_status
=
memo
.
state
===
State
.
ARCHIVED
?
"ARCHIVED"
:
"NORMAL"
;
}
const
data
=
await
fetchJson
<
ApiMemo
>
(
`/memos/
${
memoId
}
`
,
{
method
:
"PATCH"
,
body
:
payload
});
return
memoFromApi
(
data
);
},
...
...
frontend/src/service/types.ts
View file @
30891fb7
...
...
@@ -6,6 +6,8 @@ export type ApiMemo = {
visibility
?:
string
|
null
;
tags
?:
string
[];
creator_id
?:
string
;
row_status
?:
string
;
// "NORMAL" | "ARCHIVED"
pinned
?:
boolean
;
create_time
?:
string
;
update_time
?:
string
;
display_time
?:
string
;
...
...
frontend/src/utils/clerk.ts
View file @
30891fb7
...
...
@@ -11,16 +11,30 @@ declare global {
/**
* Get a Clerk session JWT without React hooks (works from non-React modules).
* Returns null if Clerk isn't initialized or user isn't signed in.
* Retries up to 5 times with 500ms delay to handle Clerk handshake redirect.
*/
export
async
function
getClerkSessionToken
():
Promise
<
string
|
null
>
{
const
maxRetries
=
5
;
const
retryDelay
=
500
;
// ms
for
(
let
attempt
=
0
;
attempt
<
maxRetries
;
attempt
++
)
{
try
{
const
clerk
=
typeof
window
===
"undefined"
?
undefined
:
window
.
Clerk
;
if
(
!
clerk
?.
session
?.
getToken
)
return
null
;
if
(
clerk
?.
session
?.
getToken
)
{
const
token
=
await
clerk
.
session
.
getToken
();
return
token
||
null
;
if
(
token
)
return
token
;
}
}
catch
{
return
null
;
// ignore and retry
}
// Only wait if we haven't exhausted retries
if
(
attempt
<
maxRetries
-
1
)
{
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
retryDelay
));
}
}
return
null
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment