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..5387eab8624 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 @@ -100,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) @@ -107,10 +113,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 +170,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 +183,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 +195,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 +227,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..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,24 +41,36 @@ 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) tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{ Data: WaffoPancakeWebhookData{ - OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", + OrderID: "ORD_internal_pancake_id", + OrderMerchantExternalID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz", MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId), }, }) @@ -69,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) @@ -84,7 +97,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 }, }) @@ -97,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) @@ -113,7 +127,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) @@ -133,22 +148,23 @@ 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) 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 +193,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 +219,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 +246,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 +271,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..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); 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/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/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/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/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/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/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/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..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,16 +221,13 @@ export function Dashboard() { return ( {t(meta.titleKey)} - - {t(meta.descriptionKey)} -
{activeSection !== 'overview' && (
{showSectionTabs ? ( - + {visibleSections.map((section) => ( {t(SECTION_META[section].titleKey)} 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/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/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/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..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' ? ( @@ -142,7 +134,7 @@ function ModelsContent() {
- + {MODELS_SECTION_IDS.map((section) => ( {t(SECTION_META[section].titleKey)} 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/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={} > - + {t('Redemption Codes')} - - {t('Manage redemption codes for quota top-up')} - diff --git a/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx b/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx index a842a0426d9..0ec5c2d096d 100644 --- a/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx +++ b/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx @@ -673,9 +673,7 @@ export function SubscriptionsMutateDrawer({ disabled={items.length === 0} > - + {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/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')} - -