mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: stack context menu, tier icons, centered logo & support (#194)
* chore: remove CLAUDE.md and MANUAL_STEPS.md from git tracking These files contain internal project instructions that should not be public. Both are now in .gitignore to prevent re-tracking. * chore: remove build output and generated files from git tracking Remove backend/public/ (frontend build artifacts) and backend/cookies.txt from tracking. Add demo-video/ and website/ to .gitignore. * docs: align mintlify theme with website and app branding Update colors to brand cyan, add Geist font, dark mode default, gradient background, navbar with GitHub link and CTA, and footer matching the website structure. * chore: add shadcn MCP server configuration * feat: add stack context menu, tier icons, centered logo, and support section - Add right-click context menu on sidebar stacks with full actions (alerts, check updates, deploy, stop, restart, update, delete) - Mirror same actions in the three-dot dropdown menu - Create TierBadge component with Globe/Crown/Users icons for Community/Pro/Team tiers, replacing ProBadge across the app - Center logo and app name in sidebar header - Add tiered Support section in Settings with docs, GitHub issues, and tier-appropriate email support channels - Remove duplicate links from About section * docs: update changelog and stack management docs for context menu
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* **sidebar:** right-click context menu on stacks with full lifecycle actions (deploy, stop, restart, update, delete, alerts, check for updates)
|
||||||
|
* **sidebar:** expanded three-dot dropdown menu to match context menu actions
|
||||||
|
* **settings:** new "Support" tab with tier-appropriate help channels (docs, GitHub Issues, email support for Pro)
|
||||||
|
* **ui:** TierBadge component with distinctive icons per tier — Globe (Community), Crown (Pro), Users (Team)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
* **sidebar:** centered logo and app name in sidebar header
|
||||||
|
* **settings:** removed duplicate Documentation and GitHub Issues links from About section (now in Support tab)
|
||||||
|
* replaced ProBadge with TierBadge across UserProfileDropdown and Settings
|
||||||
|
|
||||||
## [0.9.0](https://github.com/AnsoCode/Sencho/compare/v0.8.0...v0.9.0) (2026-03-27)
|
## [0.9.0](https://github.com/AnsoCode/Sencho/compare/v0.8.0...v0.9.0) (2026-03-27)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,11 +60,16 @@ The stack header exposes four actions:
|
|||||||
Right-click or use the **⋮** button on any stack in the sidebar to access:
|
Right-click or use the **⋮** button on any stack in the sidebar to access:
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/stack-management/stack-context-menu.png" alt="Stack context menu showing Alerts option" />
|
<img src="/images/stack-management/stack-context-menu.png" alt="Stack context menu" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
- **Alerts** - configure metric-based alerting rules for this stack
|
- **Alerts** — configure metric-based alerting rules for this stack
|
||||||
- **Check for updates** - manually trigger an image update check
|
- **Check for updates** — manually trigger an image update check
|
||||||
|
- **Deploy** — run `docker compose up -d`
|
||||||
|
- **Stop** — stop all containers in the stack
|
||||||
|
- **Restart** — restart all containers in the stack
|
||||||
|
- **Update** — pull latest images and recreate containers
|
||||||
|
- **Delete** — stop and remove the stack (admin only)
|
||||||
|
|
||||||
## Converting a `docker run` command
|
## Converting a `docker run` command
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -12,6 +12,7 @@
|
|||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|||||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
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 } 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 } from 'lucide-react';
|
||||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -27,7 +27,8 @@ import { Skeleton } from './ui/skeleton';
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
|
||||||
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { SettingsModal } from './SettingsModal';
|
import { SettingsModal } from './SettingsModal';
|
||||||
import { StackAlertSheet } from './StackAlertSheet';
|
import { StackAlertSheet } from './StackAlertSheet';
|
||||||
@@ -891,6 +892,54 @@ export default function EditorLayout() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Context-menu-friendly stack actions (accept file name directly)
|
||||||
|
const executeStackActionByFile = async (stackFile: string, action: string, endpoint: string) => {
|
||||||
|
if (loadingAction !== null) return;
|
||||||
|
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||||
|
setLoadingAction(action);
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text();
|
||||||
|
throw new Error(errText || `${action} failed`);
|
||||||
|
}
|
||||||
|
toast.success(`Stack ${action}ed successfully!`);
|
||||||
|
if (selectedFile === stackFile) {
|
||||||
|
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||||
|
const conts = await containersRes.json();
|
||||||
|
setContainers(Array.isArray(conts) ? conts : []);
|
||||||
|
}
|
||||||
|
await refreshStacks(true);
|
||||||
|
if (action === 'deploy' && isPro) {
|
||||||
|
try {
|
||||||
|
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||||
|
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to ${action}:`, error);
|
||||||
|
const msg = (error as Error).message || `Failed to ${action} stack`;
|
||||||
|
toast.error(action === 'deploy' && isPro ? `${msg} - automatically rolled back to previous version.` : msg);
|
||||||
|
} finally {
|
||||||
|
setLoadingAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkUpdatesForStack = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/image-updates/refresh', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Checking for image updates...');
|
||||||
|
setTimeout(() => fetchImageUpdates(), 3000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
toast.error(data.error || 'Failed to check for updates');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to check for updates');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateStack = async () => {
|
const handleCreateStack = async () => {
|
||||||
if (!newStackName.trim()) return;
|
if (!newStackName.trim()) return;
|
||||||
// Send stackName directly (no .yml extension - backend creates directory)
|
// Send stackName directly (no .yml extension - backend creates directory)
|
||||||
@@ -979,7 +1028,7 @@ export default function EditorLayout() {
|
|||||||
{/* Left Sidebar (Stacks) */}
|
{/* Left Sidebar (Stacks) */}
|
||||||
<div className="w-64 border-r border-border bg-card flex flex-col">
|
<div className="w-64 border-r border-border bg-card flex flex-col">
|
||||||
{/* Branding Header */}
|
{/* Branding Header */}
|
||||||
<div className="h-16 flex items-center px-4 border-b border-border">
|
<div className="h-16 flex items-center justify-center px-4 border-b border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'} alt="Sencho Logo" className="w-12 h-12" />
|
<img src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'} alt="Sencho Logo" className="w-12 h-12" />
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Sencho</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Sencho</h1>
|
||||||
@@ -1069,8 +1118,10 @@ export default function EditorLayout() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(filteredFiles || []).map(file => (
|
(filteredFiles || []).map(file => (
|
||||||
|
<ContextMenu key={file}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={file}
|
|
||||||
value={file}
|
value={file}
|
||||||
onSelect={() => loadFile(file)}
|
onSelect={() => loadFile(file)}
|
||||||
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
|
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
|
||||||
@@ -1102,11 +1153,92 @@ export default function EditorLayout() {
|
|||||||
<BellRing className="h-4 w-4 mr-2" />
|
<BellRing className="h-4 w-4 mr-2" />
|
||||||
Alerts
|
Alerts
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => checkUpdatesForStack()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Check for updates
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Deploy
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||||||
|
<Square className="h-4 w-4 mr-2" />
|
||||||
|
Stop
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
||||||
|
<RotateCw className="h-4 w-4 mr-2" />
|
||||||
|
Restart
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Update
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setStackToDelete(file.replace(/\.(yml|yaml)$/, ''));
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={() => openAlertSheet(file)}>
|
||||||
|
<BellRing className="h-4 w-4 mr-2" />
|
||||||
|
Alerts
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => checkUpdatesForStack()}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Check for updates
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||||||
|
<Play className="h-4 w-4 mr-2" />
|
||||||
|
Deploy
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||||||
|
<Square className="h-4 w-4 mr-2" />
|
||||||
|
Stop
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
||||||
|
<RotateCw className="h-4 w-4 mr-2" />
|
||||||
|
Restart
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Update
|
||||||
|
</ContextMenuItem>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setStackToDelete(file.replace(/\.(yml|yaml)$/, ''));
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Crown } from 'lucide-react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
interface ProBadgeProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProBadge({ className }: ProBadgeProps) {
|
|
||||||
return (
|
|
||||||
<Badge variant="secondary" className={`gap-1 text-[10px] font-semibold uppercase px-1.5 py-0 ${className || ''}`}>
|
|
||||||
<Crown className="w-2.5 h-2.5" />
|
|
||||||
Pro
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,14 +18,14 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard } from 'lucide-react';
|
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History, Users, Pencil, ExternalLink, CreditCard, LifeBuoy, Book, Mail, Bug } from 'lucide-react';
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { NodeManager } from './NodeManager';
|
import { NodeManager } from './NodeManager';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
import { ProBadge } from './ProBadge';
|
import { TierBadge } from './TierBadge';
|
||||||
import { ProGate } from './ProGate';
|
import { ProGate } from './ProGate';
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
@@ -48,7 +48,7 @@ interface PatchableSettings {
|
|||||||
log_retention_days?: string;
|
log_retention_days?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about';
|
type SectionId = 'account' | 'license' | 'users' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'support' | 'about';
|
||||||
|
|
||||||
interface WebhookItem {
|
interface WebhookItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -190,7 +190,7 @@ function WebhooksSection({ isPro }: { isPro: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">Webhooks <ProBadge /></h3>
|
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
|
||||||
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
|
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
|
||||||
</div>
|
</div>
|
||||||
<ProGate featureName="Webhooks">
|
<ProGate featureName="Webhooks">
|
||||||
@@ -207,7 +207,7 @@ function WebhooksSection({ isPro }: { isPro: boolean }) {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start justify-between pr-8">
|
<div className="flex items-start justify-between pr-8">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">Webhooks <ProBadge /></h3>
|
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
|
||||||
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
|
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setShowForm(!showForm)}>
|
<Button size="sm" onClick={() => setShowForm(!showForm)}>
|
||||||
@@ -1019,6 +1019,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
{!isRemote && (
|
{!isRemote && (
|
||||||
<NavButton section="appstore" icon={<Package className="w-4 h-4 mr-2" />} label="App Store" />
|
<NavButton section="appstore" icon={<Package className="w-4 h-4 mr-2" />} label="App Store" />
|
||||||
)}
|
)}
|
||||||
|
<NavButton section="support" icon={<LifeBuoy className="w-4 h-4 mr-2" />} label="Support" />
|
||||||
<NavButton section="about" icon={<Info className="w-4 h-4 mr-2" />} label="About" />
|
<NavButton section="about" icon={<Info className="w-4 h-4 mr-2" />} label="About" />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -1087,7 +1088,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
{license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
|
{license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{license?.tier === 'pro' && <ProBadge />}
|
<TierBadge />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{license?.status === 'trial' && license.trialDaysRemaining !== null && (
|
{license?.status === 'trial' && license.trialDaysRemaining !== null && (
|
||||||
@@ -1525,6 +1526,90 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
<NodeManager />
|
<NodeManager />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeSection === 'support' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight">Help & Support</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Get help with Sencho based on your plan.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Self-serve channels (all tiers) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground">Resources</h4>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<a href="https://docs.sencho.io" target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<Book className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">Documentation</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Guides, reference, and tutorials</p>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/AnsoCode/Sencho/issues" target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<Bug className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">GitHub Issues</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Report bugs and request features</p>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pro support channels */}
|
||||||
|
{isPro && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
Pro Support <TierBadge />
|
||||||
|
</h4>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<a href={license?.variant === 'team' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{license?.variant === 'team' ? 'Priority Email Support' : 'Email Support'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{license?.variant === 'team'
|
||||||
|
? 'Direct support with responses within 24 hours'
|
||||||
|
: 'Reach our support team directly'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upsell for Community */}
|
||||||
|
{!isPro && (
|
||||||
|
<div className="rounded-lg border border-border p-4 bg-muted/30">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Crown className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Need faster support?</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Upgrade to Pro for direct email support and priority issue handling.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" className="mt-3" onClick={() => window.open('https://sencho.io/#pricing', '_blank')}>
|
||||||
|
Upgrade to Pro
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSection === 'about' && (
|
{activeSection === 'about' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -1539,7 +1624,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Tier</span>
|
<span className="text-sm text-muted-foreground">Tier</span>
|
||||||
<div>{license?.tier === 'pro' ? <ProBadge /> : <Badge variant="outline">Community</Badge>}</div>
|
<div><TierBadge /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">License Status</span>
|
<span className="text-sm text-muted-foreground">License Status</span>
|
||||||
@@ -1556,14 +1641,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-medium">Links</h4>
|
<h4 className="text-sm font-medium">Links</h4>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<a
|
|
||||||
href="https://docs.sencho.io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Documentation →
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/AnsoCode/Sencho/blob/main/CHANGELOG.md"
|
href="https://github.com/AnsoCode/Sencho/blob/main/CHANGELOG.md"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1572,14 +1649,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
>
|
>
|
||||||
Changelog →
|
Changelog →
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
href="https://github.com/AnsoCode/Sencho/issues"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Report an Issue →
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Crown, Globe, Users } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useLicense, type LicenseTier, type LicenseVariant, type LicenseStatus } from '@/context/LicenseContext';
|
||||||
|
|
||||||
|
interface TierBadgeProps {
|
||||||
|
tier?: LicenseTier;
|
||||||
|
variant?: LicenseVariant;
|
||||||
|
status?: LicenseStatus;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierConfig = {
|
||||||
|
community: { icon: Globe, label: 'Community' },
|
||||||
|
pro: { icon: Crown, label: 'Pro' },
|
||||||
|
team: { icon: Users, label: 'Team' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) {
|
||||||
|
// Only show Team badge for active team licenses, not trials
|
||||||
|
// (trials default to team variant to unlock all features)
|
||||||
|
if (tier === 'pro' && variant === 'team' && status === 'active') return tierConfig.team;
|
||||||
|
if (tier === 'pro') return tierConfig.pro;
|
||||||
|
return tierConfig.community;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TierBadge({ tier, variant, status, className }: TierBadgeProps) {
|
||||||
|
const { license } = useLicense();
|
||||||
|
const resolvedTier = tier ?? license?.tier ?? 'community';
|
||||||
|
const resolvedVariant = variant !== undefined ? variant : license?.variant ?? null;
|
||||||
|
const resolvedStatus = status ?? license?.status ?? 'community';
|
||||||
|
const { icon: Icon, label } = resolveTier(resolvedTier, resolvedVariant, resolvedStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className={`gap-1 text-[10px] font-semibold uppercase px-1.5 py-0 ${className || ''}`}>
|
||||||
|
<Icon className="w-2.5 h-2.5" />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ 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 { ProBadge } from './ProBadge';
|
import { TierBadge } from './TierBadge';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'auto';
|
type Theme = 'light' | 'dark' | 'auto';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ 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, isPro } = useLicense();
|
const { license } = useLicense();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
@@ -39,7 +39,7 @@ export function UserProfileDropdown({ theme, setTheme, onOpenSettings }: UserPro
|
|||||||
{user?.role ?? 'admin'}
|
{user?.role ?? 'admin'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground/40">·</span>
|
<span className="text-muted-foreground/40">·</span>
|
||||||
{isPro ? <ProBadge /> : <span>Community</span>}
|
<TierBadge />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ContextMenu = ContextMenuPrimitive.Root
|
||||||
|
|
||||||
|
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||||
|
|
||||||
|
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const ContextMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const ContextMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const ContextMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
ContextMenuCheckboxItem.displayName =
|
||||||
|
ContextMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-4 w-4 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const ContextMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const ContextMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const ContextMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user