From 0354c38beffc6b3fed23f97ae6e837d452d171fa Mon Sep 17 00:00:00 2001 From: Hill-waffo Date: Sun, 24 May 2026 16:19:27 +0800 Subject: [PATCH 1/4] [BugFix] fix webhook process (#5047) --- .../subscription_payment_waffo_pancake.go | 5 +- controller/topup_waffo_pancake.go | 14 +++--- go.mod | 2 +- go.sum | 2 + service/waffo_pancake.go | 46 +++++++++++-------- service/waffo_pancake_test.go | 26 +++++++---- .../components/settings/PaymentSetting.jsx | 7 +++ 7 files changed, 62 insertions(+), 40 deletions(-) diff --git a/controller/subscription_payment_waffo_pancake.go b/controller/subscription_payment_waffo_pancake.go index d98f7f6be2d..0915ddc6b24 100644 --- a/controller/subscription_payment_waffo_pancake.go +++ b/controller/subscription_payment_waffo_pancake.go @@ -103,8 +103,9 @@ func SubscriptionRequestWaffoPancakePay(c *gin.Context) { Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2), TaxCategory: "saas", }, - BuyerEmail: getWaffoPancakeBuyerEmail(user), - ExpiresInSeconds: &expiresInSeconds, + BuyerEmail: getWaffoPancakeBuyerEmail(user), + ExpiresInSeconds: &expiresInSeconds, + OrderMerchantExternalID: tradeNo, }) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error())) diff --git a/controller/topup_waffo_pancake.go b/controller/topup_waffo_pancake.go index 98b00acab95..cd8b0d668b2 100644 --- a/controller/topup_waffo_pancake.go +++ b/controller/topup_waffo_pancake.go @@ -96,9 +96,6 @@ func getWaffoPancakeBuyerEmail(user *model.User) string { if user != nil && strings.TrimSpace(user.Email) != "" { return user.Email } - if user != nil { - return fmt.Sprintf("%d@new-api.local", user.Id) - } return "" } @@ -408,8 +405,9 @@ func RequestWaffoPancakePay(c *gin.Context) { Amount: formatWaffoPancakeAmount(payMoney), TaxCategory: "saas", }, - BuyerEmail: getWaffoPancakeBuyerEmail(user), - ExpiresInSeconds: &expiresInSeconds, + BuyerEmail: getWaffoPancakeBuyerEmail(user), + ExpiresInSeconds: &expiresInSeconds, + OrderMerchantExternalID: tradeNo, }) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error())) @@ -485,9 +483,9 @@ func WaffoPancakeWebhook(c *gin.Context) { return } - // Subscription vs top-up dispatch by trade_no prefix (written at - // session-creation time): WAFFO_PANCAKE_SUB- vs WAFFO_PANCAKE-. - rawTradeNo := strings.TrimSpace(event.Data.OrderID) + // Dispatch by trade_no prefix. OrderMerchantExternalID = our trade_no; + // OrderID is Pancake's internal ORD_* (logs only). + rawTradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID) isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-") if isSubscription { diff --git a/go.mod b/go.mod index 672c7418a82..eceb5e7d888 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,7 @@ require ( gorm.io/gorm v1.25.2 ) -require github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 +require github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 require ( github.com/DmitriyVTitov/size v1.5.0 // indirect diff --git a/go.sum b/go.sum index e16f7e20f55..1eb08878e60 100644 --- a/go.sum +++ b/go.sum @@ -312,6 +312,8 @@ github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/a github.com/waffo-com/waffo-pancake-sdk-go v0.1.1/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI= github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 h1:cCSgccM66p7feTtgRqUUGT50tYQOhahsoPXavd+ib1U= github.com/waffo-com/waffo-pancake-sdk-go v0.2.0/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI= +github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 h1:ngQSN/oVB35xTwFPLfg++bxPC+SptcF145Mb6c62YCc= +github.com/waffo-com/waffo-pancake-sdk-go v0.3.1/go.mod h1:OB2MyFIQaefoPO0FV3J+yu9sDP8RVFQ+sbFsXqGuObc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index d603ece19a4..766a686dfe6 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -17,14 +17,15 @@ type WaffoPancakePriceSnapshot struct { } // WaffoPancakeCreateSessionParams is the input to CreateWaffoPancakeCheckoutSession. -// BuyerIdentity (merchant-controlled, stable per user) is what survives the -// buyer editing email at checkout — see WaffoPancakeBuyerIdentityFromUserID. +// BuyerIdentity must be stable per user (see WaffoPancakeBuyerIdentityFromUserID). +// OrderMerchantExternalID = our trade_no; Pancake echoes it back in webhooks. type WaffoPancakeCreateSessionParams struct { - ProductID string - BuyerIdentity string - PriceSnapshot *WaffoPancakePriceSnapshot - BuyerEmail string - ExpiresInSeconds *int + ProductID string + BuyerIdentity string + PriceSnapshot *WaffoPancakePriceSnapshot + BuyerEmail string + ExpiresInSeconds *int + OrderMerchantExternalID string } // WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession. @@ -52,7 +53,9 @@ type WaffoPancakeWebhookEvent struct { } type WaffoPancakeWebhookData struct { + // OrderID = Pancake ORD_* (logs); OrderMerchantExternalID = our trade_no (lookup). OrderID string + OrderMerchantExternalID string BuyerEmail string Currency string Amount string @@ -107,10 +110,11 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake sdkParams := pancake.AuthenticatedCheckoutParams{ CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{ - ProductID: params.ProductID, - Currency: "USD", - BuyerEmail: optionalString(params.BuyerEmail), - ExpiresInSeconds: params.ExpiresInSeconds, + ProductID: params.ProductID, + Currency: "USD", + BuyerEmail: optionalString(params.BuyerEmail), + ExpiresInSeconds: params.ExpiresInSeconds, + OrderMerchantExternalID: optionalString(params.OrderMerchantExternalID), }, BuyerIdentity: params.BuyerIdentity, } @@ -163,6 +167,10 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) if evt.Data.MerchantProvidedBuyerIdentity != nil { identity = *evt.Data.MerchantProvidedBuyerIdentity } + externalID := "" + if evt.Data.OrderMerchantExternalID != nil { + externalID = *evt.Data.OrderMerchantExternalID + } return &WaffoPancakeWebhookEvent{ ID: evt.ID, Timestamp: evt.Timestamp, @@ -172,6 +180,7 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) Mode: string(evt.Mode), Data: WaffoPancakeWebhookData{ OrderID: evt.Data.OrderID, + OrderMerchantExternalID: externalID, BuyerEmail: evt.Data.BuyerEmail, Currency: evt.Data.Currency, Amount: evt.Data.Amount, @@ -183,19 +192,18 @@ func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) } // ResolveWaffoPancakeTradeNo maps a verified webhook event to a local TopUp -// trade_no, rejecting any payload whose buyer identity doesn't match the one -// we recorded at checkout — defence-in-depth on top of signature verification. +// trade_no via OrderMerchantExternalID, and rejects buyer-identity mismatches. func ResolveWaffoPancakeTradeNo(event *WaffoPancakeWebhookEvent) (string, error) { if event == nil { return "", fmt.Errorf("missing webhook event") } - tradeNo := strings.TrimSpace(event.Data.OrderID) + tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID) if tradeNo == "" { - return "", fmt.Errorf("missing webhook orderId") + return "", fmt.Errorf("missing webhook orderMerchantExternalId") } topUp := model.GetTopUpByTradeNo(tradeNo) if topUp == nil || topUp.PaymentProvider != model.PaymentProviderWaffoPancake { - return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo) + return "", fmt.Errorf("waffo pancake order not found for tradeNo=%s", tradeNo) } expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(topUp.UserId) actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity) @@ -216,13 +224,13 @@ func ResolveWaffoPancakeSubscriptionTradeNo(event *WaffoPancakeWebhookEvent) (st if event == nil { return "", fmt.Errorf("missing webhook event") } - tradeNo := strings.TrimSpace(event.Data.OrderID) + tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID) if tradeNo == "" { - return "", fmt.Errorf("missing webhook orderId") + return "", fmt.Errorf("missing webhook orderMerchantExternalId") } order := model.GetSubscriptionOrderByTradeNo(tradeNo) if order == nil || order.PaymentProvider != model.PaymentProviderWaffoPancake { - return "", fmt.Errorf("waffo pancake subscription order not found for webhook orderId=%s", tradeNo) + return "", fmt.Errorf("waffo pancake subscription order not found for tradeNo=%s", tradeNo) } expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(order.UserId) actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity) diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go index 43df1bf5fa8..6f89f045f09 100644 --- a/service/waffo_pancake_test.go +++ b/service/waffo_pancake_test.go @@ -57,7 +57,8 @@ func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *te tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId), }, }) @@ -84,7 +85,8 @@ func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) { // crossed-wires bug or a tampered payload. Either way: reject. tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_identity_mismatch_case", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "ORD_identity_mismatch_case", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user }, }) @@ -113,7 +115,8 @@ func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) { // reject so that we never credit anonymous orders to a specific user. tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_missing_identity", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "ORD_missing_identity", }, }) require.Error(t, err) @@ -146,9 +149,10 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_unknown", - BuyerEmail: user.Email, - Amount: "29.00", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "WAFFO_PANCAKE-unknown", + BuyerEmail: user.Email, + Amount: "29.00", }, }) require.Error(t, err) @@ -177,7 +181,8 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_UsesWebhookOrderIDWhenLocalOrder tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(order.UserId), }, }) @@ -202,7 +207,8 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsBuyerIdentityMismatch(t * tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "WAFFO_PANCAKE_SUB-42-mismatch", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-42-mismatch", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user }, }) @@ -228,7 +234,7 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsMissingBuyerIdentity(t *t tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "WAFFO_PANCAKE_SUB-7-missing-identity", + OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-7-missing-identity", }, }) require.Error(t, err) @@ -253,7 +259,7 @@ func TestResolveWaffoPancakeSubscriptionTradeNo_FailsWhenWebhookOrderIDIsUnknown tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "WAFFO_PANCAKE_SUB-unknown", + OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-unknown", }, }) require.Error(t, err) diff --git a/web/classic/src/components/settings/PaymentSetting.jsx b/web/classic/src/components/settings/PaymentSetting.jsx index 2fb3ab6b4c3..d2d35558a59 100644 --- a/web/classic/src/components/settings/PaymentSetting.jsx +++ b/web/classic/src/components/settings/PaymentSetting.jsx @@ -304,6 +304,13 @@ const PaymentSetting = () => { hideSectionTitle /> + + + Date: Sun, 24 May 2026 16:37:43 +0800 Subject: [PATCH 2/4] fix(payment): hide classic Waffo Pancake settings (#5085) --- service/waffo_pancake.go | 3 + service/waffo_pancake_test.go | 60 +++++++++++-------- .../components/settings/PaymentSetting.jsx | 15 ----- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/service/waffo_pancake.go b/service/waffo_pancake.go index 766a686dfe6..5387eab8624 100644 --- a/service/waffo_pancake.go +++ b/service/waffo_pancake.go @@ -103,6 +103,9 @@ func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancake if strings.TrimSpace(params.BuyerIdentity) == "" { return nil, fmt.Errorf("missing buyer identity") } + if strings.TrimSpace(params.OrderMerchantExternalID) == "" { + return nil, fmt.Errorf("missing order merchant external id") + } client, err := newWaffoPancakeClient() if err != nil { return nil, fmt.Errorf("build Waffo Pancake client: %w", err) diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go index 6f89f045f09..41c91a15ae2 100644 --- a/service/waffo_pancake_test.go +++ b/service/waffo_pancake_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" "strings" "testing" @@ -40,18 +41,29 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { return db } +func TestCreateWaffoPancakeCheckoutSession_RequiresOrderMerchantExternalID(t *testing.T) { + session, err := CreateWaffoPancakeCheckoutSession(context.Background(), &WaffoPancakeCreateSessionParams{ + ProductID: "PROD_checkout_guard", + BuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(1), + }) + + require.Error(t, err) + require.Nil(t, session) + require.Contains(t, err.Error(), "missing order merchant external id") +} + func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) { db := setupWaffoPancakeTestDB(t) topUp := &model.TopUp{ - UserId: 1, - Amount: 10, - Money: 29, - TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", + UserId: 1, + Amount: 10, + Money: 29, + TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", PaymentMethod: model.PaymentMethodWaffoPancake, PaymentProvider: model.PaymentProviderWaffoPancake, - CreateTime: time.Now().Unix(), - Status: common.TopUpStatusPending, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, } require.NoError(t, db.Create(topUp).Error) @@ -70,14 +82,14 @@ func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) { db := setupWaffoPancakeTestDB(t) topUp := &model.TopUp{ - UserId: 42, - Amount: 10, - Money: 29, - TradeNo: "ORD_identity_mismatch_case", + UserId: 42, + Amount: 10, + Money: 29, + TradeNo: "ORD_identity_mismatch_case", PaymentMethod: model.PaymentMethodWaffoPancake, PaymentProvider: model.PaymentProviderWaffoPancake, - CreateTime: time.Now().Unix(), - Status: common.TopUpStatusPending, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, } require.NoError(t, db.Create(topUp).Error) @@ -99,14 +111,14 @@ func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) { db := setupWaffoPancakeTestDB(t) topUp := &model.TopUp{ - UserId: 7, - Amount: 10, - Money: 29, - TradeNo: "ORD_missing_identity", + UserId: 7, + Amount: 10, + Money: 29, + TradeNo: "ORD_missing_identity", PaymentMethod: model.PaymentMethodWaffoPancake, PaymentProvider: model.PaymentProviderWaffoPancake, - CreateTime: time.Now().Unix(), - Status: common.TopUpStatusPending, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, } require.NoError(t, db.Create(topUp).Error) @@ -136,14 +148,14 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing. require.NoError(t, db.Create(user).Error) topUp := &model.TopUp{ - UserId: user.Id, - Amount: 10, - Money: 29, - TradeNo: "WAFFO_PANCAKE-42-123456-abc123", + UserId: user.Id, + Amount: 10, + Money: 29, + TradeNo: "WAFFO_PANCAKE-42-123456-abc123", PaymentMethod: model.PaymentMethodWaffoPancake, PaymentProvider: model.PaymentProviderWaffoPancake, - CreateTime: time.Now().Unix(), - Status: common.TopUpStatusPending, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, } require.NoError(t, db.Create(topUp).Error) diff --git a/web/classic/src/components/settings/PaymentSetting.jsx b/web/classic/src/components/settings/PaymentSetting.jsx index d2d35558a59..880001123d5 100644 --- a/web/classic/src/components/settings/PaymentSetting.jsx +++ b/web/classic/src/components/settings/PaymentSetting.jsx @@ -52,9 +52,6 @@ const PaymentSetting = () => { StripeMinTopUp: 1, StripePromotionCodesEnabled: false, - WaffoPancakeMerchantID: '', - WaffoPancakePrivateKey: '', - WaffoPancakeReturnURL: '', 'payment_setting.compliance_confirmed': false, 'payment_setting.compliance_terms_version': '', 'payment_setting.compliance_confirmed_at': 0, @@ -165,11 +162,6 @@ const PaymentSetting = () => { case 'StripeMinTopUp': newInputs[item.key] = parseFloat(item.value); break; - case 'WaffoPancakeMerchantID': - case 'WaffoPancakePrivateKey': - case 'WaffoPancakeReturnURL': - newInputs[item.key] = item.value; - break; default: if (item.key.endsWith('Enabled')) { newInputs[item.key] = toBoolean(item.value); @@ -304,13 +296,6 @@ const PaymentSetting = () => { hideSectionTitle /> - - - Date: Sun, 24 May 2026 22:09:05 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20refactor(web/default):=20adopt?= =?UTF-8?q?=20drill-in=20sidebar=20pattern=20for=20System=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ad-hoc "workspace" abstraction with a focused, URL-driven "sidebar view" registry that implements the modern Vercel / Cloudflare drill-in pattern: clicking a top-level entry (e.g. System Settings) swaps the sidebar to a contextual workspace, with a `← Back to Dashboard` affordance, instead of stacking sub-navigation in the root. Architecture ------------ - types.ts + SidebarView — declarative nested view config (id, pathPattern, parent, getNavGroups) + SidebarViewParent — back-navigation descriptor + ResolvedSidebarView — { key, view, navGroups } returned by hook + SidebarData — slimmed to { navGroups } only - Workspace — removed (logo/plan never rendered) - lib/sidebar-view-registry.ts (new, replaces workspace-registry.ts) + SIDEBAR_VIEWS array — single source of truth for nested views + resolveSidebarView(pathname) + getNavGroupsForPath(pathname, t) — back-compat helper for the command palette - config/system-settings.config.ts Refactored to export a single SYSTEM_SETTINGS_VIEW (SidebarView) with parent `/dashboard/overview` + label `Back to Dashboard`. - components/sidebar-view-header.tsx (new) Renders only the back affordance (chevron + label). Uses the default SidebarMenuButton size so its typography matches the nav items below; collapses gracefully into icon mode via the existing tooltip behavior. The redundant "title + icon" row was removed — workspace context is already carried by the nav groups. - hooks/use-sidebar-view.ts (new) Encapsulates view resolution and root-nav filtering: · matched view → returns its nav groups verbatim (route-level beforeLoad guards already enforce access); · no match → returns root nav groups, narrowed by user role (admin gate) and useSidebarConfig (admin × user sidebar_modules overlay). - components/app-sidebar.tsx Now a thin presentation layer: reads { key, view, navGroups } from useSidebarView() and orchestrates the view transition via AnimatePresence + MOTION_VARIANTS.sidebarSlide (respects prefers-reduced-motion). No logic, no role checks, no path matching — those live in the hook. - components/command-menu.tsx Switched to the new getNavGroupsForPath() API; behavior preserved. Cleanup ------- - Deleted layout/context/workspace-context.tsx (zero consumers). - Deleted layout/lib/workspace-registry.ts and its workspace-registry.example.ts companion (over-abstracted: name/id metadata, isInWorkspace / getAllWorkspaces / WORKSPACE_IDS were registered but never read). - Removed `workspaces` field from useSidebarData (never consumed after the top-switcher was dropped). - Dropped WorkspaceProvider from authenticated-layout.tsx. - Trimmed dead `Manage and configure` translation key from all six locale files and from static-keys.ts. i18n ---- Added the `Back to Dashboard` key to en, zh, fr, ja, ru, vi, and registered it in static-keys.ts under "Sidebar views". Verification ------------ - bun run typecheck: passes - Lint: no new warnings/errors on the touched files - Adding a new drill-in workspace now only requires registering a SidebarView in SIDEBAR_VIEWS — no changes to AppSidebar required. --- web/default/src/components/command-menu.tsx | 7 +- .../components/data-table/faceted-filter.tsx | 5 +- .../layout/components/app-sidebar.tsx | 78 +++++------ .../components/authenticated-layout.tsx | 35 +++-- .../components/layout/components/footer.tsx | 4 +- .../layout/components/sidebar-view-header.tsx | 70 ++++++++++ .../layout/config/system-settings.config.ts | 30 +++- .../layout/context/workspace-context.tsx | 62 --------- web/default/src/components/layout/index.ts | 37 ++--- .../layout/lib/sidebar-view-registry.ts | 58 ++++++++ .../layout/lib/workspace-registry.example.ts | 119 ---------------- .../layout/lib/workspace-registry.ts | 128 ------------------ web/default/src/components/layout/types.ts | 60 ++++++-- .../src/features/auth/sign-in/index.tsx | 25 ++-- .../auth/sign-up/components/sign-up-form.tsx | 1 - .../channels/components/channels-columns.tsx | 2 +- .../dialogs/fetch-models-dialog.tsx | 18 +-- .../drawers/channel-mutate-drawer.tsx | 4 +- .../overview/performance-health-panel.tsx | 82 ++++++----- .../components/overview/summary-cards.tsx | 23 +--- .../dashboard/components/ui/stat-card.tsx | 3 +- web/default/src/features/dashboard/index.tsx | 2 +- .../home/components/sections/hero.tsx | 4 +- .../components/api-keys-mutate-drawer.tsx | 9 +- .../src/features/keys/lib/api-key-form.ts | 4 +- web/default/src/features/models/index.tsx | 2 +- .../components/profile-settings-card.tsx | 2 +- .../subscriptions-mutate-drawer.tsx | 8 +- .../waffo-pancake-settings-section.tsx | 16 +-- .../columns/common-logs-columns.tsx | 10 +- .../components/common-logs-filter-bar.tsx | 4 +- .../components/dialogs/details-dialog.tsx | 56 ++++---- web/default/src/features/usage-logs/index.tsx | 2 +- web/default/src/features/wallet/lib/ui.tsx | 2 +- web/default/src/hooks/use-sidebar-data.ts | 36 +++-- web/default/src/hooks/use-sidebar-view.ts | 74 ++++++++++ web/default/src/hooks/use-top-nav-links.ts | 2 +- web/default/src/i18n/locales/en.json | 2 +- web/default/src/i18n/locales/fr.json | 2 +- web/default/src/i18n/locales/ja.json | 2 +- web/default/src/i18n/locales/ru.json | 2 +- web/default/src/i18n/locales/vi.json | 2 +- web/default/src/i18n/locales/zh.json | 2 +- web/default/src/i18n/static-keys.ts | 4 +- web/default/src/routes/__root.tsx | 2 +- .../_authenticated/playground/index.tsx | 2 +- 46 files changed, 514 insertions(+), 590 deletions(-) create mode 100644 web/default/src/components/layout/components/sidebar-view-header.tsx delete mode 100644 web/default/src/components/layout/context/workspace-context.tsx create mode 100644 web/default/src/components/layout/lib/sidebar-view-registry.ts delete mode 100644 web/default/src/components/layout/lib/workspace-registry.example.ts delete mode 100644 web/default/src/components/layout/lib/workspace-registry.ts create mode 100644 web/default/src/hooks/use-sidebar-view.ts diff --git a/web/default/src/components/command-menu.tsx b/web/default/src/components/command-menu.tsx index 66af025f28e..692cf8631e2 100644 --- a/web/default/src/components/command-menu.tsx +++ b/web/default/src/components/command-menu.tsx @@ -33,7 +33,7 @@ import { CommandList, CommandSeparator, } from '@/components/ui/command' -import { getNavGroupsForPath } from './layout/lib/workspace-registry' +import { getNavGroupsForPath } from './layout/lib/sidebar-view-registry' import { ScrollArea } from './ui/scroll-area' export function CommandMenu() { @@ -44,8 +44,9 @@ export function CommandMenu() { const { pathname } = useLocation() const sidebarData = useSidebarData() - // 根据当前路径从工作区注册表获取对应的侧边栏配置 - const navGroups = getNavGroupsForPath(pathname, t) || sidebarData.navGroups + // Use the active nested sidebar view's nav groups when one matches + // the current URL; otherwise fall back to the root navigation. + const navGroups = getNavGroupsForPath(pathname, t) ?? sidebarData.navGroups const runCommand = React.useCallback( (command: () => unknown) => { diff --git a/web/default/src/components/data-table/faceted-filter.tsx b/web/default/src/components/data-table/faceted-filter.tsx index bd81a1530e0..9198e7e0801 100644 --- a/web/default/src/components/data-table/faceted-filter.tsx +++ b/web/default/src/components/data-table/faceted-filter.tsx @@ -107,10 +107,7 @@ export function DataTableFacetedFilter({ )} - + diff --git a/web/default/src/components/layout/components/app-sidebar.tsx b/web/default/src/components/layout/components/app-sidebar.tsx index ba387a9ab7f..f96926b39e7 100644 --- a/web/default/src/components/layout/components/app-sidebar.tsx +++ b/web/default/src/components/layout/components/app-sidebar.tsx @@ -16,59 +16,59 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useMemo } from 'react' -import { useLocation } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' -import { useAuthStore } from '@/stores/auth-store' -import { ROLE } from '@/lib/roles' +import { AnimatePresence, motion, useReducedMotion } from 'motion/react' +import { MOTION_TRANSITION, MOTION_VARIANTS } from '@/lib/motion' import { useLayout } from '@/context/layout-provider' -import { useSidebarConfig } from '@/hooks/use-sidebar-config' -import { useSidebarData } from '@/hooks/use-sidebar-data' +import { useSidebarView } from '@/hooks/use-sidebar-view' import { Sidebar, SidebarContent, SidebarRail } from '@/components/ui/sidebar' -import { getNavGroupsForPath } from '../lib/workspace-registry' import { NavGroup } from './nav-group' +import { SidebarViewHeader } from './sidebar-view-header' /** - * Application sidebar component - * Fetches corresponding navigation menu from workspace registry based on current path - * Dynamically filters navigation items based on backend SidebarModulesAdmin configuration + * Application sidebar. * - * Automatically matches workspace configuration for current path through workspace registry system - * Adding new workspaces only requires registration in workspace-registry.ts + * Adopts the Vercel / Cloudflare "drill-in" pattern: the URL drives + * which sidebar *view* is rendered. Clicking a top-level entry like + * `System Settings` swaps the sidebar to a contextual workspace — + * with a `← Back to Dashboard` affordance — instead of stacking the + * sub-navigation inside the root tree. + * + * Architecture: + * - View resolution + filtering: {@link useSidebarView} + * - View registry: `layout/lib/sidebar-view-registry.ts` + * - Per-view header: {@link SidebarViewHeader} + * + * Adding a new nested view only requires registering a {@link SidebarView} + * in the registry; this component requires no changes. */ export function AppSidebar() { - const { t } = useTranslation() const { collapsible, variant } = useLayout() - const { pathname } = useLocation() - const userRole = useAuthStore((state) => state.auth.user?.role) - const sidebarData = useSidebarData() - - // Get navigation group configuration corresponding to current path from workspace registry - const allNavGroups = getNavGroupsForPath(pathname, t) || sidebarData.navGroups - - // Filter sidebar navigation items based on backend configuration - const configFilteredNavGroups = useSidebarConfig(allNavGroups) - - // Filter navigation groups based on user role - // Non-Admin users cannot see Admin navigation group - const currentNavGroups = useMemo(() => { - const isAdmin = userRole && userRole >= ROLE.ADMIN - return configFilteredNavGroups.filter((group) => { - if (group.id === 'admin') { - return isAdmin - } - return true - }) - }, [configFilteredNavGroups, userRole]) + const { key, view, navGroups } = useSidebarView() + const shouldReduce = useReducedMotion() return ( + {view && } + - {currentNavGroups.map((props) => { - const key = props.id || props.title - return - })} + + + {navGroups.map((props) => ( + + ))} + + + ) diff --git a/web/default/src/components/layout/components/authenticated-layout.tsx b/web/default/src/components/layout/components/authenticated-layout.tsx index 49a1ff89e02..a39b4e19faf 100644 --- a/web/default/src/components/layout/components/authenticated-layout.tsx +++ b/web/default/src/components/layout/components/authenticated-layout.tsx @@ -23,7 +23,6 @@ import { SearchProvider } from '@/context/search-provider' import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar' import { AnimatedOutlet } from '@/components/page-transition' import { SkipToMain } from '@/components/skip-to-main' -import { WorkspaceProvider } from '../context/workspace-context' import { AppHeader } from './app-header' import { AppSidebar } from './app-sidebar' @@ -37,24 +36,22 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) { return ( - - - - -
- - - {props.children ?? } - -
-
-
+ + + +
+ + + {props.children ?? } + +
+
) diff --git a/web/default/src/components/layout/components/footer.tsx b/web/default/src/components/layout/components/footer.tsx index 4e18e6724c4..be4e612fd42 100644 --- a/web/default/src/components/layout/components/footer.tsx +++ b/web/default/src/components/layout/components/footer.tsx @@ -20,8 +20,8 @@ import { Fragment, useMemo } from 'react' import { Link } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' -import { useSystemConfig } from '@/hooks/use-system-config' import { useStatus } from '@/hooks/use-status' +import { useSystemConfig } from '@/hooks/use-system-config' interface FooterLink { text: string @@ -235,7 +235,7 @@ export function Footer(props: FooterProps) { className='custom-footer text-muted-foreground min-w-0 text-center text-sm sm:text-left' dangerouslySetInnerHTML={{ __html: footerHtml }} /> -
+
diff --git a/web/default/src/components/layout/components/sidebar-view-header.tsx b/web/default/src/components/layout/components/sidebar-view-header.tsx new file mode 100644 index 00000000000..97d001bc7ef --- /dev/null +++ b/web/default/src/components/layout/components/sidebar-view-header.tsx @@ -0,0 +1,70 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Link } from '@tanstack/react-router' +import { ChevronLeft } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/components/ui/sidebar' +import type { SidebarView } from '../types' + +type SidebarViewHeaderProps = { + view: SidebarView +} + +/** + * Header for a nested sidebar view (Vercel / Cloudflare drill-in pattern). + * + * Renders only the back affordance — workspace context is conveyed by + * the nav groups below, not a redundant title row. + */ +export function SidebarViewHeader(props: SidebarViewHeaderProps) { + const { t } = useTranslation() + const { setOpenMobile } = useSidebar() + + return ( + + + + setOpenMobile(false)} + /> + } + > + + {t(props.view.parent.label)} + + + + + ) +} diff --git a/web/default/src/components/layout/config/system-settings.config.ts b/web/default/src/components/layout/config/system-settings.config.ts index 281223a81fc..c0b7b260b29 100644 --- a/web/default/src/components/layout/config/system-settings.config.ts +++ b/web/default/src/components/layout/config/system-settings.config.ts @@ -33,15 +33,16 @@ import { getModelsSectionNavItems } from '@/features/system-settings/models/sect import { getOperationsSectionNavItems } from '@/features/system-settings/operations/section-registry.tsx' import { getSecuritySectionNavItems } from '@/features/system-settings/security/section-registry.tsx' import { getSiteSectionNavItems } from '@/features/system-settings/site/section-registry.tsx' -import { type NavGroup } from '../types' +import type { NavGroup, SidebarView } from '../types' /** - * System settings sidebar configuration - * Displayed when switching to "System Settings" workspace + * Sidebar nav groups for the System Settings nested view. + * + * Kept as a single group because the workspace title in the sidebar + * header already provides top-level context — the inner group label + * scopes the items as "administration" actions. */ -export const WORKSPACE_SYSTEM_SETTINGS_ID = 'system-settings' - -export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] { +function getSystemSettingsNavGroups(t: TFunction): NavGroup[] { return [ { id: 'system-administration', @@ -86,3 +87,20 @@ export function getSystemSettingsNavGroups(t: TFunction): NavGroup[] { }, ] } + +/** + * Nested sidebar view for `/system-settings/*`. + * + * Activates the Vercel / Cloudflare-style drill-in sidebar: + * the root navigation is replaced by the system administration + * groups, with a "Back to Dashboard" affordance in the header. + */ +export const SYSTEM_SETTINGS_VIEW: SidebarView = { + id: 'system-settings', + pathPattern: /^\/system-settings(\/|$)/, + parent: { + to: '/dashboard/overview', + label: 'Back to Dashboard', + }, + getNavGroups: getSystemSettingsNavGroups, +} diff --git a/web/default/src/components/layout/context/workspace-context.tsx b/web/default/src/components/layout/context/workspace-context.tsx deleted file mode 100644 index c5b2a96bdca..00000000000 --- a/web/default/src/components/layout/context/workspace-context.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright (C) 2023-2026 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ -/* eslint-disable react-refresh/only-export-components */ -import * as React from 'react' -import { type Workspace } from '../types' - -type WorkspaceContextType = { - activeWorkspace: Workspace | null - setActiveWorkspace: (workspace: Workspace) => void -} - -const WorkspaceContext = React.createContext( - undefined -) - -/** - * 工作区上下文 Provider - * 管理当前激活的工作区状态,用于切换不同的侧边栏视图 - */ -export function WorkspaceProvider({ children }: { children: React.ReactNode }) { - const [activeWorkspace, setActiveWorkspace] = - React.useState(null) - - const value = React.useMemo( - () => ({ activeWorkspace, setActiveWorkspace }), - [activeWorkspace] - ) - - return ( - - {children} - - ) -} - -/** - * 使用工作区上下文的 Hook - * @throws 如果在 WorkspaceProvider 外部使用会抛出错误 - */ -export function useWorkspace() { - const context = React.useContext(WorkspaceContext) - if (!context) { - throw new Error('useWorkspace must be used within WorkspaceProvider') - } - return context -} diff --git a/web/default/src/components/layout/index.ts b/web/default/src/components/layout/index.ts index 7a3ac23e6b9..fe5ae178bc4 100644 --- a/web/default/src/components/layout/index.ts +++ b/web/default/src/components/layout/index.ts @@ -17,10 +17,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ /** - * Layout 组件统一导出 + * Public surface of the Layout module. */ -// 核心组件 +// Core components export { AppHeader } from './components/app-header' export { AppSidebar } from './components/app-sidebar' export { AuthenticatedLayout } from './components/authenticated-layout' @@ -34,41 +34,34 @@ export { Main } from './components/main' export { PageFooterPortal } from './components/page-footer' export { NavGroup } from './components/nav-group' export { SectionPageLayout } from './components/section-page-layout' +export { SidebarViewHeader } from './components/sidebar-view-header' export { SystemBrand } from './components/system-brand' export { TopNav } from './components/top-nav' export { MobileDrawer } from './components/mobile-drawer' -// 上下文 -export { WorkspaceProvider, useWorkspace } from './context/workspace-context' - -// 配置 -export { - getSystemSettingsNavGroups, - WORKSPACE_SYSTEM_SETTINGS_ID, -} from './config/system-settings.config' +// Configuration +export { SYSTEM_SETTINGS_VIEW } from './config/system-settings.config' export { defaultTopNavLinks } from './config/top-nav.config' -// 常量 +// Constants export { MOBILE_DRAWER_ANIMATION, MOBILE_DRAWER_CONFIG } from './constants' -// 工具函数 - 工作区注册表 +// Sidebar view registry export { - getWorkspaceByPath, getNavGroupsForPath, - isInWorkspace, - getAllWorkspaces, - WORKSPACE_IDS, -} from './lib/workspace-registry' + resolveSidebarView, +} from './lib/sidebar-view-registry' -// 类型导出(使用 type-only 导出避免与组件冲突) +// Type exports (type-only to avoid conflicts with components above) export type { - Workspace, - NavLink, NavCollapsible, - NavItem, NavGroup as NavGroupType, + NavItem, + NavLink, + ResolvedSidebarView, SidebarData, + SidebarView, + SidebarViewParent, TopNavLink, } from './types' -export type { WorkspaceConfig, WorkspaceId } from './lib/workspace-registry' export type { SectionPageLayoutProps } from './components/section-page-layout' diff --git a/web/default/src/components/layout/lib/sidebar-view-registry.ts b/web/default/src/components/layout/lib/sidebar-view-registry.ts new file mode 100644 index 00000000000..c22f880865d --- /dev/null +++ b/web/default/src/components/layout/lib/sidebar-view-registry.ts @@ -0,0 +1,58 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { type TFunction } from 'i18next' +import { SYSTEM_SETTINGS_VIEW } from '../config/system-settings.config' +import type { NavGroup, SidebarView } from '../types' + +/** + * Registered nested sidebar views. + * + * Each entry describes a contextual sidebar that replaces the root + * navigation when the user enters that workspace (Vercel-style + * "drill-in" pattern). Add new entries here to register a new view. + * + * Match priority is array order; the first matching `pathPattern` wins. + */ +const SIDEBAR_VIEWS: readonly SidebarView[] = [SYSTEM_SETTINGS_VIEW] + +/** + * Resolve the active nested view for the given path. + * + * @returns Matching {@link SidebarView}, or `null` when the root + * navigation should be displayed. + */ +export function resolveSidebarView(pathname: string): SidebarView | null { + return SIDEBAR_VIEWS.find((view) => view.pathPattern.test(pathname)) ?? null +} + +/** + * Backwards-compatible helper for consumers (e.g. command palette) that + * just need the navigation groups for the current path, without caring + * about the view metadata. + * + * @returns Nav groups for the matched view, or `null` if no nested view + * matches (callers should then fall back to root nav groups). + */ +export function getNavGroupsForPath( + pathname: string, + t: TFunction +): NavGroup[] | null { + const view = resolveSidebarView(pathname) + return view ? view.getNavGroups(t) : null +} diff --git a/web/default/src/components/layout/lib/workspace-registry.example.ts b/web/default/src/components/layout/lib/workspace-registry.example.ts deleted file mode 100644 index 8e330725b5b..00000000000 --- a/web/default/src/components/layout/lib/workspace-registry.example.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* -Copyright (C) 2023-2026 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ -/** - * 工作区注册表使用示例 - * - * 本文件展示如何添加新工作区,仅作示例参考,不会被编译 - */ - -/** - * 步骤1: 创建工作区的侧边栏配置文件 - * 例如:web/src/components/layout/config/user-management.config.ts - */ -/* -import { Users, UserPlus, Shield } from 'lucide-react' -import { type NavGroup } from '../types' - -export const userManagementConfig: NavGroup[] = [ - { - title: 'User Management', - items: [ - { - title: 'All Users', - url: '/user-management/list', - icon: Users, - }, - { - title: 'Create User', - url: '/user-management/create', - icon: UserPlus, - }, - { - title: 'Permissions', - url: '/user-management/permissions', - icon: Shield, - }, - ], - }, -] -*/ - -/** - * 步骤2: 在 workspace-registry.ts 中注册新工作区 - * 在 workspaceRegistry 数组中添加配置(在默认工作区之前) - */ -/* -import { userManagementConfig } from '../config/user-management.config' - -const workspaceRegistry: WorkspaceConfig[] = [ - // System Settings 工作区 - { - name: 'System Settings', - pathPattern: /^\/system-settings/, - navGroups: systemSettingsConfig, - }, - // 新增的 User Management 工作区 - { - name: 'User Management', - pathPattern: /^\/user-management/, // 或使用字符串: '/user-management' - navGroups: userManagementConfig, - }, - // 默认工作区(必须放在最后) - { - name: 'Default', - pathPattern: /.* /, - navGroups: sidebarConfig.navGroups, - }, -] -*/ - -/** - * 步骤3: (可选)在 sidebar.config.ts 中添加工作区到切换器 - */ -/* -export const sidebarConfig: SidebarData = { - workspaces: [ - { - name: '', - logo: Command, - plan: '', - }, - { - name: 'User Management', - logo: Users, - plan: 'Manage users', - }, - { - name: 'System Settings', - logo: Settings, - plan: 'Manage and configure', - }, - ], - navGroups: [...], -} -*/ - -/** - * 同语注:这里就完成了,现在: - * - 侧边栏会根据当前路径自动切换显示对应的工作区菜单 - * - 搜索功能会自动显示当前工作区的菜单项 - * - 工作区切换器会显示新的工作区选项 - * - * 无需修改任何其他文件! - */ diff --git a/web/default/src/components/layout/lib/workspace-registry.ts b/web/default/src/components/layout/lib/workspace-registry.ts deleted file mode 100644 index f50668f142f..00000000000 --- a/web/default/src/components/layout/lib/workspace-registry.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* -Copyright (C) 2023-2026 QuantumNous - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as -published by the Free Software Foundation, either version 3 of the -License, or (at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -For commercial licensing, please contact support@quantumnous.com -*/ -import { type TFunction } from 'i18next' -import { - getSystemSettingsNavGroups, - WORKSPACE_SYSTEM_SETTINGS_ID, -} from '../config/system-settings.config' -import type { NavGroup } from '../types' - -export const WORKSPACE_IDS = { - SYSTEM_SETTINGS: WORKSPACE_SYSTEM_SETTINGS_ID, - DEFAULT: 'default', -} as const - -export type WorkspaceId = (typeof WORKSPACE_IDS)[keyof typeof WORKSPACE_IDS] - -/** - * Workspace configuration type - * Each workspace contains name, path matching rules, and corresponding navigation group configuration - */ -export type WorkspaceConfig = { - /** Workspace identifier (for logic) */ - id: WorkspaceId - /** Workspace name */ - name: string - /** Path matching rule, supports string (contains match) or regular expression */ - pathPattern: string | RegExp - /** Sidebar navigation group configuration for this workspace */ - getNavGroups?: (t: TFunction) => NavGroup[] -} - -/** - * Workspace registry - * - * Sorted by priority, first matched workspace will be used - * Last one should be default workspace (matches all paths) - * - * @example - * // Add new workspace - * { - * name: 'User Management', - * pathPattern: /^\/user-management/, - * navGroups: userManagementConfig - * } - */ -const workspaceRegistry: WorkspaceConfig[] = [ - // System Settings workspace - { - id: WORKSPACE_IDS.SYSTEM_SETTINGS, - name: 'System Settings', - pathPattern: /^\/system-settings/, - getNavGroups: getSystemSettingsNavGroups, - }, - // Default workspace (must be last) - { - id: WORKSPACE_IDS.DEFAULT, - name: 'Default', - pathPattern: /.*/, - // getNavGroups is undefined, will be handled by consumers (e.g. useSidebarData) - }, -] - -/** - * Get matched workspace configuration based on path - * @param pathname - Current route path - * @returns Matched workspace configuration - */ -export function getWorkspaceByPath(pathname: string): WorkspaceConfig { - const workspace = workspaceRegistry.find((ws) => { - if (typeof ws.pathPattern === 'string') { - return pathname.includes(ws.pathPattern) - } - return ws.pathPattern.test(pathname) - }) - - // If no match, return default workspace (last one) - return workspace || workspaceRegistry[workspaceRegistry.length - 1] -} - -/** - * Get corresponding sidebar navigation group configuration based on path - * @param pathname - Current route path - * @returns Navigation group configuration for corresponding workspace - */ -export function getNavGroupsForPath( - pathname: string, - t: TFunction -): NavGroup[] | undefined { - const workspace = getWorkspaceByPath(pathname) - return workspace.getNavGroups?.(t) -} - -/** - * Determine if in specified workspace - * @param pathname - Current route path - * @param workspaceId - Workspace identifier - * @returns Whether in specified workspace - */ -export function isInWorkspace( - pathname: string, - workspaceId: WorkspaceId -): boolean { - return getWorkspaceByPath(pathname).id === workspaceId -} - -/** - * Get all registered workspace configurations - * @returns Array of workspace configurations - */ -export function getAllWorkspaces(): WorkspaceConfig[] { - return workspaceRegistry -} diff --git a/web/default/src/components/layout/types.ts b/web/default/src/components/layout/types.ts index 8e0e843f403..087ff2e5409 100644 --- a/web/default/src/components/layout/types.ts +++ b/web/default/src/components/layout/types.ts @@ -17,17 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { type LinkProps } from '@tanstack/react-router' - -/** - * Workspace type - * Used for top switcher to display different workspaces - */ -export type Workspace = { - id: string - name: string - logo: React.ElementType - plan: string -} +import { type TFunction } from 'i18next' /** * Base navigation item type @@ -82,10 +72,12 @@ export type NavGroup = { } /** - * Sidebar data type + * Root sidebar data type + * + * Used by the default (top-level) sidebar view that lists primary + * application navigation (chat, dashboard, admin, etc). */ export type SidebarData = { - workspaces: Workspace[] navGroups: NavGroup[] } @@ -100,3 +92,45 @@ export type TopNavLink = { requiresAuth?: boolean external?: boolean } + +/** + * Back-navigation descriptor for a nested sidebar view + */ +export type SidebarViewParent = { + /** Destination URL for the back button */ + to: LinkProps['to'] | (string & {}) + /** Visible label, e.g. "Back to Dashboard" — already localized */ + label: string +} + +/** + * Nested sidebar view configuration + * + * A nested view replaces the root navigation when the user enters a + * dedicated workspace (e.g. System Settings). It models the modern + * Vercel / Cloudflare "drill-in" sidebar UX: clicking a top-level entry + * swaps the sidebar to a contextual view with a "Back" affordance. + */ +export type SidebarView = { + /** Stable identifier (also drives transition animation keys) */ + id: string + /** Path matcher that activates this view */ + pathPattern: RegExp + /** Back-navigation descriptor; required for nested views */ + parent: SidebarViewParent + /** Nav group builder, called per render with the active translator */ + getNavGroups: (t: TFunction) => NavGroup[] +} + +/** + * Resolved sidebar view returned by `useSidebarView()` + * + * - `view === null`: root navigation (default sidebar) + * - `view !== null`: nested workspace view (renders header + back button) + */ +export type ResolvedSidebarView = { + /** Animation/identity key — falls back to a sentinel for the root view */ + key: string + view: SidebarView | null + navGroups: NavGroup[] +} diff --git a/web/default/src/features/auth/sign-in/index.tsx b/web/default/src/features/auth/sign-in/index.tsx index d9675c238ee..1cf7373081c 100644 --- a/web/default/src/features/auth/sign-in/index.tsx +++ b/web/default/src/features/auth/sign-in/index.tsx @@ -35,18 +35,19 @@ export function SignIn() {

{t('Sign in')}

- {!status?.self_use_mode_enabled && status?.register_enabled !== false && ( -

- {t("Don't have an account?")}{' '} - - {t('Sign up')} - - . -

- )} + {!status?.self_use_mode_enabled && + status?.register_enabled !== false && ( +

+ {t("Don't have an account?")}{' '} + + {t('Sign up')} + + . +

+ )}
diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index 6db51347e21..1e7cb5a8fbc 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -319,7 +319,6 @@ export function SignUpForm({ )} - )} diff --git a/web/default/src/features/channels/components/channels-columns.tsx b/web/default/src/features/channels/components/channels-columns.tsx index 8747c862a85..6ad8b646e88 100644 --- a/web/default/src/features/channels/components/channels-columns.tsx +++ b/web/default/src/features/channels/components/channels-columns.tsx @@ -36,7 +36,6 @@ import { } from '@/lib/format' import { getLobeIcon } from '@/lib/lobe-icon' import { cn, truncateText } from '@/lib/utils' -import { TruncatedText } from '@/components/truncated-text' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { @@ -53,6 +52,7 @@ import { dotColorMap, textColorMap, } from '@/components/status-badge' +import { TruncatedText } from '@/components/truncated-text' import { getCodexUsage } from '../api' import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants' import { diff --git a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx index 180fdb9a4c8..108d49a9b86 100644 --- a/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx +++ b/web/default/src/features/channels/components/dialogs/fetch-models-dialog.tsx @@ -89,9 +89,7 @@ export function FetchModelsDialog({ // Parse existing models const existingModels = useMemo( - () => - existingModelsOverride ?? - parseModelsString(currentRow?.models || ''), + () => existingModelsOverride ?? parseModelsString(currentRow?.models || ''), [existingModelsOverride, currentRow?.models] ) @@ -369,12 +367,14 @@ export function FetchModelsDialog({ {t('Fetch Models')} - {currentRow - ? <> - {t('Fetch available models for:')}{' '} - {currentRow.name} - - : t('Fetch available models from upstream')} + {currentRow ? ( + <> + {t('Fetch available models for:')}{' '} + {currentRow.name} + + ) : ( + t('Fetch available models from upstream') + )} diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 39a6e1527b5..a3195a531d9 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -3381,7 +3381,9 @@ export function ChannelMutateDrawer({ redirectSourceModels={redirectModelKeyList} customFetcher={!isEditing ? createModeFetcher : undefined} existingModelsOverride={ - !isEditing ? parseModelsString(form.getValues('models') || '') : undefined + !isEditing + ? parseModelsString(form.getValues('models') || '') + : undefined } /> diff --git a/web/default/src/features/dashboard/components/overview/performance-health-panel.tsx b/web/default/src/features/dashboard/components/overview/performance-health-panel.tsx index bcbe3e1b42b..cbde734d41e 100644 --- a/web/default/src/features/dashboard/components/overview/performance-health-panel.tsx +++ b/web/default/src/features/dashboard/components/overview/performance-health-panel.tsx @@ -82,9 +82,17 @@ export function PerformanceHealthPanel() { const summary = useMemo(() => { return { avgLatencyMs: Math.round( - simpleAverage(models, 'avg_latency_ms', (v) => Number.isFinite(v) && v > 0) + simpleAverage( + models, + 'avg_latency_ms', + (v) => Number.isFinite(v) && v > 0 + ) + ), + avgTps: simpleAverage( + models, + 'avg_tps', + (v) => Number.isFinite(v) && v > 0 ), - avgTps: simpleAverage(models, 'avg_tps', (v) => Number.isFinite(v) && v > 0), successRate: simpleAverage(models, 'success_rate', Number.isFinite), } }, [models]) @@ -96,7 +104,10 @@ export function PerformanceHealthPanel() { return (
-
- ) : hasData && ( -
- - {t('Top models by traffic')} - -
- {topModels.map((model) => ( -
- - {model.model_name} - - -
-
+ ) )}
diff --git a/web/default/src/features/dashboard/components/overview/summary-cards.tsx b/web/default/src/features/dashboard/components/overview/summary-cards.tsx index 21839692982..37ef4003476 100644 --- a/web/default/src/features/dashboard/components/overview/summary-cards.tsx +++ b/web/default/src/features/dashboard/components/overview/summary-cards.tsx @@ -19,12 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { - ArrowRight, - Flame, - ShieldCheck, - TrendingDown, -} from 'lucide-react' +import { ArrowRight, Flame, ShieldCheck, TrendingDown } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency' @@ -102,7 +97,10 @@ function getSummarySparkline( return undefined } -function getRunwayDays(remainQuota: number, recentUsage: number): number | null { +function getRunwayDays( + remainQuota: number, + recentUsage: number +): number | null { if (remainQuota <= 0 || recentUsage <= 0) return null const days = remainQuota / recentUsage if (!Number.isFinite(days)) return null @@ -111,10 +109,7 @@ function getRunwayDays(remainQuota: number, recentUsage: number): number | null type HealthLevel = 'healthy' | 'caution' | 'critical' -function getHealthLevel( - remainQuota: number, - recentUsage: number -): HealthLevel { +function getHealthLevel(remainQuota: number, recentUsage: number): HealthLevel { if (remainQuota <= 0) return 'critical' const days = getRunwayDays(remainQuota, recentUsage) if (days !== null && days < 3) return 'caution' @@ -139,7 +134,6 @@ const HEALTH_CONFIG: Record< }, } - export function SummaryCards() { const { t } = useTranslation() const user = useAuthStore((state) => state.auth.user) @@ -341,10 +335,7 @@ export function SummaryCards() { - diff --git a/web/default/src/features/dashboard/components/ui/stat-card.tsx b/web/default/src/features/dashboard/components/ui/stat-card.tsx index 0cc541f2eda..b2c9d129f2a 100644 --- a/web/default/src/features/dashboard/components/ui/stat-card.tsx +++ b/web/default/src/features/dashboard/components/ui/stat-card.tsx @@ -96,8 +96,7 @@ function buildLineSparkline(values?: number[]) { sanitized.length === 1 ? width / 2 : (index / (sanitized.length - 1)) * width - const normalized = - range > 0 ? (value - min) / range : max > 0 ? 0.5 : 0 + const normalized = range > 0 ? (value - min) / range : max > 0 ? 0.5 : 0 const y = height - padding - normalized * (height - padding * 2) return { x, y } diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx index 4e300be274a..6f57e7a9284 100644 --- a/web/default/src/features/dashboard/index.tsx +++ b/web/default/src/features/dashboard/index.tsx @@ -236,7 +236,7 @@ export function Dashboard() {
{showSectionTabs ? ( - + {visibleSections.map((section) => ( {t(SECTION_META[section].titleKey)} diff --git a/web/default/src/features/home/components/sections/hero.tsx b/web/default/src/features/home/components/sections/hero.tsx index 34f6d915042..83ec3beca64 100644 --- a/web/default/src/features/home/components/sections/hero.tsx +++ b/web/default/src/features/home/components/sections/hero.tsx @@ -65,7 +65,9 @@ export function Hero(props: HeroProps) { className='landing-animate-fade-up text-muted-foreground/80 mt-5 max-w-lg text-base leading-relaxed opacity-0 md:text-lg' style={{ animationDelay: '80ms' }} > - {t('Power AI applications, manage digital assets, connect the Future')} + {t( + 'Power AI applications, manage digital assets, connect the Future' + )}

g.value === currentGroup)) { - const fallback = groups.find((g) => g.value === 'default')?.value ?? groups[0]?.value ?? '' + const fallback = + groups.find((g) => g.value === 'default')?.value ?? + groups[0]?.value ?? + '' form.setValue('group', fallback) if (currentGroup === 'auto') { form.setValue('cross_group_retry', false) diff --git a/web/default/src/features/keys/lib/api-key-form.ts b/web/default/src/features/keys/lib/api-key-form.ts index 692e359fc97..a2635db2829 100644 --- a/web/default/src/features/keys/lib/api-key-form.ts +++ b/web/default/src/features/keys/lib/api-key-form.ts @@ -57,9 +57,7 @@ export function getApiKeyFormSchema(t: TFunction) { }) } -export type ApiKeyFormValues = z.infer< - ReturnType -> +export type ApiKeyFormValues = z.infer> // ============================================================================ // Form Defaults diff --git a/web/default/src/features/models/index.tsx b/web/default/src/features/models/index.tsx index 202005bbcfa..71d9602460b 100644 --- a/web/default/src/features/models/index.tsx +++ b/web/default/src/features/models/index.tsx @@ -142,7 +142,7 @@ function ModelsContent() {
- + {MODELS_SECTION_IDS.map((section) => ( {t(SECTION_META[section].titleKey)} diff --git a/web/default/src/features/profile/components/profile-settings-card.tsx b/web/default/src/features/profile/components/profile-settings-card.tsx index 160386b16ea..9703104f554 100644 --- a/web/default/src/features/profile/components/profile-settings-card.tsx +++ b/web/default/src/features/profile/components/profile-settings-card.tsx @@ -69,7 +69,7 @@ export function ProfileSettingsCard({ icon={} > - + - + {items.map((item) => ( @@ -689,7 +687,9 @@ export function SubscriptionsMutateDrawer({ type='button' variant='outline' onClick={handleCreatePancakeProduct} - disabled={creatingPancakeProduct || !pancakeCreateReady} + disabled={ + creatingPancakeProduct || !pancakeCreateReady + } className='shrink-0' > {creatingPancakeProduct diff --git a/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx b/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx index 8b0fa16a94a..94a4aafb411 100644 --- a/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/waffo-pancake-settings-section.tsx @@ -75,9 +75,7 @@ const DEFAULT_NEW_PAIR_NAME = `${DEFAULT_NEW_STORE_NAME} + ${DEFAULT_NEW_PRODUCT export function WaffoPancakeSettingsSection(props: Props) { const { t } = useTranslation() - const [storeID, setStoreID] = React.useState( - props.provisionedStoreID ?? '' - ) + const [storeID, setStoreID] = React.useState(props.provisionedStoreID ?? '') const [productID, setProductID] = React.useState( props.provisionedProductID ?? '' ) @@ -283,9 +281,7 @@ export function WaffoPancakeSettingsSection(props: Props) { // returning admins (saved merchant ID but empty key field) would send // a mixed-state body that the backend rejects. const readCreds = () => { - const formMerchant = ( - form.getValues('WaffoPancakeMerchantID') || '' - ).trim() + const formMerchant = (form.getValues('WaffoPancakeMerchantID') || '').trim() const formKey = (form.getValues('WaffoPancakePrivateKey') || '').trim() const saved = (props.defaultValues.WaffoPancakeMerchantID || '').trim() const edited = formMerchant !== saved || formKey.length > 0 @@ -370,12 +366,8 @@ export function WaffoPancakeSettingsSection(props: Props) { // Sends raw form values (not readCreds): SaveWaffoPancakeConfig already // treats a blank PrivateKey as "keep existing", and MerchantID stays // populated from props for returning admins. - const merchantID = ( - form.getValues('WaffoPancakeMerchantID') || '' - ).trim() - const privateKey = ( - form.getValues('WaffoPancakePrivateKey') || '' - ).trim() + const merchantID = (form.getValues('WaffoPancakeMerchantID') || '').trim() + const privateKey = (form.getValues('WaffoPancakePrivateKey') || '').trim() if (!merchantID) { toast.error(t('Merchant ID is required')) return diff --git a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx index d512f79fc25..4753d44ea05 100644 --- a/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx +++ b/web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx @@ -441,9 +441,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] { {sensitiveVisible ? log.username : '••••'} {sensitiveVisible && log.username.length > 12 && ( - - {log.username} - + {log.username} )} @@ -484,11 +482,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef[] {
- - } - > + }> ( - handleChange('upstreamRequestId', e.target.value) - } + onChange={(e) => handleChange('upstreamRequestId', e.target.value)} onKeyDown={handleKeyDown} className={inputClass} /> diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx index 929695a91ed..6f4adfb396a 100644 --- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -986,35 +986,33 @@ export function DetailsDialog(props: DetailsDialogProps) { )} {/* Param override */} - {other?.po && - Array.isArray(other.po) && - other.po.length > 0 && ( - - )} + {other?.po && Array.isArray(other.po) && other.po.length > 0 && ( + + )} {/* Content */} {details && ( diff --git a/web/default/src/features/usage-logs/index.tsx b/web/default/src/features/usage-logs/index.tsx index a975c78d682..0e25253bb36 100644 --- a/web/default/src/features/usage-logs/index.tsx +++ b/web/default/src/features/usage-logs/index.tsx @@ -127,7 +127,7 @@ function UsageLogsContent() {
{showTaskSwitcher && ( - + {visibleSections.map((section) => ( {t(SECTION_META[section].titleKey)} diff --git a/web/default/src/features/wallet/lib/ui.tsx b/web/default/src/features/wallet/lib/ui.tsx index b9129302e98..5806b53f048 100644 --- a/web/default/src/features/wallet/lib/ui.tsx +++ b/web/default/src/features/wallet/lib/ui.tsx @@ -17,9 +17,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { type ReactNode } from 'react' +import i18next from 'i18next' import { CreditCard, Landmark } from 'lucide-react' import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si' -import i18next from 'i18next' import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants' // ============================================================================ diff --git a/web/default/src/hooks/use-sidebar-data.ts b/web/default/src/hooks/use-sidebar-data.ts index f5c9a9780bb..e1b99d0331f 100644 --- a/web/default/src/hooks/use-sidebar-data.ts +++ b/web/default/src/hooks/use-sidebar-data.ts @@ -17,39 +17,35 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { - LayoutDashboard, Activity, - Key, - FileText, - Wallet, Box, - Users, - Ticket, - User, - Command, - Radio, - FlaskConical, - MessageSquare, CreditCard, + FileText, + FlaskConical, + Key, + LayoutDashboard, ListTodo, + MessageSquare, + Radio, Settings, + Ticket, + User, + Users, + Wallet, } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { WORKSPACE_IDS } from '@/components/layout/lib/workspace-registry' import { type SidebarData } from '@/components/layout/types' +/** + * Root navigation groups for the application sidebar. + * + * These are shown when the URL does not match any nested sidebar view + * registered in `layout/lib/sidebar-view-registry.ts`. + */ export function useSidebarData(): SidebarData { const { t } = useTranslation() return { - workspaces: [ - { - id: WORKSPACE_IDS.DEFAULT, - name: '', // Dynamically fetches system name - logo: Command, - plan: '', // Dynamically fetches system version - }, - ], navGroups: [ { id: 'chat', diff --git a/web/default/src/hooks/use-sidebar-view.ts b/web/default/src/hooks/use-sidebar-view.ts new file mode 100644 index 00000000000..1b430db5d73 --- /dev/null +++ b/web/default/src/hooks/use-sidebar-view.ts @@ -0,0 +1,74 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useMemo } from 'react' +import { useLocation } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { useAuthStore } from '@/stores/auth-store' +import { ROLE } from '@/lib/roles' +import { resolveSidebarView } from '@/components/layout/lib/sidebar-view-registry' +import type { NavGroup, ResolvedSidebarView } from '@/components/layout/types' +import { useSidebarConfig } from './use-sidebar-config' +import { useSidebarData } from './use-sidebar-data' + +/** Sentinel key used for the root navigation in animation `key=` props */ +const ROOT_VIEW_KEY = '__root' + +/** + * Resolve the active sidebar view for the current location. + * + * - Returns the matching nested {@link SidebarView} (with its nav + * groups) when the URL belongs to a registered drill-in workspace. + * - Otherwise returns the root navigation, narrowed by: + * · admin-only group visibility (role-based); + * · `useSidebarConfig` (admin × user `sidebar_modules` overlay). + * + * Nested views are intentionally NOT passed through `useSidebarConfig` + * — those filters target known dashboard URLs only, and gating is + * already enforced at the route level (`beforeLoad` redirects). + */ +export function useSidebarView(): ResolvedSidebarView { + const { t } = useTranslation() + const pathname = useLocation({ select: (l) => l.pathname }) + const userRole = useAuthStore((s) => s.auth.user?.role) + const rootSidebarData = useSidebarData() + const configFilteredRoot = useSidebarConfig(rootSidebarData.navGroups) + + const rootNavGroups = useMemo(() => { + const isAdmin = userRole !== undefined && userRole >= ROLE.ADMIN + return configFilteredRoot.filter((group) => + group.id === 'admin' ? isAdmin : true + ) + }, [configFilteredRoot, userRole]) + + const view = resolveSidebarView(pathname) + + if (view) { + return { + key: view.id, + view, + navGroups: view.getNavGroups(t), + } + } + + return { + key: ROOT_VIEW_KEY, + view: null, + navGroups: rootNavGroups, + } +} diff --git a/web/default/src/hooks/use-top-nav-links.ts b/web/default/src/hooks/use-top-nav-links.ts index 0897284f85b..a7996101a3d 100644 --- a/web/default/src/hooks/use-top-nav-links.ts +++ b/web/default/src/hooks/use-top-nav-links.ts @@ -19,8 +19,8 @@ For commercial licensing, please contact support@quantumnous.com import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useAuthStore } from '@/stores/auth-store' -import { useStatus } from '@/hooks/use-status' import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules' +import { useStatus } from '@/hooks/use-status' export type TopNavLink = { title: string diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 41c34e44d92..8f7109820c9 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -469,6 +469,7 @@ "Azure": "Azure", "AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *", "Back": "Back", + "Back to Dashboard": "Back to Dashboard", "Back to Home": "Back to Home", "Back to login": "Back to login", "Back to Models": "Back to Models", @@ -2241,7 +2242,6 @@ "Make it easier for teammates to pick the right group.": "Make it easier for teammates to pick the right group.", "Manage": "Manage", "Manage account bindings for this user": "Manage account bindings for this user", - "Manage and configure": "Manage and configure", "Manage API channels and provider configurations": "Manage API channels and provider configurations", "Manage Bindings": "Manage Bindings", "Manage catalog visibility and pricing.": "Manage catalog visibility and pricing.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 7f1aae94734..4b29cf8111a 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -469,6 +469,7 @@ "Azure": "Azure", "AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *", "Back": "Retour", + "Back to Dashboard": "Retour au tableau de bord", "Back to Home": "Retour à l'accueil", "Back to login": "Retour à la connexion", "Back to Models": "Retour aux modèles", @@ -2232,7 +2233,6 @@ "Make it easier for teammates to pick the right group.": "Faciliter le choix du bon groupe pour les coéquipiers.", "Manage": "Gestion", "Manage account bindings for this user": "Gérer les liaisons de compte pour cet utilisateur", - "Manage and configure": "Gérer et configurer", "Manage API channels and provider configurations": "Gérer les canaux d'API et les configurations des fournisseurs", "Manage Bindings": "Gérer les liaisons", "Manage catalog visibility and pricing.": "Gérer la visibilité du catalogue et les prix.", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index c317c61264f..0b9259b5186 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -469,6 +469,7 @@ "Azure": "Azure", "AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *", "Back": "戻る", + "Back to Dashboard": "ダッシュボードに戻る", "Back to Home": "ホームに戻る", "Back to login": "ログインに戻る", "Back to Models": "モデルに戻る", @@ -2232,7 +2233,6 @@ "Make it easier for teammates to pick the right group.": "チームメイトが適切なグループを選択しやすくする。", "Manage": "管理", "Manage account bindings for this user": "このユーザーのアカウントバインドを管理", - "Manage and configure": "管理と設定", "Manage API channels and provider configurations": "APIチャネルとプロバイダー構成を管理する", "Manage Bindings": "バインド管理", "Manage catalog visibility and pricing.": "カタログの表示と価格設定を管理。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index baa9f2909fb..db4f32dad17 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -469,6 +469,7 @@ "Azure": "Azure", "AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *", "Back": "Назад", + "Back to Dashboard": "Вернуться к панели управления", "Back to Home": "Вернуться на главную", "Back to login": "Вернуться к входу", "Back to Models": "Вернуться к моделям", @@ -2232,7 +2233,6 @@ "Make it easier for teammates to pick the right group.": "Упростите выбор правильной группы для товарищей по команде.", "Manage": "Управление", "Manage account bindings for this user": "Управление привязками аккаунта пользователя", - "Manage and configure": "Управление и настройка", "Manage API channels and provider configurations": "Управление каналами API и конфигурациями провайдеров", "Manage Bindings": "Управление привязками", "Manage catalog visibility and pricing.": "Управление видимостью каталога и ценообразованием.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 2bc63aae329..4901a0b014c 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -469,6 +469,7 @@ "Azure": "Azure", "AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *", "Back": "Quay lại", + "Back to Dashboard": "Quay lại Bảng điều khiển", "Back to Home": "Trở về Trang chủ", "Back to login": "Quay lại đăng nhập", "Back to Models": "Quay lại Mô hình", @@ -2232,7 +2233,6 @@ "Make it easier for teammates to pick the right group.": "Giúp đồng đội dễ dàng chọn đúng nhóm hơn.", "Manage": "Quản lý", "Manage account bindings for this user": "Quản lý liên kết tài khoản cho người dùng này", - "Manage and configure": "Quản lý và cấu hình", "Manage API channels and provider configurations": "Quản lý các kênh API và cấu hình nhà cung cấp", "Manage Bindings": "Quản lý liên kết", "Manage catalog visibility and pricing.": "Quản lý hiển thị danh mục và giá cả.", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 0b1d3ad42a0..2525c34887c 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -469,6 +469,7 @@ "Azure": "Azure", "AZURE_OPENAI_ENDPOINT *": "AZURE_OPENAI_ENDPOINT *", "Back": "返回", + "Back to Dashboard": "返回控制台", "Back to Home": "返回主页", "Back to login": "返回登录", "Back to Models": "返回模型", @@ -2241,7 +2242,6 @@ "Make it easier for teammates to pick the right group.": "让队友更容易选择正确的分组。", "Manage": "管理", "Manage account bindings for this user": "管理此用户的账户绑定", - "Manage and configure": "管理和配置", "Manage API channels and provider configurations": "管理 API 渠道和提供商配置", "Manage Bindings": "管理绑定", "Manage catalog visibility and pricing.": "管理目录可见性和定价。", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 3576e8c1929..0942b1989c1 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -27,9 +27,9 @@ export const STATIC_I18N_KEYS = [ 'Docs', 'About', - // Workspace + // Sidebar views (drill-in workspaces) 'System Settings', - 'Manage and configure', + 'Back to Dashboard', // System settings sidebar 'System Administration', diff --git a/web/default/src/routes/__root.tsx b/web/default/src/routes/__root.tsx index 1af569a1092..1a1fd8264df 100644 --- a/web/default/src/routes/__root.tsx +++ b/web/default/src/routes/__root.tsx @@ -29,10 +29,10 @@ import { ThemeCustomizationProvider } from '@/context/theme-customization-provid import { useSystemConfig } from '@/hooks/use-system-config' import { Toaster } from '@/components/ui/sonner' import { NavigationProgress } from '@/components/navigation-progress' +import { saveAffiliateCode } from '@/features/auth/lib/storage' import { GeneralError } from '@/features/errors/general-error' import { NotFoundError } from '@/features/errors/not-found-error' import { getSetupStatus } from '@/features/setup/api' -import { saveAffiliateCode } from '@/features/auth/lib/storage' function RootComponent() { // Load system configuration (logo, system name, etc.) from backend diff --git a/web/default/src/routes/_authenticated/playground/index.tsx b/web/default/src/routes/_authenticated/playground/index.tsx index a755accc2e6..6a6aa4d49f5 100644 --- a/web/default/src/routes/_authenticated/playground/index.tsx +++ b/web/default/src/routes/_authenticated/playground/index.tsx @@ -17,9 +17,9 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { createFileRoute, redirect } from '@tanstack/react-router' +import { isSidebarModuleEnabled } from '@/lib/nav-modules' import { Main } from '@/components/layout' import { Playground } from '@/features/playground' -import { isSidebarModuleEnabled } from '@/lib/nav-modules' export const Route = createFileRoute('/_authenticated/playground/')({ beforeLoad: () => { From b08febaa3c41b7ea1da5118744f661073a585ba5 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 25 May 2026 00:34:26 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20refactor:=20system=20settings?= =?UTF-8?q?=20UI=20for=20consistent,=20compact=20layouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the system settings interface to align with the rest of the console experience by using fixed header actions, removing redundant subtitles, respecting global content width, and standardizing responsive form layouts. Introduce reusable settings layout primitives for forms, switch rows, grouped controls, nested control sections, title status indicators, and page action portals. Replace duplicated card-style switch markup with explicit compact components, improve nested switch readability, and reduce visual noise across authentication, billing, content, integrations, maintenance, models, and request-limit settings. Also complete missing i18n translations, remove obsolete subtitle translation keys, refine i18n sync reporting, fix sidebar truncation for long labels, and verify the frontend with type checking and lint diagnostics. --- web/default/scripts/sync-i18n.mjs | 106 ++++ .../layout/components/chat-presets-item.tsx | 12 +- .../layout/components/nav-group.tsx | 22 +- .../layout/components/section-page-layout.tsx | 10 +- web/default/src/features/channels/index.tsx | 3 - web/default/src/features/dashboard/index.tsx | 11 +- .../features/dashboard/section-registry.tsx | 3 - web/default/src/features/keys/index.tsx | 3 - web/default/src/features/models/index.tsx | 10 +- .../src/features/models/section-registry.tsx | 2 - .../src/features/redemption-codes/index.tsx | 3 - .../src/features/subscriptions/index.tsx | 3 - .../auth/basic-auth-section.tsx | 96 ++-- .../auth/bot-protection-section.tsx | 42 +- .../components/preset-selector.tsx | 5 +- .../components/provider-form-dialog.tsx | 21 +- .../custom-oauth/custom-oauth-section.tsx | 14 +- .../features/system-settings/auth/index.tsx | 2 + .../system-settings/auth/oauth-section.tsx | 128 +++-- .../system-settings/auth/passkey-section.tsx | 46 +- .../system-settings/auth/section-registry.tsx | 6 +- .../system-settings/billing/index.tsx | 2 + .../billing/section-registry.tsx | 9 +- .../components/form-dirty-indicator.tsx | 20 +- .../components/settings-accordion.tsx | 8 +- .../components/settings-form-layout.tsx | 182 +++++++ .../components/settings-page-context.tsx | 146 ++++++ .../components/settings-page.tsx | 99 +++- .../components/settings-section.tsx | 37 +- .../content/announcements-section.tsx | 19 +- .../content/api-info-section.tsx | 19 +- .../content/chat-settings-section.tsx | 21 +- .../content/dashboard-section.tsx | 36 +- .../content/drawing-settings-section.tsx | 38 +- .../system-settings/content/faq-section.tsx | 21 +- .../system-settings/content/index.tsx | 119 ++--- .../content/json-toggle-section.tsx | 36 +- .../content/section-registry.tsx | 8 +- .../content/uptime-kuma-section.tsx | 21 +- .../general/channel-affinity/index.tsx | 45 +- .../channel-affinity/rule-editor-dialog.tsx | 65 ++- .../general/checkin-settings-section.tsx | 47 +- .../general/pricing-section.tsx | 65 +-- .../general/quota-settings-section.tsx | 337 ++++++------- .../general/system-behavior-section.tsx | 60 ++- .../general/system-info-section.tsx | 461 +++++++++--------- .../src/features/system-settings/index.tsx | 9 +- .../integrations/email-settings-section.tsx | 53 +- .../ionet-deployment-settings-section.tsx | 46 +- .../monitoring-settings-section.tsx | 65 ++- .../integrations/payment-settings-section.tsx | 383 +++------------ .../waffo-pancake-settings-section.tsx | 25 +- .../integrations/waffo-settings-section.tsx | 45 +- .../integrations/worker-settings-section.tsx | 45 +- .../maintenance/header-navigation-section.tsx | 99 ++-- .../maintenance/log-settings-section.tsx | 42 +- .../maintenance/notice-section.tsx | 23 +- .../maintenance/performance-section.tsx | 51 +- .../maintenance/sidebar-modules-section.tsx | 71 ++- .../maintenance/update-checker-section.tsx | 5 +- .../models/claude-settings-card.tsx | 43 +- .../models/gemini-settings-card.tsx | 65 ++- .../models/global-settings-card.tsx | 49 +- .../models/grok-settings-card.tsx | 44 +- .../models/group-ratio-form.tsx | 52 +- .../features/system-settings/models/index.tsx | 2 + .../models/model-pricing-sheet.tsx | 40 +- .../models/model-ratio-form.tsx | 81 ++- .../models/ratio-settings-card.tsx | 4 +- .../models/section-registry.tsx | 7 +- .../system-settings/operations/index.tsx | 56 +-- .../operations/section-registry.tsx | 8 +- .../request-limits/rate-limit-section.tsx | 38 +- .../sensitive-words-section.tsx | 51 +- .../request-limits/ssrf-section.tsx | 61 ++- .../system-settings/security/index.tsx | 2 + .../security/section-registry.tsx | 4 +- .../features/system-settings/site/index.tsx | 2 + .../system-settings/site/section-registry.tsx | 5 +- .../system-settings/utils/section-registry.ts | 8 +- web/default/src/features/usage-logs/index.tsx | 11 +- .../features/usage-logs/section-registry.tsx | 3 - web/default/src/features/users/index.tsx | 3 - web/default/src/features/wallet/index.tsx | 3 - .../i18n/locales/_reports/_sync-report.json | 10 +- .../locales/_reports/fr.untranslated.json | 23 - .../locales/_reports/ja.untranslated.json | 122 ----- .../locales/_reports/ru.untranslated.json | 137 ------ .../locales/_reports/vi.untranslated.json | 25 - .../locales/_reports/zh.untranslated.json | 101 ---- web/default/src/i18n/locales/en.json | 52 -- web/default/src/i18n/locales/fr.json | 167 +++---- web/default/src/i18n/locales/ja.json | 175 ++++--- web/default/src/i18n/locales/ru.json | 175 ++++--- web/default/src/i18n/locales/vi.json | 175 ++++--- web/default/src/i18n/locales/zh.json | 172 +++---- web/default/src/i18n/static-keys.ts | 3 - 97 files changed, 2399 insertions(+), 3011 deletions(-) create mode 100644 web/default/src/features/system-settings/components/settings-form-layout.tsx create mode 100644 web/default/src/features/system-settings/components/settings-page-context.tsx delete mode 100644 web/default/src/i18n/locales/_reports/fr.untranslated.json delete mode 100644 web/default/src/i18n/locales/_reports/ja.untranslated.json delete mode 100644 web/default/src/i18n/locales/_reports/ru.untranslated.json delete mode 100644 web/default/src/i18n/locales/_reports/vi.untranslated.json delete mode 100644 web/default/src/i18n/locales/_reports/zh.untranslated.json diff --git a/web/default/scripts/sync-i18n.mjs b/web/default/scripts/sync-i18n.mjs index cb93b7484ab..a4e5da6d6e2 100644 --- a/web/default/scripts/sync-i18n.mjs +++ b/web/default/scripts/sync-i18n.mjs @@ -29,6 +29,90 @@ const OBFUSCATED_KEYS = [ }, ] +const BRAND_AND_LITERAL_KEYS = new Set([ + 'AI Proxy', + 'AIGC2D', + 'Alipay', + 'Anthropic', + 'API URL', + 'API2GPT', + 'AccessKey / SecretAccessKey', + 'AZURE_OPENAI_ENDPOINT *', + 'Baidu V2', + 'ChatGPT', + 'Claude', + 'Client ID', + 'Client Secret', + 'Cloudflare', + 'Cohere', + 'DeepSeek', + 'Discord', + 'DoubaoVideo', + 'FastGPT', + 'Gemini', + 'Gemini Image 4K', + 'GitHub', + 'Jimeng', + 'JustSong', + 'LingYiWanWu', + 'LinuxDO', + 'Midjourney', + 'MidjourneyPlus', + 'Midjourney-Proxy', + 'MiniMax', + 'Mistral', + 'MokaAI', + 'Moonshot', + 'New API', + 'New API <noreply@example.com>', + 'NewAPI', + 'OAuth Client Secret', + 'OhMyGPT', + 'Ollama', + 'One API', + 'OpenAI', + 'OpenAIMax', + 'OpenRouter', + 'Pancake', + 'Passkey', + 'Perplexity', + 'QuantumNous', + 'Quota:', + 'Replicate', + 'SiliconFlow', + 'Stripe', + 'Submodel', + 'SunoAPI', + 'Telegram', + 'Tencent', + 'TTFT P50', + 'TTFT P95', + 'TTFT P99', + 'Uptime Kuma', + 'Uptime Kuma URL', + 'Vertex AI', + 'VolcEngine', + 'Waffo Pancake Dashboard', + 'Waffo Pancake MoR', + 'WeChat', + 'WeChat Pay', + 'Webhook URL', + 'Webhook URL:', + 'Well-Known URL', + 'Worker URL', + 'Xinference', + 'Xunfei', + 'Zhipu V4', + '"default": "us-central1", "claude-3-5-sonnet-20240620": "europe-west1"', + 'edit_this', + 'footer.columns.related.links.midjourney', + 'footer.columns.related.links.newApiKeyTool', + 'my-status', + 'new-api-key-tool', + 'price_xxx', + 'whsec_xxx', +]) + function isPlainObject(v) { return typeof v === 'object' && v !== null && !Array.isArray(v) } @@ -97,6 +181,24 @@ function isLikelyUntranslated({ locale, baseValue, value }) { // Skip short tokens / acronyms / ids const s = baseValue.trim() + if (BRAND_AND_LITERAL_KEYS.has(s)) return false + if ( + /^https?:\/\//.test(s) || + /^\/[\w/-]+/.test(s) || + /^[\w.-]+@[\w.-]+$/.test(s) || + /^smtp\./i.test(s) || + /^socks5:/i.test(s) || + /^org-/.test(s) || + /^gpt-/i.test(s) || + /^checkout\./.test(s) || + /^footer\./.test(s) || + /^[A-Z0-9_ *./:-]+$/.test(s) || + s.startsWith('{') || + s.startsWith('[') || + s.includes(' ') + ) { + return false + } if (s.length < 6) return false if (!/[A-Za-z]{3,}/.test(s)) return false @@ -187,6 +289,8 @@ async function main() { if (Object.keys(extras).length > 0) { await fs.writeFile(path.join(extrasDir, `${locale}.extras.json`), stableStringify(extras), 'utf8') + } else { + await fs.rm(path.join(extrasDir, `${locale}.extras.json`), { force: true }) } if (Object.keys(untranslated).length > 0) { await fs.writeFile( @@ -194,6 +298,8 @@ async function main() { stableStringify(untranslated), 'utf8', ) + } else { + await fs.rm(path.join(reportsDir, `${locale}.untranslated.json`), { force: true }) } // Rewrite locale file in base order (even for en to normalize formatting) diff --git a/web/default/src/components/layout/components/chat-presets-item.tsx b/web/default/src/components/layout/components/chat-presets-item.tsx index f82c78b261a..c954113de81 100644 --- a/web/default/src/components/layout/components/chat-presets-item.tsx +++ b/web/default/src/components/layout/components/chat-presets-item.tsx @@ -231,9 +231,9 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { } > - {item.icon && } - {item.title} - + {item.icon && } + {item.title} + {visiblePresets.map((preset) => ( @@ -261,9 +261,9 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) { className='group/collapsible-trigger' render={} > - {item.icon && } - {item.title} - + {item.icon && } + {item.title} + diff --git a/web/default/src/components/layout/components/nav-group.tsx b/web/default/src/components/layout/components/nav-group.tsx index c1688acf095..23845e54113 100644 --- a/web/default/src/components/layout/components/nav-group.tsx +++ b/web/default/src/components/layout/components/nav-group.tsx @@ -112,7 +112,7 @@ export function NavGroup({ title, items }: NavGroupProps) { * Navigation badge component */ function NavBadge({ children }: { children: ReactNode }) { - return {children} + return {children} } /** @@ -127,8 +127,8 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) { tooltip={item.title} render={ setOpenMobile(false)} />} > - {item.icon && } - {item.title} + {item.icon && } + {item.title} {item.badge && {item.badge}} @@ -170,10 +170,10 @@ function SidebarMenuCollapsible({ className='group/collapsible-trigger' render={} > - {item.icon && } - {item.title} + {item.icon && } + {item.title} {item.badge && {item.badge}} - + @@ -185,8 +185,8 @@ function SidebarMenuCollapsible({ setOpenMobile(false)} /> } > - {subItem.icon && } - {subItem.title} + {subItem.icon && } + {subItem.title} {subItem.badge && {subItem.badge}} @@ -219,10 +219,10 @@ function SidebarMenuCollapsedDropdown({ /> } > - {item.icon && } - {item.title} + {item.icon && } + {item.title} {item.badge && {item.badge}} - + diff --git a/web/default/src/components/layout/components/section-page-layout.tsx b/web/default/src/components/layout/components/section-page-layout.tsx index c3a7e462bf2..35c381f04f6 100644 --- a/web/default/src/components/layout/components/section-page-layout.tsx +++ b/web/default/src/components/layout/components/section-page-layout.tsx @@ -33,11 +33,6 @@ function SectionPageLayoutTitle(_props: SlotProps) { } SectionPageLayoutTitle.displayName = 'SectionPageLayout.Title' -function SectionPageLayoutDescription(_props: SlotProps) { - return null -} -SectionPageLayoutDescription.displayName = 'SectionPageLayout.Description' - function SectionPageLayoutActions(_props: SlotProps) { return null } @@ -87,13 +82,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
{breadcrumb}
)}
-
+

{title}

{actions != null && ( -
+
{actions}
)} @@ -114,7 +109,6 @@ export function SectionPageLayout(props: SectionPageLayoutProps) { } SectionPageLayout.Title = SectionPageLayoutTitle -SectionPageLayout.Description = SectionPageLayoutDescription SectionPageLayout.Actions = SectionPageLayoutActions SectionPageLayout.Content = SectionPageLayoutContent SectionPageLayout.Breadcrumb = SectionPageLayoutBreadcrumb diff --git a/web/default/src/features/channels/index.tsx b/web/default/src/features/channels/index.tsx index f3ea43b72fb..4099c105c25 100644 --- a/web/default/src/features/channels/index.tsx +++ b/web/default/src/features/channels/index.tsx @@ -29,9 +29,6 @@ export function Channels() { {t('Channels')} - - {t('Manage API channels and provider configurations')} - diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx index 6f57e7a9284..3c20ad71068 100644 --- a/web/default/src/features/dashboard/index.tsx +++ b/web/default/src/features/dashboard/index.tsx @@ -130,21 +130,15 @@ function PerformanceOverviewFallback() { ) } -const SECTION_META: Record< - DashboardSectionId, - { titleKey: string; descriptionKey: string } -> = { +const SECTION_META: Record = { overview: { titleKey: 'Overview', - descriptionKey: 'View dashboard overview and statistics', }, models: { titleKey: 'Model Call Analytics', - descriptionKey: 'View model call count analytics and charts', }, users: { titleKey: 'User Analytics', - descriptionKey: 'View user consumption statistics and charts', }, } @@ -227,9 +221,6 @@ export function Dashboard() { return ( {t(meta.titleKey)} - - {t(meta.descriptionKey)} -
{activeSection !== 'overview' && ( diff --git a/web/default/src/features/dashboard/section-registry.tsx b/web/default/src/features/dashboard/section-registry.tsx index d258dde66ff..db670f0c422 100644 --- a/web/default/src/features/dashboard/section-registry.tsx +++ b/web/default/src/features/dashboard/section-registry.tsx @@ -26,19 +26,16 @@ const DASHBOARD_SECTIONS = [ { id: 'overview', titleKey: 'Overview', - descriptionKey: 'View dashboard overview and statistics', build: () => null, }, { id: 'models', titleKey: 'Model Call Analytics', - descriptionKey: 'View model call count analytics and charts', build: () => null, }, { id: 'users', titleKey: 'User Analytics', - descriptionKey: 'View user consumption statistics and charts', adminOnly: true, build: () => null, }, diff --git a/web/default/src/features/keys/index.tsx b/web/default/src/features/keys/index.tsx index 9c980909687..0414d456bee 100644 --- a/web/default/src/features/keys/index.tsx +++ b/web/default/src/features/keys/index.tsx @@ -29,9 +29,6 @@ export function ApiKeys() { {t('API Keys')} - - {t('Manage your API keys for accessing the service')} - diff --git a/web/default/src/features/models/index.tsx b/web/default/src/features/models/index.tsx index 71d9602460b..3afbff60933 100644 --- a/web/default/src/features/models/index.tsx +++ b/web/default/src/features/models/index.tsx @@ -42,17 +42,12 @@ import { const route = getRouteApi('/_authenticated/models/$section') -const SECTION_META: Record< - ModelsSectionId, - { titleKey: string; descriptionKey: string } -> = { +const SECTION_META: Record = { metadata: { titleKey: 'Metadata', - descriptionKey: 'Manage model metadata and configuration', }, deployments: { titleKey: 'Deployments', - descriptionKey: 'Manage model deployments', }, } @@ -126,9 +121,6 @@ function ModelsContent() { <> {t(meta.titleKey)} - - {t(meta.descriptionKey)} - {activeSection === 'metadata' ? ( diff --git a/web/default/src/features/models/section-registry.tsx b/web/default/src/features/models/section-registry.tsx index f8c0e01800d..6f96ac0fedf 100644 --- a/web/default/src/features/models/section-registry.tsx +++ b/web/default/src/features/models/section-registry.tsx @@ -25,13 +25,11 @@ const MODELS_SECTIONS = [ { id: 'metadata', titleKey: 'Metadata', - descriptionKey: 'Manage model metadata and configuration', build: () => null, // Content is rendered directly in the page component }, { id: 'deployments', titleKey: 'Deployments', - descriptionKey: 'Manage model deployments', build: () => null, // Content is rendered directly in the page component }, ] as const diff --git a/web/default/src/features/redemption-codes/index.tsx b/web/default/src/features/redemption-codes/index.tsx index dc71ee35cb7..9fd74a666eb 100644 --- a/web/default/src/features/redemption-codes/index.tsx +++ b/web/default/src/features/redemption-codes/index.tsx @@ -31,9 +31,6 @@ export function Redemptions() { {t('Redemption Codes')} - - {t('Manage redemption codes for quota top-up')} - diff --git a/web/default/src/features/subscriptions/index.tsx b/web/default/src/features/subscriptions/index.tsx index 322042ff5c8..ddae94057ce 100644 --- a/web/default/src/features/subscriptions/index.tsx +++ b/web/default/src/features/subscriptions/index.tsx @@ -38,9 +38,6 @@ function SubscriptionsContent() { {t('Subscription Management')} - - {t('Manage subscription plan creation, pricing and status')} -
diff --git a/web/default/src/features/system-settings/auth/basic-auth-section.tsx b/web/default/src/features/system-settings/auth/basic-auth-section.tsx index ebf8e29d513..8d53ea1a4bb 100644 --- a/web/default/src/features/system-settings/auth/basic-auth-section.tsx +++ b/web/default/src/features/system-settings/auth/basic-auth-section.tsx @@ -21,7 +21,6 @@ import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -33,6 +32,12 @@ import { } from '@/components/ui/form' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useResetForm } from '../hooks/use-reset-form' import { useUpdateOption } from '../hooks/use-update-option' @@ -100,32 +105,31 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) { } return ( - +
- + + ( - -
- - {t('Password Login')} - + + + {t('Password Login')} {t('Allow users to log in with password')} -
+ -
+ )} /> @@ -133,22 +137,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) { control={form.control} name='RegisterEnabled' render={({ field }) => ( - -
- - {t('Registration Enabled')} - + + + {t('Registration Enabled')} {t('Allow new users to register')} -
+ -
+ )} /> @@ -156,22 +158,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) { control={form.control} name='PasswordRegisterEnabled' render={({ field }) => ( - -
- - {t('Password Registration')} - + + + {t('Password Registration')} {t('Allow registration with password')} -
+ -
+ )} /> @@ -179,22 +179,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) { control={form.control} name='EmailVerificationEnabled' render={({ field }) => ( - -
- - {t('Email Verification')} - + + + {t('Email Verification')} {t('Require email verification for new accounts')} -
+ -
+ )} /> @@ -202,22 +200,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) { control={form.control} name='EmailDomainRestrictionEnabled' render={({ field }) => ( - -
- - {t('Email Domain Restriction')} - + + + {t('Email Domain Restriction')} {t('Only allow specific email domains')} -
+ -
+ )} /> @@ -225,22 +221,20 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) { control={form.control} name='EmailAliasRestrictionEnabled' render={({ field }) => ( - -
- - {t('Email Alias Restriction')} - + + + {t('Email Alias Restriction')} {t('Block email aliases (e.g., user+alias@domain.com)')} -
+ -
+ )} /> @@ -266,11 +260,7 @@ export function BasicAuthSection({ defaultValues }: BasicAuthSectionProps) { )} /> - - - +
) diff --git a/web/default/src/features/system-settings/auth/bot-protection-section.tsx b/web/default/src/features/system-settings/auth/bot-protection-section.tsx index 143d220e3c8..44943705629 100644 --- a/web/default/src/features/system-settings/auth/bot-protection-section.tsx +++ b/web/default/src/features/system-settings/auth/bot-protection-section.tsx @@ -21,7 +21,6 @@ import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -33,6 +32,12 @@ import { } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -75,40 +80,33 @@ export function BotProtectionSection({ } return ( - +
- + + ( - -
- - {t('Enable Turnstile')} - + + + {t('Enable Turnstile')} {t( 'Protect login and registration with Cloudflare Turnstile' )} -
+ -
+ )} /> @@ -148,11 +146,7 @@ export function BotProtectionSection({ )} /> - - - +
) diff --git a/web/default/src/features/system-settings/auth/custom-oauth/components/preset-selector.tsx b/web/default/src/features/system-settings/auth/custom-oauth/components/preset-selector.tsx index 3ab4bd47aa4..70293102658 100644 --- a/web/default/src/features/system-settings/auth/custom-oauth/components/preset-selector.tsx +++ b/web/default/src/features/system-settings/auth/custom-oauth/components/preset-selector.tsx @@ -29,6 +29,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { SettingsControlGroup } from '../../../components/settings-form-layout' import { OAUTH_PRESETS, type CustomOAuthFormValues } from '../types' type PresetSelectorProps = { @@ -102,7 +103,7 @@ export function PresetSelector(props: PresetSelectorProps) { } return ( -
+

{t('Quick Setup from Preset')}

@@ -140,6 +141,6 @@ export function PresetSelector(props: PresetSelectorProps) { />
-
+ ) } diff --git a/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx b/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx index 4280de5b144..e34afe15165 100644 --- a/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx +++ b/web/default/src/features/system-settings/auth/custom-oauth/components/provider-form-dialog.tsx @@ -50,6 +50,11 @@ import { import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../../../components/settings-form-layout' import { useCreateProvider, useUpdateProvider, @@ -185,7 +190,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
- + {/* Preset Selector (only for creating) */} {!isEditing && } @@ -197,22 +202,20 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { control={form.control} name='enabled' render={({ field }) => ( - -
- - {t('Enabled')} - + + + {t('Enabled')} {t('Allow users to sign in with this provider')} -
+ -
+ )} /> @@ -602,7 +605,7 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) { : t('Create Provider')} - +
diff --git a/web/default/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx b/web/default/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx index 5fc4b57579d..39517cd9f41 100644 --- a/web/default/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx +++ b/web/default/src/features/system-settings/auth/custom-oauth/custom-oauth-section.tsx @@ -50,12 +50,7 @@ export function CustomOAuthSection() { if (isLoading) { return ( - +
{t('Loading...')}
@@ -64,12 +59,7 @@ export function CustomOAuthSection() { } return ( - + ) } diff --git a/web/default/src/features/system-settings/auth/oauth-section.tsx b/web/default/src/features/system-settings/auth/oauth-section.tsx index e9d2d78cff5..31af1e15961 100644 --- a/web/default/src/features/system-settings/auth/oauth-section.tsx +++ b/web/default/src/features/system-settings/auth/oauth-section.tsx @@ -21,10 +21,8 @@ import * as z from 'zod' import axios from 'axios' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { RotateCcw } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -39,6 +37,12 @@ import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { FormDirtyIndicator } from '../components/form-dirty-indicator' import { FormNavigationGuard } from '../components/form-navigation-guard' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -69,6 +73,9 @@ const oauthSchema = z.object({ WeChatAccountQRCodeImageURL: z.string().optional(), }) +const oauthTabContentClassName = + 'grid min-w-0 gap-x-5 gap-y-6 lg:grid-cols-2 [&>[data-slot=form-item]]:min-w-0 lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2' + type OAuthFormValues = z.infer type OAuthSectionProps = { @@ -250,12 +257,15 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { <> - +
- + + @@ -268,27 +278,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { {t('WeChat')} - + ( - -
- - {t('Enable GitHub OAuth')} - + + + {t('Enable GitHub OAuth')} {t('Allow users to sign in with GitHub')} -
+ -
+ )} /> @@ -330,27 +338,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { />
- + ( - -
- - {t('Enable Discord OAuth')} - + + + {t('Enable Discord OAuth')} {t('Allow users to sign in with Discord')} -
+ -
+ )} /> @@ -393,27 +399,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { />
- + ( - -
- - {t('Enable OIDC')} - + + + {t('Enable OIDC')} {t('Allow users to sign in with OpenID Connect')} -
+ -
+ )} /> @@ -537,27 +541,28 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { />
- + ( - -
- - {t('Enable Telegram OAuth')} - + + + {t('Enable Telegram OAuth')} {t('Allow users to sign in with Telegram')} -
+ -
+ )} /> @@ -599,27 +604,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { />
- + ( - -
- - {t('Enable LinuxDO OAuth')} - + + + {t('Enable LinuxDO OAuth')} {t('Allow users to sign in with LinuxDO')} -
+ -
+ )} /> @@ -678,27 +681,25 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { />
- + ( - -
- - {t('Enable WeChat Auth')} - + + + {t('Enable WeChat Auth')} {t('Allow users to sign in with WeChat')} -
+ -
+ )} /> @@ -758,22 +759,7 @@ export function OAuthSection({ defaultValues }: OAuthSectionProps) { />
- -
- - -
- +
diff --git a/web/default/src/features/system-settings/auth/passkey-section.tsx b/web/default/src/features/system-settings/auth/passkey-section.tsx index d789778972d..873302c8b08 100644 --- a/web/default/src/features/system-settings/auth/passkey-section.tsx +++ b/web/default/src/features/system-settings/auth/passkey-section.tsx @@ -21,7 +21,6 @@ import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -42,6 +41,12 @@ import { } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useResetForm } from '../hooks/use-reset-form' import { useUpdateOption } from '../hooks/use-update-option' @@ -156,34 +161,33 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) { } return ( - +
- + + ( - -
- - {t('Enable Passkey')} - + + + {t('Enable Passkey')} {t( 'Allow users to register and sign in with Passkey (WebAuthn)' )} -
+ -
+ )} /> @@ -323,24 +327,22 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) { control={form.control} name='passkey.allow_insecure_origin' render={({ field }) => ( - -
- - {t('Allow Insecure Origins')} - + + + {t('Allow Insecure Origins')} {t( 'Permit Passkey registration on non-HTTPS origins (only recommended for development)' )} -
+ -
+ )} /> @@ -367,9 +369,7 @@ export function PasskeySection({ defaultValues }: PasskeySectionProps) { )} /> - - - +
) diff --git a/web/default/src/features/system-settings/auth/section-registry.tsx b/web/default/src/features/system-settings/auth/section-registry.tsx index 89839d68f13..622eb1d351e 100644 --- a/web/default/src/features/system-settings/auth/section-registry.tsx +++ b/web/default/src/features/system-settings/auth/section-registry.tsx @@ -28,7 +28,6 @@ const AUTH_SECTIONS = [ { id: 'basic-auth', titleKey: 'Basic Authentication', - descriptionKey: 'Configure password-based login and registration', build: (settings: AuthSettings) => ( ( ( ( , }, ] as const @@ -143,3 +138,4 @@ export const AUTH_SECTION_IDS = authRegistry.sectionIds export const AUTH_DEFAULT_SECTION = authRegistry.defaultSection export const getAuthSectionNavItems = authRegistry.getSectionNavItems export const getAuthSectionContent = authRegistry.getSectionContent +export const getAuthSectionMeta = authRegistry.getSectionMeta diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index 93817224015..daad50668a9 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -21,6 +21,7 @@ import type { BillingSettings } from '../types' import { BILLING_DEFAULT_SECTION, getBillingSectionContent, + getBillingSectionMeta, } from './section-registry.tsx' const defaultBillingSettings: BillingSettings = { @@ -113,6 +114,7 @@ export function BillingSettings() { defaultSettings={defaultBillingSettings} defaultSection={BILLING_DEFAULT_SECTION} getSectionContent={getBillingSectionContent} + getSectionMeta={getBillingSectionMeta} /> ) } diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index 2e43d66e9be..1a1dc8a2f6c 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -54,7 +54,6 @@ const BILLING_SECTIONS = [ { id: 'quota', titleKey: 'Quota Settings', - descriptionKey: 'Configure user quota allocation and rewards', build: (settings: BillingSettings) => ( ( ( ( ( ( . For commercial licensing, please contact support@quantumnous.com */ -import { Info } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { Alert, AlertDescription } from '@/components/ui/alert' +import { SettingsPageTitleStatusPortal } from './settings-page-context' type FormDirtyIndicatorProps = { isDirty: boolean @@ -26,7 +25,7 @@ type FormDirtyIndicatorProps = { } /** - * Visual indicator that the form has unsaved changes + * Compact page-title status indicator for unsaved form changes. * * @example * ```tsx @@ -41,14 +40,11 @@ export function FormDirtyIndicator({ if (!isDirty) return null return ( - - - - {message ?? t('You have unsaved changes')} - - + + + + {message ? t(message) : t('Unsaved changes')} + + ) } diff --git a/web/default/src/features/system-settings/components/settings-accordion.tsx b/web/default/src/features/system-settings/components/settings-accordion.tsx index 76ec9c91389..31222b43099 100644 --- a/web/default/src/features/system-settings/components/settings-accordion.tsx +++ b/web/default/src/features/system-settings/components/settings-accordion.tsx @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { cn } from '@/lib/utils' import { AccordionItem, AccordionTrigger, @@ -25,7 +26,6 @@ import { type SettingsAccordionProps = { value: string title: string - description?: string children: React.ReactNode className?: string } @@ -33,18 +33,14 @@ type SettingsAccordionProps = { export function SettingsAccordion({ value, title, - description, children, className, }: SettingsAccordionProps) { return ( - +
{title}
- {description && ( -
{description}
- )}
{children} diff --git a/web/default/src/features/system-settings/components/settings-form-layout.tsx b/web/default/src/features/system-settings/components/settings-form-layout.tsx new file mode 100644 index 00000000000..070136b49bc --- /dev/null +++ b/web/default/src/features/system-settings/components/settings-form-layout.tsx @@ -0,0 +1,182 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { ComponentProps, ReactNode } from 'react' +import { cn } from '@/lib/utils' +import { FormItem } from '@/components/ui/form' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' + +type SettingsFormGridProps = { + children: ReactNode + className?: string +} + +type SettingsFormGridItemProps = SettingsFormGridProps & { + span?: 'default' | 'full' +} + +type SettingsSwitchItemProps = ComponentProps +type SettingsSwitchRowProps = ComponentProps<'div'> +type SettingsControlGroupProps = ComponentProps<'div'> +type SettingsControlChildrenProps = ComponentProps<'div'> +type SettingsSwitchFieldProps = SettingsSwitchRowProps & { + checked: boolean + onCheckedChange: (checked: boolean) => void + label: ReactNode + description?: ReactNode + disabled?: boolean +} + +const settingsSwitchRowClassName = + 'flex min-w-0 flex-row items-center justify-between gap-4 border-b py-2.5 last:border-b-0' + +export function SettingsFormGrid(props: SettingsFormGridProps) { + return ( +
+ {props.children} +
+ ) +} + +export function SettingsFormGridItem(props: SettingsFormGridItemProps) { + return ( +
+ {props.children} +
+ ) +} + +export function SettingsSwitchItem({ + className, + ...props +}: SettingsSwitchItemProps) { + return ( + + ) +} + +export function SettingsSwitchRow({ + className, + ...props +}: SettingsSwitchRowProps) { + return ( +
+ ) +} + +export function SettingsSwitchField({ + checked, + onCheckedChange, + label, + description, + disabled, + className, + ...props +}: SettingsSwitchFieldProps) { + return ( + + + + {description ? ( +

{description}

+ ) : null} +
+ +
+ ) +} + +export function SettingsSwitchContent(props: SettingsFormGridProps) { + return ( +
+ {props.children} +
+ ) +} + +export function SettingsControlGroup({ + className, + ...props +}: SettingsControlGroupProps) { + return ( +
+ ) +} + +export function SettingsControlChildren({ + className, + ...props +}: SettingsControlChildrenProps) { + return ( +
+ ) +} + +export function SettingsForm({ className, ...props }: ComponentProps<'form'>) { + return ( +
*:not([data-slot=form-item])]:col-span-2', + 'lg:[&>[data-settings-form-span=full]]:col-span-2', + 'lg:[&>[data-slot=alert]]:col-span-2', + '[&>[data-slot=form-item]]:min-w-0', + 'lg:[&>[data-slot=form-item]:has(textarea)]:col-span-2', + 'lg:[&>[data-slot=form-item]:has([data-slot=switch])]:col-span-2', + className + )} + {...props} + /> + ) +} diff --git a/web/default/src/features/system-settings/components/settings-page-context.tsx b/web/default/src/features/system-settings/components/settings-page-context.tsx new file mode 100644 index 00000000000..2cdb73e4114 --- /dev/null +++ b/web/default/src/features/system-settings/components/settings-page-context.tsx @@ -0,0 +1,146 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { + createContext, + useContext, + type ComponentProps, + type ReactNode, + type RefObject, +} from 'react' +import { RotateCcw, Save } from 'lucide-react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' + +type SettingsPageContextValue = { + actionsContainer: HTMLDivElement | null + titleStatusContainer: HTMLSpanElement | null + suppressSectionHeader: boolean +} + +const SettingsPageContext = createContext({ + actionsContainer: null, + titleStatusContainer: null, + suppressSectionHeader: false, +}) + +type SettingsPageProviderProps = { + actionsContainer: HTMLDivElement | null + titleStatusContainer?: HTMLSpanElement | null + children: ReactNode + suppressSectionHeader?: boolean +} + +export function SettingsPageProvider(props: SettingsPageProviderProps) { + return ( + + {props.children} + + ) +} + +export function useSuppressSettingsSectionHeader() { + return useContext(SettingsPageContext).suppressSectionHeader +} + +type SettingsPageTitleStatusPortalProps = { + children: ReactNode +} + +export function SettingsPageTitleStatusPortal( + props: SettingsPageTitleStatusPortalProps +) { + const { titleStatusContainer } = useContext(SettingsPageContext) + + if (!titleStatusContainer) return null + + return createPortal(props.children, titleStatusContainer) +} + +type SettingsPageActionsPortalProps = { + children: ReactNode +} + +export function SettingsPageActionsPortal( + props: SettingsPageActionsPortalProps +) { + const { actionsContainer } = useContext(SettingsPageContext) + + if (!actionsContainer) return null + + return createPortal( +
+ {props.children} +
, + actionsContainer + ) +} + +type SettingsPageFormActionsProps = { + onSave: () => void + onReset?: () => void + isSaving?: boolean + isSaveDisabled?: boolean + isResetDisabled?: boolean + saveLabel?: string + savingLabel?: string + resetLabel?: string + resetVariant?: ComponentProps['variant'] + saveButtonRef?: RefObject +} + +export function SettingsPageFormActions(props: SettingsPageFormActionsProps) { + const { t } = useTranslation() + const saveLabel = props.isSaving + ? (props.savingLabel ?? 'Saving...') + : (props.saveLabel ?? 'Save Changes') + + return ( + + {props.onReset && ( + + )} + + + ) +} diff --git a/web/default/src/features/system-settings/components/settings-page.tsx b/web/default/src/features/system-settings/components/settings-page.tsx index 1cf8bc5b687..98e3bf1f676 100644 --- a/web/default/src/features/system-settings/components/settings-page.tsx +++ b/web/default/src/features/system-settings/components/settings-page.tsx @@ -16,13 +16,18 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { useMemo, useState, type ReactNode } from 'react' import { useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { SectionPageLayout } from '@/components/layout' import { useSystemOptions, getOptionValue } from '../hooks/use-system-options' +import type { SystemOption } from '../types' +import { SettingsPageProvider } from './settings-page-context' type SettingsPageProps< TSettings extends Record, TSectionId extends string, + TExtraArgs extends unknown[] = [], > = { routePath: string defaultSettings: TSettings @@ -30,9 +35,57 @@ type SettingsPageProps< getSectionContent: ( sectionId: TSectionId, settings: TSettings, - ...extraArgs: unknown[] - ) => React.ReactNode - extraArgs?: unknown[] + ...extraArgs: TExtraArgs + ) => ReactNode + getSectionMeta: (sectionId: TSectionId) => { + titleKey: string + } + extraArgs?: TExtraArgs + loadingMessage?: string + resolveSettings?: ( + settings: TSettings, + raw: SystemOption[] | undefined + ) => TSettings +} + +type SettingsPageFrameProps = { + title: ReactNode + children: ReactNode +} + +function SettingsPageFrame(props: SettingsPageFrameProps) { + const [actionsContainer, setActionsContainer] = + useState(null) + const [titleStatusContainer, setTitleStatusContainer] = + useState(null) + + return ( + + + + + {props.title} + + + + +
+ + +
{props.children}
+
+ + + ) } /** @@ -42,39 +95,53 @@ type SettingsPageProps< export function SettingsPage< TSettings extends Record, TSectionId extends string, + TExtraArgs extends unknown[] = [], >({ routePath, defaultSettings, defaultSection, getSectionContent, - extraArgs = [], -}: SettingsPageProps) { + getSectionMeta, + extraArgs, + loadingMessage = 'Loading settings...', + resolveSettings, +}: SettingsPageProps) { const { t } = useTranslation() const { data, isLoading } = useSystemOptions() // eslint-disable-next-line @typescript-eslint/no-explicit-any const params = useParams({ from: routePath as any }) + const activeSection = (params?.section ?? defaultSection) as TSectionId + const sectionMeta = getSectionMeta(activeSection) + + const settings = useMemo(() => { + const baseSettings = getOptionValue( + data?.data, + defaultSettings + ) as TSettings + return resolveSettings + ? resolveSettings(baseSettings, data?.data) + : baseSettings + }, [data?.data, defaultSettings, resolveSettings]) if (isLoading) { return ( -
-
{t('Loading settings...')}
-
+ +
+ {t(loadingMessage)} +
+
) } - const settings = getOptionValue(data?.data, defaultSettings) as TSettings - const activeSection = (params?.section ?? defaultSection) as TSectionId const sectionContent = getSectionContent( activeSection, settings, - ...extraArgs + ...((extraArgs ?? []) as TExtraArgs) ) return ( -
-
-
{sectionContent}
-
-
+ + {sectionContent} + ) } diff --git a/web/default/src/features/system-settings/components/settings-section.tsx b/web/default/src/features/system-settings/components/settings-section.tsx index 89cbe9e05ba..b0eb2cb9e1d 100644 --- a/web/default/src/features/system-settings/components/settings-section.tsx +++ b/web/default/src/features/system-settings/components/settings-section.tsx @@ -16,10 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import { cn } from '@/lib/utils' +import { useSuppressSettingsSectionHeader } from './settings-page-context' + type SettingsSectionProps = { title: string titleProps?: React.HTMLAttributes - description?: string children: React.ReactNode className?: string } @@ -27,32 +29,23 @@ type SettingsSectionProps = { export function SettingsSection({ title, titleProps, - description, children, className, }: SettingsSectionProps) { - const baseClassName = 'space-y-4' - const sectionClassName = className - ? `${baseClassName} ${className}` - : baseClassName + const suppressHeader = useSuppressSettingsSectionHeader() return ( -
-
-

- {title} -

- {description && ( -

{description}

- )} -
+
+ {!suppressHeader && ( +
+

+ {title} +

+
+ )} {children}
) diff --git a/web/default/src/features/system-settings/content/announcements-section.tsx b/web/default/src/features/system-settings/content/announcements-section.tsx index f48df817fc4..560cb2cac04 100644 --- a/web/default/src/features/system-settings/content/announcements-section.tsx +++ b/web/default/src/features/system-settings/content/announcements-section.tsx @@ -62,7 +62,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' import { Table, TableBody, @@ -74,6 +73,7 @@ import { import { Textarea } from '@/components/ui/textarea' import { DateTimePicker } from '@/components/datetime-picker' import { StatusBadge } from '@/components/status-badge' +import { SettingsSwitchField } from '../components/settings-form-layout' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -319,10 +319,7 @@ export function AnnouncementsSection({ } return ( - +
@@ -350,12 +347,12 @@ export function AnnouncementsSection({ {updateOption.isPending ? t('Saving...') : t('Save Settings')}
-
- - {t('Enabled')} - - -
+
diff --git a/web/default/src/features/system-settings/content/api-info-section.tsx b/web/default/src/features/system-settings/content/api-info-section.tsx index eb552584f2b..3616449cd63 100644 --- a/web/default/src/features/system-settings/content/api-info-section.tsx +++ b/web/default/src/features/system-settings/content/api-info-section.tsx @@ -62,7 +62,6 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' import { Table, TableBody, @@ -72,6 +71,7 @@ import { TableRow, } from '@/components/ui/table' import { StatusBadge } from '@/components/status-badge' +import { SettingsSwitchField } from '../components/settings-form-layout' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -275,10 +275,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { const getColorClass = (color: string) => getBgColorClass(color) return ( - +
@@ -306,12 +303,12 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { {updateOption.isPending ? t('Saving...') : t('Save Settings')}
-
- - {t('Enabled')} - - -
+
diff --git a/web/default/src/features/system-settings/content/chat-settings-section.tsx b/web/default/src/features/system-settings/content/chat-settings-section.tsx index 2fa64f1b97d..bc030657fd5 100644 --- a/web/default/src/features/system-settings/content/chat-settings-section.tsx +++ b/web/default/src/features/system-settings/content/chat-settings-section.tsx @@ -21,7 +21,6 @@ import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -33,6 +32,8 @@ import { } from '@/components/ui/form' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Textarea } from '@/components/ui/textarea' +import { SettingsForm } from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' import { ChatSettingsVisualEditor } from './chat-settings-visual-editor' @@ -125,13 +126,15 @@ export function ChatSettingsSection({ } return ( - + {/* eslint-disable-next-line react-hooks/refs */} - + + setEditMode(value as 'visual' | 'json')} @@ -186,11 +189,7 @@ export function ChatSettingsSection({ /> - - - + ) diff --git a/web/default/src/features/system-settings/content/dashboard-section.tsx b/web/default/src/features/system-settings/content/dashboard-section.tsx index 0d80685d91d..4206c9d7010 100644 --- a/web/default/src/features/system-settings/content/dashboard-section.tsx +++ b/web/default/src/features/system-settings/content/dashboard-section.tsx @@ -21,7 +21,6 @@ import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -41,6 +40,12 @@ import { SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -89,29 +94,28 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) { const isEnabled = form.watch('DataExportEnabled') return ( - +
- + + ( - -
- - {t('Enable Data Dashboard')} - -
+ + + {t('Enable Data Dashboard')} + -
+ )} /> @@ -183,11 +187,7 @@ export function DashboardSection({ defaultValues }: DashboardSectionProps) { )} />
- - - + ) diff --git a/web/default/src/features/system-settings/content/drawing-settings-section.tsx b/web/default/src/features/system-settings/content/drawing-settings-section.tsx index 129aa5aaa63..eedf41384ca 100644 --- a/web/default/src/features/system-settings/content/drawing-settings-section.tsx +++ b/web/default/src/features/system-settings/content/drawing-settings-section.tsx @@ -21,17 +21,21 @@ import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, FormDescription, FormField, - FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Switch } from '@/components/ui/switch' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -124,12 +128,14 @@ export function DrawingSettingsSection({ ] return ( - +
- + +
{switches.map((item) => ( ( - -
- {item.label} + + + {item.label} {item.description} -
+ -
+ )} /> ))}
- - - +
) diff --git a/web/default/src/features/system-settings/content/faq-section.tsx b/web/default/src/features/system-settings/content/faq-section.tsx index 23df8e1194d..4ca07c4031a 100644 --- a/web/default/src/features/system-settings/content/faq-section.tsx +++ b/web/default/src/features/system-settings/content/faq-section.tsx @@ -53,7 +53,6 @@ import { FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' -import { Switch } from '@/components/ui/switch' import { Table, TableBody, @@ -63,6 +62,7 @@ import { TableRow, } from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' +import { SettingsSwitchField } from '../components/settings-form-layout' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -238,12 +238,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) { } return ( - +
@@ -271,12 +266,12 @@ export function FAQSection({ enabled, data }: FAQSectionProps) { {updateOption.isPending ? t('Saving...') : t('Save Settings')}
-
- - {t('Enabled')} - - -
+
diff --git a/web/default/src/features/system-settings/content/index.tsx b/web/default/src/features/system-settings/content/index.tsx index 74709aff820..147a11dd3af 100644 --- a/web/default/src/features/system-settings/content/index.tsx +++ b/web/default/src/features/system-settings/content/index.tsx @@ -16,14 +16,12 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useMemo } from 'react' -import { useParams } from '@tanstack/react-router' -import { useTranslation } from 'react-i18next' -import { getOptionValue, useSystemOptions } from '../hooks/use-system-options' -import type { ContentSettings } from '../types' +import { SettingsPage } from '../components/settings-page' +import type { ContentSettings, SystemOption } from '../types' import { CONTENT_DEFAULT_SECTION, getContentSectionContent, + getContentSectionMeta, } from './section-registry.tsx' const defaultContentSettings: ContentSettings = { @@ -47,84 +45,53 @@ const defaultContentSettings: ContentSettings = { MjActionCheckSuccessEnabled: false, } -export function ContentSettings() { - const { t } = useTranslation() - const { data, isLoading } = useSystemOptions() - const params = useParams({ - from: '/_authenticated/system-settings/content/$section', - }) - - const settings = useMemo(() => { - const resolved = getOptionValue(data?.data, defaultContentSettings) - - const optionMap = new Map( - (data?.data ?? []).map((item) => [item.key, item.value]) - ) - - if (!optionMap.has('console_setting.announcements')) { - const legacy = optionMap.get('Announcements') - if (legacy !== undefined) { - resolved['console_setting.announcements'] = legacy - } - } - - if (!optionMap.has('console_setting.api_info')) { - const legacy = optionMap.get('ApiInfo') - if (legacy !== undefined) { - resolved['console_setting.api_info'] = legacy - } - } - - if (!optionMap.has('console_setting.faq')) { - const legacy = optionMap.get('FAQ') - if (legacy !== undefined) { - resolved['console_setting.faq'] = legacy +function resolveContentSettings( + settings: ContentSettings, + raw: SystemOption[] | undefined +): ContentSettings { + if (!raw || raw.length === 0) return settings + + const optionMap = new Map(raw.map((item) => [item.key, item.value])) + const next = { ...settings } + + const legacyMap = [ + { current: 'console_setting.announcements', legacy: 'Announcements' }, + { current: 'console_setting.api_info', legacy: 'ApiInfo' }, + { current: 'console_setting.faq', legacy: 'FAQ' }, + ] as const + + for (const { current, legacy } of legacyMap) { + if (!optionMap.has(current)) { + const legacyValue = optionMap.get(legacy) + if (legacyValue !== undefined) { + next[current] = legacyValue } } + } - if (!optionMap.has('console_setting.uptime_kuma_groups')) { - const legacyUrl = optionMap.get('UptimeKumaUrl') - const legacySlug = optionMap.get('UptimeKumaSlug') - if (legacyUrl && legacySlug) { - resolved['console_setting.uptime_kuma_groups'] = JSON.stringify([ - { - id: 1, - categoryName: 'Legacy', - url: legacyUrl, - slug: legacySlug, - }, - ]) - } + if (!optionMap.has('console_setting.uptime_kuma_groups')) { + const legacyUrl = optionMap.get('UptimeKumaUrl') + const legacySlug = optionMap.get('UptimeKumaSlug') + if (legacyUrl && legacySlug) { + next['console_setting.uptime_kuma_groups'] = JSON.stringify([ + { id: 1, categoryName: 'Legacy', url: legacyUrl, slug: legacySlug }, + ]) } - - return resolved - }, [data?.data]) - - if (isLoading) { - return ( -
-
- {t('Loading content settings...')} -
-
- ) } - const activeSection = (params?.section ?? CONTENT_DEFAULT_SECTION) as - | 'dashboard' - | 'announcements' - | 'api-info' - | 'faq' - | 'uptime-kuma' - | 'chat' - | 'drawing' - const sectionContent = getContentSectionContent(activeSection, settings) + return next +} +export function ContentSettings() { return ( -
-
-
{sectionContent}
-
-
+ ) } diff --git a/web/default/src/features/system-settings/content/json-toggle-section.tsx b/web/default/src/features/system-settings/content/json-toggle-section.tsx index c04da62a257..4b79b1a600c 100644 --- a/web/default/src/features/system-settings/content/json-toggle-section.tsx +++ b/web/default/src/features/system-settings/content/json-toggle-section.tsx @@ -21,7 +21,6 @@ import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -34,13 +33,18 @@ import { import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { SettingsAccordion } from '../components/settings-accordion' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { useUpdateOption } from '../hooks/use-update-option' import { formatJsonForEditor, normalizeJsonString } from './utils' type JsonToggleSectionProps = { value: string title: string - description?: string toggleDescription?: string optionKey: string enabledKey: string @@ -63,7 +67,6 @@ type JsonToggleFormValues = { export function JsonToggleSection({ value, title, - description, toggleDescription, optionKey, enabledKey, @@ -157,30 +160,33 @@ export function JsonToggleSection({ } return ( - +
{/* eslint-disable-next-line react-hooks/refs */} - + + ( - -
- - {t('Module availability')} - + + + {t('Module availability')} {toggleDescription && ( {t(toggleDescription)} )} -
+ -
+ )} /> @@ -203,11 +209,7 @@ export function JsonToggleSection({ )} /> - - - +
) diff --git a/web/default/src/features/system-settings/content/section-registry.tsx b/web/default/src/features/system-settings/content/section-registry.tsx index 172e690aeef..1de31657b96 100644 --- a/web/default/src/features/system-settings/content/section-registry.tsx +++ b/web/default/src/features/system-settings/content/section-registry.tsx @@ -41,7 +41,6 @@ const CONTENT_SECTIONS = [ { id: 'dashboard', titleKey: 'Data Dashboard', - descriptionKey: 'Configure data export settings for dashboard', build: (settings: ContentSettings) => ( ( ( ( ( ( ), @@ -109,7 +103,6 @@ const CONTENT_SECTIONS = [ { id: 'drawing', titleKey: 'Drawing', - descriptionKey: 'Configure drawing and Midjourney settings', build: (settings: ContentSettings) => ( +
@@ -280,12 +275,12 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) { {updateOption.isPending ? t('Saving...') : t('Save Settings')}
-
- - {t('Enabled')} - - -
+
diff --git a/web/default/src/features/system-settings/general/channel-affinity/index.tsx b/web/default/src/features/system-settings/general/channel-affinity/index.tsx index b9a9cc6014a..6cd86de3487 100644 --- a/web/default/src/features/system-settings/general/channel-affinity/index.tsx +++ b/web/default/src/features/system-settings/general/channel-affinity/index.tsx @@ -31,7 +31,6 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Separator } from '@/components/ui/separator' -import { Switch } from '@/components/ui/switch' import { Table, TableBody, @@ -43,6 +42,8 @@ import { import { Textarea } from '@/components/ui/textarea' import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' +import { SettingsSwitchField } from '../../components/settings-form-layout' +import { SettingsPageActionsPortal } from '../../components/settings-page-context' import { SettingsSection } from '../../components/settings-section' import { useUpdateOption } from '../../hooks/use-update-option' import { getCacheStats, clearAllCache, clearRuleCache } from './api' @@ -333,12 +334,7 @@ export function ChannelAffinitySection(props: Props) { return ( <> - + {t( @@ -349,10 +345,12 @@ export function ChannelAffinitySection(props: Props) { {/* Basic Settings */}
-
- - -
+
-
- - - - {t( - 'If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.' - )} - -
+ - {/* Toolbar */} -
+
+ {/* Rules Table or JSON Editor */} {editMode === 'visual' ? ( diff --git a/web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx b/web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx index e5b7ff579c7..3224f16cd38 100644 --- a/web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx +++ b/web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx @@ -45,8 +45,8 @@ import { SelectValue, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' -import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' +import { SettingsSwitchField } from '../../components/settings-form-layout' import { RULE_TEMPLATES } from './constants' import type { AffinityRule, KeySource } from './types' @@ -264,13 +264,11 @@ export function RuleEditorDialog(props: Props) {
-
- form.setValue('skip_retry_on_failure', v)} - /> - -
+ form.setValue('skip_retry_on_failure', v)} + label={t('Skip retry on failure')} + /> @@ -415,34 +413,29 @@ export function RuleEditorDialog(props: Props) { />
-
-
- - form.setValue('include_using_group', v) - } - /> - -
-
- - form.setValue('include_model_name', v) - } - /> - -
-
- - form.setValue('include_rule_name', v) - } - /> - -
+
+ + form.setValue('include_using_group', v) + } + label={t('Include Group')} + className='border-b-0 py-0' + /> + + form.setValue('include_model_name', v) + } + label={t('Include Model')} + className='border-b-0 py-0' + /> + form.setValue('include_rule_name', v)} + label={t('Include Rule Name')} + className='border-b-0 py-0' + />
diff --git a/web/default/src/features/system-settings/general/checkin-settings-section.tsx b/web/default/src/features/system-settings/general/checkin-settings-section.tsx index 2818f104aaa..da70eaf2091 100644 --- a/web/default/src/features/system-settings/general/checkin-settings-section.tsx +++ b/web/default/src/features/system-settings/general/checkin-settings-section.tsx @@ -21,7 +21,6 @@ import { useForm, type Resolver } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -33,6 +32,12 @@ import { } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useUpdateOption } from '../hooks/use-update-option' @@ -105,31 +110,28 @@ export function CheckinSettingsSection({ } return ( - +
- + + ( - -
- - {t('Enable check-in feature')} - + + + {t('Enable check-in feature')} {t( 'Allow users to check in daily for random quota rewards' )} -
+ -
+ )} /> @@ -188,16 +190,7 @@ export function CheckinSettingsSection({ />
)} - - - +
) diff --git a/web/default/src/features/system-settings/general/pricing-section.tsx b/web/default/src/features/system-settings/general/pricing-section.tsx index 744ef5290c8..cdeb815d37e 100644 --- a/web/default/src/features/system-settings/general/pricing-section.tsx +++ b/web/default/src/features/system-settings/general/pricing-section.tsx @@ -19,10 +19,8 @@ For commercial licensing, please contact support@quantumnous.com import * as z from 'zod' import type { Resolver } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { RotateCcw } from 'lucide-react' import { useTranslation } from 'react-i18next' import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -44,6 +42,12 @@ import { import { Switch } from '@/components/ui/switch' import { FormDirtyIndicator } from '../components/form-dirty-indicator' import { FormNavigationGuard } from '../components/form-navigation-guard' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useSettingsForm } from '../hooks/use-settings-form' import { useUpdateOption } from '../hooks/use-update-option' @@ -141,12 +145,15 @@ export function PricingSection({ defaultValues }: PricingSectionProps) { <> - +
- + + {showQuotaPerUnit && ( ( - -
- - {t('Display in Currency')} - + + + {t('Display in Currency')} {displayType === 'TOKENS' ? t( @@ -332,14 +337,14 @@ export function PricingSection({ defaultValues }: PricingSectionProps) { ) : t('Show prices in currency instead of quota.')} -
+ -
+ )} /> )} @@ -348,43 +353,23 @@ export function PricingSection({ defaultValues }: PricingSectionProps) { control={form.control} name='DisplayTokenStatEnabled' render={({ field }) => ( - -
- - {t('Display Token Statistics')} - + + + {t('Display Token Statistics')} {t('Show token usage statistics in the UI')} -
+ -
+ )} /> - -
- - -
- +
diff --git a/web/default/src/features/system-settings/general/quota-settings-section.tsx b/web/default/src/features/system-settings/general/quota-settings-section.tsx index 96a981dcbc4..a8a83700408 100644 --- a/web/default/src/features/system-settings/general/quota-settings-section.tsx +++ b/web/default/src/features/system-settings/general/quota-settings-section.tsx @@ -22,7 +22,6 @@ import type { Resolver } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' import { Alert, AlertDescription } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -36,6 +35,14 @@ import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' import { FormDirtyIndicator } from '../components/form-dirty-indicator' import { FormNavigationGuard } from '../components/form-navigation-guard' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, + SettingsFormGrid, + SettingsFormGridItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useSettingsForm } from '../hooks/use-settings-form' import { useUpdateOption } from '../hooks/use-update-option' @@ -94,10 +101,7 @@ export function QuotaSettingsSection({ }) return ( - + {!complianceConfirmed ? ( @@ -111,177 +115,176 @@ export function QuotaSettingsSection({ ) : null}
- - - ( - - {t('New User Quota')} - - - - - {t('Initial quota given to new users')} - - - - )} - /> - - ( - - {t('Pre-Consumed Quota')} - - - - - {t('Quota consumed before charging users')} - - - - )} + + + + + ( + + {t('New User Quota')} + + + + + {t('Initial quota given to new users')} + + + + )} + /> - ( - - {t('Inviter Reward')} - - - - - {t('Quota given to users who invite others')} - - - - )} - /> + ( + + {t('Pre-Consumed Quota')} + + + + + {t('Quota consumed before charging users')} + + + + )} + /> - ( - - {t('Invitee Reward')} - - - - - {t('Quota given to invited users')} - - - - )} - /> + ( + + {t('Inviter Reward')} + + + + + {t('Quota given to users who invite others')} + + + + )} + /> - ( - -
- - {t('Pre-Consume for Free Models')} - + ( + + {t('Invitee Reward')} + + + - {t( - 'When enabled, zero-cost models also pre-consume quota before final settlement.' - )} + {t('Quota given to invited users')} -
- - - -
- )} - /> + + + )} + /> - ( - - {t('Top-Up Link')} - - - - - {t('External link for users to purchase quota')} - - - - )} - /> + + ( + + + {t('Pre-Consume for Free Models')} + + {t( + 'When enabled, zero-cost models also pre-consume quota before final settlement.' + )} + + + + + + + )} + /> + - ( - - {t('Documentation Link')} - - - - - {t('Link to your documentation site')} - - - - )} - /> + ( + + {t('Top-Up Link')} + + + + + {t('External link for users to purchase quota')} + + + + )} + /> - - + ( + + {t('Documentation Link')} + + + + + {t('Link to your documentation site')} + + + + )} + /> +
+
) diff --git a/web/default/src/features/system-settings/general/system-behavior-section.tsx b/web/default/src/features/system-settings/general/system-behavior-section.tsx index 8ebfbe5b117..e4232903a72 100644 --- a/web/default/src/features/system-settings/general/system-behavior-section.tsx +++ b/web/default/src/features/system-settings/general/system-behavior-section.tsx @@ -20,7 +20,6 @@ import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -32,6 +31,12 @@ import { } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useResetForm } from '../hooks/use-reset-form' import { useUpdateOption } from '../hooks/use-update-option' @@ -73,12 +78,13 @@ export function SystemBehaviorSection({ } return ( - +
- + + ( - -
- - {t('Default Collapse Sidebar')} - + + + {t('Default Collapse Sidebar')} {t('Sidebar collapsed by default for new users')} -
+ -
+ )} /> @@ -132,22 +136,20 @@ export function SystemBehaviorSection({ control={form.control} name='DemoSiteEnabled' render={({ field }) => ( - -
- - {t('Demo Site Mode')} - + + + {t('Demo Site Mode')} {t('Enable demo mode with limited functionality')} -
+ -
+ )} /> @@ -155,29 +157,23 @@ export function SystemBehaviorSection({ control={form.control} name='SelfUseModeEnabled' render={({ field }) => ( - -
- - {t('Self-Use Mode')} - + + + {t('Self-Use Mode')} {t('Optimize system for self-hosted single-user usage')} -
+ -
+ )} /> - - - +
) diff --git a/web/default/src/features/system-settings/general/system-info-section.tsx b/web/default/src/features/system-settings/general/system-info-section.tsx index b1a85227a91..d1ecbc57eb9 100644 --- a/web/default/src/features/system-settings/general/system-info-section.tsx +++ b/web/default/src/features/system-settings/general/system-info-section.tsx @@ -19,9 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import * as z from 'zod' import type { Resolver } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { RotateCcw } from 'lucide-react' import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' import { Form, FormControl, @@ -43,6 +41,12 @@ import { import { Textarea } from '@/components/ui/textarea' import { FormDirtyIndicator } from '../components/form-dirty-indicator' import { FormNavigationGuard } from '../components/form-navigation-guard' +import { + SettingsForm, + SettingsFormGrid, + SettingsFormGridItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' import { SettingsSection } from '../components/settings-section' import { useSettingsForm } from '../hooks/use-settings-form' import { useUpdateOption } from '../hooks/use-update-option' @@ -139,250 +143,243 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) { <> - +
- + + - ( - - {t('Frontend Theme')} - + + + + + + + + + {t('Default (New Frontend)')} + + + {t('Classic (Legacy Frontend)')} + + + + + + {t( + 'Switch between the new frontend and the classic frontend. Changes take effect after page reload.' + )} + + + + )} + /> + + ( + + {t('System Name')} - - - + - - - - {t('Default (New Frontend)')} - - - {t('Classic (Legacy Frontend)')} - - - - - - {t( - 'Switch between the new frontend and the classic frontend. Changes take effect after page reload.' - )} - - - - )} - /> - - ( - - {t('System Name')} - - - - - {t('The name displayed across the application')} - - - - )} - /> + + {t('The name displayed across the application')} + + + + )} + /> - ( - - {t('Server Address')} - - - - - {t( - 'The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations' - )} - - - - )} - /> + ( + + {t('Server Address')} + + + + + {t( + 'The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations' + )} + + + + )} + /> - ( - - {t('Logo URL')} - - - - - {t('URL to your logo image (optional)')} - - - - )} - /> + ( + + {t('Logo URL')} + + + + + {t('URL to your logo image (optional)')} + + + + )} + /> - ( - - {t('Footer')} - -