improvement(templates): add share button, serve public templates routes for unauthenticated users and workspace one for authenticated users, improve settings style and organization (#1962)

* improvement(templates): add share button, serve public templates routes for unauthenticated users and workspace one for authenticated users, improve settings style and organization

* fix lint

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Waleed
2025-11-12 21:46:26 -08:00
committed by GitHub
parent 07e803cfdd
commit 304fb28baf
28 changed files with 1141 additions and 1225 deletions

View File

@@ -0,0 +1,44 @@
import { db } from '@sim/db'
import { permissions, workspace } from '@sim/db/schema'
import { and, desc, eq } from 'drizzle-orm'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
export const dynamic = 'force-dynamic'
export const revalidate = 0
interface TemplateLayoutProps {
children: React.ReactNode
params: Promise<{
id: string
}>
}
/**
* Template detail layout (public scope).
* - If user is authenticated, redirect to workspace-scoped template detail.
* - Otherwise render the public template detail children.
*/
export default async function TemplateDetailLayout({ children, params }: TemplateLayoutProps) {
const { id } = await params
const session = await getSession()
if (session?.user?.id) {
const userWorkspaces = await db
.select({
workspace: workspace,
})
.from(permissions)
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
.orderBy(desc(workspace.createdAt))
.limit(1)
if (userWorkspaces.length > 0) {
const firstWorkspace = userWorkspaces[0].workspace
redirect(`/workspace/${firstWorkspace.id}/templates/${id}`)
}
}
return children
}

View File

@@ -1,5 +1,9 @@
import TemplateDetails from './template'
/**
* Public template detail page for unauthenticated users.
* Authenticated-user redirect is handled in templates/[id]/layout.tsx.
*/
export default function TemplatePage() {
return <TemplateDetails />
}

View File

@@ -9,12 +9,20 @@ import {
Globe,
Linkedin,
Mail,
Share2,
Star,
User,
} from 'lucide-react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import { Button } from '@/components/emcn'
import {
Button,
Copy,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
} from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,6 +31,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
import type { Template } from '@/app/templates/templates'
@@ -63,7 +72,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
const [showWorkspaceSelectorForEdit, setShowWorkspaceSelectorForEdit] = useState(false)
const [showWorkspaceSelectorForUse, setShowWorkspaceSelectorForUse] = useState(false)
const [sharePopoverOpen, setSharePopoverOpen] = useState(false)
const currentUserId = session?.user?.id || null
@@ -351,8 +360,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
// In workspace context, use current workspace directly
if (isWorkspaceContext && workspaceId) {
handleWorkspaceSelectForUse(workspaceId)
} else {
setShowWorkspaceSelectorForUse(true)
}
}
@@ -415,7 +422,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
if (isUsing || !template) return
setIsUsing(true)
setShowWorkspaceSelectorForUse(false)
try {
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
@@ -518,6 +524,57 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
}
}
/**
* Shares the template to X (Twitter)
*/
const handleShareToTwitter = () => {
if (!template) return
setSharePopoverOpen(false)
const templateUrl = `${getBaseUrl()}/templates/${template.id}`
let tweetText = `🚀 Check out this workflow template: ${template.name}`
if (template.details?.tagline) {
const taglinePreview =
template.details.tagline.length > 100
? `${template.details.tagline.substring(0, 100)}...`
: template.details.tagline
tweetText += `\n\n${taglinePreview}`
}
const maxTextLength = 280 - 23 - 1
if (tweetText.length > maxTextLength) {
tweetText = `${tweetText.substring(0, maxTextLength - 3)}...`
}
const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(templateUrl)}`
window.open(twitterUrl, '_blank', 'noopener,noreferrer')
}
/**
* Shares the template to LinkedIn.
*/
const handleShareToLinkedIn = () => {
if (!template) return
setSharePopoverOpen(false)
const templateUrl = `${getBaseUrl()}/templates/${template.id}`
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(templateUrl)}`
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
}
const handleCopyLink = async () => {
setSharePopoverOpen(false)
const templateUrl = `${getBaseUrl()}/templates/${template?.id}`
try {
await navigator.clipboard.writeText(templateUrl)
logger.info('Template link copied to clipboard')
} catch (error) {
logger.error('Failed to copy link:', error)
}
}
return (
<div className={cn('flex min-h-screen flex-col', isWorkspaceContext && 'pl-64')}>
<div className='flex flex-1 overflow-hidden'>
@@ -530,7 +587,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
className='flex items-center gap-[6px] font-medium text-[#ADADAD] text-[14px] transition-colors hover:text-white'
>
<ArrowLeft className='h-[14px] w-[14px]' />
<span>Back</span>
<span>More Templates</span>
</button>
</div>
@@ -622,7 +679,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
<>
{!currentUserId ? (
<Button
variant='active'
variant='primary'
onClick={() => {
const callbackUrl =
isWorkspaceContext && workspaceId
@@ -645,48 +702,39 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
>
{isUsing ? 'Creating...' : 'Use template'}
</Button>
) : (
<DropdownMenu
open={showWorkspaceSelectorForUse}
onOpenChange={setShowWorkspaceSelectorForUse}
>
<DropdownMenuTrigger asChild>
<Button
variant='primary'
onClick={() => setShowWorkspaceSelectorForUse(true)}
disabled={isUsing || isLoadingWorkspaces}
className='h-[32px] rounded-[6px] px-[16px] text-[#FFFFFF] text-[14px]'
>
{isUsing ? 'Creating...' : isLoadingWorkspaces ? 'Loading...' : 'Use'}
<ChevronDown className='ml-2 h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{workspaces.length === 0 ? (
<DropdownMenuItem disabled className='text-muted-foreground text-sm'>
No workspaces with write access
</DropdownMenuItem>
) : (
workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceSelectForUse(workspace.id)}
className='flex cursor-pointer items-center justify-between'
>
<div className='flex flex-col'>
<span className='font-medium text-sm'>{workspace.name}</span>
<span className='text-muted-foreground text-xs capitalize'>
{workspace.permissions} access
</span>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
)}
) : null}
</>
)}
{/* Share button */}
<Popover open={sharePopoverOpen} onOpenChange={setSharePopoverOpen}>
<PopoverTrigger asChild>
<Button variant='active' className='h-[32px] rounded-[6px] px-[12px]'>
<Share2 className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={8}>
<PopoverItem onClick={handleCopyLink}>
<Copy className='h-3 w-3' />
<span>Copy link</span>
</PopoverItem>
<PopoverItem onClick={handleShareToTwitter}>
<svg
className='h-3 w-3'
viewBox='0 0 24 24'
fill='currentColor'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z' />
</svg>
<span>Share on X</span>
</PopoverItem>
<PopoverItem onClick={handleShareToLinkedIn}>
<Linkedin className='h-3 w-3' />
<span>Share on LinkedIn</span>
</PopoverItem>
</PopoverContent>
</Popover>
</div>
</div>

View File

@@ -1,33 +0,0 @@
import { cn } from '@/lib/utils'
interface NavigationTab {
id: string
label: string
count?: number
}
interface NavigationTabsProps {
tabs: NavigationTab[]
activeTab?: string
onTabClick?: (tabId: string) => void
className?: string
}
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
{tabs.map((tab, index) => (
<button
key={tab.id}
onClick={() => onTabClick?.(tab.id)}
className={cn(
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
)}
>
<span>{tab.label}</span>
</button>
))}
</div>
)
}

View File

