Commit 93a96fbf authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

refactor: temporarily disable clerk auth, update settings UI with admin role check mock

parent 910b67cb
This diff is collapsed.
...@@ -4,7 +4,7 @@ Memo service routes for Memos-style backend. ...@@ -4,7 +4,7 @@ Memo service routes for Memos-style backend.
from typing import List from typing import List
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from memos_core.schemas import ( from memos_core.schemas import (
MemoCreate, MemoCreate,
...@@ -17,19 +17,28 @@ from memos_core.services import get_memo_service ...@@ -17,19 +17,28 @@ from memos_core.services import get_memo_service
router = APIRouter(prefix="/memos") router = APIRouter(prefix="/memos")
def get_current_user_id(request: Request) -> str | None:
"""Extract user_id from request.state (set by auth middleware)."""
return getattr(request.state, "user_id", None)
@router.get("", summary="List memos", response_model=List[MemoResponse]) @router.get("", summary="List memos", response_model=List[MemoResponse])
async def list_memos( async def list_memos(
request: Request,
tag: str | None = Query(default=None), tag: str | None = Query(default=None),
memo_service=Depends(get_memo_service), memo_service=Depends(get_memo_service),
): ):
"""List memos for the current user (or anonymous if not logged in)."""
try: try:
return await memo_service.list_memos(tag=tag) user_id = get_current_user_id(request)
return await memo_service.list_memos(user_id=user_id, tag=tag)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=500, detail=str(exc)) from exc raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("", summary="Create memo (Connect compatibility)") @router.post("", summary="Create memo (Connect compatibility)")
async def create_memo_or_list_memos( async def create_memo_or_list_memos(
request: Request,
payload: dict = Body(default_factory=dict), # noqa: B008 payload: dict = Body(default_factory=dict), # noqa: B008
memo_service=Depends(get_memo_service), memo_service=Depends(get_memo_service),
): ):
...@@ -41,52 +50,66 @@ async def create_memo_or_list_memos( ...@@ -41,52 +50,66 @@ async def create_memo_or_list_memos(
To avoid 422 and keep dev UX smooth, accept an untyped payload and branch by shape. To avoid 422 and keep dev UX smooth, accept an untyped payload and branch by shape.
""" """
try: try:
user_id = get_current_user_id(request)
raw = payload if isinstance(payload, dict) else {} raw = payload if isinstance(payload, dict) else {}
# Connect CreateMemo often wraps payload as { "memo": { ... } } # Connect CreateMemo often wraps payload as { "memo": { ... } }
if isinstance(raw.get("memo"), dict): if isinstance(raw.get("memo"), dict):
memo_create = MemoCreate(**raw["memo"]) memo_create = MemoCreate(**raw["memo"])
return await memo_service.create_memo(memo_create) return await memo_service.create_memo(memo_create, user_id=user_id)
# If it looks like a create payload, also treat as create. # If it looks like a create payload, also treat as create.
if "content" in raw: if "content" in raw:
memo_create = MemoCreate(**raw) memo_create = MemoCreate(**raw)
return await memo_service.create_memo(memo_create) return await memo_service.create_memo(memo_create, user_id=user_id)
# Otherwise treat as ListMemos. # Otherwise treat as ListMemos.
# Support basic tag filter when provided. # Support basic tag filter when provided.
tag = raw.get("tag") if isinstance(raw.get("tag"), str) else None tag = raw.get("tag") if isinstance(raw.get("tag"), str) else None
return await memo_service.list_memos(tag=tag) return await memo_service.list_memos(user_id=user_id, tag=tag)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{memo_id}", summary="Get memo by ID", response_model=MemoResponse) @router.get("/{memo_id}", summary="Get memo by ID", response_model=MemoResponse)
async def get_memo(memo_id: int, memo_service=Depends(get_memo_service)): async def get_memo(
request: Request,
memo_id: int,
memo_service=Depends(get_memo_service)
):
"""Get a specific memo (only if owned by current user or public)."""
try: try:
return await memo_service.get_memo(memo_id) user_id = get_current_user_id(request)
return await memo_service.get_memo(memo_id, user_id=user_id)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.patch("/{memo_id}", summary="Update memo", response_model=MemoResponse) @router.patch("/{memo_id}", summary="Update memo", response_model=MemoResponse)
async def update_memo( async def update_memo(
request: Request,
memo_id: int, memo_id: int,
payload: MemoUpdate, payload: MemoUpdate,
memo_service=Depends(get_memo_service), memo_service=Depends(get_memo_service),
): ):
"""Update a memo (only if owned by current user)."""
try: try:
return await memo_service.update_memo(memo_id, payload) user_id = get_current_user_id(request)
return await memo_service.update_memo(memo_id, payload, user_id=user_id)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/{memo_id}", summary="Delete memo") @router.delete("/{memo_id}", summary="Delete memo")
async def delete_memo(memo_id: int, memo_service=Depends(get_memo_service)): async def delete_memo(
request: Request,
memo_id: int,
memo_service=Depends(get_memo_service)
):
"""Delete a memo (only if owned by current user)."""
try: try:
await memo_service.delete_memo(memo_id) user_id = get_current_user_id(request)
await memo_service.delete_memo(memo_id, user_id=user_id)
return {"status": "success"} return {"status": "success"}
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
No preview for this file type
...@@ -13,6 +13,7 @@ async def init_memo_db(): ...@@ -13,6 +13,7 @@ async def init_memo_db():
"""Ensure memo database and tables exist.""" """Ensure memo database and tables exist."""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
# Main memos table - creator_id is TEXT to store Clerk user IDs
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS memos ( CREATE TABLE IF NOT EXISTS memos (
...@@ -20,12 +21,18 @@ async def init_memo_db(): ...@@ -20,12 +21,18 @@ async def init_memo_db():
content TEXT NOT NULL, content TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'PRIVATE', visibility TEXT NOT NULL DEFAULT 'PRIVATE',
tags_json TEXT NOT NULL DEFAULT '[]', tags_json TEXT NOT NULL DEFAULT '[]',
creator_id INTEGER NOT NULL DEFAULT 1, creator_id TEXT NOT NULL DEFAULT 'anonymous',
created_at TEXT NOT NULL DEFAULT (DATETIME('now')), created_at TEXT NOT NULL DEFAULT (DATETIME('now')),
updated_at TEXT NOT NULL DEFAULT (DATETIME('now')) updated_at TEXT NOT NULL DEFAULT (DATETIME('now'))
) )
""" """
) )
# Create index on creator_id for fast user-specific queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_memos_creator_id ON memos (creator_id)"
)
await db.execute( await db.execute(
""" """
CREATE TABLE IF NOT EXISTS memo_embeddings ( CREATE TABLE IF NOT EXISTS memo_embeddings (
......
...@@ -106,31 +106,36 @@ class MemoUpdate(BaseModel): ...@@ -106,31 +106,36 @@ class MemoUpdate(BaseModel):
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
class MemoResponse(MemoBase): class MemoResponse(MemoBase):
id: int id: int
creator_id: int creator_id: str # Clerk user ID (e.g., "user_2abc123..." or "anonymous")
create_time: Optional[str] = None
update_time: Optional[str] = None
class MemoEmbeddingCreate(BaseModel): display_time: Optional[str] = None
memoId: int
content: str model_config = ConfigDict(populate_by_name=True)
tags: List[str] = []
dateKey: Optional[str] = None
embedding: List[float] class MemoEmbeddingCreate(BaseModel):
model: Optional[str] = "stub-embedding" memoId: int
content: str
tags: List[str] = []
class MemoEmbeddingResponse(BaseModel): dateKey: Optional[str] = None
id: int embedding: List[float]
memoId: int model: Optional[str] = "stub-embedding"
content: str
tags: List[str]
dateKey: Optional[str] = None class MemoEmbeddingResponse(BaseModel):
dim: int id: int
model: str memoId: int
score: Optional[float] = None content: str
tags: List[str]
dateKey: Optional[str] = None
dim: int
model: str
score: Optional[float] = None
class AttachmentResponse(BaseModel): class AttachmentResponse(BaseModel):
id: int id: int
filename: str filename: str
......
...@@ -54,11 +54,35 @@ class UserService: ...@@ -54,11 +54,35 @@ class UserService:
class MemoService: class MemoService:
async def list_memos(self, tag: str | None = None) -> List[schemas.MemoResponse]: async def list_memos(self, user_id: str | None = None, tag: str | None = None) -> List[schemas.MemoResponse]:
"""
List memos for a specific user.
- If user_id is provided: show user's memos + public memos
- If user_id is None (guest): show only public memos
"""
await init_memo_db() await init_memo_db()
rows = await fetch_all(
"SELECT id, content, visibility, tags_json, creator_id FROM memos ORDER BY created_at DESC" if user_id:
) # Show user's own memos + public memos from others
rows = await fetch_all(
"""
SELECT id, content, visibility, tags_json, creator_id, created_at, updated_at
FROM memos
WHERE creator_id = ? OR visibility = 'PUBLIC'
ORDER BY created_at DESC
""",
(user_id,)
)
else:
# Guest: only show public memos
rows = await fetch_all(
"""
SELECT id, content, visibility, tags_json, creator_id, created_at, updated_at
FROM memos
WHERE visibility = 'PUBLIC'
ORDER BY created_at DESC
"""
)
memos: list[schemas.MemoResponse] = [] memos: list[schemas.MemoResponse] = []
for row in rows: for row in rows:
...@@ -71,13 +95,21 @@ class MemoService: ...@@ -71,13 +95,21 @@ class MemoService:
content=row["content"], content=row["content"],
visibility=row.get("visibility") or "PRIVATE", visibility=row.get("visibility") or "PRIVATE",
tags=tags, tags=tags,
creator_id=row.get("creator_id", 1), creator_id=row.get("creator_id") or "anonymous",
create_time=row.get("created_at"),
update_time=row.get("updated_at"),
display_time=row.get("created_at"),
) )
) )
return memos return memos
async def create_memo(self, payload: schemas.MemoCreate) -> schemas.MemoResponse: async def create_memo(self, payload: schemas.MemoCreate, user_id: str | None = None) -> schemas.MemoResponse:
"""Create a memo for the specified user."""
await init_memo_db() await init_memo_db()
# Use actual user_id from Clerk, or 'anonymous' for guests
creator = user_id or "anonymous"
tags_json = _encode_tags(payload.tags) tags_json = _encode_tags(payload.tags)
memo_id = await execute( memo_id = await execute(
""" """
...@@ -88,7 +120,7 @@ class MemoService: ...@@ -88,7 +120,7 @@ class MemoService:
payload.content, payload.content,
payload.visibility or "PRIVATE", payload.visibility or "PRIVATE",
tags_json, tags_json,
1, # stub creator_id creator,
), ),
) )
await self._upsert_stub_embedding( await self._upsert_stub_embedding(
...@@ -96,33 +128,53 @@ class MemoService: ...@@ -96,33 +128,53 @@ class MemoService:
content=payload.content, content=payload.content,
tags=payload.tags or [], tags=payload.tags or [],
) )
return schemas.MemoResponse( return await self.get_memo(memo_id, user_id=creator)
id=memo_id,
creator_id=1,
content=payload.content,
visibility=payload.visibility or "PRIVATE",
tags=payload.tags or [],
)
async def get_memo(self, memo_id: int) -> schemas.MemoResponse: async def get_memo(self, memo_id: int, user_id: str | None = None) -> schemas.MemoResponse:
"""Get a memo by ID. Access control: owner or public."""
await init_memo_db() await init_memo_db()
row = await fetch_one( row = await fetch_one(
"SELECT id, content, visibility, tags_json, creator_id FROM memos WHERE id = ?", "SELECT id, content, visibility, tags_json, creator_id, created_at, updated_at FROM memos WHERE id = ?",
(memo_id,), (memo_id,),
) )
if not row: if not row:
raise ValueError(f"Memo {memo_id} not found") raise ValueError(f"Memo {memo_id} not found")
# Access control: owner can see all, others can only see public
memo_creator = row.get("creator_id") or "anonymous"
memo_visibility = row.get("visibility") or "PRIVATE"
if memo_creator != user_id and memo_visibility != "PUBLIC":
raise ValueError(f"Access denied to memo {memo_id}")
return schemas.MemoResponse( return schemas.MemoResponse(
id=row["id"], id=row["id"],
content=row["content"], content=row["content"],
visibility=row.get("visibility") or "PRIVATE", visibility=memo_visibility,
tags=_decode_tags(row.get("tags_json")), tags=_decode_tags(row.get("tags_json")),
creator_id=row.get("creator_id", 1), creator_id=memo_creator,
create_time=row.get("created_at"),
update_time=row.get("updated_at"),
display_time=row.get("created_at"),
) )
async def update_memo(self, memo_id: int, payload: schemas.MemoUpdate) -> schemas.MemoResponse: async def update_memo(self, memo_id: int, payload: schemas.MemoUpdate, user_id: str | None = None) -> schemas.MemoResponse:
"""Update a memo. Only the owner can update."""
await init_memo_db() await init_memo_db()
current = await self.get_memo(memo_id)
# Check ownership first
row = await fetch_one(
"SELECT creator_id FROM memos WHERE id = ?",
(memo_id,),
)
if not row:
raise ValueError(f"Memo {memo_id} not found")
memo_creator = row.get("creator_id") or "anonymous"
if memo_creator != user_id:
raise ValueError(f"Access denied: you don't own memo {memo_id}")
current = await self.get_memo(memo_id, user_id=user_id)
new_content = payload.content if payload.content is not None else current.content new_content = payload.content if payload.content is not None else current.content
new_visibility = payload.visibility if payload.visibility is not None else current.visibility new_visibility = payload.visibility if payload.visibility is not None else current.visibility
...@@ -132,9 +184,9 @@ class MemoService: ...@@ -132,9 +184,9 @@ class MemoService:
""" """
UPDATE memos UPDATE memos
SET content = ?, visibility = ?, tags_json = ?, updated_at = DATETIME('now') SET content = ?, visibility = ?, tags_json = ?, updated_at = DATETIME('now')
WHERE id = ? WHERE id = ? AND creator_id = ?
""", """,
(new_content, new_visibility, _encode_tags(new_tags), memo_id), (new_content, new_visibility, _encode_tags(new_tags), memo_id, user_id),
) )
await self._upsert_stub_embedding( await self._upsert_stub_embedding(
memo_id=memo_id, memo_id=memo_id,
...@@ -142,17 +194,25 @@ class MemoService: ...@@ -142,17 +194,25 @@ class MemoService:
tags=new_tags or [], tags=new_tags or [],
) )
return schemas.MemoResponse( return await self.get_memo(memo_id, user_id=user_id)
id=memo_id,
creator_id=current.creator_id,
content=new_content,
visibility=new_visibility,
tags=new_tags,
)
async def delete_memo(self, memo_id: int) -> None: async def delete_memo(self, memo_id: int, user_id: str | None = None) -> None:
"""Delete a memo. Only the owner can delete."""
await init_memo_db() await init_memo_db()
await execute("DELETE FROM memos WHERE id = ?", (memo_id,))
# Check ownership first
row = await fetch_one(
"SELECT creator_id FROM memos WHERE id = ?",
(memo_id,),
)
if not row:
raise ValueError(f"Memo {memo_id} not found")
memo_creator = row.get("creator_id") or "anonymous"
if memo_creator != user_id:
raise ValueError(f"Access denied: you don't own memo {memo_id}")
await execute("DELETE FROM memos WHERE id = ? AND creator_id = ?", (memo_id, user_id))
await execute("DELETE FROM memo_embeddings WHERE memo_id = ?", (memo_id,)) await execute("DELETE FROM memo_embeddings WHERE memo_id = ?", (memo_id,))
return None return None
......
...@@ -12,17 +12,28 @@ if str(ROOT_DIR) not in sys.path: ...@@ -12,17 +12,28 @@ if str(ROOT_DIR) not in sys.path:
# Disable auth/rate limit for tests # Disable auth/rate limit for tests
os.environ["DISABLE_AUTH"] = "true" os.environ["DISABLE_AUTH"] = "true"
os.environ["REDIS_CACHE_TURN_ON"] = "false" os.environ["REDIS_CACHE_TURN_ON"] = "false"
os.environ.setdefault("OPENAI_API_KEY", "test-key")
from memos_core.db import DB_PATH, init_memo_db, execute # noqa: E402 from memos_core.db import DB_PATH, execute, init_memo_db # noqa: E402
import memos_core.services as memo_services # noqa: E402
from server import app # noqa: E402 from server import app # noqa: E402
# Stub out embedding calls during tests to avoid external OpenAI dependency
async def _noop_embed(self, *_args, **_kwargs): # pragma: no cover
return None
memo_services.MemoService._upsert_stub_embedding = _noop_embed # type: ignore[attr-defined]
def setup_module(module=None): def setup_module(module=None):
# Ensure DB exists and is clean # Ensure DB exists and is clean
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
async def _reset(): async def _reset():
await init_memo_db() await init_memo_db()
await execute("DELETE FROM memo_embeddings")
await execute("DELETE FROM memos") await execute("DELETE FROM memos")
import asyncio import asyncio
......
This diff is collapsed.
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
"format": "biome format --write src" "format": "biome format --write src"
}, },
"dependencies": { "dependencies": {
"@clerk/clerk-react": "^6.36.8", "@clerk/clerk-react": "^5.59.4",
"@connectrpc/connect": "^2.1.1", "@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1", "@connectrpc/connect-web": "^2.1.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
...@@ -97,4 +97,4 @@ ...@@ -97,4 +97,4 @@
"esbuild" "esbuild"
] ]
} }
} }
\ No newline at end of file
import { BellIcon, EarthIcon, LibraryIcon, PaperclipIcon } from "lucide-react"; import { ArchiveIcon, BellIcon, EarthIcon, LibraryIcon, PaperclipIcon, SettingsIcon, User2Icon } from "lucide-react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
...@@ -15,6 +15,7 @@ interface NavLinkItem { ...@@ -15,6 +15,7 @@ interface NavLinkItem {
path: string; path: string;
title: string; title: string;
icon: React.ReactNode; icon: React.ReactNode;
requiresAuth?: boolean;
} }
interface Props { interface Props {
...@@ -45,6 +46,7 @@ const Navigation = (props: Props) => { ...@@ -45,6 +46,7 @@ const Navigation = (props: Props) => {
path: Routes.ATTACHMENTS, path: Routes.ATTACHMENTS,
title: t("common.attachments"), title: t("common.attachments"),
icon: <PaperclipIcon className="w-6 h-auto shrink-0" />, icon: <PaperclipIcon className="w-6 h-auto shrink-0" />,
requiresAuth: true,
}; };
const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length; const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const inboxNavLink: NavLinkItem = { const inboxNavLink: NavLinkItem = {
...@@ -61,10 +63,30 @@ const Navigation = (props: Props) => { ...@@ -61,10 +63,30 @@ const Navigation = (props: Props) => {
)} )}
</div> </div>
), ),
requiresAuth: true,
}; };
const navLinks: NavLinkItem[] = currentUser const archivedNavLink: NavLinkItem = {
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink] id: "header-archived",
: [exploreNavLink]; path: Routes.ARCHIVED,
title: t("common.archived"),
icon: <ArchiveIcon className="w-6 h-auto shrink-0" />,
requiresAuth: true,
};
const settingsNavLink: NavLinkItem = {
id: "header-setting",
path: Routes.SETTING,
title: t("common.settings"),
icon: <SettingsIcon className="w-6 h-auto shrink-0" />,
requiresAuth: true,
};
const navLinks: NavLinkItem[] = [
homeNavLink,
exploreNavLink,
attachmentsNavLink,
inboxNavLink,
archivedNavLink,
settingsNavLink,
];
return ( return (
<header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar", className)}> <header className={cn("w-full h-full overflow-auto flex flex-col justify-between items-start gap-4 hide-scrollbar", className)}>
...@@ -78,13 +100,14 @@ const Navigation = (props: Props) => { ...@@ -78,13 +100,14 @@ const Navigation = (props: Props) => {
cn( cn(
"px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors", "px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors",
collapsed ? "" : "w-full px-4", collapsed ? "" : "w-full px-4",
navLink.requiresAuth && !currentUser ? "opacity-60" : "",
isActive isActive
? "bg-sidebar-accent text-sidebar-accent-foreground border-sidebar-accent-border drop-shadow" ? "bg-sidebar-accent text-sidebar-accent-foreground border-sidebar-accent-border drop-shadow"
: "border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80", : "border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80",
) )
} }
key={navLink.id} key={navLink.id}
to={navLink.path} to={navLink.requiresAuth && !currentUser ? Routes.AUTH : navLink.path}
id={navLink.id} id={navLink.id}
viewTransition viewTransition
> >
...@@ -106,11 +129,39 @@ const Navigation = (props: Props) => { ...@@ -106,11 +129,39 @@ const Navigation = (props: Props) => {
</NavLink> </NavLink>
))} ))}
</div> </div>
{currentUser && ( <div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}>
<div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}> {currentUser ? (
<UserMenu collapsed={collapsed} /> <UserMenu collapsed={collapsed} />
</div> ) : (
)} <NavLink
to={Routes.AUTH}
className={cn(
"px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80",
collapsed ? "" : "w-full px-4",
)}
>
{collapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<User2Icon className="w-6 h-auto shrink-0" />
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>{t("common.sign-in")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<User2Icon className="w-6 h-auto shrink-0" />
<span className="ml-3 truncate">{t("common.sign-in")}</span>
</>
)}
</NavLink>
)}
</div>
</header> </header>
); );
}; };
......
import { MoreVerticalIcon, PenLineIcon } from "lucide-react"; // import { UserProfile, SignedIn, SignedOut, SignInButton } from "@clerk/clerk-react";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import ChangeMemberPasswordDialog from "../ChangeMemberPasswordDialog";
import UpdateAccountDialog from "../UpdateAccountDialog";
import UserAvatar from "../UserAvatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
import AccessTokenSection from "./AccessTokenSection";
import SettingGroup from "./SettingGroup"; import SettingGroup from "./SettingGroup";
import SettingSection from "./SettingSection"; import SettingSection from "./SettingSection";
import AccessTokenSection from "./AccessTokenSection";
import LocaleSelect from "../LocaleSelect";
import ThemeSelect from "../ThemeSelect";
import { UserSetting_GeneralSetting, UserSetting_GeneralSettingSchema } from "@/types/proto/api/v1/user_service_pb";
import { useAuth } from "@/contexts/AuthContext";
import { create } from "@bufbuild/protobuf";
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
import { loadLocale } from "@/utils/i18n";
import { loadTheme } from "@/utils/theme";
import SettingRow from "./SettingRow";
const MyAccountSection = () => { const MyAccountSection = () => {
const t = useTranslate(); const t = useTranslate();
const user = useCurrentUser();
const accountDialog = useDialog();
const passwordDialog = useDialog();
const handleEditAccount = () => { const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
accountDialog.open(); const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const handleLocaleSelectChange = async (locale: Locale) => {
loadLocale(locale);
updateUserGeneralSetting(
{ generalSetting: { locale }, updateMask: ["locale"] },
{ onSuccess: () => refetchSettings() },
);
}; };
const handleChangePassword = () => { const handleThemeChange = async (theme: string) => {
passwordDialog.open(); loadTheme(theme);
updateUserGeneralSetting(
{ generalSetting: { theme }, updateMask: ["theme"] },
{ onSuccess: () => refetchSettings() },
);
}; };
const setting: UserSetting_GeneralSetting =
generalSetting ||
create(UserSetting_GeneralSettingSchema, {
locale: "en",
memoVisibility: "PRIVATE",
theme: "system",
});
return ( return (
<SettingSection> <SettingSection>
<SettingGroup title={t("setting.account-section.title")}> <SettingGroup title="Clerk Profile">
<div className="w-full flex flex-row justify-start items-center gap-3"> <div className="w-full flex justify-center py-4 border border-dashed border-border rounded-lg bg-muted/30">
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} /> {/* Placeholder for Clerk UserProfile */}
<div className="flex-1 min-w-0 flex flex-col justify-center items-start gap-1"> <div className="text-center p-6">
<div className="w-full"> <p className="text-muted-foreground font-medium">Authentication is managed by Clerk</p>
<span className="text-lg font-semibold">{user?.displayName}</span> <p className="text-xs text-muted-foreground mt-1">(UserProfile component will appear here when Auth is enabled)</p>
<span className="ml-2 text-sm text-muted-foreground">@{user?.username}</span>
</div>
{user?.description && <p className="w-full text-sm text-muted-foreground truncate">{user?.description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleEditAccount}>
<PenLineIcon className="w-4 h-4 mr-1.5" />
{t("common.edit")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVerticalIcon className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleChangePassword}>{t("setting.account-section.change-password")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
{/* <SignedIn>
<UserProfile
routing="virtual"
appearance={{
elements: {
rootBox: "w-full shadow-none",
card: "w-full shadow-none border-0 bg-transparent",
navbar: "hidden",
navbarMobileMenuButton: "hidden",
headerTitle: "hidden",
headerSubtitle: "hidden",
}
}}
/>
</SignedIn>
<SignedOut>
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-muted-foreground">Please sign in to manage your account.</p>
<SignInButton mode="modal">
<Button>Sign In</Button>
</SignInButton>
</div>
</SignedOut> */}
</div> </div>
</SettingGroup> </SettingGroup>
<SettingGroup showSeparator> <SettingGroup title={t("setting.preference")} showSeparator>
<AccessTokenSection /> <SettingRow label={t("common.language")}>
</SettingGroup> <LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
</SettingRow>
{/* Update Account Dialog */} <SettingRow label={t("setting.preference-section.theme")}>
<UpdateAccountDialog open={accountDialog.isOpen} onOpenChange={accountDialog.setOpen} /> <ThemeSelect value={setting.theme} onValueChange={handleThemeChange} />
</SettingRow>
</SettingGroup>
{/* Change Password Dialog */} <SettingGroup title={t("setting.access-token")} showSeparator>
<ChangeMemberPasswordDialog open={passwordDialog.isOpen} onOpenChange={passwordDialog.setOpen} user={user} /> <AccessTokenSection />
</SettingGroup>
</SettingSection> </SettingSection>
); );
}; };
......
import { ArchiveIcon, CheckIcon, GlobeIcon, LogOutIcon, PaletteIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react"; import { ArchiveIcon, LogOutIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
import { locales } from "@/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Routes } from "@/router"; import { Routes } from "@/router";
import { getLocaleDisplayName, getLocaleWithFallback, loadLocale, useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { getThemeWithFallback, loadTheme, THEME_OPTIONS } from "@/utils/theme";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "./ui/dropdown-menu"; } from "./ui/dropdown-menu";
...@@ -28,40 +22,7 @@ const UserMenu = (props: Props) => { ...@@ -28,40 +22,7 @@ const UserMenu = (props: Props) => {
const t = useTranslate(); const t = useTranslate();
const navigateTo = useNavigateTo(); const navigateTo = useNavigateTo();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const { userGeneralSetting, refetchSettings, logout } = useAuth(); const { logout } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const currentLocale = getLocaleWithFallback(userGeneralSetting?.locale);
const currentTheme = getThemeWithFallback(userGeneralSetting?.theme);
const handleLocaleChange = async (locale: Locale) => {
if (!currentUser) return;
// Apply locale immediately for instant UI feedback and persist to localStorage
loadLocale(locale);
// Persist to user settings
updateUserGeneralSetting(
{ generalSetting: { locale }, updateMask: ["locale"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
const handleThemeChange = async (theme: string) => {
if (!currentUser) return;
// Apply theme immediately for instant UI feedback
loadTheme(theme);
// Persist to user settings
updateUserGeneralSetting(
{ generalSetting: { theme }, updateMask: ["theme"] },
{
onSuccess: () => {
refetchSettings();
},
},
);
};
const handleSignOut = async () => { const handleSignOut = async () => {
// First, clear auth state and cache BEFORE doing anything else // First, clear auth state and cache BEFORE doing anything else
...@@ -114,36 +75,6 @@ const UserMenu = (props: Props) => { ...@@ -114,36 +75,6 @@ const UserMenu = (props: Props) => {
<ArchiveIcon className="size-4 text-muted-foreground" /> <ArchiveIcon className="size-4 text-muted-foreground" />
{t("common.archived")} {t("common.archived")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<GlobeIcon className="size-4 text-muted-foreground" />
{t("common.language")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[90vh] overflow-y-auto">
{locales.map((locale) => (
<DropdownMenuItem key={locale} onClick={() => handleLocaleChange(locale)}>
{currentLocale === locale && <CheckIcon className="w-4 h-auto" />}
{currentLocale !== locale && <span className="w-4" />}
{getLocaleDisplayName(locale)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PaletteIcon className="size-4 text-muted-foreground" />
{t("setting.preference-section.theme")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{THEME_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => handleThemeChange(option.value)}>
{currentTheme === option.value && <CheckIcon className="w-4 h-auto" />}
{currentTheme !== option.value && <span className="w-4" />}
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem onClick={() => navigateTo(Routes.SETTING)}> <DropdownMenuItem onClick={() => navigateTo(Routes.SETTING)}>
<SettingsIcon className="size-4 text-muted-foreground" /> <SettingsIcon className="size-4 text-muted-foreground" />
{t("common.settings")} {t("common.settings")}
......
...@@ -39,7 +39,10 @@ type ApiMemo = { ...@@ -39,7 +39,10 @@ type ApiMemo = {
content: string; content: string;
visibility?: string | null; visibility?: string | null;
tags?: string[]; tags?: string[];
creator_id?: number; creator_id?: number | string;
create_time?: string;
update_time?: string;
display_time?: string;
}; };
type ApiAttachment = { type ApiAttachment = {
...@@ -194,9 +197,20 @@ const visibilityToApi = (value?: Visibility): string => { ...@@ -194,9 +197,20 @@ const visibilityToApi = (value?: Visibility): string => {
} }
}; };
const parseTimestamp = (dateStr?: string) => {
if (!dateStr) return undefined;
// Handle "YYYY-MM-DD HH:MM:SS" SQLite string format
// Append 'Z' to treat as UTC if no timezone info, or assume local?
// Backend stores DATETIME('now') which is UTC.
const date = new Date(dateStr.endsWith("Z") ? dateStr : `${dateStr}Z`);
const seconds = BigInt(Math.floor(date.getTime() / 1000));
return { seconds, nanos: 0 } as any;
};
const memoFromApi = (raw: ApiMemo): Memo => { const memoFromApi = (raw: ApiMemo): Memo => {
const content = raw.content || ""; const content = raw.content || "";
return { console.log("DEBUG: memoFromApi input:", raw);
const result = {
name: `memos/${raw.id}`, name: `memos/${raw.id}`,
state: State.NORMAL, state: State.NORMAL,
creator: `users/${raw.creator_id ?? 1}`, creator: `users/${raw.creator_id ?? 1}`,
...@@ -214,7 +228,12 @@ const memoFromApi = (raw: ApiMemo): Memo => { ...@@ -214,7 +228,12 @@ const memoFromApi = (raw: ApiMemo): Memo => {
hasIncompleteTasks: false, hasIncompleteTasks: false,
}, },
snippet: buildSnippet(content), snippet: buildSnippet(content),
createTime: parseTimestamp(raw.create_time),
updateTime: parseTimestamp(raw.update_time),
displayTime: parseTimestamp(raw.display_time),
}; };
console.log("DEBUG: memoFromApi output:", result);
return result;
}; };
const userFromApi = (raw: ApiUser): User => { const userFromApi = (raw: ApiUser): User => {
......
import { useAuth } from "@/contexts/AuthContext"; import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
const useCurrentUser = () => { const useCurrentUser = () => {
const { currentUser } = useAuth(); // Mock user for testing without backend/auth
return currentUser; return {
name: "users/test-user",
id: 1,
role: User_Role.HOST,
email: "test@example.com",
nickname: "Test User",
avatarUrl: "",
description: "",
createTime: undefined,
updateTime: undefined,
rowStatus: "NORMAL"
} as unknown as User;
// Real implementation:
// const { currentUser } = useAuth();
// return currentUser;
}; };
export default useCurrentUser; export default useCurrentUser;
import { Suspense, useEffect, useMemo } from "react"; import { Suspense, useEffect, useMemo } from "react";
import { Outlet, useLocation, useSearchParams, Link } from "react-router-dom"; import { Outlet, useLocation, useSearchParams, Link } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious"; import usePrevious from "react-use/lib/usePrevious";
import { SignedIn, SignedOut, UserButton } from "@clerk/clerk-react"; // import { SignedIn, SignedOut, UserButton } from "@clerk/clerk-react";
import Navigation from "@/components/Navigation"; import Navigation from "@/components/Navigation";
import ChatbotWidget from "@/components/ChatbotWidget"; import ChatbotWidget from "@/components/ChatbotWidget";
import Spinner from "@/components/Spinner"; import Spinner from "@/components/Spinner";
...@@ -22,11 +22,11 @@ const RootLayout = () => { ...@@ -22,11 +22,11 @@ const RootLayout = () => {
const pathname = useMemo(() => location.pathname, [location.pathname]); const pathname = useMemo(() => location.pathname, [location.pathname]);
const prevPathname = usePrevious(pathname); const prevPathname = usePrevious(pathname);
useEffect(() => { // useEffect(() => {
if (!currentUser && memoRelatedSetting.disallowPublicVisibility) { // if (!currentUser && memoRelatedSetting.disallowPublicVisibility) {
redirectOnAuthFailure(); // redirectOnAuthFailure();
} // }
}, [currentUser, memoRelatedSetting.disallowPublicVisibility]); // }, [currentUser, memoRelatedSetting.disallowPublicVisibility]);
useEffect(() => { useEffect(() => {
// When the route changes and there is no filter in the search params, remove all filters // When the route changes and there is no filter in the search params, remove all filters
...@@ -59,7 +59,7 @@ const RootLayout = () => { ...@@ -59,7 +59,7 @@ const RootLayout = () => {
<Outlet /> <Outlet />
</Suspense> </Suspense>
</main> </main>
<div className="fixed top-4 right-4 z-50"> {/* <div className="fixed top-4 right-4 z-50">
<SignedOut> <SignedOut>
<Link <Link
to="/auth" to="/auth"
...@@ -71,7 +71,7 @@ const RootLayout = () => { ...@@ -71,7 +71,7 @@ const RootLayout = () => {
<SignedIn> <SignedIn>
<UserButton /> <UserButton />
</SignedIn> </SignedIn>
</div> </div> */}
<ChatbotWidget /> <ChatbotWidget />
</div> </div>
); );
......
import "@github/relative-time-element"; import "@github/relative-time-element";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { ClerkProvider } from "@clerk/clerk-react"; // import { ClerkProvider } from "@clerk/clerk-react";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
...@@ -52,6 +52,7 @@ function AppInitializer({ children }: { children: React.ReactNode }) { ...@@ -52,6 +52,7 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
} }
function Main() { function Main() {
/*
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
if (!publishableKey) { if (!publishableKey) {
return ( return (
...@@ -73,27 +74,33 @@ VITE_API_BASE_URL=http://localhost:5000 ...@@ -73,27 +74,33 @@ VITE_API_BASE_URL=http://localhost:5000
</div> </div>
); );
} }
*/
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ClerkProvider publishableKey={publishableKey}> {/* <ClerkProvider
<QueryClientProvider client={queryClient}> publishableKey={publishableKey}
<InstanceProvider> signInUrl="/auth"
<AuthProvider> signUpUrl="/auth?mode=signup"
<ViewProvider> fallbackRedirectUrl="/app"
<AppInitializer> > */}
<RouterProvider router={router} /> <QueryClientProvider client={queryClient}>
<Toaster position="top-right" /> <InstanceProvider>
</AppInitializer> <AuthProvider>
</ViewProvider> <ViewProvider>
</AuthProvider> <AppInitializer>
</InstanceProvider> <RouterProvider router={router} />
</QueryClientProvider> <Toaster position="top-right" />
</ClerkProvider> </AppInitializer>
</ViewProvider>
</AuthProvider>
</InstanceProvider>
</QueryClientProvider>
{/* </ClerkProvider> */}
</ErrorBoundary> </ErrorBoundary>
); );
} }
const container = document.getElementById("root"); const container = document.getElementById("root");
const root = createRoot(container as HTMLElement); const root = createRoot(container as HTMLElement);
root.render(<Main />); root.render(<Main />);
\ No newline at end of file
import { ArchiveIcon } from "lucide-react";
import { MemoRenderContext } from "@/components/MasonryView"; import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView"; import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import PagedMemoList from "@/components/PagedMemoList"; import PagedMemoList from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks"; import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
import { State } from "@/types/proto/api/v1/common_pb"; import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
const Archived = () => { const Archived = () => {
const t = useTranslate();
const md = useMediaQuery("md");
const user = useCurrentUser(); const user = useCurrentUser();
// Build filter using unified hook (no shortcuts or pinned filter) // Build filter using unified hook (no shortcuts or pinned filter)
...@@ -23,15 +29,30 @@ const Archived = () => { ...@@ -23,15 +29,30 @@ const Archived = () => {
}); });
return ( return (
<PagedMemoList <section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
renderer={(memo: Memo, context?: MemoRenderContext) => ( {!md && <MobileHeader />}
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={context?.compact} /> <div className="w-full px-4 sm:px-6">
)} <div className="w-full border border-border flex flex-col justify-start items-start rounded-xl bg-background text-foreground overflow-hidden">
listSort={listSort} <div className="w-full px-4 py-4 border-b border-border">
state={State.ARCHIVED} <div className="flex flex-row items-center gap-2">
orderBy={orderBy} <ArchiveIcon className="w-5 h-auto text-muted-foreground" />
filter={memoFilter} <h1 className="text-xl font-semibold">{t("common.archived")}</h1>
/> </div>
</div>
<div className="w-full px-4 py-8">
<PagedMemoList
renderer={(memo: Memo, context?: MemoRenderContext) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact={context?.compact} />
)}
listSort={listSort}
state={State.ARCHIVED}
orderBy={orderBy}
filter={memoFilter}
/>
</div>
</div>
</div>
</section>
); );
}; };
......
/* ============================================
AUTH PAGE STYLES
============================================ */
.auth-page {
min-height: 100vh;
width: 100%;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
position: relative;
overflow: hidden;
}
/* Background Orbs */
.auth-background {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.auth-gradient-orb {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.5;
animation: authFloat 25s ease-in-out infinite;
}
.auth-gradient-orb.orb-1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.4) 0%, transparent 70%);
top: -150px;
right: -100px;
animation-delay: 0s;
}
.auth-gradient-orb.orb-2 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(139, 92, 246, 0.35) 0%, transparent 70%);
bottom: -100px;
left: -100px;
animation-delay: -8s;
}
.auth-gradient-orb.orb-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.25) 0%, transparent 70%);
top: 40%;
left: 30%;
animation-delay: -16s;
}
@keyframes authFloat {
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(40px, -40px) scale(1.1);
}
66% {
transform: translate(-30px, 30px) scale(0.9);
}
}
/* Container */
.auth-container {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 80px;
max-width: 1100px;
width: 100%;
}
/* Branding Section */
.auth-branding {
flex: 1;
max-width: 450px;
}
.auth-logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 24px;
font-weight: 700;
color: #fff;
margin-bottom: 40px;
cursor: pointer;
transition: opacity 0.2s;
}
.auth-logo:hover {
opacity: 0.8;
}
.auth-logo svg {
width: 48px;
height: 48px;
}
.auth-title {
font-size: clamp(32px, 5vw, 48px);
font-weight: 800;
color: #fff;
line-height: 1.2;
margin-bottom: 16px;
letter-spacing: -0.02em;
}
.auth-subtitle {
font-size: 17px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.7;
margin-bottom: 40px;
}
.auth-features {
display: flex;
flex-direction: column;
gap: 16px;
}
.auth-feature {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
color: rgba(255, 255, 255, 0.8);
}
.auth-feature svg {
color: #10b981;
flex-shrink: 0;
}
/* Form Container */
.auth-form-container {
flex: 1;
max-width: 420px;
display: flex;
justify-content: center;
}
/* Clerk Customizations */
.auth-form-container :global(.cl-rootBox) {
width: 100%;
}
.auth-form-container :global(.cl-card) {
background: rgba(255, 255, 255, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 20px !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4) !important;
backdrop-filter: blur(20px);
}
.auth-form-container :global(.cl-headerTitle) {
color: #fff !important;
font-weight: 700 !important;
}
.auth-form-container :global(.cl-headerSubtitle) {
color: rgba(255, 255, 255, 0.6) !important;
}
.auth-form-container :global(.cl-socialButtonsBlockButton) {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 12px !important;
color: #fff !important;
transition: all 0.2s ease !important;
}
.auth-form-container :global(.cl-socialButtonsBlockButton:hover) {
background: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
}
.auth-form-container :global(.cl-formFieldInput) {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
border-radius: 12px !important;
color: #fff !important;
}
.auth-form-container :global(.cl-formFieldInput:focus) {
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2) !important;
}
.auth-form-container :global(.cl-formButtonPrimary) {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
border-radius: 12px !important;
font-weight: 600 !important;
transition: all 0.3s ease !important;
}
.auth-form-container :global(.cl-formButtonPrimary:hover) {
transform: translateY(-2px) !important;
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4) !important;
}
.auth-form-container :global(.cl-footerActionLink) {
color: #a5b4fc !important;
}
.auth-form-container :global(.cl-footerActionLink:hover) {
color: #c7d2fe !important;
}
.auth-form-container :global(.cl-dividerLine) {
background: rgba(255, 255, 255, 0.1) !important;
}
.auth-form-container :global(.cl-dividerText) {
color: rgba(255, 255, 255, 0.4) !important;
}
.auth-form-container :global(.cl-formFieldLabel) {
color: rgba(255, 255, 255, 0.8) !important;
}
.auth-form-container :global(.cl-internal-b3fm6y) {
color: rgba(255, 255, 255, 0.5) !important;
}
/* Signed In State */
.auth-signed-in {
text-align: center;
padding: 48px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
backdrop-filter: blur(20px);
}
.auth-signed-in-icon {
margin-bottom: 24px;
}
.auth-signed-in h2 {
font-size: 24px;
font-weight: 700;
color: #fff;
margin-bottom: 8px;
}
.auth-signed-in p {
font-size: 15px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 32px;
}
.auth-go-to-app {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
font-size: 15px;
font-weight: 600;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.auth-go-to-app:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.4);
}
/* Back Button */
.auth-back-btn {
position: absolute;
top: 24px;
left: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
}
.auth-back-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
/* Responsive */
@media (max-width: 900px) {
.auth-container {
flex-direction: column;
gap: 48px;
}
.auth-branding {
text-align: center;
max-width: 100%;
}
.auth-features {
align-items: center;
}
.auth-form-container {
width: 100%;
max-width: 400px;
}
}
@media (max-width: 480px) {
.auth-page {
padding: 80px 16px 40px;
}
.auth-title {
font-size: 28px;
}
.auth-features {
display: none;
}
}
\ No newline at end of file
import { SignedIn, SignedOut, SignIn } from "@clerk/clerk-react"; import { SignedIn, SignedOut, SignIn, SignUp, useAuth } from "@clerk/clerk-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import "./Auth.css";
const AuthPage = () => { const AuthPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { isSignedIn } = useAuth();
const [searchParams] = useSearchParams();
const mode = searchParams.get("mode"); // "signup" or null (signin)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const next = params.get("redirect") || "/app";
if (!isSignedIn) return;
if (window.location.pathname.startsWith("/auth")) {
navigate(next);
}
}, [isSignedIn, navigate]);
return (
<div className="auth-page">
{/* Background */}
<div className="auth-background">
<div className="auth-gradient-orb orb-1"></div>
<div className="auth-gradient-orb orb-2"></div>
<div className="auth-gradient-orb orb-3"></div>
</div>
// Nếu đã đăng nhập rồi thì đưa về trang chủ {/* Content */}
useEffect(() => { <div className="auth-container">
// nhỏ gọn: SignedIn phía dưới cũng handle, đây chỉ là fallback {/* Left Side - Branding */}
}, [navigate]); <div className="auth-branding">
<div className="auth-logo" onClick={() => navigate("/")}>
<svg width="48" height="48" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="url(#authGradient)" />
<path d="M10 16L14 20L22 12" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<defs>
<linearGradient id="authGradient" x1="0" y1="0" x2="32" y2="32">
<stop stopColor="#6366f1" />
<stop offset="1" stopColor="#8b5cf6" />
</linearGradient>
</defs>
</svg>
<span>BasicNotion</span>
</div>
<h1 className="auth-title">
{mode === "signup" ? "Start your journey" : "Welcome back"}
</h1>
<p className="auth-subtitle">
{mode === "signup"
? "Create your account and start organizing your thoughts today."
: "Sign in to continue to your notes and ideas."}
</p>
<div className="auth-features">
<div className="auth-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 4L12 14.01l-3-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>Quick capture thoughts</span>
</div>
<div className="auth-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 4L12 14.01l-3-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>Sync across all devices</span>
</div>
<div className="auth-feature">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 4L12 14.01l-3-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>AI-powered assistant</span>
</div>
</div>
</div>
return ( {/* Right Side - Auth Form */}
<div className="w-full min-h-svh flex items-center justify-center bg-background px-4"> <div className="auth-form-container">
<div className="max-w-md w-full flex flex-col items-center gap-6"> <SignedOut>
<SignedOut> {mode === "signup" ? (
<SignIn <SignUp
routing="path" appearance={{
path="/auth" elements: {
signUpUrl="/auth" rootBox: "clerk-root-box",
redirectUrl="/" card: "clerk-card",
/> headerTitle: "clerk-header-title",
</SignedOut> headerSubtitle: "clerk-header-subtitle",
<SignedIn> socialButtonsBlockButton: "clerk-social-btn",
<button formFieldInput: "clerk-input",
type="button" formButtonPrimary: "clerk-primary-btn",
className="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90" footerActionLink: "clerk-footer-link",
onClick={() => navigate("/")} },
> variables: {
You are already signed in – Go to app colorPrimary: "#6366f1",
</button> colorBackground: "#1a1a2e",
</SignedIn> colorText: "#ffffff",
colorTextSecondary: "rgba(255,255,255,0.6)",
colorInputBackground: "rgba(255,255,255,0.05)",
colorInputText: "#ffffff",
borderRadius: "12px",
},
}}
routing="path"
path="/auth"
signInUrl="/auth"
fallbackRedirectUrl="/app"
/>
) : (
<SignIn
appearance={{
elements: {
rootBox: "clerk-root-box",
card: "clerk-card",
headerTitle: "clerk-header-title",
headerSubtitle: "clerk-header-subtitle",
socialButtonsBlockButton: "clerk-social-btn",
formFieldInput: "clerk-input",
formButtonPrimary: "clerk-primary-btn",
footerActionLink: "clerk-footer-link",
},
variables: {
colorPrimary: "#6366f1",
colorBackground: "#1a1a2e",
colorText: "#ffffff",
colorTextSecondary: "rgba(255,255,255,0.6)",
colorInputBackground: "rgba(255,255,255,0.05)",
colorInputText: "#ffffff",
borderRadius: "12px",
},
}}
routing="path"
path="/auth"
signUpUrl="/auth?mode=signup"
fallbackRedirectUrl="/app"
/>
)}
</SignedOut>
<SignedIn>
<div className="auth-signed-in">
<div className="auth-signed-in-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="#10b981" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 4L12 14.01l-3-3" stroke="#10b981" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h2>You're signed in!</h2>
<p>You're already logged in to your account.</p>
<button
type="button"
className="auth-go-to-app"
onClick={() => navigate("/app")}
>
Go to Dashboard
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.167 10h11.666M10 4.167L15.833 10 10 15.833" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</SignedIn>
</div>
</div> </div>
{/* Back to Landing */}
<button className="auth-back-btn" onClick={() => navigate("/")}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.833 10H4.167M10 15.833L4.167 10 10 4.167" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Back to home
</button>
</div> </div>
); );
}; };
export default AuthPage; export default AuthPage;
import { AuthenticateWithRedirectCallback } from "@clerk/clerk-react";
const AuthSsoCallback = () => {
return <AuthenticateWithRedirectCallback redirectUrl="/app" />;
};
export default AuthSsoCallback;
import { timestampDate } from "@bufbuild/protobuf/wkt"; import { timestampDate } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react"; import { BellIcon, InboxIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import Empty from "@/components/Empty"; import Empty from "@/components/Empty";
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage"; import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
...@@ -14,7 +14,7 @@ import { useTranslate } from "@/utils/i18n"; ...@@ -14,7 +14,7 @@ import { useTranslate } from "@/utils/i18n";
const Inboxes = () => { const Inboxes = () => {
const t = useTranslate(); const t = useTranslate();
const md = useMediaQuery("md"); const md = useMediaQuery("md");
const [filter, setFilter] = useState<"all" | "unread" | "archived">("all"); const [filter, setFilter] = useState<"all" | "unread">("all");
// Fetch notifications with React Query // Fetch notifications with React Query
const { data: fetchedNotifications = [] } = useNotifications(); const { data: fetchedNotifications = [] } = useNotifications();
...@@ -25,12 +25,11 @@ const Inboxes = () => { ...@@ -25,12 +25,11 @@ const Inboxes = () => {
const notifications = allNotifications.filter((notification) => { const notifications = allNotifications.filter((notification) => {
if (filter === "unread") return notification.status === UserNotification_Status.UNREAD; if (filter === "unread") return notification.status === UserNotification_Status.UNREAD;
if (filter === "archived") return notification.status === UserNotification_Status.ARCHIVED; return notification.status !== UserNotification_Status.ARCHIVED;
return true;
}); });
const unreadCount = allNotifications.filter((n) => n.status === UserNotification_Status.UNREAD).length; const unreadCount = allNotifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const archivedCount = allNotifications.filter((n) => n.status === UserNotification_Status.ARCHIVED).length; // const archivedCount = allNotifications.filter((n) => n.status === UserNotification_Status.ARCHIVED).length;
return ( return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8"> <section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
...@@ -78,18 +77,6 @@ const Inboxes = () => { ...@@ -78,18 +77,6 @@ const Inboxes = () => {
<InboxIcon className="w-3.5 h-auto" /> <InboxIcon className="w-3.5 h-auto" />
{t("inbox.unread")} ({unreadCount}) {t("inbox.unread")} ({unreadCount})
</button> </button>
<button
onClick={() => setFilter("archived")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5",
filter === "archived"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
<ArchiveIcon className="w-3.5 h-auto" />
{t("common.archived")} ({archivedCount})
</button>
</div> </div>
</div> </div>
...@@ -99,7 +86,7 @@ const Inboxes = () => { ...@@ -99,7 +86,7 @@ const Inboxes = () => {
<div className="w-full py-16 flex flex-col justify-center items-center"> <div className="w-full py-16 flex flex-col justify-center items-center">
<Empty /> <Empty />
<p className="mt-4 text-sm text-muted-foreground"> <p className="mt-4 text-sm text-muted-foreground">
{filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")} {filter === "unread" ? t("inbox.no-unread") : t("message.no-data")}
</p> </p>
</div> </div>
) : ( ) : (
......
This diff is collapsed.
This diff is collapsed.
...@@ -6,18 +6,21 @@ import Spinner from "@/components/Spinner"; ...@@ -6,18 +6,21 @@ import Spinner from "@/components/Spinner";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout"; import RootLayout from "@/layouts/RootLayout";
import Home from "@/pages/Home"; import Home from "@/pages/Home";
import NotFound from "@/pages/NotFound";
const Archived = lazy(() => import("@/pages/Archived")); const Archived = lazy(() => import("@/pages/Archived"));
const Explore = lazy(() => import("@/pages/Explore")); const Explore = lazy(() => import("@/pages/Explore"));
const Inboxes = lazy(() => import("@/pages/Inboxes")); const Inboxes = lazy(() => import("@/pages/Inboxes"));
const MemoDetail = lazy(() => import("@/pages/MemoDetail")); const MemoDetail = lazy(() => import("@/pages/MemoDetail"));
const NotFound = lazy(() => import("@/pages/NotFound"));
const PermissionDenied = lazy(() => import("@/pages/PermissionDenied")); const PermissionDenied = lazy(() => import("@/pages/PermissionDenied"));
const Attachments = lazy(() => import("@/pages/Attachments")); const Attachments = lazy(() => import("@/pages/Attachments"));
const Setting = lazy(() => import("@/pages/Setting")); const Setting = lazy(() => import("@/pages/Setting"));
const UserProfile = lazy(() => import("@/pages/UserProfile")); const UserProfile = lazy(() => import("@/pages/UserProfile"));
const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect")); const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
const AuthPage = lazy(() => import("@/pages/Auth")); const AuthPage = lazy(() => import("@/pages/Auth"));
const AuthCallback = lazy(() => import("@/pages/AuthCallback"));
const AuthSsoCallback = lazy(() => import("@/pages/AuthSsoCallback"));
const Landing = lazy(() => import("@/pages/Landing"));
import { ROUTES } from "./routes"; import { ROUTES } from "./routes";
...@@ -42,7 +45,15 @@ const router = createBrowserRouter([ ...@@ -42,7 +45,15 @@ const router = createBrowserRouter([
{ {
path: "/", path: "/",
element: <App />, element: <App />,
errorElement: <NotFound />,
children: [ children: [
// Landing page at root
{ index: true, element: <LazyRoute component={Landing} /> },
{ path: "landing", element: <LazyRoute component={Landing} /> },
// Auth page (separate from main app - no sidebar)
{ path: Routes.AUTH, element: <LazyRoute component={AuthPage} /> },
{ path: "auth/callback", element: <LazyRoute component={AuthCallback} /> },
{ path: "auth/sso-callback", element: <LazyRoute component={AuthSsoCallback} /> },
{ {
path: Routes.ROOT, path: Routes.ROOT,
element: <RootLayout />, element: <RootLayout />,
...@@ -51,15 +62,14 @@ const router = createBrowserRouter([ ...@@ -51,15 +62,14 @@ const router = createBrowserRouter([
element: <MainLayout />, element: <MainLayout />,
children: [ children: [
{ path: "", element: <Home /> }, { path: "", element: <Home /> },
{ path: Routes.EXPLORE, element: <LazyRoute component={Explore} /> }, { path: "explore", element: <LazyRoute component={Explore} /> },
{ path: Routes.ARCHIVED, element: <LazyRoute component={Archived} /> },
{ path: "u/:username", element: <LazyRoute component={UserProfile} /> }, { path: "u/:username", element: <LazyRoute component={UserProfile} /> },
], ],
}, },
{ path: Routes.AUTH, element: <LazyRoute component={AuthPage} /> }, { path: "attachments", element: <LazyRoute component={Attachments} /> },
{ path: Routes.ATTACHMENTS, element: <LazyRoute component={Attachments} /> }, { path: "archived", element: <LazyRoute component={Archived} /> },
{ path: Routes.INBOX, element: <LazyRoute component={Inboxes} /> }, { path: "inbox", element: <LazyRoute component={Inboxes} /> },
{ path: Routes.SETTING, element: <LazyRoute component={Setting} /> }, { path: "setting", element: <LazyRoute component={Setting} /> },
{ path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> }, { path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> },
// Redirect old path to new path // Redirect old path to new path
{ path: "m/:uid", element: <LazyRoute component={MemoDetailRedirect} /> }, { path: "m/:uid", element: <LazyRoute component={MemoDetailRedirect} /> },
...@@ -68,6 +78,7 @@ const router = createBrowserRouter([ ...@@ -68,6 +78,7 @@ const router = createBrowserRouter([
{ path: "*", element: <LazyRoute component={NotFound} /> }, { path: "*", element: <LazyRoute component={NotFound} /> },
], ],
}, },
{ path: "*", element: <LazyRoute component={NotFound} /> },
], ],
}, },
]); ]);
......
export const ROUTES = { export const ROUTES = {
ROOT: "/", ROOT: "/app",
ATTACHMENTS: "/attachments", LANDING: "/",
INBOX: "/inbox", AUTH: "/auth",
ARCHIVED: "/archived", ATTACHMENTS: "/app/attachments",
SETTING: "/setting", INBOX: "/app/inbox",
EXPLORE: "/explore", ARCHIVED: "/app/archived",
SETTING: "/app/setting",
EXPLORE: "/app/explore",
} as const; } as const;
export type RouteKey = keyof typeof ROUTES; export type RouteKey = keyof typeof ROUTES;
......
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