diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 7dce8c0..9f3872b 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -14,7 +14,6 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { token: { colorBgContainer, borderRadiusLG }, } = theme.useToken(); - // Close drawer when switching to desktop useEffect(() => { if (!isMobile) setDrawerOpen(false); }, [isMobile]); @@ -39,6 +38,8 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { overflow: 'auto', minWidth: 0, maxWidth: isMobile ? undefined : 1400, + width: isMobile ? undefined : 'calc(100% - 48px)', + alignSelf: isMobile ? undefined : 'center', }} > {children} @@ -50,7 +51,7 @@ const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { placement="left" open={isMobile && drawerOpen} onClose={() => setDrawerOpen(false)} - width={280} + width={Math.min(300, typeof window !== 'undefined' ? window.innerWidth - 24 : 280)} styles={{ body: { padding: 0 } }} title={null} closable={false} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 8a027ac..d91b469 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -85,7 +85,7 @@ const HeaderBar: React.FC = ({ collapsed, setCollapsed, onOpenMobileNav } {!isMobile && (LOCALE_OPTIONS.find((o) => o.key === locale)?.label || locale)} - } style={{ marginInlineEnd: 0 }}> + } title={displayName} style={{ marginInlineEnd: 0 }}> {isMobile ? shortName : displayName} diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index 1cd2096..65ff4de 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -2,56 +2,52 @@ import React from 'react'; import { Typography, Space } from 'antd'; import useIsMobile from '../hooks/useIsMobile'; -const { Title } = Typography; +const { Title, Text } = Typography; -/** Consistent page title + optional subtitle/actions for mobile & desktop. */ -const PageHeader: React.FC<{ +export interface PageHeaderProps { title: React.ReactNode; subtitle?: React.ReactNode; extra?: React.ReactNode; -}> = ({ title, subtitle, extra }) => { + style?: React.CSSProperties; +} + +/** + * Shared page title block. Stacks actions under the title on narrow screens + * so long translations (DE/RU/AR/…) remain readable and tappable. + */ +const PageHeader: React.FC = ({ title, subtitle, extra, style }) => { const isMobile = useIsMobile(); - const sub = - subtitle === undefined || subtitle === null || subtitle === false || subtitle === '' - ? null - : subtitle; return ( -
-
- - {title} - - {sub != null ? ( -

- {sub} -

+
+
+
+ + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} +
+ {extra ? ( +
+ + {extra} + +
) : null}
- {extra ? ( - - {extra} - - ) : null}
); }; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 93b6f0b..e9f7e48 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -46,7 +46,7 @@ const Dashboard: React.FC = () => { return (
- + @@ -73,7 +73,7 @@ const Dashboard: React.FC = () => { - + diff --git a/frontend/src/pages/Listeners.tsx b/frontend/src/pages/Listeners.tsx index 829312b..76287c1 100644 --- a/frontend/src/pages/Listeners.tsx +++ b/frontend/src/pages/Listeners.tsx @@ -233,7 +233,7 @@ const columns = [ ) : ( - )} }, { key: 'templates', label: t('listeners.templates'), children: } onClick={loadTemplates}>{t('common.refresh')}}>
}]} /> + )} }, { key: 'templates', label: t('listeners.templates'), children: } onClick={loadTemplates}>{t('common.refresh')}}>
}]} /> l.id === runtimeListener.id) || runtimeListener : null} status={runtimeListener ? details[runtimeListener.id] || statuses[runtimeListener.id] : undefined} checking={!!runtimeListener && testing.includes(runtimeListener.id)} onClose={() => setRuntimeListener(null)} onCheck={() => { if (runtimeListener) void test(runtimeListener.id).catch(() => {}); }} /> { setModalOpen(false); setEditing(null); form.resetFields(); }} onOk={() => form.validateFields().then((vals) => onSubmit(vals))} width={isMobile ? '100%' : 720} style={isMobile ? { top: 8, maxWidth: '100vw', margin: 0, padding: 0 } : undefined} className={isMobile ? 'mobile-full-modal' : undefined} destroyOnClose styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}> {submitError &&
{submitError}
{runtimeText.failedHint}
} style={{ marginBottom: 16 }} />} @@ -256,7 +256,7 @@ const columns = [ setCloneModal(false)} onOk={() => cloneForm.submit()}>
setTemplateModal(false)} onOk={() => templateForm.submit()}>
{templateSource?.protocol}
setInstantiateModal(false)} onOk={() => instantiateForm.submit()}>
- setVersionsModal(false)} footer={null} width={800}>
v || '-' }, { title: t('listeners.createdAt'), dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleString() }, { title: t('common.actions'), render: (_: any, v: ListenerVersion) => doRollback(v.version)}> }]} /> + setVersionsModal(false)} footer={null} width={800}>
v || '-' }, { title: t('listeners.createdAt'), dataIndex: 'created_at', render: (v: string) => new Date(v).toLocaleString() }, { title: t('common.actions'), render: (_: any, v: ListenerVersion) => doRollback(v.version)}> }]} /> setDiffModal(false)} footer={null} width={900}>
{diffText || t('common.empty')}
setUriModal(false)} footer={null} width={isMobile ? '100%' : 560} style={isMobile ? { top: 8 } : undefined} className={isMobile ? 'mobile-full-modal' : undefined}> diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 6e40990..9a4b4b7 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -572,10 +572,30 @@ const Users: React.FC = () => { {record.enabled ? t('common.enabled') : t('common.disabled')} - - {formatBytes(used)} - {limit > 0 ? ` / ${formatBytes(limit)}` : ` / ${t('users.unlimited')}`} - + {record.group ? {record.group} : null} + +
+
+ {t('users.traffic')} + + {formatBytes(used)} + {limit > 0 ? ` / ${formatBytes(limit)}` : ` / ${t('users.unlimited')}`} + +
+
+ {t('users.expire')} + + {!record.expire_time || String(record.expire_time).startsWith('0001') + ? t('users.neverExpire') + : dayjs(record.expire_time).format('YYYY-MM-DD')} + +
+ {(record.tags || record.remark) && ( +
+ {t('users.remark') || 'Remark'} + {[record.remark, record.tags].filter(Boolean).join(' · ')} +
+ )}
span, +.ant-menu-title-content, +.ant-select-selection-item, +.ant-form-item-label > label, +.ant-table-cell, +.ant-tag { + overflow-wrap: anywhere; + word-break: break-word; +} + +.ant-form-item-label > label { + white-space: normal !important; + height: auto !important; + line-height: 1.4 !important; +} + +.ant-menu-item, +.ant-menu-submenu-title { + white-space: normal !important; + height: auto !important; + line-height: 1.35 !important; + padding-block: 10px !important; +} + +.ant-menu-item .ant-menu-title-content, +.ant-menu-submenu-title .ant-menu-title-content { + white-space: normal; +} + /* Desktop readability */ @media (min-width: 768px) { .app-page-content { max-width: 1400px; + width: 100%; + margin-inline: auto !important; + } + + .page-header { + margin-bottom: 20px; } .page-header h3.ant-typography { letter-spacing: -0.01em; } + .page-header .page-header-subtitle { + max-width: 720px; + line-height: 1.5; + } + .ant-card { border-radius: 10px; } .ant-table-wrapper { border-radius: 8px; } - /* Comfortable row density */ .ant-table-thead > tr > th { font-weight: 600; } .ant-card + .ant-card { margin-top: 16px; } + + /* Desktop action rows stay usable with long labels */ + .page-toolbar, + .ant-card-extra { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } } @media (max-width: 767px) { + /* iOS zoom prevention on focus */ input.ant-input, .ant-input, .ant-input-number-input, @@ -47,8 +97,9 @@ body { top: 0; z-index: 100; backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); background: color-mix(in srgb, var(--ant-color-bg-container, #fff) 88%, transparent) !important; - border-bottom: 1px solid var(--ant-color-border-secondary, rgba(0,0,0,.06)); + border-bottom: 1px solid var(--ant-color-border-secondary, rgba(0, 0, 0, 0.06)); height: 52px !important; line-height: 52px !important; padding-inline: 8px !important; @@ -57,6 +108,15 @@ body { min-width: 40px; min-height: 40px; } + .app-header-bar .ant-space { + flex-wrap: nowrap; + gap: 4px !important; + } + .app-header-bar .ant-tag { + max-width: 96px; + overflow: hidden; + text-overflow: ellipsis; + } .app-page-content { margin: 8px !important; @@ -64,6 +124,30 @@ body { border-radius: 12px !important; } + .page-header { + margin-bottom: 12px !important; + } + .page-header .page-header-title-row { + flex-direction: column; + align-items: stretch !important; + gap: 8px; + } + .page-header .page-header-actions { + width: 100%; + } + .page-header .page-header-actions .ant-space { + width: 100%; + display: flex; + flex-wrap: wrap; + } + .page-header .page-header-actions .ant-btn { + flex: 1 1 auto; + } + .page-header .page-header-subtitle { + font-size: 13px; + line-height: 1.45; + } + .ant-table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; @@ -98,6 +182,7 @@ body { } .ant-modal-footer .ant-btn { margin: 0 !important; + min-height: 40px; } .ant-card { @@ -108,6 +193,11 @@ body { min-height: auto; padding: 10px 12px !important; } + .ant-card-head-title { + white-space: normal !important; + padding: 4px 0 !important; + line-height: 1.35; + } .ant-card-head-wrapper { flex-wrap: wrap; gap: 8px; @@ -118,36 +208,42 @@ body { } .ant-card-extra .ant-space { width: 100%; - justify-content: flex-start; + display: flex; + flex-wrap: wrap; } .ant-card-body { padding: 12px !important; } - .ant-space { - flex-wrap: wrap !important; - } - - .ant-card-extra .ant-input-search, - .ant-card-extra .ant-input-affix-wrapper { - width: 100% !important; - max-width: 100% !important; - } - .ant-tabs-nav { - margin-bottom: 8px !important; + margin-bottom: 12px !important; } .ant-tabs-tab { padding: 8px 10px !important; } + .ant-tabs-tab-btn { + white-space: normal; + text-align: center; + } .ant-form-item { margin-bottom: 12px !important; } + .ant-form-item-control-input-content > .ant-input, + .ant-form-item-control-input-content > .ant-input-password, + .ant-form-item-control-input-content > .ant-input-number, + .ant-form-item-control-input-content > .ant-select, + .ant-form-item-control-input-content > .ant-picker { + width: 100% !important; + } .ant-statistic-content { font-size: 20px !important; } + .ant-statistic-title { + white-space: normal; + line-height: 1.3; + } .settings-section-select .ant-select-selector { min-height: 44px !important; @@ -168,6 +264,7 @@ body { .ant-dropdown-menu-item { padding: 10px 14px !important; + white-space: normal; } .login-page { @@ -180,11 +277,14 @@ body { max-width: 100% !important; } - .ant-btn-primary { + .ant-btn { min-height: 36px; } + .ant-btn-primary { + min-height: 40px; + } - /* Entity lists */ + /* Entity lists (users / listeners mobile cards) */ .mobile-entity-list { display: flex; flex-direction: column; @@ -194,7 +294,7 @@ body { border: 1px solid var(--ant-color-border-secondary, #f0f0f0); border-radius: 12px; background: var(--ant-color-bg-container, #fff); - padding: 10px 12px; + padding: 12px; } .mobile-entity-card-main { display: flex; @@ -215,7 +315,7 @@ body { } .mobile-entity-sub { font-weight: 400; - opacity: 0.65; + opacity: 0.7; font-size: 13px; } .mobile-entity-meta { @@ -223,14 +323,48 @@ body { flex-wrap: wrap; align-items: center; gap: 6px; - margin-top: 6px; + margin-top: 8px; font-size: 13px; } + .mobile-entity-rows { + display: grid; + gap: 4px; + margin-top: 8px; + font-size: 12.5px; + line-height: 1.4; + opacity: 0.85; + } + .mobile-entity-row { + display: flex; + gap: 8px; + justify-content: space-between; + min-width: 0; + } + .mobile-entity-row .label { + opacity: 0.65; + flex-shrink: 0; + } + .mobile-entity-row .value { + text-align: end; + word-break: break-word; + min-width: 0; + } .mobile-empty { text-align: center; padding: 32px 12px; opacity: 0.55; } + + /* Drawer nav: comfortable touch targets + wrapping labels */ + .app-mobile-drawer .ant-menu-item { + margin-block: 2px !important; + width: calc(100% - 8px) !important; + } + + /* Stats grids */ + .ant-row { + row-gap: 12px; + } } @media (max-width: 480px) { @@ -238,4 +372,13 @@ body { margin: 6px !important; padding: 10px !important; } + .ant-card-body { + padding: 10px !important; + } +} + +/* Dark mode soft adjustments */ +html.dark .mobile-entity-card, +[data-theme='dark'] .mobile-entity-card { + border-color: rgba(255, 255, 255, 0.12); }