@@ -1,116 +1,14 @@
import { useState } from 'react'
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
ChartNoAxesColumn,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Star, User } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateCard')
// Icon mapping for template icons
const iconMap = {
// Content & Documentation
FileText,
NotebookPen,
BookOpen,
Edit,
// Analytics & Charts
BarChart3,
LineChart,
TrendingUp,
Target,
// Database & Storage
Database,
Server,
Cloud,
Folder,
// Marketing & Communication
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
// Sales & Finance
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
// Support & Service
HeadphonesIcon,
User,
Users,
Settings,
Wrench,
// AI & Technology
Bot,
Brain,
Cpu,
Code,
Zap,
// Workflow & Process
Workflow,
Search,
Play,
Layers,
// General
Lightbulb,
Star,
Globe,
Award,
}
interface TemplateCardProps {
id: string
title: string
@@ -119,78 +17,56 @@ interface TemplateCardProps {
authorImageUrl?: string | null
usageCount: string
stars?: number
icon?: React.ReactNode | string
iconColor?: string
blocks?: string[]
tags?: string[]
onClick?: () => void
className?: string
state?: {
blocks?: Record<string, { type: string; name?: string }>
}
// Workflow state for rendering preview
state?: WorkflowState
isStarred?: boolean
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
isAuthenticated?: boolean
// Optional callback when template is successfully used (for closing modals, etc.)
onTemplateUsed?: () => void
status?: 'pending' | 'approved' | 'rejected'
isSuperUser?: boolean
onApprove?: (templateId: string) => void
onReject?: (templateId: string) => void
// Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// User authentication status
isAuthenticated?: boolean
}
// Skeleton component for loading states
/**
* Skeleton component for loading states
*/
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
{/* Left side - Info skeleton */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section skeleton */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon skeleton */}
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
{/* Title skeleton */}
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
</div>
<div className={cn('h-[268px] w-full rounded-[8px] bg-[#202020] p-[8px]', className)}>
{/* Workflow preview skeleton */}
<div className='h-[180px] w-full animate-pulse rounded-[6px] bg-gray-700' />
{/* Star and Use button skeleton */}
<div className='flex flex-shrink-0 items-center gap-3'>
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
</div>
</div>
{/* Description skeleton */}
<div className='space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
</div>
</div>
{/* Bottom section skeleton */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
{/* Stars section - hidden on smaller screens */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
</div>
{/* Title and blocks row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='h-4 w-32 animate-pulse rounded bg-gray-700' />
<div className='flex items-center gap-[-4px]'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='h-[18px] w-[18px] animate-pulse rounded-[4px] bg-gray-700'
/>
))}
</div>
</div>
{/* Right side - Block Icons skeleton */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='animate-pulse rounded bg-gray-200'
style={{ width: '30px', height: '30px' }}
/>
))}
{/* Creator and stats row skeleton */}
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex items-center gap-[8px]'>
<div className='h-[14px] w-[14px] animate-pulse rounded-full bg-gray-700' />
<div className='h-3 w-20 animate-pulse rounded bg-gray-700' />
</div>
<div className='flex items-center gap-[6px]'>
<div className='h-3 w-3 animate-pulse rounded bg-gray-700' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-700' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-700' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-700' />
</div>
</div>
</div>
)
@@ -211,13 +87,59 @@ const extractBlockTypesFromState = (state?: {
return [...new Set(blockTypes)]
}
// Utility function to get block display name
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
export function TemplateCard({
/**
* Normalize an arbitrary workflow-like object into a valid WorkflowState for preview rendering.
* Ensures required fields exist: blocks with required properties, edges array, loops and parallels maps.
*/
function normalizeWorkflowState(input?: any): WorkflowState | null {
if (!input || !input.blocks) return null
const normalizedBlocks: WorkflowState['blocks'] = {}
for (const [id, raw] of Object.entries<any>(input.blocks || {})) {
if (!raw || !raw.type) continue
normalizedBlocks[id] = {
id: raw.id ?? id,
type: raw.type,
name: raw.name ?? raw.type,
position: raw.position ?? { x: 0, y: 0 },
subBlocks: raw.subBlocks ?? {},
outputs: raw.outputs ?? {},
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : true,
horizontalHandles: raw.horizontalHandles,
height: raw.height,
advancedMode: raw.advancedMode,
triggerMode: raw.triggerMode,
data: raw.data ?? {},
layout: raw.layout,
}
}
const normalized: WorkflowState = {
blocks: normalizedBlocks,
edges: Array.isArray(input.edges) ? input.edges : [],
loops: input.loops ?? {},
parallels: input.parallels ?? {},
lastSaved: input.lastSaved,
lastUpdate: input.lastUpdate,
metadata: input.metadata,
variables: input.variables,
isDeployed: input.isDeployed,
deployedAt: input.deployedAt,
deploymentStatuses: input.deploymentStatuses,
needsRedeployment: input.needsRedeployment,
dragStartPosition: input.dragStartPosition ?? null,
}
return normalized
}
function TemplateCardInner({
id,
title,
description,
@@ -225,18 +147,16 @@ export function TemplateCard({
authorImageUrl,
usageCount,
stars = 0,
icon,
iconColor = 'bg-blue-500',
blocks = [],
tags = [],
onClick,
className,
state,
isStarred = false,
onTemplateUsed,
onStarChange,
isAuthenticated = true,
onTemplateUsed,
status,
isSuperUser,
onApprove,
onReject,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
@@ -245,17 +165,39 @@ export function TemplateCard({
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
const [isApproving, setIsApproving] = useState(false)
const [isRejecting, setIsRejecting] = useState(false)
// Memoize normalized workflow state to avoid recalculation on every render
const normalizedState = useMemo(() => normalizeWorkflowState(state), [state])
// Use IntersectionObserver to defer rendering the heavy WorkflowPreview until in viewport
const previewRef = useRef<HTMLDivElement | null>(null)
const [isInView, setIsInView] = useState(false)
useEffect(() => {
if (!previewRef.current) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true)
observer.disconnect()
}
},
{ root: null, rootMargin: '200px', threshold: 0 }
)
observer.observe(previewRef.current)
return () => observer.disconnect()
}, [])
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
const blockTypes = state
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort()
// Determine if we're in a workspace context
const workspaceId = params?.workspaceId as string | undefined
// Memoized to prevent recalculation on every render
const blockTypes = useMemo(
() =>
state
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort(),
[state, blocks]
)
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
@@ -311,305 +253,162 @@ export function TemplateCard({
}
/**
* Handles template use action
* - In workspace context: Creates workflow instance via API
* - Outside workspace: Navigates to template detail page
* Get the appropriate template detail page URL based on context.
* If we're in a workspace context, navigate to the workspace template page.
* Otherwise, navigate to the global template page.
* Memoized to avoid recalculation on every render.
*/
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
const templateUrl = useMemo(() => {
const workspaceId = params?.workspaceId as string | undefined
if (workspaceId) {
// Workspace context: Use API to create workflow instance
try {
const response = await fetch(`/api/templates/${id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (response.ok) {
const data = await response.json()
logger.info('Template use API response:', data)
if (!data.workflowId) {
logger.error('No workflowId returned from API:', data)
return
}
const workflowUrl = `/workspace/${workspaceId}/w/${data.workflowId}`
logger.info('Template used successfully, navigating to:', workflowUrl)
if (onTemplateUsed) {
onTemplateUsed()
}
window.location.href = workflowUrl
} else {
const errorText = await response.text()
logger.error('Failed to use template:', response.statusText, errorText)
}
} catch (error) {
logger.error('Error using template:', error)
}
} else {
// Non-workspace context: Navigate to template detail page
router.push(`/templates/${id}`)
return `/workspace/${workspaceId}/templates/${id}`
}
}
return `/templates/${id}`
}, [params?.workspaceId, id])
/**
* Handles card click navigation
* - In workspace context: Navigate to workspace template detail
* - Outside workspace: Navigate to global template detail
* Handle use button click - navigate to template detail page
*/
const handleCardClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
if (workspaceId) {
router.push(`/workspace/${workspaceId}/templates/${id}`)
} else {
router.push(`/templates/${id}`)
}
}
const handleUseClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
router.push(templateUrl)
},
[router, templateUrl]
)
/**
* Handles template approval (super user only)
* Handle card click - navigate to template detail page
*/
const handleApprove = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isApproving || !onApprove) return
setIsApproving(true)
try {
const response = await fetch(`/api/templates/${id}/approve`, {
method: 'POST',
})
if (response.ok) {
onApprove(id)
} else {
logger.error('Failed to approve template:', response.statusText)
const handleCardClick = useCallback(
(e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
} catch (error) {
logger.error('Error approving template:', error)
} finally {
setIsApproving(false)
}
}
/**
* Handles template rejection (super user only)
*/
const handleReject = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isRejecting || !onReject) return
setIsRejecting(true)
try {
const response = await fetch(`/api/templates/${id}/reject`, {
method: 'POST',
})
if (response.ok) {
onReject(id)
} else {
logger.error('Failed to reject template:', response.statusText)
}
} catch (error) {
logger.error('Error rejecting template:', error)
} finally {
setIsRejecting(false)
}
}
router.push(templateUrl)
},
[router, templateUrl]
)
return (
<div
onClick={handleCardClick}
className={cn(
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
className={cn('w-full cursor-pointer rounded-[8px] bg-[#202020] p-[8px]', className)}
>
{/* Left side - Info */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Template name */}
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
{title}
</h3>
</div>
{/* Actions */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Super user approval buttons for pending templates */}
{isSuperUser && status === 'pending' ? (
<>
<button
onClick={handleApprove}
disabled={isApproving}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-green-600 hover:bg-green-700 disabled:opacity-50'
)}
>
{isApproving ? '...' : 'Approve'}
</button>
<button
onClick={handleReject}
disabled={isRejecting}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-red-600 hover:bg-red-700 disabled:opacity-50'
)}
>
{isRejecting ? '...' : 'Reject'}
</button>
</>
) : (
<>
{/* Star button - only for authenticated users */}
{isAuthenticated && (
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-500 text-yellow-500'
: 'text-muted-foreground hover:fill-yellow-500 hover:text-yellow-500',
isStarLoading && 'opacity-50'
)}
/>
)}
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</>
)}
</div>
</div>
{/* Description */}
<p className='line-clamp-2 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
{description}
</p>
{/* Tags */}
{tags && tags.length > 0 && (
<div className='mt-1 flex flex-wrap gap-1'>
{tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
variant='secondary'
className='h-5 border-0 bg-muted/60 px-1.5 text-[10px] hover:bg-muted/80'
>
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge
variant='secondary'
className='h-5 border-0 bg-muted/60 px-1.5 text-[10px] hover:bg-muted/80'
>
+{tags.length - 3}
</Badge>
)}
</div>
)}
</div>
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
{authorImageUrl ? (
<div className='h-3 w-3 flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
) : (
<User className='h-3 w-3 flex-shrink-0' />
)}
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'></span>
<ChartNoAxesColumn className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<span></span>
<Star className='h-3 w-3' />
<span>{localStarCount}</span>
</div>
</div>
{/* Workflow Preview */}
<div
ref={previewRef}
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
>
{normalizedState && isInView ? (
<WorkflowPreview
workflowState={normalizedState}
showSubBlocks={false}
height={180}
width='100%'
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
/>
) : (
<div className='h-full w-full bg-[#2A2A2A]' />
)}
</div>
{/* Right side - Block Icons */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{blockTypes.length > 3 ? (
<>
{/* Show first 2 blocks when there are more than 3 */}
{blockTypes.slice(0, 2).map((blockType, index) => {
{/* Title and Blocks Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Template Name */}
<h3 className='truncate pr-[8px] pl-[2px] font-medium text-[16px] text-white'>{title}</h3>
{/* Block Icons */}
<div className='flex flex-shrink-0'>
{blockTypes.length > 4 ? (
<>
{/* Show first 3 blocks when there are more than 4 */}
{blockTypes.slice(0, 3).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div
key={index}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
marginLeft: index > 0 ? '-4px' : '0',
}}
>
<blockConfig.icon className='h-[10px] w-[10px] text-white' />
</div>
)
})}
{/* Show +n for remaining blocks */}
<div
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#4A4A4A]'
style={{ marginLeft: '-4px' }}
>
<span className='font-medium text-[10px] text-white'>+{blockTypes.length - 3}</span>
</div>
</>
) : (
/* Show all blocks when 4 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})}
{/* Show +n block for remaining blocks */}
<div className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
style={{ width: '30px', height: '30px' }}
>
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
</div>
</div>
</>
) : (
/* Show all blocks when 3 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
key={index}
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
marginLeft: index > 0 ? '-4px' : '0',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
<blockConfig.icon className='h-[10px] w-[10px] text-white' />
</div>
</div>
)
})
)}
)
})
)}
</div>
</div>
{/* Creator and Stats Row */}
<div className='mt-[10px] flex items-center justify-between'>
{/* Creator Info */}
<div className='flex items-center gap-[8px]'>
{authorImageUrl ? (
<div className='h-[26px] w-[26px] flex-shrink-0 overflow-hidden rounded-full'>
<img src={authorImageUrl} alt={author} className='h-full w-full object-cover' />
</div>
) : (
<div className='flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
<User className='h-[18px] w-[18px] text-[#888888]' />
</div>
)}
<span className='truncate font-medium text-[#888888] text-[12px]'>{author}</span>
</div>
{/* Stats */}
<div className='flex flex-shrink-0 items-center gap-[6px] font-medium text-[#888888] text-[12px]'>
<User className='h-[12px] w-[12px]' />
<span>{usageCount}</span>
<Star
onClick={handleStarClick}
className={cn(
'h-[12px] w-[12px] cursor-pointer transition-colors',
localIsStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
isStarLoading && 'opacity-50'
)}
/>
<span>{localStarCount}</span>
</div>
</div>
</div>
)
}
export const TemplateCard = memo(TemplateCardInner)

View File

@@ -0,0 +1,12 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { season } from '@/app/fonts/season/season'
export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) {
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className={`${season.variable} font-season`}>{children}</div>
</Tooltip.Provider>
)
}

View File

@@ -1,12 +1,9 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { season } from '@/app/fonts/season/season'
import TemplatesLayoutClient from './layout-client'
/**
* Templates layout - server component wrapper for client layout.
* Redirect logic is handled by individual pages to preserve paths.
*/
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className={`${season.variable} font-season`}>{children}</div>
</Tooltip.Provider>
)
return <TemplatesLayoutClient>{children}</TemplatesLayoutClient>
}

View File

@@ -1,99 +1,66 @@
import { db } from '@sim/db'
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
import { and, desc, eq } from 'drizzle-orm'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import type { Template } from '@/app/templates/templates'
import Templates from '@/app/templates/templates'
/**
* Public templates list page.
* Redirects authenticated users to their workspace-scoped templates page.
* Allows unauthenticated users to view templates for SEO and discovery.
*/
export default async function TemplatesPage() {
const session = await getSession()
// Check if user is a super user and if super user mode is enabled
let effectiveSuperUser = false
// Authenticated users: redirect to workspace-scoped templates
if (session?.user?.id) {
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const userSettings = await db
.select({ superUserModeEnabled: settings.superUserModeEnabled })
.from(settings)
.where(eq(settings.userId, session.user.id))
const userWorkspaces = await db
.select({
workspace: workspace,
})
.from(permissions)
.innerJoin(workspace, eq(permissions.entityId, workspace.id))
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
.orderBy(desc(workspace.createdAt))
.limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
// Effective super user = database status AND UI mode enabled
effectiveSuperUser = isSuperUser && superUserModeEnabled
if (userWorkspaces.length > 0) {
const firstWorkspace = userWorkspaces[0].workspace
redirect(`/workspace/${firstWorkspace.id}/templates`)
}
}
// Fetch templates based on user status
let templatesData
if (session?.user?.id) {
// Build where condition based on super user status
const whereCondition = effectiveSuperUser ? undefined : eq(templates.status, 'approved')
// Logged-in users: include star status
templatesData = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
name: templates.name,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
})
.from(templates)
.leftJoin(
templateStars,
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(whereCondition)
.orderBy(desc(templates.views), desc(templates.createdAt))
} else {
// Non-logged-in users: only approved templates, no star status
templatesData = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
name: templates.name,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.status, 'approved'))
.orderBy(desc(templates.views), desc(templates.createdAt))
.then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
}
// Unauthenticated users: show public templates
const templatesData = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
name: templates.name,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.status, 'approved'))
.orderBy(desc(templates.views), desc(templates.createdAt))
.then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
return (
<Templates
initialTemplates={templatesData as unknown as Template[]}
currentUserId={session?.user?.id || null}
isSuperUser={effectiveSuperUser}
currentUserId={null}
isSuperUser={false}
/>
)
}

View File

@@ -1,13 +1,12 @@
'use client'
import { useState } from 'react'
import { ArrowLeft, Search } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Layout, Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
import { NavigationTabs } from '@/app/templates/components/navigation-tabs'
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { CreatorProfileDetails } from '@/types/creator-profile'
@@ -60,11 +59,30 @@ export default function Templates({
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
const handleTabClick = (tabId: string) => {
setActiveTab(tabId)
}
// Redirect authenticated users to workspace templates
useEffect(() => {
if (currentUserId) {
const redirectToWorkspace = async () => {
try {
const response = await fetch('/api/workspaces')
if (response.ok) {
const data = await response.json()
const defaultWorkspace = data.workspaces?.[0]
if (defaultWorkspace) {
router.push(`/workspace/${defaultWorkspace.id}/templates`)
}
}
} catch (error) {
logger.error('Error redirecting to workspace:', error)
}
}
redirectToWorkspace()
}
}, [currentUserId, router])
// Handle star change callback from template card
/**
* Update star status for a template
*/
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
setTemplates((prevTemplates) =>
prevTemplates.map((template) =>
@@ -73,239 +91,137 @@ export default function Templates({
)
}
const matchesSearch = (template: Template) => {
if (!searchQuery) return true
/**
* Filter templates based on active tab and search query
* Memoized to prevent unnecessary recalculations on render
*/
const filteredTemplates = useMemo(() => {
const query = searchQuery.toLowerCase()
return (
template.name.toLowerCase().includes(query) ||
template.details?.tagline?.toLowerCase().includes(query) ||
template.creator?.name?.toLowerCase().includes(query)
)
}
const ownedTemplates = currentUserId
? templates.filter(
(template) =>
template.creator?.referenceType === 'user' &&
template.creator?.referenceId === currentUserId
)
: []
const starredTemplates = currentUserId
? templates.filter(
(template) =>
template.isStarred &&
!(
template.creator?.referenceType === 'user' &&
template.creator?.referenceId === currentUserId
)
)
: []
return templates.filter((template) => {
// Filter by tab - only gallery and pending for public page
const tabMatch =
activeTab === 'gallery' ? template.status === 'approved' : template.status === 'pending'
const filteredOwnedTemplates = ownedTemplates.filter(matchesSearch)
const filteredStarredTemplates = starredTemplates.filter(matchesSearch)
if (!tabMatch) return false
const galleryTemplates = templates
.filter((template) => template.status === 'approved')
.filter(matchesSearch)
// Filter by search query
if (!query) return true
const pendingTemplates = templates
.filter((template) => template.status === 'pending')
.filter(matchesSearch)
const searchableText = [template.name, template.details?.tagline, template.creator?.name]
.filter(Boolean)
.join(' ')
.toLowerCase()
// Helper function to render template cards
const renderTemplateCard = (template: Template) => (
<TemplateCard
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline || ''}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}
stars={template.stars}
tags={template.tags}
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={!!currentUserId}
/>
)
return searchableText.includes(query)
})
}, [templates, activeTab, searchQuery])
// Render skeleton cards for loading state
const renderSkeletonCards = () => {
return Array.from({ length: 8 }).map((_, index) => (
<TemplateCardSkeleton key={`skeleton-${index}`} />
))
}
// Calculate counts for tabs
const yourTemplatesCount = ownedTemplates.length + starredTemplates.length
const galleryCount = templates.filter((template) => template.status === 'approved').length
const pendingCount = templates.filter((template) => template.status === 'pending').length
// Build tabs based on user status
const navigationTabs = [
{
id: 'gallery',
label: 'Gallery',
count: galleryCount,
},
...(currentUserId
? [
{
id: 'your',
label: 'Your Templates',
count: yourTemplatesCount,
},
]
: []),
...(isSuperUser
? [
{
id: 'pending',
label: 'Pending',
count: pendingCount,
},
]
: []),
]
// Show tabs if there's more than one tab
const showTabs = navigationTabs.length > 1
const handleBackToWorkspace = async () => {
try {
const response = await fetch('/api/workspaces')
if (response.ok) {
const data = await response.json()
const defaultWorkspace = data.workspaces?.[0]
if (defaultWorkspace) {
router.push(`/workspace/${defaultWorkspace.id}`)
}
/**
* Get empty state message based on current filters
* Memoized to prevent unnecessary recalculations on render
*/
const emptyState = useMemo(() => {
if (searchQuery) {
return {
title: 'No templates found',
description: 'Try a different search term',
}
} catch (error) {
logger.error('Error navigating to workspace:', error)
}
}
const messages = {
pending: {
title: 'No pending templates',
description: 'New submissions will appear here',
},
gallery: {
title: 'No templates available',
description: 'Templates will appear once approved',
},
}
return messages[activeTab as keyof typeof messages] || messages.gallery
}, [searchQuery, activeTab])
return (
<div className='flex h-[100vh] flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto p-6'>
{/* Header with Back Button */}
<div className='mb-6'>
{currentUserId && (
<Button
variant='ghost'
size='sm'
onClick={handleBackToWorkspace}
className='-ml-2 mb-4 text-muted-foreground hover:text-foreground'
>
<ArrowLeft className='mr-2 h-4 w-4' />
Back to Workspace
</Button>
)}
<h1 className='mb-2 font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'>
Templates
</h1>
<p className='font-[350] font-sans text-muted-foreground text-sm leading-[1.5] tracking-[0.01em]'>
Grab a template and start building, or make
<br />
one from scratch.
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
<div>
<div className='flex items-start gap-[12px]'>
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
<Layout className='h-[14px] w-[14px] text-[#FBBC04]' />
</div>
<h1 className='font-medium text-[18px]'>Templates</h1>
</div>
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
Grab a template and start building, or make one from scratch.
</p>
</div>
{/* Search */}
<div className='mb-6 flex items-center justify-between'>
<div className='flex h-9 w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
<Input
placeholder='Search templates...'
placeholder='Search'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='flex items-center gap-[8px]'>
<Button
variant={activeTab === 'gallery' ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={() => setActiveTab('gallery')}
>
Gallery
</Button>
{isSuperUser && (
<Button
variant={activeTab === 'pending' ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={() => setActiveTab('pending')}
>
Pending
</Button>
)}
</div>
</div>
{/* Navigation - only show if multiple tabs */}
{showTabs && (
<div className='mb-6'>
<NavigationTabs
tabs={navigationTabs}
activeTab={activeTab}
onTabClick={handleTabClick}
/>
</div>
)}
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
{loading ? (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{renderSkeletonCards()}
</div>
) : activeTab === 'your' ? (
filteredOwnedTemplates.length === 0 && filteredStarredTemplates.length === 0 ? (
<div className='flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading ? (
Array.from({ length: 8 }).map((_, index) => (
<TemplateCardSkeleton key={`skeleton-${index}`} />
))
) : filteredTemplates.length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-muted-foreground text-sm'>
{searchQuery ? 'No templates found' : 'No templates yet'}
</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>
{searchQuery
? 'Try a different search term'
: 'Create or star templates to see them here'}
</p>
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
</div>
</div>
) : (
<div className='space-y-8'>
{filteredOwnedTemplates.length > 0 && (
<section>
<h2 className='mb-3 font-semibold text-lg'>Your Templates</h2>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{filteredOwnedTemplates.map((template) => renderTemplateCard(template))}
</div>
</section>
)}
{filteredStarredTemplates.length > 0 && (
<section>
<h2 className='mb-3 font-semibold text-lg'>Starred Templates</h2>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{filteredStarredTemplates.map((template) => renderTemplateCard(template))}
</div>
</section>
)}
</div>
)
) : (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-muted-foreground text-sm'>
{searchQuery
? 'No templates found'
: activeTab === 'pending'
? 'No pending templates'
: 'No templates available'}
</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>
{searchQuery
? 'Try a different search term'
: activeTab === 'pending'
? 'New submissions will appear here'
: 'Templates will appear once approved'}
</p>
</div>
</div>
) : (
(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).map((template) =>
renderTemplateCard(template)
)
)}
</div>
)}
filteredTemplates.map((template) => (
<TemplateCard
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline || ''}
author={template.creator?.name || 'Unknown'}
authorImageUrl={template.creator?.profileImageUrl || null}
usageCount={template.views.toString()}
stars={template.stars}
state={template.state}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={!!currentUserId}
/>
))
)}
</div>
</div>
</div>
</div>

View File

@@ -16,12 +16,12 @@ interface TemplatePageProps {
* Uses the shared TemplateDetails component with workspace context.
*/
export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId } = await params
const { workspaceId, id } = await params
const session = await getSession()
// Require authentication
// Redirect unauthenticated users to public template detail page
if (!session?.user?.id) {
redirect('/login')
redirect(`/templates/${id}`)
}
// Verify workspace membership

View File

@@ -21,9 +21,9 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
const { workspaceId } = await params
const session = await getSession()
// Require authentication
// Redirect unauthenticated users to public templates page
if (!session?.user?.id) {
redirect('/login')
redirect('/templates')
}
// Verify workspace membership

View File

@@ -2,11 +2,11 @@
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { CheckCircle2, Loader2, Plus, Trash2 } from 'lucide-react'
import { CheckCircle2, Loader2, Plus } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Badge, Button, Input, Textarea, Trash } from '@/components/emcn'
import {
Button,
Dialog,
DialogContent,
DialogHeader,
@@ -17,13 +17,11 @@ import {
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@/components/ui'
import { TagInput } from '@/components/ui/tag-input'
import { useSession } from '@/lib/auth-client'
@@ -273,18 +271,23 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
return (
<div className='space-y-4'>
{existingTemplate && (
<div className='flex items-center justify-between rounded-lg border border-border/50 bg-muted/30 px-4 py-3'>
<div className='flex items-center gap-3'>
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
<div className='flex items-center gap-2'>
<span className='font-medium text-sm'>Template Connected</span>
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)] px-[16px] py-[12px]'>
<div className='flex items-center gap-[12px]'>
<CheckCircle2 className='h-[16px] w-[16px] text-green-600 dark:text-green-400' />
<div className='flex items-center gap-[8px]'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
Template Connected
</span>
{existingTemplate.status === 'pending' && (
<span className='rounded-md bg-yellow-100 px-2 py-0.5 font-medium text-xs text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'>
<Badge
variant='outline'
className='border-yellow-300 bg-yellow-100 text-yellow-700 dark:border-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
>
Under Review
</span>
</Badge>
)}
{existingTemplate.status === 'approved' && existingTemplate.views > 0 && (
<span className='text-muted-foreground text-xs'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{existingTemplate.views} views
{existingTemplate.stars > 0 && `${existingTemplate.stars} stars`}
</span>
@@ -294,11 +297,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setShowDeleteDialog(true)}
className='h-8 px-2 text-muted-foreground hover:text-red-600 dark:hover:text-red-400'
className='h-[32px] px-[8px] text-[var(--text-muted)] hover:text-red-600 dark:hover:text-red-400'
>
<Trash2 className='h-4 w-4' />
<Trash className='h-[14px] w-[14px]' />
</Button>
</div>
)}
@@ -362,8 +364,6 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
<Button
type='button'
variant='outline'
size='sm'
className='gap-2'
onClick={() => {
try {
const event = new CustomEvent('open-settings', {
@@ -377,9 +377,10 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
})
}
}}
className='gap-[8px]'
>
<Plus className='h-4 w-4 text-muted-foreground' />
<span className='text-muted-foreground'>Create a Creator Profile</span>
<Plus className='h-[14px] w-[14px] text-[var(--text-muted)]' />
<span className='text-[var(--text-muted)]'>Create a Creator Profile</span>
</Button>
</div>
) : (
@@ -432,7 +433,7 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
)}
/>
<div className='flex justify-end gap-2 border-t pt-4'>
<div className='flex justify-end gap-[8px] border-[var(--border)] border-t pt-[16px]'>
{existingTemplate && (
<Button
type='button'
@@ -445,12 +446,12 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
)}
<Button
type='submit'
variant='primary'
disabled={isSubmitting || !form.formState.isValid}
className='bg-purple-600 hover:bg-purple-700'
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
<Loader2 className='mr-[8px] h-[14px] w-[14px] animate-spin' />
{existingTemplate ? 'Updating...' : 'Publishing...'}
</>
) : existingTemplate ? (
@@ -465,19 +466,21 @@ export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDep
{showDeleteDialog && (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
<div className='w-full max-w-md rounded-lg bg-background p-6 shadow-lg'>
<h3 className='mb-4 font-semibold text-lg'>Delete Template?</h3>
<p className='mb-6 text-muted-foreground text-sm'>
<div className='w-full max-w-md rounded-[8px] bg-[var(--surface-3)] p-[24px] shadow-lg'>
<h3 className='mb-[16px] font-semibold text-[18px] text-[var(--text-primary)]'>
Delete Template?
</h3>
<p className='mb-[24px] text-[14px] text-[var(--text-secondary)]'>
This will permanently delete your template. This action cannot be undone.
</p>
<div className='flex justify-end gap-2'>
<div className='flex justify-end gap-[8px]'>
<Button variant='outline' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={isDeleting}
className='bg-red-600 hover:bg-red-700'
className='bg-red-600 text-white hover:bg-red-700'
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>

View File

@@ -2,12 +2,14 @@
import type { ReactNode } from 'react'
import { Badge } from '@/components/emcn'
import { Progress } from '@/components/ui'
import { cn } from '@/lib/utils'
const GRADIENT_BADGE_STYLES =
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer'
// Constants matching UsageIndicator
const PILL_COUNT = 8
interface UsageHeaderProps {
title: string
gradientTitle?: boolean
@@ -43,17 +45,22 @@ export function UsageHeader({
}: UsageHeaderProps) {
const progress = progressValue ?? (limit > 0 ? Math.min((current / limit) * 100, 100) : 0)
// Calculate filled pills based on usage percentage
const filledPillsCount = Math.ceil((progress / 100) * PILL_COUNT)
const isAlmostOut = filledPillsCount === PILL_COUNT
return (
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
<div className='space-y-2'>
{/* Top row */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span
className={cn(
'font-medium text-sm',
'font-medium text-[12px]',
gradientTitle
? 'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
: 'text-foreground'
: 'text-[#FFFFFF]'
)}
>
{title}
@@ -67,25 +74,42 @@ export function UsageHeader({
<span className='text-muted-foreground text-xs'>({seatsText})</span>
) : null}
</div>
<div className='flex items-center gap-1 text-xs tabular-nums'>
<div className='flex items-center gap-[4px] text-xs tabular-nums'>
{isBlocked ? (
<span className='text-muted-foreground'>Payment required</span>
<span className='font-medium text-[#B1B1B1] text-[12px]'>Payment required</span>
) : (
<>
<span className='text-muted-foreground'>${current.toFixed(2)}</span>
<span className='text-muted-foreground'>/</span>
{rightContent ?? <span className='text-muted-foreground'>${limit}</span>}
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
${current.toFixed(2)}
</span>
<span className='font-medium text-[#B1B1B1] text-[12px]'>/</span>
{rightContent ?? (
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
${limit}
</span>
)}
</>
)}
</div>
</div>
<Progress
value={isBlocked ? 100 : progress}
className='h-2'
indicatorClassName='bg-black dark:bg-white'
/>
{/* Pills row - matching UsageIndicator */}
<div className='flex items-center gap-[4px]'>
{Array.from({ length: PILL_COUNT }).map((_, i) => {
const isFilled = i < filledPillsCount
return (
<div
key={i}
className='h-[6px] flex-1 rounded-[2px]'
style={{
backgroundColor: isFilled ? (isAlmostOut ? '#ef4444' : '#34B5FF') : '#414141',
}}
/>
)
})}
</div>
{/* Status messages */}
{isBlocked && (
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
<span className='text-destructive text-xs'>

View File

@@ -2,7 +2,8 @@
import { useEffect, useState } from 'react'
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
import { Alert, AlertDescription, Button, Input, Label } from '@/components/ui'
import { Button, Combobox } from '@/components/emcn'
import { Alert, AlertDescription, Input, Label } from '@/components/ui'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/lib/auth-client'
import { isBillingEnabled } from '@/lib/environment'
@@ -535,7 +536,7 @@ export function SSO() {
// SSO Provider Status View
<div className='space-y-4'>
{providers.map((provider) => (
<div key={provider.id} className='rounded-[12px] border border-border p-6'>
<div key={provider.id} className='rounded-[8px] bg-muted/30 p-4'>
<div className='flex items-start justify-between gap-3'>
<div className='flex-1'>
<h3 className='font-medium text-base'>Single Sign-On Provider</h3>
@@ -545,10 +546,9 @@ export function SSO() {
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
size='sm'
variant='ghost'
onClick={() => handleReconfigure(provider)}
className='rounded-[8px]'
className='h-8'
>
Reconfigure
</Button>
@@ -609,12 +609,12 @@ export function SSO() {
{hasProviders && (
<div className='mb-4'>
<Button
variant='outline'
variant='ghost'
onClick={() => {
setShowConfigForm(false)
setIsEditing(false)
}}
className='rounded-[8px]'
className='h-8'
>
Back to SSO Status
</Button>
@@ -626,14 +626,14 @@ export function SSO() {
{/* Provider Type Selection */}
<div className='space-y-1'>
<Label>Provider Type</Label>
<div className='flex rounded-[10px] border border-input bg-background p-1'>
<div className='flex gap-2 rounded-[10px] border border-[var(--border-muted)] bg-[var(--surface-3)] p-1'>
<button
type='button'
className={cn(
'flex-1 rounded-[6px] px-3 py-2 font-medium text-sm transition-colors',
'flex-1 rounded-[6px] px-3 py-2 font-medium text-sm transition-all',
formData.providerType === 'oidc'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
? 'bg-[var(--surface-1)] text-[var(--text-primary)] shadow-sm ring-1 ring-[var(--border-muted)]'
: 'text-muted-foreground hover:bg-[var(--surface-2)] hover:text-foreground'
)}
onClick={() => handleInputChange('providerType', 'oidc')}
>
@@ -642,10 +642,10 @@ export function SSO() {
<button
type='button'
className={cn(
'flex-1 rounded-[6px] px-3 py-2 font-medium text-sm transition-colors',
'flex-1 rounded-[6px] px-3 py-2 font-medium text-sm transition-all',
formData.providerType === 'saml'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
? 'bg-[var(--surface-1)] text-[var(--text-primary)] shadow-sm ring-1 ring-[var(--border-muted)]'
: 'text-muted-foreground hover:bg-[var(--surface-2)] hover:text-foreground'
)}
onClick={() => handleInputChange('providerType', 'saml')}
>
@@ -661,24 +661,21 @@ export function SSO() {
<div className='space-y-1'>
<Label htmlFor='provider-id'>Provider ID</Label>
<select
id='provider-id'
<Combobox
value={formData.providerId}
onChange={(e) => handleInputChange('providerId', e.target.value)}
onChange={(value: string) => handleInputChange('providerId', value)}
options={TRUSTED_SSO_PROVIDERS.map((id) => ({
label: id,
value: id,
}))}
placeholder='Select a provider ID'
editable={true}
className={cn(
'w-full rounded-[10px] border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showErrors &&
errors.providerId.length > 0 &&
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
>
<option value=''>Select a provider ID</option>
{TRUSTED_SSO_PROVIDERS.map((providerId) => (
<option key={providerId} value={providerId}>
{providerId}
</option>
))}
</select>
/>
{showErrors && errors.providerId.length > 0 && (
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.providerId.join(' ')}</p>
@@ -808,16 +805,20 @@ export function SSO() {
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
)}
/>
<button
type='button'
<Button
variant='ghost'
onClick={() => setShowClientSecret((s) => !s)}
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
className='-translate-y-1/2 absolute top-1/2 right-3 h-6 w-6 rounded-[4px] p-0 text-muted-foreground hover:text-foreground'
aria-label={
showClientSecret ? 'Hide client secret' : 'Show client secret'
}
>
{showClientSecret ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
{showClientSecret ? (
<EyeOff className='h-4 w-4' />
) : (
<Eye className='h-4 w-4' />
)}
</Button>
</div>
{showErrors && errors.clientSecret.length > 0 && (
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
@@ -1011,7 +1012,7 @@ export function SSO() {
<Button
type='submit'
className='w-full rounded-[10px]'
className='h-9 w-full'
disabled={isLoading || hasAnyErrors(errors) || !isFormValid()}
>
{isLoading
@@ -1029,29 +1030,18 @@ export function SSO() {
<p className='text-muted-foreground text-xs'>
Configure this URL in your identity provider as the callback/redirect URI
</p>
<div className='relative'>
<Input
id='callback-url'
readOnly
value={callbackUrl}
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
className='h-9 w-full cursor-text pr-10 font-mono text-xs focus-visible:ring-2 focus-visible:ring-primary/20'
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type='button'
<div className='relative flex h-9 items-center rounded-[8px] bg-muted px-3 pr-10'>
<code className='flex-1 truncate font-mono text-foreground text-xs'>
{callbackUrl}
</code>
<Button
variant='ghost'
onClick={copyCallback}
aria-label='Copy callback URL'
className='-translate-y-1/2 absolute top-1/2 right-3 rounded p-1 text-muted-foreground transition hover:text-foreground'
className='absolute right-1 h-6 w-6 rounded-[4px] p-0 text-muted-foreground hover:text-foreground'
>
{copied ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</button>
{copied ? <Check className='h-4 w-4' /> : <Copy className='h-4 w-4' />}
</Button>
</div>
</div>
</>

View File

@@ -437,7 +437,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
return (
<div className='px-6 pt-4 pb-4'>
<div className='flex flex-col gap-2'>
{/* Current Plan & Usage Overview - Styled like usage-indicator */}
{/* Current Plan & Usage Overview */}
<div className='mb-2'>
<UsageHeader
title={formatPlanName(subscription.plan)}

View File

@@ -1,11 +1,8 @@
import React, { useMemo, useState } from 'react'
import { CheckCircle } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { CheckCircle, ChevronDown } from 'lucide-react'
import { Button, Input, Label } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { quickValidateEmail } from '@/lib/email/validation'
import { cn } from '@/lib/utils'
@@ -140,127 +137,120 @@ export function MemberInvitationCard({
}
return (
<div className='space-y-4'>
{/* Header - clean like account page */}
<div>
<h4 className='font-medium text-sm'>Invite Team Members</h4>
<p className='text-muted-foreground text-xs'>
Add new members to your team and optionally give them access to specific workspaces
</p>
</div>
<div className='rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)] p-4'>
<div className='space-y-3'>
{/* Header */}
<div>
<h4 className='font-medium text-sm'>Invite Team Members</h4>
<p className='text-muted-foreground text-xs'>
Add new members to your team and optionally give them access to specific workspaces
</p>
</div>
{/* Main invitation input - clean layout */}
<div className='flex items-start gap-3'>
<div className='flex-1'>
<div>
{/* Main invitation input */}
<div className='flex items-start gap-2'>
<div className='flex-1'>
<Input
placeholder='Enter email address'
value={inviteEmail}
onChange={handleEmailChange}
disabled={isInviting || !hasAvailableSeats}
className={cn('w-full', emailError && 'border-red-500 focus-visible:ring-red-500')}
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
/>
<div className='h-4 pt-1'>
{emailError && (
<p className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{emailError}
</p>
)}
</div>
</div>
</div>
<Button
variant='outline'
size='sm'
onClick={() => {
setShowWorkspaceInvite(!showWorkspaceInvite)
if (!showWorkspaceInvite) {
onLoadUserWorkspaces()
}
}}
disabled={isInviting || !hasAvailableSeats}
className='h-9 shrink-0 rounded-[8px] text-sm'
>
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces
</Button>
<Button
size='sm'
onClick={handleInviteClick}
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
className='h-9 shrink-0 rounded-[8px]'
>
{isInviting ? <ButtonSkeleton /> : null}
{hasAvailableSeats ? 'Invite' : 'No Seats'}
</Button>
</div>
{showWorkspaceInvite && (
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<h5 className='font-medium text-xs'>Workspace Access</h5>
<Badge variant='outline' className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'>
Optional
</Badge>
</div>
{selectedCount > 0 && (
<span className='text-muted-foreground text-xs'>{selectedCount} selected</span>
{emailError && (
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
{emailError}
</p>
)}
</div>
<p className='text-muted-foreground text-xs leading-relaxed'>
Grant access to specific workspaces. You can modify permissions later.
</p>
<Button
variant='ghost'
onClick={() => {
setShowWorkspaceInvite(!showWorkspaceInvite)
if (!showWorkspaceInvite) {
onLoadUserWorkspaces()
}
}}
disabled={isInviting || !hasAvailableSeats}
>
<ChevronDown
className={cn(
'h-3.5 w-3.5 transition-transform',
showWorkspaceInvite && 'rotate-180'
)}
/>
Workspaces
</Button>
<Button
variant='secondary'
onClick={handleInviteClick}
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
>
{isInviting ? <ButtonSkeleton /> : hasAvailableSeats ? 'Invite' : 'No Seats'}
</Button>
</div>
{userWorkspaces.length === 0 ? (
<div className='rounded-md border border-dashed py-8 text-center'>
<p className='text-muted-foreground text-sm'>No workspaces available</p>
<p className='mt-1 text-muted-foreground text-xs'>
You need admin access to workspaces to invite members
</p>
{/* Workspace selection - collapsible */}
{showWorkspaceInvite && (
<div className='space-y-3 rounded-md border border-[var(--border-muted)] bg-[var(--surface-2)] p-3'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<h5 className='font-medium text-xs'>Workspace Access</h5>
<span className='text-[11px] text-muted-foreground'>(Optional)</span>
</div>
{selectedCount > 0 && (
<span className='text-muted-foreground text-xs'>{selectedCount} selected</span>
)}
</div>
) : (
<div className='max-h-48 space-y-2 overflow-y-auto'>
{userWorkspaces.map((workspace) => {
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
const selectedWorkspace = selectedWorkspaces.find(
(w) => w.workspaceId === workspace.id
)
return (
<div key={workspace.id} className='flex items-center justify-between gap-2 py-1'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<Checkbox
id={`workspace-${workspace.id}`}
checked={isSelected}
onCheckedChange={(checked) => {
if (checked) {
onWorkspaceToggle(workspace.id, 'read')
} else {
onWorkspaceToggle(workspace.id, '')
}
}}
disabled={isInviting}
/>
<Label
htmlFor={`workspace-${workspace.id}`}
className='cursor-pointer font-medium text-sm'
>
{workspace.name}
</Label>
{workspace.isOwner && (
<Badge
variant='outline'
className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'
{userWorkspaces.length === 0 ? (
<div className='py-4 text-center'>
<p className='text-muted-foreground text-xs'>No workspaces available</p>
</div>
) : (
<div className='max-h-48 space-y-1.5 overflow-y-auto'>
{userWorkspaces.map((workspace) => {
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
const selectedWorkspace = selectedWorkspaces.find(
(w) => w.workspaceId === workspace.id
)
return (
<div
key={workspace.id}
className='flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-[var(--surface-3)]'
>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2'>
<Checkbox
id={`workspace-${workspace.id}`}
checked={isSelected}
onCheckedChange={(checked) => {
if (checked) {
onWorkspaceToggle(workspace.id, 'read')
} else {
onWorkspaceToggle(workspace.id, '')
}
}}
disabled={isInviting}
/>
<Label
htmlFor={`workspace-${workspace.id}`}
className='cursor-pointer text-xs'
>
Owner
</Badge>
)}
{workspace.name}
</Label>
{workspace.isOwner && (
<Badge
variant='outline'
className='h-[1.125rem] rounded-[6px] px-2 py-0 text-[10px]'
>
Owner
</Badge>
)}
</div>
</div>
</div>
{/* Always reserve space for permission selector to maintain consistent layout */}
<div className='flex h-[30px] w-32 flex-shrink-0 items-center justify-end gap-2'>
{isSelected && (
<PermissionSelector
value={
@@ -276,24 +266,25 @@ export function MemberInvitationCard({
/>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
)
})}
</div>
)}
</div>
)}
{inviteSuccess && (
<Alert className='rounded-[8px] border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
<CheckCircle className='h-4 w-4 text-green-600 dark:text-green-400' />
<AlertDescription>
Invitation sent successfully
{selectedCount > 0 &&
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
</AlertDescription>
</Alert>
)}
{/* Success message */}
{inviteSuccess && (
<div className='flex items-start gap-2 rounded-md bg-green-500/10 p-2.5 text-green-600 dark:text-green-400'>
<CheckCircle className='h-4 w-4 flex-shrink-0' />
<p className='text-xs'>
Invitation sent successfully
{selectedCount > 0 &&
` with access to ${selectedCount} workspace${selectedCount !== 1 ? 's' : ''}`}
</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'
import { LogOut, UserX, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { UserX, X } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { Button as UIButton } from '@/components/ui/button'
import { UserAvatar } from '@/components/user-avatar/user-avatar'
import { createLogger } from '@/lib/logs/console/logger'
import type { Invitation, Member, Organization } from '@/stores/organization'
@@ -20,6 +21,8 @@ interface BaseItem {
name: string
email: string
avatarInitial: string
avatarUrl?: string | null
userId?: string
usage: string
}
@@ -95,6 +98,8 @@ export function TeamMembers({
name,
email: member.user?.email || '',
avatarInitial: name.charAt(0).toUpperCase(),
avatarUrl: member.user?.image,
userId: member.user?.id,
usage: `$${usageAmount.toFixed(2)}`,
role: member.role,
member,
@@ -118,6 +123,8 @@ export function TeamMembers({
name: emailPrefix,
email: invitation.email,
avatarInitial: emailPrefix.charAt(0).toUpperCase(),
avatarUrl: null,
userId: invitation.email, // Use email as fallback for color generation
usage: '-',
invitation,
}
@@ -163,15 +170,12 @@ export function TeamMembers({
{/* Member info */}
<div className='flex flex-1 items-center gap-3'>
{/* Avatar */}
<div
className={`flex h-8 w-8 items-center justify-center rounded-full font-medium text-sm ${
item.type === 'member'
? 'bg-primary/10 text-muted-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{item.avatarInitial}
</div>
<UserAvatar
userId={item.userId || item.email}
userName={item.name}
avatarUrl={item.avatarUrl}
size={32}
/>
{/* Name and email */}
<div className='min-w-0 flex-1'>
@@ -223,14 +227,14 @@ export function TeamMembers({
item.email !== currentUserEmail && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
<UIButton
variant='outline'
size='sm'
onClick={() => onRemoveMember(item.member)}
className='h-8 w-8 rounded-[8px] p-0'
>
<UserX className='h-4 w-4' />
</Button>
</UIButton>
</Tooltip.Trigger>
<Tooltip.Content side='left'>Remove Member</Tooltip.Content>
</Tooltip.Root>
@@ -240,7 +244,7 @@ export function TeamMembers({
{isAdminOrOwner && item.type === 'invitation' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
<UIButton
variant='outline'
size='sm'
onClick={() => handleCancelInvitation(item.invitation.id)}
@@ -252,7 +256,7 @@ export function TeamMembers({
) : (
<X className='h-4 w-4' />
)}
</Button>
</UIButton>
</Tooltip.Trigger>
<Tooltip.Content side='left'>
{cancellingInvitations.has(item.invitation.id)
@@ -268,10 +272,9 @@ export function TeamMembers({
{/* Leave Organization button */}
{canLeaveOrganization && (
<div className='border-t pt-4'>
<div className='mt-4 border-[var(--border-muted)] border-t pt-4'>
<Button
variant='outline'
size='default'
variant='default'
onClick={() => {
if (!currentUserMember?.user?.id) {
logger.error('Cannot leave organization: missing user ID', { currentUserMember })
@@ -279,9 +282,7 @@ export function TeamMembers({
}
onRemoveMember(currentUserMember)
}}
className='w-full hover:bg-muted'
>
<LogOut className='mr-2 h-4 w-4' />
Leave Organization
</Button>
</div>

View File

@@ -1,10 +1,11 @@
import { Building2 } from 'lucide-react'
import { Badge } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/env'
import { cn } from '@/lib/utils'
const PILL_COUNT = 8
type Subscription = {
id: string
@@ -93,56 +94,59 @@ export function TeamSeatsOverview({
)
}
const totalSeats = subscriptionData.seats || 0
const isEnterprise = checkEnterprisePlan(subscriptionData)
return (
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
<div className='space-y-2'>
{/* Seats info and usage - matching team usage layout */}
{/* Top row - matching UsageHeader */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-sm'>Seats</span>
{!checkEnterprisePlan(subscriptionData) ? (
<span className='text-muted-foreground text-xs'>
(${env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT}/month each)
</span>
) : null}
<span className='font-medium text-[#FFFFFF] text-[12px]'>Seats</span>
{!isEnterprise && (
<Badge
className='gradient-text h-[1.125rem] cursor-pointer rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs'
onClick={onAddSeatDialog}
>
Add Seats
</Badge>
)}
</div>
<div className='flex items-center gap-1 text-xs tabular-nums'>
<span className='text-muted-foreground'>{usedSeats} used</span>
<span className='text-muted-foreground'>/</span>
<span className='text-muted-foreground'>{subscriptionData.seats || 0} total</span>
<div className='flex items-center gap-[4px] text-xs tabular-nums'>
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
{usedSeats} used
</span>
<span className='font-medium text-[#B1B1B1] text-[12px]'>/</span>
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
{totalSeats} total
</span>
</div>
</div>
{/* Progress Bar - matching team usage component */}
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-2' />
{/* Pills row - one pill per seat */}
<div className='flex items-center gap-[4px]'>
{Array.from({ length: totalSeats }).map((_, i) => {
const isFilled = i < usedSeats
return (
<div
key={i}
className={cn(
'h-[6px] flex-1 rounded-full transition-colors',
isFilled ? 'bg-[#4285F4]' : 'bg-[#2C2C2C]'
)}
/>
)
})}
</div>
{/* Action buttons - below the usage display */}
{checkEnterprisePlan(subscriptionData) ? (
<div className='text-center'>
{/* Enterprise message */}
{isEnterprise && (
<div className='pt-1 text-center'>
<p className='text-muted-foreground text-xs'>
Contact support for enterprise usage limit changes
</p>
</div>
) : (
<div className='flex gap-2 pt-1'>
<Button
variant='outline'
size='sm'
onClick={onReduceSeats}
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
className='h-8 flex-1 rounded-[8px]'
>
Remove Seat
</Button>
<Button
size='sm'
onClick={onAddSeatDialog}
disabled={isLoading}
className='h-8 flex-1 rounded-[8px]'
>
Add Seat
</Button>
</div>
)}
</div>
</div>

View File

@@ -147,7 +147,7 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
onLimitUpdated={handleLimitUpdated}
/>
) : (
<span className='text-muted-foreground text-xs tabular-nums'>
<span className='font-medium text-[#B1B1B1] text-[12px] tabular-nums'>
${currentCap.toFixed(0)}
</span>
)

View File

@@ -12,7 +12,6 @@ import {
TeamMembers,
TeamSeats,
TeamSeatsOverview,
TeamUsage,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components'
import { generateSlug, useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -275,112 +274,149 @@ export function TeamManagement() {
}
return (
<div className='flex h-full flex-col px-6 pt-4 pb-4'>
<div className='flex flex-1 flex-col gap-6 overflow-y-auto'>
<div className='flex h-full flex-col'>
<div className='flex flex-1 flex-col overflow-y-auto px-6 pt-4 pb-4'>
{error && (
<Alert variant='destructive' className='rounded-[8px]'>
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Team Usage Overview */}
<TeamUsage hasAdminAccess={adminOrOwner} />
{/* Seats Overview - Full Width */}
{adminOrOwner && (
<div className='mb-4'>
<TeamSeatsOverview
subscriptionData={subscriptionData}
isLoadingSubscription={isLoadingSubscription}
usedSeats={usedSeats.used}
isLoading={isLoading}
onConfirmTeamUpgrade={confirmTeamUpgrade}
onReduceSeats={handleReduceSeats}
onAddSeatDialog={handleAddSeatDialog}
/>
</div>
)}
{/* Team Billing Information (only show for Team Plan, not Enterprise) */}
{hasTeamPlan && !hasEnterprisePlan && (
<div className='rounded-[8px] border bg-blue-50/50 p-4 shadow-xs dark:bg-blue-950/20'>
<div className='space-y-3'>
<h4 className='font-medium text-sm'>How Team Billing Works</h4>
<ul className='ml-4 list-disc space-y-2 text-muted-foreground text-xs'>
<li>
Your team is billed a minimum of $
{(subscriptionData?.seats || 0) *
(env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)}
/month for {subscriptionData?.seats || 0} licensed seats
</li>
<li>All team member usage is pooled together from a shared limit</li>
<li>
When pooled usage exceeds the limit, all members are blocked from using the
service
</li>
<li>You can increase the usage limit to allow for higher usage</li>
<li>
Any usage beyond the minimum seat cost is billed as overage at the end of the
billing period
</li>
</ul>
{/* Main Content: Team Members */}
<div className='mb-4'>
<TeamMembers
organization={activeOrganization}
currentUserEmail={session?.user?.email ?? ''}
isAdminOrOwner={adminOrOwner}
onRemoveMember={handleRemoveMember}
onCancelInvitation={cancelInvitation}
/>
</div>
{/* Action: Invite New Members */}
{adminOrOwner && (
<div className='mb-4'>
<MemberInvitationCard
inviteEmail={inviteEmail}
setInviteEmail={setInviteEmail}
isInviting={isInviting}
showWorkspaceInvite={showWorkspaceInvite}
setShowWorkspaceInvite={setShowWorkspaceInvite}
selectedWorkspaces={selectedWorkspaces}
userWorkspaces={userWorkspaces}
onInviteMember={handleInviteMember}
onLoadUserWorkspaces={() => loadUserWorkspaces(session?.user?.id)}
onWorkspaceToggle={handleWorkspaceToggle}
inviteSuccess={inviteSuccess}
availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)}
maxSeats={subscriptionData?.seats || 0}
/>
</div>
)}
{/* Additional Info - Subtle and collapsed */}
<div className='space-y-3'>
{/* Single Organization Notice */}
{adminOrOwner && (
<div className='rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)] p-3'>
<p className='text-muted-foreground text-xs'>
<span className='font-medium'>Note:</span> Users can only be part of one
organization at a time.
</p>
</div>
</div>
)}
)}
{/* Team Seats Overview */}
{adminOrOwner && (
<TeamSeatsOverview
subscriptionData={subscriptionData}
isLoadingSubscription={isLoadingSubscription}
usedSeats={usedSeats.used}
isLoading={isLoading}
onConfirmTeamUpgrade={confirmTeamUpgrade}
onReduceSeats={handleReduceSeats}
onAddSeatDialog={handleAddSeatDialog}
/>
)}
{/* Team Information */}
<details className='group rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)]'>
<summary className='flex cursor-pointer items-center justify-between p-3 font-medium text-sm hover:bg-[var(--surface-4)]'>
<span>Team Information</span>
<svg
className='h-4 w-4 transition-transform group-open:rotate-180'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M19 9l-7 7-7-7'
/>
</svg>
</summary>
<div className='space-y-2 border-[var(--border-muted)] border-t p-3 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Team ID:</span>
<span className='font-mono text-[10px]'>{activeOrganization.id}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Created:</span>
<span>{new Date(activeOrganization.createdAt).toLocaleDateString()}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Your Role:</span>
<span className='font-medium capitalize'>{userRole}</span>
</div>
</div>
</details>
{/* Team Members */}
<TeamMembers
organization={activeOrganization}
currentUserEmail={session?.user?.email ?? ''}
isAdminOrOwner={adminOrOwner}
onRemoveMember={handleRemoveMember}
onCancelInvitation={cancelInvitation}
/>
{/* Single Organization Notice */}
{adminOrOwner && (
<div className='mt-4 rounded-lg bg-muted/50 p-3'>
<p className='text-muted-foreground text-xs'>
<span className='font-medium'>Note:</span> Users can only be part of one organization
at a time. They must leave their current organization before joining another.
</p>
</div>
)}
{/* Member Invitation Card */}
{adminOrOwner && (
<MemberInvitationCard
inviteEmail={inviteEmail}
setInviteEmail={setInviteEmail}
isInviting={isInviting}
showWorkspaceInvite={showWorkspaceInvite}
setShowWorkspaceInvite={setShowWorkspaceInvite}
selectedWorkspaces={selectedWorkspaces}
userWorkspaces={userWorkspaces}
onInviteMember={handleInviteMember}
onLoadUserWorkspaces={() => loadUserWorkspaces(session?.user?.id)}
onWorkspaceToggle={handleWorkspaceToggle}
inviteSuccess={inviteSuccess}
availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)}
maxSeats={subscriptionData?.seats || 0}
/>
)}
</div>
{/* Team Information Section - pinned to bottom of modal */}
<div className='mt-6 flex-shrink-0 border-t pt-6'>
<div className='space-y-3 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Team ID:</span>
<span className='font-mono'>{activeOrganization.id}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Created:</span>
<span>{new Date(activeOrganization.createdAt).toLocaleDateString()}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Your Role:</span>
<span className='font-medium capitalize'>{userRole}</span>
</div>
{/* Team Billing Information (only show for Team Plan, not Enterprise) */}
{hasTeamPlan && !hasEnterprisePlan && (
<details className='group rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)]'>
<summary className='flex cursor-pointer items-center justify-between p-3 font-medium text-sm hover:bg-[var(--surface-4)]'>
<span>Billing Information</span>
<svg
className='h-4 w-4 transition-transform group-open:rotate-180'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M19 9l-7 7-7-7'
/>
</svg>
</summary>
<div className='border-[var(--border-muted)] border-t p-3'>
<ul className='ml-4 list-disc space-y-2 text-muted-foreground text-xs'>
<li>
Your team is billed a minimum of $
{(subscriptionData?.seats || 0) *
(env.TEAM_TIER_COST_LIMIT ?? DEFAULT_TEAM_TIER_COST_LIMIT)}
/month for {subscriptionData?.seats || 0} licensed seats
</li>
<li>All team member usage is pooled together from a shared limit</li>
<li>
When pooled usage exceeds the limit, all members are blocked from using the
service
</li>
<li>You can increase the usage limit to allow for higher usage</li>
<li>
Any usage beyond the minimum seat cost is billed as overage at the end of the
billing period
</li>
</ul>
</div>
</details>
)}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { generateWorkspaceName } from '@/lib/naming'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -32,6 +32,7 @@ export function useWorkspaceManagement({
sessionUserId,
}: UseWorkspaceManagementProps) {
const router = useRouter()
const pathname = usePathname()
const { switchToWorkspace } = useWorkflowRegistry()
// Workspace management state
@@ -45,12 +46,14 @@ export function useWorkspaceManagement({
// Refs to avoid dependency issues
const workspaceIdRef = useRef<string>(workspaceId)
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
const pathnameRef = useRef<string | null>(pathname || null)
const activeWorkspaceRef = useRef<Workspace | null>(null)
const isInitializedRef = useRef<boolean>(false)
// Update refs when values change
workspaceIdRef.current = workspaceId
routerRef.current = router
pathnameRef.current = pathname || null
activeWorkspaceRef.current = activeWorkspace
/**
@@ -189,7 +192,17 @@ export function useWorkspaceManagement({
try {
// Switch workspace and update URL
await switchToWorkspace(workspace.id)
routerRef.current?.push(`/workspace/${workspace.id}/w`)
const currentPath = pathnameRef.current || ''
// Preserve templates route if user is on templates or template detail
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
if (templateDetailMatch) {
const templateId = templateDetailMatch[1]
routerRef.current?.push(`/workspace/${workspace.id}/templates/${templateId}`)
} else if (/^\/workspace\/[^/]+\/templates$/.test(currentPath)) {
routerRef.current?.push(`/workspace/${workspace.id}/templates`)
} else {
routerRef.current?.push(`/workspace/${workspace.id}/w`)
}
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
} catch (error) {
logger.error('Error switching workspace:', error)

View File

@@ -347,13 +347,22 @@ export function Sidebar() {
try {
// Switch workspace and update URL
await switchToWorkspace(workspace.id)
routerRef.current?.push(`/workspace/${workspace.id}/w`)
const currentPath = pathname || ''
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
if (templateDetailMatch) {
const templateId = templateDetailMatch[1]
routerRef.current?.push(`/workspace/${workspace.id}/templates/${templateId}`)
} else if (/^\/workspace\/[^/]+\/templates$/.test(currentPath)) {
routerRef.current?.push(`/workspace/${workspace.id}/templates`)
} else {
routerRef.current?.push(`/workspace/${workspace.id}/w`)
}
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
} catch (error) {
logger.error('Error switching workspace:', error)
}
},
[switchToWorkspace] // Removed activeWorkspace and router dependencies
[switchToWorkspace, pathname] // include pathname
)
/**

View File

@@ -439,7 +439,26 @@ const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
}
}}
>
<PopoverScrollArea className='max-h-48 p-[4px]'>
<PopoverScrollArea
className='!flex-none max-h-48 p-[4px]'
onWheelCapture={(e) => {
// Ensure wheel events are captured and don't get blocked by parent handlers
const target = e.currentTarget
const { scrollTop, scrollHeight, clientHeight } = target
const delta = e.deltaY
const isScrollingDown = delta > 0
const isScrollingUp = delta < 0
// Check if we're at scroll boundaries
const isAtTop = scrollTop === 0
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1
// Only stop propagation if we can scroll in the requested direction
if ((isScrollingDown && !isAtBottom) || (isScrollingUp && !isAtTop)) {
e.stopPropagation()
}
}}
>
<div ref={dropdownRef} role='listbox'>
{isLoading ? (
<div className='flex items-center justify-center py-[14px]'>

View File

@@ -278,7 +278,7 @@ const PopoverContent = React.forwardRef<
sticky='partial'
{...restProps}
className={cn(
'z-[9999999] flex flex-col overflow-hidden rounded-[8px] bg-[var(--surface-3)] px-[5.5px] py-[5px] text-foreground outline-none dark:bg-[var(--surface-3)]',
'z-[10000001] flex flex-col overflow-hidden rounded-[8px] bg-[var(--surface-3)] px-[5.5px] py-[5px] text-foreground outline-none dark:bg-[var(--surface-3)]',
// If width is constrained by the caller, ensure inner flexible text truncates by default.
hasUserWidthConstraint && '[&_.flex-1]:truncate',
className

View File

@@ -29,7 +29,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[10000000] bg-white/50 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/50',
className
)}
style={{ backdropFilter: 'blur(1.5px)', ...style }}
@@ -70,7 +70,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-3)] p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-[var(--surface-3)]',
'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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[10000000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[8px] border border-[var(--border-muted)] bg-[var(--surface-3)] p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-[var(--surface-3)]',
className
)}
onInteractOutside={(e) => {

View File

@@ -0,0 +1,66 @@
'use client'
import { type CSSProperties, useEffect, useState } from 'react'
import Image from 'next/image'
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
interface UserAvatarProps {
userId: string
userName?: string | null
avatarUrl?: string | null
size?: number
className?: string
}
/**
* Reusable user avatar component with error handling for image loading.
* Falls back to colored circle with initials if image fails to load or is not available.
*/
export function UserAvatar({
userId,
userName,
avatarUrl,
size = 32,
className = '',
}: UserAvatarProps) {
const [imageError, setImageError] = useState(false)
const color = getUserColor(userId)
const initials = userName ? userName.charAt(0).toUpperCase() : '?'
const hasAvatar = Boolean(avatarUrl) && !imageError
// Reset error state when avatar URL changes
useEffect(() => {
setImageError(false)
}, [avatarUrl])
const fontSize = Math.max(10, size / 2.5)
return (
<div
className={`relative flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold text-white ${className}`}
style={
{
background: hasAvatar ? undefined : color,
width: `${size}px`,
height: `${size}px`,
fontSize: `${fontSize}px`,
} as CSSProperties
}
>
{hasAvatar && avatarUrl ? (
<Image
src={avatarUrl}
alt={userName ? `${userName}'s avatar` : 'User avatar'}
fill
sizes={`${size}px`}
className='object-cover'
referrerPolicy='no-referrer'
unoptimized={avatarUrl.startsWith('http')}
onError={() => setImageError(true)}
/>
) : (
initials
)}
</div>
)
}

View File

@@ -157,6 +157,11 @@ export async function middleware(request: NextRequest) {
}
if (url.pathname.startsWith('/workspace')) {
// Allow public access to workspace template pages - they handle their own redirects
if (url.pathname.match(/^\/workspace\/[^/]+\/templates/)) {
return NextResponse.next()
}
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))
}

View File

@@ -2,6 +2,7 @@ export interface User {
name?: string
email?: string
id?: string
image?: string | null
}
export interface Member {