refactor: update frontend components and pages
This commit is contained in:
parent
10baa7fbd2
commit
9bc0e742bc
121
src/App.tsx
121
src/App.tsx
@ -1,69 +1,86 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, lazy, useRef } from "react";
|
||||
import { Navigate, createBrowserRouter, useParams } from "react-router";
|
||||
import { RouterProvider } from "react-router/dom";
|
||||
import { getSavedThemeId, getThemeById, applyTheme, defaultThemeId } from "@/lib/theme";
|
||||
import { pushFaroMeasurement } from "@/faro";
|
||||
|
||||
// Eager imports — app shell (always needed)
|
||||
import RootLayout from "@/app/root-layout";
|
||||
|
||||
import AuthLayout from "@/page/auth/layout";
|
||||
import LoginPage from "@/page/auth/login";
|
||||
import RegisterPage from "@/page/auth/register";
|
||||
import ForgotPasswordPage from "@/page/auth/forgot-password";
|
||||
import ResetPasswordPage from "@/page/auth/reset-password";
|
||||
import { PersonalShell, WorkspaceShell, SettingsShell } from "@/components/shell/navshell";
|
||||
import WorkspaceRepositoriesPage from "@/page/workspace/repositories";
|
||||
import WorkspaceIssuesPage from "@/page/workspace/issues";
|
||||
import IssueDetailPage from "@/page/workspace/issues/detail";
|
||||
import RepoLayout, { RepoIndexRedirect } from "@/page/workspace/repo/layout";
|
||||
import CodeTab from "@/page/workspace/repo/code";
|
||||
import CommitsTab from "@/page/workspace/repo/commits";
|
||||
import BranchesTab from "@/page/workspace/repo/branches";
|
||||
import TagsTab from "@/page/workspace/repo/tags";
|
||||
import PullsTab from "@/page/workspace/repo/pulls";
|
||||
import ContributorsTab from "@/page/workspace/repo/contributors";
|
||||
import WebhooksTab from "@/page/workspace/repo/webhooks";
|
||||
import RepoSettingsTab from "@/page/workspace/repo/settings";
|
||||
import ReadmePage from "@/page/workspace/repo/readme-page";
|
||||
import CommitDetailPage from "@/page/workspace/repo/commit-detail";
|
||||
import PullCreatePage from "@/page/workspace/repo/pull-create";
|
||||
import PullDetailPage from "@/page/workspace/repo/pull-detail";
|
||||
import WorkspaceSettingsPage from "@/page/workspace/settings";
|
||||
import ChannelPage from "@/page/workspace/channel";
|
||||
import WorkplanChatList from "@/page/workspace/workplan/chat-list";
|
||||
import WorkplanChatConversation from "@/page/workspace/workplan/chat-conversation";
|
||||
import MeOverviewPage from "@/page/me/overview";
|
||||
import MeReposPage from "@/page/me/repos";
|
||||
import MeChatListPage from "@/page/me/chat-list.tsx";
|
||||
import MeChatConversationPage from "@/page/me/chat-conversation.tsx";
|
||||
import MeNotificationsPage from "@/page/me/notifications";
|
||||
import MeStarsPage from "@/page/me/stars";
|
||||
import MeFollowingPage from "@/page/me/following";
|
||||
import SettingsProfilePage from "@/page/settings/profile";
|
||||
import SettingsAppearancePage from "@/page/settings/appearance";
|
||||
import SettingsNotificationsPage from "@/page/settings/notifications";
|
||||
import SettingsPrivacyPage from "@/page/settings/privacy";
|
||||
import SettingsAccessibilityPage from "@/page/settings/accessibility";
|
||||
import SettingsSecurityPage from "@/page/settings/security";
|
||||
import SettingsTokensPage from "@/page/settings/tokens";
|
||||
import SettingsSshKeysPage from "@/page/settings/ssh-keys";
|
||||
import LandingLayout from "@/page/landing/layout";
|
||||
import LandingHome from "@/page/landing/home";
|
||||
import FeaturesPage from "@/page/landing/features";
|
||||
import WorkflowPage from "@/page/landing/workflow";
|
||||
import PricingPage from "@/page/landing/pricing";
|
||||
|
||||
// Lazy-loaded pages — code-split by route
|
||||
const AuthLayout = lazy(() => import("@/page/auth/layout"));
|
||||
const LoginPage = lazy(() => import("@/page/auth/login"));
|
||||
const RegisterPage = lazy(() => import("@/page/auth/register"));
|
||||
const ForgotPasswordPage = lazy(() => import("@/page/auth/forgot-password"));
|
||||
const ResetPasswordPage = lazy(() => import("@/page/auth/reset-password"));
|
||||
const WorkspaceRepositoriesPage = lazy(() => import("@/page/workspace/repositories"));
|
||||
const WorkspaceIssuesPage = lazy(() => import("@/page/workspace/issues"));
|
||||
const IssueDetailPage = lazy(() => import("@/page/workspace/issues/detail"));
|
||||
const RepoLayout = lazy(() => import("@/page/workspace/repo/layout"));
|
||||
const RepoIndexRedirect = lazy(() =>
|
||||
import("@/page/workspace/repo/layout").then((m) => ({ default: m.RepoIndexRedirect })),
|
||||
);
|
||||
const CodeTab = lazy(() => import("@/page/workspace/repo/code"));
|
||||
const CommitsTab = lazy(() => import("@/page/workspace/repo/commits"));
|
||||
const BranchesTab = lazy(() => import("@/page/workspace/repo/branches"));
|
||||
const TagsTab = lazy(() => import("@/page/workspace/repo/tags"));
|
||||
const PullsTab = lazy(() => import("@/page/workspace/repo/pulls"));
|
||||
const ContributorsTab = lazy(() => import("@/page/workspace/repo/contributors"));
|
||||
const WebhooksTab = lazy(() => import("@/page/workspace/repo/webhooks"));
|
||||
const RepoSettingsTab = lazy(() => import("@/page/workspace/repo/settings"));
|
||||
const ReadmePage = lazy(() => import("@/page/workspace/repo/readme-page"));
|
||||
const CommitDetailPage = lazy(() => import("@/page/workspace/repo/commit-detail"));
|
||||
const PullCreatePage = lazy(() => import("@/page/workspace/repo/pull-create"));
|
||||
const PullDetailPage = lazy(() => import("@/page/workspace/repo/pull-detail"));
|
||||
const WorkspaceSettingsPage = lazy(() => import("@/page/workspace/settings"));
|
||||
const ChannelPage = lazy(() => import("@/page/workspace/channel"));
|
||||
const WorkplanChatList = lazy(() => import("@/page/workspace/workplan/chat-list"));
|
||||
const WorkplanChatConversation = lazy(() => import("@/page/workspace/workplan/chat-conversation"));
|
||||
const MeOverviewPage = lazy(() => import("@/page/me/overview"));
|
||||
const MeReposPage = lazy(() => import("@/page/me/repos"));
|
||||
const MeChatListPage = lazy(() => import("@/page/me/chat-list"));
|
||||
const MeChatConversationPage = lazy(() => import("@/page/me/chat-conversation"));
|
||||
const MeNotificationsPage = lazy(() => import("@/page/me/notifications"));
|
||||
const MeStarsPage = lazy(() => import("@/page/me/stars"));
|
||||
const MeFollowingPage = lazy(() => import("@/page/me/following"));
|
||||
const SettingsProfilePage = lazy(() => import("@/page/settings/profile"));
|
||||
const SettingsAppearancePage = lazy(() => import("@/page/settings/appearance"));
|
||||
const SettingsNotificationsPage = lazy(() => import("@/page/settings/notifications"));
|
||||
const SettingsPrivacyPage = lazy(() => import("@/page/settings/privacy"));
|
||||
const SettingsAccessibilityPage = lazy(() => import("@/page/settings/accessibility"));
|
||||
const SettingsSecurityPage = lazy(() => import("@/page/settings/security"));
|
||||
const SettingsTokensPage = lazy(() => import("@/page/settings/tokens"));
|
||||
const SettingsSshKeysPage = lazy(() => import("@/page/settings/ssh-keys"));
|
||||
const LandingLayout = lazy(() => import("@/page/landing/layout"));
|
||||
const LandingHome = lazy(() => import("@/page/landing/home"));
|
||||
const FeaturesPage = lazy(() => import("@/page/landing/features"));
|
||||
const WorkflowPage = lazy(() => import("@/page/landing/workflow"));
|
||||
const PricingPage = lazy(() => import("@/page/landing/pricing"));
|
||||
const JoinInvitePage = lazy(() => import("@/page/join-invite"));
|
||||
const WorkspaceJoinApplyPage = lazy(() => import("@/page/workspace/join-apply"));
|
||||
|
||||
/** Moved to module level — avoids re-creating on every App render. */
|
||||
function WorkspaceCompatRedirect() {
|
||||
const { projectName = "" } = useParams();
|
||||
|
||||
return <Navigate replace to={`/${projectName}/repos`} />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const mountRef = useRef(performance.now());
|
||||
|
||||
useEffect(() => {
|
||||
const id = getSavedThemeId();
|
||||
const preset = getThemeById(id) || getThemeById(defaultThemeId)!;
|
||||
applyTheme(preset);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pushFaroMeasurement('app_render', {
|
||||
timeToInteractiveMs: Math.round(performance.now() - mountRef.current),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <RootLayout />,
|
||||
@ -89,6 +106,14 @@ function App() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/join/:code",
|
||||
element: <JoinInvitePage />,
|
||||
},
|
||||
{
|
||||
path: "/:projectName/join",
|
||||
element: <WorkspaceJoinApplyPage />,
|
||||
},
|
||||
{
|
||||
path: "/workspace/:projectName/*",
|
||||
element: <WorkspaceCompatRedirect />,
|
||||
|
||||
@ -1,10 +1,28 @@
|
||||
import { Suspense } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { SettingsModalProvider } from "@/components/settings/SettingsModalContext";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
/** Minimal skeleton shown while lazy route chunks load. */
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="flex h-svh items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
||||
<span className="text-sm text-muted-foreground">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SettingsModalProvider>
|
||||
<Outlet />
|
||||
</SettingsModalProvider>
|
||||
<ErrorBoundary>
|
||||
<SettingsModalProvider>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</SettingsModalProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@ -135,26 +135,39 @@ export function CommandPalette() {
|
||||
}, [open]);
|
||||
|
||||
// Fetch data
|
||||
const fetchData = useCallback(async (q: string) => {
|
||||
const fetchData = useCallback(async (q: string, signal: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data } = await api.get<SearchResponse>("/api/v1/search", {
|
||||
params: q.trim() ? { q: q.trim() } : {},
|
||||
signal,
|
||||
});
|
||||
setItems(buildHits(data));
|
||||
if (!signal.aborted) setItems(buildHits(data));
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : "Search failed");
|
||||
setItems([]);
|
||||
} finally {
|
||||
if (!signal.aborted) setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Pre-fetch on open, debounced re-fetch on type
|
||||
// Debounced re-fetch on type — only when search is non-empty
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setTimeout(() => fetchData(search), search ? 150 : 0);
|
||||
return () => clearTimeout(timer);
|
||||
if (!search.trim()) {
|
||||
setItems([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => fetchData(search, controller.signal), 150);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, search, fetchData]);
|
||||
|
||||
// Reset on close
|
||||
@ -252,7 +265,7 @@ export function CommandPalette() {
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
<button
|
||||
className="mt-1 rounded-md px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/10"
|
||||
onClick={() => fetchData(search)}
|
||||
onClick={() => fetchData(search, new AbortController().signal)}
|
||||
type="button"
|
||||
>
|
||||
Retry
|
||||
|
||||
71
src/components/ErrorBoundary.tsx
Normal file
71
src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { faro } from '@/faro';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Report to Faro with component stack
|
||||
faro.api.pushError(error, {
|
||||
context: {
|
||||
componentStack: info.componentStack ?? '',
|
||||
reactErrorBoundary: 'true',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-svh items-center justify-center bg-background p-8">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<pre className="mt-4 max-h-32 overflow-auto rounded-md bg-muted p-3 text-left text-xs text-muted-foreground">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="mt-6 inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,8 @@ interface Props {
|
||||
content: string;
|
||||
/** Ref to the underlying textarea for scroll sync. */
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
/** Optional className to match the textarea's padding/font. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -19,6 +21,7 @@ interface Props {
|
||||
export const MentionTextareaOverlay = memo(function MentionTextareaOverlay({
|
||||
content,
|
||||
textareaRef,
|
||||
className = "",
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -50,7 +53,7 @@ export const MentionTextareaOverlay = memo(function MentionTextareaOverlay({
|
||||
<div
|
||||
ref={overlayRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-0 overflow-hidden whitespace-pre-wrap break-words px-2.5 py-2 text-sm leading-relaxed [&_span]:align-baseline"
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 z-0 overflow-hidden whitespace-pre-wrap break-words px-2.5 py-2 text-sm leading-relaxed [&_span]:align-baseline ${className}`}
|
||||
>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === "mention" && seg.mention) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { client } from "@/client";
|
||||
import {
|
||||
@ -6,6 +6,7 @@ import {
|
||||
GitBranch, FileText, GitCommitHorizontal, Tag, Users,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sanitizeHtml } from "@/lib/sanitize-html";
|
||||
import CodePanel from "./code-panel";
|
||||
import CommitsPanel from "./commits-panel";
|
||||
import BranchesPanel from "./branches-panel";
|
||||
@ -76,6 +77,10 @@ export default function RepoView({ workspace, repo }: Props) {
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState(readme?.html ? "readme" : "code");
|
||||
const safeReadmeHtml = useMemo(
|
||||
() => sanitizeHtml(readme?.html ?? ""),
|
||||
[readme?.html],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -98,7 +103,7 @@ export default function RepoView({ workspace, repo }: Props) {
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
...(readme?.html ? [{ id: "readme", label: "README", icon: <FileText className="size-3.5" /> }] : []),
|
||||
...(safeReadmeHtml ? [{ id: "readme", label: "README", icon: <FileText className="size-3.5" /> }] : []),
|
||||
{ id: "code", label: "Code", icon: <FileText className="size-3.5" /> },
|
||||
{ id: "commits", label: "Commits", icon: <GitCommitHorizontal className="size-3.5" /> },
|
||||
{ id: "branches", label: "Branches", icon: <GitBranch className="size-3.5" /> },
|
||||
@ -161,8 +166,8 @@ export default function RepoView({ workspace, repo }: Props) {
|
||||
{/* Tab content */}
|
||||
<div className="mt-6">
|
||||
{activeTab === "code" && <CodePanel workspace={workspace} repo={repo} />}
|
||||
{activeTab === "readme" && readme?.html && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: readme.html }} />
|
||||
{activeTab === "readme" && safeReadmeHtml && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: safeReadmeHtml }} />
|
||||
)}
|
||||
{activeTab === "commits" && <CommitsPanel workspace={workspace} repo={repo} />}
|
||||
{activeTab === "branches" && <BranchesPanel workspace={workspace} repo={repo} />}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, lazy, Suspense } from "react";
|
||||
import { useNavigate, useLocation } from "react-router";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
@ -14,14 +14,16 @@ import {
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SettingsProfilePage from "@/page/settings/profile";
|
||||
import SettingsSecurityPage from "@/page/settings/security";
|
||||
import SettingsAppearancePage from "@/page/settings/appearance";
|
||||
import SettingsNotificationsPage from "@/page/settings/notifications";
|
||||
import SettingsPrivacyPage from "@/page/settings/privacy";
|
||||
import SettingsAccessibilityPage from "@/page/settings/accessibility";
|
||||
import SettingsTokensPage from "@/page/settings/tokens";
|
||||
import SettingsSshKeysPage from "@/page/settings/ssh-keys";
|
||||
|
||||
// Lazy-loaded settings sections — avoid bundling all settings into the modal chunk
|
||||
const SettingsProfilePage = lazy(() => import("@/page/settings/profile"));
|
||||
const SettingsSecurityPage = lazy(() => import("@/page/settings/security"));
|
||||
const SettingsAppearancePage = lazy(() => import("@/page/settings/appearance"));
|
||||
const SettingsNotificationsPage = lazy(() => import("@/page/settings/notifications"));
|
||||
const SettingsPrivacyPage = lazy(() => import("@/page/settings/privacy"));
|
||||
const SettingsAccessibilityPage = lazy(() => import("@/page/settings/accessibility"));
|
||||
const SettingsTokensPage = lazy(() => import("@/page/settings/tokens"));
|
||||
const SettingsSshKeysPage = lazy(() => import("@/page/settings/ssh-keys"));
|
||||
|
||||
type SectionKey =
|
||||
| "profile"
|
||||
@ -239,7 +241,9 @@ export function SettingsModal({ open, onClose, initialSection }: SettingsModalPr
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="mx-auto max-w-[660px] px-10 py-8">
|
||||
<ActiveComponent />
|
||||
<Suspense fallback={<div className="space-y-4">{Array.from({ length: 5 }).map((_, i) => <div key={i} className="h-6 animate-pulse rounded bg-muted/50" />)}</div>}>
|
||||
<ActiveComponent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -37,7 +37,7 @@ function PersonalAvatar() {
|
||||
)}
|
||||
>
|
||||
{me?.avatar_url ? (
|
||||
<img alt="" className="size-full object-cover" src={me.avatar_url} />
|
||||
<img alt={name + " 的头像"} className="size-full object-cover" src={me.avatar_url} />
|
||||
) : (
|
||||
workspaceInitial(name)
|
||||
)}
|
||||
|
||||
@ -47,7 +47,7 @@ function WorkspaceIcon({
|
||||
>
|
||||
{workspace.avatar_url ? (
|
||||
<img
|
||||
alt=""
|
||||
alt={workspace.name + " 的头像"}
|
||||
className="size-full object-cover"
|
||||
src={workspace.avatar_url}
|
||||
/>
|
||||
|
||||
@ -14,7 +14,7 @@ export function WorkspaceAvatar({ workspace }: { workspace?: WorkspaceResponse }
|
||||
)}
|
||||
>
|
||||
{workspace?.avatar_url ? (
|
||||
<img alt="" className="size-full object-cover" src={workspace.avatar_url} />
|
||||
<img alt={name + " 的头像"} className="size-full object-cover" src={workspace.avatar_url} />
|
||||
) : (
|
||||
workspaceInitial(name)
|
||||
)}
|
||||
|
||||
@ -12,15 +12,18 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
UserPlus,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, client } from "@/client";
|
||||
import { useAuth } from "@/context/auth-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import RoomCreateDialog from "@/page/workspace/channel/room-create-dialog";
|
||||
import InviteDialog from "@/page/workspace/channel/invite-dialog";
|
||||
import NavShell from "./rail";
|
||||
import { ShellNavLink } from "./shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -386,6 +389,7 @@ function TopBar() {
|
||||
const { data: channelsData } = useQuery<{
|
||||
rooms: ChannelItem[];
|
||||
categories: CategoryItem[];
|
||||
workspace_id?: string;
|
||||
}>({
|
||||
queryKey: ["channel", "rooms"],
|
||||
queryFn: async () => {
|
||||
@ -399,6 +403,8 @@ function TopBar() {
|
||||
const channelName = roomId
|
||||
? channelsData?.rooms?.find((r) => r.id === roomId)?.name
|
||||
: null;
|
||||
const workspaceId =
|
||||
channelsData?.rooms?.[0]?.workspace_id ?? channelsData?.workspace_id ?? "";
|
||||
|
||||
const title = isRepo
|
||||
? segments[2]
|
||||
@ -458,6 +464,18 @@ function TopBar() {
|
||||
>
|
||||
<Search className="size-[14px]" />
|
||||
</button>
|
||||
{workspaceId && (
|
||||
<InviteDialog roomId={roomId ?? undefined} workspaceId={workspaceId}>
|
||||
<button
|
||||
aria-label="Invite people"
|
||||
className="grid size-10 place-items-center rounded-xl transition-[background-color,color] duration-150 hover:bg-accent/50 hover:text-muted-foreground/70"
|
||||
title="Invite people"
|
||||
type="button"
|
||||
>
|
||||
<UserPlus className="size-[14px]" />
|
||||
</button>
|
||||
</InviteDialog>
|
||||
)}
|
||||
<button
|
||||
aria-label="Open members panel"
|
||||
className={cn(
|
||||
@ -551,15 +569,73 @@ function MembersPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceAccessLoading() {
|
||||
return (
|
||||
<div className="grid h-svh place-items-center bg-background">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="size-5 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceAccessMessage({
|
||||
actionHref,
|
||||
actionText,
|
||||
description,
|
||||
title,
|
||||
}: {
|
||||
actionHref: string;
|
||||
actionText: string;
|
||||
description: string;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-svh place-items-center bg-background px-4">
|
||||
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-6 text-center shadow-sm">
|
||||
<h1 className="text-base font-semibold text-foreground">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
<Button asChild className="mt-5">
|
||||
<Link to={actionHref}>{actionText}</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function apiErrorStatus(error: unknown) {
|
||||
return (error as { response?: { status?: number } } | null)?.response?.status;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shell */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function WorkspaceShell() {
|
||||
const { projectName = "" } = useParams();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isEmbed = searchParams.get("embed") === "1";
|
||||
const [showMembers, setShowMembers] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
|
||||
const {
|
||||
data: workspace,
|
||||
error: workspaceError,
|
||||
isLoading: workspaceLoading,
|
||||
} = useQuery({
|
||||
queryKey: ["workspace", projectName],
|
||||
queryFn: async () => {
|
||||
const response = await client.workspaceGetWorkspace(projectName);
|
||||
return response.data;
|
||||
},
|
||||
enabled: isAuthenticated && Boolean(projectName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const hasWorkspaceAccess = Boolean(workspace);
|
||||
|
||||
const { data: channelsData } = useQuery<{
|
||||
rooms: ChannelItem[];
|
||||
@ -571,6 +647,7 @@ export function WorkspaceShell() {
|
||||
const response = await api.get("/api/v1/ws/rooms");
|
||||
return response.data;
|
||||
},
|
||||
enabled: hasWorkspaceAccess,
|
||||
retry: false,
|
||||
});
|
||||
const workspaceId =
|
||||
@ -588,7 +665,7 @@ export function WorkspaceShell() {
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
enabled: Boolean(workspaceId),
|
||||
enabled: hasWorkspaceAccess && Boolean(workspaceId),
|
||||
retry: false,
|
||||
staleTime: 30000,
|
||||
});
|
||||
@ -607,6 +684,49 @@ export function WorkspaceShell() {
|
||||
[showMembers, showSearch, members, membersLoading],
|
||||
);
|
||||
|
||||
if (authLoading) {
|
||||
return <WorkspaceAccessLoading />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<WorkspaceAccessMessage
|
||||
actionHref={`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`}
|
||||
actionText="去登录"
|
||||
description="登录后才能查看 workspace、频道、成员等受限数据。"
|
||||
title="请先登录"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (workspaceLoading) {
|
||||
return <WorkspaceAccessLoading />;
|
||||
}
|
||||
|
||||
if (apiErrorStatus(workspaceError) === 403) {
|
||||
return (
|
||||
<NavShell>
|
||||
<WorkspaceAccessMessage
|
||||
actionHref={`/${projectName}/join`}
|
||||
actionText="申请加入"
|
||||
description="你还不是该 workspace 的成员,申请通过后才能查看其中的数据。"
|
||||
title="需要加入 workspace"
|
||||
/>
|
||||
</NavShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (workspaceError) {
|
||||
return (
|
||||
<WorkspaceAccessMessage
|
||||
actionHref="/"
|
||||
actionText="返回首页"
|
||||
description="无法打开该 workspace,请确认链接是否正确或稍后再试。"
|
||||
title="无法访问 workspace"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Embed mode: render only the page content, no shell chrome
|
||||
if (isEmbed) {
|
||||
return (
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import axios from "axios";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { client, type ContextMe } from "@/client";
|
||||
import { setFaroUser, pushFaroEvent } from "@/faro";
|
||||
|
||||
type AuthContextValue = {
|
||||
me: ContextMe | null;
|
||||
@ -36,10 +39,45 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const { data, error, isLoading, refetch } = useQuery({
|
||||
queryKey: ["auth", "me"],
|
||||
queryFn: fetchMe,
|
||||
staleTime: 5 * 60 * 1000, // 5 min — avoid re-fetch on every mount
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const me = data ?? null;
|
||||
const prevMe = useRef<ContextMe | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (me) {
|
||||
setFaroUser({
|
||||
id: me.id,
|
||||
username: me.username,
|
||||
attributes: {
|
||||
displayName: me.display_name ?? '',
|
||||
language: me.language,
|
||||
timezone: me.timezone,
|
||||
},
|
||||
});
|
||||
|
||||
// Login event: user was null → now authenticated
|
||||
if (!prevMe.current) {
|
||||
pushFaroEvent('auth_login', {
|
||||
userId: me.id,
|
||||
username: me.username,
|
||||
});
|
||||
}
|
||||
} else if (!isLoading) {
|
||||
// Logout event: user was authenticated → now null
|
||||
if (prevMe.current) {
|
||||
pushFaroEvent('auth_logout', {
|
||||
userId: prevMe.current.id,
|
||||
username: prevMe.current.username,
|
||||
});
|
||||
}
|
||||
setFaroUser(null);
|
||||
}
|
||||
|
||||
prevMe.current = me;
|
||||
}, [me, isLoading]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
|
||||
144
src/faro.tsx
Normal file
144
src/faro.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import {
|
||||
initializeFaro,
|
||||
getWebInstrumentations,
|
||||
faro,
|
||||
type MetaUser,
|
||||
} from '@grafana/faro-web-sdk';
|
||||
import { LogLevel } from '@grafana/faro-web-sdk';
|
||||
import { OtlpHttpTransport } from '@grafana/faro-transport-otlp-http';
|
||||
|
||||
/**
|
||||
* Patterns for errors that should be ignored (browser extensions, network flakes, etc).
|
||||
* These errors are external to the app and not actionable.
|
||||
*/
|
||||
const IGNORE_ERROR_PATTERNS = [
|
||||
/ResizeObserver loop limit exceeded/i,
|
||||
/ResizeObserver loop completed with undelivered notifications/i,
|
||||
/Script error\.?$/i,
|
||||
/@parcel|moz-extension|chrome-extension|safari-extension/i,
|
||||
/Non-Error promise rejection captured/i,
|
||||
/Request aborted/i,
|
||||
/AbortError/i,
|
||||
/NetworkError/i,
|
||||
/Load failed/i,
|
||||
/^Loading chunk \d+ failed/i,
|
||||
/^Importing a module script failed/i,
|
||||
/^Cancel rendering route/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Initialize Grafana Faro — Real User Monitoring (RUM) for the frontend.
|
||||
*
|
||||
* Collects:
|
||||
* - Uncaught exceptions & unhandled promise rejections
|
||||
* - console.error / console.warn
|
||||
* - Web Vitals (LCP, FCP, CLS, INP, TTFB)
|
||||
* - Session metadata (browser, OS, screen size etc.)
|
||||
* - Page navigation / route changes (via History API)
|
||||
* - User interactions (click, keypress etc.)
|
||||
* - Custom events pushed via faro.api.pushEvent / pushMeasurement
|
||||
*/
|
||||
initializeFaro({
|
||||
url: 'https://faro.gitdata.me/collect',
|
||||
|
||||
app: {
|
||||
name: 'gitdataai',
|
||||
version: __APP_VERSION__,
|
||||
environment: __APP_ENV__,
|
||||
namespace: 'frontend',
|
||||
},
|
||||
|
||||
sessionTracking: {
|
||||
enabled: true,
|
||||
// Sample 100% of sessions for telemetry; adjust in production if needed
|
||||
samplingRate: 1,
|
||||
// Persist session across page reloads via sessionStorage
|
||||
persistent: true,
|
||||
},
|
||||
|
||||
instrumentations: [
|
||||
...getWebInstrumentations({
|
||||
// Capture console.error and console.warn as Faro logs
|
||||
captureConsole: true,
|
||||
// Enable Web Vitals with attribution for richer debugging
|
||||
enablePerformanceInstrumentation: true,
|
||||
}),
|
||||
],
|
||||
|
||||
// Only capture console.error / console.warn (not debug/info/log) to keep noise low
|
||||
consoleInstrumentation: {
|
||||
disabledLevels: [LogLevel.DEBUG, LogLevel.INFO, LogLevel.LOG, LogLevel.TRACE],
|
||||
},
|
||||
|
||||
// Filter errors before they are sent
|
||||
ignoreErrors: IGNORE_ERROR_PATTERNS,
|
||||
|
||||
// Deduplicate identical errors within a session
|
||||
dedupe: true,
|
||||
|
||||
// Use OTLP HTTP transport (already in package.json deps)
|
||||
transports: [
|
||||
new OtlpHttpTransport({
|
||||
bufferSize: 30,
|
||||
concurrency: 5,
|
||||
defaultRateLimitBackoffMs: 5000,
|
||||
}),
|
||||
],
|
||||
|
||||
// Transform / filter every event before sending to the collector
|
||||
beforeSend(item) {
|
||||
// Strip query strings & hashes from page URL to avoid leaking tokens/secrets
|
||||
if (item.meta?.page?.url) {
|
||||
try {
|
||||
const url = new URL(item.meta.page.url, window.location.origin);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
item.meta.page.url = url.toString();
|
||||
} catch {
|
||||
// Not a valid URL — leave as-is
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Set Faro user context — call after auth state changes.
|
||||
*/
|
||||
export function setFaroUser(user: MetaUser | null) {
|
||||
if (user) {
|
||||
faro.api.setUser(user);
|
||||
} else {
|
||||
faro.api.resetUser();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Faro user context — call on logout.
|
||||
*/
|
||||
export function clearFaroUser() {
|
||||
faro.api.resetUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a custom event to Faro.
|
||||
*/
|
||||
export function pushFaroEvent(
|
||||
name: string,
|
||||
attributes?: Record<string, string>,
|
||||
) {
|
||||
faro.api.pushEvent(name, attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a custom measurement to Faro.
|
||||
*/
|
||||
export function pushFaroMeasurement(
|
||||
type: string,
|
||||
values: Record<string, number>,
|
||||
) {
|
||||
faro.api.pushMeasurement({ type, values });
|
||||
}
|
||||
|
||||
export { faro };
|
||||
26
src/hooks/use-faro-measure.ts
Normal file
26
src/hooks/use-faro-measure.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { pushFaroMeasurement } from '@/faro';
|
||||
|
||||
/**
|
||||
* Hook for measuring custom durations and reporting to Faro.
|
||||
*
|
||||
* @example
|
||||
* const measure = useFaroMeasure('api-call');
|
||||
* await fetch('/api/...');
|
||||
* measure({ duration: Date.now() - start });
|
||||
*/
|
||||
export function useFaroMeasure(type: string) {
|
||||
const startRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
startRef.current = performance.now();
|
||||
}, []);
|
||||
|
||||
return useCallback(
|
||||
(values: Record<string, number>) => {
|
||||
const elapsed = performance.now() - startRef.current;
|
||||
pushFaroMeasurement(type, { ...values, elapsedMs: Math.round(elapsed) });
|
||||
},
|
||||
[type],
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { AtSign, Hash, User, GitBranch, Bug } from 'lucide-react';
|
||||
import { AtSign, Hash, User, GitBranch, Bug, Megaphone } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MentionData } from './parser';
|
||||
|
||||
@ -9,6 +9,7 @@ const entityIcons: Record<string, React.ComponentType<{ className?: string }>> =
|
||||
issue: Bug,
|
||||
pr: GitBranch,
|
||||
room: Hash,
|
||||
all: Megaphone,
|
||||
};
|
||||
|
||||
const entityColors: Record<string, string> = {
|
||||
@ -17,6 +18,7 @@ const entityColors: Record<string, string> = {
|
||||
issue: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-950',
|
||||
pr: 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-950',
|
||||
room: 'text-info dark:text-info bg-info/10 dark:bg-info/20',
|
||||
all: 'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
|
||||
105
src/lib/sanitize-html.ts
Normal file
105
src/lib/sanitize-html.ts
Normal file
@ -0,0 +1,105 @@
|
||||
const DANGEROUS_TAGS = new Set([
|
||||
"script",
|
||||
"style",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
"svg",
|
||||
"math",
|
||||
"link",
|
||||
"meta",
|
||||
]);
|
||||
|
||||
const ALLOWED_TAGS = new Set([
|
||||
"a",
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"code",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"img",
|
||||
]);
|
||||
|
||||
const ALLOWED_ATTRS = new Set(["href", "src", "alt", "title"]);
|
||||
|
||||
function isSafeUrl(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("#") || trimmed.startsWith("/")) return true;
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed, window.location.origin);
|
||||
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAttributes(element: Element) {
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const value = attr.value;
|
||||
const isUrlAttr = name === "href" || name === "src";
|
||||
|
||||
if (!ALLOWED_ATTRS.has(name) || name.startsWith("on") || (isUrlAttr && !isSafeUrl(value))) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.tagName.toLowerCase() === "a") {
|
||||
element.setAttribute("rel", "noopener noreferrer");
|
||||
element.setAttribute("target", "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeNode(node: Node) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
|
||||
const element = node as Element;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
|
||||
if (DANGEROUS_TAGS.has(tag)) {
|
||||
element.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
const children = Array.from(element.childNodes);
|
||||
for (const child of children) sanitizeNode(child);
|
||||
element.replaceWith(...children);
|
||||
return;
|
||||
}
|
||||
|
||||
sanitizeAttributes(element);
|
||||
for (const child of Array.from(element.childNodes)) sanitizeNode(child);
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
for (const child of Array.from(doc.body.childNodes)) sanitizeNode(child);
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './faro'
|
||||
import App from './App.tsx'
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
import { AuthProvider } from "@/context/auth-context";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import axios from "axios";
|
||||
|
||||
import { client } from "@/client";
|
||||
@ -31,6 +31,11 @@ type LoginPayload = {
|
||||
captcha: string;
|
||||
};
|
||||
|
||||
function safeRedirect(value: string | null) {
|
||||
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
return value;
|
||||
}
|
||||
|
||||
function isTwoFactorRequired(error: unknown) {
|
||||
return (
|
||||
axios.isAxiosError(error) &&
|
||||
@ -41,6 +46,7 @@ function isTwoFactorRequired(error: unknown) {
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [twoFactorSubmitting, setTwoFactorSubmitting] = useState(false);
|
||||
@ -56,7 +62,7 @@ export default function LoginPage() {
|
||||
totp_code: totpCode || undefined,
|
||||
});
|
||||
await refresh();
|
||||
navigate("/");
|
||||
navigate(safeRedirect(searchParams.get("redirect")));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
|
||||
136
src/page/join-invite.tsx
Normal file
136
src/page/join-invite.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router";
|
||||
import { CheckCircle2, Loader2, UserPlus, XCircle } from "lucide-react";
|
||||
import { api } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/context/auth-context";
|
||||
|
||||
type InviteAcceptEvent = {
|
||||
data?: {
|
||||
workspace?: { name?: string };
|
||||
room?: { id?: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
function CenteredMessage({
|
||||
actionHref,
|
||||
actionText,
|
||||
description,
|
||||
title,
|
||||
}: {
|
||||
actionHref?: string;
|
||||
actionText?: string;
|
||||
description: string;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-6 text-center shadow-sm">
|
||||
<h1 className="text-base font-semibold text-foreground">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
{actionHref && actionText && (
|
||||
<Button asChild className="mt-5">
|
||||
<Link to={actionHref}>{actionText}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JoinInvitePage() {
|
||||
const { code = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [acceptedTo, setAcceptedTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const targetPath = useMemo(() => {
|
||||
if (!acceptedTo) return null;
|
||||
return acceptedTo;
|
||||
}, [acceptedTo]);
|
||||
|
||||
const acceptInvite = useCallback(async () => {
|
||||
if (!code || accepting) return;
|
||||
setAccepting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.post<InviteAcceptEvent>("/api/v1/ws/invites/accept", { code });
|
||||
const workspaceName = res.data?.data?.workspace?.name;
|
||||
const roomId = res.data?.data?.room?.id;
|
||||
const path = workspaceName
|
||||
? roomId
|
||||
? `/${workspaceName}/channel/${roomId}`
|
||||
: `/${workspaceName}/channel`
|
||||
: "/me";
|
||||
setAcceptedTo(path);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to accept invite.");
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
}, [accepting, code]);
|
||||
|
||||
if (!code) return <Navigate replace to="/" />;
|
||||
|
||||
if (authLoading) {
|
||||
return <CenteredMessage description="Checking login status…" title="Loading" />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<CenteredMessage
|
||||
actionHref={`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`}
|
||||
actionText="去登录"
|
||||
description="登录后才能接受邀请,未登录状态不会展示 workspace 邀请内容。"
|
||||
title="请先登录"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="grid size-10 place-items-center rounded-xl bg-primary/10 text-primary">
|
||||
<UserPlus className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-foreground">Join workspace</h1>
|
||||
<p className="text-sm text-muted-foreground">Accept this invitation to continue.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{targetPath ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-xl bg-emerald-500/10 px-4 py-3 text-sm text-emerald-600">
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0" />
|
||||
<span>Invite accepted. You can now open the workspace.</span>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => navigate(targetPath)}>
|
||||
Open workspace
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-xl bg-destructive/10 px-4 py-3 text-sm text-destructive" role="alert">
|
||||
<XCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" disabled={accepting} onClick={acceptInvite}>
|
||||
{accepting && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
Accept invite
|
||||
</Button>
|
||||
<Button asChild className="w-full" variant="outline">
|
||||
<Link to="/">Back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,12 @@ import TerminalDemo from "@/components/landing/terminal-demo";
|
||||
import DashboardMockup from "@/components/landing/dashboard-mockup";
|
||||
import { GitBranch, Layers, MessageSquare, Bot } from "lucide-react";
|
||||
|
||||
const productHighlights = [
|
||||
{ label: "Git-native", value: "Repos, PRs, branches" },
|
||||
{ label: "AI in context", value: "Review, triage, automate" },
|
||||
{ label: "Team sync", value: "Channels tied to work" },
|
||||
];
|
||||
|
||||
export default function LandingHome() {
|
||||
return (
|
||||
<>
|
||||
@ -55,11 +61,7 @@ export default function LandingHome() {
|
||||
{/* Product loop */}
|
||||
<section className="border-t border-border/20 bg-muted/[0.1] py-10">
|
||||
<div className="mx-auto grid max-w-6xl gap-3 px-6 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: "Git-native", value: "Repos, PRs, branches" },
|
||||
{ label: "AI in context", value: "Review, triage, automate" },
|
||||
{ label: "Team sync", value: "Channels tied to work" },
|
||||
].map((item) => (
|
||||
{productHighlights.map((item) => (
|
||||
<div className="rounded-2xl border border-border/30 bg-card/70 px-5 py-4 text-center shadow-sm" key={item.label}>
|
||||
<div className="font-heading text-sm font-semibold text-foreground">{item.label}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground/70">{item.value}</div>
|
||||
|
||||
@ -132,8 +132,8 @@ export default function MeChatConversationPage() {
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// Non-critical.
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversation:", err);
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
@ -552,29 +552,32 @@ function ChatComposer({
|
||||
onModelChange: (provider: string) => void;
|
||||
}) {
|
||||
const { textInput } = usePromptInputController();
|
||||
const textInputRef = useRef(textInput);
|
||||
textInputRef.current = textInput;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(text: string) => {
|
||||
if (text.trim()) {
|
||||
sendMessage(text);
|
||||
textInput.clear();
|
||||
textInputRef.current.clear();
|
||||
}
|
||||
},
|
||||
[sendMessage, textInput],
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !sending) {
|
||||
e.preventDefault();
|
||||
const text = textInput.value;
|
||||
const ti = textInputRef.current;
|
||||
const text = ti.value;
|
||||
if (text.trim()) {
|
||||
sendMessage(text);
|
||||
textInput.clear();
|
||||
ti.clear();
|
||||
}
|
||||
}
|
||||
},
|
||||
[sendMessage, textInput, sending],
|
||||
[sendMessage, sending],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -83,7 +83,7 @@ export default function MeFollowingPage() {
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 py-3 hover:bg-accent/50 transition-colors" key={user.username}>
|
||||
<Link className="flex items-center gap-3 min-w-0 flex-1" to={`/${user.username}`}>
|
||||
<span className={`grid size-8 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(user.username)}`}>
|
||||
{user.avatar_url ? <img alt="" className="size-full object-cover" src={user.avatar_url} /> : workspaceInitial(user.username)}
|
||||
{user.avatar_url ? <img alt={user.username + " 的头像"} className="size-full object-cover" src={user.avatar_url} /> : workspaceInitial(user.username)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading font-medium text-foreground">{user.display_name ?? user.username}</p>
|
||||
|
||||
@ -28,7 +28,7 @@ export function WorkspaceList({ workspaces }: { workspaces: { name: string; avat
|
||||
<span
|
||||
className={`grid size-7 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(ws.name)}`}
|
||||
>
|
||||
{ws.avatar_url ? <img alt="" className="size-full object-cover" src={ws.avatar_url} /> : workspaceInitial(ws.name)}
|
||||
{ws.avatar_url ? <img alt={ws.name + " 的头像"} className="size-full object-cover" src={ws.avatar_url} /> : workspaceInitial(ws.name)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAuth } from "@/context/auth-context";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { client } from "@/client";
|
||||
@ -49,13 +50,20 @@ export default function MeOverviewPage() {
|
||||
? new Date(createdAt).toLocaleDateString("en-US", { month: "short", year: "numeric" })
|
||||
: "";
|
||||
|
||||
const stats = useMemo(() => [
|
||||
{ value: workspaces?.length ?? 0, label: "Workspaces", icon: Folder, to: "/me/repos" },
|
||||
{ value: 0, label: "Repositories", icon: GitFork, to: "/me/repos" },
|
||||
{ value: 0, label: "Stars", icon: Star, to: "/me/stars" },
|
||||
{ value: relationCounts?.followers ?? 0, label: "Followers", icon: Users, to: "/me/following" },
|
||||
], [workspaces?.length, relationCounts?.followers]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-8 py-10">
|
||||
{/* Profile header */}
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="grid size-16 place-items-center overflow-hidden rounded-xl bg-gradient-to-br text-xl font-heading font-bold text-white ring-1 ring-black/5 shadow-sm">
|
||||
{avatarUrl ? (
|
||||
<img alt="" className="size-full object-cover" src={avatarUrl} />
|
||||
<img alt={displayName + " 的头像"} className="size-full object-cover" src={avatarUrl} />
|
||||
) : (
|
||||
workspaceInitial(displayName)
|
||||
)}
|
||||
@ -86,12 +94,7 @@ export default function MeOverviewPage() {
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-8 grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ value: workspaces?.length ?? 0, label: "Workspaces", icon: Folder, to: "/me/repos" },
|
||||
{ value: 0, label: "Repositories", icon: GitFork, to: "/me/repos" },
|
||||
{ value: 0, label: "Stars", icon: Star, to: "/me/stars" },
|
||||
{ value: relationCounts?.followers ?? 0, label: "Followers", icon: Users, to: "/me/following" },
|
||||
].map((stat) => (
|
||||
{stats.map((stat) => (
|
||||
<Link
|
||||
className="group rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:border-primary/20"
|
||||
key={stat.label}
|
||||
|
||||
@ -50,7 +50,7 @@ export default function MeReposPage() {
|
||||
<span
|
||||
className={`grid size-8 place-items-center rounded-lg bg-gradient-to-br text-xs font-bold text-white ${workspaceColor(ws.name)}`}
|
||||
>
|
||||
{ws.avatar_url ? <img alt="" className="size-full object-cover" src={ws.avatar_url} /> : workspaceInitial(ws.name)}
|
||||
{ws.avatar_url ? <img alt={ws.name + " 的头像"} className="size-full object-cover" src={ws.avatar_url} /> : workspaceInitial(ws.name)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { client } from "@/client";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -7,6 +8,10 @@ import { Label } from "@/components/ui/label";
|
||||
import { Shield, ShieldOff, Mail } from "lucide-react";
|
||||
|
||||
function TwoFactorSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [verifiedBackupCodes, setVerifiedBackupCodes] = useState<string[]>([]);
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ["auth", "2fa", "status"],
|
||||
queryFn: async () => {
|
||||
@ -23,6 +28,17 @@ function TwoFactorSection() {
|
||||
},
|
||||
});
|
||||
|
||||
const verify2fa = useMutation({
|
||||
mutationFn: async (code: string) => {
|
||||
await client.authVerify2fa({ code });
|
||||
},
|
||||
onSuccess: () => {
|
||||
setVerifiedBackupCodes(enable2fa.data?.backup_codes ?? []);
|
||||
setVerificationCode("");
|
||||
queryClient.invalidateQueries({ queryKey: ["auth", "2fa", "status"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-16 animate-pulse rounded-lg bg-muted" />;
|
||||
}
|
||||
@ -37,11 +53,23 @@ function TwoFactorSection() {
|
||||
</CardTitle>
|
||||
<CardDescription>Your account is protected with 2FA</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Method: {status.method ?? "TOTP"}
|
||||
{status.has_backup_codes ? " — Backup codes available" : " — No backup codes"}
|
||||
</p>
|
||||
{verifiedBackupCodes.length > 0 && (
|
||||
<div className="space-y-2 rounded-lg border border-border p-3">
|
||||
<p className="font-heading text-sm font-medium text-foreground">
|
||||
Backup codes — save these now. They won't be shown again.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{verifiedBackupCodes.map((code) => (
|
||||
<p className="font-mono text-xs text-muted-foreground" key={code}>{code}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@ -58,21 +86,37 @@ function TwoFactorSection() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{enable2fa.data ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
verify2fa.mutate(verificationCode.trim());
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Scan this QR code with your authenticator app, then enter the code below to verify.
|
||||
</p>
|
||||
<img alt="2FA QR code" className="size-48 rounded-lg border border-border" src={enable2fa.data.qr_code} />
|
||||
<p className="font-mono text-xs text-muted-foreground">Secret: {enable2fa.data.secret}</p>
|
||||
<div className="space-y-2">
|
||||
<p className="font-heading font-medium text-sm text-foreground">Backup codes (save these!)</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{enable2fa.data.backup_codes.map((code) => (
|
||||
<p className="font-mono text-xs text-muted-foreground" key={code}>{code}</p>
|
||||
))}
|
||||
</div>
|
||||
<Label htmlFor="two-factor-code">Verification code</Label>
|
||||
<Input
|
||||
className="h-9 max-w-48 font-mono"
|
||||
id="two-factor-code"
|
||||
inputMode="numeric"
|
||||
maxLength={8}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="123456"
|
||||
value={verificationCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{verify2fa.isError && (
|
||||
<p className="text-sm text-destructive">Invalid code. Please try again.</p>
|
||||
)}
|
||||
<Button disabled={!verificationCode.trim() || verify2fa.isPending} size="sm" type="submit">
|
||||
Verify and enable
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Button onClick={() => enable2fa.mutate()}>
|
||||
Enable 2FA
|
||||
|
||||
@ -1,43 +1,71 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Reply, Send, X } from "lucide-react";
|
||||
import { useParams } from "react-router";
|
||||
import { Reply, Send, SmilePlus, X } from "lucide-react";
|
||||
import EmojiPicker, { type EmojiClickData } from "emoji-picker-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import FileUploadButton from "./file-upload";
|
||||
import FileUploadButton, { type UploadedAttachment } from "./file-upload";
|
||||
import MentionTextarea, { type MentionRoomSuggestion } from "./mention-textarea";
|
||||
import type { MessageNewService } from "@/socket";
|
||||
|
||||
type RoomSuggestion = MentionRoomSuggestion;
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
type Props = {
|
||||
roomName: string;
|
||||
roomId?: string;
|
||||
rooms?: RoomSuggestion[];
|
||||
workspaceId?: string;
|
||||
replyTarget?: MessageNewService | null;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
onSend: (content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onCancelReply?: () => void;
|
||||
onTyping?: (typing: boolean) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// ---- Component ----
|
||||
|
||||
export default function MessageComposer({
|
||||
roomName,
|
||||
roomId,
|
||||
rooms = [],
|
||||
workspaceId,
|
||||
replyTarget,
|
||||
onSend,
|
||||
onCancelReply,
|
||||
onTyping,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const { projectName = "" } = useParams();
|
||||
const [content, setContent] = useState("");
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const typingTimeout = useRef<number | undefined>(undefined);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Focus textarea when entering reply mode
|
||||
// Focus textarea when entering reply mode (via imperative ref)
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const getTextarea = useCallback(() => containerRef.current?.querySelector("textarea") ?? null, []);
|
||||
useEffect(() => {
|
||||
if (replyTarget) {
|
||||
textareaRef.current?.focus();
|
||||
// Small delay to let textarea mount
|
||||
const timer = setTimeout(() => {
|
||||
const ta = getTextarea();
|
||||
ta?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [replyTarget?.id]);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(value: string) => {
|
||||
setContent(value);
|
||||
const handleChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
setContent(nextValue);
|
||||
if (onTyping) {
|
||||
onTyping(true);
|
||||
clearTimeout(typingTimeout.current);
|
||||
@ -47,30 +75,52 @@ export default function MessageComposer({
|
||||
[onTyping],
|
||||
);
|
||||
|
||||
const handleUploaded = useCallback((att: UploadedAttachment) => {
|
||||
setAttachmentIds((prev) => [...prev, att.id]);
|
||||
}, []);
|
||||
|
||||
const handleEmojiClick = useCallback(
|
||||
(emoji: EmojiClickData) => {
|
||||
const ta = getTextarea();
|
||||
if (!ta) return;
|
||||
|
||||
const start = ta.selectionStart ?? ta.value.length;
|
||||
const end = ta.selectionEnd ?? ta.value.length;
|
||||
const next = ta.value.slice(0, start) + emoji.emoji + ta.value.slice(end);
|
||||
|
||||
// Set native value and dispatch input event so MentionTextarea picks it up
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeSetter?.call(ta, next);
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
|
||||
// Place cursor after inserted emoji
|
||||
const caret = start + emoji.emoji.length;
|
||||
ta.setSelectionRange(caret, caret);
|
||||
ta.focus();
|
||||
|
||||
setEmojiOpen(false);
|
||||
},
|
||||
[getTextarea],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = content.trim();
|
||||
if (!text || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await onSend(text);
|
||||
await onSend(text, attachmentIds.length > 0 ? attachmentIds : undefined);
|
||||
setContent("");
|
||||
setAttachmentIds([]);
|
||||
onTyping?.(false);
|
||||
clearTimeout(typingTimeout.current);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [content, sending, onSend, onTyping]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
}, [content, sending, attachmentIds, onSend, onTyping]);
|
||||
|
||||
const hasContent = content.trim().length > 0;
|
||||
|
||||
@ -78,11 +128,10 @@ export default function MessageComposer({
|
||||
replyTarget?.sender.display_name ??
|
||||
replyTarget?.sender.username ??
|
||||
"Unknown";
|
||||
const replyPreview =
|
||||
replyTarget?.content.slice(0, 100) ?? "";
|
||||
const replyPreview = replyTarget?.content.slice(0, 100) ?? "";
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4 pt-1">
|
||||
<div className="shrink-0 px-4 pb-4 pt-1" ref={containerRef}>
|
||||
{/* Reply indicator */}
|
||||
{replyTarget && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-lg border border-primary/[0.08] bg-primary/[0.03] px-3 py-2">
|
||||
@ -107,6 +156,7 @@ export default function MessageComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composer with custom mention textarea */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-2xl border bg-card/80 shadow-sm transition-[background-color,border-color,box-shadow] duration-200",
|
||||
@ -114,25 +164,55 @@ export default function MessageComposer({
|
||||
replyTarget ? "border-primary/[0.12]" : "border-border/40",
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
aria-label={`Message #${roomName}`}
|
||||
className={cn(
|
||||
"min-h-[52px] max-h-48 resize-none border-0 bg-transparent px-5 py-3.5 pr-28 text-[13px] leading-relaxed shadow-none focus-visible:ring-0",
|
||||
"placeholder:text-muted-foreground/35",
|
||||
)}
|
||||
<MentionTextarea
|
||||
disabled={disabled || sending}
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={
|
||||
replyTarget
|
||||
? `Reply to ${replyAuthorName}…`
|
||||
: `Send a message in #${roomName}`
|
||||
: `Send a message in #${roomName} (use @ to mention)`
|
||||
}
|
||||
ref={textareaRef}
|
||||
projectName={projectName}
|
||||
rooms={rooms}
|
||||
value={content}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 flex items-center gap-1">
|
||||
<FileUploadButton disabled={disabled || sending} />
|
||||
|
||||
{/* Action buttons (absolute positioned over the textarea) */}
|
||||
<div className="absolute right-2 bottom-2 z-20 flex items-center gap-1">
|
||||
<Popover onOpenChange={setEmojiOpen} open={emojiOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="Open emoji picker"
|
||||
className="size-8 cursor-pointer rounded-lg text-muted-foreground/40 hover:bg-accent/50 hover:text-muted-foreground"
|
||||
disabled={disabled || sending}
|
||||
size="icon"
|
||||
title="Add emoji"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<SmilePlus className="size-[15px]" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-auto border-0 bg-transparent p-0 shadow-none"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-xl">
|
||||
<EmojiPicker
|
||||
height={360}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
width={320}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FileUploadButton disabled={disabled || sending} onUploaded={handleUploaded} roomId={roomId} />
|
||||
<Button
|
||||
aria-label="Send message"
|
||||
className={cn(
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Image, Loader2, Paperclip, X } from "lucide-react";
|
||||
import { api } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type UploadedAttachment = {
|
||||
id: string;
|
||||
filename: string;
|
||||
url: string | null;
|
||||
size: number;
|
||||
content_type: string | null;
|
||||
};
|
||||
|
||||
type PendingFile = {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -13,13 +22,15 @@ type PendingFile = {
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
onUploaded?: (url: string, filename: string) => void;
|
||||
roomId?: string;
|
||||
onUploaded?: (att: UploadedAttachment) => void;
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
};
|
||||
|
||||
export default function FileUploadButton({
|
||||
disabled,
|
||||
roomId,
|
||||
onUploaded,
|
||||
maxFiles = 5,
|
||||
maxSize = 50 * 1024 * 1024,
|
||||
@ -28,6 +39,19 @@ export default function FileUploadButton({
|
||||
const [error, setError] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const idCounter = useRef(0);
|
||||
const filesRef = useRef<PendingFile[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
filesRef.current = files;
|
||||
}, [files]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const file of filesRef.current) {
|
||||
if (file.preview) URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(selected: FileList | null) => {
|
||||
@ -59,24 +83,34 @@ export default function FileUploadButton({
|
||||
: undefined,
|
||||
};
|
||||
newFiles.push(pending);
|
||||
simulateUpload(file)
|
||||
.then(() => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
onUploaded?.(`blob:${file.name}`, file.name);
|
||||
|
||||
// Real upload
|
||||
uploadFile(file, id, roomId)
|
||||
.then((attachment) => {
|
||||
setFiles((prev) => {
|
||||
const uploaded = prev.find((f) => f.id === id);
|
||||
if (uploaded?.preview) URL.revokeObjectURL(uploaded.preview);
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
onUploaded?.(attachment);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === id ? { ...f, uploading: false, progress: 0 } : f,
|
||||
),
|
||||
);
|
||||
setError(`Failed to upload ${file.name}`);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? `Failed to upload ${file.name}: ${err.message}`
|
||||
: `Failed to upload ${file.name}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
},
|
||||
[files.length, maxFiles, maxSize, onUploaded],
|
||||
[files.length, maxFiles, maxSize, onUploaded, roomId],
|
||||
);
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
@ -178,8 +212,23 @@ export default function FileUploadButton({
|
||||
);
|
||||
}
|
||||
|
||||
async function simulateUpload(_file: File): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, 800 + Math.random() * 1200);
|
||||
});
|
||||
async function uploadFile(
|
||||
file: File,
|
||||
_pendingId: string,
|
||||
roomId?: string,
|
||||
): Promise<UploadedAttachment> {
|
||||
if (!roomId) throw new Error("No room selected");
|
||||
|
||||
const res = await api.post(
|
||||
`/api/v1/ws/rooms/${roomId}/attachments`,
|
||||
file,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
params: { filename: file.name },
|
||||
},
|
||||
);
|
||||
|
||||
return res.data as UploadedAttachment;
|
||||
}
|
||||
|
||||
223
src/page/workspace/channel/github-embed-card.tsx
Normal file
223
src/page/workspace/channel/github-embed-card.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Loader2,
|
||||
Star,
|
||||
Circle,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import type { GithubLinkMatch } from "./github-link-parser";
|
||||
|
||||
type GithubRepo = {
|
||||
full_name: string;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
language: string | null;
|
||||
stargazers_count: number;
|
||||
forks_count: number;
|
||||
updated_at: string;
|
||||
owner: {
|
||||
avatar_url: string;
|
||||
login: string;
|
||||
};
|
||||
topics: string[];
|
||||
license: {
|
||||
spdx_id: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function languageColor(lang: string): string {
|
||||
const map: Record<string, string> = {
|
||||
Rust: "#DEA584",
|
||||
TypeScript: "#3178C6",
|
||||
JavaScript: "#F7DF1E",
|
||||
Python: "#3572A5",
|
||||
Go: "#00ADD8",
|
||||
Java: "#B07219",
|
||||
Kotlin: "#A97BFF",
|
||||
Swift: "#F05138",
|
||||
C: "#555555",
|
||||
"C++": "#F34B7D",
|
||||
"C#": "#178600",
|
||||
Ruby: "#701516",
|
||||
Zig: "#EC915C",
|
||||
Elixir: "#6E4A7E",
|
||||
Haskell: "#5E5086",
|
||||
CSS: "#563D7C",
|
||||
HTML: "#E34C26",
|
||||
Shell: "#89E051",
|
||||
PHP: "#4F5D95",
|
||||
Dart: "#00B4AB",
|
||||
Scala: "#C22D40",
|
||||
R: "#198CE7",
|
||||
Lua: "#000080",
|
||||
Vue: "#41B883",
|
||||
Svelte: "#FF3E00",
|
||||
MDX: "#FCB32C",
|
||||
Dockerfile: "#384D54",
|
||||
Makefile: "#427819",
|
||||
Markdown: "#083FA1",
|
||||
Nix: "#7E7EFF",
|
||||
OCaml: "#3BE133",
|
||||
Objective_C: "#438EFF",
|
||||
Perl: "#0298C3",
|
||||
Erlang: "#B83998",
|
||||
CMake: "#DA3434",
|
||||
PowerShell: "#012456",
|
||||
SQL: "#E38C00",
|
||||
Solidity: "#AA6746",
|
||||
Terraform: "#7B42BC",
|
||||
Vim_Script: "#199F4B",
|
||||
};
|
||||
return map[lang] ?? "#6B7280";
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 1) return "today";
|
||||
if (days === 1) return "yesterday";
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
export default function GithubEmbedCard({ link }: { link: GithubLinkMatch }) {
|
||||
const [repo, setRepo] = useState<GithubRepo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
fetch(`https://api.github.com/repos/${link.owner}/${link.repo}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error("not found");
|
||||
return res.json();
|
||||
})
|
||||
.then((data: GithubRepo) => {
|
||||
if (!cancelled) setRepo(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [link.owner, link.repo]);
|
||||
|
||||
return (
|
||||
<a
|
||||
className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm"
|
||||
href={link.url}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading GitHub repo…
|
||||
</div>
|
||||
) : error || !repo ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
|
||||
<svg
|
||||
className="size-4 text-muted-foreground/50"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold text-foreground/70">
|
||||
{link.owner}/{link.repo}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground/25" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
alt={repo.owner.login}
|
||||
className="size-8 shrink-0 rounded-full ring-1 ring-border/20"
|
||||
src={repo.owner.avatar_url}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold text-foreground/90">
|
||||
{repo.full_name}
|
||||
</p>
|
||||
{repo.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground/50">
|
||||
{repo.language && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2.5 rounded-full"
|
||||
style={{ backgroundColor: languageColor(repo.language) }}
|
||||
/>
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
{repo.stargazers_count > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="size-3" />
|
||||
{formatCount(repo.stargazers_count)}
|
||||
</span>
|
||||
)}
|
||||
{repo.forks_count > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitFork className="size-3" />
|
||||
{formatCount(repo.forks_count)}
|
||||
</span>
|
||||
)}
|
||||
{repo.license && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Circle className="size-1.5 fill-current" />
|
||||
{repo.license.spdx_id}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{repo.updated_at ? timeAgo(repo.updated_at) : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{repo.topics.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{repo.topics.slice(0, 5).map((t) => (
|
||||
<span
|
||||
className="inline-block rounded-full bg-primary/[0.06] px-2 py-0.5 text-[10px] font-medium text-primary/70"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
36
src/page/workspace/channel/github-link-parser.ts
Normal file
36
src/page/workspace/channel/github-link-parser.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Parse GitHub repository links from message content.
|
||||
* Matches: https://github.com/owner/repo (with optional trailing path)
|
||||
*/
|
||||
|
||||
const GITHUB_REPO_RE =
|
||||
/(?:^|\s)(https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+))(?:\/[\w\-./]*)?(?=[\s?&#]|$)/g;
|
||||
|
||||
export interface GithubLinkMatch {
|
||||
/** Full matched URL string */
|
||||
url: string;
|
||||
/** Repository owner (org or username) */
|
||||
owner: string;
|
||||
/** Repository name */
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export function parseGithubLinks(text: string): GithubLinkMatch[] {
|
||||
const results: GithubLinkMatch[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const match of text.matchAll(GITHUB_REPO_RE)) {
|
||||
const url = match[1];
|
||||
// Dedupe by URL
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
|
||||
results.push({
|
||||
url,
|
||||
owner: match[2],
|
||||
repo: match[3].replace(".git", ""),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@ -126,6 +126,8 @@ export default function ChannelPage() {
|
||||
hasMore={state.hasMore}
|
||||
loading={state.loadingMessages}
|
||||
messages={state.messages}
|
||||
rooms={state.rooms.map((r) => ({ id: r.id, name: r.name, isPrivate: r.is_private }))}
|
||||
workspaceId={state.currentRoom?.workspace_id}
|
||||
onDelete={actions.handleDeleteMessage}
|
||||
onEdit={actions.handleEditMessage}
|
||||
onLoadMore={actions.handleLoadMore}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, Copy, Link, Loader2, UserPlus } from "lucide-react";
|
||||
import { api } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -26,8 +26,21 @@ export default function InviteDialog({ workspaceId, roomId, children }: Props) {
|
||||
const [inviteLink, setInviteLink] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markCopied = useCallback(() => {
|
||||
setCopied(true);
|
||||
if (copiedTimer.current) clearTimeout(copiedTimer.current);
|
||||
copiedTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
@ -42,13 +55,19 @@ export default function InviteDialog({ workspaceId, roomId, children }: Props) {
|
||||
if (code) {
|
||||
const link = `${window.location.origin}/join/${code}`;
|
||||
setInviteLink(link);
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopied(true);
|
||||
toast({
|
||||
title: "Invite link copied",
|
||||
description: "Share this link with your team",
|
||||
});
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
markCopied();
|
||||
toast({
|
||||
title: "Invite link copied",
|
||||
description: "Share this link with your team",
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: "Invite link created",
|
||||
description: "Copy it manually from the input field.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
@ -59,19 +78,26 @@ export default function InviteDialog({ workspaceId, roomId, children }: Props) {
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [workspaceId, roomId, toast]);
|
||||
}, [workspaceId, roomId, markCopied, toast]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!inviteLink) return;
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
toast({ title: "Copied!" });
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [inviteLink, toast]);
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
markCopied();
|
||||
toast({ title: "Copied!" });
|
||||
} catch {
|
||||
toast({
|
||||
title: "Copy failed",
|
||||
description: "Please copy the invite link manually.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [inviteLink, markCopied, toast]);
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogTrigger>{children}</DialogTrigger>
|
||||
<DialogTrigger render={React.Children.only(children) as React.ReactElement} />
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
|
||||
187
src/page/workspace/channel/mention-textarea-utils.ts
Normal file
187
src/page/workspace/channel/mention-textarea-utils.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { client } from "@/client";
|
||||
import { formatMention } from "@/lib/ir/parser";
|
||||
|
||||
export type MentionRoomSuggestion = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
};
|
||||
|
||||
export type MentionType = "all" | "room" | "repo" | "user";
|
||||
|
||||
export type MentionSuggestion = {
|
||||
type: MentionType;
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type MentionToken = MentionSuggestion & {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export type MentionQuery = {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export async function loadRemoteSuggestions(
|
||||
query: string,
|
||||
projectName: string,
|
||||
workspaceId: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<MentionSuggestion[]> {
|
||||
const [repos, members] = await Promise.all([
|
||||
loadRepoSuggestions(query, projectName, signal),
|
||||
loadMemberSuggestions(query, workspaceId, signal),
|
||||
]);
|
||||
return [...repos, ...members];
|
||||
}
|
||||
|
||||
export function findMentionQuery(text: string, caret: number): MentionQuery | null {
|
||||
const prefix = text.slice(0, caret);
|
||||
const match = prefix.match(/(^|\s)@([^\s@]*)$/u);
|
||||
if (!match || match[2].includes("[")) return null;
|
||||
|
||||
const start = (match.index ?? 0) + match[1].length;
|
||||
return { start, end: caret, query: match[2] };
|
||||
}
|
||||
|
||||
export function serializeMentions(text: string, tokens: MentionToken[]) {
|
||||
const ordered = [...tokens].sort((a, b) => a.start - b.start);
|
||||
let cursor = 0;
|
||||
let result = "";
|
||||
|
||||
for (const token of ordered) {
|
||||
if (token.start < cursor) continue;
|
||||
result += text.slice(cursor, token.start);
|
||||
result += formatMention(token.type, token.id, sanitizeMentionLabel(token.label));
|
||||
cursor = token.end;
|
||||
}
|
||||
|
||||
return result + text.slice(cursor);
|
||||
}
|
||||
|
||||
export function reconcileTokens(
|
||||
oldText: string,
|
||||
newText: string,
|
||||
tokens: MentionToken[],
|
||||
) {
|
||||
const change = getChangeRange(oldText, newText);
|
||||
const delta = newText.length - oldText.length;
|
||||
|
||||
return tokens.flatMap((token) => {
|
||||
if (token.end <= change.start) return [token];
|
||||
if (token.start >= change.oldEnd) {
|
||||
return [{ ...token, start: token.start + delta, end: token.end + delta }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function shiftTokensForMention(
|
||||
tokens: MentionToken[],
|
||||
query: MentionQuery,
|
||||
insertedLength: number,
|
||||
nextToken: MentionToken,
|
||||
) {
|
||||
const delta = insertedLength - (query.end - query.start);
|
||||
const shifted = tokens.flatMap((token) => {
|
||||
if (token.end <= query.start) return [token];
|
||||
if (token.start >= query.end) {
|
||||
return [{ ...token, start: token.start + delta, end: token.end + delta }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
return [...shifted, nextToken].sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
export function nextIndex(index: number, count: number, key: string) {
|
||||
return key === "ArrowDown"
|
||||
? (index + 1) % count
|
||||
: (index - 1 + count) % count;
|
||||
}
|
||||
|
||||
export function deleteMentionAtCaret(
|
||||
textarea: HTMLTextAreaElement,
|
||||
text: string,
|
||||
tokens: MentionToken[],
|
||||
emitChange: (text: string, tokens: MentionToken[]) => void,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
if (selectionStart !== selectionEnd) return false;
|
||||
|
||||
const token = tokens.find((item) => selectionStart > item.start && selectionStart <= item.end);
|
||||
if (!token) return false;
|
||||
|
||||
const nextText = text.slice(0, token.start) + text.slice(token.end);
|
||||
const delta = token.end - token.start;
|
||||
const nextTokens = tokens
|
||||
.filter((item) => item !== token)
|
||||
.map((item) => item.start >= token.end
|
||||
? { ...item, start: item.start - delta, end: item.end - delta }
|
||||
: item);
|
||||
|
||||
emitChange(nextText, nextTokens);
|
||||
requestAnimationFrame(() => textarea.setSelectionRange(token.start, token.start));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadRepoSuggestions(query: string, projectName: string, signal: AbortSignal) {
|
||||
if (!projectName) return [];
|
||||
const { data } = await client.gitListRepos(
|
||||
projectName,
|
||||
{ search: query || undefined, limit: 5 },
|
||||
{ signal },
|
||||
);
|
||||
return data.map((repo) => ({
|
||||
type: "repo" as const,
|
||||
id: repo.name,
|
||||
label: repo.name,
|
||||
description: "Repository",
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadMemberSuggestions(
|
||||
query: string,
|
||||
workspaceId: string | undefined,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
if (!workspaceId) return [];
|
||||
const res = await client.workspaceListMembers(workspaceId, undefined, { signal });
|
||||
const q = query.toLowerCase();
|
||||
return res.data
|
||||
.filter((member) => {
|
||||
const displayName = member.display_name || member.username;
|
||||
return displayName.toLowerCase().includes(q) || member.username.toLowerCase().includes(q);
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((member) => ({
|
||||
type: "user" as const,
|
||||
id: member.username,
|
||||
label: member.display_name || member.username,
|
||||
description: `@${member.username}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function sanitizeMentionLabel(label: string) {
|
||||
return label.replace(/[:\]]/g, " ").trim() || "mention";
|
||||
}
|
||||
|
||||
function getChangeRange(oldText: string, newText: string) {
|
||||
let start = 0;
|
||||
while (start < oldText.length && start < newText.length && oldText[start] === newText[start]) {
|
||||
start++;
|
||||
}
|
||||
|
||||
let oldEnd = oldText.length;
|
||||
let newEnd = newText.length;
|
||||
while (oldEnd > start && newEnd > start && oldText[oldEnd - 1] === newText[newEnd - 1]) {
|
||||
oldEnd--;
|
||||
newEnd--;
|
||||
}
|
||||
|
||||
return { start, oldEnd, newEnd };
|
||||
}
|
||||
341
src/page/workspace/channel/mention-textarea.tsx
Normal file
341
src/page/workspace/channel/mention-textarea.tsx
Normal file
@ -0,0 +1,341 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AtSign, GitBranch, Hash, Megaphone, User } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
deleteMentionAtCaret,
|
||||
findMentionQuery,
|
||||
loadRemoteSuggestions,
|
||||
nextIndex,
|
||||
reconcileTokens,
|
||||
serializeMentions,
|
||||
shiftTokensForMention,
|
||||
type MentionQuery,
|
||||
type MentionRoomSuggestion,
|
||||
type MentionSuggestion,
|
||||
type MentionToken,
|
||||
type MentionType,
|
||||
} from "./mention-textarea-utils";
|
||||
|
||||
export type { MentionRoomSuggestion } from "./mention-textarea-utils";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
rooms: MentionRoomSuggestion[];
|
||||
projectName: string;
|
||||
workspaceId?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
const TYPE_ICON: Record<MentionType, typeof AtSign> = {
|
||||
all: Megaphone,
|
||||
room: Hash,
|
||||
repo: GitBranch,
|
||||
user: User,
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<MentionType, string> = {
|
||||
all: "all",
|
||||
room: "channel",
|
||||
repo: "repo",
|
||||
user: "user",
|
||||
};
|
||||
|
||||
export default function MentionTextarea(props: Props) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [displayValue, setDisplayValue] = useState("");
|
||||
const [tokens, setTokens] = useState<MentionToken[]>([]);
|
||||
const [query, setQuery] = useState<MentionQuery | null>(null);
|
||||
const [remoteSuggestions, setRemoteSuggestions] = useState<MentionSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.value) {
|
||||
setDisplayValue("");
|
||||
setTokens([]);
|
||||
setQuery(null);
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
const staticSuggestions = useMemo(
|
||||
() => buildStaticSuggestions(props.rooms),
|
||||
[props.rooms],
|
||||
);
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => buildSuggestions(query, staticSuggestions, remoteSuggestions),
|
||||
[query, remoteSuggestions, staticSuggestions],
|
||||
);
|
||||
|
||||
useRemoteSuggestions({
|
||||
query,
|
||||
projectName: props.projectName,
|
||||
workspaceId: props.workspaceId,
|
||||
setLoading,
|
||||
setRemoteSuggestions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query?.query]);
|
||||
|
||||
const emitChange = useCallback(
|
||||
(nextDisplay: string, nextTokens: MentionToken[]) => {
|
||||
setDisplayValue(nextDisplay);
|
||||
setTokens(nextTokens);
|
||||
props.onChange(serializeMentions(nextDisplay, nextTokens));
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const updateQueryFromCaret = useCallback((text: string, caret: number) => {
|
||||
setQuery(findMentionQuery(text, caret));
|
||||
}, []);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const nextDisplay = e.target.value;
|
||||
const nextTokens = reconcileTokens(displayValue, nextDisplay, tokens);
|
||||
emitChange(nextDisplay, nextTokens);
|
||||
updateQueryFromCaret(nextDisplay, e.target.selectionStart);
|
||||
},
|
||||
[displayValue, emitChange, tokens, updateQueryFromCaret],
|
||||
);
|
||||
|
||||
const insertMention = useCallback(
|
||||
(item: MentionSuggestion) => {
|
||||
if (!query) return;
|
||||
const { nextDisplay, nextTokens, caret } = applyMention(displayValue, tokens, query, item);
|
||||
setQuery(null);
|
||||
emitChange(nextDisplay, nextTokens);
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.setSelectionRange(caret, caret);
|
||||
});
|
||||
},
|
||||
[displayValue, emitChange, query, tokens],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (handleSuggestionKeys(e, query, suggestions, activeIndex, setActiveIndex, insertMention, setQuery)) return;
|
||||
if (handleMentionBackspace(e, displayValue, tokens, emitChange, setQuery)) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
props.onSubmit();
|
||||
}
|
||||
},
|
||||
[activeIndex, displayValue, emitChange, insertMention, props, query, suggestions, tokens],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
className="min-h-[52px] max-h-48 w-full resize-none border-0 bg-transparent px-5 py-3.5 pr-28 text-[13px] leading-relaxed text-foreground shadow-none outline-none placeholder:text-muted-foreground/35 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={props.disabled}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelect={(e) => updateQueryFromCaret(displayValue, e.currentTarget.selectionStart)}
|
||||
placeholder={props.placeholder}
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={displayValue}
|
||||
/>
|
||||
<MentionSuggestions
|
||||
activeIndex={activeIndex}
|
||||
loading={loading}
|
||||
onSelect={insertMention}
|
||||
open={Boolean(query)}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionSuggestions({
|
||||
activeIndex,
|
||||
loading,
|
||||
onSelect,
|
||||
open,
|
||||
suggestions,
|
||||
}: {
|
||||
activeIndex: number;
|
||||
loading: boolean;
|
||||
onSelect: (item: MentionSuggestion) => void;
|
||||
open: boolean;
|
||||
suggestions: MentionSuggestion[];
|
||||
}) {
|
||||
if (!open || (!loading && suggestions.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-3 right-3 z-30 mb-2 overflow-hidden rounded-xl border border-border/60 bg-popover p-1 shadow-xl">
|
||||
{suggestions.map((item, index) => (
|
||||
<SuggestionRow
|
||||
active={index === activeIndex}
|
||||
item={item}
|
||||
key={`${item.type}-${item.id}`}
|
||||
onMouseDown={() => onSelect(item)}
|
||||
/>
|
||||
))}
|
||||
{loading && suggestions.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">Loading mentions…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionRow({
|
||||
active,
|
||||
item,
|
||||
onMouseDown,
|
||||
}: {
|
||||
active: boolean;
|
||||
item: MentionSuggestion;
|
||||
onMouseDown: () => void;
|
||||
}) {
|
||||
const Icon = TYPE_ICON[item.type];
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors",
|
||||
active ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent/60",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onMouseDown();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/60">
|
||||
{item.description ?? TYPE_LABEL[item.type]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function useRemoteSuggestions({
|
||||
query,
|
||||
projectName,
|
||||
workspaceId,
|
||||
setLoading,
|
||||
setRemoteSuggestions,
|
||||
}: {
|
||||
query: MentionQuery | null;
|
||||
projectName: string;
|
||||
workspaceId?: string;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setRemoteSuggestions: (items: MentionSuggestion[]) => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setRemoteSuggestions([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
loadRemoteSuggestions(query.query, projectName, workspaceId, controller.signal)
|
||||
.then((items) => !controller.signal.aborted && setRemoteSuggestions(items))
|
||||
.catch(() => !controller.signal.aborted && setRemoteSuggestions([]))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
}, 150);
|
||||
|
||||
setLoading(true);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [projectName, query, setLoading, setRemoteSuggestions, workspaceId]);
|
||||
}
|
||||
|
||||
function buildStaticSuggestions(rooms: MentionRoomSuggestion[]) {
|
||||
return [
|
||||
{ type: "all" as const, id: "everyone", label: "everyone", description: "Notify everyone" },
|
||||
...rooms.map((room) => ({
|
||||
type: "room" as const,
|
||||
id: room.id,
|
||||
label: room.name,
|
||||
description: room.isPrivate ? "Private channel" : "Channel",
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function buildSuggestions(
|
||||
query: MentionQuery | null,
|
||||
staticSuggestions: MentionSuggestion[],
|
||||
remoteSuggestions: MentionSuggestion[],
|
||||
) {
|
||||
if (!query) return [];
|
||||
const q = query.query.toLowerCase();
|
||||
const local = staticSuggestions.filter((item) =>
|
||||
item.label.toLowerCase().includes(q),
|
||||
);
|
||||
return [...local, ...remoteSuggestions].slice(0, 10);
|
||||
}
|
||||
|
||||
function applyMention(
|
||||
displayValue: string,
|
||||
tokens: MentionToken[],
|
||||
query: MentionQuery,
|
||||
item: MentionSuggestion,
|
||||
) {
|
||||
const mentionText = `@${item.label}`;
|
||||
const insertText = `${mentionText} `;
|
||||
const nextDisplay = `${displayValue.slice(0, query.start)}${insertText}${displayValue.slice(query.end)}`;
|
||||
const nextToken = { ...item, start: query.start, end: query.start + mentionText.length };
|
||||
const nextTokens = shiftTokensForMention(tokens, query, insertText.length, nextToken);
|
||||
return { nextDisplay, nextTokens, caret: query.start + insertText.length };
|
||||
}
|
||||
|
||||
function handleSuggestionKeys(
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
query: MentionQuery | null,
|
||||
suggestions: MentionSuggestion[],
|
||||
activeIndex: number,
|
||||
setActiveIndex: (fn: (idx: number) => number) => void,
|
||||
insertMention: (item: MentionSuggestion) => void,
|
||||
setQuery: (query: MentionQuery | null) => void,
|
||||
) {
|
||||
if (!query) return false;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setQuery(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) return false;
|
||||
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((idx) => nextIndex(idx, suggestions.length, e.key));
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
insertMention(suggestions[activeIndex] ?? suggestions[0]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMentionBackspace(
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
displayValue: string,
|
||||
tokens: MentionToken[],
|
||||
emitChange: (text: string, tokens: MentionToken[]) => void,
|
||||
setQuery: (query: MentionQuery | null) => void,
|
||||
) {
|
||||
if (e.key !== "Backspace") return false;
|
||||
const deleted = deleteMentionAtCaret(e.currentTarget, displayValue, tokens, emitChange);
|
||||
if (!deleted) return false;
|
||||
e.preventDefault();
|
||||
setQuery(null);
|
||||
return true;
|
||||
}
|
||||
@ -1,8 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import RepoEmbedCard from "./repo-embed-card";
|
||||
import XEmbedCard from "./x-embed-card";
|
||||
import GithubEmbedCard from "./github-embed-card";
|
||||
import { parseRepoLinks } from "./repo-link-parser";
|
||||
import { parseXLinks } from "./x-link-parser";
|
||||
import { parseGithubLinks } from "./github-link-parser";
|
||||
import { parseMentions } from "@/lib/ir/parser";
|
||||
import { MentionChip } from "@/lib/ir/mention-chip";
|
||||
import type { MentionData } from "@/lib/ir/parser";
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
@ -10,70 +16,133 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders message content, detecting same-origin repo links and
|
||||
* X/Twitter links, replacing them with embed cards.
|
||||
* Renders message content, detecting:
|
||||
* - @[type:id:label] mentions → rendered as clickable chips
|
||||
* - Same-origin repo links → embed cards
|
||||
* - X/Twitter links → embed cards
|
||||
* - GitHub repo links → embed cards
|
||||
*/
|
||||
export default function MessageContent({ content, contentType }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const { projectName = "" } = useParams();
|
||||
const isPlainText = contentType === "text" || !contentType;
|
||||
|
||||
const elements = useMemo(() => {
|
||||
const repoLinks = parseRepoLinks(content);
|
||||
const xLinks = parseXLinks(content);
|
||||
// 1. Parse mentions
|
||||
const segments = parseMentions(content);
|
||||
|
||||
const allLinks = [
|
||||
...repoLinks.map((l) => ({ kind: "repo" as const, url: l.url, data: l, index: content.indexOf(l.url) })),
|
||||
...xLinks.map((l) => ({ kind: "x" as const, url: l.url, data: l, index: content.indexOf(l.url) })),
|
||||
].sort((a, b) => a.index - b.index);
|
||||
// If no mentions, fall back to link-parsing only
|
||||
if (segments.length === 1 && segments[0].type === "text") {
|
||||
// Just plain text + links
|
||||
const repoLinks = parseRepoLinks(segments[0].content);
|
||||
const xLinks = parseXLinks(segments[0].content);
|
||||
const ghLinks = parseGithubLinks(segments[0].content);
|
||||
|
||||
if (allLinks.length === 0) {
|
||||
return [
|
||||
<p
|
||||
className={
|
||||
isPlainText
|
||||
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
|
||||
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
|
||||
}
|
||||
key="only"
|
||||
>
|
||||
{content}
|
||||
</p>,
|
||||
];
|
||||
if (repoLinks.length === 0 && xLinks.length === 0 && ghLinks.length === 0) {
|
||||
return [
|
||||
<p
|
||||
className={
|
||||
isPlainText
|
||||
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
|
||||
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
|
||||
}
|
||||
key="only"
|
||||
>
|
||||
{segments[0].content}
|
||||
</p>,
|
||||
];
|
||||
}
|
||||
// Fall through to old link-parsing for non-mention content
|
||||
return buildLinkElements(segments[0].content, isPlainText);
|
||||
}
|
||||
|
||||
// 2. Render segments: text segments as paragraphs, mentions as chips
|
||||
const result: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const link of allLinks) {
|
||||
if (link.index > cursor) {
|
||||
const text = content.slice(cursor, link.index);
|
||||
if (text.trim()) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (seg.type === "text" && seg.content.trim()) {
|
||||
// Check for links within text segments
|
||||
const repoLinks = parseRepoLinks(seg.content);
|
||||
const xLinks = parseXLinks(seg.content);
|
||||
const ghLinks = parseGithubLinks(seg.content);
|
||||
|
||||
if (repoLinks.length > 0 || xLinks.length > 0 || ghLinks.length > 0) {
|
||||
result.push(...buildLinkElements(seg.content, isPlainText));
|
||||
} else {
|
||||
result.push(
|
||||
<p
|
||||
className={
|
||||
isPlainText
|
||||
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
|
||||
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
|
||||
}
|
||||
key={`t-${cursor}`}
|
||||
className="whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
|
||||
key={`t-${i}`}
|
||||
>
|
||||
{text}
|
||||
{seg.content}
|
||||
</p>,
|
||||
);
|
||||
}
|
||||
} else if (seg.type === "mention" && seg.mention) {
|
||||
const mention = seg.mention;
|
||||
const handleClick = mention.entityType === "all" ? undefined : (m: MentionData) => {
|
||||
if (m.entityType === "repo") {
|
||||
navigate(`/${projectName}/repo/${m.entityId}`);
|
||||
} else if (m.entityType === "room") {
|
||||
navigate(`/${projectName}/channel/${m.entityId}`);
|
||||
}
|
||||
};
|
||||
result.push(
|
||||
<MentionChip
|
||||
key={`m-${i}-${mention.entityId}`}
|
||||
entityType={mention.entityType}
|
||||
entityId={mention.entityId}
|
||||
entityLabel={mention.entityLabel}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (link.kind === "repo") {
|
||||
result.push(<RepoEmbedCard key={`repo-${link.url}`} link={link.data} />);
|
||||
} else {
|
||||
result.push(<XEmbedCard key={`x-${link.url}`} link={link.data} />);
|
||||
}
|
||||
|
||||
cursor = link.index + link.url.length;
|
||||
}
|
||||
|
||||
if (cursor < content.length) {
|
||||
const text = content.slice(cursor);
|
||||
if (text.trim()) {
|
||||
return result.length > 0 ? result : [<span key="empty" />];
|
||||
}, [content, isPlainText, navigate, projectName]);
|
||||
|
||||
return <div className="flex flex-wrap items-center gap-1">{elements}</div>;
|
||||
}
|
||||
|
||||
/** Build link embed elements from plain text. */
|
||||
function buildLinkElements(
|
||||
text: string,
|
||||
isPlainText: boolean,
|
||||
): React.ReactNode[] {
|
||||
const repoLinks = parseRepoLinks(text);
|
||||
const xLinks = parseXLinks(text);
|
||||
const ghLinks = parseGithubLinks(text);
|
||||
|
||||
const allLinks = [
|
||||
...repoLinks.map((l) => ({ kind: "repo" as const, url: l.url, data: l, index: text.indexOf(l.url) })),
|
||||
...xLinks.map((l) => ({ kind: "x" as const, url: l.url, data: l, index: text.indexOf(l.url) })),
|
||||
...ghLinks.map((l) => ({ kind: "github" as const, url: l.url, data: l, index: text.indexOf(l.url) })),
|
||||
].sort((a, b) => a.index - b.index);
|
||||
|
||||
if (allLinks.length === 0) {
|
||||
return [
|
||||
<p
|
||||
className={
|
||||
isPlainText
|
||||
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
|
||||
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
|
||||
}
|
||||
key="only"
|
||||
>
|
||||
{text}
|
||||
</p>,
|
||||
];
|
||||
}
|
||||
|
||||
const result: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
for (const link of allLinks) {
|
||||
if (link.index > cursor) {
|
||||
const beforeText = text.slice(cursor, link.index);
|
||||
if (beforeText.trim()) {
|
||||
result.push(
|
||||
<p
|
||||
className={
|
||||
@ -83,14 +152,40 @@ export default function MessageContent({ content, contentType }: Props) {
|
||||
}
|
||||
key={`t-${cursor}`}
|
||||
>
|
||||
{text}
|
||||
{beforeText}
|
||||
</p>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [content, isPlainText]);
|
||||
if (link.kind === "repo") {
|
||||
result.push(<RepoEmbedCard key={`repo-${link.url}`} link={link.data} />);
|
||||
} else if (link.kind === "github") {
|
||||
result.push(<GithubEmbedCard key={`gh-${link.url}`} link={link.data} />);
|
||||
} else {
|
||||
result.push(<XEmbedCard key={`x-${link.url}`} link={link.data} />);
|
||||
}
|
||||
|
||||
return <div>{elements}</div>;
|
||||
cursor = link.index + link.url.length;
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
const afterText = text.slice(cursor);
|
||||
if (afterText.trim()) {
|
||||
result.push(
|
||||
<p
|
||||
className={
|
||||
isPlainText
|
||||
? "whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85"
|
||||
: "whitespace-pre-wrap break-words text-[13px] text-foreground/85"
|
||||
}
|
||||
key={`t-${cursor}`}
|
||||
>
|
||||
{afterText}
|
||||
</p>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -18,8 +18,10 @@ type Props = {
|
||||
messages: MessageNewService[];
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
workspaceId?: string;
|
||||
rooms?: { id: string; name: string; isPrivate: boolean }[];
|
||||
onLoadMore: () => void;
|
||||
onSend: (content: string, inReplyTo?: string) => Promise<void>;
|
||||
onSend: (content: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
|
||||
onTyping?: (typing: boolean) => void;
|
||||
onPinToggle?: (messageId: string, pinned: boolean) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
@ -74,6 +76,8 @@ export default function MessageView({
|
||||
messages,
|
||||
loading,
|
||||
hasMore,
|
||||
workspaceId,
|
||||
rooms: channelRooms,
|
||||
onLoadMore,
|
||||
onSend,
|
||||
onTyping,
|
||||
@ -371,9 +375,12 @@ export default function MessageView({
|
||||
)}
|
||||
|
||||
<MessageComposer
|
||||
roomId={roomId}
|
||||
rooms={channelRooms}
|
||||
workspaceId={workspaceId}
|
||||
onCancelReply={() => setReplyTarget(null)}
|
||||
onSend={async (content) => {
|
||||
await onSend(content, replyTarget?.id);
|
||||
onSend={async (content, attachmentIds) => {
|
||||
await onSend(content, replyTarget?.id, attachmentIds);
|
||||
setReplyTarget(null);
|
||||
}}
|
||||
onTyping={onTyping}
|
||||
|
||||
@ -1,46 +1,53 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
GitFork,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
Star,
|
||||
Clock,
|
||||
Lock,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/client";
|
||||
import RepoDrawer from "./repo-drawer";
|
||||
import type { RepoLinkMatch } from "./repo-link-parser";
|
||||
|
||||
type RepoInfo = {
|
||||
type RepoEmbedData = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
default_branch: string;
|
||||
language: string | null;
|
||||
visibility: string;
|
||||
updated_at: string;
|
||||
language: string | null;
|
||||
star_count: number;
|
||||
fork_count: number;
|
||||
topics: string[];
|
||||
};
|
||||
|
||||
function languageColor(lang: string): string {
|
||||
const map: Record<string, string> = {
|
||||
Rust: "#DEA584",
|
||||
TypeScript: "#3178C6",
|
||||
JavaScript: "#F7DF1E",
|
||||
Python: "#3572A5",
|
||||
Go: "#00ADD8",
|
||||
Java: "#B07219",
|
||||
Kotlin: "#A97BFF",
|
||||
Swift: "#F05138",
|
||||
C: "#555555",
|
||||
"C++": "#F34B7D",
|
||||
"C#": "#178600",
|
||||
Ruby: "#701516",
|
||||
Zig: "#EC915C",
|
||||
Elixir: "#6E4A7E",
|
||||
Haskell: "#5E5086",
|
||||
CSS: "#563D7C",
|
||||
HTML: "#E34C26",
|
||||
Shell: "#89E051",
|
||||
Rust: "#DEA584", TypeScript: "#3178C6", JavaScript: "#F7DF1E",
|
||||
Python: "#3572A5", Go: "#00ADD8", Java: "#B07219",
|
||||
Kotlin: "#A97BFF", Swift: "#F05138", C: "#555555",
|
||||
"C++": "#F34B7D", "C#": "#178600", Ruby: "#701516",
|
||||
Zig: "#EC915C", Elixir: "#6E4A7E", Haskell: "#5E5086",
|
||||
CSS: "#563D7C", HTML: "#E34C26", Shell: "#89E051",
|
||||
PHP: "#4F5D95", Dart: "#00B4AB", Scala: "#C22D40",
|
||||
R: "#198CE7", Lua: "#000080", Vue: "#41B883",
|
||||
Svelte: "#FF3E00", MDX: "#FCB32C", Dockerfile: "#384D54",
|
||||
Makefile: "#427819", Markdown: "#083FA1", Nix: "#7E7EFF",
|
||||
OCaml: "#3BE133", "Objective-C": "#438EFF", Perl: "#0298C3",
|
||||
Erlang: "#B83998", CMake: "#DA3434", PowerShell: "#012456",
|
||||
SQL: "#E38C00", Solidity: "#AA6746", Terraform: "#7B42BC",
|
||||
};
|
||||
return map[lang] ?? "#6B7280";
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n === 0) return "";
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(diff / 86400000);
|
||||
@ -53,43 +60,21 @@ function timeAgo(iso: string): string {
|
||||
}
|
||||
|
||||
export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) {
|
||||
const [info, setInfo] = useState<RepoInfo | null>(null);
|
||||
const [data, setData] = useState<RepoEmbedData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on mount
|
||||
setLoading(true);
|
||||
|
||||
const repoPath = `/api/v1/workspace/${link.workspace}/repos/${link.repo}`;
|
||||
setError(false);
|
||||
|
||||
api
|
||||
.get<Record<string, unknown>>(repoPath)
|
||||
.then(async (repoRes) => {
|
||||
if (cancelled) return;
|
||||
const d = repoRes.data as Record<string, unknown>;
|
||||
// Fetch top language
|
||||
let topLang: string | null = null;
|
||||
try {
|
||||
const langRes = await api.get<
|
||||
{ language: string; percent: number }[]
|
||||
>(`${repoPath}/git/languages`);
|
||||
if (!cancelled && langRes.data.length > 0) {
|
||||
topLang = langRes.data[0].language;
|
||||
}
|
||||
} catch {
|
||||
// language fetch is best-effort
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
setInfo({
|
||||
name: (d.name as string) ?? link.repo,
|
||||
description: (d.description as string) ?? null,
|
||||
default_branch: (d.default_branch as string) ?? "main",
|
||||
language: topLang,
|
||||
updated_at: (d.updated_at as string) ?? "",
|
||||
});
|
||||
.get<RepoEmbedData>(
|
||||
`/api/v1/workspace/${link.workspace}/repos/${link.repo}/embed-card`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (!cancelled) setData(res.data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
@ -105,62 +90,99 @@ export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) {
|
||||
|
||||
return (
|
||||
<RepoDrawer repo={link.repo} workspace={link.workspace}>
|
||||
<div className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading repo info…
|
||||
</div>
|
||||
) : error || !info ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<BookOpen className="size-4 shrink-0 text-muted-foreground/30" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold text-foreground/70">
|
||||
{link.workspace}/{link.repo}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/40">
|
||||
Click to open repository
|
||||
</p>
|
||||
<div
|
||||
className="mt-2 block max-w-[420px] cursor-pointer rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading repo info…
|
||||
</div>
|
||||
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground/25" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
) : error || !data ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
|
||||
<BookOpen className="size-4 text-muted-foreground/50" />
|
||||
<div className="grid size-7 place-items-center rounded-md bg-gradient-to-br from-primary/30 to-primary/10 text-[10px] font-bold text-primary/70">
|
||||
{link.repo.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold text-foreground">
|
||||
<p className="text-[13px] font-semibold text-foreground/70">
|
||||
{link.workspace}/
|
||||
<span className="text-primary/80">{link.repo}</span>
|
||||
</p>
|
||||
{info.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
|
||||
{info.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
|
||||
<div className="grid size-7 place-items-center rounded-md bg-gradient-to-br from-primary/30 to-primary/10 text-[10px] font-bold text-primary/70">
|
||||
{data.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold text-foreground/90">
|
||||
{link.workspace}/
|
||||
<span className="text-primary/80">{link.repo}</span>
|
||||
</p>
|
||||
{data.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
|
||||
{data.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground/50">
|
||||
{info.language && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2.5 rounded-full"
|
||||
style={{ backgroundColor: languageColor(info.language) }}
|
||||
/>
|
||||
{info.language}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground/50">
|
||||
{data.language && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block size-2.5 rounded-full"
|
||||
style={{ backgroundColor: languageColor(data.language) }}
|
||||
/>
|
||||
{data.language}
|
||||
</span>
|
||||
)}
|
||||
{data.star_count > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="size-3" />
|
||||
{formatCount(data.star_count)}
|
||||
</span>
|
||||
)}
|
||||
{data.fork_count > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GitFork className="size-3" />
|
||||
{formatCount(data.fork_count)}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{data.visibility === "private" ? (
|
||||
<Lock className="size-3" />
|
||||
) : (
|
||||
<Globe className="size-3" />
|
||||
)}
|
||||
{data.visibility === "private" ? "private" : "public"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{data.updated_at ? timeAgo(data.updated_at) : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.topics.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{data.topics.slice(0, 5).map((t) => (
|
||||
<span
|
||||
className="inline-block rounded-full bg-primary/[0.06] px-2 py-0.5 text-[10px] font-medium text-primary/70"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{info.updated_at ? timeAgo(info.updated_at) : ""}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</RepoDrawer>
|
||||
);
|
||||
|
||||
@ -50,7 +50,7 @@ export type ChannelState = {
|
||||
};
|
||||
|
||||
export type ChannelActions = {
|
||||
handleSend: (content: string, inReplyTo?: string) => Promise<void>;
|
||||
handleSend: (content: string, inReplyTo?: string, attachmentIds?: string[]) => Promise<void>;
|
||||
handleLoadMore: () => void;
|
||||
handleTyping: (typing: boolean) => void;
|
||||
handlePinToggle: (messageId: string, pinned: boolean) => void;
|
||||
@ -79,6 +79,7 @@ export function useChannelState(roomId: string | undefined) {
|
||||
|
||||
const lastSeq = useRef(0);
|
||||
const prevRoomId = useRef<string | undefined>(undefined);
|
||||
const loadRequestSeq = useRef(0);
|
||||
|
||||
// Load threads for current room
|
||||
const { data: threadsResponse } = useQuery({
|
||||
@ -139,6 +140,9 @@ export function useChannelState(roomId: string | undefined) {
|
||||
const loadMessages = useCallback(
|
||||
async (targetRoomId: string, beforeSeq?: number) => {
|
||||
if (!targetRoomId) return;
|
||||
const requestSeq = ++loadRequestSeq.current;
|
||||
const isStaleRequest = () =>
|
||||
requestSeq !== loadRequestSeq.current || targetRoomId !== prevRoomId.current;
|
||||
setLoadingMessages(true);
|
||||
try {
|
||||
const params: Record<string, string | number> = { limit: 50 };
|
||||
@ -149,6 +153,8 @@ export function useChannelState(roomId: string | undefined) {
|
||||
{ params },
|
||||
);
|
||||
|
||||
if (isStaleRequest()) return;
|
||||
|
||||
const result = (response.data as Record<string, unknown>)?.data as { messages?: MessageNewService[] } | undefined;
|
||||
if (result?.messages) {
|
||||
const allMsgs = result.messages as MessageNewService[];
|
||||
@ -170,7 +176,7 @@ export function useChannelState(roomId: string | undefined) {
|
||||
} catch (err) {
|
||||
console.error("Failed to load messages:", err);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
if (!isStaleRequest()) setLoadingMessages(false);
|
||||
}
|
||||
},
|
||||
[messageCache],
|
||||
@ -559,11 +565,12 @@ export function useChannelState(roomId: string | undefined) {
|
||||
}, [roomId, currentUserId, onEvent, queryClient, toast, messageCache]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string, inReplyTo?: string) => {
|
||||
async (content: string, inReplyTo?: string, attachmentIds?: string[]) => {
|
||||
if (!roomId) return;
|
||||
try {
|
||||
const body: Record<string, unknown> = { content, content_type: "text" };
|
||||
if (inReplyTo) body.in_reply_to = inReplyTo;
|
||||
if (attachmentIds && attachmentIds.length > 0) body.attachment_ids = attachmentIds;
|
||||
const res = await api.post(
|
||||
`/api/v1/ws/rooms/${roomId}/messages`,
|
||||
body,
|
||||
|
||||
@ -80,10 +80,9 @@ export default function XEmbedCard({ link }: { link: XLinkMatch }) {
|
||||
@{link.username}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-[12px] leading-relaxed text-muted-foreground/70 [&_a]:text-primary/70 [&_a]:no-underline [&_p]:my-1"
|
||||
dangerouslySetInnerHTML={{ __html: extractText(data.html) }}
|
||||
/>
|
||||
<p className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-muted-foreground/70">
|
||||
{extractText(data.html)}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" />
|
||||
</div>
|
||||
@ -95,11 +94,9 @@ export default function XEmbedCard({ link }: { link: XLinkMatch }) {
|
||||
|
||||
/** Extract plain text from the oEmbed HTML blockquote */
|
||||
function extractText(html: string): string {
|
||||
// The oEmbed HTML is a <blockquote> with <p> tags inside.
|
||||
// We strip the wrapper and keep the inner paragraphs.
|
||||
const match = html.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/);
|
||||
if (!match) return "";
|
||||
return match[1].trim();
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const blockquote = doc.querySelector("blockquote");
|
||||
return (blockquote?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
function XIcon() {
|
||||
|
||||
@ -50,7 +50,7 @@ function AuthorAvatar({ author }: { author: IssueAuthor }) {
|
||||
)}
|
||||
>
|
||||
{author.avatar_url ? (
|
||||
<img alt="" className="size-full object-cover" src={author.avatar_url} />
|
||||
<img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
|
||||
) : (
|
||||
workspaceInitial(name)
|
||||
)}
|
||||
|
||||
@ -53,7 +53,7 @@ function AuthorAvatar({ author }: { author: { username: string; avatar_url?: str
|
||||
)}
|
||||
>
|
||||
{author.avatar_url ? (
|
||||
<img alt="" className="size-full object-cover" src={author.avatar_url} />
|
||||
<img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
|
||||
) : (
|
||||
workspaceInitial(name)
|
||||
)}
|
||||
|
||||
@ -50,7 +50,7 @@ function AuthorAvatar({ author }: { author: { username: string; avatar_url?: str
|
||||
)}
|
||||
>
|
||||
{author.avatar_url ? (
|
||||
<img alt="" className="size-full object-cover" src={author.avatar_url} />
|
||||
<img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
|
||||
) : (
|
||||
workspaceInitial(name)
|
||||
)}
|
||||
|
||||
232
src/page/workspace/join-apply.tsx
Normal file
232
src/page/workspace/join-apply.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Link, Navigate, useLocation, useParams } from "react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Clock, Loader2, UserPlus, XCircle } from "lucide-react";
|
||||
import { client } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/context/auth-context";
|
||||
import NavShell from "@/components/shell/rail";
|
||||
|
||||
function AccessMessage({
|
||||
actionHref,
|
||||
actionText,
|
||||
description,
|
||||
title,
|
||||
}: {
|
||||
actionHref?: string;
|
||||
actionText?: string;
|
||||
description: string;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-background px-4">
|
||||
<section className="w-full max-w-md rounded-2xl border border-border bg-card p-6 text-center shadow-sm">
|
||||
<h1 className="text-base font-semibold text-foreground">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
{actionHref && actionText && (
|
||||
<Button asChild className="mt-5">
|
||||
<Link to={actionHref}>{actionText}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceJoinApplyPage() {
|
||||
const { projectName = "" } = useParams();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const { data: strategy, isLoading } = useQuery({
|
||||
queryKey: ["workspace", projectName, "join-strategy"],
|
||||
queryFn: async () => {
|
||||
const res = await client.workspaceJoinStrategy(projectName);
|
||||
return res.data;
|
||||
},
|
||||
enabled: isAuthenticated && Boolean(projectName),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: myApplies = [] } = useQuery({
|
||||
queryKey: ["workspace", "join", "my-applies"],
|
||||
queryFn: async () => {
|
||||
const res = await client.workspaceMyJoinApplies();
|
||||
return res.data;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const currentApply = useMemo(
|
||||
() => myApplies.find((item) => item.workspace_name === projectName),
|
||||
[myApplies, projectName],
|
||||
);
|
||||
|
||||
const applyJoin = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await client.workspaceApplyJoin(projectName, {
|
||||
answer: answer.trim() || null,
|
||||
message: message.trim() || null,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace", "join", "my-applies"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace", projectName] }),
|
||||
]);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to submit join request.");
|
||||
},
|
||||
});
|
||||
|
||||
const cancelJoin = useMutation({
|
||||
mutationFn: async () => {
|
||||
await client.workspaceCancelJoin(projectName);
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["workspace", "join", "my-applies"] }),
|
||||
});
|
||||
|
||||
const submit = useCallback(() => {
|
||||
setError("");
|
||||
applyJoin.mutate();
|
||||
}, [applyJoin]);
|
||||
|
||||
if (!projectName) return <Navigate replace to="/" />;
|
||||
|
||||
if (authLoading) {
|
||||
return <AccessMessage description="Checking login status…" title="Loading" />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<AccessMessage
|
||||
actionHref={`/auth/login?redirect=${encodeURIComponent(location.pathname + location.search)}`}
|
||||
actionText="去登录"
|
||||
description="登录后才能申请加入 workspace。未登录状态不会展示 workspace 相关数据。"
|
||||
title="请先登录"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pending = currentApply?.status === "pending";
|
||||
const approved = currentApply?.status === "approved";
|
||||
|
||||
return (
|
||||
<NavShell>
|
||||
<main className="grid h-svh place-items-center bg-background px-4">
|
||||
<section className="w-full max-w-lg rounded-2xl border border-border bg-card p-6 shadow-sm">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="grid size-10 place-items-center rounded-xl bg-primary/10 text-primary">
|
||||
<UserPlus className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-foreground">Join {projectName}</h1>
|
||||
<p className="text-sm text-muted-foreground">Request access to this workspace.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> Loading join settings…
|
||||
</div>
|
||||
) : !strategy?.enabled ? (
|
||||
<StatusBlock
|
||||
icon={<XCircle className="size-4" />}
|
||||
title="Join requests are disabled"
|
||||
tone="destructive"
|
||||
/>
|
||||
) : pending ? (
|
||||
<div className="space-y-4">
|
||||
<StatusBlock
|
||||
icon={<Clock className="size-4" />}
|
||||
title="Your request is pending admin approval."
|
||||
tone="warning"
|
||||
/>
|
||||
<Button disabled={cancelJoin.isPending} onClick={() => cancelJoin.mutate()} variant="outline">
|
||||
Cancel request
|
||||
</Button>
|
||||
</div>
|
||||
) : approved ? (
|
||||
<div className="space-y-4">
|
||||
<StatusBlock
|
||||
icon={<CheckCircle2 className="size-4" />}
|
||||
title="You are approved for this workspace."
|
||||
tone="success"
|
||||
/>
|
||||
<Button asChild>
|
||||
<Link to={`/${projectName}/repos`}>Open workspace</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{strategy.require_question && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="join-answer">
|
||||
{strategy.question ?? "Answer the join question"}
|
||||
</label>
|
||||
<Textarea
|
||||
id="join-answer"
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder="Your answer"
|
||||
value={answer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="join-message">
|
||||
Message <span className="font-normal text-muted-foreground">optional</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="join-message"
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Tell admins why you want to join"
|
||||
value={message}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</p>}
|
||||
<Button
|
||||
disabled={applyJoin.isPending || (strategy.require_question && !answer.trim())}
|
||||
onClick={submit}
|
||||
>
|
||||
{applyJoin.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
Submit request
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</NavShell>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBlock({
|
||||
icon,
|
||||
title,
|
||||
tone,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
tone: "success" | "warning" | "destructive";
|
||||
}) {
|
||||
const className = {
|
||||
success: "bg-emerald-500/10 text-emerald-600",
|
||||
warning: "bg-amber-500/10 text-amber-600",
|
||||
destructive: "bg-destructive/10 text-destructive",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-3 rounded-xl px-4 py-3 text-sm ${className}`}>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { NavLink, Outlet, Navigate } from "react-router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@ -111,10 +112,13 @@ export default function RepoLayout() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const allTabs = [
|
||||
...(readme?.html ? [{ label: "README", to: "readme" }] : []),
|
||||
...tabs,
|
||||
];
|
||||
const allTabs = useMemo(
|
||||
() => [
|
||||
...(readme?.html ? [{ label: "README", to: "readme" }] : []),
|
||||
...tabs,
|
||||
],
|
||||
[readme?.html],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@ -31,7 +31,7 @@ function AuthorAvatar({ author }: { author: { username: string; display_name?: s
|
||||
<span
|
||||
className={cn("grid size-6 place-items-center overflow-hidden rounded-full bg-gradient-to-br text-[10px] font-bold text-white", workspaceColor(name))}
|
||||
>
|
||||
{author.avatar_url ? <img alt="" className="size-full object-cover" src={author.avatar_url} /> : workspaceInitial(name)}
|
||||
{author.avatar_url ? <img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} /> : workspaceInitial(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ function AuthorAvatar({ author }: { author: { username: string; avatar_url?: str
|
||||
)}
|
||||
>
|
||||
{author.avatar_url ? (
|
||||
<img alt="" className="size-full object-cover" src={author.avatar_url} />
|
||||
<img alt={name + " 的头像"} className="size-full object-cover" src={author.avatar_url} />
|
||||
) : (
|
||||
workspaceInitial(name)
|
||||
)}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { client } from "@/client";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { sanitizeHtml } from "@/lib/sanitize-html";
|
||||
|
||||
const SKELETON_WIDTHS = Array.from({ length: 8 }, () => 30 + Math.floor(Math.random() * 60));
|
||||
|
||||
@ -18,11 +20,16 @@ export default function ReadmePage() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const safeReadmeHtml = useMemo(
|
||||
() => sanitizeHtml(readme?.html ?? ""),
|
||||
[readme?.html],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="space-y-3">{SKELETON_WIDTHS.map((w, i) => <div className="h-4 animate-pulse rounded bg-muted/50" key={i} style={{ width: `${w}%` }} />)}</div>;
|
||||
}
|
||||
|
||||
if (!readme?.html) {
|
||||
if (!safeReadmeHtml) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<BookOpen className="mx-auto size-5 text-muted-foreground/20" />
|
||||
@ -40,7 +47,7 @@ export default function ReadmePage() {
|
||||
</div>
|
||||
<div
|
||||
className="px-6 py-4 text-[14px] leading-relaxed [&_h1]:text-lg [&_h1]:font-bold [&_h1]:font-heading [&_h1]:mb-3 [&_h1]:mt-6 [&_h1:first-child]:mt-0 [&_h2]:text-base [&_h2]:font-bold [&_h2]:font-heading [&_h2]:mb-2 [&_h2]:mt-5 [&_h3]:text-[15px] [&_h3]:font-bold [&_h3]:mb-2 [&_h3]:mt-4 [&_h4]:text-[14px] [&_h4]:font-bold [&_h4]:mb-1 [&_h4]:mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_a]:text-primary [&_a]:underline [&_a:hover]:opacity-80 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-3 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-3 [&_li]:mb-1 [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[12px] [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-4 [&_pre]:mb-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[12px] [&_blockquote]:border-l-2 [&_blockquote]:border-primary/30 [&_blockquote]:pl-4 [&_blockquote]:py-0.5 [&_blockquote]:mb-3 [&_blockquote]:text-muted-foreground [&_img]:rounded-md [&_img]:max-w-full [&_img]:my-3 [&_hr]:border-border [&_hr]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:mb-3 [&_th]:border [&_th]:border-border [&_th]:px-3 [&_th]:py-2 [&_th]:bg-muted/50 [&_th]:text-left [&_th]:font-heading [&_th]:text-[13px] [&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2"
|
||||
dangerouslySetInnerHTML={{ __html: readme.html }}
|
||||
dangerouslySetInnerHTML={{ __html: safeReadmeHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -24,7 +24,7 @@ const GroupRow = memo(function GroupRow({
|
||||
)}
|
||||
>
|
||||
{group.avatar_url ? (
|
||||
<img alt="" className="size-full object-cover" src={group.avatar_url} />
|
||||
<img alt={group.name + " 的头像"} className="size-full object-cover" src={group.avatar_url} />
|
||||
) : (
|
||||
workspaceInitial(group.name)
|
||||
)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useId, type FormEvent } from "react";
|
||||
import { useEffect, useState, useId, type FormEvent } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { client } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -40,10 +40,18 @@ export default function JoinTab({ projectName }: { projectName: string }) {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const [enabled, setEnabled] = useState(strategy?.enabled ?? false);
|
||||
const [requireApproval, setRequireApproval] = useState(strategy?.require_approval ?? true);
|
||||
const [requireQuestion, setRequireQuestion] = useState(strategy?.require_question ?? false);
|
||||
const [question, setQuestion] = useState(strategy?.question ?? "");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [requireApproval, setRequireApproval] = useState(true);
|
||||
const [requireQuestion, setRequireQuestion] = useState(false);
|
||||
const [question, setQuestion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!strategy) return;
|
||||
setEnabled(strategy.enabled);
|
||||
setRequireApproval(strategy.require_approval);
|
||||
setRequireQuestion(strategy.require_question);
|
||||
setQuestion(strategy.question ?? "");
|
||||
}, [strategy]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@ -51,16 +59,17 @@ export default function JoinTab({ projectName }: { projectName: string }) {
|
||||
setSubmitting(true);
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const answer = (form.get("answer") as string) || null;
|
||||
const rawAnswer = ((form.get("answer") as string) || "").trim();
|
||||
const payload = {
|
||||
enabled,
|
||||
require_approval: requireApproval,
|
||||
require_question: requireQuestion,
|
||||
question: requireQuestion ? question : null,
|
||||
...(rawAnswer || !strategy?.has_answer ? { answer: rawAnswer || null } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await client.workspaceUpdateJoinStrategy(projectName, {
|
||||
enabled,
|
||||
require_approval: requireApproval,
|
||||
require_question: requireQuestion,
|
||||
question: requireQuestion ? question : null,
|
||||
answer,
|
||||
});
|
||||
await client.workspaceUpdateJoinStrategy(projectName, payload);
|
||||
await qc.invalidateQueries({ queryKey: ["workspace", projectName, "join-strategy"] });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update join strategy.");
|
||||
@ -73,7 +82,19 @@ export default function JoinTab({ projectName }: { projectName: string }) {
|
||||
mutationFn: async ({ username, approved }: { username: string; approved: boolean }) => {
|
||||
await client.workspaceApproveJoin(projectName, username, { approved, reason: null });
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["workspace", projectName, "join-applies"] }),
|
||||
onSuccess: async (_data, variables) => {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["workspace", projectName, "join-applies"] }),
|
||||
qc.invalidateQueries({ queryKey: ["workspace", projectName, "members"] }),
|
||||
qc.invalidateQueries({ queryKey: ["workspace", "join", "my-applies"] }),
|
||||
qc.invalidateQueries({ queryKey: ["workspace", "join-applies"] }),
|
||||
]);
|
||||
if (!variables.approved) return;
|
||||
await qc.invalidateQueries({ queryKey: ["workspace", projectName] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to update join request.");
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <FormSkeleton />;
|
||||
@ -146,19 +167,17 @@ export default function JoinTab({ projectName }: { projectName: string }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{strategy?.has_answer && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground" htmlFor={aId}>
|
||||
Expected answer <span className="font-normal text-muted-foreground">— optional</span>
|
||||
</label>
|
||||
<Input
|
||||
className="mt-2 h-10 text-sm"
|
||||
id={aId}
|
||||
name="answer"
|
||||
placeholder="Answer that auto-approves requests"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground" htmlFor={aId}>
|
||||
Expected answer <span className="font-normal text-muted-foreground">— optional</span>
|
||||
</label>
|
||||
<Input
|
||||
className="mt-2 h-10 text-sm"
|
||||
id={aId}
|
||||
name="answer"
|
||||
placeholder={strategy?.has_answer ? "Leave blank to keep current answer" : "Answer that auto-approves requests"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -201,6 +220,12 @@ export default function JoinTab({ projectName }: { projectName: string }) {
|
||||
{apply.message && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{apply.message}</p>
|
||||
)}
|
||||
{apply.question && (
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||
<span className="font-medium">Q:</span> {apply.question}
|
||||
{apply.answer && <span> · <span className="font-medium">A:</span> {apply.answer}</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{new Date(apply.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
@ -209,6 +234,7 @@ export default function JoinTab({ projectName }: { projectName: string }) {
|
||||
<Button
|
||||
aria-label={`Approve ${apply.username}`}
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={approveApply.isPending}
|
||||
onClick={() => approveApply.mutate({ username: apply.username, approved: true })}
|
||||
size="sm"
|
||||
type="button"
|
||||
@ -218,6 +244,7 @@ export default function JoinTab({ projectName }: { projectName: string }) {
|
||||
<Button
|
||||
aria-label={`Reject ${apply.username}`}
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={approveApply.isPending}
|
||||
onClick={() => approveApply.mutate({ username: apply.username, approved: false })}
|
||||
size="sm"
|
||||
type="button"
|
||||
|
||||
@ -34,7 +34,7 @@ const MemberRow = memo(function MemberRow({
|
||||
)}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<img alt="" className="size-full object-cover" src={member.avatar_url} />
|
||||
<img alt={name + " 的头像"} className="size-full object-cover" src={member.avatar_url} />
|
||||
) : (
|
||||
workspaceInitial(name)
|
||||
)}
|
||||
|
||||
@ -501,6 +501,8 @@ function ChatInner({
|
||||
handleStop: () => void;
|
||||
}) {
|
||||
const { textInput } = usePromptInputController();
|
||||
const textInputRef = useRef(textInput);
|
||||
textInputRef.current = textInput;
|
||||
const allMessages = useMemo(
|
||||
() => messageGroups.flatMap((g) => g.messages),
|
||||
[messageGroups],
|
||||
@ -510,26 +512,27 @@ function ChatInner({
|
||||
(text: string) => {
|
||||
if (text.trim()) {
|
||||
sendMessage(text);
|
||||
textInput.clear();
|
||||
textInputRef.current.clear();
|
||||
}
|
||||
},
|
||||
[sendMessage, textInput],
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const ti = textInputRef.current;
|
||||
// ---- Mention-aware backspace ----
|
||||
if (e.key === "Backspace") {
|
||||
const ta = e.currentTarget;
|
||||
const { selectionStart, selectionEnd } = ta;
|
||||
if (selectionStart === selectionEnd && selectionStart > 0) {
|
||||
const hit = mentionAtCursor(textInput.value, selectionStart);
|
||||
const hit = mentionAtCursor(ti.value, selectionStart);
|
||||
if (hit) {
|
||||
e.preventDefault();
|
||||
const newText =
|
||||
textInput.value.slice(0, hit.start) +
|
||||
textInput.value.slice(hit.end);
|
||||
textInput.setInput(newText);
|
||||
ti.value.slice(0, hit.start) +
|
||||
ti.value.slice(hit.end);
|
||||
ti.setInput(newText);
|
||||
// Restore cursor to the position where the mention was.
|
||||
requestAnimationFrame(() => {
|
||||
ta.selectionStart = ta.selectionEnd = hit.start;
|
||||
@ -542,14 +545,14 @@ function ChatInner({
|
||||
// ---- Enter to submit ----
|
||||
if (e.key === "Enter" && !e.shiftKey && !sending) {
|
||||
e.preventDefault();
|
||||
const text = textInput.value;
|
||||
const text = ti.value;
|
||||
if (text.trim()) {
|
||||
sendMessage(text);
|
||||
textInput.clear();
|
||||
ti.clear();
|
||||
}
|
||||
}
|
||||
},
|
||||
[sendMessage, textInput, sending],
|
||||
[sendMessage, sending],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
4
src/vite-env.d.ts
vendored
Normal file
4
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __APP_ENV__: string;
|
||||
Loading…
Reference in New Issue
Block a user