Compare commits
9 Commits
b8bd0ec545
...
32bd760b77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32bd760b77 | ||
|
|
df4cf55b07 | ||
|
|
826fa1226a | ||
|
|
9981664731 | ||
|
|
e64dc94d29 | ||
|
|
aaf518a66c | ||
|
|
f4397256bd | ||
|
|
a1d245a767 | ||
|
|
a3ecf0c88b |
5
Cargo.lock
generated
5
Cargo.lock
generated
@ -4676,6 +4676,7 @@ dependencies = [
|
||||
"ed25519-dalek 3.0.0-pre.6",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"num-bigint-dig",
|
||||
"p256 0.14.0-rc.7",
|
||||
"p384 0.14.0-rc.7",
|
||||
"p521",
|
||||
@ -5989,6 +5990,7 @@ dependencies = [
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@ -8237,6 +8239,7 @@ dependencies = [
|
||||
"rand_core 0.10.0",
|
||||
"sha2 0.11.0",
|
||||
"signature 3.0.0-rc.10",
|
||||
"spki 0.8.0-rc.4",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@ -8292,12 +8295,14 @@ dependencies = [
|
||||
"pageant",
|
||||
"pbkdf2 0.12.2",
|
||||
"pbkdf2 0.13.0",
|
||||
"pkcs1 0.8.0-rc.4",
|
||||
"pkcs5",
|
||||
"pkcs8 0.11.0-rc.11",
|
||||
"polyval 0.7.1",
|
||||
"rand 0.10.1",
|
||||
"rand_core 0.10.0",
|
||||
"ring",
|
||||
"rsa 0.10.0-rc.16",
|
||||
"russh-cryptovec",
|
||||
"russh-util",
|
||||
"salsa20",
|
||||
|
||||
@ -106,7 +106,7 @@ prost-build = "0.14.3"
|
||||
qdrant-client = "1.17.0"
|
||||
prost-types = "0.14.3"
|
||||
rand = "0.10.0"
|
||||
russh = { version = "0.60.2", default-features = false, features = ["ring"] }
|
||||
russh = { version = "0.60.2", default-features = false, features = ["ring", "rsa"] }
|
||||
hmac = { version = "0.13" }
|
||||
hkdf = "0.13.0"
|
||||
sha1_smol = "1.0.1"
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
<link href="/logo.png" rel="icon" type="image/svg+xml"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>GitData.AI</title>
|
||||
<link rel="preload" href="/@fs/node_modules/@fontsource-variable/geist/files/Geist%5Bwght%5D.woff2" as="font" type="font/woff2" crossorigin />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -14,6 +14,12 @@ pub struct SshAuthService {
|
||||
db: AppDatabase,
|
||||
}
|
||||
|
||||
pub struct SshKeyUser {
|
||||
pub user: user::Model,
|
||||
pub key_id: i64,
|
||||
pub key_title: String,
|
||||
}
|
||||
|
||||
impl SshAuthService {
|
||||
pub fn new(db: AppDatabase) -> Self {
|
||||
Self { db }
|
||||
@ -97,7 +103,7 @@ impl SshAuthService {
|
||||
pub async fn find_user_by_public_key(
|
||||
&self,
|
||||
public_key_str: &str,
|
||||
) -> Result<Option<user::Model>, DbErr> {
|
||||
) -> Result<Option<SshKeyUser>, DbErr> {
|
||||
let fingerprint = match self.generate_fingerprint_from_public_key(public_key_str) {
|
||||
Ok(fp) => fp,
|
||||
Err(e) => {
|
||||
@ -144,16 +150,20 @@ impl SshAuthService {
|
||||
.one(self.db.reader())
|
||||
.await?;
|
||||
|
||||
if let Some(ref user) = user_model {
|
||||
if let Some(user) = user_model {
|
||||
tracing::info!(
|
||||
"user authenticated via SSH key user={} key={}",
|
||||
"SSH key matched user={} key={}",
|
||||
user.username,
|
||||
ssh_key.title
|
||||
);
|
||||
self.update_key_last_used_async(ssh_key.id);
|
||||
return Ok(Some(SshKeyUser {
|
||||
user,
|
||||
key_id: ssh_key.id,
|
||||
key_title: ssh_key.title,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(user_model)
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn is_key_expired(&self, ssh_key: &user_ssh_key::Model) -> bool {
|
||||
@ -165,7 +175,7 @@ impl SshAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_key_last_used_async(&self, key_id: i64) {
|
||||
pub fn update_key_last_used_async(&self, key_id: i64) {
|
||||
let db_clone = self.db.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::update_key_last_used_sync(db_clone, key_id).await {
|
||||
|
||||
@ -163,7 +163,52 @@ impl russh::server::Handler for SSHandle {
|
||||
user: &str,
|
||||
public_key: &PublicKey,
|
||||
) -> Result<Auth, Self::Error> {
|
||||
self.auth_publickey(user, public_key).await
|
||||
let client_info = self
|
||||
.client_addr
|
||||
.map(|addr| format!("{}", addr))
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if user != "git" {
|
||||
tracing::warn!(
|
||||
"auth_publickey_offer_rejected_invalid_username user={} client={}",
|
||||
user,
|
||||
client_info
|
||||
);
|
||||
return Err(russh::Error::NotAuthenticated);
|
||||
}
|
||||
|
||||
let public_key_str = public_key.to_string();
|
||||
if public_key_str.len() < 32 {
|
||||
tracing::warn!(
|
||||
"auth_publickey_offer_rejected_invalid_key_length key_length={}",
|
||||
public_key_str.len()
|
||||
);
|
||||
return Err(russh::Error::NotAuthenticated);
|
||||
}
|
||||
|
||||
tracing::info!("auth_publickey_offer client={}", client_info);
|
||||
match self.auth.find_user_by_public_key(&public_key_str).await {
|
||||
Ok(Some(key_user)) => {
|
||||
tracing::info!(
|
||||
"auth_publickey_offer_accepted user={} key={} client={}",
|
||||
key_user.user.username,
|
||||
key_user.key_title,
|
||||
client_info
|
||||
);
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
"auth_publickey_offer_rejected_key_not_found client={}",
|
||||
client_info
|
||||
);
|
||||
Err(russh::Error::NotAuthenticated)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("auth_publickey_offer_error error={}", e);
|
||||
Err(russh::Error::NotAuthenticated)
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn auth_publickey(
|
||||
&mut self,
|
||||
@ -193,8 +238,8 @@ impl russh::server::Handler for SSHandle {
|
||||
}
|
||||
|
||||
tracing::info!("auth_publickey_attempt client={}", client_info);
|
||||
let user_model = match self.auth.find_user_by_public_key(&public_key_str).await {
|
||||
Ok(Some(model)) => model,
|
||||
let key_user = match self.auth.find_user_by_public_key(&public_key_str).await {
|
||||
Ok(Some(key_user)) => key_user,
|
||||
Ok(None) => {
|
||||
tracing::warn!("auth_rejected_key_not_found client={}", client_info);
|
||||
return Err(russh::Error::NotAuthenticated);
|
||||
@ -207,10 +252,11 @@ impl russh::server::Handler for SSHandle {
|
||||
|
||||
tracing::info!(
|
||||
"auth_publickey_success user={} client={}",
|
||||
user_model.username,
|
||||
key_user.user.username,
|
||||
client_info
|
||||
);
|
||||
self.operator = Some(user_model);
|
||||
self.auth.update_key_last_used_async(key_user.key_id);
|
||||
self.operator = Some(key_user.user);
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
async fn auth_openssh_certificate(
|
||||
@ -241,8 +287,8 @@ impl russh::server::Handler for SSHandle {
|
||||
}
|
||||
|
||||
tracing::info!("auth_publickey_attempt client={}", client_info);
|
||||
let user_model = match self.auth.find_user_by_public_key(&public_key_str).await {
|
||||
Ok(Some(model)) => model,
|
||||
let key_user = match self.auth.find_user_by_public_key(&public_key_str).await {
|
||||
Ok(Some(key_user)) => key_user,
|
||||
Ok(None) => {
|
||||
tracing::warn!("auth_rejected_key_not_found client={}", client_info);
|
||||
return Err(russh::Error::NotAuthenticated);
|
||||
@ -255,10 +301,11 @@ impl russh::server::Handler for SSHandle {
|
||||
|
||||
tracing::info!(
|
||||
"auth_publickey_success user={} client={}",
|
||||
user_model.username,
|
||||
key_user.user.username,
|
||||
client_info
|
||||
);
|
||||
self.operator = Some(user_model);
|
||||
self.auth.update_key_last_used_async(key_user.key_id);
|
||||
self.operator = Some(key_user.user);
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
async fn authentication_banner(&mut self) -> Result<Option<String>, Self::Error> {
|
||||
|
||||
@ -82,7 +82,9 @@ impl russh::server::Server for SSHServer {
|
||||
);
|
||||
|
||||
if io_err.kind() == io::ErrorKind::UnexpectedEof {
|
||||
tracing::warn!("Client disconnected during handshake or before authentication");
|
||||
tracing::warn!(
|
||||
"SSH peer closed the connection before a clean disconnect was received"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
14
package.json
14
package.json
@ -21,22 +21,16 @@
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@lobehub/icons": "^5.8.0",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@streamdown/mermaid": "^1.0.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-form": "^1.29.1",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-pacer": "^0.22.0",
|
||||
"@tanstack/react-query": "^5.100.8",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tanstack/react-store": "^0.11.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"ai": "^6.0.177",
|
||||
"alova": "^3.5.1",
|
||||
"axios": "^1.15.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -44,7 +38,6 @@
|
||||
"dayjs": "^1.11.20",
|
||||
"dexie": "^4.4.2",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"dnd-kit": "^0.0.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.4.2",
|
||||
@ -53,15 +46,12 @@
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"orval": "^8.9.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-i18next": "^17.0.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-resizable-panels": "^4.10.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-virtuoso": "^4.18.6",
|
||||
@ -69,9 +59,7 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.7.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@ -96,8 +84,10 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^16.5.0",
|
||||
"orval": "^8.9.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"shadcn": "^4.7.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
"vite": "^7.3.1",
|
||||
|
||||
112
src/App.tsx
112
src/App.tsx
@ -1,67 +1,78 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import { store } from "@/store";
|
||||
import {
|
||||
ChangePasswordPage,
|
||||
ForgotPasswordPage,
|
||||
LoginPage,
|
||||
RegisterPage,
|
||||
ResetPasswordPage,
|
||||
TwoFactorPage,
|
||||
VerifyEmailPage,
|
||||
} from "@/app/auth";
|
||||
import { RootLayout } from "@/app/layout";
|
||||
import { MePage, MeLayout } from "@/app/me";
|
||||
import { MeLayout } from "@/app/me";
|
||||
import { ChannelLayout } from "@/app/channel";
|
||||
import {
|
||||
ProjectLayout,
|
||||
ReposPage,
|
||||
IssuesPage,
|
||||
NewIssuePage,
|
||||
SkillsPage,
|
||||
BoardPage,
|
||||
ChannelPage,
|
||||
RepoDetailPage,
|
||||
CommitDetailPage,
|
||||
IssueDetailPage,
|
||||
SkillDetailPage,
|
||||
PullRequestDetailPage,
|
||||
ProjectSettingsLayout,
|
||||
GeneralSettings,
|
||||
MembersSettings,
|
||||
AccessSettings,
|
||||
LabelsSettings,
|
||||
BillingSettings,
|
||||
} from "@/app/project";
|
||||
import { ChatPage } from "@/app/chat";
|
||||
import { ExplorePage } from "@/app/explore/ExplorePage";
|
||||
import CodePage from "@/app/project/repo/code";
|
||||
import CommitsPage from "@/app/project/repo/commits";
|
||||
import PullsPage from "@/app/project/repo/pulls";
|
||||
import BranchesPage from "@/app/project/repo/branches";
|
||||
import TagsPage from "@/app/project/repo/tags";
|
||||
import RepoSettingsLayout from "@/app/project/repo/settings/RepoSettingsLayout";
|
||||
import RepoGeneralSettings from "@/app/project/repo/settings/GeneralSettings";
|
||||
import BranchProtectionSettings from "@/app/project/repo/settings/BranchProtectionSettings";
|
||||
import TreePage from "@/app/project/repo/tree";
|
||||
import { RedirectIfAuth, RequireAuth } from "@/components/auth";
|
||||
import {
|
||||
SettingsLayout,
|
||||
MyAccountPage,
|
||||
BillingPage,
|
||||
AppearancePage,
|
||||
NotificationsPage,
|
||||
PasswordPage,
|
||||
EmailPage,
|
||||
SshKeysPage,
|
||||
AccessKeysPage,
|
||||
PushSettingsPage,
|
||||
} from "@/app/settings";
|
||||
import RepoSettingsLayout from "@/app/project/repo/settings/RepoSettingsLayout";
|
||||
|
||||
// Lazy-loaded page components
|
||||
const LoginPage = lazy(() => import("@/app/auth/login").then(m => ({ default: m.LoginPage })));
|
||||
const RegisterPage = lazy(() => import("@/app/auth/register").then(m => ({ default: m.RegisterPage })));
|
||||
const ForgotPasswordPage = lazy(() => import("@/app/auth/forgot-password").then(m => ({ default: m.ForgotPasswordPage })));
|
||||
const ResetPasswordPage = lazy(() => import("@/app/auth/reset-password").then(m => ({ default: m.ResetPasswordPage })));
|
||||
const TwoFactorPage = lazy(() => import("@/app/auth/two-factor").then(m => ({ default: m.TwoFactorPage })));
|
||||
const VerifyEmailPage = lazy(() => import("@/app/auth/verify-email").then(m => ({ default: m.VerifyEmailPage })));
|
||||
const ChangePasswordPage = lazy(() => import("@/app/auth/change-password").then(m => ({ default: m.ChangePasswordPage })));
|
||||
const MePage = lazy(() => import("@/app/me").then(m => ({ default: m.MePage })));
|
||||
const ChatPage = lazy(() => import("@/app/chat").then(m => ({ default: m.ChatPage })));
|
||||
const ExplorePage = lazy(() => import("@/app/explore/ExplorePage").then(m => ({ default: m.ExplorePage })));
|
||||
const MyAccountPage = lazy(() => import("@/app/settings").then(m => ({ default: m.MyAccountPage })));
|
||||
const BillingPage = lazy(() => import("@/app/settings").then(m => ({ default: m.BillingPage })));
|
||||
const AppearancePage = lazy(() => import("@/app/settings").then(m => ({ default: m.AppearancePage })));
|
||||
const NotificationsPage = lazy(() => import("@/app/settings").then(m => ({ default: m.NotificationsPage })));
|
||||
const PasswordPage = lazy(() => import("@/app/settings").then(m => ({ default: m.PasswordPage })));
|
||||
const EmailPage = lazy(() => import("@/app/settings").then(m => ({ default: m.EmailPage })));
|
||||
const SshKeysPage = lazy(() => import("@/app/settings").then(m => ({ default: m.SshKeysPage })));
|
||||
const AccessKeysPage = lazy(() => import("@/app/settings").then(m => ({ default: m.AccessKeysPage })));
|
||||
const PushSettingsPage = lazy(() => import("@/app/settings").then(m => ({ default: m.PushSettingsPage })));
|
||||
const ReposPage = lazy(() => import("@/app/project").then(m => ({ default: m.ReposPage })));
|
||||
const IssuesPage = lazy(() => import("@/app/project").then(m => ({ default: m.IssuesPage })));
|
||||
const NewIssuePage = lazy(() => import("@/app/project").then(m => ({ default: m.NewIssuePage })));
|
||||
const SkillsPage = lazy(() => import("@/app/project").then(m => ({ default: m.SkillsPage })));
|
||||
const BoardPage = lazy(() => import("@/app/project").then(m => ({ default: m.BoardPage })));
|
||||
const ChannelPage = lazy(() => import("@/app/project").then(m => ({ default: m.ChannelPage })));
|
||||
const RepoDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.RepoDetailPage })));
|
||||
const CommitDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.CommitDetailPage })));
|
||||
const IssueDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.IssueDetailPage })));
|
||||
const SkillDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.SkillDetailPage })));
|
||||
const PullRequestDetailPage = lazy(() => import("@/app/project").then(m => ({ default: m.PullRequestDetailPage })));
|
||||
const GeneralSettings = lazy(() => import("@/app/project").then(m => ({ default: m.GeneralSettings })));
|
||||
const MembersSettings = lazy(() => import("@/app/project").then(m => ({ default: m.MembersSettings })));
|
||||
const AccessSettings = lazy(() => import("@/app/project").then(m => ({ default: m.AccessSettings })));
|
||||
const LabelsSettings = lazy(() => import("@/app/project").then(m => ({ default: m.LabelsSettings })));
|
||||
const BillingSettings = lazy(() => import("@/app/project").then(m => ({ default: m.BillingSettings })));
|
||||
const CodePage = lazy(() => import("@/app/project/repo/code"));
|
||||
const CommitsPage = lazy(() => import("@/app/project/repo/commits"));
|
||||
const PullsPage = lazy(() => import("@/app/project/repo/pulls"));
|
||||
const BranchesPage = lazy(() => import("@/app/project/repo/branches"));
|
||||
const TagsPage = lazy(() => import("@/app/project/repo/tags"));
|
||||
const RepoGeneralSettings = lazy(() => import("@/app/project/repo/settings/GeneralSettings"));
|
||||
const BranchProtectionSettings = lazy(() => import("@/app/project/repo/settings/BranchProtectionSettings"));
|
||||
const TreePage = lazy(() => import("@/app/project/repo/tree"));
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center" style={{ backgroundColor: "var(--surface-ground)" }}>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2" style={{ borderColor: "var(--border-strong)", borderTopColor: "var(--accent)" }} />
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route element={<RedirectIfAuth />}>
|
||||
<Route path="/auth/login" element={<LoginPage />} />
|
||||
@ -84,6 +95,7 @@ export default function App() {
|
||||
<Route path="/me/stars" element={<MePage />} />
|
||||
<Route path="/me/followers" element={<MePage />} />
|
||||
<Route path="/me/following" element={<MePage />} />
|
||||
<Route path="/me/notify" element={<MePage />} />
|
||||
<Route path="/me/chat" element={<ChatPage scope="personal" />} />
|
||||
<Route path="/me/chat/:conversationId" element={<ChatPage scope="personal" />} />
|
||||
<Route path="/explore" element={<ExplorePage />} />
|
||||
@ -149,7 +161,7 @@ export default function App() {
|
||||
<Route path="/" element={<Navigate to="/me" replace />} />
|
||||
<Route path="*" element={<Navigate to="/me" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
||||
import { useProjectsQuery } from "@/hooks/useProjectsQuery";
|
||||
import { issueSummary } from "@/client/api";
|
||||
import { SettingsModalContext } from "@/components/settings/SettingsModalContext";
|
||||
import { SettingsModal } from "@/components/settings/SettingsModal";
|
||||
import { SettingsDataCacheProvider } from "@/components/settings/SettingsDataCache";
|
||||
@ -18,6 +20,7 @@ const WS_CONFIG = {
|
||||
export function RootLayout() {
|
||||
const { data: user } = useCurrentUserQuery();
|
||||
useProjectsQuery(); // Keep query active for caching
|
||||
const queryClient = useQueryClient();
|
||||
const wsClientRef = useRef<WsClient | null>(null);
|
||||
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
@ -68,6 +71,25 @@ export function RootLayout() {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
// Prefetch common data for faster navigation
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
// Prefetch issue summaries for each project (cached by React Query)
|
||||
const projects = queryClient.getQueryData(["projects"]);
|
||||
if (Array.isArray(projects)) {
|
||||
for (const project of projects.slice(0, 5)) {
|
||||
const name = typeof project === "string" ? project : (project as { name?: string }).name;
|
||||
if (name) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["issueSummary", name],
|
||||
queryFn: () => issueSummary(name).then(r => r.data?.data ?? { total: 0, open: 0, closed: 0 }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [user, queryClient]);
|
||||
|
||||
return (
|
||||
<SettingsDataCacheProvider>
|
||||
<SettingsModalContext.Provider value={modalCtx}>
|
||||
|
||||
@ -19,6 +19,7 @@ import { UserCardList } from "./components/UserCardList";
|
||||
import { FollowerCardList } from "./components/FollowerCardList";
|
||||
import { RepoList } from "./components/RepoList";
|
||||
import { ProjectList } from "./components/ProjectList";
|
||||
import { NotificationList } from "./components/NotificationList";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCurrentUserQuery } from "@/hooks/useAuth";
|
||||
|
||||
@ -43,6 +44,7 @@ export function MePage() {
|
||||
else if (path.includes("/stars")) activeSection = "stars";
|
||||
else if (path.includes("/following")) activeSection = "following";
|
||||
else if (path.includes("/followers")) activeSection = "followers";
|
||||
else if (path.includes("/notify")) activeSection = "notify";
|
||||
|
||||
// Conditional fetching for specific sections
|
||||
const { data: activityData, isLoading: isActivityLoading } = useUserActivityQuery(username, 1, 20);
|
||||
@ -123,6 +125,12 @@ export function MePage() {
|
||||
) : (
|
||||
<FollowerCardList users={followers ?? []} />
|
||||
);
|
||||
case "notify":
|
||||
return (
|
||||
<div className="rounded-xl p-6 border-[0.5px]" style={{ backgroundColor: "var(--surface-secondary)", borderColor: "var(--border-subtle)" }}>
|
||||
<NotificationList />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
History,
|
||||
@ -35,7 +36,7 @@ const ICON_MAP: Record<string, React.ComponentType<{ className?: string; "aria-h
|
||||
avatar_upload: ImageIcon,
|
||||
};
|
||||
|
||||
export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
|
||||
export const ActivityTimeline = memo(function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flow-root">
|
||||
@ -93,4 +94,4 @@ export function ActivityTimeline({ items, isLoading }: ActivityTimelineProps) {
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@ -7,6 +7,7 @@ import {
|
||||
Star,
|
||||
Users,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
PanelLeftClose
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
@ -47,6 +48,11 @@ const ME_NAV_ITEMS: NavItem[] = [
|
||||
name: "Chat",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
path: "/me/notify",
|
||||
name: "Notifications",
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
path: "/me/stars",
|
||||
name: "Stars",
|
||||
|
||||
172
src/app/me/components/NotificationList.tsx
Normal file
172
src/app/me/components/NotificationList.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { notificationList, notificationMarkRead, notificationMarkAllRead } from "@/client/api";
|
||||
import type { NotificationResponse } from "@/client/model";
|
||||
import { Bell, CheckCheck, Mail, MailOpen, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const NOTIFICATION_TYPE_LABELS: Record<string, string> = {
|
||||
mention: "Mention",
|
||||
invitation: "Invitation",
|
||||
role_change: "Role Change",
|
||||
room_created: "Room Created",
|
||||
room_deleted: "Room Deleted",
|
||||
system_announcement: "Announcement",
|
||||
project_invitation: "Project Invitation",
|
||||
};
|
||||
|
||||
function NotificationItem({ notification }: { notification: NotificationResponse }) {
|
||||
const queryClient = useQueryClient();
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: () => notificationMarkRead(notification.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationList"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-4 rounded-xl border-[0.5px] transition-all"
|
||||
style={{
|
||||
backgroundColor: notification.is_read ? "var(--surface-secondary)" : "var(--surface-tertiary)",
|
||||
borderColor: "var(--border-subtle)",
|
||||
opacity: notification.is_read ? 0.75 : 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!notification.is_read) {
|
||||
markReadMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="mt-0.5 w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: notification.is_read ? "var(--surface-ground)" : "var(--accent)" }}
|
||||
>
|
||||
{notification.is_read ? (
|
||||
<MailOpen className="w-4 h-4" style={{ color: "var(--text-muted)" }} />
|
||||
) : (
|
||||
<Mail className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[12px] font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
{NOTIFICATION_TYPE_LABELS[notification.notification_type] || notification.notification_type}
|
||||
</span>
|
||||
{!notification.is_read && (
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: "var(--accent)" }} />
|
||||
)}
|
||||
<span className="text-[11px] ml-auto shrink-0" style={{ color: "var(--text-tertiary)" }}>
|
||||
{new Date(notification.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] font-medium truncate" style={{ color: "var(--text-primary)" }}>
|
||||
{notification.title}
|
||||
</p>
|
||||
{notification.content && (
|
||||
<p className="text-[12px] mt-1 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
|
||||
{notification.content}
|
||||
</p>
|
||||
)}
|
||||
{(notification.room || notification.project) && (
|
||||
<div className="flex items-center gap-2 mt-2 text-[11px]" style={{ color: "var(--text-tertiary)" }}>
|
||||
{notification.project && <span>Project: {notification.project}</span>}
|
||||
{notification.room && <span>Room: {notification.room}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationList() {
|
||||
const queryClient = useQueryClient();
|
||||
const [onlyUnread, setOnlyUnread] = useState(false);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["notificationList", onlyUnread],
|
||||
queryFn: () => notificationList({ only_unread: onlyUnread || undefined, limit: 50 }),
|
||||
});
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: notificationMarkAllRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationList"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: "var(--accent)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
|
||||
<p>Failed to load notifications</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const notifications = data?.data?.data?.notifications ?? [];
|
||||
const unreadCount = data?.data?.data?.unread_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-4 h-4" style={{ color: "var(--text-tertiary)" }} />
|
||||
<span className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{unreadCount > 0 ? `${unreadCount} unread` : "All read"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="text-[12px] px-2.5 py-1 rounded-md transition-colors"
|
||||
style={{
|
||||
color: onlyUnread ? "var(--accent)" : "var(--text-secondary)",
|
||||
backgroundColor: onlyUnread ? "color-mix(in srgb, var(--accent) 10%, transparent)" : "transparent",
|
||||
}}
|
||||
onClick={() => setOnlyUnread(!onlyUnread)}
|
||||
>
|
||||
Unread only
|
||||
</button>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[12px] h-7 gap-1"
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="text-center py-16" style={{ color: "var(--text-muted)" }}>
|
||||
<Bell className="w-10 h-10 mx-auto mb-3" style={{ color: "var(--text-tertiary)" }} />
|
||||
<p className="text-[14px] font-medium">No notifications</p>
|
||||
<p className="text-[12px] mt-1">
|
||||
{onlyUnread ? "No unread notifications" : "You're all caught up"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem key={n.id} notification={n} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useIssuesQuery, useIssueSummaryQuery } from "@/hooks/useIssuesQuery";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { issueGet } from "@/client/api";
|
||||
import { LoadingState } from "@/components/ui/LoadingState";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { ErrorState } from "@/components/ui/ErrorState";
|
||||
@ -17,16 +19,100 @@ import {
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { ISSUES_PAGE } from "@/css/issues/styles";
|
||||
import { useState, useMemo } from "react";
|
||||
import { memo, useState, useMemo, useDeferredValue, useRef, useCallback } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { stripMarkdown, truncate } from "@/lib/utils";
|
||||
import type { IssueResponse, IssueLabelResponse } from "@/client/model";
|
||||
|
||||
interface IssueRowProps {
|
||||
issue: IssueResponse;
|
||||
projectName: string;
|
||||
onNavigate: (path: string) => void;
|
||||
onPrefetch?: (projectName: string, issueNumber: number) => void;
|
||||
}
|
||||
|
||||
const IssueRow = memo(function IssueRow({ issue, projectName, onNavigate, onPrefetch }: IssueRowProps) {
|
||||
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:'));
|
||||
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
|
||||
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={issue.number}
|
||||
onClick={() => onNavigate(`/${projectName}/issues/${issue.number}`)}
|
||||
onMouseEnter={() => onPrefetch?.(projectName, issue.number)}
|
||||
className={ISSUES_PAGE.issueRow}
|
||||
>
|
||||
<div className={`${ISSUES_PAGE.statusIcon} ${issue.state === 'open' ? 'text-green-500' : 'text-purple-500'}`}>
|
||||
{issue.state === 'open' ? <CircleDot className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
<div className={ISSUES_PAGE.issueBody}>
|
||||
<div className={ISSUES_PAGE.issueTop}>
|
||||
<span className={ISSUES_PAGE.issueNum}>#{issue.number}</span>
|
||||
<h3 className={ISSUES_PAGE.issueTitle}>{issue.title}</h3>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{otherLabels.map((l: IssueLabelResponse) => (
|
||||
<span
|
||||
key={l.label_id}
|
||||
className={ISSUES_PAGE.label}
|
||||
style={{
|
||||
backgroundColor: `#${l.label_color}20`,
|
||||
color: `#${l.label_color}`,
|
||||
border: `1px solid #${l.label_color}30`
|
||||
}}
|
||||
>
|
||||
{l.label_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issue.body && (
|
||||
<p className={ISSUES_PAGE.issueExcerpt}>
|
||||
{truncate(stripMarkdown(issue.body), 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={ISSUES_PAGE.issueMeta}>
|
||||
{priority && (
|
||||
<span className={`${ISSUES_PAGE.priorityPill} ${
|
||||
priority === 'critical' ? ISSUES_PAGE.pCritical :
|
||||
priority === 'high' ? ISSUES_PAGE.pHigh :
|
||||
priority === 'medium' ? ISSUES_PAGE.pMedium :
|
||||
ISSUES_PAGE.pLow
|
||||
}`}>
|
||||
{priority === 'critical' ? <Zap className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
|
||||
{priority}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={ISSUES_PAGE.metaItem}>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>{issue.author_username || 'anonymous'}</span>
|
||||
</div>
|
||||
|
||||
<div className={ISSUES_PAGE.metaItem}>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>{new Date(issue.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ESTIMATED_ROW_SIZE = 120;
|
||||
const OVERSCAN = 5;
|
||||
|
||||
export function IssuesPage() {
|
||||
const { projectName } = useParams<{ projectName: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const deferredQuery = useDeferredValue(searchQuery);
|
||||
const isSearchStale = searchQuery !== deferredQuery;
|
||||
|
||||
const { data: issues = [], isLoading, error, refetch } = useIssuesQuery(projectName, {
|
||||
state: activeTab
|
||||
@ -35,18 +121,44 @@ export function IssuesPage() {
|
||||
const { data: summary } = useIssueSummaryQuery(projectName);
|
||||
|
||||
const filteredIssues = useMemo(() => {
|
||||
if (!searchQuery) return issues;
|
||||
const lowQuery = searchQuery.toLowerCase();
|
||||
if (!deferredQuery) return issues;
|
||||
const lowQuery = deferredQuery.toLowerCase();
|
||||
return issues.filter(i =>
|
||||
i.title.toLowerCase().includes(lowQuery) ||
|
||||
(i.body && i.body.toLowerCase().includes(lowQuery)) ||
|
||||
String(i.number).includes(lowQuery)
|
||||
);
|
||||
}, [issues, searchQuery]);
|
||||
}, [issues, deferredQuery]);
|
||||
|
||||
const openCount = summary?.open ?? 0;
|
||||
const closedCount = summary?.closed ?? 0;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredIssues.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ESTIMATED_ROW_SIZE,
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => navigate(path),
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handlePrefetch = useCallback(
|
||||
(project: string, issueNumber: number) => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["issues", project, issueNumber],
|
||||
queryFn: () => issueGet(project, issueNumber).then(r => (r.data?.data as IssueResponse) ?? null),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
if (!projectName) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
@ -80,7 +192,7 @@ export function IssuesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ISSUES_PAGE.container}>
|
||||
<div className={`${ISSUES_PAGE.container} flex flex-col`}>
|
||||
{/* Page Header */}
|
||||
<div className={ISSUES_PAGE.headerRow}>
|
||||
<div className={ISSUES_PAGE.titleGroup}>
|
||||
@ -106,6 +218,11 @@ export function IssuesPage() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{isSearchStale && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="h-3 w-3 animate-spin rounded-full border" style={{ borderColor: "var(--border-strong)", borderTopColor: "var(--accent)" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className={ISSUES_PAGE.filterBtn}>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
@ -162,83 +279,29 @@ export function IssuesPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={ISSUES_PAGE.issueList}>
|
||||
{filteredIssues.map((issue: IssueResponse) => {
|
||||
// Find priority label if exists
|
||||
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:'));
|
||||
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
|
||||
|
||||
// Other labels
|
||||
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
|
||||
|
||||
<div ref={scrollRef} className={`${ISSUES_PAGE.issueList} flex-1 overflow-y-auto`}>
|
||||
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const issue = filteredIssues[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={issue.number}
|
||||
onClick={() => navigate(`/${projectName}/issues/${issue.number}`)}
|
||||
className={ISSUES_PAGE.issueRow}
|
||||
>
|
||||
<div className={`${ISSUES_PAGE.statusIcon} ${issue.state === 'open' ? 'text-green-500' : 'text-purple-500'}`}>
|
||||
{issue.state === 'open' ? <CircleDot className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
<div className={ISSUES_PAGE.issueBody}>
|
||||
<div className={ISSUES_PAGE.issueTop}>
|
||||
<span className={ISSUES_PAGE.issueNum}>#{issue.number}</span>
|
||||
<h3 className={ISSUES_PAGE.issueTitle}>{issue.title}</h3>
|
||||
|
||||
{/* Inline Labels */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{otherLabels.map((l: IssueLabelResponse) => (
|
||||
<span
|
||||
key={l.label_id}
|
||||
className={ISSUES_PAGE.label}
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
backgroundColor: `#${l.label_color}20`,
|
||||
color: `#${l.label_color}`,
|
||||
border: `1px solid #${l.label_color}30`
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{l.label_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issue.body && (
|
||||
<p className={ISSUES_PAGE.issueExcerpt}>
|
||||
{truncate(stripMarkdown(issue.body), 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={ISSUES_PAGE.issueMeta}>
|
||||
{/* Priority Pill */}
|
||||
{priority && (
|
||||
<span className={`${ISSUES_PAGE.priorityPill} ${
|
||||
priority === 'critical' ? ISSUES_PAGE.pCritical :
|
||||
priority === 'high' ? ISSUES_PAGE.pHigh :
|
||||
priority === 'medium' ? ISSUES_PAGE.pMedium :
|
||||
ISSUES_PAGE.pLow
|
||||
}`}>
|
||||
{priority === 'critical' ? <Zap className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
|
||||
{priority}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={ISSUES_PAGE.metaItem}>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
<span>{issue.author_username || 'anonymous'}</span>
|
||||
</div>
|
||||
|
||||
<div className={ISSUES_PAGE.metaItem}>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>{new Date(issue.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IssueRow issue={issue} projectName={projectName} onNavigate={handleNavigate} onPrefetch={handlePrefetch} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
|
||||
import type { RootState, AppDispatch } from "./index";
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@ -1,8 +0,0 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
@ -20,6 +20,43 @@ export default defineConfig({
|
||||
optimizeDeps: {
|
||||
entries: ["src/**/*.{ts,tsx}"],
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id: string) {
|
||||
if (id.includes("node_modules")) {
|
||||
// React + deps that import React — keep together to avoid circular deps
|
||||
if (id.includes("react-dom") || id.includes("react-router-dom") || id.includes("scheduler") || id.includes("@tanstack/react-query")) {
|
||||
return "vendor-react";
|
||||
}
|
||||
if (id.includes("react-markdown") || id.includes("remark-gfm") || id.includes("rehype-raw") || id.includes("rehype-sanitize")) {
|
||||
return "vendor-markdown";
|
||||
}
|
||||
if (id.includes("lucide-react")) {
|
||||
return "vendor-lucide";
|
||||
}
|
||||
if (id.includes("motion")) {
|
||||
return "vendor-motion";
|
||||
}
|
||||
if (id.includes("recharts")) {
|
||||
return "vendor-recharts";
|
||||
}
|
||||
// Streamdown + diagram deps — keep together to avoid circular deps
|
||||
if (id.includes("streamdown") || id.includes("@streamdown") || id.includes("cytoscape") || id.includes("d3-") || id.includes("dagre")) {
|
||||
return "vendor-streamdown";
|
||||
}
|
||||
if (id.includes("@tanstack/react-table")) {
|
||||
return "vendor-table";
|
||||
}
|
||||
if (id.includes("radix-ui")) {
|
||||
return "vendor-radix";
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 400,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user