Commit 0a87a5d8 authored by Hoanganhvu123's avatar Hoanganhvu123

feat: Inbox notifications, anonymous name fixes, bookmarks UI, version history

parent 118c3180
---
description: Run Ralph Loop - autonomous AI iteration until tests pass
---
# Ralph Loop Workflow
Ralph Loop chạy AI agent liên tục đến khi test pass hoặc max iterations.
## Prerequisites
1. **Antigravity For Loop Extension**: Install từ https://github.com/ImL1s/antigravity_for_loop
2. **Enable CDP**: Run Antigravity với `--remote-debugging-port=9000`
## Quick Start (GUI - Recommended)
1. Click **"For Loop"** trong status bar (bottom)
2. Select **"Start Ralph Loop..."**
3. Enter task description
4. Select completion condition:
- **Tests Pass** - stop khi test exit 0
- **AI Self-Judgment** - stop khi AI output "DONE"
5. Set max iterations (default: 10)
6. Go!
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Cmd+Alt+Shift+L` | Open Ralph menu |
| `Cmd+Alt+Shift+A` | Toggle auto-accept |
| `Cmd+Alt+Shift+C` | Copy continuation prompt |
## Manual Loop (Alternative)
Nếu extension không hoạt động:
// turbo-all
```
1. Review RALPH_TASK.md
2. Chat with AI: "Work on the unchecked items in RALPH_TASK.md"
3. Run tests: cd backend && pytest
4. If tests fail, continue iterating
5. If tests pass, check off completed items
6. Repeat until all done
```
## Create New Task
File: `RALPH_TASK.md`
```markdown
task: "Your task description here"
test_command: "npm test" # or "pytest", "cargo test", etc.
---
# Task: [Title]
## Success Criteria
- [ ] Criterion 1
- [ ] Criterion 2
```
## Windows Setup
```powershell
.\run_ralph_windows.ps1 -MaxIterations 50
```
## Tips
- **Always git commit** before starting a Ralph loop
- Set reasonable **maxIterations** (10-50)
- Use **AI Self-Judgment** for open-ended refactoring tasks
- Use **Tests Pass** for bug fixes with clear test coverage
VITE_CLERK_PUBLISHABLE_KEY=pk_test_Y29tbXVuYWwtc3VuYmVhbS0wLmNsZXJrLmFjY291bnRzLmRldiQ
CLERK_SECRET_KEY=sk_test_ek7ozVR80Qi9UdvhGaTmlXovS16GDuBDlDrpH1rkyQ
\ No newline at end of file
CLERK_SECRET_KEY=sk_test_ek7ozVR80Qi9UdvhGaTmlXovS16GDuBDlDrpH1rkyQ
MONGODB_URI = mongodb+srv://20010841:vuhoanganh1704@cluster0.h6qro.mongodb.net/?appName=Cluster0
\ No newline at end of file
......@@ -3,6 +3,7 @@ Activity service routes for Memos-style backend.
"""
from typing import List
import re
from fastapi import APIRouter, Depends, HTTPException
......@@ -21,3 +22,30 @@ async def list_activities(activity_service=Depends(get_activity_service)):
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/{name:path}", summary="Get activity by name", response_model=ActivityResponse)
async def get_activity(name: str, activity_service=Depends(get_activity_service)):
"""
Get a single activity by name.
Name format: activities/{id} or just the ID number.
"""
try:
# Extract activity ID from name (e.g., "activities/123456" -> 123456)
if name.startswith("activities/"):
name = name[len("activities/"):]
# Parse ID
match = re.match(r"^(\d+)$", name)
if not match:
raise HTTPException(status_code=400, detail=f"Invalid activity name format: {name}")
activity_id = int(match.group(1))
activity = await activity_service.get_activity(activity_id)
if not activity:
raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found")
return activity
except HTTPException:
raise
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
......@@ -14,6 +14,7 @@ from common.memos_core.schemas import (
AuthSignUpResponse,
)
from common.memos_core.services import get_auth_service
from config import DISABLE_AUTH
router = APIRouter(prefix="/auth")
......@@ -73,21 +74,51 @@ async def get_me(
token = authorization.split(" ", 1)[1]
me = await auth_service.get_me(token=token)
# Build username - prefer me.username, fallback to email prefix
username = getattr(me, "username", None) or (
me.email.split("@", 1)[0] if getattr(me, "email", None) else "user"
)
# Build display name from firstName + lastName
first_name = getattr(me, "first_name", None) or ""
last_name = getattr(me, "last_name", None) or ""
display_name = f"{first_name} {last_name}".strip() or username
avatar_url = getattr(me, "avatar_url", None) or ""
# Connect/proto expects: { "user": User }
return {
"user": {
"name": "users/1",
"role": 1, # HOST
"username": me.email.split("@", 1)[0] if getattr(me, "email", None) else "demo",
"email": getattr(me, "email", "demo@example.com"),
"displayName": "",
"avatarUrl": "",
"username": username,
"email": getattr(me, "email", ""),
"displayName": display_name,
"avatarUrl": avatar_url,
"description": "",
"password": "",
"state": 1,
}
}
except Exception as exc: # pragma: no cover - placeholder
# In local/dev mode, don't break the app just because auth fails.
# Fall back to a demo user so that memo features still work.
if DISABLE_AUTH:
return {
"user": {
"name": "users/1",
"role": 1, # HOST
"username": "demo",
"email": "demo@example.com",
"displayName": "",
"avatarUrl": "",
"description": "",
"password": "",
"state": 1,
}
}
raise HTTPException(status_code=401, detail=str(exc)) from exc
......@@ -15,6 +15,7 @@ from common.memos_core.schemas import (
MemoCreate,
MemoUpdate,
MemoResponse,
MemoVersionResponse,
)
from common.memos_core.services import get_memo_service, get_memo_relation_service
from common.memos_core.query_parser import parse_date_range
......@@ -167,6 +168,20 @@ async def delete_memo(
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{memo_id}/versions", summary="Get memo version history", response_model=MemoVersionResponse)
async def get_memo_versions(
request: Request,
memo_id: str,
memo_service=Depends(get_memo_service),
):
"""Get version history for a memo."""
try:
user_id = get_current_user_id(request)
return await memo_service.get_memo_versions(memo_id, user_id=user_id)
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{memo_id}/comments", summary="Create comment", response_model=MemoResponse)
async def create_memo_comment(
request: Request,
......@@ -205,21 +220,30 @@ async def create_memo_comment(
# Use _id string as parent (standardized format)
parent_id_str = str(parent_doc["_id"])
# 3. Handle anonymous users: build synthetic creator_id
if user_id is None:
# Anonymous user: use anonymous_id from payload or generate one
# 3. Handle users: prioritize anonymous_id/anonymous_name from payload if provided
# This allows custom display names even when DISABLE_AUTH=true
if payload.anonymous_id or payload.anonymous_name:
# Use provided anonymous_id or generate one
anonymous_id = payload.anonymous_id or f"anonymous_{secrets.token_hex(4)}"
anonymous_name = payload.anonymous_name
# Build creator_id: anonymous_<sessionId> or anonymous_<sessionId>_<displayName>
if anonymous_name:
# Sanitize display name (remove special chars that could break queries)
sanitized_name = re.sub(r'[^a-zA-Z0-9_-]', '', anonymous_name)[:20] # Limit length
synthetic_user_id = f"{anonymous_id}_{sanitized_name}"
# URL-safe base64 encode the display name to preserve Unicode characters
# This allows Vietnamese, Chinese, etc. names to be stored and displayed
import base64
encoded_name = base64.urlsafe_b64encode(anonymous_name.encode('utf-8')).decode('ascii')
# Limit to 50 chars max (base64 expands size)
encoded_name = encoded_name[:50].rstrip('=')
synthetic_user_id = f"{anonymous_id}_{encoded_name}"
else:
synthetic_user_id = anonymous_id
user_id = synthetic_user_id
elif user_id is None:
# No auth and no anonymous info provided - generate anonymous ID
user_id = f"anonymous_{secrets.token_hex(4)}"
# 4. Create comment memo (with parent = _id string)
# Allow anonymous users to choose PRIVATE comments
......@@ -263,9 +287,43 @@ async def create_memo_comment(
relation_type="COMMENT"
)
# 4. TODO: Create Activity and Inbox notification
# (if comment is not PRIVATE and creator != original memo creator)
# This can be implemented later
# 5. Create Activity and Notification for memo owner
# Only if: comment is PUBLIC and commenter is different from original memo creator
try:
parent_creator_id = parent_doc.get("creator_id")
# Only notify if:
# 1. Comment is PUBLIC (owner should see it)
# 2. Commenter is not the memo owner
if comment_visibility == "PUBLIC" and parent_creator_id and parent_creator_id != user_id:
from common.memos_core.services import ActivityService, NotificationService
activity_service = ActivityService()
notification_service = NotificationService()
# Create activity record
activity = await activity_service.create_activity(
activity_type="MEMO_COMMENT",
creator_id=user_id,
memo_id=comment.id, # The comment memo
related_memo_id=parent_id_str # The original memo
)
# Extract activity ID from name (activities/123456 -> 123456)
activity_id = int(activity.name.split("/")[-1])
# Create notification for memo owner
await notification_service.create_notification(
recipient_id=parent_creator_id,
sender_id=user_id,
activity_id=activity_id,
notification_type="MEMO_COMMENT"
)
except Exception as notify_exc:
# Don't fail the comment creation if notification fails
# Just log and continue
import logging
logging.warning(f"Failed to create notification: {notify_exc}")
return comment
......
......@@ -61,10 +61,70 @@ async def list_user_settings_connect_compat(payload: dict = Body(default_factory
}
@router.get("/{user_id}", summary="Get user by ID", response_model=UserResponse)
async def get_user(user_id: str, user_service=Depends(get_user_service)):
@router.get("/{user_id}/stats", summary="Get user stats (tags, activity)")
async def get_user_stats(user_id: str, user_service=Depends(get_user_service)):
"""
Get user statistics including:
- tagCount: aggregated tag counts from all user's memos
- memoDisplayTimestamps: list of memo created_at timestamps for activity calendar
"""
try:
return await user_service.get_user(user_id)
stats = await user_service.get_user_stats(user_id)
return {
"tagCount": stats.tag_count,
"memoDisplayTimestamps": stats.memo_display_timestamps,
}
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.get("/{user_id}", summary="Get user by ID")
async def get_user(user_id: str, request: Request, user_service=Depends(get_user_service)):
"""
Get user by ID. Returns a User object matching the proto format.
For the current user (user_id==1 or me), returns data from auth session.
"""
try:
# Get user data from auth middleware - request.state.user contains Clerk JWT payload
user_payload = getattr(request.state, "user", None)
# Default values for when no auth is available (DISABLE_AUTH mode)
username = "user"
display_name = ""
avatar_url = ""
email = ""
if user_payload and isinstance(user_payload, dict):
# Extract from Clerk JWT claims
username = user_payload.get("username") or user_payload.get("preferred_username") or ""
first_name = user_payload.get("first_name") or user_payload.get("given_name") or ""
last_name = user_payload.get("last_name") or user_payload.get("family_name") or ""
avatar_url = user_payload.get("image_url") or user_payload.get("picture") or ""
email = user_payload.get("email") or ""
# Build display name
display_name = f"{first_name} {last_name}".strip()
# Fallback username from email if not set
if not username and email:
username = email.split("@")[0]
# Ensure we always have valid strings (never 'undefined')
username = username if username else "user"
display_name = display_name if display_name else username
# Return proto-compatible User format
return {
"name": f"users/{user_id}",
"role": 1, # HOST
"username": username,
"email": email,
"displayName": display_name,
"avatarUrl": avatar_url,
"description": "",
"password": "",
"state": 1, # ACTIVE
}
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=404, detail=str(exc)) from exc
......@@ -180,3 +240,105 @@ async def delete_user_openai_key(
raise HTTPException(status_code=500, detail=str(exc)) from exc
# ============================================================================
# User Notifications (Inbox) Routes
# ============================================================================
from common.memos_core.services import get_notification_service
from common.memos_core.schemas import NotificationResponse
@router.post("/notifications:list", summary="List user notifications (Connect RPC style)")
async def list_user_notifications(
request: Request,
notification_service=Depends(get_notification_service),
):
"""
List all notifications for the current user.
Connect RPC style: POST /users/notifications:list
Returns notifications for the Inbox page.
"""
try:
# Get current user ID from auth middleware
user_id = getattr(request.state, "user_id", None) or "1"
notifications = await notification_service.list_notifications(user_id)
# Return in Connect RPC format matching frontend expectations
return {
"notifications": [n.model_dump() for n in notifications]
}
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.patch("/notifications/{name:path}", summary="Update notification status")
async def update_user_notification(
name: str,
payload: dict = Body(default_factory=dict),
notification_service=Depends(get_notification_service),
):
"""
Update a notification (e.g., mark as READ or ARCHIVED).
Name format: notifications/{id} or just the ID number.
"""
import re
try:
# Extract notification ID from name
if name.startswith("notifications/"):
name = name[len("notifications/"):]
match = re.match(r"^(\d+)$", name)
if not match:
raise HTTPException(status_code=400, detail=f"Invalid notification name: {name}")
notification_id = int(match.group(1))
# Get status from payload (Connect RPC format: { notification: { status: "..." } })
notification_data = payload.get("notification", payload)
new_status = notification_data.get("status", "READ")
notification = await notification_service.update_notification(notification_id, new_status)
if not notification:
raise HTTPException(status_code=404, detail=f"Notification {notification_id} not found")
return notification.model_dump()
except HTTPException:
raise
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.delete("/notifications/{name:path}", summary="Delete notification")
async def delete_user_notification(
name: str,
notification_service=Depends(get_notification_service),
):
"""
Delete a notification.
Name format: notifications/{id} or just the ID number.
"""
import re
try:
# Extract notification ID from name
if name.startswith("notifications/"):
name = name[len("notifications/"):]
match = re.match(r"^(\d+)$", name)
if not match:
raise HTTPException(status_code=400, detail=f"Invalid notification name: {name}")
notification_id = int(match.group(1))
deleted = await notification_service.delete_notification(notification_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Notification {notification_id} not found")
return {"success": True}
except HTTPException:
raise
except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc
......@@ -55,6 +55,12 @@ class AuthSignUpResponse(BaseModel):
class AuthMeResponse(BaseModel):
id: str
email: EmailStr
username: Optional[str] = None
first_name: Optional[str] = Field(default=None, alias="firstName")
last_name: Optional[str] = Field(default=None, alias="lastName")
avatar_url: Optional[str] = Field(default=None, alias="avatarUrl")
model_config = ConfigDict(populate_by_name=True)
class UserBase(BaseModel):
......@@ -73,6 +79,22 @@ class UserUpdate(BaseModel):
class UserResponse(UserBase):
id: str
nickname: Optional[str] = None
username: Optional[str] = None
display_name: Optional[str] = Field(default=None, alias="displayName")
avatar_url: Optional[str] = Field(default=None, alias="avatarUrl")
name: Optional[str] = None # Resource name: users/{id}
role: int = 1 # 1 = HOST
state: int = 1 # 1 = ACTIVE
model_config = ConfigDict(populate_by_name=True)
class UserStatsResponse(BaseModel):
"""User statistics including tag counts and memo timestamps."""
tag_count: dict[str, int] = Field(default_factory=dict, alias="tagCount")
memo_display_timestamps: List[str] = Field(default_factory=list, alias="memoDisplayTimestamps")
model_config = ConfigDict(populate_by_name=True)
class MemoBase(BaseModel):
......@@ -95,10 +117,28 @@ class MemoUpdate(BaseModel):
row_status: Optional[str] = None
class MemoVersion(BaseModel):
"""Represents a historical version of a memo's content."""
version: int = 1
content: str
created_at: datetime
created_by: Optional[str] = None # User ID who made the edit
model_config = ConfigDict(populate_by_name=True)
class MemoVersionResponse(BaseModel):
"""Response schema for memo version history."""
versions: List[MemoVersion] = []
total: int = 0
class MemoResponse(MemoBase):
id: str
uid: str = ""
creator_id: str
creator: str = "" # Resource name: users/{creator_id}
name: str = "" # Resource name: memos/{uid}
pinned: bool = False
row_status: str = "NORMAL"
create_time: Optional[str] = None
......@@ -106,6 +146,7 @@ class MemoResponse(MemoBase):
display_time: Optional[str] = None
parent: Optional[str] = None
comment_count: int = 0
versions: List[MemoVersion] = [] # Version history
model_config = ConfigDict(populate_by_name=True)
......@@ -159,10 +200,48 @@ class ListShortcutsResponse(BaseModel):
shortcuts: List[ShortcutResponse] = []
class ActivityMemoCommentPayload(BaseModel):
"""Payload for memo comment activities"""
memo: str # Comment memo name (memos/{uid})
related_memo: str # Parent memo name that was commented on
class ActivityPayload(BaseModel):
"""Activity payload with different types"""
memo_comment: Optional[ActivityMemoCommentPayload] = None
class ActivityResponse(BaseModel):
id: int
type: str
description: Optional[str] = None
"""Activity record - e.g., when a comment is created"""
name: str # activities/{id}
creator: str # users/{id}
type: str # MEMO_COMMENT, etc.
level: str = "INFO" # INFO, WARNING, ERROR
create_time: Optional[str] = None
payload: Optional[ActivityPayload] = None
class NotificationStatus:
"""Notification status constants"""
UNREAD = "UNREAD"
READ = "READ"
ARCHIVED = "ARCHIVED"
class NotificationType:
"""Notification type constants"""
MEMO_COMMENT = "MEMO_COMMENT"
class NotificationResponse(BaseModel):
"""User notification - shows in Inbox"""
name: str # notifications/{id}
sender: str # users/{creator_id} or anonymous_{id}
activity_id: int # Reference to activity
type: str # MEMO_COMMENT
status: str = "UNREAD" # UNREAD, READ, ARCHIVED
create_time: Optional[str] = None
class IdentityProviderCreate(BaseModel):
......
This diff is collapsed.
......@@ -88,16 +88,6 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
auth_header = request.headers.get("Authorization")
device_id = request.headers.get("device_id", "")
# ========== DEV MODE: Bypass auth ==========
dev_user_id = request.headers.get("X-Dev-User-Id")
if dev_user_id:
logger.warning(f"⚠️ DEV MODE: Using X-Dev-User-Id={dev_user_id}")
request.state.user = {"customer_id": dev_user_id}
request.state.user_id = dev_user_id
request.state.is_authenticated = True
request.state.device_id = device_id or dev_user_id
return await call_next(request)
# --- TRƯỜNG HỢP 1: KHÔNG CÓ TOKEN -> GUEST ---
if not auth_header or not auth_header.startswith("Bearer "):
request.state.user = None
......
......@@ -7,8 +7,53 @@ import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Base dir of backend (this file)
BASE_DIR = os.path.dirname(__file__)
# 1) Load environment variables from `.env` in backend folder (ổn định, không phụ thuộc cwd)
BACKEND_DOTENV_PATH = os.path.join(BASE_DIR, ".env")
load_dotenv(dotenv_path=BACKEND_DOTENV_PATH, override=False)
# 1b) Also load project-root `.env` (nếu bạn đặt key ở root giống hình)
ROOT_DOTENV_PATH = os.path.join(BASE_DIR, "..", ".env")
load_dotenv(dotenv_path=ROOT_DOTENV_PATH, override=False)
# 2) Fallback: cũng load env từ file `env.txt` (KEY=VALUE per line) trong thư mục backend
ENV_TXT_PATH = os.path.join(BASE_DIR, "env.txt")
def _load_env_from_env_txt() -> None:
"""
Đọc thêm biến môi trường từ `env.txt` nếu tồn tại.
- Format: mỗi dòng `KEY=VALUE`
- Bỏ qua dòng trống / comment
- Chỉ set nếu biến đó CHƯA có trong os.environ
"""
if not os.path.exists(ENV_TXT_PATH):
return
try:
with open(ENV_TXT_PATH, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if key and key not in os.environ:
os.environ[key] = value
except Exception:
# Không fail startup chỉ vì file dev-env phụ; dùng như fallback local thôi.
pass
# Gọi ngay sau khi load_dotenv để các os.getenv phía dưới đều nhìn thấy
_load_env_from_env_txt()
# Export all config variables for type checking
__all__ = [
......@@ -117,8 +162,9 @@ LANGSMITH_PROJECT = None
# ====================== CLERK AUTHENTICATION ======================
CLERK_SECRET_KEY: str | None = os.getenv("CLERK_SECRET_KEY")
CLERK_JWKS_URL: str | None = os.getenv("CLERK_JWKS_URL")
CLERK_ISSUER: str | None = os.getenv("CLERK_ISSUER")
# Hardcode Clerk domain để test nhanh (có thể override bằng env)
CLERK_JWKS_URL: str | None = os.getenv("CLERK_JWKS_URL") or "https://communal-sunbeam-0.clerk.accounts.dev/.well-known/jwks.json"
CLERK_ISSUER: str | None = os.getenv("CLERK_ISSUER") or "https://communal-sunbeam-0.clerk.accounts.dev"
# ====================== DATABASE CONNECTION ======================
# Redis Cache Configuration
......
VITE_CLERK_PUBLISHABLE_KEY=pk_test_Y29tbXVuYWwtc3VuYmVhbS0wLmNsZXJrLmFjY291bnRzLmRldiQ
CLERK_SECRET_KEY=sk_test_ek7ozVR80Qi9UdvhGaTmlXovS16GDuBDlDrpH1rkyQ
MONGODB_URI=mongodb+srv://20010841:vuhoanganh1704@cluster0.h6qro.mongodb.net/?appName=Cluster0
\ No newline at end of file
aiofiles==25.1.0
aiosqlite==0.20.0
aiomysql==0.3.2
# Core FastAPI
fastapi==0.124.4
uvicorn==0.38.0
starlette==0.50.0
pydantic==2.12.5
pydantic_core==2.41.5
# Database - MongoDB
motor==3.7.0
# Database - PostgreSQL (for LangGraph checkpoints)
psycopg==3.3.2
psycopg-binary==3.3.2
psycopg-pool==3.3.0
# Redis for caching
redis[hiredis]==5.2.1
# Auth & Security
PyJWT==2.10.1
cryptography==46.0.3
email-validator==2.2.0
annotated-doc==0.0.4
annotated-types==0.7.0
# Async
aiofiles==25.1.0
anyio==4.12.0
backoff==2.2.1
bidict==0.23.1
blinker==1.9.0
brotli==1.2.0
cachetools==6.2.4
certifi==2025.11.12
cffi==2.0.0
cfgv==3.5.0
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
ConfigArgParse==1.7.1
cryptography==46.0.3
dis==1.0.1
distlib==0.4.0
distro==1.9.0
dotenv==0.9.9
faiss-cpu==1.13.1
fastapi==0.124.4
filelock==3.20.1
filetype==1.2.0
Flask==3.1.2
flask-cors==6.0.2
Flask-Login==0.6.3
gevent==25.9.1
geventhttpclient==2.3.7
google-auth==2.45.0
google-genai==1.56.0
googleapis-common-protos==1.72.0
greenlet==3.3.0
h11==0.16.0
httpcore==1.0.9
# HTTP Clients
httpx==0.28.1
identify==2.6.15
idna==3.11
importlib_metadata==8.7.0
iniconfig==2.3.0
itsdangerous==2.2.0
Jinja2==3.1.6
jiter==0.12.0
jsonpatch==1.33
jsonpointer==3.0.0
httpcore==1.0.9
requests==2.32.4
# AI/LLM - LangChain & LangGraph
langchain==1.2.0
langchain-core==1.2.3
langchain-google-genai==4.1.2
langchain-openai==1.1.6
langfuse==3.11.0
langgraph==1.0.5
langgraph-checkpoint==3.0.1
langgraph-checkpoint-postgres==3.0.2
langgraph-prebuilt==1.0.5
langgraph-sdk==0.3.0
langsmith==0.5.0
loguru==0.7.3
MarkupSafe==3.0.3
msgpack==1.1.2
nodeenv==1.10.0
numpy==2.4.0
langfuse==3.11.0
# AI/LLM - OpenAI & Google
openai==2.13.0
google-genai==1.56.0
google-auth==2.45.0
# Tokenization
tiktoken==0.12.0
# Observability
opentelemetry-api==1.39.1
opentelemetry-exporter-otlp-proto-common==1.39.1
opentelemetry-exporter-otlp-proto-http==1.39.1
opentelemetry-proto==1.39.1
opentelemetry-sdk==1.39.1
opentelemetry-semantic-conventions==0.60b1
orjson==3.11.5
ormsgpack==1.12.1
packaging==25.0
pillow==12.0.0
platformdirs==4.5.1
playwright==1.57.0
playwright-stealth==2.0.0
pluggy==1.6.0
pre_commit==4.5.1
protobuf==6.33.2
psutil==7.1.3
psycopg==3.3.2
psycopg-binary==3.3.2
psycopg-pool==3.3.0
pyasn1==0.6.1
pyasn1_modules==0.4.2
pycparser==2.23
pydantic==2.12.5
pydantic_core==2.41.5
pyee==13.0.0
Pygments==2.19.2
PyMySQL==1.1.2
pyscn==1.5.5
pytest==9.0.2
PyJWT==2.10.1
# Utilities
python-dotenv==1.2.1
python-multipart==0.0.20
python-engineio==4.12.3
python-socketio==5.15.1
loguru==0.7.3
orjson==3.11.5
PyYAML==6.0.3
pyzmq==27.1.0
redis[hiredis]==5.2.1
regex==2025.11.3
requests==2.32.4
requests-toolbelt==1.0.0
rsa==4.9.1
ruff==0.14.10
simple-websocket==1.1.0
sniffio==1.3.1
starlette==0.50.0
tenacity==9.1.2
tiktoken==0.12.0
click==8.3.1
tqdm==4.67.1
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2025.3
tenacity==9.1.2
backoff==2.2.1
regex==2025.11.3
Unidecode==1.4.0
urllib3==2.6.2
uuid_utils==0.12.0
uv==0.9.18
uvicorn==0.38.0
virtualenv==20.35.4
websocket-client==1.9.0
pillow==12.0.0
# WebSocket
websockets==15.0.1
Werkzeug==3.1.4
win32_setctime==1.2.0
wrapt==1.17.3
wsproto==1.3.2
xxhash==3.6.0
zipp==3.23.0
zope.event==6.1
zope.interface==8.1.1
zstandard==0.25.0
websocket-client==1.9.0
python-engineio==4.12.3
python-socketio==5.15.1
# BSON for ObjectId
# (included via motor)
# Common dependencies (transitive but pinned)
certifi==2025.11.12
charset-normalizer==3.4.4
idna==3.11
urllib3==2.6.2
typing_extensions==4.15.0
annotated-types==0.7.0
sniffio==1.3.1
h11==0.16.0
protobuf==6.33.2
packaging==25.0
colorama==0.4.6
numpy==2.4.0
jiter==0.12.0
MarkupSafe==3.0.3
Jinja2==3.1.6
cachetools==6.2.4
# Testing
pytest==9.0.2
# Production server
gunicorn==23.0.0
"""
Simple script to test MongoDB connection using the same config style as the backend.
Ưu tiên lấy `MONGODB_URI` từ:
- Biến môi trường / file `.env` (đúng chuẩn backend đang dùng)
- Fallback: file `env.txt` trong thư mục `backend` (đang có sẵn của bạn)
"""
from __future__ import annotations
import asyncio
import os
from typing import Optional
from motor.motor_asyncio import AsyncIOMotorClient
from dotenv import load_dotenv
BASE_DIR = os.path.dirname(__file__)
ENV_TXT_PATH = os.path.join(BASE_DIR, "env.txt")
DOTENV_PATH = os.path.join(BASE_DIR, ".env")
def load_env_from_env_txt() -> None:
"""
Load các biến môi trường từ file `env.txt` (KEY=VALUE per line).
Chỉ set nếu biến đó chưa có trong `os.environ`,
để ưu tiên biến môi trường / file `.env` nếu có.
"""
if not os.path.exists(ENV_TXT_PATH):
return
with open(ENV_TXT_PATH, encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
if key and key not in os.environ:
os.environ[key] = value
def get_mongodb_uri() -> Optional[str]:
"""Lấy MONGODB_URI từ env (.env, env.txt hoặc biến môi trường)."""
# 1. Load từ .env nếu có
if os.path.exists(DOTENV_PATH):
load_dotenv(DOTENV_PATH, override=False)
else:
# Vẫn thử load mặc định (nếu có .env ở cwd)
load_dotenv(override=False)
# 2. Load thêm từ env.txt (chỉ set nếu chưa có)
load_env_from_env_txt()
return os.getenv("MONGODB_URI")
async def main() -> None:
uri = get_mongodb_uri()
if not uri:
print(
"❌ Không tìm thấy MONGODB_URI.\n"
"- Thêm MONGODB_URI vào file `.env` trong thư mục `backend`, hoặc\n"
"- Đảm bảo có dòng `MONGODB_URI=...` trong `backend/env.txt`."
)
return
print(f"🔍 Đang thử kết nối MongoDB với URI (đã ẩn password, chỉ in prefix): {uri[:40]}...")
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=5000)
try:
await client.admin.command("ping")
print("✅ Kết nối MongoDB thành công! `ping` OK.")
except Exception as e: # pragma: no cover - pure debug script
print("❌ Kết nối MongoDB thất bại:")
print(repr(e))
finally:
client.close()
print("🔌 Đã đóng kết nối MongoDB.")
if __name__ == "__main__":
asyncio.run(main())
"""
API Integration tests for OpenNotion backend.
Validates all API endpoints work correctly end-to-end.
"""
import pytest
from httpx import AsyncClient
class TestMemosApiIntegration:
"""Integration tests for memos API."""
@pytest.mark.asyncio
async def test_list_memos_returns_valid_structure(self, async_client: AsyncClient):
"""Test that list memos returns proper structure."""
response = await async_client.get("/api/v1/memos")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
if len(data) > 0:
memo = data[0]
assert "id" in memo
assert "content" in memo
assert "creator_id" in memo
@pytest.mark.asyncio
async def test_get_memo_by_id(self, async_client: AsyncClient):
"""Test getting a single memo by ID."""
# First list memos
list_response = await async_client.get("/api/v1/memos")
assert list_response.status_code == 200
memos = list_response.json()
if len(memos) > 0:
memo_id = memos[0]["id"]
# Get single memo
response = await async_client.get(f"/api/v1/memos/{memo_id}")
assert response.status_code == 200
memo = response.json()
assert memo["id"] == memo_id
@pytest.mark.asyncio
async def test_list_memos_with_tag_filter(self, async_client: AsyncClient):
"""Test filtering memos by tag."""
response = await async_client.get("/api/v1/memos?tag=test")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
class TestUserStatsApiIntegration:
"""Integration tests for user stats API."""
@pytest.mark.asyncio
async def test_user_stats_returns_valid_structure(self, async_client: AsyncClient):
"""Test that user stats returns proper structure."""
response = await async_client.get("/api/v1/users/1/stats")
assert response.status_code == 200
data = response.json()
assert "tagCount" in data or "tag_count" in data
assert "memoDisplayTimestamps" in data or "memo_display_timestamps" in data
@pytest.mark.asyncio
async def test_user_stats_tag_count_is_dict(self, async_client: AsyncClient):
"""Test that tag count is a dictionary."""
response = await async_client.get("/api/v1/users/1/stats")
assert response.status_code == 200
data = response.json()
tag_count = data.get("tagCount") or data.get("tag_count", {})
assert isinstance(tag_count, dict)
class TestVersionsApiIntegration:
"""Integration tests for versions API."""
@pytest.mark.asyncio
async def test_versions_endpoint_exists(self, async_client: AsyncClient):
"""Test that versions endpoint is accessible."""
# First get a memo
memos_response = await async_client.get("/api/v1/memos")
if memos_response.status_code == 200 and len(memos_response.json()) > 0:
memo_id = memos_response.json()[0]["id"]
response = await async_client.get(f"/api/v1/memos/{memo_id}/versions")
assert response.status_code == 200
data = response.json()
assert "versions" in data
assert "total" in data
@pytest.mark.asyncio
async def test_versions_returns_empty_for_unedited_memo(self, async_client: AsyncClient):
"""Test that versions is empty for unedited memo."""
memos_response = await async_client.get("/api/v1/memos")
if memos_response.status_code == 200 and len(memos_response.json()) > 0:
memo_id = memos_response.json()[0]["id"]
response = await async_client.get(f"/api/v1/memos/{memo_id}/versions")
assert response.status_code == 200
data = response.json()
# Versions may be empty if never edited
assert isinstance(data["versions"], list)
class TestCommentsApiIntegration:
"""Integration tests for comments API."""
@pytest.mark.asyncio
async def test_list_comments_endpoint(self, async_client: AsyncClient):
"""Test that comments endpoint works."""
memos_response = await async_client.get("/api/v1/memos")
if memos_response.status_code == 200 and len(memos_response.json()) > 0:
memo_id = memos_response.json()[0]["id"]
response = await async_client.get(f"/api/v1/memos/{memo_id}/comments")
# Should return 200 or valid error
assert response.status_code in [200, 404]
class TestHealthCheck:
"""Health check and basic functionality tests."""
@pytest.mark.asyncio
async def test_api_is_accessible(self, async_client: AsyncClient):
"""Test that API is accessible."""
response = await async_client.get("/api/v1/memos")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_cors_headers(self, async_client: AsyncClient):
"""Test CORS headers are present."""
response = await async_client.options("/api/v1/memos")
# CORS middleware should handle OPTIONS
assert response.status_code in [200, 204, 405]
"""
Simple script / pytest test to verify MongoDB connection.
Usage as script (recommended for quick check):
cd backend
# Ensure MONGODB_URI is set in environment or .env
python -m tests.test_mongodb_connection
It will:
- Read MONGODB_URI from environment (preferred)
- If missing, fallback to a hardcoded URI for local testing
- Try to run admin.command(\"ping\") against MongoDB
"""
from __future__ import annotations
import asyncio
import os
import pytest
from motor.motor_asyncio import AsyncIOMotorClient
# Fallback URI for local testing ONLY.
# Prefer setting MONGODB_URI in backend/.env or your shell instead of relying on this.
HARDCODED_TEST_URI = (
"mongodb+srv://20010841:vuhoanganh1704@cluster0.h6qro.mongodb.net/"
"?appName=Cluster0"
)
async def _ping_mongodb(uri: str) -> None:
print(f"🔌 Testing MongoDB connection with URI: {uri!r}")
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=5000)
try:
await client.admin.command("ping")
print("✅ MongoDB ping succeeded")
finally:
client.close()
@pytest.mark.asyncio
async def test_mongodb_connection() -> None:
"""Pytest-compatible test to verify MongoDB connection."""
uri = os.getenv("MONGODB_URI") or HARDCODED_TEST_URI
await _ping_mongodb(uri)
if __name__ == "__main__":
# Allow running as a standalone script: python -m tests.test_mongodb_connection
uri = os.getenv("MONGODB_URI") or HARDCODED_TEST_URI
asyncio.run(_ping_mongodb(uri))
"""
Performance tests for the OpenNotion backend.
Tests API response times, concurrent load, and database query performance.
"""
import asyncio
import time
from typing import List
import pytest
from httpx import AsyncClient
# Performance thresholds (in seconds)
RESPONSE_TIME_THRESHOLD = 0.5 # 500ms
BULK_OPERATION_THRESHOLD = 2.0 # 2s for bulk operations
class TestApiPerformance:
"""Performance tests for API endpoints."""
@pytest.mark.asyncio
async def test_list_memos_response_time(self, async_client: AsyncClient):
"""Test that listing memos responds within threshold."""
start = time.perf_counter()
response = await async_client.get("/api/v1/memos")
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"List memos took {elapsed:.3f}s (threshold: {RESPONSE_TIME_THRESHOLD}s)"
@pytest.mark.asyncio
async def test_get_user_stats_response_time(self, async_client: AsyncClient):
"""Test that user stats API responds within threshold."""
start = time.perf_counter()
response = await async_client.get("/api/v1/users/1/stats")
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"User stats took {elapsed:.3f}s (threshold: {RESPONSE_TIME_THRESHOLD}s)"
@pytest.mark.asyncio
async def test_create_memo_response_time(self, async_client: AsyncClient):
"""Test memo creation performance."""
memo_data = {
"content": "Performance test memo #perf #test",
"visibility": "PUBLIC",
}
start = time.perf_counter()
response = await async_client.post("/api/v1/memos", json=memo_data)
elapsed = time.perf_counter() - start
# May fail due to auth, but we're testing response time
assert elapsed < RESPONSE_TIME_THRESHOLD, f"Create memo took {elapsed:.3f}s"
@pytest.mark.asyncio
async def test_concurrent_requests(self, async_client: AsyncClient):
"""Test handling of concurrent requests."""
num_requests = 10
async def make_request():
return await async_client.get("/api/v1/memos")
start = time.perf_counter()
tasks = [make_request() for _ in range(num_requests)]
responses = await asyncio.gather(*tasks)
elapsed = time.perf_counter() - start
# All should succeed
success_count = sum(1 for r in responses if r.status_code == 200)
assert success_count == num_requests, f"Only {success_count}/{num_requests} succeeded"
assert elapsed < BULK_OPERATION_THRESHOLD, f"Concurrent requests took {elapsed:.3f}s"
class TestDatabasePerformance:
"""Performance tests for database operations."""
@pytest.mark.asyncio
async def test_memo_query_with_filters(self, async_client: AsyncClient):
"""Test memo listing with tag filter performance."""
start = time.perf_counter()
response = await async_client.get("/api/v1/memos?tag=test")
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"Filtered query took {elapsed:.3f}s"
@pytest.mark.asyncio
async def test_memo_query_with_date_range(self, async_client: AsyncClient):
"""Test memo listing with date range filter."""
start = time.perf_counter()
response = await async_client.get(
"/api/v1/memos?start_date=2026-01-01&end_date=2026-12-31"
)
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"Date range query took {elapsed:.3f}s"
class TestVersionsApiPerformance:
"""Performance tests for version history API."""
@pytest.mark.asyncio
async def test_get_versions_response_time(self, async_client: AsyncClient):
"""Test version history API performance."""
# First get a memo ID
memos_response = await async_client.get("/api/v1/memos")
if memos_response.status_code == 200 and len(memos_response.json()) > 0:
memo_id = memos_response.json()[0].get("id")
start = time.perf_counter()
response = await async_client.get(f"/api/v1/memos/{memo_id}/versions")
elapsed = time.perf_counter() - start
assert response.status_code == 200
assert elapsed < RESPONSE_TIME_THRESHOLD, f"Versions API took {elapsed:.3f}s"
class TestMemoryAndResources:
"""Test for memory leaks and resource management."""
@pytest.mark.asyncio
async def test_repeated_requests_no_memory_leak(self, async_client: AsyncClient):
"""Test that repeated requests don't cause memory issues."""
import psutil
import os
process = psutil.Process(os.getpid())
initial_memory = process.memory_info().rss / 1024 / 1024 # MB
# Make many requests
for _ in range(50):
await async_client.get("/api/v1/memos")
final_memory = process.memory_info().rss / 1024 / 1024 # MB
memory_increase = final_memory - initial_memory
# Memory shouldn't increase by more than 50MB
assert memory_increase < 50, f"Memory increased by {memory_increase:.1f}MB"
class TestLoadStress:
"""Load and stress tests."""
@pytest.mark.asyncio
async def test_sustained_load(self, async_client: AsyncClient):
"""Test handling sustained load over time."""
num_requests = 30
request_times: List[float] = []
for _ in range(num_requests):
start = time.perf_counter()
response = await async_client.get("/api/v1/memos")
elapsed = time.perf_counter() - start
request_times.append(elapsed)
assert response.status_code == 200
avg_time = sum(request_times) / len(request_times)
max_time = max(request_times)
assert avg_time < RESPONSE_TIME_THRESHOLD, f"Average response time {avg_time:.3f}s exceeds threshold"
assert max_time < RESPONSE_TIME_THRESHOLD * 2, f"Max response time {max_time:.3f}s is too high"
......@@ -32,10 +32,6 @@ export const MonthCalendar = memo((props: MonthCalendarProps) => {
return (
<div className={cn("flex flex-col gap-2 relative group", className)}>
{/* Festive Decorations for Calendar */}
<div className="absolute -top-3 -left-3 text-lg pointer-events-none select-none opacity-40 group-hover:opacity-100 transition-opacity duration-300 animate-pulse">🌸</div>
<div className="absolute -bottom-3 -right-3 text-lg pointer-events-none select-none opacity-40 group-hover:opacity-100 transition-opacity duration-300 animate-pulse delay-500">🌼</div>
<div className={cn("grid grid-cols-7", sizeConfig.gap, "text-muted-foreground mb-1", size === "small" ? "text-[10px]" : "text-xs")}>
{rotatedWeekDays.map((label, index) => (
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/60 font-medium">
......
......@@ -135,7 +135,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
filter,
},
});
toast.success("Create shortcut successfully");
toast.success("Create workspace successfully");
} else {
await shortcutServiceClient.updateShortcut({
shortcut: {
......@@ -147,7 +147,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
},
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }),
});
toast.success("Update shortcut successfully");
toast.success("Update workspace successfully");
}
await refetchSettings();
requestState.setFinish();
......@@ -155,7 +155,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
onOpenChange(false);
} catch (error: unknown) {
await handleError(error, toast.error, {
context: isCreating ? "Create shortcut" : "Update shortcut",
context: isCreating ? "Create workspace" : "Update workspace",
onError: () => requestState.setError(),
});
}
......
......@@ -42,31 +42,8 @@ const FestiveCorner = () => {
</div>
{/* Two original side envelopes */}
<Envelope side="left" delay="-2s" message="Vạn Sự Như Ý! 🧧" />
<Envelope side="right" message="Chúc Mừng Năm Mới! ✨" />
{/* Central decoration branch */}
<div className="fixed top-0 left-64 md:left-[480px] z-[10] pointer-events-none select-none hidden md:block">
<div className="relative">
<div className="absolute top-0 left-0 animate-[sway_6s_ease-in-out_infinite] origin-top-left">
<div className="text-5xl filter drop-shadow-lg opacity-90">🌸</div>
<div className="absolute top-4 left-6 text-3xl opacity-80">🌸</div>
<div className="absolute top-8 left-4 animate-[sway_4s_ease-in-out_infinite] delay-500 origin-top">
<div className="w-[1px] h-10 bg-red-600/30 mx-auto" />
<div className="group pointer-events-auto cursor-pointer relative">
<div className="w-12 h-18 bg-red-600 rounded-sm shadow-lg flex flex-col items-center justify-center border border-red-500 transition-transform hover:scale-110">
<div className="text-yellow-400 font-bold text-sm">🧧</div>
<div className="text-[8px] text-yellow-200 font-bold mt-1">2026</div>
</div>
<div className="absolute top-full mt-1 left-0 bg-white/90 backdrop-blur px-2 py-0.5 rounded-full text-[8px] font-bold text-red-600 shadow-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap border border-red-50 italic">
Lộc xuân tràn đầy! 🌸
</div>
</div>
</div>
</div>
</div>
</div>
{/* Scattered Blossoms and Icons around the screen */}
<Blossom top="20%" left="5%" delay="-1s" size="text-2xl" />
<Blossom top="70%" left="2%" delay="-3s" size="text-xl" />
......
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { BookmarkIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { cn } from "@/lib/utils";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
interface BookmarksSectionProps {
className?: string;
}
// Max items to show when expanded
const MAX_VISIBLE = 5;
const BookmarksSection = ({ className }: BookmarksSectionProps) => {
const t = useTranslate();
const user = useCurrentUser();
// Default COLLAPSED
const [isOpen, setIsOpen] = useState(false);
const [showAll, setShowAll] = useState(false);
// Build simple filter for pinned memos - NO context filters
const pinnedFilter = useMemo(() => {
if (user?.name) {
const userId = user.name.split("/")[1];
return `creator_id == ${userId} && pinned`;
}
return "pinned";
}, [user?.name]);
const { data: pinnedData, isLoading } = useInfiniteMemos({
state: State.NORMAL,
orderBy: "display_time desc",
filter: pinnedFilter,
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
});
const pinnedMemos = useMemo(() => {
return pinnedData?.pages.flatMap((page) => page.memos) || [];
}, [pinnedData]);
if (isLoading || pinnedMemos.length === 0) {
return null;
}
// Get preview (first line, max 30 chars)
const getPreview = (content: string): string => {
const firstLine = content.split("\n")[0].trim();
const cleanLine = firstLine.replace(/^#+\s*/, "");
return cleanLine.length > 30 ? `${cleanLine.slice(0, 30)}...` : cleanLine || "(empty)";
};
// Format relative time
const formatTime = (memo: Memo): string => {
if (!memo.displayTime) return "";
const date = timestampDate(memo.displayTime);
if (!date) return "";
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) return "now";
if (diffHours < 24) return `${diffHours}h`;
return `${diffDays}d`;
};
const visibleMemos = showAll ? pinnedMemos : pinnedMemos.slice(0, MAX_VISIBLE);
const hiddenCount = pinnedMemos.length - MAX_VISIBLE;
return (
<div className={cn("w-full", className)}>
{/* Collapsible Header */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full flex items-center justify-between py-2 px-1",
"hover:bg-muted/40 rounded-md transition-colors",
"group cursor-pointer"
)}
>
<div className="flex items-center gap-1.5">
{isOpen ? (
<ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRightIcon className="w-4 h-4 text-muted-foreground" />
)}
<BookmarkIcon className="w-4 h-4 text-primary/70" />
<span className="text-sm font-medium text-foreground/80">
{t("common.bookmarks" as any) || "Bookmarks"}
</span>
</div>
<span className="text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded-full">
{pinnedMemos.length}
</span>
</button>
{/* Bookmark List - Only show when expanded */}
{isOpen && (
<div className="flex flex-col gap-0.5 mt-1 pl-5">
{visibleMemos.map((memo) => {
const memoPath = memo.name.startsWith("memos/") ? memo.name : `memos/${memo.name}`;
const preview = getPreview(memo.content || "");
const time = formatTime(memo);
return (
<Link
key={memo.name}
to={`/${memoPath}`}
className={cn(
"flex items-center justify-between",
"px-2 py-1.5 rounded-md",
"hover:bg-muted/60 transition-colors",
"group"
)}
>
<span className="text-sm text-foreground/70 group-hover:text-foreground truncate flex-1 mr-2">
{preview}
</span>
{time && (
<span className="text-[10px] text-muted-foreground shrink-0">
{time}
</span>
)}
</Link>
);
})}
{/* Show More / Show Less */}
{hiddenCount > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
setShowAll(!showAll);
}}
className={cn(
"flex items-center gap-1 px-2 py-1.5",
"text-xs text-muted-foreground hover:text-foreground",
"transition-colors rounded-md hover:bg-muted/40"
)}
>
{showAll ? (
<>
<ChevronDownIcon className="w-3 h-3" />
<span>{t("common.hide-older" as any) || "Show less"}</span>
</>
) : (
<>
<ChevronRightIcon className="w-3 h-3" />
<span>+{hiddenCount} {t("common.more" as any) || "more"}</span>
</>
)}
</button>
)}
</div>
)}
</div>
);
};
export default BookmarksSection;
......@@ -3,6 +3,7 @@ import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import type { StatisticsData } from "@/types/statistics";
import StatisticsView from "../StatisticsView";
import BookmarksSection from "./BookmarksSection";
import ShortcutsSection from "./ShortcutsSection";
import TagsSection from "./TagsSection";
......@@ -11,6 +12,7 @@ export type MemoExplorerContext = "home" | "explore" | "archived" | "profile";
export interface MemoExplorerFeatures {
search?: boolean;
statistics?: boolean;
bookmarks?: boolean;
shortcuts?: boolean;
tags?: boolean;
}
......@@ -29,21 +31,24 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
return {
search: true,
statistics: true,
shortcuts: false, // Global explore doesn't use shortcuts
bookmarks: false, // No bookmarks in explore
shortcuts: true,
tags: true,
};
case "archived":
return {
search: true,
statistics: true,
shortcuts: false, // Archived doesn't typically use shortcuts
bookmarks: false, // No bookmarks in archived
shortcuts: true,
tags: true,
};
case "profile":
return {
search: true,
statistics: true,
shortcuts: false, // Profile view doesn't use shortcuts
bookmarks: false, // No bookmarks in profile view
shortcuts: true,
tags: true,
};
case "home":
......@@ -51,6 +56,7 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
return {
search: true,
statistics: true,
bookmarks: true, // Bookmarks in home
shortcuts: true,
tags: true,
};
......@@ -77,7 +83,8 @@ const MemoExplorer = (props: Props) => {
{features.search && <SearchBar />}
<div className="mt-1 px-1 w-full">
{features.statistics && <StatisticsView statisticsData={statisticsData} />}
{features.shortcuts && currentUser && <ShortcutsSection />}
{features.bookmarks && <BookmarksSection className="mt-2" />}
{features.shortcuts && <ShortcutsSection />}
{features.tags && <TagsSection readonly={context === "explore"} tagCount={tagCount} />}
</div>
</aside>
......
......@@ -75,44 +75,50 @@ function ShortcutsSection() {
</Tooltip>
</TooltipProvider>
</div>
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
{shortcuts.map((shortcut) => {
const shortcutId = getShortcutId(shortcut.name);
const maybeEmoji = shortcut.title.split(" ")[0];
const emoji = emojiRegex.test(maybeEmoji) ? maybeEmoji : undefined;
const title = emoji ? shortcut.title.replace(emoji, "") : shortcut.title;
const selected = selectedShortcut === shortcutId;
return (
<div
key={shortcutId}
className="shrink-0 w-full text-sm rounded-md leading-6 flex flex-row justify-between items-center select-none gap-2 text-muted-foreground"
>
<span
className={cn("truncate cursor-pointer text-muted-foreground", selected && "text-primary font-medium")}
onClick={() => (selected ? setShortcut(undefined) : setShortcut(shortcutId))}
{shortcuts.length > 0 ? (
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1">
{shortcuts.map((shortcut) => {
const shortcutId = getShortcutId(shortcut.name);
const maybeEmoji = shortcut.title.split(" ")[0];
const emoji = emojiRegex.test(maybeEmoji) ? maybeEmoji : undefined;
const title = emoji ? shortcut.title.replace(emoji, "") : shortcut.title;
const selected = selectedShortcut === shortcutId;
return (
<div
key={shortcutId}
className="shrink-0 w-full text-sm rounded-md leading-6 flex flex-row justify-between items-center select-none gap-2 text-muted-foreground"
>
{emoji && <span className="text-base mr-1">{emoji}</span>}
{title.trim()}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" alignOffset={-12}>
<DropdownMenuItem onClick={() => handleEditShortcut(shortcut)}>
<Edit3Icon className="w-4 h-auto" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteShortcut(shortcut)}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
<span
className={cn("truncate cursor-pointer text-muted-foreground", selected && "text-primary font-medium")}
onClick={() => (selected ? setShortcut(undefined) : setShortcut(shortcutId))}
>
{emoji && <span className="text-base mr-1">{emoji}</span>}
{title.trim()}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<MoreVerticalIcon className="w-4 h-auto shrink-0 text-muted-foreground cursor-pointer hover:text-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" alignOffset={-12}>
<DropdownMenuItem onClick={() => handleEditShortcut(shortcut)}>
<Edit3Icon className="w-4 h-auto" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteShortcut(shortcut)}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
) : (
<div className="w-full text-sm text-muted-foreground/60 italic px-1 py-1">
{t("common.no-shortcuts") || "No workspaces yet. Click + to create one."}
</div>
)}
<CreateShortcutDialog
open={isCreateShortcutDialogOpen}
onOpenChange={setIsCreateShortcutDialogOpen}
......
import { useState } from "react";
import { Clock, ChevronDown, ChevronUp, History } from "lucide-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
interface MemoVersion {
version: number;
content: string;
createdAt: string;
createdBy?: string;
}
interface MemoVersionHistoryProps {
versions: MemoVersion[];
currentContent?: string;
}
/**
* MemoVersionHistory - Component to display version history of a memo
* Shows a collapsible list of previous versions with diffs
*/
export function MemoVersionHistory({ versions, currentContent }: MemoVersionHistoryProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
if (!versions || versions.length === 0) {
return null; // Don't render if no versions
}
const sortedVersions = [...versions].sort((a, b) => b.version - a.version);
return (
<div className="memo-version-history border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Header - Toggle Button */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-300">
<History size={16} />
<span className="text-sm font-medium">Version History</span>
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-0.5 rounded-full">
{versions.length}
</span>
</div>
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{/* Versions List */}
{isExpanded && (
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{sortedVersions.map((version) => (
<div
key={version.version}
className={`p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${selectedVersion === version.version ? "bg-blue-50 dark:bg-blue-900/20" : ""
}`}
onClick={() => setSelectedVersion(
selectedVersion === version.version ? null : version.version
)}
>
{/* Version Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
v{version.version}
</span>
{version.createdBy && (
<span className="text-xs text-gray-500 dark:text-gray-400">
by {version.createdBy}
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<Clock size={12} />
{dayjs(version.createdAt).fromNow()}
</div>
</div>
{/* Version Content Preview */}
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{version.content.substring(0, 150)}
{version.content.length > 150 && "..."}
</div>
{/* Expanded Content */}
{selectedVersion === version.version && (
<div className="mt-3 p-3 bg-gray-100 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Full content at this version:
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{version.content}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
export default MemoVersionHistory;
export { MemoVersionHistory } from "./MemoVersionHistory";
......@@ -22,6 +22,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
const isAnonymousCreator = memoData.creator.startsWith("users/anonymous_");
const creator = useUser(memoData.creator, { enabled: !isAnonymousCreator }).data;
const isArchived = memoData.state === State.ARCHIVED;
// In production, only the memo owner (or superuser) can edit/pin.
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
const parentPage = parentPageProp || "/";
......
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { setAccessToken } from "@/auth-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient } from "@/service";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n";
// Legacy password-based sign-in form has been removed.
// Auth is now handled entirely by Clerk components (`<SignIn />`, `<SignUp />`).
// This file is kept as a placeholder to avoid import breakages;
// you can safely delete it once all references are removed.
function PasswordSignInForm() {
const t = useTranslate();
const navigateTo = useNavigateTo();
const { profile } = useInstance();
const { initialize } = useAuth();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState(profile.mode === "demo" ? "demo" : "");
const [password, setPassword] = useState(profile.mode === "demo" ? "secret" : "");
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setUsername(text);
};
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setPassword(text);
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSignInButtonClick();
};
const handleSignInButtonClick = async () => {
if (username === "" || password === "") {
return;
}
if (actionBtnLoadingState.isLoading) {
return;
}
try {
actionBtnLoadingState.setLoading();
const response = await authServiceClient.signIn({
credentials: {
case: "passwordCredentials",
value: { username, password },
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
await initialize();
navigateTo("/");
} catch (error: unknown) {
handleError(error, toast.error, {
fallbackMessage: "Failed to sign in.",
});
}
actionBtnLoadingState.setFinish();
};
return (
<form className="w-full mt-2" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
<Input
className="w-full bg-background h-10"
type="text"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.username")}
value={username}
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
onChange={handleUsernameInputChanged}
required
/>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.password")}</span>
<Input
className="w-full bg-background h-10"
type="password"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.password")}
value={password}
autoComplete="current-password"
autoCapitalize="off"
spellCheck={false}
onChange={handlePasswordInputChanged}
required
/>
</div>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
<Button type="submit" className="w-full h-10" disabled={actionBtnLoadingState.isLoading} onClick={handleSignInButtonClick}>
{t("common.sign-in")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="w-5 h-auto ml-2 animate-spin opacity-60" />}
</Button>
</div>
</form>
);
}
const PasswordSignInForm = () => null;
export default PasswordSignInForm;
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { cn } from "@/lib/utils";
import { BookmarkIcon, ChevronDownIcon, ChevronRightIcon, SparklesIcon } from "lucide-react";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { useMemoFilters } from "@/hooks";
import { State } from "@/types/proto/api/v1/common_pb";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
import i18n from "@/i18n";
interface PinnedSectionProps {
creatorName?: string;
className?: string;
}
// Number of days to consider a bookmark as "recent"
const RECENT_DAYS = 3;
const PinnedSection = ({ creatorName, className }: PinnedSectionProps) => {
const t = useTranslate();
const [showOlder, setShowOlder] = useState(false);
// Build filter for pinned memos only
const baseFilter = useMemoFilters({
creatorName,
includeShortcuts: true,
includePinned: false, // Don't include pinned filter from context
});
// Add pinned filter
const pinnedFilter = useMemo(() => {
return baseFilter ? `${baseFilter} && pinned` : "pinned";
}, [baseFilter]);
// Fetch only pinned memos
const { data: pinnedData, isLoading } = useInfiniteMemos({
state: State.NORMAL,
orderBy: "display_time desc",
filter: pinnedFilter,
pageSize: DEFAULT_LIST_MEMOS_PAGE_SIZE,
});
// Flatten pinned memos
const pinnedMemos = useMemo(() => {
return pinnedData?.pages.flatMap((page) => page.memos) || [];
}, [pinnedData]);
// Split memos into recent and older
const { recentMemos, olderMemos } = useMemo(() => {
const now = new Date();
const recentThreshold = new Date(now.getTime() - RECENT_DAYS * 24 * 60 * 60 * 1000);
const recent: Memo[] = [];
const older: Memo[] = [];
pinnedMemos.forEach((memo) => {
if (!memo.displayTime) {
older.push(memo);
return;
}
const memoDate = timestampDate(memo.displayTime);
if (memoDate && memoDate >= recentThreshold) {
recent.push(memo);
} else {
older.push(memo);
}
});
return { recentMemos: recent, olderMemos: older };
}, [pinnedMemos]);
if (isLoading || pinnedMemos.length === 0) {
return null;
}
// Extract preview text (first line, max 50 chars)
const getPreview = (content: string): string => {
const firstLine = content.split("\n")[0].trim();
// Remove markdown headers
const cleanLine = firstLine.replace(/^#+\s*/, "");
return cleanLine.length > 50 ? `${cleanLine.slice(0, 50)}...` : cleanLine;
};
// Format relative time
const formatTime = (memo: Memo): string => {
if (!memo.displayTime) return "";
const date = timestampDate(memo.displayTime);
if (!date) return "";
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) return t("common.just-now" as any) || "Just now";
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return date.toLocaleDateString(i18n.language, { month: "short", day: "numeric" });
};
// Render a single bookmark card
const renderBookmarkCard = (memo: Memo, isRecent: boolean = false) => {
const preview = getPreview(memo.content || "");
const timeStr = formatTime(memo);
const memoPath = memo.name.startsWith("memos/") ? memo.name : `memos/${memo.name}`;
return (
<Link
key={memo.name}
to={`/${memoPath}`}
className={cn(
"flex flex-col gap-1 px-3 py-2",
"rounded-lg border transition-all duration-200",
"shrink-0 min-w-[160px] max-w-[220px]",
"group relative overflow-hidden",
isRecent ? [
// Recent bookmarks - more prominent style
"bg-gradient-to-br from-primary/5 to-primary/10",
"border-primary/20 hover:border-primary/40",
"hover:shadow-md hover:shadow-primary/10",
"hover:-translate-y-0.5",
] : [
// Older bookmarks - subtle style
"bg-muted/30 border-border/50",
"hover:bg-muted/50 hover:border-border",
"hover:shadow-sm",
]
)}
>
{/* Recent indicator */}
{isRecent && (
<div className="absolute top-1 right-1">
<SparklesIcon className="w-3 h-3 text-primary/60" />
</div>
)}
{/* Content preview */}
<span className={cn(
"truncate font-medium leading-tight text-sm",
isRecent ? "text-foreground" : "text-foreground/80"
)}>
{preview || "(empty)"}
</span>
{/* Time */}
{timeStr && (
<span className={cn(
"text-[10px]",
isRecent ? "text-primary/70" : "text-muted-foreground"
)}>
{timeStr}
</span>
)}
</Link>
);
};
return (
<div className={cn("w-full", className)}>
{/* Bookmarks Section */}
<div className="rounded-xl bg-gradient-to-r from-muted/40 via-muted/30 to-muted/40 border border-border/50 p-3">
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-primary/10">
<BookmarkIcon className="w-4 h-4 text-primary" />
</div>
<span className="text-sm font-semibold text-foreground">
{t("common.bookmarks" as any) || "Bookmarks"}
</span>
<span className="text-xs text-muted-foreground px-1.5 py-0.5 rounded-full bg-muted/50">
{pinnedMemos.length}
</span>
</div>
</div>
{/* Recent Bookmarks */}
{recentMemos.length > 0 && (
<div className="mb-2">
<div className="flex items-center gap-1.5 mb-2">
<SparklesIcon className="w-3 h-3 text-primary/70" />
<span className="text-xs font-medium text-primary/80">
{t("common.recent" as any) || "Recent"}
</span>
</div>
<div className="flex flex-row items-stretch gap-2 overflow-x-auto pb-1 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{recentMemos.map((memo) => renderBookmarkCard(memo, true))}
</div>
</div>
)}
{/* Older Bookmarks - Collapsible */}
{olderMemos.length > 0 && (
<div>
<button
onClick={() => setShowOlder(!showOlder)}
className={cn(
"flex items-center gap-1.5 text-xs text-muted-foreground",
"hover:text-foreground transition-colors py-1 px-1 -ml-1 rounded",
"hover:bg-muted/50"
)}
>
{showOlder ? (
<ChevronDownIcon className="w-3 h-3" />
) : (
<ChevronRightIcon className="w-3 h-3" />
)}
<span>
{showOlder
? (t("common.hide-older" as any) || "Hide older")
: `${olderMemos.length} ${t("common.older-bookmarks" as any) || "older bookmarks"}`
}
</span>
</button>
{showOlder && (
<div className="flex flex-row items-stretch gap-2 overflow-x-auto pt-2 pb-1 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{olderMemos.map((memo) => renderBookmarkCard(memo, false))}
</div>
)}
</div>
)}
{/* If all bookmarks are recent, just show them */}
{recentMemos.length === 0 && olderMemos.length > 0 && !showOlder && (
<div className="flex flex-row items-stretch gap-2 overflow-x-auto pb-1 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
{olderMemos.slice(0, 5).map((memo) => renderBookmarkCard(memo, false))}
{olderMemos.length > 5 && (
<button
onClick={() => setShowOlder(true)}
className={cn(
"flex items-center justify-center px-4 py-2",
"rounded-lg border border-dashed border-muted-foreground/30",
"text-xs text-muted-foreground hover:text-foreground",
"hover:border-muted-foreground/50 hover:bg-muted/30",
"transition-all duration-200 shrink-0"
)}
>
+{olderMemos.length - 5} {t("common.more") || "more"}
</button>
)}
</div>
)}
</div>
</div>
);
};
export default PinnedSection;
export { default } from "./PinnedSection";
import { useQueryClient } from "@tanstack/react-query";
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { clearAccessToken } from "@/auth-state";
import { clearAccessToken, getAccessToken } from "@/auth-state";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service";
import { userKeys } from "@/hooks/useUserQueries";
import { getClerkSessionToken } from "@/utils/clerk";
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
import type { User, UserSetting_GeneralSetting, UserSetting_WebhooksSetting } from "@/types/proto/api/v1/user_service_pb";
......@@ -54,9 +55,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const initialize = useCallback(async () => {
setState((prev) => ({ ...prev, isLoading: true }));
try {
// Check if we have a token/session before making API call
// This prevents unnecessary 401 errors when user is not logged in
const hasToken = localStorage.getItem("accessToken") || sessionStorage.getItem("accessToken");
// Check if we have a token/session before making API call.
// - Prefer Clerk session (SSO)
// - Fallback to legacy memo access token store
const clerkToken = await getClerkSessionToken();
const legacyToken = getAccessToken();
const hasToken = !!(clerkToken || legacyToken);
if (!hasToken) {
// No token means user is not logged in - skip API call
......
......@@ -19,10 +19,12 @@ export interface UseFilteredMemoStatsOptions {
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
const { userName } = options;
// Fetch user stats if userName is provided
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName);
// Always fetch user stats - use provided userName or fallback to default 'users/1'
// This ensures Tags section always shows data from backend API
const effectiveUserName = userName || "users/1";
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(effectiveUserName);
// Fetch memos for fallback computation (or when userName is not provided)
// Fetch memos for additional fallback computation (activity calendar etc)
const { data: memosResponse, isLoading: isLoadingMemos } = useMemos({});
const data = useMemo(() => {
......@@ -30,8 +32,8 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
let activityStats: Record<string, number> = {};
let tagCount: Record<string, number> = {};
// Try to use backend user stats if userName is provided and available
if (userName && userStats) {
// Prioritize backend user stats (always called now with effectiveUserName)
if (userStats) {
// Use activity timestamps from user stats
if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
activityStats = countBy(
......@@ -58,10 +60,20 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
displayTimeList.push(displayTime);
}
// Count tags
// 1) Prefer explicit tags field from backend if available
if (memo.tags && memo.tags.length > 0) {
for (const tag of memo.tags) {
tagCount[tag] = (tagCount[tag] || 0) + 1;
}
} else if (memo.content && memo.content.length > 0) {
// 2) Fallback for current CuCu Mongo backend:
// parse inline tags from markdown content (e.g. "#work", "#chao_em").
const matches = memo.content.match(/#([^\s#]+)/g) || [];
for (const raw of matches) {
const tag = raw.slice(1); // remove leading '#'
if (!tag) continue;
tagCount[tag] = (tagCount[tag] || 0) + 1;
}
}
}
......
......@@ -3,6 +3,8 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/service";
import { buildUserSettingName } from "@/helpers/resource-names";
import { getAccessToken } from "@/auth-state";
import { getClerkSessionToken } from "@/utils/clerk";
import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
// Query keys factory
......@@ -18,24 +20,37 @@ export const userKeys = {
byNames: (names: string[]) => [...userKeys.all, "byNames", ...names.sort()] as const,
};
// NOTE: This hook is currently UNUSED in favor of the AuthContext-based
// useCurrentUser hook (src/hooks/useCurrentUser.ts). This is kept for potential
// future migration to React Query for auth state.
export function useCurrentUserQuery() {
return useQuery({
queryKey: userKeys.currentUser(),
queryFn: async () => {
try {
const { user } = await authServiceClient.getCurrentUser({});
return user;
// Avoid hitting /auth/me when we clearly have no token/session yet.
// This prevents unnecessary 401s during initial load as Clerk boots up.
const [clerkToken, legacyToken] = await Promise.all([
getClerkSessionToken(),
Promise.resolve(getAccessToken()),
]);
if (!clerkToken && !legacyToken) {
// No session at all -> treat as not logged in without calling backend.
return null;
}
const { user } = await authServiceClient.getCurrentUser({});
return user;
} catch (error) {
// 401 is expected when not logged in - return undefined instead of throwing
// 401 is expected when not logged in - treat as "no user" instead of error
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("401") ||
if (
errorMessage.includes("401") ||
errorMessage.includes("403") ||
errorMessage.includes("Unauthorized") ||
errorMessage.includes("Missing authentication token")) {
return undefined;
errorMessage.includes("Missing authentication token")
) {
// React Query v5 không cho phép queryFn trả về undefined,
// nên dùng null để biểu diễn "chưa đăng nhập".
return null;
}
throw error;
}
......
......@@ -69,6 +69,11 @@
"password": "Password",
"pin": "Pin",
"pinned": "Pinned",
"bookmarks": "Bookmarks",
"recent": "Recent",
"older-bookmarks": "older bookmarks",
"hide-older": "Hide older",
"just-now": "Just now",
"preview": "Preview",
"profile": "Profile",
"properties": "Properties",
......@@ -86,8 +91,8 @@
"select": "Select",
"settings": "Settings",
"share": "Share",
"shortcut-filter": "Shortcut filter",
"shortcuts": "Shortcuts",
"shortcut-filter": "Workspace filter",
"shortcuts": "Workspaces",
"sign-in": "Sign in",
"sign-in-with": "Sign in with {{provider}}",
"sign-out": "Sign out",
......
......@@ -49,6 +49,11 @@
"password": "Mật khẩu",
"pin": "Ghim",
"pinned": "Đã ghim",
"bookmarks": "Đánh dấu",
"recent": "Gần đây",
"older-bookmarks": "đánh dấu cũ hơn",
"hide-older": "Ẩn cũ hơn",
"just-now": "Vừa xong",
"preview": "Xem trước",
"profile": "Hồ sơ",
"remember-me": "Ghi nhớ tôi",
......
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import Spinner from "@/components/Spinner";
import { authServiceClient } from "@/service";
import { useAuth } from "@/contexts/AuthContext";
import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { validateOAuthState } from "@/utils/oauth";
interface State {
loading: boolean;
......@@ -16,8 +8,6 @@ interface State {
}
const AuthCallback = () => {
const navigateTo = useNavigateTo();
const { initialize } = useAuth();
const [searchParams] = useSearchParams();
const [state, setState] = useState<State>({
loading: true,
......@@ -47,67 +37,12 @@ const AuthCallback = () => {
return;
}
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) {
setState({
loading: false,
errorMessage: "Failed to authorize. Missing authorization code or state parameter.",
});
return;
}
// Validate OAuth state (CSRF protection) and retrieve PKCE code_verifier
const validatedState = validateOAuthState(state);
if (!validatedState) {
// Legacy OAuth callback for memos has been deprecated.
// Clerk now handles all OAuth flows internally.
setState({
loading: false,
errorMessage: "Failed to authorize. Invalid or expired state parameter. This may indicate a CSRF attack attempt.",
});
return;
}
const { identityProviderId, returnUrl, codeVerifier } = validatedState;
const redirectUri = absolutifyLink("/auth/callback");
(async () => {
try {
const response = await authServiceClient.signIn({
credentials: {
case: "ssoCredentials",
value: {
idpId: identityProviderId,
code,
redirectUri,
codeVerifier: codeVerifier || "", // Pass PKCE code_verifier for token exchange
},
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
setState({
loading: false,
errorMessage: "",
});
await initialize();
// Redirect to return URL if specified, otherwise home
navigateTo(returnUrl || "/");
} catch (error: unknown) {
handleError(error, () => {}, {
fallbackMessage: "Failed to authenticate.",
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to authenticate.";
setState({
loading: false,
errorMessage: message,
errorMessage: "This OAuth callback endpoint is no longer used. Authentication is handled by Clerk.",
});
},
});
}
})();
}, [searchParams, navigateTo]);
return (
......
import { useEffect } from "react";
import { useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
......@@ -12,15 +12,13 @@ import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Home = () => {
const user = useCurrentUser();
const [searchParams] = useSearchParams();
const { addFilter, getFiltersByFactor } = useMemoFilterContext();
const { addFilter } = useMemoFilterContext();
// Auto-set today's date filter if no filter is present
useEffect(() => {
if (!user) return;
// Check if there's already a filter in URL
const hasFilterInUrl = searchParams.has("filter");
// Only set default date filter if no filter exists in URL
if (!hasFilterInUrl) {
// Get today's date in YYYY-MM-DD format (UTC)
......@@ -29,11 +27,11 @@ const Home = () => {
const month = String(today.getUTCMonth() + 1).padStart(2, "0");
const day = String(today.getUTCDate()).padStart(2, "0");
const todayStr = `${year}-${month}-${day}`;
// Add displayTime filter for today
addFilter({ factor: "displayTime", value: todayStr });
}
}, [user, searchParams, addFilter]);
}, [searchParams, addFilter]);
// Build filter using unified hook
const memoFilter = useMemoFilters({
......@@ -48,25 +46,14 @@ const Home = () => {
state: State.NORMAL,
});
if (!user) {
return (
<div className="w-full min-h-full flex flex-col justify-start items-center p-4">
<div className="w-full max-w-3xl flex flex-col justify-start items-center">
<div className="w-full h-32 bg-muted/20 animate-pulse rounded-xl mb-4" />
<div className="w-full h-32 bg-muted/20 animate-pulse rounded-xl mb-4" />
<div className="w-full h-32 bg-muted/20 animate-pulse rounded-xl mb-4" />
</div>
</div>
);
}
return (
<div className="w-full min-h-full bg-background text-foreground">
{/* Regular Memos List - Bookmarks are now in sidebar */}
<PagedMemoList
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.displayTime}`} memo={memo} showVisibility showPinned compact={context?.compact} />
)}
listSort={listSort}
listSort={(memos) => memos.filter((m) => !m.pinned)} // Exclude pinned from regular list
orderBy={orderBy}
filter={memoFilter}
/>
......@@ -75,3 +62,4 @@ const Home = () => {
};
export default Home;
......@@ -150,7 +150,35 @@ const MemoDetail = () => {
{comments.length === 0 ? (
// Empty state: Show button for logged-in users or anonymous comment UI
currentUser ? (
showCreateCommentButton && (
showCommentEditor ? (
<div className="w-full flex flex-col gap-3 py-6">
<div className="flex flex-col gap-2">
<label htmlFor="comment-name-empty" className="text-sm text-muted-foreground">
{t("memo.comment.your-name") || "Your name (optional)"}
</label>
<Input
id="comment-name-empty"
placeholder={t("memo.comment.name-placeholder") || "Anonymous"}
value={anonymousDisplayName}
onChange={(e) => setAnonymousDisplayName(e.target.value)}
className="max-w-xs"
/>
</div>
<MemoEditor
cacheKey={`${memo.name}-${memo.updateTime}-comment-empty`}
placeholder={t("editor.add-your-comment-here")}
parentMemoName={memo.name}
anonymousId={anonymousId || undefined}
anonymousName={anonymousDisplayName || undefined}
autoFocus
onConfirm={handleCommentCreated}
onCancel={() => {
setShowCommentEditor(false);
setAnonymousDisplayName("");
}}
/>
</div>
) : (
<div className="w-full flex flex-row justify-center items-center py-6">
<Button variant="ghost" onClick={handleShowCommentEditor}>
<span className="text-muted-foreground">{t("memo.comment.write-a-comment")}</span>
......@@ -280,6 +308,36 @@ const MemoDetail = () => {
</div>
)}
</div>
) : showCommentEditor ? (
<div className="w-full mt-4 pt-4 border-t border-border">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<label htmlFor="comment-name" className="text-sm text-muted-foreground">
{t("memo.comment.your-name") || "Your name (optional)"}
</label>
<Input
id="comment-name"
placeholder={t("memo.comment.name-placeholder") || "Anonymous"}
value={anonymousDisplayName}
onChange={(e) => setAnonymousDisplayName(e.target.value)}
className="max-w-xs"
/>
</div>
<MemoEditor
cacheKey={`${memo.name}-${memo.updateTime}-comment`}
placeholder={t("editor.add-your-comment-here")}
parentMemoName={memo.name}
anonymousId={anonymousId || undefined}
anonymousName={anonymousDisplayName || undefined}
autoFocus
onConfirm={handleCommentCreated}
onCancel={() => {
setShowCommentEditor(false);
setAnonymousDisplayName("");
}}
/>
</div>
</div>
) : showCreateCommentButton ? (
<div className="w-full mt-4 pt-4 border-t border-border">
<div className="flex flex-row justify-center items-center py-2">
......
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Link } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import AuthFooter from "@/components/AuthFooter";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient, userServiceClient } from "@/service";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
// Legacy username/password signup page has been removed.
// New user registration is handled by Clerk `<SignUp />` on `/auth?mode=signup`.
const SignUp = () => {
const t = useTranslate();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const { generalSetting: instanceGeneralSetting, profile } = useInstance();
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setUsername(text);
};
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setPassword(text);
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSignUpButtonClick();
};
const handleSignUpButtonClick = async () => {
if (username === "" || password === "") {
return;
}
if (actionBtnLoadingState.isLoading) {
return;
}
try {
actionBtnLoadingState.setLoading();
const user = create(UserSchema, {
username,
password,
role: User_Role.USER,
});
await userServiceClient.createUser({ user });
const response = await authServiceClient.signIn({
credentials: {
case: "passwordCredentials",
value: { username, password },
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
window.location.href = "/";
} catch (error: unknown) {
handleError(error, toast.error, {
fallbackMessage: "Sign up failed",
});
}
actionBtnLoadingState.setFinish();
};
return (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="w-full flex flex-row justify-center items-center mb-6">
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/unnamed.jpg"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{!instanceGeneralSetting.disallowUserRegistration ? (
<>
<p className="w-full text-2xl mt-2 text-muted-foreground">{t("auth.create-your-account")}</p>
<form className="w-full mt-2" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
<Input
className="w-full bg-background h-10"
type="text"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.username")}
value={username}
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
onChange={handleUsernameInputChanged}
required
/>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.password")}</span>
<Input
className="w-full bg-background h-10"
type="password"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.password")}
value={password}
autoComplete="new-password"
autoCapitalize="off"
spellCheck={false}
onChange={handlePasswordInputChanged}
required
/>
</div>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
<Button type="submit" className="w-full h-10" disabled={actionBtnLoadingState.isLoading} onClick={handleSignUpButtonClick}>
{t("common.sign-up")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="w-5 h-auto ml-2 animate-spin opacity-60" />}
</Button>
</div>
</form>
</>
) : (
<p className="w-full text-2xl mt-2 text-muted-foreground">Sign up is not allowed.</p>
)}
{!profile.owner ? (
<p className="w-full mt-4 text-sm font-medium text-muted-foreground">{t("auth.host-tip")}</p>
) : (
<p className="w-full mt-4 text-sm">
<span className="text-muted-foreground">{t("auth.sign-in-tip")}</span>
<Link to="/auth" className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
{t("common.sign-in")}
</Link>
</p>
)}
</div>
<AuthFooter />
</div>
);
};
const SignUp = () => null;
export default SignUp;
import { getAccessToken } from "@/auth-state";
import { redirectOnAuthFailure } from "@/utils/auth-redirect";
import { getClerkSessionToken } from "@/utils/clerk";
import type { RequestOptions } from "./types";
......@@ -31,9 +30,8 @@ const parseBody = async (response: Response): Promise<unknown> => {
};
export const fetchJson = async <T>(path: string, options: RequestOptions = {}): Promise<T> => {
// Prefer Clerk token if available; fallback to legacy token store.
const clerkToken = await getClerkSessionToken();
const token = clerkToken || getAccessToken();
// Use Clerk session token for all authenticated requests.
const token = await getClerkSessionToken();
const headers = new Headers(options.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
......
import type { GetCurrentUserResponse, RefreshTokenResponse, SignInResponse } from "@/types/proto/api/v1/auth_service_pb";
import type { GetCurrentUserResponse } from "@/types/proto/api/v1/auth_service_pb";
import { fetchJson } from "./apiClient";
// Clerk is the source of truth for authentication.
// This client is now only used to fetch the "current user" view
// backed by Clerk JWT on the backend.
export const authServiceClient = {
async getCurrentUser(_request?: unknown): Promise<GetCurrentUserResponse> {
void _request;
const data = await fetchJson<GetCurrentUserResponse>("/auth/me", { method: "GET" });
return data;
},
async signIn(request: unknown): Promise<SignInResponse> {
const body = typeof request === "object" && request ? request : {};
const data = await fetchJson<{ accessToken?: string }>("/auth/signin", { method: "POST", body });
return {
accessToken: data.accessToken || "",
};
},
async signOut(_request?: unknown): Promise<Record<string, never>> {
void _request;
return {};
},
async refreshToken(_request?: unknown): Promise<RefreshTokenResponse> {
void _request;
return { accessToken: "" };
},
};
......@@ -54,7 +54,7 @@ export const memoFromApi = (raw: ApiMemo): Memo => {
// Route expects uid, so we prefer uid for the name
const memoIdentifier = raw.uid || raw.id;
const memoName = `memos/${memoIdentifier}`;
// Create fake relations for comment count if available
// This allows MemoView to display comment count badge
const relations = [];
......@@ -71,7 +71,7 @@ export const memoFromApi = (raw: ApiMemo): Memo => {
);
}
}
const result = {
name: memoName,
state: State.NORMAL,
......@@ -100,20 +100,29 @@ export const memoFromApi = (raw: ApiMemo): Memo => {
export const userFromApi = (raw: ApiUser): User => {
const email = raw.email || `user${raw.id}@example.com`;
const username = email.includes("@") ? email.split("@")[0] : `user${raw.id}`;
// Prefer raw.username from API, fallback to email prefix
const username = raw.username || (email.includes("@") ? email.split("@")[0] : `user${raw.id}`);
// Prefer raw.displayName from API, fallback to raw.nickname for backwards compat
const displayName = raw.displayName || raw.nickname || "";
// Prefer raw.avatarUrl from API
const avatarUrl = raw.avatarUrl || "";
// Prefer raw.name from API (resource name format)
const name = raw.name || `users/${raw.id}`;
return {
name: `users/${raw.id}`,
name,
role: User_Role.USER,
username,
email,
displayName: raw.nickname || "",
avatarUrl: "",
displayName,
avatarUrl,
description: "",
password: "",
state: State.NORMAL,
};
} as User;
};
export const attachmentFromApi = (raw: ApiAttachment): Attachment => {
return {
name: `attachments/${raw.id}`,
......@@ -166,11 +175,11 @@ export const applyMemoFilter = (memos: Memo[], filter?: string): Memo[] => {
}
let filtered = memos;
// Only apply client-side filters that the backend doesn't handle
// Backend handles: creator_id, created_ts/updated_ts (date range), tag
// Frontend only needs to handle: content.contains
const contentValue = extractFilterValue(filter, /content\.contains\("([^"]+)"\)/);
if (contentValue) {
const needle = contentValue.toLowerCase();
......
......@@ -46,8 +46,14 @@ export type ApiUser = {
id: string;
email?: string | null;
nickname?: string | null;
// New fields from backend for proper user display
username?: string | null;
displayName?: string | null;
avatarUrl?: string | null;
name?: string | null; // Resource name: users/{id}
};
export type RequestOptions = {
method?: string;
body?: unknown;
......
......@@ -79,19 +79,29 @@ export const userServiceClient = {
return;
},
async getUserStats(request: { name: string }): Promise<UserStats> {
const userId = parseResourceId(request.name);
const data = await fetchJson<{ tagCount: Record<string, number>; memoDisplayTimestamps: string[] }>(
`/users/${userId}/stats`,
{ method: "GET" },
);
// Convert ISO timestamps to protobuf Timestamp format
const timestamps = (data.memoDisplayTimestamps || []).map((ts) => {
const date = new Date(ts);
return { seconds: BigInt(Math.floor(date.getTime() / 1000)), nanos: 0 };
});
return {
name: request.name,
memoDisplayTimestamps: [],
memoDisplayTimestamps: timestamps,
memoTypeStats: {
linkCount: 0,
codeCount: 0,
todoCount: 0,
undoCount: 0,
},
tagCount: {},
tagCount: data.tagCount || {},
pinnedMemos: [],
totalMemoCount: 0,
};
} as unknown as UserStats;
},
async listAllUserStats(_request?: unknown): Promise<{ stats: UserStats[] }> {
void _request;
......
......@@ -8,28 +8,47 @@ export const isSuperUser = (user: User | undefined) => {
* Formats an anonymous creator_id into a display-friendly label.
* Handles patterns like:
* - "anonymous_abc123" -> "Anonymous #abc123"
* - "anonymous_abc123_John" -> "Anonymous (John)"
* - "anonymous_abc123_John_Doe" -> "Anonymous (John_Doe)"
* - "anonymous_abc123_VGVzdA" -> "Anonymous (Test)" - base64 encoded name
* - Legacy: "anonymous_abc123_John" -> "Anonymous (John)" - plain text name
*/
export function formatAnonymousName(creatorId: string): string {
if (!creatorId.startsWith("anonymous_")) {
return creatorId; // Not an anonymous ID, return as-is
}
// Extract parts: anonymous_<sessionId>_<optionalName>
// Extract parts: anonymous_<sessionId>_<optionalEncodedName>
const parts = creatorId.split("_");
if (parts.length < 2) {
return "Anonymous";
}
const sessionId = parts[1]; // The random ID part
const displayName = parts.slice(2).join("_"); // Everything after sessionId
const encodedName = parts.slice(2).join("_"); // Everything after sessionId
if (displayName) {
return `Anonymous (${displayName})`;
} else {
// Show last 4 chars of sessionId for identification
const shortId = sessionId.length > 4 ? sessionId.slice(-4) : sessionId;
return `Anonymous #${shortId}`;
if (encodedName) {
// Try to decode base64-encoded name (new format for Unicode support)
try {
// URL-safe base64 decode: replace - with +, _ with /
const base64 = encodedName.replace(/-/g, '+').replace(/_/g, '/');
// Pad if needed
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
const decoded = atob(padded);
// Convert from UTF-8 bytes to string
const bytes = Uint8Array.from(decoded, c => c.charCodeAt(0));
const displayName = new TextDecoder('utf-8').decode(bytes);
if (displayName && displayName.trim()) {
return `Anonymous (${displayName.trim()})`;
}
} catch {
// Fallback: treat as plain text (legacy format)
if (/^[a-zA-Z0-9_-]+$/.test(encodedName)) {
return `Anonymous (${encodedName})`;
}
}
}
// Show last 4 chars of sessionId for identification
const shortId = sessionId.length > 4 ? sessionId.slice(-4) : sessionId;
return `Anonymous #${shortId}`;
}
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