0.29.1原版

This commit is contained in:
anian
2026-07-02 19:14:14 +08:00
commit d94008f0fb
947 changed files with 174905 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
import { ExternalLinkIcon } from "lucide-react";
import { useEffect, useState } from "react";
import TileSpriteStrip from "@/components/Placeholder/TileSpriteStrip";
import { TILE_SPRITES, type TileSprite } from "@/components/Placeholder/tileSprites";
import SettingGroup from "@/components/Settings/SettingGroup";
import SettingSection from "@/components/Settings/SettingSection";
import { Button } from "@/components/ui/button";
const SPRITE_SCALE = 2;
const PRODUCT_LINKS = [
{ label: "Website", href: "https://usememos.com/" },
{ label: "GitHub", href: "https://github.com/usememos/memos" },
{ label: "Docs", href: "https://usememos.com/docs" },
];
const PRODUCT_POINTS = ["Open. Write. Done.", "Markdown-native.", "Fully yours."];
const SPONSORS = [
{
label: "CodeRabbit",
href: "https://coderabbit.link/usememos",
description: "Cut code review time & bugs in half, instantly.",
lightLogo: "https://victorious-bubble-f69a016683.media.strapiapp.com/Orange_Typemark_43bf516c9d.svg",
darkLogo: "https://victorious-bubble-f69a016683.media.strapiapp.com/White_Typemark_79b9189d19.svg",
},
{
label: "Warp",
href: "https://go.warp.dev/memos",
description: "The agentic development environment.",
lightLogo: "https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png",
darkLogo: "https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-White.png",
},
];
type Sponsor = (typeof SPONSORS)[number];
const isDarkThemeName = (theme: string | null): boolean => {
return theme?.endsWith("-dark") || theme?.endsWith(".dark") || false;
};
const getCurrentThemeUsesDarkLogo = (): boolean => {
if (typeof document === "undefined") {
return false;
}
return isDarkThemeName(document.documentElement.getAttribute("data-theme"));
};
const BirdSprite = ({ sprite }: { sprite: TileSprite }) => {
return (
<figure className="flex w-auto min-w-28 flex-none flex-col items-center gap-3 rounded-xl border border-border bg-muted/20 px-4 py-4 text-center">
<TileSpriteStrip sprite={sprite} scale={SPRITE_SCALE} className="size-16" testId="about-bird-sprite" />
<figcaption className="min-w-0">
<h3 className="font-mono text-sm text-foreground">{sprite.name}</h3>
</figcaption>
</figure>
);
};
const SponsorLogo = ({ sponsor }: { sponsor: Sponsor }) => {
const [usesDarkLogo, setUsesDarkLogo] = useState(getCurrentThemeUsesDarkLogo);
useEffect(() => {
const updateLogoTheme = () => setUsesDarkLogo(getCurrentThemeUsesDarkLogo());
updateLogoTheme();
const observer = new MutationObserver(updateLogoTheme);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
return () => observer.disconnect();
}, []);
return (
<img
className="h-9 max-w-44 object-contain object-left"
src={usesDarkLogo ? sponsor.darkLogo : sponsor.lightLogo}
alt={sponsor.label}
/>
);
};
const About = () => {
return (
<section className="mx-auto w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
<div className="w-full px-4 sm:px-6">
<div className="w-full rounded-xl border border-border bg-background px-4 py-4 text-muted-foreground">
<SettingSection
title="About Memos"
description="Open-source, self-hosted note-taking built for quick capture: Markdown-native, lightweight, and fully yours."
>
<SettingGroup>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<img className="size-12 shrink-0 select-none rounded-md" src="/logo.webp" alt="" draggable={false} />
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Memos</h1>
<p className="mt-1 text-sm text-muted-foreground">Capture first. Keep it yours.</p>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{PRODUCT_LINKS.map((link) => (
<Button key={link.href} asChild variant="outline" size="lg">
<a href={link.href} target="_blank" rel="noreferrer">
{link.label}
<ExternalLinkIcon className="size-3.5" />
</a>
</Button>
))}
</div>
</div>
</SettingGroup>
<SettingGroup
showSeparator
title="Product"
description="A small timeline for notes that should be saved now and organized later."
>
<div className="grid gap-3 sm:grid-cols-3">
{PRODUCT_POINTS.map((item) => (
<div key={item} className="rounded-lg bg-muted/40 px-3 py-2 text-sm text-foreground">
{item}
</div>
))}
</div>
</SettingGroup>
<SettingGroup showSeparator title="Sponsors" description="Featured sponsors helping keep Memos open-source and independent.">
<section aria-label="Sponsors" className="grid gap-3 sm:grid-cols-2">
{SPONSORS.map((sponsor) => (
<a
key={sponsor.href}
href={sponsor.href}
target="_blank"
rel="noreferrer"
className="group flex min-h-32 min-w-0 flex-col justify-between gap-5 rounded-lg border border-border bg-muted/20 px-4 py-4 text-muted-foreground transition-colors hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="flex min-w-0 items-start justify-between gap-4">
<SponsorLogo sponsor={sponsor} />
<ExternalLinkIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div className="flex min-w-0 flex-col gap-3">
<p className="text-sm leading-6 text-muted-foreground">{sponsor.description}</p>
<span className="text-xs font-medium text-foreground transition-colors group-hover:text-primary">
Visit {sponsor.label}
</span>
</div>
</a>
))}
</section>
</SettingGroup>
<SettingGroup showSeparator title="Birds" description="Pixel tile strips used by empty states.">
<section aria-label="Birds" className="flex flex-row flex-wrap gap-3">
{TILE_SPRITES.map((sprite) => (
<BirdSprite key={sprite.name} sprite={sprite} />
))}
</section>
</SettingGroup>
</SettingSection>
</div>
</div>
</section>
);
};
export default About;
+23
View File
@@ -0,0 +1,23 @@
import AuthFooter from "@/components/AuthFooter";
import PasswordSignInForm from "@/components/PasswordSignInForm";
import { useInstance } from "@/contexts/InstanceContext";
const AdminSignIn = () => {
const { generalSetting: instanceGeneralSetting } = useInstance();
return (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="w-full flex flex-row justify-center items-center mb-6">
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
<p className="w-full text-xl font-medium text-muted-foreground">Sign in with admin accounts</p>
<PasswordSignInForm />
</div>
<AuthFooter />
</div>
);
};
export default AdminSignIn;
+35
View File
@@ -0,0 +1,35 @@
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Archived = () => {
const user = useCurrentUser();
// Build filter using unified hook (no shortcuts or pinned filter)
const memoFilter = useMemoFilters({
creatorName: user?.name,
includeShortcuts: false,
includePinned: false,
});
// Get sorting logic using unified hook (pinned first, archived state)
const { listSort, orderBy } = useMemoSorting({
pinnedFirst: true,
state: State.ARCHIVED,
});
return (
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility compact />}
listSort={listSort}
state={State.ARCHIVED}
orderBy={orderBy}
filter={memoFilter}
/>
);
};
export default Archived;
+221
View File
@@ -0,0 +1,221 @@
import { create } from "@bufbuild/protobuf";
import { LoaderCircleIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import {
AttachmentAudioRows,
AttachmentDocumentRows,
AttachmentLibraryEmptyState,
AttachmentLibraryErrorState,
AttachmentLibrarySkeletonGrid,
AttachmentLibraryToolbar,
AttachmentLibraryUnusedPanel,
AttachmentMediaGrid,
AttachmentUnusedRows,
} from "@/components/AttachmentLibrary";
import ConfirmDialog from "@/components/ConfirmDialog";
import MobileHeader from "@/components/MobileHeader";
import PreviewImageDialog from "@/components/PreviewImageDialog";
import { Button } from "@/components/ui/button";
import { attachmentServiceClient } from "@/connect";
import { type AttachmentLibraryStats, type AttachmentLibraryTab, useAttachmentLibrary } from "@/hooks/useAttachmentLibrary";
import { useBatchDeleteAttachments } from "@/hooks/useAttachmentQueries";
import useDialog from "@/hooks/useDialog";
import useMediaQuery from "@/hooks/useMediaQuery";
import i18n from "@/i18n";
import { handleError } from "@/lib/error";
import { ListAttachmentsRequestSchema } from "@/types/proto/api/v1/attachment_service_pb";
import { useTranslate } from "@/utils/i18n";
const UNUSED_PAGE_SIZE = 1000;
const BATCH_DELETE_SIZE = 100;
const TAB_COUNT_SELECTOR = {
audio: (stats: AttachmentLibraryStats) => stats.audio,
documents: (stats: AttachmentLibraryStats) => stats.documents,
media: (stats: AttachmentLibraryStats) => stats.media,
} as const;
const chunkNames = (names: string[], size: number) => {
const chunks: string[][] = [];
for (let index = 0; index < names.length; index += size) {
chunks.push(names.slice(index, index + size));
}
return chunks;
};
const listUnusedAttachmentNames = async () => {
const names: string[] = [];
let pageToken = "";
do {
const response = await attachmentServiceClient.listAttachments(
create(ListAttachmentsRequestSchema, {
filter: "memo_id == null",
pageSize: UNUSED_PAGE_SIZE,
pageToken,
}),
);
names.push(...response.attachments.map((attachment) => attachment.name));
pageToken = response.nextPageToken;
} while (pageToken);
return names;
};
const Attachments = () => {
const t = useTranslate();
const md = useMediaQuery("md");
const deleteUnusedAttachmentsDialog = useDialog();
const [activeTab, setActiveTab] = useState<AttachmentLibraryTab>("media");
const [previewState, setPreviewState] = useState({ open: false, initialIndex: 0 });
const [showUnusedSection, setShowUnusedSection] = useState(false);
const { mutateAsync: batchDeleteAttachments, isPending: isDeletingUnused } = useBatchDeleteAttachments();
const {
audioItems,
documentItems,
error,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
mediaGroups,
mediaPreviewItems,
refetch,
stats,
unusedItems,
} = useAttachmentLibrary(i18n.language);
const currentItemsCount = useMemo(() => TAB_COUNT_SELECTOR[activeTab](stats), [activeTab, stats]);
useEffect(() => {
if (stats.unused === 0) {
setShowUnusedSection(false);
}
}, [stats.unused]);
const handlePreview = (itemId: string) => {
const initialIndex = mediaPreviewItems.findIndex((item) => item.id === itemId);
setPreviewState({ open: true, initialIndex: initialIndex >= 0 ? initialIndex : 0 });
};
const handleDeleteUnusedAttachments = async () => {
try {
const names = await listUnusedAttachmentNames();
if (names.length === 0) {
await refetch();
return;
}
for (const chunk of chunkNames(names, BATCH_DELETE_SIZE)) {
await batchDeleteAttachments(chunk);
}
toast.success(t("resource.delete-all-unused-success"));
await refetch();
} catch (deleteError) {
handleError(deleteError, toast.error, {
context: "Failed to delete unused attachments",
fallbackMessage: t("resource.delete-all-unused-error"),
});
}
};
const renderContent = () => {
if (isLoading) {
return <AttachmentLibrarySkeletonGrid />;
}
if (isError) {
return <AttachmentLibraryErrorState error={error instanceof Error ? error : undefined} onRetry={() => refetch()} />;
}
if (currentItemsCount === 0) {
return <AttachmentLibraryEmptyState tab={activeTab} />;
}
if (activeTab === "media") {
return <AttachmentMediaGrid groups={mediaGroups} onPreview={handlePreview} />;
}
if (activeTab === "documents") {
return <AttachmentDocumentRows items={documentItems} />;
}
if (activeTab === "audio") {
return <AttachmentAudioRows items={audioItems} />;
}
return null;
};
return (
<section className="@container w-full min-h-full pb-10 sm:pt-3 md:pt-6">
{!md && <MobileHeader />}
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5 px-4 sm:gap-6 sm:px-6">
<AttachmentLibraryToolbar activeTab={activeTab} onTabChange={setActiveTab} stats={stats} />
{stats.unused > 0 && (
<AttachmentLibraryUnusedPanel
count={stats.unused}
isDeleting={isDeletingUnused}
isExpanded={showUnusedSection}
onDelete={() => deleteUnusedAttachmentsDialog.open()}
onToggle={() => setShowUnusedSection((state) => !state)}
/>
)}
<div className="min-h-[16rem] pt-1">
{renderContent()}
{hasNextPage && (
<div className="mt-6 flex justify-center">
<Button variant="outline" className="rounded-full px-4" onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : null}
{isFetchingNextPage ? t("resource.fetching-data") : t("memo.load-more")}
</Button>
</div>
)}
{!isLoading && isFetching && !isFetchingNextPage && (
<div className="mt-4 text-center text-xs text-muted-foreground">{t("resource.fetching-data")}</div>
)}
</div>
{showUnusedSection && stats.unused > 0 && (
<div className="space-y-4 pt-1">
<div className="text-sm font-medium text-foreground">{t("attachment-library.unused.title")}</div>
<AttachmentUnusedRows items={unusedItems} />
</div>
)}
</div>
<ConfirmDialog
open={deleteUnusedAttachmentsDialog.isOpen}
onOpenChange={deleteUnusedAttachmentsDialog.setOpen}
title={t("resource.delete-all-unused-confirm")}
description={t("attachment-library.unused.confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDeleteUnusedAttachments}
confirmVariant="destructive"
/>
<PreviewImageDialog
open={previewState.open}
onOpenChange={(open) => setPreviewState((prev) => ({ ...prev, open }))}
items={mediaPreviewItems}
initialIndex={previewState.initialIndex}
/>
</section>
);
};
export default Attachments;
+149
View File
@@ -0,0 +1,149 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import { authServiceClient, userServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { absolutifyLink } from "@/helpers/utils";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { getSafeRedirectPath } from "@/utils/auth-redirect";
import { validateOAuthState } from "@/utils/oauth";
interface State {
loading: boolean;
errorMessage: string;
}
const AuthCallback = () => {
const navigateTo = useNavigateTo();
const { currentUser, initialize, isInitialized } = useAuth();
const [searchParams] = useSearchParams();
const handledRef = useRef(false);
const [state, setState] = useState<State>({
loading: true,
errorMessage: "",
});
useEffect(() => {
if (!isInitialized) {
return;
}
if (handledRef.current) {
return;
}
// Check for OAuth error response first (e.g., user denied access)
const error = searchParams.get("error");
const errorDescription = searchParams.get("error_description");
const errorUri = searchParams.get("error_uri");
if (error) {
// OAuth provider returned an error
let errorMessage = `OAuth error: ${error}`;
if (errorDescription) {
errorMessage += `\n${decodeURIComponent(errorDescription)}`;
}
if (errorUri) {
errorMessage += `\nMore info: ${errorUri}`;
}
setState({
loading: false,
errorMessage,
});
return;
}
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) {
setState({
loading: false,
errorMessage: "Failed to authorize. Missing authorization code or state parameter.",
});
return;
}
// Validate OAuth state (CSRF protection) and retrieve PKCE code_verifier
const validatedState = validateOAuthState(state);
if (!validatedState) {
setState({
loading: false,
errorMessage: "Failed to authorize. Invalid or expired state parameter. This may indicate a CSRF attack attempt.",
});
return;
}
const { flowMode, identityProviderName, returnUrl, linkingUserName, codeVerifier } = validatedState;
const redirectUri = absolutifyLink("/auth/callback");
handledRef.current = true;
(async () => {
try {
if (flowMode === "link") {
if (!currentUser?.name) {
throw new Error("Failed to link account. Please sign in to Memos again and retry.");
}
if (linkingUserName && currentUser.name !== linkingUserName) {
throw new Error("The signed-in user changed before the OAuth callback completed. Please retry linking from account settings.");
}
await userServiceClient.createLinkedIdentity({
parent: currentUser.name,
idpName: identityProviderName,
code,
redirectUri,
codeVerifier: codeVerifier || "",
});
} else {
const response = await authServiceClient.signIn({
credentials: {
case: "ssoCredentials",
value: {
idpName: identityProviderName,
code,
redirectUri,
codeVerifier: codeVerifier || "", // Pass PKCE code_verifier for token exchange
},
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
}
setState({
loading: false,
errorMessage: "",
});
await initialize();
// Defense-in-depth: even though `returnUrl` was sanitized before being
// stored (see storeOAuthState in SignIn), re-validate on the way out so
// a corrupted state entry can never be used for an open redirect.
navigateTo(getSafeRedirectPath(returnUrl) ?? ROUTES.HOME);
} catch (error: unknown) {
handleError(error, () => {}, {
fallbackMessage: "Failed to authenticate.",
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to authenticate.";
setState({
loading: false,
errorMessage: message,
});
},
});
}
})();
}, [currentUser?.name, initialize, isInitialized, navigateTo, searchParams]);
if (state.loading) return null;
return (
<div className="p-4 py-24 w-full h-full flex justify-center items-center">
<div className="max-w-lg font-mono whitespace-pre-wrap opacity-80">{state.errorMessage}</div>
</div>
);
};
export default AuthCallback;
+41
View File
@@ -0,0 +1,41 @@
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
const Explore = () => {
const currentUser = useCurrentUser();
// Determine visibility filter based on authentication status
// - Logged-in users: Can see PUBLIC and PROTECTED memos
// - Visitors: Can only see PUBLIC memos
// Note: The backend is responsible for filtering stats based on visibility permissions.
const visibilities = currentUser ? [Visibility.PUBLIC, Visibility.PROTECTED] : [Visibility.PUBLIC];
// Build filter using unified hook (no creator scoping for Explore)
const memoFilter = useMemoFilters({
includeShortcuts: false,
includePinned: false,
visibilities,
});
// Get sorting logic using unified hook (no pinned sorting)
const { listSort, orderBy } = useMemoSorting({
pinnedFirst: false,
state: State.NORMAL,
});
return (
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showCreator showVisibility compact />}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
showCreator
/>
);
};
export default Explore;
+38
View File
@@ -0,0 +1,38 @@
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
const Home = () => {
const user = useCurrentUser();
const { isInitialized } = useInstance();
const memoFilter = useMemoFilters({
creatorName: user?.name,
includeShortcuts: true,
includePinned: true,
});
const { listSort, orderBy } = useMemoSorting({
pinnedFirst: true,
state: State.NORMAL,
});
return (
<div className="w-full min-h-full bg-background text-foreground">
<PagedMemoList
renderer={(memo: Memo) => <MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact />}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
enabled={isInitialized}
showMemoEditor
/>
</div>
);
};
export default Home;
+124
View File
@@ -0,0 +1,124 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { sortBy } from "lodash-es";
import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react";
import { useState } from "react";
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
import MemoMentionMessage from "@/components/Inbox/MemoMentionMessage";
import MobileHeader from "@/components/MobileHeader";
import Placeholder from "@/components/Placeholder";
import useMediaQuery from "@/hooks/useMediaQuery";
import { useNotifications } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { UserNotification, UserNotification_Status, UserNotification_Type } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
const Inboxes = () => {
const t = useTranslate();
const md = useMediaQuery("md");
const [filter, setFilter] = useState<"all" | "unread" | "archived">("all");
// Fetch notifications with React Query
const { data: fetchedNotifications = [] } = useNotifications();
const allNotifications = sortBy(fetchedNotifications, (notification: UserNotification) => {
return -((notification.createTime ? timestampDate(notification.createTime) : undefined)?.getTime() || 0);
});
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;
});
const unreadCount = allNotifications.filter((n) => n.status === UserNotification_Status.UNREAD).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">
{!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">
{/* Header */}
<div className="w-full px-4 py-4 border-b border-border">
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row items-center gap-2">
<BellIcon className="w-5 h-auto text-muted-foreground" />
<h1 className="text-xl font-semibold">{t("common.inbox")}</h1>
{unreadCount > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs font-medium rounded-full bg-primary text-primary-foreground">
{unreadCount}
</span>
)}
</div>
</div>
</div>
{/* Filter Tabs */}
<div className="w-full px-4 py-2 border-b border-border bg-muted/30">
<div className="flex flex-row gap-1">
<button
onClick={() => setFilter("all")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
filter === "all"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
{t("common.all")} ({allNotifications.length})
</button>
<button
onClick={() => setFilter("unread")}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5",
filter === "unread"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
)}
>
<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>
{/* Notifications List */}
<div className="w-full">
{notifications.length === 0 ? (
<Placeholder
variant="empty"
message={filter === "unread" ? t("inbox.no-unread") : filter === "archived" ? t("inbox.no-archived") : t("message.no-data")}
/>
) : (
<div className="flex flex-col">
{notifications.map((notification: UserNotification) => {
if (notification.type === UserNotification_Type.MEMO_COMMENT) {
return <MemoCommentMessage key={notification.name} notification={notification} />;
}
if (notification.type === UserNotification_Type.MEMO_MENTION) {
return <MemoMentionMessage key={notification.name} notification={notification} />;
}
return null;
})}
</div>
)}
</div>
</div>
</div>
</section>
);
};
export default Inboxes;
+126
View File
@@ -0,0 +1,126 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { ArrowUpLeftFromCircleIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, Navigate, useLocation, useParams } from "react-router-dom";
import MemoCommentSection from "@/components/MemoCommentSection";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
import MemoView from "@/components/MemoView";
import MobileHeader from "@/components/MobileHeader";
import { memoNamePrefix } from "@/helpers/resource-names";
import useMediaQuery from "@/hooks/useMediaQuery";
import useMemoDetailError from "@/hooks/useMemoDetailError";
import { useMemo, useMemoComments } from "@/hooks/useMemoQueries";
import { useSharedMemo, withShareAttachmentLinks } from "@/hooks/useMemoShareQueries";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
const MemoDetail = () => {
const md = useMediaQuery("md");
const [shareImageDialogOpen, setShareImageDialogOpen] = useState(false);
const params = useParams();
const location = useLocation();
const { state: locationState, hash } = location;
// Detect share mode from the route parameter.
const shareToken = params.token;
const isShareMode = !!shareToken;
// Primary memo fetch — share token or direct name.
const memoNameFromParams = params.uid ? `${memoNamePrefix}${params.uid}` : "";
const {
data: memoFromDirect,
error: directError,
isLoading: directLoading,
} = useMemo(memoNameFromParams, { enabled: !isShareMode && !!memoNameFromParams });
const { data: memoFromShare, error: shareError, isLoading: shareLoading } = useSharedMemo(shareToken ?? "", { enabled: isShareMode });
const memo = isShareMode ? memoFromShare : memoFromDirect;
const error = isShareMode ? shareError : directError;
const isLoading = isShareMode ? shareLoading : directLoading;
const memoName = memo?.name ?? memoNameFromParams;
useMemoDetailError({
error: error as Error | null,
});
const { data: parentMemo } = useMemo(memo?.parent || "", {
enabled: !!memo?.parent,
});
const { data: commentsResponse } = useMemoComments(memoName, {
enabled: !!memo,
});
const comments = commentsResponse?.memos || [];
useEffect(() => {
if (!hash || comments.length === 0) return;
const el = document.getElementById(hash.slice(1));
el?.scrollIntoView({ behavior: "smooth", block: "center" });
}, [hash, comments]);
if (isShareMode) {
const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated);
if (isNotFound || (!isLoading && !memo)) {
return <Navigate to="/404" replace />;
}
}
if (isLoading || !memo) {
return null;
}
// In share mode, rewrite attachment URLs to include the share token for unauthenticated access.
const displayMemo = isShareMode
? { ...memo, attachments: withShareAttachmentLinks(memo.attachments as Attachment[], shareToken!) }
: memo;
const mentionResolutionContents = [displayMemo.content, ...comments.map((comment) => comment.content)];
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>
<MemoDetailSidebarDrawer memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
</MobileHeader>
)}
<MentionResolutionProvider contents={mentionResolutionContents}>
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
<div className={cn("w-full md:w-[calc(100%-15rem)]")}>
{parentMemo && (
<div className="w-auto inline-block mb-2">
<Link
className="px-3 py-1 border border-border rounded-lg max-w-xs w-auto text-sm flex flex-row justify-start items-center flex-nowrap text-muted-foreground hover:shadow hover:opacity-80"
to={`/${parentMemo.name}`}
state={locationState}
viewTransition
>
<ArrowUpLeftFromCircleIcon className="w-4 h-auto shrink-0 opacity-60 mr-2" />
<span className="truncate">{parentMemo.content}</span>
</Link>
</div>
)}
<MemoView
key={`${displayMemo.name}-${displayMemo.updateTime}`}
memo={displayMemo}
compact={false}
parentPage={locationState?.from}
shareImageDialogOpen={shareImageDialogOpen}
showCreator
showVisibility
showPinned
onShareImageDialogOpenChange={setShareImageDialogOpen}
/>
<MemoCommentSection memo={displayMemo} comments={comments} parentPage={locationState?.from} />
</div>
{md && (
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
<MemoDetailSidebar className="py-6" memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
</div>
)}
</div>
</MentionResolutionProvider>
</section>
);
};
export default MemoDetail;
+15
View File
@@ -0,0 +1,15 @@
import MobileHeader from "@/components/MobileHeader";
const NotFound = () => {
return (
<section className="@container w-full max-w-5xl min-h-svh flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
<MobileHeader />
<div className="w-full px-4 grow flex flex-col justify-center items-center sm:px-6">
<p className="font-medium">{"The page you are looking for can't be found."}</p>
<p className="mt-4 text-[8rem] font-mono text-foreground">404</p>
</div>
</section>
);
};
export default NotFound;
+15
View File
@@ -0,0 +1,15 @@
import MobileHeader from "@/components/MobileHeader";
const PermissionDenied = () => {
return (
<section className="@container w-full max-w-5xl min-h-svh flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
<MobileHeader />
<div className="w-full px-4 grow flex flex-col justify-center items-center sm:px-6">
<p className="font-medium">Permission denied</p>
<p className="mt-4 text-[8rem] font-mono text-foreground">403</p>
</div>
</section>
);
};
export default PermissionDenied;
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import MobileHeader from "@/components/MobileHeader";
import SectionMenuItem from "@/components/Settings/SectionMenuItem";
import {
DEFAULT_SETTING_SECTION,
isSettingSectionKey,
SETTINGS_SECTIONS,
type SettingSectionDefinition,
type SettingSectionKey,
} from "@/components/Settings/settingSections";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery";
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
const isCommitSha = (commit: string) => /^[0-9a-f]{7,40}$/i.test(commit);
const Setting = () => {
const t = useTranslate();
const sm = useMediaQuery("sm");
const location = useLocation();
const user = useCurrentUser();
const { profile, fetchSettings } = useInstance();
const [selectedSection, setSelectedSection] = useState<SettingSectionKey>(DEFAULT_SETTING_SECTION);
const isHost = user?.role === User_Role.ADMIN;
const commitUrl = isCommitSha(profile.commit) ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
const sectionGroups = useMemo(() => {
const visibleSections = SETTINGS_SECTIONS.filter((section) => section.scope === "basic" || isHost);
return {
basic: visibleSections.filter((section) => section.scope === "basic"),
admin: visibleSections.filter((section) => section.scope === "admin"),
all: visibleSections,
};
}, [isHost]);
const visibleSectionKeys = useMemo(() => new Set(sectionGroups.all.map((section) => section.key)), [sectionGroups.all]);
useEffect(() => {
const hash = location.hash.slice(1);
const nextSection = isSettingSectionKey(hash) && visibleSectionKeys.has(hash) ? hash : DEFAULT_SETTING_SECTION;
setSelectedSection(nextSection);
}, [location.hash, visibleSectionKeys]);
useEffect(() => {
if (!isHost) {
return;
}
const preloadSettingKeys = new Set(sectionGroups.admin.flatMap((section) => section.preloadSettingKeys ?? []));
void fetchSettings([...preloadSettingKeys]);
}, [fetchSettings, isHost, sectionGroups.admin]);
const handleSectionSelectorItemClick = (section: SettingSectionKey) => {
window.location.hash = section;
};
const selectedSectionDefinition =
sectionGroups.all.find((section) => section.key === selectedSection) ??
SETTINGS_SECTIONS.find((section) => section.key === DEFAULT_SETTING_SECTION) ??
SETTINGS_SECTIONS[0];
const ActiveSection = selectedSectionDefinition.component;
const renderSectionMenuItems = (sections: SettingSectionDefinition[]) =>
sections.map((section) => (
<SectionMenuItem
key={section.key}
text={t(section.labelKey)}
icon={section.icon}
isSelected={selectedSection === section.key}
onClick={() => handleSectionSelectorItemClick(section.key)}
/>
));
return (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
{!sm && <MobileHeader />}
<div className="w-full px-4 sm:px-6">
<div className="w-full border border-border flex flex-row justify-start items-start px-4 py-3 rounded-xl bg-background text-muted-foreground">
{sm && (
<div className="flex flex-col justify-start items-start w-40 h-auto shrink-0 py-2">
<span className="text-sm mt-0.5 pl-3 font-mono select-none text-muted-foreground">{t("common.basic")}</span>
<div className="w-full flex flex-col justify-start items-start mt-1">{renderSectionMenuItems(sectionGroups.basic)}</div>
{isHost && (
<>
<span className="text-sm mt-4 pl-3 font-mono select-none text-muted-foreground">{t("common.admin")}</span>
<div className="w-full flex flex-col justify-start items-start mt-1">
{renderSectionMenuItems(sectionGroups.admin)}
<div className="px-3 mt-2 opacity-70 text-sm leading-5">
{t("setting.version")}: {profile.version}
{profile.commit && (
<span className="block font-mono break-all">
Commit:{" "}
{commitUrl ? (
<a className="underline hover:text-foreground" href={commitUrl} target="_blank" rel="noreferrer">
{profile.commit}
</a>
) : (
profile.commit
)}
</span>
)}
</div>
</div>
</>
)}
</div>
)}
<div className="w-full grow sm:pl-4 overflow-x-auto">
{!sm && (
<div className="w-auto inline-block my-2">
<Select value={selectedSection} onValueChange={(value) => handleSectionSelectorItemClick(value as SettingSectionKey)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("setting.select-section")} />
</SelectTrigger>
<SelectContent>
{sectionGroups.all.map((section) => (
<SelectItem key={section.key} value={section.key}>
{t(section.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<ActiveSection />
</div>
</div>
</div>
</section>
);
};
export default Setting;
+500
View File
@@ -0,0 +1,500 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import {
CheckCircle2Icon,
ClipboardCheckIcon,
Clock3Icon,
ExternalLinkIcon,
FilterIcon,
MoreVerticalIcon,
PencilIcon,
PinIcon,
PlusIcon,
SaveIcon,
SearchIcon,
ShieldIcon,
TagsIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useLocation, useNavigate } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { shortcutServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb";
import { useTranslate } from "@/utils/i18n";
const shortcutExamples = [
{
title: "Pinned",
filter: "pinned",
description: "Only pinned memos.",
icon: PinIcon,
},
{
title: "Recent notes",
filter: "created_ts >= now() - 60 * 60",
description: "Memos created in the last hour.",
icon: Clock3Icon,
},
{
title: "Public memos",
filter: 'visibility == "PUBLIC"',
description: "Only public memos.",
icon: ShieldIcon,
},
{
title: "Project tags",
filter: 'tag in ["work", "personal"]',
description: "Match one or more exact tags.",
icon: TagsIcon,
},
{
title: "Archive tree",
filter: 'tags.exists(t, t.startsWith("archive"))',
description: "Match hierarchical tags by prefix.",
icon: TagsIcon,
},
{
title: "Open tasks",
filter: "has_task_list && has_incomplete_tasks",
description: "Memos with unfinished tasks.",
icon: ClipboardCheckIcon,
},
{
title: "Links or code",
filter: "has_link || has_code",
description: "Memos containing links or code blocks.",
icon: FilterIcon,
},
{
title: "Content search",
filter: 'content.contains("TODO")',
description: "Search text inside memo content.",
icon: SearchIcon,
},
];
const filterFields = [
"content.contains(...)",
"visibility",
"pinned",
"tag in [...]",
"tags.exists(...)",
"has_task_list",
"has_incomplete_tasks",
"has_link",
"has_code",
"created_ts",
"updated_ts",
];
const getShortcutId = (name: string): string => {
const parts = name.split("/");
return parts.length === 4 ? parts[3] : name;
};
const createEmptyShortcut = () =>
create(ShortcutSchema, {
name: "",
title: "",
filter: "",
});
interface ShortcutGuideProps {
onUseExample: (example: (typeof shortcutExamples)[number]) => void;
}
interface ShortcutsRouteState {
openCreate?: boolean;
shortcut?: Shortcut;
}
const ShortcutGuide = ({ onUseExample }: ShortcutGuideProps) => {
return (
<aside className="flex flex-col gap-5">
<div className="rounded-lg border border-border p-4">
<h2 className="text-sm font-semibold text-foreground">Expression examples</h2>
<div className="mt-3 flex flex-col gap-2">
{shortcutExamples.map((example) => {
const Icon = example.icon;
return (
<button
key={example.filter}
type="button"
className="group rounded-md border border-transparent p-2 text-left transition-colors hover:border-border hover:bg-muted/50"
onClick={() => onUseExample(example)}
>
<span className="flex items-center gap-2 text-sm font-medium text-foreground">
<Icon className="h-4 w-4 text-muted-foreground group-hover:text-primary" />
{example.title}
</span>
<span className="mt-1 block font-mono text-xs leading-5 text-muted-foreground">{example.filter}</span>
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{example.description}</span>
</button>
);
})}
</div>
</div>
<div className="rounded-lg border border-border p-4">
<h2 className="text-sm font-semibold text-foreground">Supported fields</h2>
<div className="mt-3 flex flex-wrap gap-2">
{filterFields.map((field) => (
<Badge key={field} variant="secondary" className="font-mono">
{field}
</Badge>
))}
</div>
</div>
</aside>
);
};
const Shortcuts = () => {
const t = useTranslate();
const location = useLocation();
const navigate = useNavigate();
const user = useCurrentUser();
const { shortcuts, refetchSettings } = useAuth();
const [isCreateFormOpen, setIsCreateFormOpen] = useState(false);
const [draft, setDraft] = useState<Shortcut>(createEmptyShortcut());
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
const createState = useLoading(false);
const validateState = useLoading(false);
const updateState = useLoading(false);
const isEditing = draft.name !== "";
const isSaving = createState.isLoading || updateState.isLoading;
useEffect(() => {
const state = location.state as ShortcutsRouteState | null;
if (!state) return;
if (state.shortcut) {
setDraft(
create(ShortcutSchema, {
name: state.shortcut.name,
title: state.shortcut.title,
filter: state.shortcut.filter,
}),
);
setIsCreateFormOpen(true);
} else if (state.openCreate) {
setDraft(createEmptyShortcut());
setIsCreateFormOpen(true);
}
navigate(location.pathname, { replace: true, state: null });
}, [location.key, location.pathname, location.state, navigate]);
const setDraftState = (state: Partial<Shortcut>) => {
setDraft((current) => ({ ...current, ...state }));
};
const handleUseExample = (example: (typeof shortcutExamples)[number]) => {
setDraft(
create(ShortcutSchema, {
name: draft.name,
title: draft.title || example.title,
filter: example.filter,
}),
);
setIsCreateFormOpen(true);
};
const handleOpenCreateForm = () => {
setDraft(createEmptyShortcut());
setIsCreateFormOpen(true);
};
const handleCloseForm = () => {
setDraft(createEmptyShortcut());
setIsCreateFormOpen(false);
};
const handleEditShortcut = (shortcut: Shortcut) => {
setDraft(
create(ShortcutSchema, {
name: shortcut.name,
title: shortcut.title,
filter: shortcut.filter,
}),
);
setIsCreateFormOpen(true);
};
const validateDraft = async () => {
if (!draft.title || !draft.filter) {
toast.error("Title and filter cannot be empty");
return false;
}
if (!user?.name) {
toast.error("No current user");
return false;
}
try {
validateState.setLoading();
await shortcutServiceClient.createShortcut({
parent: user.name,
shortcut: { name: "", title: draft.title, filter: draft.filter },
validateOnly: true,
});
validateState.setFinish();
toast.success("Filter expression looks valid");
return true;
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Validate shortcut filter",
onError: () => validateState.setError(),
});
return false;
}
};
const handleCreateShortcut = async () => {
if (!draft.title || !draft.filter) {
toast.error("Title and filter cannot be empty");
return;
}
if (!user?.name) {
toast.error("No current user");
return;
}
try {
createState.setLoading();
await shortcutServiceClient.createShortcut({
parent: user.name,
shortcut: { name: "", title: draft.title, filter: draft.filter },
});
await refetchSettings();
createState.setFinish();
setDraft(createEmptyShortcut());
setIsCreateFormOpen(false);
toast.success("Create shortcut successfully");
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Create shortcut",
onError: () => createState.setError(),
});
}
};
const handleUpdateShortcut = async () => {
if (!draft.title || !draft.filter) {
toast.error("Title and filter cannot be empty");
return;
}
try {
updateState.setLoading();
await shortcutServiceClient.updateShortcut({
shortcut: draft,
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }),
});
await refetchSettings();
updateState.setFinish();
setDraft(createEmptyShortcut());
setIsCreateFormOpen(false);
toast.success("Update shortcut successfully");
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Update shortcut",
onError: () => updateState.setError(),
});
}
};
const handleSaveShortcut = async () => {
if (isEditing) {
await handleUpdateShortcut();
return;
}
await handleCreateShortcut();
};
const confirmDeleteShortcut = async () => {
if (!deleteTarget) return;
try {
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
await refetchSettings();
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
setDeleteTarget(undefined);
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Delete shortcut",
});
}
};
return (
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 pb-10">
<div className="flex flex-col gap-2 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-muted-foreground">
<FilterIcon className="h-4 w-4" />
<span className="text-sm font-medium">{t("common.shortcuts")}</span>
</div>
<h1 className="text-2xl font-semibold tracking-normal text-foreground">Shortcut filters</h1>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Create reusable memo filters with fields, operators, time helpers, and tag matching. Use examples as starting points, then
validate before saving.
</p>
</div>
<Button onClick={isCreateFormOpen ? handleCloseForm : handleOpenCreateForm}>
{isCreateFormOpen ? <XIcon className="h-4 w-4" /> : <PlusIcon className="h-4 w-4" />}
{isCreateFormOpen ? t("common.cancel") : `${t("common.create")} ${t("common.shortcuts")}`}
</Button>
</div>
<div className={cn("grid grid-cols-1 gap-6", isCreateFormOpen && "xl:grid-cols-[minmax(0,1fr)_20rem]")}>
<div className="flex min-w-0 flex-col gap-6">
<div
className={cn(
"overflow-hidden rounded-lg border border-border bg-background transition-[max-height,opacity] duration-200",
isCreateFormOpen ? "max-h-[48rem] opacity-100" : "max-h-0 border-transparent opacity-0",
)}
>
<div className="grid gap-5 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">{isEditing ? "Edit shortcut" : "Create shortcut"}</h2>
<p className="mt-1 text-sm text-muted-foreground">
Name the shortcut and define the memo filter expression it should apply.
</p>
</div>
<a
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
href="https://www.usememos.com/docs/usage/shortcuts"
target="_blank"
rel="noopener noreferrer"
>
Docs
<ExternalLinkIcon className="h-3.5 w-3.5" />
</a>
</div>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="shortcut-title">{t("common.title")}</Label>
<Input
id="shortcut-title"
value={draft.title}
placeholder="Pinned, Recent notes, Work"
onChange={(event) => setDraftState({ title: event.target.value })}
/>
<p className="text-xs leading-5 text-muted-foreground">
Prefix the title with an emoji if you want it to appear in the sidebar.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="shortcut-filter">{t("common.filter")}</Label>
<Textarea
id="shortcut-filter"
rows={5}
className="font-mono text-sm"
value={draft.filter}
placeholder='pinned && tag in ["work"]'
onChange={(event) => setDraftState({ filter: event.target.value })}
/>
<p className="text-xs leading-5 text-muted-foreground">
Combine expressions with <span className="font-mono">&&</span>, <span className="font-mono">||</span>, and{" "}
<span className="font-mono">!</span>. Time fields use Unix seconds and support <span className="font-mono">now()</span>.
</p>
</div>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" disabled={validateState.isLoading || isSaving} onClick={validateDraft}>
<CheckCircle2Icon className="h-4 w-4" />
Validate
</Button>
<Button disabled={isSaving || validateState.isLoading} onClick={handleSaveShortcut}>
<SaveIcon className="h-4 w-4" />
{t("common.save")}
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">All shortcuts</h2>
<Badge variant="outline">{shortcuts.length}</Badge>
</div>
{shortcuts.length === 0 ? (
<div className="rounded-lg border border-dashed border-border px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">No shortcuts yet</p>
<p className="mt-1 text-sm text-muted-foreground">Open the create form to choose an example and add your first filter.</p>
</div>
) : (
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
{shortcuts.map((shortcut) => (
<div
key={shortcut.name}
className="grid gap-3 bg-background px-4 py-3 sm:grid-cols-[minmax(10rem,14rem)_minmax(0,1fr)_2rem]"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{shortcut.title}</div>
<div className="mt-1 font-mono text-xs text-muted-foreground">{getShortcutId(shortcut.name)}</div>
</div>
<pre className="min-w-0 overflow-x-auto rounded-md bg-muted/50 px-3 py-2 font-mono text-xs leading-5 text-muted-foreground">
{shortcut.filter}
</pre>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="justify-self-end">
<MoreVerticalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditShortcut(shortcut)}>
<PencilIcon className="h-4 w-4" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteTarget(shortcut)}>
<Trash2Icon className="h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</div>
</div>
{isCreateFormOpen ? <ShortcutGuide onUseExample={handleUseExample} /> : null}
</div>
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
title={t("setting.shortcut.delete-confirm", { title: deleteTarget?.title ?? "" })}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteShortcut}
confirmVariant="destructive"
/>
</section>
);
};
export default Shortcuts;
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Link, useSearchParams } from "react-router-dom";
import AuthFooter from "@/components/AuthFooter";
import PasswordSignInForm from "@/components/PasswordSignInForm";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/connect";
import { useInstance } from "@/contexts/InstanceContext";
import { absolutifyLink } from "@/helpers/utils";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect";
import { useTranslate } from "@/utils/i18n";
import { storeOAuthState } from "@/utils/oauth";
const SignIn = () => {
const t = useTranslate();
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
const { generalSetting: instanceGeneralSetting } = useInstance();
const [searchParams] = useSearchParams();
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`;
// Prepare identity provider list.
useEffect(() => {
const fetchIdentityProviderList = async () => {
const { identityProviders } = await identityProviderServiceClient.listIdentityProviders({});
setIdentityProviderList(identityProviders);
};
fetchIdentityProviderList();
}, []);
const handleSignInWithIdentityProvider = async (identityProvider: IdentityProvider) => {
if (identityProvider.type === IdentityProvider_Type.OAUTH2) {
const redirectUri = absolutifyLink("/auth/callback");
const oauth2Config = identityProvider.config?.config?.case === "oauth2Config" ? identityProvider.config.config.value : undefined;
if (!oauth2Config) {
toast.error("Identity provider configuration is invalid.");
return;
}
try {
// Generate and store secure state parameter with CSRF protection
// Also generate PKCE parameters (code_challenge) for enhanced security if available
const { state, codeChallenge } = await storeOAuthState(identityProvider.name, "signin", redirectTarget);
// Build OAuth authorization URL with secure state
// Include PKCE if available (requires HTTPS/localhost for crypto.subtle)
// Using S256 (SHA-256) as the code_challenge_method per RFC 7636
let authUrl = `${oauth2Config.authUrl}?client_id=${
oauth2Config.clientId
}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}&response_type=code&scope=${encodeURIComponent(
oauth2Config.scopes.join(" "),
)}`;
// Add PKCE parameters if available
if (codeChallenge) {
authUrl += `&code_challenge=${codeChallenge}&code_challenge_method=S256`;
}
window.location.href = authUrl;
} catch (error) {
handleError(error, toast.error, {
context: "Failed to initiate OAuth flow",
fallbackMessage: "Failed to initiate sign-in. Please try again.",
});
}
}
};
return (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="w-full flex flex-row justify-center items-center mb-6">
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{!instanceGeneralSetting.disallowPasswordAuth ? (
<PasswordSignInForm redirectPath={redirectTarget} />
) : (
identityProviderList.length === 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
)}
{!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && (
<p className="w-full mt-4 text-sm">
<span className="text-muted-foreground">{t("auth.sign-up-tip")}</span>
<Link to={signUpPath} className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
{t("common.sign-up")}
</Link>
</p>
)}
{identityProviderList.length > 0 && (
<>
{!instanceGeneralSetting.disallowPasswordAuth && (
<div className="relative my-4 w-full">
<Separator />
<div className="absolute inset-0 flex items-center justify-center">
<span className="bg-background px-2 text-xs text-muted-foreground">{t("common.or")}</span>
</div>
</div>
)}
<div className="w-full flex flex-col space-y-2">
{identityProviderList.map((identityProvider) => (
<Button
className="bg-background w-full"
key={identityProvider.name}
variant="outline"
onClick={() => handleSignInWithIdentityProvider(identityProvider)}
>
{t("common.sign-in-with", { provider: identityProvider.title })}
</Button>
))}
</div>
</>
)}
</div>
<AuthFooter />
</div>
);
};
export default SignIn;
+162
View File
@@ -0,0 +1,162 @@
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Link, useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
import AuthFooter from "@/components/AuthFooter";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authServiceClient, userServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useLoading from "@/hooks/useLoading";
import useNavigateTo from "@/hooks/useNavigateTo";
import { handleError } from "@/lib/error";
import { ROUTES } from "@/router/routes";
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect";
import { useTranslate } from "@/utils/i18n";
const SignUp = () => {
const t = useTranslate();
const navigateTo = useNavigateTo();
const actionBtnLoadingState = useLoading(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const { initialize: initAuth } = useAuth();
const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance();
const [searchParams] = useSearchParams();
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
const signInPath = searchParams.toString() ? `${ROUTES.AUTH}?${searchParams.toString()}` : ROUTES.AUTH;
const canUsePasswordSignUp = !instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth;
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setUsername(text);
};
const handlePasswordInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setPassword(text);
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
handleSignUpButtonClick();
};
const handleSignUpButtonClick = async () => {
if (username === "" || password === "") {
return;
}
if (actionBtnLoadingState.isLoading) {
return;
}
try {
actionBtnLoadingState.setLoading();
const user = create(UserSchema, {
username,
password,
role: User_Role.USER,
});
await userServiceClient.createUser({ user });
const response = await authServiceClient.signIn({
credentials: {
case: "passwordCredentials",
value: { username, password },
},
});
// Store access token from login response
if (response.accessToken) {
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
}
// Refresh auth context to load the current user
await initAuth();
// Refetch instance profile to update the initialized status
await initInstance();
navigateTo(redirectTarget || ROUTES.HOME, { replace: true });
} catch (error: unknown) {
handleError(error, toast.error, {
fallbackMessage: "Sign up failed",
});
}
actionBtnLoadingState.setFinish();
};
return (
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
<div className="w-full py-4 grow flex flex-col justify-center items-center">
<div className="w-full flex flex-row justify-center items-center mb-6">
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{canUsePasswordSignUp ? (
<>
<p className="w-full text-2xl mt-2 text-muted-foreground">{t("auth.create-your-account")}</p>
<form className="w-full mt-2" onSubmit={handleFormSubmit}>
<div className="flex flex-col justify-start items-start w-full gap-4">
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.username")}</span>
<Input
className="w-full bg-background h-10"
type="text"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.username")}
value={username}
autoComplete="username"
autoCapitalize="off"
spellCheck={false}
onChange={handleUsernameInputChanged}
required
/>
</div>
<div className="w-full flex flex-col justify-start items-start">
<span className="leading-8 text-muted-foreground">{t("common.password")}</span>
<Input
className="w-full bg-background h-10"
type="password"
readOnly={actionBtnLoadingState.isLoading}
placeholder={t("common.password")}
value={password}
autoComplete="new-password"
autoCapitalize="off"
spellCheck={false}
onChange={handlePasswordInputChanged}
required
/>
</div>
</div>
<div className="flex flex-row justify-end items-center w-full mt-6">
<Button type="submit" className="w-full h-10" disabled={actionBtnLoadingState.isLoading} onClick={handleSignUpButtonClick}>
{t("common.sign-up")}
{actionBtnLoadingState.isLoading && <LoaderIcon className="w-5 h-auto ml-2 animate-spin opacity-60" />}
</Button>
</div>
</form>
</>
) : instanceGeneralSetting.disallowPasswordAuth ? (
<p className="w-full text-2xl mt-2 text-muted-foreground">Password sign up is not allowed.</p>
) : (
<p className="w-full text-2xl mt-2 text-muted-foreground">Sign up is not allowed.</p>
)}
{!profile.admin ? (
<p className="w-full mt-4 text-sm font-medium text-muted-foreground">{t("auth.host-tip")}</p>
) : (
<p className="w-full mt-4 text-sm">
<span className="text-muted-foreground">{t("auth.sign-in-tip")}</span>
<Link to={signInPath} className="cursor-pointer ml-2 text-primary hover:underline" viewTransition>
{t("common.sign-in")}
</Link>
</p>
)}
</div>
<AuthFooter />
</div>
);
};
export default SignUp;
+160
View File
@@ -0,0 +1,160 @@
import copy from "copy-to-clipboard";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { lazy, Suspense } from "react";
import { toast } from "react-hot-toast";
import { useParams, useSearchParams } from "react-router-dom";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import UserAvatar from "@/components/UserAvatar";
import { Button } from "@/components/ui/button";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import { useUser } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
type TabView = "memos" | "map";
const UserMemoMap = lazy(() => import("@/components/UserMemoMap"));
const TabButton = ({
icon: Icon,
label,
isActive,
onClick,
}: {
icon: LucideIcon;
label: string;
isActive: boolean;
onClick: () => void;
}) => (
<button
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm font-medium transition-all duration-200 border-b-2 rounded-t-lg",
isActive
? "border-primary text-primary bg-primary/5"
: "border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50",
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
);
interface User {
name: string;
username: string;
displayName: string;
avatarUrl?: string;
description?: string;
}
const ProfileHeader = ({ user, onCopyProfileLink, shareLabel }: { user: User; onCopyProfileLink: () => void; shareLabel: string }) => (
<div className="border-b border-border/10 px-4 py-8 sm:px-6">
<div className="mx-auto flex max-w-2xl gap-4 sm:gap-6">
<UserAvatar className="h-20 w-20 shrink-0 rounded-2xl shadow-sm sm:h-24 sm:w-24" avatarUrl={user.avatarUrl} />
<div className="flex flex-1 flex-col gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground sm:text-3xl">{user.displayName || user.username}</h1>
{user.displayName && <p className="text-sm text-muted-foreground">@{user.username}</p>}
</div>
{user.description && <p className="text-sm text-foreground/70">{user.description}</p>}
<Button variant="outline" size="sm" onClick={onCopyProfileLink} className="w-fit gap-2">
<ExternalLinkIcon className="h-4 w-4" />
{shareLabel}
</Button>
</div>
</div>
</div>
);
const UserProfile = () => {
const t = useTranslate();
const username = useParams().username;
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = (searchParams.get("view") === "map" ? "map" : "memos") as TabView;
const { data: user, isLoading, error } = useUser(`users/${username}`, { enabled: !!username });
if (error && !isLoading) {
toast.error(t("message.user-not-found"));
}
const memoFilter = useMemoFilters({
creatorName: user?.name,
includeShortcuts: false,
includePinned: true,
});
const { listSort, orderBy } = useMemoSorting({
pinnedFirst: true,
state: State.NORMAL,
});
const handleCopyProfileLink = () => {
if (!user) return;
copy(`${window.location.origin}/u/${encodeURIComponent(user.username)}`);
toast.success(t("message.copied"));
};
const toggleTab = (view: TabView) => {
setSearchParams((prev) => {
view === "map" ? prev.set("view", "map") : prev.delete("view");
return prev;
});
};
if (isLoading) return null;
return (
<section className="flex min-h-screen w-full flex-col bg-background">
{user ? (
<>
<ProfileHeader user={user} onCopyProfileLink={handleCopyProfileLink} shareLabel={t("common.share")} />
<div className="border-b border-border/10 mb-4">
<div className="mx-auto flex max-w-2xl">
<TabButton
icon={LayoutListIcon}
label={t("common.memos")}
isActive={activeTab === "memos"}
onClick={() => toggleTab("memos")}
/>
<TabButton icon={MapIcon} label={t("common.map")} isActive={activeTab === "map"} onClick={() => toggleTab("map")} />
</div>
</div>
<div className="flex-1">
<div className="mx-auto w-full max-w-2xl">
{activeTab === "memos" ? (
<PagedMemoList
renderer={(memo: Memo) => (
<MemoView key={`${memo.name}-${memo.updateTime}`} memo={memo} showVisibility showPinned compact />
)}
listSort={listSort}
orderBy={orderBy}
filter={memoFilter}
/>
) : (
<div className="">
<Suspense fallback={<div className="h-[60dvh] sm:h-[500px] rounded-xl border border-border bg-muted/30" />}>
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</Suspense>
</div>
)}
</div>
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground">{t("message.user-not-found")}</p>
</div>
)}
</section>
);
};
export default UserProfile;