Commit 30891fb7 authored by Hoanganhvu123's avatar Hoanganhvu123

update: latest changes

parent 97cb2112
...@@ -14,14 +14,12 @@ from langchain_core.runnables import RunnableConfig ...@@ -14,14 +14,12 @@ from langchain_core.runnables import RunnableConfig
from common.cache import redis_cache from common.cache import redis_cache
from common.conversation_manager import get_conversation_manager from common.conversation_manager import get_conversation_manager
from common.langfuse_client import get_callback_handler 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 config import DEFAULT_MODEL, REDIS_CACHE_TURN_ON
from langfuse import propagate_attributes from langfuse import propagate_attributes
from .graph import build_graph from .graph import build_graph
from .helper import extract_product_ids, handle_post_chat_async, parse_ai_response from .helper import extract_product_ids, handle_post_chat_async, parse_ai_response
from .models import AgentState, get_config from .models import AgentState
from .tools.get_tools import get_all_tools
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -72,12 +70,9 @@ async def chat_controller( ...@@ -72,12 +70,9 @@ async def chat_controller(
# ====================== NORMAL LLM FLOW ====================== # ====================== NORMAL LLM FLOW ======================
logger.info("chat_controller: proceed with live LLM call") logger.info("chat_controller: proceed with live LLM call")
config = get_config() # Graph is a singleton — built once and reused across requests.
config.model_name = model_name # LLM instances are cached by LLMFactory, tools are static.
graph = build_graph()
llm = create_llm(model_name=model_name, streaming=False, json_mode=True)
tools = get_all_tools()
graph = build_graph(config, llm=llm, tools=tools)
# Init ConversationManager (Singleton) # Init ConversationManager (Singleton)
memory = await get_conversation_manager() memory = await get_conversation_manager()
......
...@@ -211,6 +211,7 @@ async def memo_retrieval_tool( ...@@ -211,6 +211,7 @@ async def memo_retrieval_tool(
"memos": memos, "memos": memos,
}, },
ensure_ascii=False, ensure_ascii=False,
default=str,
) )
except Exception as exc: except Exception as exc:
......
...@@ -36,6 +36,7 @@ async def list_memos( ...@@ -36,6 +36,7 @@ async def list_memos(
request: Request, request: Request,
tag: str | None = Query(default=None), tag: str | None = Query(default=None),
filter: 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)"), 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)"), end_date: str | None = Query(default=None, description="ISO format date (YYYY-MM-DD)"),
memo_service=Depends(get_memo_service), memo_service=Depends(get_memo_service),
...@@ -43,6 +44,9 @@ async def list_memos( ...@@ -43,6 +44,9 @@ async def list_memos(
"""List memos for the current user (or anonymous if not logged in).""" """List memos for the current user (or anonymous if not logged in)."""
try: try:
user_id = get_current_user_id(request) 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 # Parse date range from query params or filter string
dt_start, dt_end = parse_date_range( dt_start, dt_end = parse_date_range(
...@@ -57,6 +61,7 @@ async def list_memos( ...@@ -57,6 +61,7 @@ async def list_memos(
return await memo_service.list_memos( return await memo_service.list_memos(
user_id=user_id, user_id=user_id,
tag=tag, tag=tag,
row_status=row_status,
start_date=dt_start, start_date=dt_start,
end_date=dt_end end_date=dt_end
) )
...@@ -352,12 +357,7 @@ async def list_memo_comments( ...@@ -352,12 +357,7 @@ async def list_memo_comments(
try: try:
user_id = get_current_user_id(request) user_id = get_current_user_id(request)
# 1. Verify memo exists and user has access # 1. Verify memo exists and get raw doc in ONE query
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
memo_doc = None memo_doc = None
if ObjectId.is_valid(memo_id): if ObjectId.is_valid(memo_id):
memo_doc = await mongodb_client.memos.find_one({"_id": ObjectId(memo_id)}) memo_doc = await mongodb_client.memos.find_one({"_id": ObjectId(memo_id)})
...@@ -367,8 +367,16 @@ async def list_memo_comments( ...@@ -367,8 +367,16 @@ async def list_memo_comments(
if not memo_doc: if not memo_doc:
raise HTTPException(status_code=404, detail=f"Memo {memo_id} not found") 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"]) 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) # 3. Query MemoRelations with related_memo_id and type = COMMENT (like memos original)
relations = await memo_relation_service.list_relations( 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]: ...@@ -35,11 +35,13 @@ def verify_clerk_jwt(token: str) -> dict[str, Any]:
signing_key = _jwks_client().get_signing_key_from_jwt(token).key signing_key = _jwks_client().get_signing_key_from_jwt(token).key
# Clerk tokens are typically RS256. # Clerk tokens are typically RS256.
# leeway=60 tolerates up to 60s clock skew between Clerk server and this machine
payload = jwt.decode( payload = jwt.decode(
token, token,
signing_key, signing_key,
algorithms=["RS256"], algorithms=["RS256"],
issuer=CLERK_ISSUER, issuer=CLERK_ISSUER,
leeway=60,
options={ options={
"verify_aud": False, # allow multiple audiences in dev "verify_aud": False, # allow multiple audiences in dev
}, },
......
...@@ -6,6 +6,7 @@ Full features: memos, attachments, relations, reactions, embeddings, inbox, sett ...@@ -6,6 +6,7 @@ Full features: memos, attachments, relations, reactions, embeddings, inbox, sett
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, List from typing import Any, List
...@@ -190,22 +191,16 @@ class MemoService: ...@@ -190,22 +191,16 @@ class MemoService:
# Viewing own profile: show all # Viewing own profile: show all
pass pass
else: else:
# Viewing others: show PUBLIC and PROTECTED (if logged in) # Viewing others' profile: blocked — each user only sees their own
if user_id and user_id != "anonymous": query["creator_id"] = "__none__"
query["visibility"] = {"$in": ["PUBLIC", "PROTECTED"]}
else:
query["visibility"] = "PUBLIC"
elif user_id and user_id != "anonymous": elif user_id and user_id != "anonymous":
# Feed view (no specific creator): user's own memos + PUBLIC + PROTECTED memos # Feed view (no specific creator): show ONLY user's own memos
query["$or"] = [ # Other users' PUBLIC/PROTECTED memos belong on the Explore page, not the home feed
{"creator_id": user_id}, query["creator_id"] = user_id
{"visibility": "PUBLIC"},
{"visibility": "PROTECTED"},
]
else: else:
# Guest feed: only show public memos # Guest / anonymous: no memos — each user only sees their own
query["visibility"] = "PUBLIC" query["creator_id"] = "__none__"
# Apply filters # Apply filters
if visibility: if visibility:
...@@ -251,11 +246,14 @@ class MemoService: ...@@ -251,11 +246,14 @@ class MemoService:
cursor = mongodb_client.memos.find(query).sort("created_at", -1) cursor = mongodb_client.memos.find(query).sort("created_at", -1)
docs = await cursor.to_list(length=100) 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] = [] memos: list[schemas.MemoResponse] = []
for doc in docs: for doc in docs:
memo_response = self._doc_to_response(doc) memo_response = self._doc_to_response(doc)
doc_visibility = doc.get("visibility", "PRIVATE") memo_response.comment_count = comment_counts.get(str(doc["_id"]), 0)
memo_response.comment_count = await self._get_comment_count(str(doc["_id"]), doc_visibility, user_id)
memos.append(memo_response) memos.append(memo_response)
return memos return memos
...@@ -346,6 +344,35 @@ class MemoService: ...@@ -346,6 +344,35 @@ class MemoService:
# PRIVATE: only owner can access (handled above) # PRIVATE: only owner can access (handled above)
raise ValueError(f"Access denied to memo {memo_id}") 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: 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.""" """Get comment count for a memo based on visibility and user access."""
query: dict[str, Any] = { query: dict[str, Any] = {
...@@ -418,8 +445,9 @@ class MemoService: ...@@ -418,8 +445,9 @@ class MemoService:
raise ValueError(f"Memo {memo_id} not found") raise ValueError(f"Memo {memo_id} not found")
memo_creator = doc.get("creator_id", "anonymous") 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: 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()} update_fields: dict[str, Any] = {"updated_at": utc_now()}
...@@ -472,8 +500,9 @@ class MemoService: ...@@ -472,8 +500,9 @@ class MemoService:
raise ValueError(f"Memo {memo_id} not found") raise ValueError(f"Memo {memo_id} not found")
memo_creator = doc.get("creator_id", "anonymous") 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: 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.memos.delete_one({"_id": doc["_id"]})
await mongodb_client.memo_embeddings.delete_many({"memo_id": str(doc["_id"])}) await mongodb_client.memo_embeddings.delete_many({"memo_id": str(doc["_id"])})
...@@ -969,39 +998,67 @@ class MemoEmbeddingService: ...@@ -969,39 +998,67 @@ class MemoEmbeddingService:
top_k: int = 5, top_k: int = 5,
user_id: str | None = None, user_id: str | None = None,
) -> list[dict]: ) -> list[dict]:
"""Search memos by embedding similarity.""" """Search memos by embedding similarity using server-side aggregation."""
cursor = mongodb_client.memo_embeddings.find({}) q = np.array(query_embedding, dtype=float)
docs = await cursor.to_list(length=1000) q_norm = float(np.linalg.norm(q))
if q_norm == 0:
if not docs:
return [] return []
q = np.array(query_embedding, dtype=float) # Normalize query vector once (for cosine similarity)
results: list[dict[str, Any]] = [] q_normalized = (q / q_norm).tolist()
for doc in docs: # Build match filter — only load this user's embeddings
emb = doc.get("embedding", []) match_filter: dict[str, Any] = {}
if not emb: if user_id and user_id != "anonymous":
continue 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 match_filter:
if v.shape != q.shape: pipeline.append({"$match": match_filter})
continue
denom = np.linalg.norm(q) * np.linalg.norm(v) # Filter docs that have embeddings
sim = float(np.dot(q, v) / denom) if denom != 0 else 0.0 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"), "$addFields": {
"content": doc.get("content", ""), "score": {
"tags": doc.get("tags", []), "$reduce": {
"score": sim, "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) cursor = mongodb_client.memo_embeddings.aggregate(pipeline)
return results[:top_k] results = await cursor.to_list(length=top_k)
return results
class InboxService: 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( ...@@ -29,11 +29,7 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
langfuse_client = get_langfuse_client() # Langfuse client initialized in startup_event (not at import time)
if langfuse_client:
logger.info("Langfuse client ready (lazy loading)")
else:
logger.warning("Langfuse client not available (missing keys or disabled)")
app = FastAPI( app = FastAPI(
title="Contract AI Service", title="Contract AI Service",
...@@ -60,6 +56,13 @@ async def startup_event(): ...@@ -60,6 +56,13 @@ async def startup_event():
await init_mongodb() await init_mongodb()
logger.info("✅ MongoDB connection initialized") 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") @app.on_event("shutdown")
async def shutdown_event(): 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 @@ ...@@ -17,6 +17,7 @@
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
...@@ -30,6 +31,7 @@ ...@@ -30,6 +31,7 @@
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
...@@ -70,6 +72,7 @@ ...@@ -70,6 +72,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.7", "@biomejs/biome": "^2.3.7",
"@bufbuild/protobuf": "^2.10.1", "@bufbuild/protobuf": "^2.10.1",
"@playwright/test": "^1.48.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
...@@ -3621,6 +3624,22 @@ ...@@ -3621,6 +3624,22 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT" "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": { "node_modules/@protobuf-ts/protoc": {
"version": "2.11.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz",
...@@ -3934,6 +3953,37 @@ ...@@ -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": { "node_modules/@radix-ui/react-id": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
...@@ -6738,6 +6788,22 @@ ...@@ -6738,6 +6788,22 @@
"node": ">=6" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
...@@ -11433,6 +11499,53 @@ ...@@ -11433,6 +11499,53 @@
"pathe": "^2.0.1" "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": { "node_modules/points-on-curve": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz",
......
...@@ -25,6 +25,7 @@ ...@@ -25,6 +25,7 @@
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
...@@ -38,6 +39,7 @@ ...@@ -38,6 +39,7 @@
"@tanstack/react-query-devtools": "^5.91.1", "@tanstack/react-query-devtools": "^5.91.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
...@@ -78,6 +80,7 @@ ...@@ -78,6 +80,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.7", "@biomejs/biome": "^2.3.7",
"@bufbuild/protobuf": "^2.10.1", "@bufbuild/protobuf": "^2.10.1",
"@playwright/test": "^1.48.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/hast": "^3.0.4", "@types/hast": "^3.0.4",
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
...@@ -92,7 +95,6 @@ ...@@ -92,7 +95,6 @@
"@types/unist": "^3.0.3", "@types/unist": "^3.0.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"@playwright/test": "^1.48.0",
"long": "^5.3.2", "long": "^5.3.2",
"terser": "^5.44.1", "terser": "^5.44.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
......
...@@ -35,7 +35,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => { ...@@ -35,7 +35,7 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText; const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
if (!day.isCurrentMonth) { 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); const intensityClass = getCellIntensityClass(day, maxCount);
...@@ -43,9 +43,10 @@ export const CalendarCell = memo((props: CalendarCellProps) => { ...@@ -43,9 +43,10 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
const buttonClasses = cn( const buttonClasses = cn(
baseClasses, baseClasses,
intensityClass, intensityClass,
day.isToday && "ring-2 ring-primary/30 ring-offset-1 font-semibold z-10", day.isToday && "ring-2 ring-amber-500/50 ring-offset-1 ring-offset-background font-bold z-10",
day.isSelected && "ring-2 ring-primary ring-offset-1 font-bold z-10", day.isSelected && "ring-2 ring-amber-500 ring-offset-1 ring-offset-background font-bold z-10",
isInteractive ? "cursor-pointer hover:scale-110 hover:shadow-md hover:z-20" : "cursor-default", 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 = ( const button = (
......
...@@ -32,14 +32,28 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => { ...@@ -32,14 +32,28 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => {
return ( return (
<div className={cn("flex flex-col gap-2 relative group", className)}> <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")}> {/* Weekday header */}
{rotatedWeekDays.map((label, index) => ( <div className={cn("grid grid-cols-7", sizeConfig.gap, "mb-1", size === "small" ? "text-[10px]" : "text-xs")}>
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/60 font-medium"> {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} {label}
</div> </div>
))} );
})}
</div> </div>
{/* Calendar grid */}
<div className={cn("grid grid-cols-7", sizeConfig.gap)}> <div className={cn("grid grid-cols-7", sizeConfig.gap)}>
{weeks.map((week, weekIndex) => {weeks.map((week, weekIndex) =>
week.days.map((day, dayIndex) => { week.days.map((day, dayIndex) => {
......
...@@ -13,23 +13,24 @@ export const INTENSITY_THRESHOLDS = { ...@@ -13,23 +13,24 @@ export const INTENSITY_THRESHOLDS = {
MINIMAL: 0, MINIMAL: 0,
} as const; } as const;
// Year of the Horse 🐎 — Warm amber/gold palette
export const CELL_STYLES = { export const CELL_STYLES = {
HIGH: "bg-primary text-primary-foreground shadow-sm", HIGH: "bg-gradient-to-br from-amber-500 to-orange-600 text-white shadow-sm shadow-amber-500/30 font-semibold",
MEDIUM: "bg-primary/80 text-primary-foreground shadow-sm", MEDIUM: "bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm shadow-amber-400/20",
LOW: "bg-primary/60 text-primary-foreground shadow-sm", LOW: "bg-amber-400/70 text-amber-950",
MINIMAL: "bg-primary/40 text-foreground", MINIMAL: "bg-amber-300/40 text-amber-900 dark:bg-amber-400/20 dark:text-amber-200",
EMPTY: "bg-secondary/30 text-muted-foreground hover:bg-secondary/50", EMPTY: "bg-secondary/40 text-muted-foreground hover:bg-secondary/60",
} as const; } as const;
export const SMALL_CELL_SIZE = { export const SMALL_CELL_SIZE = {
font: "text-xs", font: "text-xs",
dimensions: "w-8 h-8 mx-auto", dimensions: "w-8 h-8 mx-auto",
borderRadius: "rounded-md", borderRadius: "rounded-lg",
gap: "gap-1", gap: "gap-1",
} as const; } as const;
export const DEFAULT_CELL_SIZE = { export const DEFAULT_CELL_SIZE = {
font: "text-xs", font: "text-xs",
borderRadius: "rounded-md", borderRadius: "rounded-lg",
gap: "gap-1.5", gap: "gap-1.5",
} as const; } as const;
...@@ -49,6 +49,7 @@ const AttachmentIcon = (props: Props) => { ...@@ -49,6 +49,7 @@ const AttachmentIcon = (props: Props) => {
<img <img
className="min-w-full min-h-full object-cover" className="min-w-full min-h-full object-cover"
src={getAttachmentThumbnailUrl(attachment)} src={getAttachmentThumbnailUrl(attachment)}
alt={attachment.filename || "Attachment image"}
onClick={handleImageClick} onClick={handleImageClick}
onError={(e) => { onError={(e) => {
// Fallback to original image if thumbnail fails // Fallback to original image if thumbnail fails
......
...@@ -13,7 +13,7 @@ interface Props { ...@@ -13,7 +13,7 @@ interface Props {
const AuthFooter = ({ className }: Props) => { const AuthFooter = ({ className }: Props) => {
const { i18n: i18nInstance } = useTranslation(); const { i18n: i18nInstance } = useTranslation();
const currentLocale = i18nInstance.language as Locale; const currentLocale = i18nInstance.language as Locale;
const [currentTheme, setCurrentTheme] = useState(getInitialTheme()); const [currentTheme, setCurrentTheme] = useState(() => getInitialTheme());
const handleLocaleChange = (locale: Locale) => { const handleLocaleChange = (locale: Locale) => {
loadLocale(locale); loadLocale(locale);
......
...@@ -188,8 +188,8 @@ const ChatbotWidget = ({ className }: { className?: string }) => { ...@@ -188,8 +188,8 @@ const ChatbotWidget = ({ className }: { className?: string }) => {
useEffect(() => { useEffect(() => {
if (isDragging) { if (isDragging) {
window.addEventListener("touchmove", handleTouchMove, { passive: false }); window.addEventListener("touchmove", handleTouchMove, { passive: true });
window.addEventListener("touchend", handleTouchEnd); window.addEventListener("touchend", handleTouchEnd, { passive: true });
return () => { return () => {
window.removeEventListener("touchmove", handleTouchMove); window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleTouchEnd); 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 { ...@@ -22,7 +22,7 @@ interface Props {
function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: Props) { function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: Props) {
const t = useTranslate(); 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 requestState = useLoading(false);
const isCreating = !initialUser; const isCreating = !initialUser;
......
...@@ -4,7 +4,7 @@ import { useCallback } from "react"; ...@@ -4,7 +4,7 @@ import { useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error"; import { handleError } from "@/lib/error";
...@@ -55,31 +55,58 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe ...@@ -55,31 +55,58 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
const handleToggleMemoStatusClick = useCallback(async () => { const handleToggleMemoStatusClick = useCallback(async () => {
const isArchiving = memo.state !== State.ARCHIVED; const isArchiving = memo.state !== State.ARCHIVED;
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED; const newState = isArchiving ? State.ARCHIVED : State.NORMAL;
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully"); 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 { try {
await updateMemo({ await updateMemo({
update: { update: {
name: memo.name, name: memo.name,
state, state: newState,
}, },
updateMask: ["state"], updateMask: ["state"],
}); });
toast.success(message);
} catch (error: unknown) { } 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`, context: `${isArchiving ? "Archive" : "Restore"} memo`,
fallbackMessage: "An error occurred", fallbackMessage: "An error occurred",
}); });
return; return;
} }
if (isInMemoDetailPage) {
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
}
memoUpdatedCallback(); memoUpdatedCallback();
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]); }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo, queryClient]);
const handleCopyLink = useCallback(() => { const handleCopyLink = useCallback(() => {
// Always use frontend URL (window.location.origin) instead of backend URL // Always use frontend URL (window.location.origin) instead of backend URL
......
...@@ -21,17 +21,7 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) => ...@@ -21,17 +21,7 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
const codeClassName = codeElement?.props?.className || ""; const codeClassName = codeElement?.props?.className || "";
const codeContent = extractCodeContent(children); const codeContent = extractCodeContent(children);
const language = extractLanguage(codeClassName); const language = extractLanguage(codeClassName);
const isMermaid = language === "mermaid";
// 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 theme = getThemeWithFallback(userGeneralSetting?.theme); const theme = getThemeWithFallback(userGeneralSetting?.theme);
const resolvedTheme = resolveTheme(theme); const resolvedTheme = resolveTheme(theme);
...@@ -39,6 +29,8 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) => ...@@ -39,6 +29,8 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
// Dynamically load highlight.js theme based on app theme // Dynamically load highlight.js theme based on app theme
useEffect(() => { useEffect(() => {
if (isMermaid) return;
const dynamicImportStyle = async () => { const dynamicImportStyle = async () => {
// Remove any existing highlight.js style // Remove any existing highlight.js style
const existingStyle = document.querySelector("style[data-hljs-theme]"); const existingStyle = document.querySelector("style[data-hljs-theme]");
...@@ -62,10 +54,12 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) => ...@@ -62,10 +54,12 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
}; };
dynamicImportStyle(); dynamicImportStyle();
}, [resolvedTheme, isDarkTheme]); }, [resolvedTheme, isDarkTheme, isMermaid]);
// Highlight code using highlight.js // Highlight code using highlight.js
const highlightedCode = useMemo(() => { const highlightedCode = useMemo(() => {
if (isMermaid) return "";
try { try {
const lang = hljs.getLanguage(language); const lang = hljs.getLanguage(language);
if (lang) { if (lang) {
...@@ -81,7 +75,18 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) => ...@@ -81,7 +75,18 @@ export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) =>
return Object.assign(document.createElement("span"), { return Object.assign(document.createElement("span"), {
textContent: codeContent, textContent: codeContent,
}).innerHTML; }).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 () => { const handleCopy = async () => {
try { try {
......
...@@ -17,8 +17,7 @@ const getMermaidTheme = (appTheme: string): "default" | "dark" => { ...@@ -17,8 +17,7 @@ const getMermaidTheme = (appTheme: string): "default" | "dark" => {
export const MermaidBlock = ({ children, className }: MermaidBlockProps) => { export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
const { userGeneralSetting } = useAuth(); const { userGeneralSetting } = useAuth();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>(""); const [renderResult, setRenderResult] = useState<{ svg: string; error: string }>({ svg: "", error: "" });
const [error, setError] = useState<string>("");
const [systemThemeChange, setSystemThemeChange] = useState(0); const [systemThemeChange, setSystemThemeChange] = useState(0);
const codeContent = extractCodeContent(children); const codeContent = extractCodeContent(children);
...@@ -60,11 +59,10 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => { ...@@ -60,11 +59,10 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
}); });
const { svg: renderedSvg } = await mermaid.render(id, codeContent); const { svg: renderedSvg } = await mermaid.render(id, codeContent);
setSvg(renderedSvg); setRenderResult({ svg: renderedSvg, error: "" });
setError("");
} catch (err) { } catch (err) {
console.error("Failed to render mermaid diagram:", 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) => { ...@@ -72,10 +70,10 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
}, [codeContent, currentTheme]); }, [codeContent, currentTheme]);
// If there's an error, fall back to showing the code // If there's an error, fall back to showing the code
if (error) { if (renderResult.error) {
return ( return (
<div className="w-full"> <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}> <pre className={className}>
<code className="language-mermaid">{codeContent}</code> <code className="language-mermaid">{codeContent}</code>
</pre> </pre>
...@@ -87,7 +85,7 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => { ...@@ -87,7 +85,7 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
<div <div
ref={containerRef} ref={containerRef}
className={cn("mermaid-diagram w-full flex justify-center items-center my-4 overflow-x-auto", className)} 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"; ...@@ -20,7 +20,7 @@ import { EditorProvider, useEditorContext } from "./state";
import type { MemoEditorProps } from "./types"; import type { MemoEditorProps } from "./types";
const MemoEditor = (props: MemoEditorProps) => { 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 ( return (
<EditorProvider> <EditorProvider>
...@@ -33,6 +33,8 @@ const MemoEditor = (props: MemoEditorProps) => { ...@@ -33,6 +33,8 @@ const MemoEditor = (props: MemoEditorProps) => {
placeholder={placeholder} placeholder={placeholder}
onConfirm={onConfirm} onConfirm={onConfirm}
onCancel={onCancel} onCancel={onCancel}
anonymousId={anonymousId}
anonymousName={anonymousName}
/> />
</EditorProvider> </EditorProvider>
); );
......
...@@ -5,6 +5,7 @@ import { BookmarkIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react"; ...@@ -5,6 +5,7 @@ import { BookmarkIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import MemoHoverCard from "@/components/MemoHoverCard";
import { useInfiniteMemos } from "@/hooks/useMemoQueries"; import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
...@@ -110,8 +111,8 @@ const BookmarksSection = ({ className }: BookmarksSectionProps) => { ...@@ -110,8 +111,8 @@ const BookmarksSection = ({ className }: BookmarksSectionProps) => {
const time = formatTime(memo); const time = formatTime(memo);
return ( return (
<MemoHoverCard key={memo.name} content={memo.content || ""}>
<Link <Link
key={memo.name}
to={`/${memoPath}`} to={`/${memoPath}`}
className={cn( className={cn(
"flex items-center justify-between", "flex items-center justify-between",
...@@ -129,6 +130,7 @@ const BookmarksSection = ({ className }: BookmarksSectionProps) => { ...@@ -129,6 +130,7 @@ const BookmarksSection = ({ className }: BookmarksSectionProps) => {
</span> </span>
)} )}
</Link> </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) => { ...@@ -22,10 +22,13 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const isAnonymousCreator = memoData.creator.startsWith("users/anonymous_"); const isAnonymousCreator = memoData.creator.startsWith("users/anonymous_");
const creator = useUser(memoData.creator, { enabled: !isAnonymousCreator }).data; const creator = useUser(memoData.creator, { enabled: !isAnonymousCreator }).data;
const isArchived = memoData.state === State.ARCHIVED; const isArchived = memoData.state === State.ARCHIVED;
// In production, only the memo owner (or superuser) can edit/pin. // Compare raw user IDs (strip "users/" prefix) for reliable ownership check.
// When currentUser is undefined (still loading), default to false to allow owner actions // This avoids format mismatches between memoData.creator and currentUser.name.
// The backend will validate permissions anyway when actions are taken. const getMemoUserId = (name: string) => name.replace(/^users\//, "");
const readonly = currentUser ? (memoData.creator !== currentUser.name && !isSuperUser(currentUser)) : false; 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 parentPage = parentPageProp || "/";
const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent); const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent);
......
...@@ -2,7 +2,6 @@ import { SignedIn, SignedOut } from "@clerk/clerk-react"; ...@@ -2,7 +2,6 @@ import { SignedIn, SignedOut } from "@clerk/clerk-react";
import { ArchiveIcon, BellIcon, BookOpenIcon, EarthIcon, LibraryIcon, PaperclipIcon, SettingsIcon, User2Icon } from "lucide-react"; import { ArchiveIcon, BellIcon, BookOpenIcon, EarthIcon, LibraryIcon, PaperclipIcon, SettingsIcon, User2Icon } from "lucide-react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useNotifications } from "@/hooks/useUserQueries"; import { useNotifications } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
...@@ -27,97 +26,80 @@ interface Props { ...@@ -27,97 +26,80 @@ interface Props {
const Navigation = (props: Props) => { const Navigation = (props: Props) => {
const { collapsed, className } = props; const { collapsed, className } = props;
const t = useTranslate(); const t = useTranslate();
const currentUser = useCurrentUser();
const { data: notifications = [] } = useNotifications(); const { data: notifications = [] } = useNotifications();
const homeNavLink: NavLinkItem = { const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const mainNavLinks: NavLinkItem[] = [
{
id: "header-memos", id: "header-memos",
path: Routes.ROOT, path: Routes.ROOT,
title: t("common.memos"), title: t("common.memos"),
icon: <LibraryIcon className="w-6 h-auto shrink-0" />, icon: <LibraryIcon className="w-5 h-5 shrink-0" />,
}; },
const exploreNavLink: NavLinkItem = { {
id: "header-explore", id: "header-explore",
path: Routes.EXPLORE, path: Routes.EXPLORE,
title: t("common.explore"), title: t("common.explore"),
icon: <EarthIcon className="w-6 h-auto shrink-0" />, icon: <EarthIcon className="w-5 h-5 shrink-0" />,
}; },
const attachmentsNavLink: NavLinkItem = { {
id: "header-attachments", id: "header-attachments",
path: Routes.ATTACHMENTS, path: Routes.ATTACHMENTS,
title: t("common.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, requiresAuth: true,
}; },
const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length; {
const inboxNavLink: NavLinkItem = {
id: "header-inbox", id: "header-inbox",
path: Routes.INBOX, path: Routes.INBOX,
title: t("common.inbox"), title: t("common.inbox"),
icon: ( icon: (
<div className="relative"> <div className="relative">
<BellIcon className="w-6 h-auto shrink-0" /> <BellIcon className="w-5 h-5 shrink-0" />
{unreadCount > 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} {unreadCount > 99 ? "99+" : unreadCount}
</span> </span>
)} )}
</div> </div>
), ),
requiresAuth: true, requiresAuth: true,
}; },
const archivedNavLink: NavLinkItem = { {
id: "header-archived", id: "header-archived",
path: Routes.ARCHIVED, path: Routes.ARCHIVED,
title: t("common.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, requiresAuth: true,
}; },
const settingsNavLink: NavLinkItem = { ];
const systemNavLinks: NavLinkItem[] = [
{
id: "header-setting", id: "header-setting",
path: Routes.SETTING, path: Routes.SETTING,
title: t("common.settings"), title: t("common.settings"),
icon: <SettingsIcon className="w-6 h-auto shrink-0" />, icon: <SettingsIcon className="w-5 h-5 shrink-0" />,
requiresAuth: true, requiresAuth: true,
}; },
const aboutNavLink: NavLinkItem = { {
id: "header-about", id: "header-about",
path: Routes.ABOUT, path: Routes.ABOUT,
title: t("common.about"), title: t("common.about"),
icon: <BookOpenIcon className="w-6 h-auto shrink-0" />, icon: <BookOpenIcon className="w-5 h-5 shrink-0" />,
}; },
const navLinks: NavLinkItem[] = [
homeNavLink,
exploreNavLink,
attachmentsNavLink,
inboxNavLink,
archivedNavLink,
settingsNavLink,
aboutNavLink,
]; ];
return ( const renderNavLink = (navLink: NavLinkItem) => (
<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) => (
<NavLink <NavLink
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors", "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-4", collapsed ? "" : "w-full px-3",
isActive isActive
? "bg-sidebar-accent text-sidebar-accent-foreground border-sidebar-accent-border drop-shadow" ? "bg-sidebar-accent text-sidebar-accent-foreground font-semibold"
: "border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80", : "opacity-70 hover:opacity-100 hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground",
) )
} }
key={navLink.id} key={navLink.id}
...@@ -125,6 +107,12 @@ const Navigation = (props: Props) => { ...@@ -125,6 +107,12 @@ const Navigation = (props: Props) => {
id={navLink.id} id={navLink.id}
viewTransition 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 ? ( {props.collapsed ? (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
...@@ -140,9 +128,50 @@ const Navigation = (props: Props) => { ...@@ -140,9 +128,50 @@ const Navigation = (props: Props) => {
navLink.icon navLink.icon
)} )}
{!props.collapsed && <span className="ml-3 truncate">{navLink.title}</span>} {!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> </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>
<div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}> <div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}>
<SignedIn> <SignedIn>
<UserMenu collapsed={collapsed} /> <UserMenu collapsed={collapsed} />
...@@ -151,8 +180,8 @@ const Navigation = (props: Props) => { ...@@ -151,8 +180,8 @@ const Navigation = (props: Props) => {
<NavLink <NavLink
to={Routes.AUTH} to={Routes.AUTH}
className={cn( 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", "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-4", collapsed ? "" : "w-full px-3",
)} )}
> >
{collapsed ? ( {collapsed ? (
...@@ -160,7 +189,7 @@ const Navigation = (props: Props) => { ...@@ -160,7 +189,7 @@ const Navigation = (props: Props) => {
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>
<User2Icon className="w-6 h-auto shrink-0" /> <User2Icon className="w-5 h-5 shrink-0" />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
...@@ -170,7 +199,7 @@ const Navigation = (props: Props) => { ...@@ -170,7 +199,7 @@ const Navigation = (props: Props) => {
</TooltipProvider> </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> <span className="ml-3 truncate">{t("common.sign-in")}</span>
</> </>
)} )}
......
...@@ -141,7 +141,7 @@ const PagedMemoList = (props: Props) => { ...@@ -141,7 +141,7 @@ const PagedMemoList = (props: Props) => {
} }
}; };
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]); }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
...@@ -202,7 +202,7 @@ const BackToTop = () => { ...@@ -202,7 +202,7 @@ const BackToTop = () => {
setIsVisible(shouldShow); setIsVisible(shouldShow);
}; };
window.addEventListener("scroll", handleScroll); window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll);
}, []); }, []);
......
...@@ -31,9 +31,7 @@ const PinnedSection = ({ creatorName, className }: PinnedSectionProps) => { ...@@ -31,9 +31,7 @@ const PinnedSection = ({ creatorName, className }: PinnedSectionProps) => {
}); });
// Add pinned filter // Add pinned filter
const pinnedFilter = useMemo(() => { const pinnedFilter = baseFilter ? `${baseFilter} && pinned` : "pinned";
return baseFilter ? `${baseFilter} && pinned` : "pinned";
}, [baseFilter]);
// Fetch only pinned memos // Fetch only pinned memos
const { data: pinnedData, isLoading } = useInfiniteMemos({ const { data: pinnedData, isLoading } = useInfiniteMemos({
......
import { X } from "lucide-react"; import { X } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
...@@ -11,12 +11,7 @@ interface Props { ...@@ -11,12 +11,7 @@ interface Props {
} }
function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) { function PreviewImageDialog({ open, onOpenChange, imgUrls, initialIndex = 0 }: Props) {
const [currentIndex, setCurrentIndex] = useState(initialIndex); const currentIndex = initialIndex;
// Update current index when initialIndex prop changes
useEffect(() => {
setCurrentIndex(initialIndex);
}, [initialIndex]);
// Handle keyboard navigation // Handle keyboard navigation
useEffect(() => { useEffect(() => {
......
...@@ -5,44 +5,61 @@ interface Props { ...@@ -5,44 +5,61 @@ interface Props {
count?: number; 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 // Memo card skeleton component for list loading states
const MemoCardSkeleton = ({ showCreator = false, index = 0 }: { showCreator?: boolean; index?: number }) => ( 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 */} {/* Header section */}
<div className="w-full flex flex-row justify-between items-center gap-2"> <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"> <div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{showCreator ? ( {showCreator ? (
<div className="w-full flex flex-row justify-start items-center gap-2"> <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="w-full flex flex-col justify-center items-start gap-1">
<div className="h-4 w-24 bg-muted rounded" /> <div className="h-4 w-24 bg-muted animate-pulse rounded" />
<div className="h-3 w-16 bg-muted rounded" /> <div className="h-3 w-16 bg-muted/70 animate-pulse rounded" />
</div> </div>
</div> </div>
) : ( ) : (
<div className="h-4 w-32 bg-muted rounded" /> <div className="h-4 w-32 bg-muted animate-pulse rounded" />
)} )}
</div> </div>
{/* Action buttons skeleton */} {/* Action buttons skeleton */}
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<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 rounded" /> <div className="w-4 h-4 bg-muted animate-pulse rounded" />
</div> </div>
</div> </div>
{/* Content section */} {/* Content section */}
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<div className="space-y-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 animate-pulse 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")} /> <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 rounded" />} {index % 2 === 0 && <div className="h-4 w-2/3 bg-muted/60 animate-pulse rounded" />}
</div> </div>
</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> </div>
); );
/** /**
* Skeleton loading state for memo lists. * Skeleton loading state for memo lists.
* Features a shimmer gradient overlay for a premium feel.
* Use this for initial memo list loading and pagination. * Use this for initial memo list loading and pagination.
* For generic page/route loading, use Spinner instead. * For generic page/route loading, use Spinner instead.
*/ */
......
...@@ -30,12 +30,15 @@ export const MonthNavigator = ({ visibleMonth, onMonthChange, activityStats }: M ...@@ -30,12 +30,15 @@ export const MonthNavigator = ({ visibleMonth, onMonthChange, activityStats }: M
}; };
return ( 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}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <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" })} {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> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="p-0 border-none bg-background md:max-w-4xl" size="2xl" showCloseButton={false}> <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 ...@@ -45,14 +48,14 @@ export const MonthNavigator = ({ visibleMonth, onMonthChange, activityStats }: M
</Dialog> </Dialog>
<div className="flex justify-end items-center shrink-0 gap-0.5"> <div className="flex justify-end items-center shrink-0 gap-0.5">
<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={handlePrevMonth} onClick={handlePrevMonth}
aria-label="Previous month" aria-label="Previous month"
> >
<ChevronLeftIcon className="w-4 h-4" /> <ChevronLeftIcon className="w-4 h-4" />
</button> </button>
<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} onClick={handleNextMonth}
aria-label="Next month" aria-label="Next month"
> >
......
...@@ -13,7 +13,7 @@ const StatisticsView = (props: Props) => { ...@@ -13,7 +13,7 @@ const StatisticsView = (props: Props) => {
const { statisticsData } = props; const { statisticsData } = props;
const { activityStats } = statisticsData; const { activityStats } = statisticsData;
const navigateToDateFilter = useDateFilterNavigation(); const navigateToDateFilter = useDateFilterNavigation();
const [visibleMonthString, setVisibleMonthString] = useState(dayjs().format("YYYY-MM")); const [visibleMonthString, setVisibleMonthString] = useState(() => dayjs().format("YYYY-MM"));
const maxCount = useMemo(() => { const maxCount = useMemo(() => {
const counts = Object.values(activityStats); const counts = Object.values(activityStats);
...@@ -22,11 +22,27 @@ const StatisticsView = (props: Props) => { ...@@ -22,11 +22,27 @@ const StatisticsView = (props: Props) => {
return ( return (
<div className="group w-full mt-2 flex flex-col text-muted-foreground animate-fade-in"> <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} /> <MonthNavigator visibleMonth={visibleMonthString} onMonthChange={setVisibleMonthString} activityStats={activityStats} />
<div className="w-full animate-scale-in"> <div className="w-full animate-scale-in">
<MonthCalendar month={visibleMonthString} data={activityStats} maxCount={maxCount} onClick={navigateToDateFilter} /> <MonthCalendar month={visibleMonthString} data={activityStats} maxCount={maxCount} onClick={navigateToDateFilter} />
</div> </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> </div>
); );
}; };
......
...@@ -4,6 +4,7 @@ import toast from "react-hot-toast"; ...@@ -4,6 +4,7 @@ import toast from "react-hot-toast";
import { clearAccessToken, getAccessToken } from "@/auth-state"; import { clearAccessToken, getAccessToken } from "@/auth-state";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service"; import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service";
import { userKeys } from "@/hooks/useUserQueries"; import { userKeys } from "@/hooks/useUserQueries";
import { memoKeys } from "@/hooks/useMemoQueries";
import { getClerkSessionToken } from "@/utils/clerk"; import { getClerkSessionToken } from "@/utils/clerk";
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb"; 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"; 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 }) { ...@@ -103,6 +104,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Pre-populate React Query cache // Pre-populate React Query cache
queryClient.setQueryData(userKeys.currentUser(), currentUser); queryClient.setQueryData(userKeys.currentUser(), currentUser);
queryClient.setQueryData(userKeys.detail(currentUser.name), 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) { } catch (error) {
// Silently handle 401/403 - user is just not logged in // Silently handle 401/403 - user is just not logged in
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
......
...@@ -4,6 +4,13 @@ ...@@ -4,6 +4,13 @@
@theme { @theme {
--default-transition-duration: 150ms; --default-transition-duration: 150ms;
--animate-shimmer: shimmer 1.5s infinite;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
} }
@layer base { @layer base {
......
import { Suspense, useEffect, useMemo } from "react"; import { Suspense, useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom"; import { Outlet, useLocation } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious"; import usePrevious from "react-use/lib/usePrevious";
import Navigation from "@/components/Navigation"; import Navigation from "@/components/Navigation";
import ChatbotWidget from "@/components/ChatbotWidget"; import ChatbotWidget from "@/components/ChatbotWidget";
import CommandPalette from "@/components/CommandPalette";
import Spinner from "@/components/Spinner"; import Spinner from "@/components/Spinner";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useMediaQuery from "@/hooks/useMediaQuery"; import useMediaQuery from "@/hooks/useMediaQuery";
...@@ -13,7 +14,7 @@ const RootLayout = () => { ...@@ -13,7 +14,7 @@ const RootLayout = () => {
const location = useLocation(); const location = useLocation();
const sm = useMediaQuery("sm"); const sm = useMediaQuery("sm");
const { removeFilter } = useMemoFilterContext(); const { removeFilter } = useMemoFilterContext();
const pathname = useMemo(() => location.pathname, [location.pathname]); const pathname = location.pathname;
const prevPathname = usePrevious(pathname); const prevPathname = usePrevious(pathname);
useEffect(() => { useEffect(() => {
...@@ -53,6 +54,7 @@ const RootLayout = () => { ...@@ -53,6 +54,7 @@ const RootLayout = () => {
</Suspense> </Suspense>
</main> </main>
<ChatbotWidget /> <ChatbotWidget />
<CommandPalette />
<FestiveCorner /> <FestiveCorner />
</div> </div>
); );
......
...@@ -74,12 +74,12 @@ export const memoFromApi = (raw: ApiMemo): Memo => { ...@@ -74,12 +74,12 @@ export const memoFromApi = (raw: ApiMemo): Memo => {
const result = { const result = {
name: memoName, name: memoName,
state: State.NORMAL, state: raw.row_status === "ARCHIVED" ? State.ARCHIVED : State.NORMAL,
creator: `users/${raw.creator_id ?? 1}`, creator: `users/${raw.creator_id ?? 1}`,
content, content,
visibility: visibilityFromApi(raw.visibility), visibility: visibilityFromApi(raw.visibility),
tags: Array.isArray(raw.tags) ? raw.tags : [], tags: Array.isArray(raw.tags) ? raw.tags : [],
pinned: false, pinned: raw.pinned ?? false,
attachments: [], attachments: [],
relations, relations,
reactions: [], reactions: [],
......
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb"; 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 { fetchJson, parseResourceId } from "./apiClient";
import { applyMemoFilter, extractTagFilter, memoFromApi, visibilityToApi } from "./converters"; import { applyMemoFilter, extractTagFilter, memoFromApi, visibilityToApi } from "./converters";
import type { ApiMemo } from "./types"; import type { ApiMemo } from "./types";
export const memoServiceClient = { 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 tag = extractTagFilter(request.filter);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (tag) params.append("tag", tag); if (tag) params.append("tag", tag);
...@@ -12,6 +13,11 @@ export const memoServiceClient = { ...@@ -12,6 +13,11 @@ export const memoServiceClient = {
console.log("DEBUG listMemos filter:", request.filter); console.log("DEBUG listMemos filter:", request.filter);
params.append("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()}` : ""; const query = params.toString() ? `?${params.toString()}` : "";
console.log("DEBUG listMemos URL:", `/memos${query}`); console.log("DEBUG listMemos URL:", `/memos${query}`);
const data = await fetchJson<ApiMemo[]>(`/memos${query}`, { method: "GET" }); const data = await fetchJson<ApiMemo[]>(`/memos${query}`, { method: "GET" });
...@@ -40,22 +46,33 @@ export const memoServiceClient = { ...@@ -40,22 +46,33 @@ export const memoServiceClient = {
const data = await fetchJson<ApiMemo>("/memos", { method: "POST", body: payload }); const data = await fetchJson<ApiMemo>("/memos", { method: "POST", body: payload });
return memoFromApi(data); return memoFromApi(data);
}, },
async updateMemo(request: { memo?: Memo }): Promise<Memo> { async updateMemo(request: { memo?: Memo; updateMask?: { paths?: string[] } }): Promise<Memo> {
const memo = request.memo; const memo = request.memo;
if (!memo?.name) { if (!memo?.name) {
throw new Error("Missing memo name"); throw new Error("Missing memo name");
} }
const memoId = parseResourceId(memo.name); const memoId = parseResourceId(memo.name);
const payload: { content?: string; visibility?: string; tags?: string[] } = {}; const payload: { content?: string; visibility?: string; tags?: string[]; pinned?: boolean; row_status?: string } = {};
if (memo.content !== undefined) {
// 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; payload.content = memo.content;
} }
if (memo.visibility !== undefined) { if ((!hasMask && memo.visibility !== undefined) || (hasMask && paths.includes("visibility"))) {
payload.visibility = visibilityToApi(memo.visibility); payload.visibility = visibilityToApi(memo.visibility);
} }
if (memo.tags) { if ((!hasMask && memo.tags) || (hasMask && paths.includes("tags"))) {
payload.tags = memo.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 }); const data = await fetchJson<ApiMemo>(`/memos/${memoId}`, { method: "PATCH", body: payload });
return memoFromApi(data); return memoFromApi(data);
}, },
......
...@@ -6,6 +6,8 @@ export type ApiMemo = { ...@@ -6,6 +6,8 @@ export type ApiMemo = {
visibility?: string | null; visibility?: string | null;
tags?: string[]; tags?: string[];
creator_id?: string; creator_id?: string;
row_status?: string; // "NORMAL" | "ARCHIVED"
pinned?: boolean;
create_time?: string; create_time?: string;
update_time?: string; update_time?: string;
display_time?: string; display_time?: string;
......
...@@ -11,16 +11,30 @@ declare global { ...@@ -11,16 +11,30 @@ declare global {
/** /**
* Get a Clerk session JWT without React hooks (works from non-React modules). * 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. * 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> { export async function getClerkSessionToken(): Promise<string | null> {
const maxRetries = 5;
const retryDelay = 500; // ms
for (let attempt = 0; attempt < maxRetries; attempt++) {
try { try {
const clerk = typeof window === "undefined" ? undefined : window.Clerk; const clerk = typeof window === "undefined" ? undefined : window.Clerk;
if (!clerk?.session?.getToken) return null; if (clerk?.session?.getToken) {
const token = await clerk.session.getToken(); const token = await clerk.session.getToken();
return token || null; if (token) return token;
}
} catch { } 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