mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-10 05:53:05 +09:00
refactor(frontend): detail pages and in-body tab strips use the shared top bar
This commit is contained in:
1 parent
3f8b6fb878
commit
e818dcaf91
12 files changed
+309
-383
No files matched your search
@@ -22,10 +22,8 @@ import api from '../services/api';
|
||||
import { useToast } from '../contexts/useToast.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { MetricCard, KpiBand, Pill, Gauge, DataTable, DataTableFooter, statusKind } from '@/components/ds';
|
||||
import { MetricCard, KpiBand, Pill, Gauge, SegControl, DataTable, DataTableFooter, statusKind } from '@/components/ds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
@@ -105,15 +103,29 @@ const AgentFleet = () => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Publish the Refresh button to the shared tab-group top bar; re-registers
|
||||
// on `loading` so the spinner/disabled state stays in sync.
|
||||
useTopbarActions(() =>
|
||||
<Button size="sm" onClick={fetchData} disabled={loading}>
|
||||
<RefreshCw size={18} className={loading ? 'fleet-refresh-spinner' : ''} />
|
||||
{t('common.actions.refresh', 'Refresh')}
|
||||
</Button>,
|
||||
[loading]
|
||||
);
|
||||
// Publish the view switcher + Refresh to the shared tab-group top bar;
|
||||
// re-registers on the values the node reads so spinner/counts stay in sync.
|
||||
useTopbarActions(() => (
|
||||
<>
|
||||
<SegControl
|
||||
aria-label={t('app.agentFleet.views', 'Fleet views')}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
options={[
|
||||
{ value: 'dashboard', icon: <Activity size={16} />, label: t('app.agentFleet.dashboard', 'Dashboard') },
|
||||
{ value: 'versions', icon: <Package size={16} />, label: t('app.agentFleet.versions', 'Versions') },
|
||||
{ value: 'rollouts', icon: <Zap size={16} />, label: t('app.agentFleet.rollouts', 'Rollouts') },
|
||||
{ value: 'queue', icon: <Clock size={16} />, label: t('app.agentFleet.commandQueue', 'Command queue'), count: queuedCommands.length || undefined },
|
||||
{ value: 'discovery', icon: <Search size={16} />, label: t('app.agentFleet.discovery', 'Discovery') },
|
||||
{ value: 'approvals', icon: <Shield size={16} />, label: t('app.agentFleet.approvals', 'Approvals'), count: pendingServers.length || undefined },
|
||||
]}
|
||||
/>
|
||||
<Button size="sm" onClick={fetchData} disabled={loading}>
|
||||
<RefreshCw size={18} className={loading ? 'fleet-refresh-spinner' : ''} />
|
||||
{t('common.actions.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</>
|
||||
), [loading, activeTab, queuedCommands.length, pendingServers.length]);
|
||||
|
||||
|
||||
const startDiscovery = async () => {
|
||||
@@ -462,30 +474,6 @@ const AgentFleet = () => {
|
||||
|
||||
return (
|
||||
<div className="sk-tabgroup__inner">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
{[
|
||||
{ key: 'dashboard', icon: Activity, labelKey: 'app.agentFleet.dashboard', label: 'Dashboard' },
|
||||
{ key: 'versions', icon: Package, labelKey: 'app.agentFleet.versions', label: 'Versions' },
|
||||
{ key: 'rollouts', icon: Zap, labelKey: 'app.agentFleet.rollouts', label: 'Rollouts' },
|
||||
{ key: 'queue', icon: Clock, labelKey: 'app.agentFleet.commandQueue', label: 'Command queue' },
|
||||
{ key: 'discovery', icon: Search, labelKey: 'app.agentFleet.discovery', label: 'Discovery' },
|
||||
{ key: 'approvals', icon: Shield, labelKey: 'app.agentFleet.approvals', label: 'Approvals' },
|
||||
].map(tab => (
|
||||
<TabsTrigger key={tab.key} value={tab.key}>
|
||||
<tab.icon size={18} />
|
||||
{tab.label}
|
||||
{tab.key === 'approvals' && pendingServers.length > 0 && (
|
||||
<Badge variant="destructive" className="fleet-tab-count">{pendingServers.length}</Badge>
|
||||
)}
|
||||
{tab.key === 'queue' && queuedCommands.length > 0 && (
|
||||
<Badge variant="warning" className="fleet-tab-count">{queuedCommands.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{loadError && !tabHasData ? (
|
||||
<ErrorState
|
||||
title={t('app.agentFleet.couldntLoadFleetData', "Couldn't load fleet data.")}
|
||||
|
||||
@@ -12,7 +12,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { SegControl } from '@/components/ds';
|
||||
import { Cloud, Server } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toastError } from '@/utils/errorMessage';
|
||||
@@ -26,6 +27,7 @@ const CloudProvision = () => {
|
||||
const [servers, setServers] = useState([]);
|
||||
const [costs, setCosts] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState('servers');
|
||||
const [showCreateProvider, setShowCreateProvider] = useState(false);
|
||||
const [showCreateServer, setShowCreateServer] = useState(false);
|
||||
const [providerOptions, setProviderOptions] = useState(null);
|
||||
@@ -54,15 +56,26 @@ const CloudProvision = () => {
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
// Publish the admin actions to the shared tab-group top bar.
|
||||
useTopbarActions(() =>
|
||||
user?.is_admin ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowCreateProvider(true)}>{t('app.cloudProvision.addProvider', 'Add provider')}</Button>
|
||||
<Button size="sm" onClick={() => setShowCreateServer(true)}>{t('app.cloudProvision.newServer', 'New server')}</Button>
|
||||
</>
|
||||
) : null,
|
||||
[user?.is_admin]
|
||||
);
|
||||
useTopbarActions(() => (
|
||||
<>
|
||||
<SegControl
|
||||
aria-label={t('app.cloudProvision.views', 'Cloud views')}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: 'servers', label: t('common.labels.servers', 'Servers') },
|
||||
{ value: 'providers', label: t('app.cloudProvision.providers', 'Providers') },
|
||||
{ value: 'costs', label: t('app.cloudProvision.costs', 'Costs') },
|
||||
]}
|
||||
/>
|
||||
{user?.is_admin && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowCreateProvider(true)}>{t('app.cloudProvision.addProvider', 'Add provider')}</Button>
|
||||
<Button size="sm" onClick={() => setShowCreateServer(true)}>{t('app.cloudProvision.newServer', 'New server')}</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
), [user?.is_admin, view]);
|
||||
|
||||
const handleCreateProvider = async () => {
|
||||
try {
|
||||
@@ -112,13 +125,7 @@ const CloudProvision = () => {
|
||||
|
||||
return (
|
||||
<div className="sk-tabgroup__inner cloud-provision-page">
|
||||
<Tabs defaultValue="servers">
|
||||
<TabsList>
|
||||
<TabsTrigger value="servers">{t('common.labels.servers', 'Servers')}</TabsTrigger>
|
||||
<TabsTrigger value="providers">{t('app.cloudProvision.providers', 'Providers')}</TabsTrigger>
|
||||
<TabsTrigger value="costs">{t('app.cloudProvision.costs', 'Costs')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsContent value="servers">
|
||||
<div className="cloud-servers-grid">
|
||||
{servers.map(srv => (
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import useTabParam from '../hooks/useTabParam';
|
||||
import { useTopbarActions } from '@/hooks/useTopbarActions';
|
||||
import { api } from '../services/api';
|
||||
import { DataTable, DataTableFooter, MetricCard, KpiBand, Pill, ListToolbar } from '@/components/ds';
|
||||
import { DataTable, DataTableFooter, MetricCard, KpiBand, Pill, ListToolbar, SegControl } from '@/components/ds';
|
||||
import {
|
||||
useTableChrome, GridViewPicker, GridChips, GridFilterButton,
|
||||
GridToolsMenu, GridFilterDrawer,
|
||||
@@ -23,7 +23,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import { InfoList, InfoItem } from '../components/InfoList';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { errorReason, toastError } from '@/utils/errorMessage';
|
||||
|
||||
@@ -498,8 +498,27 @@ function FTPServer() {
|
||||
const isInstalled = status?.any_installed;
|
||||
const isRunning = status?.any_running;
|
||||
|
||||
const selectTab = (val) => {
|
||||
setActiveTab(val);
|
||||
if (val === 'connections') loadConnections();
|
||||
if (val === 'logs') loadLogs();
|
||||
};
|
||||
|
||||
useTopbarActions(() =>
|
||||
<>
|
||||
{isInstalled && (
|
||||
<SegControl
|
||||
aria-label={t('app.fTPServer.views', 'FTP views')}
|
||||
value={activeTab}
|
||||
onChange={selectTab}
|
||||
options={[
|
||||
{ value: 'overview', label: t('common.labels.overview', 'Overview') },
|
||||
{ value: 'users', label: t('app.fTPServer.users', 'Users') },
|
||||
{ value: 'connections', label: t('app.fTPServer.connections', 'Connections') },
|
||||
{ value: 'logs', label: t('common.labels.logs', 'Logs') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{!isInstalled ? (
|
||||
<Button onClick={() => setShowInstallModal(true)}>
|
||||
{t('app.fTPServer.installFtpServer', 'Install FTP server')}
|
||||
@@ -541,7 +560,7 @@ function FTPServer() {
|
||||
</>
|
||||
)}
|
||||
</>,
|
||||
[isInstalled, isRunning, actionLoading],
|
||||
[isInstalled, isRunning, actionLoading, activeTab],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -601,18 +620,7 @@ function FTPServer() {
|
||||
/>
|
||||
</KpiBand>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(val) => {
|
||||
setActiveTab(val);
|
||||
if (val === 'connections') loadConnections();
|
||||
if (val === 'logs') loadLogs();
|
||||
}}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{t('common.labels.overview', 'Overview')}</TabsTrigger>
|
||||
<TabsTrigger value="users">{t('app.fTPServer.users', 'Users')}</TabsTrigger>
|
||||
<TabsTrigger value="connections">{t('app.fTPServer.connections', 'Connections')}</TabsTrigger>
|
||||
<TabsTrigger value="logs">{t('common.labels.logs', 'Logs')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={selectTab}>
|
||||
<TabsContent value="overview">
|
||||
<div className="overview-tab">
|
||||
<div className="config-section">
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useConfirm } from '../hooks/useConfirm';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import Modal from '@/components/Modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tip } from '@/components/ui/tooltip';
|
||||
import PageLayout from '../layouts/PageLayout';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -337,35 +339,43 @@ const QueueDetail = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="queue-page queue-detail">
|
||||
<div className="queue-detail-header">
|
||||
<Button variant="unstyled" type="button" className="queue-back" onClick={() => navigate('/queue')}>
|
||||
<ArrowLeft size={16} /> {t('app.queueDetail.queueBus', 'Queue bus')}
|
||||
</Button>
|
||||
<div className="queue-detail-headline">
|
||||
<div className="queue-workbar-title">
|
||||
<span>{t('app.queueDetail.queue', 'Queue')}</span>
|
||||
<h1>{queue?.name || queueSlug}</h1>
|
||||
<em>{groupSlug} / {queueSlug}</em>
|
||||
</div>
|
||||
<div className="queue-detail-actions">
|
||||
{viewOnly && (
|
||||
<span className="queue-readonly-badge">
|
||||
<Lock size={12} /> {t('app.queueDetail.readOnly', 'Read-only')}
|
||||
</span>
|
||||
)}
|
||||
{!viewOnly && (
|
||||
<Button variant="outline" onClick={() => setShowSend(true)}>
|
||||
<Send size={16} /> {t('app.queueDetail.sendMessage', 'Send message')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => { loadMeta(); loadMessages(statusFilter); }}>
|
||||
<RefreshCw size={16} /> {t('common.actions.refresh', 'Refresh')}
|
||||
<PageLayout
|
||||
className="queue-page"
|
||||
icon={<Inbox size={18} />}
|
||||
title={queue?.name || queueSlug}
|
||||
meta={(
|
||||
<>
|
||||
<span>{groupSlug} / {queueSlug}</span>
|
||||
{viewOnly && (
|
||||
<span className="queue-readonly-badge">
|
||||
<Lock size={12} /> {t('app.queueDetail.readOnly', 'Read-only')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
actions={(
|
||||
<>
|
||||
<Tip content={t('app.queueDetail.queueBus', 'Queue bus')}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('app.queueDetail.queueBus', 'Queue bus')} onClick={() => navigate('/queue')}>
|
||||
<ArrowLeft size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Tip>
|
||||
{!viewOnly && (
|
||||
<Tip content={t('app.queueDetail.sendMessage', 'Send message')}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('app.queueDetail.sendMessage', 'Send message')} onClick={() => setShowSend(true)}>
|
||||
<Send size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
<Tip content={t('common.actions.refresh', 'Refresh')}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('common.actions.refresh', 'Refresh')} onClick={() => { loadMeta(); loadMessages(statusFilter); }}>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="queue-detail">
|
||||
<KpiBand>
|
||||
<MetricCard label={t('app.queueDetail.total', 'Total')} value={stats.total || 0} />
|
||||
{STATUS_ORDER.map(s => (
|
||||
@@ -512,7 +522,8 @@ const QueueDetail = () => {
|
||||
</Modal>
|
||||
|
||||
<GridFilterDrawer {...chrome.drawerProps} />
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { useConfirm } from '../hooks/useConfirm';
|
||||
import EmptyState from '../components/EmptyState';
|
||||
import Modal from '@/components/Modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tip } from '@/components/ui/tooltip';
|
||||
import PageLayout from '../layouts/PageLayout';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -454,7 +456,32 @@ const QueueOperations = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="queue-page queue-page--ops">
|
||||
<PageLayout
|
||||
fill
|
||||
className="queue-page queue-page--ops"
|
||||
icon={<Layers size={18} />}
|
||||
title={t('app.queueOperations.queueBus', 'Queue bus')}
|
||||
meta={`${activeGroupLabel} · ${activeStatusLabel} · ${filteredQueues.length} visible`}
|
||||
actions={(
|
||||
<>
|
||||
<Tip content={t('app.queueOperations.group', 'Group')}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('app.queueOperations.group', 'Group')} onClick={() => setShowGroupModal(true)}>
|
||||
<Folder size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip content={t('app.queueOperations.queue', 'Queue')}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('app.queueOperations.queue', 'Queue')} onClick={() => setShowQueueModal(true)}>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip content={t('common.actions.refresh', 'Refresh')}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('common.actions.refresh', 'Refresh')} onClick={() => { loadData(); loadQueues(selectedGroup); }}>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="queue-ops-workspace">
|
||||
<aside className="queue-fleet-rail">
|
||||
<section className="queue-rail-section queue-rail-section--overview">
|
||||
@@ -536,25 +563,6 @@ const QueueOperations = () => {
|
||||
</aside>
|
||||
|
||||
<main className="queue-main">
|
||||
<div className="queue-workbar">
|
||||
<div className="queue-workbar-title">
|
||||
<span>{t('app.queueOperations.queueBus', 'Queue bus')}</span>
|
||||
<h1>{activeGroupLabel}</h1>
|
||||
<em>{activeStatusLabel} · {filteredQueues.length} visible</em>
|
||||
</div>
|
||||
<div className="queue-workbar-actions">
|
||||
<Button variant="outline" onClick={() => setShowGroupModal(true)}>
|
||||
<Folder size={16} /> {t('app.queueOperations.group', 'Group')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowQueueModal(true)}>
|
||||
<Plus size={16} /> {t('app.queueOperations.queue', 'Queue')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { loadData(); loadQueues(selectedGroup); }}>
|
||||
<RefreshCw size={16} /> {t('common.actions.refresh', 'Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The view name is the page's heading for the list, and
|
||||
the table's own chrome sits on that same line. */}
|
||||
<GridViewPicker
|
||||
@@ -758,7 +766,7 @@ const QueueOperations = () => {
|
||||
</Modal>
|
||||
|
||||
<GridFilterDrawer {...chrome.drawerProps} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ import { useRecordVisit } from '@/hooks/useRecordVisit';
|
||||
import FavoriteStar from '@/components/FavoriteStar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pill } from '../components/ds';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { Tip } from '@/components/ui/tooltip';
|
||||
import { Server, ArrowLeft } from 'lucide-react';
|
||||
import PageLayout from '../layouts/PageLayout';
|
||||
import PackagesTab from '../components/serverdetail/PackagesTab';
|
||||
import ServicesTab from '../components/serverdetail/ServicesTab';
|
||||
import ServerOverviewTab from '../components/serverdetail/ServerOverviewTab';
|
||||
@@ -20,8 +23,6 @@ import ServerMetricsTab from '../components/serverdetail/ServerMetricsTab';
|
||||
import SurveyTab from '../components/serverdetail/SurveyTab';
|
||||
import ServerSettingsTab, { TokenModal } from '../components/serverdetail/ServerSettingsTab';
|
||||
import {
|
||||
CopyChip,
|
||||
FolderTinyIcon,
|
||||
RefreshIcon,
|
||||
} from '../components/serverdetail/serverDetailShared';
|
||||
import ProxyStackPanel from '../components/proxy/ProxyStackPanel';
|
||||
@@ -32,7 +33,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/useAuth.js';
|
||||
import ServerRestorePointsTab from '../components/serverdetail/ServerRestorePointsTab';
|
||||
import { toastError } from '@/utils/errorMessage';
|
||||
import { formatDateTime } from '@/utils/intl';
|
||||
|
||||
// Live host metrics cadence while the server is online.
|
||||
const METRICS_POLL_MS = 10000;
|
||||
@@ -273,90 +273,46 @@ const ServerDetail = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-container server-detail-page">
|
||||
<div className="page-breadcrumb">
|
||||
<Link to="/servers">{t('common.labels.servers', 'Servers')}</Link>
|
||||
<span className="breadcrumb-separator">/</span>
|
||||
<span>{server.name}</span>
|
||||
</div>
|
||||
|
||||
<header className="server-detail-header">
|
||||
<div className="server-detail-header__main">
|
||||
<div className={`server-detail-header__avatar server-detail-header__avatar--${server.status || 'pending'}`}>
|
||||
{(server.name || '?').charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="server-detail-header__identity">
|
||||
<div className="server-detail-header__title-row">
|
||||
<h1>{server.name}</h1>
|
||||
<FavoriteStar type="server" id={server.id} path={`/servers/${server.id}`} label={server.name} />
|
||||
<Pill kind={serverStatusKind(server.status)}>
|
||||
{server.status || 'pending'}
|
||||
</Pill>
|
||||
<CopyChip
|
||||
label="id"
|
||||
value={server.id}
|
||||
title={t('app.serverDetail.copyServerId', 'Copy server ID')}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
<div className="server-detail-header__meta">
|
||||
<span className="server-detail-header__meta-item">
|
||||
{server.hostname || server.ip_address || t('app.serverDetail.noEndpointConfigured', 'No endpoint configured')}
|
||||
</span>
|
||||
{server.group_name && (
|
||||
<>
|
||||
<span className="dotsep">·</span>
|
||||
<span className="server-detail-header__meta-item"><FolderTinyIcon /> {server.group_name}</span>
|
||||
</>
|
||||
)}
|
||||
{server.os_type && (
|
||||
<>
|
||||
<span className="dotsep">·</span>
|
||||
<span className="server-detail-header__meta-item">{server.os_type}</span>
|
||||
</>
|
||||
)}
|
||||
{server.agent_version && (
|
||||
<>
|
||||
<span className="dotsep">·</span>
|
||||
<span className="server-detail-header__meta-item">agent {server.agent_version}</span>
|
||||
</>
|
||||
)}
|
||||
{server.last_seen && (
|
||||
<>
|
||||
<span className="dotsep">·</span>
|
||||
<span className="server-detail-header__meta-item">
|
||||
{t('app.serverDetail.lastSeen', 'last seen')} {formatDateTime(server.last_seen)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{server.description && (
|
||||
<p className="server-detail-header__description">{server.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="server-detail-header__actions">
|
||||
<Button variant="outline" size="sm" onClick={handlePingServer}>
|
||||
<RefreshIcon /> {t('app.serverDetail.ping', 'Ping')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) =>
|
||||
navigate(value === 'overview' ? `/servers/${id}` : `/servers/${id}/${value}`, { replace: true })
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((item) => (
|
||||
<TabsTrigger key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
{item.badge ? <span className="tab-badge">{item.badge}</span> : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<PageLayout
|
||||
className="server-detail-page"
|
||||
icon={<Server size={18} />}
|
||||
title={server.name}
|
||||
navLabel={t('app.serverDetail.sections', 'Server sections')}
|
||||
meta={(
|
||||
<>
|
||||
<FavoriteStar type="server" id={server.id} path={`/servers/${server.id}`} label={server.name} />
|
||||
<Pill kind={serverStatusKind(server.status)}>{server.status || 'pending'}</Pill>
|
||||
<span>
|
||||
{server.hostname || server.ip_address || t('app.serverDetail.noEndpointConfigured', 'No endpoint configured')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
tabs={tabs.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.badge ? <>{item.label}<span className="tab-badge">{item.badge}</span></> : item.label,
|
||||
active: item.id === activeTab,
|
||||
onSelect: () => navigate(item.id === 'overview' ? `/servers/${id}` : `/servers/${id}/${item.id}`, { replace: true }),
|
||||
}))}
|
||||
actions={(
|
||||
<>
|
||||
<Tip content={t('app.serverDetail.backToServers', 'Back to servers')}>
|
||||
<Button asChild variant="ghost" size="icon" aria-label={t('app.serverDetail.backToServers', 'Back to servers')}>
|
||||
<Link to="/servers"><ArrowLeft size={16} /></Link>
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip content={t('app.serverDetail.ping', 'Ping')}>
|
||||
<Button variant="ghost" size="icon" aria-label={t('app.serverDetail.ping', 'Ping')} onClick={handlePingServer}>
|
||||
<RefreshIcon />
|
||||
</Button>
|
||||
</Tip>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{server.description && (
|
||||
<p className="server-detail-header__description">{server.description}</p>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab}>
|
||||
<div className="server-detail-content">
|
||||
<TabsContent value="overview">
|
||||
<ServerOverviewTab
|
||||
@@ -434,7 +390,7 @@ const ServerDetail = () => {
|
||||
onGenerated={handleTokenGenerated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectTrigger, SelectContent, SelectItem, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { DataTable, Pill, CatalogCard, CatalogGrid } from '@/components/ds';
|
||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { DataTable, Pill, SegControl, CatalogCard, CatalogGrid } from '@/components/ds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useTableChrome, GridViewPicker, GridChips, GridFilterButton,
|
||||
@@ -336,12 +336,24 @@ const ServerTemplates = () => {
|
||||
|
||||
|
||||
// Publish the admin "Create Template" action to the shared tab-group top bar.
|
||||
useTopbarActions(() =>
|
||||
user?.is_admin ? (
|
||||
<Button size="sm" onClick={() => setShowCreateModal(true)}>{t('app.serverTemplates.createTemplate', 'Create template')}</Button>
|
||||
) : null,
|
||||
[user?.is_admin]
|
||||
);
|
||||
// Library/Templates switcher rides the bar beside it. Library leads, because
|
||||
// on a fresh panel Templates is empty; the default follows the data.
|
||||
useTopbarActions(() => (
|
||||
<>
|
||||
<SegControl
|
||||
aria-label={t('app.serverTemplates.views', 'Template views')}
|
||||
value={activeTab || 'library'}
|
||||
onChange={setActiveTab}
|
||||
options={[
|
||||
{ value: 'library', label: t('app.serverTemplates.library', 'Library') },
|
||||
{ value: 'templates', label: t('app.serverTemplates.templates', 'Templates'), count: templates.length || undefined },
|
||||
]}
|
||||
/>
|
||||
{user?.is_admin && (
|
||||
<Button size="sm" onClick={() => setShowCreateModal(true)}>{t('app.serverTemplates.createTemplate', 'Create template')}</Button>
|
||||
)}
|
||||
</>
|
||||
), [user?.is_admin, activeTab, templates.length]);
|
||||
|
||||
// Two accessors per column where they differ: `value` is what the column
|
||||
// menu, the filter rules, the grouping and the export read, `sortValue` is
|
||||
@@ -555,18 +567,7 @@ const ServerTemplates = () => {
|
||||
return (
|
||||
<div className="sk-tabgroup__inner server-templates-page">
|
||||
{topbarChrome}
|
||||
{/* Library leads, because on a fresh panel Templates has nothing in
|
||||
it — landing there showed an empty page as the first impression.
|
||||
The default follows the data rather than being fixed: once you
|
||||
have templates of your own, those are what you came for. */}
|
||||
<Tabs value={activeTab || 'library'} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="library">{t('app.serverTemplates.library', 'Library')}</TabsTrigger>
|
||||
<TabsTrigger value="templates">
|
||||
{t('app.serverTemplates.templates', 'Templates')}{templates.length > 0 ? ` (${templates.length})` : ''}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="templates">
|
||||
{loadErrors.templates && templates.length > 0 && (
|
||||
<ErrorState compact error={loadErrors.templates} onRetry={loadData} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { servicePortUrl } from '@/utils/serviceUrl';
|
||||
import api from '../services/api';
|
||||
import { useToast } from '../contexts/useToast.js';
|
||||
@@ -25,7 +25,8 @@ import EmptyState from '../components/EmptyState';
|
||||
import PluginSlot from '../components/PluginSlot';
|
||||
import { Layers, FileArchive, RotateCcw, LayoutDashboard, History, ScrollText, Variable, Terminal, Activity, Package, Server, SquareTerminal, Settings, Eye, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pill, ServiceTile, PageTopbar, statusKind } from '@/components/ds';
|
||||
import { Pill, statusKind } from '@/components/ds';
|
||||
import PageLayout from '../layouts/PageLayout';
|
||||
import FavoriteStar from '@/components/FavoriteStar';
|
||||
import { useRecordVisit } from '@/hooks/useRecordVisit';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -279,24 +280,52 @@ const ServiceDetail = () => {
|
||||
const openUrl = primaryDomain ? null : servicePortUrl(service);
|
||||
|
||||
return (
|
||||
<div className="app-detail-page app-detail-page--wide svc-detail">
|
||||
{/* Full-bleed top bar — the canonical PageTopbar (.sk-topbar): the SAME
|
||||
chrome as the Services LIST page and the WordPress detail page, so the
|
||||
top menu stays consistent. Breadcrumb in the title slot, all service
|
||||
actions on the right. */}
|
||||
<PageTopbar
|
||||
className="svc-detail-topbar"
|
||||
icon={<Layers size={18} />}
|
||||
title={(
|
||||
<>
|
||||
<span className="svc-crumbs">
|
||||
<Link to="/services">{t('common.labels.services', 'Services')}</Link>
|
||||
<span className="svc-crumbs__sep">/</span>
|
||||
<span className="svc-crumbs__cur">{service.name}</span>
|
||||
</span>
|
||||
<FavoriteStar type="service" id={service.id} path={`/services/${service.id}`} label={service.name} />
|
||||
</>
|
||||
)}
|
||||
<PageLayout
|
||||
fill
|
||||
className="svc-detail"
|
||||
topbarClassName="svc-detail-topbar"
|
||||
icon={<Layers size={18} />}
|
||||
title={service.name}
|
||||
navLabel={t('app.serviceDetail.sections', 'Service sections')}
|
||||
meta={(
|
||||
<>
|
||||
<FavoriteStar type="service" id={service.id} path={`/services/${service.id}`} label={service.name} />
|
||||
{openUrl && (
|
||||
<a
|
||||
href={openUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="svc-detail__open"
|
||||
title={t('app.serviceDetail.open', 'Open {{openUrl}}', { openUrl: openUrl })}
|
||||
aria-label={t('app.serviceDetail.openInANewTab', 'Open {{name}} in a new tab', { name: service.name })}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</a>
|
||||
)}
|
||||
<Pill kind={statusKind(service.statusInfo.dotClass)}>{service.statusInfo.label}</Pill>
|
||||
<span
|
||||
className="svc-detail__type-badge"
|
||||
style={{ backgroundColor: service.typeInfo.bgColor, color: service.typeInfo.color, borderColor: service.typeInfo.borderColor }}
|
||||
>
|
||||
{service.typeInfo.label}
|
||||
</span>
|
||||
{service.port && <span>{t('common.labels.port', 'Port')} {service.port}</span>}
|
||||
<span>{t('common.labels.created', 'Created')} {formatDate(service.created_at)}</span>
|
||||
{primaryDomain && (
|
||||
<a href={`https://${primaryDomain}`} target="_blank" rel="noopener noreferrer">{primaryDomain}</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
tabs={availableTabs.map(tab => {
|
||||
const Icon = TAB_ICONS[tab];
|
||||
return {
|
||||
id: tab,
|
||||
label: TAB_LABELS[tab] || tab,
|
||||
icon: Icon ? <Icon size={15} /> : null,
|
||||
active: activeTab === tab,
|
||||
onSelect: () => setActiveTab(tab),
|
||||
};
|
||||
})}
|
||||
actions={(
|
||||
<>
|
||||
{/* Deploy dropdown */}
|
||||
@@ -407,76 +436,11 @@ const ServiceDetail = () => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
|
||||
{/* Everything below the full-bleed top bar lives in a centered, padded
|
||||
column (.app-detail-body), matching the WordPress detail page. */}
|
||||
<div className="app-detail-body">
|
||||
{/* Identity — tile + name + status + type (no actions). */}
|
||||
<div className="app-detail-header">
|
||||
<ServiceTile name={service.name} size={52} className="svc-detail__tile" />
|
||||
<div className="app-detail-title-block">
|
||||
<h1>
|
||||
{service.name}
|
||||
{/* Before the status pill: the first thing you want
|
||||
after seeing a service is live is to go look at it. */}
|
||||
{openUrl && (
|
||||
<a
|
||||
href={openUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="svc-detail__open"
|
||||
title={t('app.serviceDetail.open', 'Open {{openUrl}}', { openUrl: openUrl })}
|
||||
aria-label={t('app.serviceDetail.openInANewTab', 'Open {{name}} in a new tab', { name: service.name })}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</a>
|
||||
)}
|
||||
<Pill kind={statusKind(service.statusInfo.dotClass)}>
|
||||
{service.statusInfo.label}
|
||||
</Pill>
|
||||
<span
|
||||
className="svc-detail__type-badge"
|
||||
style={{ backgroundColor: service.typeInfo.bgColor, color: service.typeInfo.color, borderColor: service.typeInfo.borderColor }}
|
||||
>
|
||||
{service.typeInfo.label}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="app-detail-subtitle">
|
||||
{service.port && (
|
||||
openUrl ? (
|
||||
<a
|
||||
href={openUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="svc-detail__portlink"
|
||||
title={t('app.serviceDetail.open', 'Open {{openUrl}}', { openUrl: openUrl })}
|
||||
>
|
||||
{t('common.labels.port', 'Port')} {service.port}
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
) : (
|
||||
<span>{t('common.labels.port', 'Port')} {service.port}</span>
|
||||
)
|
||||
)}
|
||||
{service.port && <span className="separator">·</span>}
|
||||
<span>{t('common.labels.created', 'Created')} {formatDate(service.created_at)}</span>
|
||||
{primaryDomain && (
|
||||
<>
|
||||
<span className="separator">·</span>
|
||||
<a
|
||||
href={`https://${primaryDomain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{primaryDomain}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repo Connection Pill */}
|
||||
<div className="svc-detail__repo-bar">
|
||||
{isUpload ? (
|
||||
@@ -573,25 +537,6 @@ const ServiceDetail = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Bar — shared underline-style strip (app-detail-tabs) with a
|
||||
glyph per tab; Settings is pinned to the far right (--end), exactly
|
||||
like the WordPress detail page. */}
|
||||
<div className="app-detail-tabs">
|
||||
{availableTabs.map(tab => {
|
||||
const Icon = TAB_ICONS[tab];
|
||||
return (
|
||||
<Button variant="unstyled" type="button"
|
||||
key={tab}
|
||||
className={`app-detail-tab ${activeTab === tab ? 'active' : ''} ${tab === 'settings' ? 'app-detail-tab--end' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{Icon && <Icon size={14} />}
|
||||
{TAB_LABELS[tab] || tab}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="app-detail-content">
|
||||
{activeTab === 'overview' && <OverviewTab app={service} deployConfig={deployConfig} />}
|
||||
@@ -618,7 +563,7 @@ const ServiceDetail = () => {
|
||||
{/* Extension slot: widgets contributed to the service detail page */}
|
||||
<PluginSlot name="service.detail.tab" context={{ serviceId: service.id }} />
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select, SelectTrigger, SelectContent, SelectItem, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Pill } from '@/components/ds';
|
||||
import { Tabs, TabsContent } from '@/components/ui/tabs';
|
||||
import { Pill, SegControl } from '@/components/ds';
|
||||
import { useTopbarActions } from '@/hooks/useTopbarActions';
|
||||
import CopyField from '../components/CopyField';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -112,6 +112,7 @@ const StatusPages = () => {
|
||||
const [selectedPage, setSelectedPage] = useState(null);
|
||||
const [components, setComponents] = useState([]);
|
||||
const [incidents, setIncidents] = useState([]);
|
||||
const [detailTab, setDetailTab] = useState('components');
|
||||
const [showCreatePage, setShowCreatePage] = useState(false);
|
||||
const [showCreateComponent, setShowCreateComponent] = useState(false);
|
||||
// Monitors are first-class now (core /api/v1/monitors) — a status page
|
||||
@@ -331,6 +332,18 @@ const StatusPages = () => {
|
||||
useTopbarActions(() =>
|
||||
(
|
||||
<>
|
||||
{selectedPage && (
|
||||
<SegControl
|
||||
aria-label={t('app.statusPages.views', 'Status page views')}
|
||||
value={detailTab}
|
||||
onChange={setDetailTab}
|
||||
options={[
|
||||
{ value: 'components', label: t('app.statusPages.components', 'Components') },
|
||||
{ value: 'incidents', label: t('app.statusPages.incidents', 'Incidents') },
|
||||
{ value: 'settings', label: t('app.statusPages.page', 'Page') },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={loadPages}>
|
||||
<RefreshCw size={16} />
|
||||
{t('common.actions.refresh', 'Refresh')}
|
||||
@@ -343,7 +356,7 @@ const StatusPages = () => {
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[isAdmin]
|
||||
[isAdmin, !!selectedPage, detailTab]
|
||||
);
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
@@ -423,13 +436,7 @@ const StatusPages = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="components">
|
||||
<TabsList>
|
||||
<TabsTrigger value="components">{t('app.statusPages.components', 'Components')}</TabsTrigger>
|
||||
<TabsTrigger value="incidents">{t('app.statusPages.incidents', 'Incidents')}</TabsTrigger>
|
||||
<TabsTrigger value="settings">{t('app.statusPages.page', 'Page')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab}>
|
||||
<TabsContent value="components">
|
||||
<div className="status-actions-bar">
|
||||
{isAdmin && (
|
||||
|
||||
@@ -254,13 +254,29 @@ const WorkspaceDetail = () => {
|
||||
topbarClassName="ws-detail-topbar"
|
||||
contentClassName="app-detail-page app-detail-page--wide"
|
||||
icon={<LayoutGrid size={18} />}
|
||||
title={(
|
||||
<span className="ws-crumbs">
|
||||
<Link to="/workspaces">{t('app.workspaceDetail.workspaces', 'Workspaces')}</Link>
|
||||
<span className="ws-crumbs__sep">/</span>
|
||||
<span className="ws-crumbs__cur">{ws.name}</span>
|
||||
</span>
|
||||
title={ws.name}
|
||||
navLabel={t('app.workspaceDetail.sections', 'Workspace sections')}
|
||||
meta={(
|
||||
<>
|
||||
<FavoriteStar type="workspace" id={ws.id} path={`/workspaces/${ws.id}`} label={ws.name} />
|
||||
{isCurrent
|
||||
? <Pill kind="green">{t('app.workspaceDetail.activeWorkspace', 'active workspace')}</Pill>
|
||||
: <Pill kind={ws.status === 'active' ? 'green' : 'amber'}>{ws.status}</Pill>}
|
||||
<span>/{ws.slug}</span>
|
||||
<span>{t('app.workspaceDetail.memberCount', { count: members.length, defaultValue_one: '1 member', defaultValue_other: '{{count}} members' })}</span>
|
||||
{since && <span>since {since}</span>}
|
||||
</>
|
||||
)}
|
||||
tabs={VALID_TABS.map((tab) => {
|
||||
const Icon = TAB_META[tab].icon;
|
||||
return {
|
||||
id: tab,
|
||||
label: TAB_META[tab].label,
|
||||
icon: <Icon size={15} />,
|
||||
active: activeTab === tab,
|
||||
onSelect: () => setActiveTab(tab),
|
||||
};
|
||||
})}
|
||||
>
|
||||
|
||||
<div className="app-detail-body">
|
||||
@@ -272,43 +288,8 @@ const WorkspaceDetail = () => {
|
||||
onRetry={load}
|
||||
/>
|
||||
)}
|
||||
<div className="app-detail-header">
|
||||
<ServiceTile name={ws.name} size={54} gradient={ws.primary_color || undefined} className="ws-detail__tile" />
|
||||
<div className="app-detail-title-block">
|
||||
<h1>
|
||||
{ws.name}
|
||||
<FavoriteStar type="workspace" id={ws.id} path={`/workspaces/${ws.id}`} label={ws.name} />
|
||||
{isCurrent
|
||||
? <Pill kind="green">{t('app.workspaceDetail.activeWorkspace', 'active workspace')}</Pill>
|
||||
: <Pill kind={ws.status === 'active' ? 'green' : 'amber'}>{ws.status}</Pill>}
|
||||
</h1>
|
||||
<div className="app-detail-subtitle">
|
||||
<span>/{ws.slug}</span>
|
||||
<span className="separator">·</span>
|
||||
<span>{t('app.workspaceDetail.memberCount', { count: members.length, defaultValue_one: '1 member', defaultValue_other: '{{count}} members' })}</span>
|
||||
{since && <><span className="separator">·</span><span>since {since}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ws.description && <p className="ws-detail__desc">{ws.description}</p>}
|
||||
|
||||
<div className="app-detail-tabs">
|
||||
{VALID_TABS.map((tab) => {
|
||||
const meta = TAB_META[tab];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div
|
||||
key={tab}
|
||||
className={`app-detail-tab ${activeTab === tab ? 'active' : ''} ${tab === 'settings' ? 'app-detail-tab--end' : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
<Icon size={14} /> {meta.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="app-detail-content">
|
||||
{activeTab === 'overview' && (
|
||||
<WorkspaceOverviewTab
|
||||
|
||||
@@ -1410,6 +1410,10 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
// Button tabs (<button role="tab">) share the link tabs' flat look.
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s, color 0.14s;
|
||||
|
||||
svg { width: 15px; height: 15px; }
|
||||
@@ -1480,6 +1484,17 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
|
||||
> .sk-topbar__chrome > .sk-searchfield { flex: 1 1 calc(100% - #{2 * ($control-lg + $space-2)}); min-width: 0; }
|
||||
|
||||
:is(button, a, [role='button']) { min-height: $control-lg; }
|
||||
|
||||
// A view switcher (SegControl) is a row of its own that scrolls
|
||||
// sideways rather than clipping or wrapping its segments.
|
||||
> .sk-seg {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
.sk-seg__btn { flex-shrink: 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,7 +447,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-5;
|
||||
padding: $space-5;
|
||||
}
|
||||
|
||||
.queue-back {
|
||||
|
||||
Reference in new issue
Block a user