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:
Anso
2026-04-03 20:33:44 -04:00
committed by GitHub
parent 2a277eb09d
commit f9ebd1d77c
13 changed files with 417 additions and 105 deletions
+10
View File
@@ -16,14 +16,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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.
* **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
* **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
* **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)
+14
View File
@@ -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 ---
/** Respond 202 and trigger the "last breath" self-update after the response flushes. */
+60 -1
View File
@@ -243,7 +243,7 @@ export class LicenseService {
validUntil,
trialDaysRemaining,
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) {
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.');
return { success: true };
@@ -345,6 +352,8 @@ export class LicenseService {
'update_payment_url',
'order_id',
'receipt_url',
'billing_portal_url',
'billing_portal_expires',
];
for (const key of keysToRemove) {
db.setSystemState(key, '');
@@ -415,6 +424,9 @@ export class LicenseService {
if (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.');
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.
*/
+3 -3
View File
@@ -123,9 +123,9 @@ export function AuditLogView() {
<CardTitle>Audit Log</CardTitle>
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="border-border">
<Download className="w-4 h-4 mr-2" />
Export
<ChevronDown className="w-3 h-3 ml-1" />
@@ -136,7 +136,7 @@ export function AuditLogView() {
<DropdownMenuItem onClick={() => handleExport('json')}>Export as JSON</DropdownMenuItem>
</DropdownMenuContent>
</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' : ''}`} />
Refresh
</Button>
+42 -17
View File
@@ -19,7 +19,7 @@ import { springs } from '@/lib/motion';
import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
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 { LabelPill, LabelDot, type Label as StackLabel } from './LabelPill';
import { LabelAssignPopover } from './LabelAssignPopover';
@@ -259,6 +259,19 @@ export default function EditorLayout() {
localStorage.setItem('sencho-theme', 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)
useEffect(() => {
const handler = (e: Event) => {
@@ -1367,13 +1380,16 @@ export default function EditorLayout() {
{/* Search Input & Stack List */}
<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
placeholder="Search stacks..."
value={searchQuery}
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>
{isPro && labels.length > 0 && (
<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) */}
<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">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
<TabsList>
@@ -2032,27 +2048,36 @@ export default function EditorLayout() {
)}
</div>
{can('stack:edit', 'stack', stackName) && (
<div className="flex gap-2">
<div className="flex items-center">
{!isEditing ? (
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</Button>
) : (
<>
<Button size="sm" variant="outline" className="rounded-lg" onClick={discardChanges}>
<X className="w-4 h-4 mr-2" />
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" />
<div className="flex items-center">
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={handleSaveAndDeploy} disabled={loadingAction === 'deploy'}>
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save & Deploy
</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>
)}
+14 -19
View File
@@ -26,7 +26,8 @@ import { useLicense } from '@/context/LicenseContext';
import { ProGate } from './ProGate';
import FleetSnapshots from './FleetSnapshots';
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 ---
@@ -1022,24 +1023,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{fleetLabels.length > 0 && (
<>
<div className="w-px h-5 bg-border mx-1" />
<div className="flex items-center gap-1.5 flex-wrap">
{fleetLabels.map(label => (
<LabelPill
key={label.id}
label={label}
size="sm"
active={labelFilters.has(label.id)}
onClick={() => {
setLabelFilters(prev => {
const next = new Set(prev);
if (next.has(label.id)) next.delete(label.id);
else next.add(label.id);
return next;
});
}}
/>
))}
</div>
<MultiSelectCombobox
options={fleetLabels.map(l => ({ value: String(l.id), label: l.name, color: l.color }))}
selected={new Set(Array.from(labelFilters).map(String))}
onSelectionChange={(sel) => setLabelFilters(new Set(Array.from(sel).map(Number)))}
placeholder="Tags"
renderOption={(option) => (
<span className="flex items-center gap-1.5">
<LabelDot color={option.color as StackLabel['color'] ?? 'slate'} />
{option.label}
</span>
)}
/>
</>
)}
</div>
@@ -200,16 +200,16 @@ export function GlobalObservabilityView() {
};
return (
<div className="flex flex-col h-full w-full relative group bg-[#0A0A0A] text-gray-300">
{/* Node Context Indicator */}
{activeNode?.type === 'remote' && (
<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">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" />
{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="flex flex-col h-full w-full bg-background text-foreground">
{/* Permanent Toolbar */}
<div className="shrink-0 flex items-center gap-2 px-4 py-2 border-b border-border bg-card">
{activeNode?.type === 'remote' && (
<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">
<span className="w-1.5 h-1.5 rounded-full bg-info shrink-0" />
{activeNode.name}
</div>
)}
<div className="relative flex items-center">
<Search className="absolute left-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
@@ -220,14 +220,14 @@ export function GlobalObservabilityView() {
/>
</div>
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-sm">
<Filter className="w-3.5 h-3.5 mr-2" />
Stacks ({selectedStacks.length === 0 ? 'All' : selectedStacks.length})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="start" className="w-48">
{allStacks.map(stack => (
<DropdownMenuCheckboxItem
key={stack}
@@ -254,46 +254,47 @@ export function GlobalObservabilityView() {
</SelectContent>
</Select>
<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 className="flex-1" />
{devMode && (
<div className="flex items-center px-2 text-xs text-success font-mono animate-pulse">
LIVE
</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>
{loading && logs.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-20">
<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}>
<div className="flex-1 min-h-0 overflow-auto p-4 relative bg-background" onScroll={handleScroll}>
{loading && logs.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
</div>
)}
{filteredLogs.length > 0 ? (
<>
{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.
</div>
)}
{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">
<span className="text-gray-500 mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
<span className="text-blue-400 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={log.source === 'STDERR' ? 'text-red-300' : 'text-gray-300'}>{log.message}</span>
<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-muted-foreground mr-2">[{new Date(log.timestampMs).toLocaleTimeString([], { hour12: true })}]</span>
<span className="text-info font-semibold mr-2">[{log.containerName}]</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-destructive/80' : 'text-foreground/80'}>{log.message}</span>
</div>
))}
<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."}
</div>
)}
+1 -1
View File
@@ -634,7 +634,7 @@ export default function ResourcesView() {
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"
>
<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">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
{(['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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { TierBadge } from './TierBadge';
type Theme = 'light' | 'dark' | 'auto';
@@ -17,6 +20,24 @@ interface UserProfileDropdownProps {
export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserProfileDropdownProps) {
const { logout, user, isAdmin } = useAuth();
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 (
<Popover>
@@ -57,15 +78,18 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
Settings
</button>
{license?.status === 'active' && (
<a
href="https://sencho.io/billing"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 w-full px-3 py-2 text-sm rounded-lg hover:bg-muted transition-colors"
<button
onClick={openBillingPortal}
disabled={billingLoading}
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"
>
<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
</a>
</button>
)}
</div>
@@ -8,14 +8,33 @@ import { useLicense } from '@/context/LicenseContext';
import { TierBadge } from '@/components/TierBadge';
import {
Crown, CheckCircle, Check, XCircle, Clock, ExternalLink,
CreditCard, RefreshCw, Zap, Compass, ShipWheel,
CreditCard, RefreshCw, Zap, Compass, ShipWheel, Loader2,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
export function LicenseSection() {
const { license, activate, deactivate } = useLicense();
const [licenseKeyInput, setLicenseKeyInput] = useState('');
const [isActivating, setIsActivating] = 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 (
<div className="space-y-6">
@@ -94,17 +113,20 @@ export function LicenseSection() {
{/* Manage Subscription (active Pro) */}
{license?.status === 'active' && (
<div className="space-y-3">
{license.portalUrl && (
<Button
variant="outline"
size="sm"
onClick={() => window.open(license.portalUrl!, '_blank')}
>
<Button
variant="outline"
size="sm"
onClick={openBillingPortal}
disabled={billingLoading}
>
{billingLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CreditCard className="w-4 h-4 mr-2" />
Manage Subscription
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
)}
)}
Manage Subscription
<ExternalLink className="w-3 h-3 ml-1.5 opacity-50" />
</Button>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
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>
)
}
+13 -13
View File
@@ -54,24 +54,24 @@ const notificationConfig: Record<ToastType, {
gradient: string;
}> = {
info: {
iconColor: 'text-blue-500 dark:text-blue-400',
iconColor: 'text-info',
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: {
iconColor: 'text-green-500 dark:text-green-400',
iconColor: 'text-success',
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: {
iconColor: 'text-yellow-500 dark:text-yellow-400',
iconColor: 'text-warning',
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: {
iconColor: 'text-red-500 dark:text-red-400',
iconColor: 'text-destructive',
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 }}
exit={{ opacity: 0, x: 100 }}
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)}
onMouseLeave={() => setHovered(false)}
>
@@ -126,11 +126,11 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
{config.icon}
</div>
<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>
<button
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"
>
<CloseIcon className="h-5 w-5" />
@@ -138,12 +138,12 @@ function ToastItem({ id, type, message }: { id: string; type: ToastType; message
</div>
{/* 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
initial={{ width: 0 }}
animate={{ width: hovered ? undefined : '100%' }}
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>
</motion.div>
+3
View File
@@ -48,6 +48,7 @@
--info: oklch(0.62 0.14 250);
--info-foreground: oklch(1 0 0);
--info-muted: oklch(0.62 0.14 250 / 0.12);
--destructive-muted: oklch(0.63 0.19 23 / 0.12);
/* Charts */
--chart-1: oklch(0.8100 0.1700 75.3500);
@@ -187,6 +188,7 @@
--info: oklch(0.70 0.14 250);
--info-foreground: oklch(0.10 0 0);
--info-muted: oklch(0.70 0.14 250 / 0.15);
--destructive-muted: oklch(0.69 0.20 24 / 0.15);
/* Charts — monochrome blue/teal */
--chart-1: oklch(0.72 0.08 220);
@@ -336,6 +338,7 @@
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-info-muted: var(--info-muted);
--color-destructive-muted: var(--destructive-muted);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);