Commit 30891fb7 authored by Hoanganhvu123's avatar Hoanganhvu123

update: latest changes

parent 97cb2112
......@@ -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()
......
......@@ -211,6 +211,7 @@ async def memo_retrieval_tool(
"memos": memos,
},
ensure_ascii=False,
default=str,
)
except Exception as exc:
......
......@@ -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(
......
"""
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
......@@ -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
},
......
......@@ -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:
......
"""
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())
......@@ -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():
......
"""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())
......@@ -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",
......
......@@ -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",
......
......@@ -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/30 bg-transparent cursor-default")}>{day.label}</div>;
return <div className={cn(baseClasses, "text-muted-foreground/20 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 = (
......
......@@ -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) => {
......
......@@ -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/50",
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/60",
} 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;
......@@ -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
......
......@@ -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);
......
......@@ -188,8 +188,8 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
useEffect(() => {
if (isDragging) {
window.addEventListener("touchmove", handleTouchMove, { passive: false });
window.addEventListener("touchend", handleTouchEnd);
window.addEventListener("touchmove", handleTouchMove, { passive: true });
window.addEventListener("touchend", handleTouchEnd, { passive: true });
return () => {
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleTouchEnd);
......
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;
......@@ -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;
......
......@@ -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.archived-successfully");
const newState = isArchiving ? State.ARCHIVED : State.NORMAL;
const message = isArchiving ? t("message.archived-successfully") : t("message.restored-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
......
......@@ -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 {
......
......@@ -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);
setError(err instanceof Error ? err.message : "Failed to render diagram");
setRenderResult({ 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 }}
/>
);
};
......@@ -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>
);
......
......@@ -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>
);
})}
......
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;
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;
......@@ -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);
......
......@@ -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-80",
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/50",
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>
</>
)}
......
......@@ -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);
}, []);
......
......@@ -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({
......
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(() => {
......
......@@ -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.
*/
......
......@@ -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"
>
......
......@@ -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>
);
};
......
......@@ -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);
......
......@@ -4,6 +4,13 @@
@theme {
--default-transition-duration: 150ms;
--animate-shimmer: shimmer 1.5s infinite;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
@layer base {
......
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>
);
......
......@@ -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: [],
......
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);
},
......
......@@ -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;
......
......@@ -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;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment