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.
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 (
MemoCreate,
......@@ -17,19 +17,28 @@ from memos_core.services import get_memo_service
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])
async def list_memos(
request: Request,
tag: str | None = Query(default=None),
memo_service=Depends(get_memo_service),
):
"""List memos for the current user (or anonymous if not logged in)."""
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
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("", summary="Create memo (Connect compatibility)")
async def create_memo_or_list_memos(
request: Request,
payload: dict = Body(default_factory=dict), # noqa: B008
memo_service=Depends(get_memo_service),
):
......@@ -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.
"""
try:
user_id = get_current_user_id(request)
raw = payload if isinstance(payload, dict) else {}
# Connect CreateMemo often wraps payload as { "memo": { ... } }
if isinstance(raw.get("memo"), dict):
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 "content" in 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.
# Support basic tag filter when provided.
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
raise HTTPException(status_code=400, detail=str(exc)) from exc
@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:
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
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.patch("/{memo_id}", summary="Update memo", response_model=MemoResponse)
async def update_memo(
request: Request,
memo_id: int,
payload: MemoUpdate,
memo_service=Depends(get_memo_service),
):
"""Update a memo (only if owned by current user)."""
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
raise HTTPException(status_code=400, detail=str(exc)) from exc
@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:
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"}
except Exception as exc: # pragma: no cover
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():
"""Ensure memo database and tables exist."""
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
async with aiosqlite.connect(DB_PATH) as db:
# Main memos table - creator_id is TEXT to store Clerk user IDs
await db.execute(
"""
CREATE TABLE IF NOT EXISTS memos (
......@@ -20,12 +21,18 @@ async def init_memo_db():
content TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'PRIVATE',
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')),
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(
"""
CREATE TABLE IF NOT EXISTS memo_embeddings (
......
......@@ -108,7 +108,12 @@ class MemoUpdate(BaseModel):
class MemoResponse(MemoBase):
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
display_time: Optional[str] = None
model_config = ConfigDict(populate_by_name=True)
class MemoEmbeddingCreate(BaseModel):
......
......@@ -54,10 +54,34 @@ class UserService:
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()
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 FROM memos ORDER BY created_at DESC"
"""
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] = []
......@@ -71,13 +95,21 @@ class MemoService:
content=row["content"],
visibility=row.get("visibility") or "PRIVATE",
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
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()
# Use actual user_id from Clerk, or 'anonymous' for guests
creator = user_id or "anonymous"
tags_json = _encode_tags(payload.tags)
memo_id = await execute(
"""
......@@ -88,7 +120,7 @@ class MemoService:
payload.content,
payload.visibility or "PRIVATE",
tags_json,
1, # stub creator_id
creator,
),
)
await self._upsert_stub_embedding(
......@@ -96,33 +128,53 @@ class MemoService:
content=payload.content,
tags=payload.tags or [],
)
return schemas.MemoResponse(
id=memo_id,
creator_id=1,
content=payload.content,
visibility=payload.visibility or "PRIVATE",
tags=payload.tags or [],
)
return await self.get_memo(memo_id, user_id=creator)
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()
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,),
)
if not row:
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(
id=row["id"],
content=row["content"],
visibility=row.get("visibility") or "PRIVATE",
visibility=memo_visibility,
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()
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_visibility = payload.visibility if payload.visibility is not None else current.visibility
......@@ -132,9 +184,9 @@ class MemoService:
"""
UPDATE memos
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(
memo_id=memo_id,
......@@ -142,17 +194,25 @@ class MemoService:
tags=new_tags or [],
)
return schemas.MemoResponse(
id=memo_id,
creator_id=current.creator_id,
content=new_content,
visibility=new_visibility,
tags=new_tags,
)
return await self.get_memo(memo_id, user_id=user_id)
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 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,))
return None
......
......@@ -12,17 +12,28 @@ if str(ROOT_DIR) not in sys.path:
# Disable auth/rate limit for tests
os.environ["DISABLE_AUTH"] = "true"
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
# 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):
# Ensure DB exists and is clean
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
async def _reset():
await init_memo_db()
await execute("DELETE FROM memo_embeddings")
await execute("DELETE FROM memos")
import asyncio
......
This diff is collapsed.
......@@ -10,7 +10,7 @@
"format": "biome format --write src"
},
"dependencies": {
"@clerk/clerk-react": "^6.36.8",
"@clerk/clerk-react": "^5.59.4",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@emotion/react": "^11.14.0",
......
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser";
......@@ -15,6 +15,7 @@ interface NavLinkItem {
path: string;
title: string;
icon: React.ReactNode;
requiresAuth?: boolean;
}
interface Props {
......@@ -45,6 +46,7 @@ const Navigation = (props: Props) => {
path: Routes.ATTACHMENTS,
title: t("common.attachments"),
icon: <PaperclipIcon className="w-6 h-auto shrink-0" />,
requiresAuth: true,
};
const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
const inboxNavLink: NavLinkItem = {
......@@ -61,10 +63,30 @@ const Navigation = (props: Props) => {
)}
</div>
),
requiresAuth: true,
};
const navLinks: NavLinkItem[] = currentUser
? [homeNavLink, exploreNavLink, attachmentsNavLink, inboxNavLink]
: [exploreNavLink];
const archivedNavLink: NavLinkItem = {
id: "header-archived",
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 (
<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) => {
cn(
"px-2 py-2 rounded-2xl border flex flex-row items-center text-lg text-sidebar-foreground transition-colors",
collapsed ? "" : "w-full px-4",
navLink.requiresAuth && !currentUser ? "opacity-60" : "",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground border-sidebar-accent-border drop-shadow"
: "border-transparent hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:border-sidebar-accent-border opacity-80",
)
}
key={navLink.id}
to={navLink.path}
to={navLink.requiresAuth && !currentUser ? Routes.AUTH : navLink.path}
id={navLink.id}
viewTransition
>
......@@ -106,11 +129,39 @@ const Navigation = (props: Props) => {
</NavLink>
))}
</div>
{currentUser && (
<div className={cn("w-full flex flex-col justify-end", props.collapsed ? "items-center" : "items-start pl-3")}>
{currentUser ? (
<UserMenu collapsed={collapsed} />
) : (
<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>
);
};
......
import { MoreVerticalIcon, PenLineIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
// import { UserProfile, SignedIn, SignedOut, SignInButton } from "@clerk/clerk-react";
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 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 t = useTranslate();
const user = useCurrentUser();
const accountDialog = useDialog();
const passwordDialog = useDialog();
const handleEditAccount = () => {
accountDialog.open();
const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
const handleLocaleSelectChange = async (locale: Locale) => {
loadLocale(locale);
updateUserGeneralSetting(
{ generalSetting: { locale }, updateMask: ["locale"] },
{ onSuccess: () => refetchSettings() },
);
};
const handleChangePassword = () => {
passwordDialog.open();
const handleThemeChange = async (theme: string) => {
loadTheme(theme);
updateUserGeneralSetting(
{ generalSetting: { theme }, updateMask: ["theme"] },
{ onSuccess: () => refetchSettings() },
);
};
const setting: UserSetting_GeneralSetting =
generalSetting ||
create(UserSetting_GeneralSettingSchema, {
locale: "en",
memoVisibility: "PRIVATE",
theme: "system",
});
return (
<SettingSection>
<SettingGroup title={t("setting.account-section.title")}>
<div className="w-full flex flex-row justify-start items-center gap-3">
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} />
<div className="flex-1 min-w-0 flex flex-col justify-center items-start gap-1">
<div className="w-full">
<span className="text-lg font-semibold">{user?.displayName}</span>
<span className="ml-2 text-sm text-muted-foreground">@{user?.username}</span>
<SettingGroup title="Clerk Profile">
<div className="w-full flex justify-center py-4 border border-dashed border-border rounded-lg bg-muted/30">
{/* Placeholder for Clerk UserProfile */}
<div className="text-center p-6">
<p className="text-muted-foreground font-medium">Authentication is managed by Clerk</p>
<p className="text-xs text-muted-foreground mt-1">(UserProfile component will appear here when Auth is enabled)</p>
</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>
{/* <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>
</SettingGroup>
<SettingGroup showSeparator>
<AccessTokenSection />
</SettingGroup>
<SettingGroup title={t("setting.preference")} showSeparator>
<SettingRow label={t("common.language")}>
<LocaleSelect value={setting.locale} onChange={handleLocaleSelectChange} />
</SettingRow>
{/* Update Account Dialog */}
<UpdateAccountDialog open={accountDialog.isOpen} onOpenChange={accountDialog.setOpen} />
<SettingRow label={t("setting.preference-section.theme")}>
<ThemeSelect value={setting.theme} onValueChange={handleThemeChange} />
</SettingRow>
</SettingGroup>
{/* Change Password Dialog */}
<ChangeMemberPasswordDialog open={passwordDialog.isOpen} onOpenChange={passwordDialog.setOpen} user={user} />
<SettingGroup title={t("setting.access-token")} showSeparator>
<AccessTokenSection />
</SettingGroup>
</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 useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
import { locales } from "@/i18n";
import { cn } from "@/lib/utils";
import { Routes } from "@/router";
import { getLocaleDisplayName, getLocaleWithFallback, loadLocale, useTranslate } from "@/utils/i18n";
import { getThemeWithFallback, loadTheme, THEME_OPTIONS } from "@/utils/theme";
import { useTranslate } from "@/utils/i18n";
import UserAvatar from "./UserAvatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
......@@ -28,40 +22,7 @@ const UserMenu = (props: Props) => {
const t = useTranslate();
const navigateTo = useNavigateTo();
const currentUser = useCurrentUser();
const { userGeneralSetting, refetchSettings, 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 { logout } = useAuth();
const handleSignOut = async () => {
// First, clear auth state and cache BEFORE doing anything else
......@@ -114,36 +75,6 @@ const UserMenu = (props: Props) => {
<ArchiveIcon className="size-4 text-muted-foreground" />
{t("common.archived")}
</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)}>
<SettingsIcon className="size-4 text-muted-foreground" />
{t("common.settings")}
......
......@@ -39,7 +39,10 @@ type ApiMemo = {
content: string;
visibility?: string | null;
tags?: string[];
creator_id?: number;
creator_id?: number | string;
create_time?: string;
update_time?: string;
display_time?: string;
};
type ApiAttachment = {
......@@ -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 content = raw.content || "";
return {
console.log("DEBUG: memoFromApi input:", raw);
const result = {
name: `memos/${raw.id}`,
state: State.NORMAL,
creator: `users/${raw.creator_id ?? 1}`,
......@@ -214,7 +228,12 @@ const memoFromApi = (raw: ApiMemo): Memo => {
hasIncompleteTasks: false,
},
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 => {
......
import { useAuth } from "@/contexts/AuthContext";
import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
const useCurrentUser = () => {
const { currentUser } = useAuth();
return currentUser;
// Mock user for testing without backend/auth
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;
import { Suspense, useEffect, useMemo } from "react";
import { Outlet, useLocation, useSearchParams, Link } from "react-router-dom";
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 ChatbotWidget from "@/components/ChatbotWidget";
import Spinner from "@/components/Spinner";
......@@ -22,11 +22,11 @@ const RootLayout = () => {
const pathname = useMemo(() => location.pathname, [location.pathname]);
const prevPathname = usePrevious(pathname);
useEffect(() => {
if (!currentUser && memoRelatedSetting.disallowPublicVisibility) {
redirectOnAuthFailure();
}
}, [currentUser, memoRelatedSetting.disallowPublicVisibility]);
// useEffect(() => {
// if (!currentUser && memoRelatedSetting.disallowPublicVisibility) {
// redirectOnAuthFailure();
// }
// }, [currentUser, memoRelatedSetting.disallowPublicVisibility]);
useEffect(() => {
// When the route changes and there is no filter in the search params, remove all filters
......@@ -59,7 +59,7 @@ const RootLayout = () => {
<Outlet />
</Suspense>
</main>
<div className="fixed top-4 right-4 z-50">
{/* <div className="fixed top-4 right-4 z-50">
<SignedOut>
<Link
to="/auth"
......@@ -71,7 +71,7 @@ const RootLayout = () => {
<SignedIn>
<UserButton />
</SignedIn>
</div>
</div> */}
<ChatbotWidget />
</div>
);
......
import "@github/relative-time-element";
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 { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast";
......@@ -52,6 +52,7 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
}
function Main() {
/*
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined;
if (!publishableKey) {
return (
......@@ -73,10 +74,16 @@ VITE_API_BASE_URL=http://localhost:5000
</div>
);
}
*/
return (
<ErrorBoundary>
<ClerkProvider publishableKey={publishableKey}>
{/* <ClerkProvider
publishableKey={publishableKey}
signInUrl="/auth"
signUpUrl="/auth?mode=signup"
fallbackRedirectUrl="/app"
> */}
<QueryClientProvider client={queryClient}>
<InstanceProvider>
<AuthProvider>
......@@ -89,7 +96,7 @@ VITE_API_BASE_URL=http://localhost:5000
</AuthProvider>
</InstanceProvider>
</QueryClientProvider>
</ClerkProvider>
{/* </ClerkProvider> */}
</ErrorBoundary>
);
}
......
import { ArchiveIcon } from "lucide-react";
import { MemoRenderContext } from "@/components/MasonryView";
import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import PagedMemoList from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
const Archived = () => {
const t = useTranslate();
const md = useMediaQuery("md");
const user = useCurrentUser();
// Build filter using unified hook (no shortcuts or pinned filter)
......@@ -23,6 +29,17 @@ const Archived = () => {
});
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">
{!md && <MobileHeader />}
<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">
<div className="w-full px-4 py-4 border-b border-border">
<div className="flex flex-row items-center gap-2">
<ArchiveIcon className="w-5 h-auto text-muted-foreground" />
<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} />
......@@ -32,6 +49,10 @@ const 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 { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import "./Auth.css";
const AuthPage = () => {
const navigate = useNavigate();
const { isSignedIn } = useAuth();
const [searchParams] = useSearchParams();
const mode = searchParams.get("mode"); // "signup" or null (signin)
// Nếu đã đăng nhập rồi thì đưa về trang chủ
useEffect(() => {
// nhỏ gọn: SignedIn phía dưới cũng handle, đây chỉ là fallback
}, [navigate]);
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="w-full min-h-svh flex items-center justify-center bg-background px-4">
<div className="max-w-md w-full flex flex-col items-center gap-6">
<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>
{/* Content */}
<div className="auth-container">
{/* Left Side - Branding */}
<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>
{/* Right Side - Auth Form */}
<div className="auth-form-container">
<SignedOut>
{mode === "signup" ? (
<SignUp
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"
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"
redirectUrl="/"
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="rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90"
onClick={() => navigate("/")}
className="auth-go-to-app"
onClick={() => navigate("/app")}
>
You are already signed in – Go to 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>
{/* 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>
);
};
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 { sortBy } from "lodash-es";
import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react";
import { BellIcon, InboxIcon } from "lucide-react";
import { useState } from "react";
import Empty from "@/components/Empty";
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
......@@ -14,7 +14,7 @@ import { useTranslate } from "@/utils/i18n";
const Inboxes = () => {
const t = useTranslate();
const md = useMediaQuery("md");
const [filter, setFilter] = useState<"all" | "unread" | "archived">("all");
const [filter, setFilter] = useState<"all" | "unread">("all");
// Fetch notifications with React Query
const { data: fetchedNotifications = [] } = useNotifications();
......@@ -25,12 +25,11 @@ const Inboxes = () => {
const notifications = allNotifications.filter((notification) => {
if (filter === "unread") return notification.status === UserNotification_Status.UNREAD;
if (filter === "archived") return notification.status === UserNotification_Status.ARCHIVED;
return true;
return notification.status !== UserNotification_Status.ARCHIVED;
});
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 (
<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 = () => {
<InboxIcon className="w-3.5 h-auto" />
{t("inbox.unread")} ({unreadCount})
</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>
......@@ -99,7 +86,7 @@ const Inboxes = () => {
<div className="w-full py-16 flex flex-col justify-center items-center">
<Empty />
<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>
</div>
) : (
......
This diff is collapsed.
This diff is collapsed.
......@@ -6,18 +6,21 @@ import Spinner from "@/components/Spinner";
import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout";
import Home from "@/pages/Home";
import NotFound from "@/pages/NotFound";
const Archived = lazy(() => import("@/pages/Archived"));
const Explore = lazy(() => import("@/pages/Explore"));
const Inboxes = lazy(() => import("@/pages/Inboxes"));
const MemoDetail = lazy(() => import("@/pages/MemoDetail"));
const NotFound = lazy(() => import("@/pages/NotFound"));
const PermissionDenied = lazy(() => import("@/pages/PermissionDenied"));
const Attachments = lazy(() => import("@/pages/Attachments"));
const Setting = lazy(() => import("@/pages/Setting"));
const UserProfile = lazy(() => import("@/pages/UserProfile"));
const MemoDetailRedirect = lazy(() => import("./MemoDetailRedirect"));
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";
......@@ -42,7 +45,15 @@ const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <NotFound />,
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,
element: <RootLayout />,
......@@ -51,15 +62,14 @@ const router = createBrowserRouter([
element: <MainLayout />,
children: [
{ path: "", element: <Home /> },
{ path: Routes.EXPLORE, element: <LazyRoute component={Explore} /> },
{ path: Routes.ARCHIVED, element: <LazyRoute component={Archived} /> },
{ path: "explore", element: <LazyRoute component={Explore} /> },
{ path: "u/:username", element: <LazyRoute component={UserProfile} /> },
],
},
{ path: Routes.AUTH, element: <LazyRoute component={AuthPage} /> },
{ path: Routes.ATTACHMENTS, element: <LazyRoute component={Attachments} /> },
{ path: Routes.INBOX, element: <LazyRoute component={Inboxes} /> },
{ path: Routes.SETTING, element: <LazyRoute component={Setting} /> },
{ path: "attachments", element: <LazyRoute component={Attachments} /> },
{ path: "archived", element: <LazyRoute component={Archived} /> },
{ path: "inbox", element: <LazyRoute component={Inboxes} /> },
{ path: "setting", element: <LazyRoute component={Setting} /> },
{ path: "memos/:uid", element: <LazyRoute component={MemoDetail} /> },
// Redirect old path to new path
{ path: "m/:uid", element: <LazyRoute component={MemoDetailRedirect} /> },
......@@ -68,6 +78,7 @@ const router = createBrowserRouter([
{ path: "*", element: <LazyRoute component={NotFound} /> },
],
},
{ path: "*", element: <LazyRoute component={NotFound} /> },
],
},
]);
......
export const ROUTES = {
ROOT: "/",
ATTACHMENTS: "/attachments",
INBOX: "/inbox",
ARCHIVED: "/archived",
SETTING: "/setting",
EXPLORE: "/explore",
ROOT: "/app",
LANDING: "/",
AUTH: "/auth",
ATTACHMENTS: "/app/attachments",
INBOX: "/app/inbox",
ARCHIVED: "/app/archived",
SETTING: "/app/setting",
EXPLORE: "/app/explore",
} as const;
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