mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
feat: UI polish sprint — 7 items + logs toolbar redesign (#365)
* feat: UI polish sprint — tag filters, toast tokens, logs toolbar, billing portal, editor UX - Fleet: replace inline tag pills with multi-select combobox dropdown - Toast: remap all notification colors to oklch design tokens - Logs: convert floating hover toolbar to permanent pinned toolbar, replace all hardcoded colors with design tokens for theme support - Audit: fix dropdown scroll-lock (modal=false), light theme button contrast - Resources: remove redundant inner border/bg on tab wrapper - Editor: add ⌘K/Ctrl+K shortcut hint and handler, standardize button heights - Billing: signed Lemon Squeezy portal URLs via sencho.io proxy for all tiers - New MultiSelectCombobox UI component * feat(editor): replace button row with split-button dropdown Replace three separate editor buttons (Discard, Save Only, Save & Deploy) with a compact split-button dropdown. Primary action is "Save & Deploy"; chevron opens dropdown with "Save Only" and "Discard Changes" options.
This commit is contained in:
@@ -16,14 +16,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
* **stacks:** bulk `/api/stacks/statuses` endpoint — fetches all stack statuses in a single Docker API call instead of N individual `docker compose ps` invocations. Falls back to per-stack queries for remote nodes on older versions.
|
* **stacks:** bulk `/api/stacks/statuses` endpoint — fetches all stack statuses in a single Docker API call instead of N individual `docker compose ps` invocations. Falls back to per-stack queries for remote nodes on older versions.
|
||||||
|
* **fleet:** tag filter dropdown — replaced inline pills with a multi-select combobox for cleaner filtering
|
||||||
|
* **editor:** ⌘K / Ctrl+K keyboard shortcut to focus the sidebar stack search
|
||||||
|
* **billing:** signed Lemon Squeezy Customer Portal URLs — "Billing" button now opens an auto-authenticated portal session via the sencho.io proxy, available for all license tiers
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
* **stacks:** sidebar status indicators showing "--" (unknown) after stopping a stack instead of "DN". Root cause was a race condition where `refreshStacks` queried container state before Docker had fully transitioned containers.
|
* **stacks:** sidebar status indicators showing "--" (unknown) after stopping a stack instead of "DN". Root cause was a race condition where `refreshStacks` queried container state before Docker had fully transitioned containers.
|
||||||
|
* **logs:** toolbar (search, filters, actions) was a hover-only floating bar that could disappear — now a permanent pinned toolbar always visible at the top
|
||||||
|
* **logs:** replaced all hardcoded colors with design tokens for proper light/dark theme support
|
||||||
|
* **audit:** Export dropdown caused toolbar to scroll out of view due to Radix scroll-locking (`modal={false}` fix)
|
||||||
|
* **audit:** light theme button contrast — outline buttons were invisible due to `--input` matching `--background`
|
||||||
|
* **toast:** notification colors used hardcoded Tailwind values instead of oklch design tokens
|
||||||
|
* **editor:** button row (Discard / Save Only / Save & Deploy) had inconsistent heights across variants
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
* **stacks:** stack actions (deploy, stop, restart, update) are now tracked per-stack instead of globally. Users can fire actions on multiple stacks concurrently without the UI blocking. Sidebar shows a spinner per stack during in-flight actions.
|
* **stacks:** stack actions (deploy, stop, restart, update) are now tracked per-stack instead of globally. Users can fire actions on multiple stacks concurrently without the UI blocking. Sidebar shows a spinner per stack during in-flight actions.
|
||||||
|
* **resources:** removed redundant inner border and background on tab navigation wrapper
|
||||||
|
|
||||||
## [0.34.0](https://github.com/AnsoCode/Sencho/compare/v0.33.1...v0.34.0) (2026-04-03)
|
## [0.34.0](https://github.com/AnsoCode/Sencho/compare/v0.33.1...v0.34.0) (2026-04-03)
|
||||||
|
|
||||||
|
|||||||
@@ -1062,6 +1062,20 @@ app.post('/api/license/validate', async (_req: Request, res: Response): Promise<
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/license/billing-portal', async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const url = await LicenseService.getInstance().getBillingPortalUrl();
|
||||||
|
if (!url) {
|
||||||
|
res.status(404).json({ error: 'No billing portal available. Ensure you have an active license.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[License] Billing portal error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to retrieve billing portal URL' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Self-Update ---
|
// --- Self-Update ---
|
||||||
|
|
||||||
/** Respond 202 and trigger the "last breath" self-update after the response flushes. */
|
/** Respond 202 and trigger the "last breath" self-update after the response flushes. */
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export class LicenseService {
|
|||||||
validUntil,
|
validUntil,
|
||||||
trialDaysRemaining,
|
trialDaysRemaining,
|
||||||
instanceId,
|
instanceId,
|
||||||
portalUrl: db.getSystemState('customer_portal_url') || null,
|
portalUrl: db.getSystemState('billing_portal_url') || db.getSystemState('customer_portal_url') || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +291,13 @@ export class LicenseService {
|
|||||||
if (data.meta?.variant_name) {
|
if (data.meta?.variant_name) {
|
||||||
db.setSystemState('license_variant_name', data.meta.variant_name);
|
db.setSystemState('license_variant_name', data.meta.variant_name);
|
||||||
}
|
}
|
||||||
|
if (data.meta?.customer_id) {
|
||||||
|
db.setSystemState('customer_id', String(data.meta.customer_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any cached portal URL so it's refreshed on next request
|
||||||
|
db.setSystemState('billing_portal_url', '');
|
||||||
|
db.setSystemState('billing_portal_expires', '');
|
||||||
|
|
||||||
console.log('[License] Activated successfully.');
|
console.log('[License] Activated successfully.');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -345,6 +352,8 @@ export class LicenseService {
|
|||||||
'update_payment_url',
|
'update_payment_url',
|
||||||
'order_id',
|
'order_id',
|
||||||
'receipt_url',
|
'receipt_url',
|
||||||
|
'billing_portal_url',
|
||||||
|
'billing_portal_expires',
|
||||||
];
|
];
|
||||||
for (const key of keysToRemove) {
|
for (const key of keysToRemove) {
|
||||||
db.setSystemState(key, '');
|
db.setSystemState(key, '');
|
||||||
@@ -415,6 +424,9 @@ export class LicenseService {
|
|||||||
if (data.meta?.variant_name) {
|
if (data.meta?.variant_name) {
|
||||||
db.setSystemState('license_variant_name', data.meta.variant_name);
|
db.setSystemState('license_variant_name', data.meta.variant_name);
|
||||||
}
|
}
|
||||||
|
if (data.meta?.customer_id && !db.getSystemState('customer_id')) {
|
||||||
|
db.setSystemState('customer_id', String(data.meta.customer_id));
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[License] Validation successful.');
|
console.log('[License] Validation successful.');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -425,6 +437,53 @@ export class LicenseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a signed billing portal URL via the sencho.io proxy.
|
||||||
|
* Returns a pre-signed Lemon Squeezy Customer Portal URL (valid 24hrs).
|
||||||
|
* Caches the URL for 12 hours to reduce external API calls.
|
||||||
|
*/
|
||||||
|
public async getBillingPortalUrl(): Promise<string | null> {
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const status = db.getSystemState('license_status');
|
||||||
|
const licenseKey = db.getSystemState('license_key');
|
||||||
|
|
||||||
|
if (status !== 'active' || !licenseKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache (12hr TTL)
|
||||||
|
const cachedUrl = db.getSystemState('billing_portal_url');
|
||||||
|
const cachedExpires = db.getSystemState('billing_portal_expires');
|
||||||
|
if (cachedUrl && cachedExpires && Date.now() < parseInt(cachedExpires, 10)) {
|
||||||
|
return cachedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<{ url: string }>(
|
||||||
|
'https://sencho.io/api/billing-portal',
|
||||||
|
{ license_key: licenseKey },
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = response.data?.url;
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for 12 hours
|
||||||
|
const ttl = 12 * 60 * 60 * 1000;
|
||||||
|
db.setSystemState('billing_portal_url', url);
|
||||||
|
db.setSystemState('billing_portal_expires', String(Date.now() + ttl));
|
||||||
|
|
||||||
|
return url;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[License] Failed to fetch billing portal URL:', (err as Error).message);
|
||||||
|
// Return stale cache if available
|
||||||
|
if (cachedUrl) return cachedUrl;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start periodic background validation every 72 hours.
|
* Start periodic background validation every 72 hours.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -123,9 +123,9 @@ export function AuditLogView() {
|
|||||||
<CardTitle>Audit Log</CardTitle>
|
<CardTitle>Audit Log</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" className="border-border">
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export
|
Export
|
||||||
<ChevronDown className="w-3 h-3 ml-1" />
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
@@ -136,7 +136,7 @@ export function AuditLogView() {
|
|||||||
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
|
<Button variant="outline" size="sm" className="border-border" onClick={fetchLogs} disabled={loading}>
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { springs } from '@/lib/motion';
|
|||||||
import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight';
|
import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check } from 'lucide-react';
|
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill';
|
import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill';
|
||||||
import { LabelAssignPopover } from './LabelAssignPopover';
|
import { LabelAssignPopover } from './LabelAssignPopover';
|
||||||
@@ -259,6 +259,19 @@ export default function EditorLayout() {
|
|||||||
localStorage.setItem('sencho-theme', theme);
|
localStorage.setItem('sencho-theme', theme);
|
||||||
}, [isDarkMode, theme]);
|
}, [isDarkMode, theme]);
|
||||||
|
|
||||||
|
// ⌘K / Ctrl+K — focus stack search input
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.querySelector<HTMLInputElement>('[cmdk-input]');
|
||||||
|
input?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Listen for cross-component navigation (e.g., NodeManager → Schedules)
|
// Listen for cross-component navigation (e.g., NodeManager → Schedules)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
@@ -1367,13 +1380,16 @@ export default function EditorLayout() {
|
|||||||
|
|
||||||
{/* Search Input & Stack List */}
|
{/* Search Input & Stack List */}
|
||||||
<Command className="bg-transparent flex-1 flex flex-col overflow-hidden">
|
<Command className="bg-transparent flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="px-4 py-2 border-b border-border flex-none">
|
<div className="px-4 py-2 flex-none relative">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search stacks..."
|
placeholder="Search stacks..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onValueChange={setSearchQuery}
|
onValueChange={setSearchQuery}
|
||||||
className="h-9"
|
className="h-9 border-none"
|
||||||
/>
|
/>
|
||||||
|
<kbd className="pointer-events-none absolute right-6 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-glass-border bg-glass-highlight px-1.5 font-mono text-[10px] text-muted-foreground">
|
||||||
|
{typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+'}K
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
{isPro && labels.length > 0 && (
|
{isPro && labels.length > 0 && (
|
||||||
<div className="flex gap-1 px-3 py-1.5 overflow-x-auto scrollbar-none flex-none">
|
<div className="flex gap-1 px-3 py-1.5 overflow-x-auto scrollbar-none flex-none">
|
||||||
@@ -2001,7 +2017,7 @@ export default function EditorLayout() {
|
|||||||
|
|
||||||
{/* Right Column (The Editor) */}
|
{/* Right Column (The Editor) */}
|
||||||
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-[600px] bg-card">
|
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-[600px] bg-card">
|
||||||
<div className="p-4 border-b border-muted flex items-center justify-between">
|
<div className="p-4 border-b border-muted flex items-center justify-between shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -2032,27 +2048,36 @@ export default function EditorLayout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{can('stack:edit', 'stack', stackName) && (
|
{can('stack:edit', 'stack', stackName) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center">
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
|
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
|
||||||
<Pencil className="w-4 h-4 mr-2" />
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center">
|
||||||
<Button size="sm" variant="outline" className="rounded-lg" onClick={discardChanges}>
|
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={handleSaveAndDeploy} disabled={loadingAction === 'deploy'}>
|
||||||
<X className="w-4 h-4 mr-2" />
|
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||||
Discard
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" className="rounded-lg" onClick={saveFile}>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
Save Only
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="default" className="rounded-lg" onClick={handleSaveAndDeploy} disabled={loadingAction === 'deploy'}>
|
|
||||||
<Rocket className="w-4 h-4 mr-2" />
|
|
||||||
Save & Deploy
|
Save & Deploy
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="sm" variant="default" className="rounded-r-lg rounded-l-none border-l border-primary-foreground/20 px-1.5" disabled={loadingAction === 'deploy'}>
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={saveFile}>
|
||||||
|
<Save className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||||
|
Save Only
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={discardChanges} className="text-destructive/80 focus:text-destructive">
|
||||||
|
<X className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||||
|
Discard Changes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { useLicense } from '@/context/LicenseContext';
|
|||||||
import { ProGate } from './ProGate';
|
import { ProGate } from './ProGate';
|
||||||
import FleetSnapshots from './FleetSnapshots';
|
import FleetSnapshots from './FleetSnapshots';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill';
|
import { LabelDot, type Label as StackLabel } from './LabelPill';
|
||||||
|
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
@@ -1022,24 +1023,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
{fleetLabels.length > 0 && (
|
{fleetLabels.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="w-px h-5 bg-border mx-1" />
|
<div className="w-px h-5 bg-border mx-1" />
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<MultiSelectCombobox
|
||||||
{fleetLabels.map(label => (
|
options={fleetLabels.map(l => ({ value: String(l.id), label: l.name, color: l.color }))}
|
||||||
<LabelPill
|
selected={new Set(Array.from(labelFilters).map(String))}
|
||||||
key={label.id}
|
onSelectionChange={(sel) => setLabelFilters(new Set(Array.from(sel).map(Number)))}
|
||||||
label={label}
|
placeholder="Tags"
|
||||||
size="sm"
|
renderOption={(option) => (
|
||||||
active={labelFilters.has(label.id)}
|
<span className="flex items-center gap-1.5">
|
||||||
onClick={() => {
|
<LabelDot color={option.color as StackLabel['color'] ?? 'slate'} />
|
||||||
setLabelFilters(prev => {
|
{option.label}
|
||||||
const next = new Set(prev);
|
</span>
|
||||||
if (next.has(label.id)) next.delete(label.id);
|
)}
|
||||||
else next.add(label.id);
|
/>
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -200,16 +200,16 @@ export function GlobalObservabilityView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full relative group bg-[#0A0A0A] text-gray-300">
|
<div className="flex flex-col h-full w-full bg-background text-foreground">
|
||||||
{/* Node Context Indicator */}
|
{/* Permanent Toolbar */}
|
||||||
{activeNode?.type === 'remote' && (
|
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-border bg-card">
|
||||||
<div className="absolute top-2 left-4 z-10 flex items-center gap-1.5 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md px-2.5 py-1 text-xs text-muted-foreground">
|
{activeNode?.type === 'remote' && (
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
|
<div className="flex items-center gap-1.5 border border-border rounded-md px-2.5 py-1 text-xs text-muted-foreground mr-1">
|
||||||
{activeNode.name}
|
<span className="w-1.5 h-1.5 rounded-full bg-info shrink-0" />
|
||||||
</div>
|
{activeNode.name}
|
||||||
)}
|
</div>
|
||||||
{/* Floating Action Bar */}
|
)}
|
||||||
<div className="absolute top-2 right-6 z-10 flex gap-2 transition-opacity duration-200 opacity-0 group-hover:opacity-100 focus-within:opacity-100 bg-background/90 backdrop-blur-sm border border-border shadow-md rounded-md p-1 pr-1">
|
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -220,14 +220,14 @@ export function GlobalObservabilityView() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="h-8 text-sm">
|
<Button variant="outline" size="sm" className="h-8 text-sm">
|
||||||
<Filter className="w-3.5 h-3.5 mr-2" />
|
<Filter className="w-3.5 h-3.5 mr-2" />
|
||||||
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
|
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
{allStacks.map(stack => (
|
{allStacks.map(stack => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={stack}
|
key={stack}
|
||||||
@@ -254,46 +254,47 @@ export function GlobalObservabilityView() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
|
<div className="flex-1" />
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{devMode && (
|
{devMode && (
|
||||||
<div className="flex items-center px-2 text-xs text-success font-mono animate-pulse">
|
<div className="flex items-center px-2 text-xs text-success font-mono animate-pulse">
|
||||||
● LIVE
|
● LIVE
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" onClick={handleClearLogs} className="h-8 text-sm px-2">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleDownload} disabled={filteredLogs.length === 0} className="h-8 text-sm px-2">
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && logs.length === 0 && (
|
<div className="flex-1 min-h-0 overflow-auto p-4 relative bg-background" onScroll={handleScroll}>
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
|
{loading && logs.length === 0 && (
|
||||||
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
|
||||||
</div>
|
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-auto p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent" onScroll={handleScroll}>
|
|
||||||
{filteredLogs.length > 0 ? (
|
{filteredLogs.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
{filteredLogs.length > MAX_DISPLAY_ROWS && (
|
{filteredLogs.length > MAX_DISPLAY_ROWS && (
|
||||||
<div className="text-gray-600 italic text-xs text-center mb-3 py-1 border-b border-gray-800">
|
<div className="text-muted-foreground italic text-xs text-center mb-3 py-1 border-b border-border">
|
||||||
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
|
Showing last {MAX_DISPLAY_ROWS} of {filteredLogs.length} matching entries. Use filters or clear logs to see earlier entries.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
|
{filteredLogs.slice(-MAX_DISPLAY_ROWS).map((log) => (
|
||||||
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-white/5 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
<div key={log._id} className="mb-1 leading-relaxed whitespace-pre-wrap break-all hover:bg-accent/50 px-2 py-0.5 rounded -mx-2 font-mono text-xs">
|
||||||
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
<span className="text-muted-foreground mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
|
||||||
<span className="text-blue-400 font-semibold mr-2">[{log.containerName}]</span>
|
<span className="text-info font-semibold mr-2">[{log.containerName}]</span>
|
||||||
<span className={`mr-2 font-medium ${log.level === 'ERROR' ? 'text-red-500' : log.level === 'WARN' ? 'text-yellow-500' : 'text-success'}`}>{log.level}:</span>
|
<span className={`mr-2 font-medium ${log.level === 'ERROR' ? 'text-destructive' : log.level === 'WARN' ? 'text-warning' : 'text-success'}`}>{log.level}:</span>
|
||||||
<span className={log.source === 'STDERR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
|
<span className={log.source === 'STDERR' ? 'text-destructive/80' : 'text-foreground/80'}>{log.message}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-gray-500 italic p-4 text-center mt-10">
|
<div className="text-muted-foreground italic p-4 text-center mt-10">
|
||||||
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
|
{logs.length === 0 ? "No active logs found." : "No logs match the current filters."}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export default function ResourcesView() {
|
|||||||
defaultValue="images"
|
defaultValue="images"
|
||||||
className="flex-1 flex flex-col w-full rounded-lg border bg-card shadow-card-bevel overflow-hidden min-h-[400px] animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-150"
|
className="flex-1 flex flex-col w-full rounded-lg border bg-card shadow-card-bevel overflow-hidden min-h-[400px] animate-in fade-in-0 slide-in-from-bottom-2 duration-300 delay-150"
|
||||||
>
|
>
|
||||||
<div className="px-4 pt-3 pb-0 border-b border-glass-border bg-glass">
|
<div className="px-4 pt-3 pb-2">
|
||||||
<TabsList className="grid grid-cols-4 w-full md:w-[680px] h-9 gap-1 p-0">
|
<TabsList className="grid grid-cols-4 w-full md:w-[680px] h-9 gap-1 p-0">
|
||||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||||
{(['images', 'volumes', 'networks'] as const).map(tab => (
|
{(['images', 'volumes', 'networks'] as const).map(tab => (
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Settings, LogOut, ExternalLink, Monitor, Sun, Moon, User } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Settings, LogOut, ExternalLink, Monitor, Sun, Moon, User, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { TierBadge } from './TierBadge';
|
import { TierBadge } from './TierBadge';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'auto';
|
type Theme = 'light' | 'dark' | 'auto';
|
||||||
@@ -17,6 +20,24 @@ interface UserProfileDropdownProps {
|
|||||||
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
|
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
|
||||||
const { logout, user, isAdmin } = useAuth();
|
const { logout, user, isAdmin } = useAuth();
|
||||||
const { license } = useLicense();
|
const { license } = useLicense();
|
||||||
|
const [billingLoading, setBillingLoading] = useState(false);
|
||||||
|
|
||||||
|
const openBillingPortal = async () => {
|
||||||
|
setBillingLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/license/billing-portal', { localOnly: true });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && data.url) {
|
||||||
|
window.open(data.url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(data?.error || data?.message || data?.data?.error || 'Something went wrong.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to open billing portal.');
|
||||||
|
} finally {
|
||||||
|
setBillingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -57,15 +78,18 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
|
|||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
{license?.status === 'active' && (
|
{license?.status === 'active' && (
|
||||||
<a
|
<button
|
||||||
href="https://sencho.io/billing"
|
onClick={openBillingPortal}
|
||||||
target="_blank"
|
disabled={billingLoading}
|
||||||
rel="noopener noreferrer"
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors text-left disabled:opacity-50"
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors"
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
{billingLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 text-muted-foreground animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
Billing
|
Billing
|
||||||
</a>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,14 +8,33 @@ import { useLicense } from '@/context/LicenseContext';
|
|||||||
import { TierBadge } from '@/components/TierBadge';
|
import { TierBadge } from '@/components/TierBadge';
|
||||||
import {
|
import {
|
||||||
Crown, CheckCircle, Check, XCircle, Clock, ExternalLink,
|
Crown, CheckCircle, Check, XCircle, Clock, ExternalLink,
|
||||||
CreditCard, RefreshCw, Zap, Compass, ShipWheel,
|
CreditCard, RefreshCw, Zap, Compass, ShipWheel, Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
|
||||||
export function LicenseSection() {
|
export function LicenseSection() {
|
||||||
const { license, activate, deactivate } = useLicense();
|
const { license, activate, deactivate } = useLicense();
|
||||||
const [licenseKeyInput, setLicenseKeyInput] = useState('');
|
const [licenseKeyInput, setLicenseKeyInput] = useState('');
|
||||||
const [isActivating, setIsActivating] = useState(false);
|
const [isActivating, setIsActivating] = useState(false);
|
||||||
const [isDeactivating, setIsDeactivating] = useState(false);
|
const [isDeactivating, setIsDeactivating] = useState(false);
|
||||||
|
const [billingLoading, setBillingLoading] = useState(false);
|
||||||
|
|
||||||
|
const openBillingPortal = async () => {
|
||||||
|
setBillingLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/license/billing-portal', { localOnly: true });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && data.url) {
|
||||||
|
window.open(data.url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.error(data?.error || data?.message || data?.data?.error || 'Something went wrong.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to open billing portal.');
|
||||||
|
} finally {
|
||||||
|
setBillingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -94,17 +113,20 @@ export function LicenseSection() {
|
|||||||
{/* Manage Subscription (active Pro) */}
|
{/* Manage Subscription (active Pro) */}
|
||||||
{license?.status === 'active' && (
|
{license?.status === 'active' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{license.portalUrl && (
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={openBillingPortal}
|
||||||
onClick={() => window.open(license.portalUrl!, '_blank')}
|
disabled={billingLoading}
|
||||||
>
|
>
|
||||||
|
{billingLoading ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
<CreditCard className="w-4 h-4 mr-2" />
|
<CreditCard className="w-4 h-4 mr-2" />
|
||||||
Manage Subscription
|
)}
|
||||||
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
|
Manage Subscription
|
||||||
</Button>
|
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
|
||||||
)}
|
</Button>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Deactivating will revert to Community features.
|
Deactivating will revert to Community features.
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface MultiSelectOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiSelectComboboxProps {
|
||||||
|
options: MultiSelectOption[]
|
||||||
|
selected: Set<string>
|
||||||
|
onSelectionChange: (selected: Set<string>) => void
|
||||||
|
placeholder?: string
|
||||||
|
searchPlaceholder?: string
|
||||||
|
emptyText?: string
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
renderOption?: (option: MultiSelectOption, isSelected: boolean) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelectCombobox({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onSelectionChange,
|
||||||
|
placeholder = "Select...",
|
||||||
|
searchPlaceholder = "Search...",
|
||||||
|
emptyText = "No results found.",
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
renderOption,
|
||||||
|
}: MultiSelectComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const [search, setSearch] = React.useState("")
|
||||||
|
const wrapperRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const filtered = search
|
||||||
|
? options.filter((o) =>
|
||||||
|
o.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: options
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
setSearch("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpen(false)
|
||||||
|
setSearch("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onMouseDown)
|
||||||
|
document.addEventListener("keydown", onKeyDown, true)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onMouseDown)
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleToggle = (option: MultiSelectOption) => {
|
||||||
|
const next = new Set(selected)
|
||||||
|
if (next.has(option.value)) {
|
||||||
|
next.delete(option.value)
|
||||||
|
} else {
|
||||||
|
next.add(option.value)
|
||||||
|
}
|
||||||
|
onSelectionChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerLabel = selected.size > 0
|
||||||
|
? `${selected.size} tag${selected.size !== 1 ? 's' : ''}`
|
||||||
|
: placeholder
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className={cn("relative", className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => { if (!disabled) setOpen(!open) }}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 items-center gap-1.5 whitespace-nowrap rounded-md border border-input bg-transparent px-2.5 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
|
||||||
|
selected.size > 0 ? "text-foreground" : "text-muted-foreground",
|
||||||
|
open && "ring-1 ring-ring border-ring"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{triggerLabel}</span>
|
||||||
|
<ChevronsUpDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 top-[calc(100%+4px)] z-50 min-w-[180px] rounded-md border border-glass-border bg-popover text-popover-foreground shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15] animate-in fade-in-0 zoom-in-95 slide-in-from-top-2">
|
||||||
|
{options.length > 5 && (
|
||||||
|
<div className="p-1.5 border-b border-glass-border">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-7 w-full bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-[200px] overflow-y-auto overflow-x-hidden p-1">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="py-3 text-center text-xs text-muted-foreground">
|
||||||
|
{emptyText}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((option) => {
|
||||||
|
const isSelected = selected.has(option.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggle(option)}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none hover:bg-accent hover:text-accent-foreground",
|
||||||
|
isSelected && "bg-accent/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3.5 w-3.5 shrink-0",
|
||||||
|
isSelected ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
{renderOption ? renderOption(option, isSelected) : option.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<div className="border-t border-glass-border p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectionChange(new Set())}
|
||||||
|
className="flex w-full items-center justify-center rounded-sm px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -54,24 +54,24 @@ const notificationConfig: Record<ToastType, {
|
|||||||
gradient: string;
|
gradient: string;
|
||||||
}> = {
|
}> = {
|
||||||
info: {
|
info: {
|
||||||
iconColor: 'text-blue-500 dark:text-blue-400',
|
iconColor: 'text-info',
|
||||||
icon: <InfoIcon className="h-6 w-6" />,
|
icon: <InfoIcon className="h-6 w-6" />,
|
||||||
gradient: 'from-blue-100/60 to-transparent dark:from-blue-900/20 dark:to-transparent',
|
gradient: 'from-info-muted to-transparent',
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
iconColor: 'text-green-500 dark:text-green-400',
|
iconColor: 'text-success',
|
||||||
icon: <SuccessIcon className="h-6 w-6" />,
|
icon: <SuccessIcon className="h-6 w-6" />,
|
||||||
gradient: 'from-green-100/60 to-transparent dark:from-green-900/20 dark:to-transparent',
|
gradient: 'from-success-muted to-transparent',
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
iconColor: 'text-yellow-500 dark:text-yellow-400',
|
iconColor: 'text-warning',
|
||||||
icon: <WarningIcon className="h-6 w-6" />,
|
icon: <WarningIcon className="h-6 w-6" />,
|
||||||
gradient: 'from-yellow-100/60 to-transparent dark:from-yellow-900/20 dark:to-transparent',
|
gradient: 'from-warning-muted to-transparent',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
iconColor: 'text-red-500 dark:text-red-400',
|
iconColor: 'text-destructive',
|
||||||
icon: <ErrorIcon className="h-6 w-6" />,
|
icon: <ErrorIcon className="h-6 w-6" />,
|
||||||
gradient: 'from-red-100/60 to-transparent dark:from-red-900/20 dark:to-transparent',
|
gradient: 'from-destructive-muted to-transparent',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
|||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: 100 }}
|
exit={{ opacity: 0, x: 100 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-white/15 dark:bg-black/15 border border-gray-300/60 dark:border-gray-700/60 overflow-hidden ring-1 ring-gray-200/40 dark:ring-gray-700/40 drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105 font-[family-name:var(--font-sans)]"
|
className="relative w-full max-w-sm rounded-xl p-4 backdrop-blur-xl bg-card/80 border border-glass-border overflow-hidden ring-1 ring-glass-border drop-shadow-xl transition-all duration-300 ease-in-out transform hover:scale-105 font-[family-name:var(--font-sans)]"
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
@@ -126,11 +126,11 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
|||||||
{config.icon}
|
{config.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-normal text-gray-900 dark:text-gray-100 text-lg">{message}</p>
|
<p className="font-normal text-foreground text-lg">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
className="flex-shrink-0 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
|
className="flex-shrink-0 text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-full hover:bg-accent"
|
||||||
aria-label="Close notification"
|
aria-label="Close notification"
|
||||||
>
|
>
|
||||||
<CloseIcon className="h-5 w-5" />
|
<CloseIcon className="h-5 w-5" />
|
||||||
@@ -138,12 +138,12 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar — Sera UI style with Framer Motion */}
|
{/* Progress bar — Sera UI style with Framer Motion */}
|
||||||
<div className="absolute bottom-0 left-0 h-1 w-full bg-gray-300/50 dark:bg-gray-600/50 rounded-b-xl overflow-hidden">
|
<div className="absolute bottom-0 left-0 h-1 w-full bg-glass-border rounded-b-xl overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: hovered ? undefined : '100%' }}
|
animate={{ width: hovered ? undefined : '100%' }}
|
||||||
transition={{ duration: duration / 1000, ease: 'linear' }}
|
transition={{ duration: duration / 1000, ease: 'linear' }}
|
||||||
className="h-full bg-gradient-to-r from-green-400 via-blue-400 to-sky-400 dark:from-green-500 dark:via-blue-500 dark:to-sky-500"
|
className="h-full bg-gradient-to-r from-success via-info to-brand"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
--info: oklch(0.62 0.14 250);
|
--info: oklch(0.62 0.14 250);
|
||||||
--info-foreground: oklch(1 0 0);
|
--info-foreground: oklch(1 0 0);
|
||||||
--info-muted: oklch(0.62 0.14 250 / 0.12);
|
--info-muted: oklch(0.62 0.14 250 / 0.12);
|
||||||
|
--destructive-muted: oklch(0.63 0.19 23 / 0.12);
|
||||||
|
|
||||||
/* Charts */
|
/* Charts */
|
||||||
--chart-1: oklch(0.8100 0.1700 75.3500);
|
--chart-1: oklch(0.8100 0.1700 75.3500);
|
||||||
@@ -187,6 +188,7 @@
|
|||||||
--info: oklch(0.70 0.14 250);
|
--info: oklch(0.70 0.14 250);
|
||||||
--info-foreground: oklch(0.10 0 0);
|
--info-foreground: oklch(0.10 0 0);
|
||||||
--info-muted: oklch(0.70 0.14 250 / 0.15);
|
--info-muted: oklch(0.70 0.14 250 / 0.15);
|
||||||
|
--destructive-muted: oklch(0.69 0.20 24 / 0.15);
|
||||||
|
|
||||||
/* Charts — monochrome blue/teal */
|
/* Charts — monochrome blue/teal */
|
||||||
--chart-1: oklch(0.72 0.08 220);
|
--chart-1: oklch(0.72 0.08 220);
|
||||||
@@ -336,6 +338,7 @@
|
|||||||
--color-info: var(--info);
|
--color-info: var(--info);
|
||||||
--color-info-foreground: var(--info-foreground);
|
--color-info-foreground: var(--info-foreground);
|
||||||
--color-info-muted: var(--info-muted);
|
--color-info-muted: var(--info-muted);
|
||||||
|
--color-destructive-muted: var(--destructive-muted);
|
||||||
|
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-mono);
|
--font-mono: var(--font-mono);
|
||||||
|
|||||||
Reference in New Issue
Block a user