0.29.1原版
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user