improvement(resources): layout and items

This commit is contained in:
Emir Karabeg
2026-03-07 18:01:17 -08:00
parent 05b8481a89
commit 13d49da8bd
19 changed files with 828 additions and 593 deletions

View File

@@ -234,6 +234,7 @@ export async function GET(request: NextRequest) {
},
rowCount: t.rowCount,
maxRows: t.maxRows,
createdBy: t.createdBy,
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:

View File

@@ -0,0 +1,39 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import {
getUserEntityPermissions,
getWorkspaceMemberProfiles,
} from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceMembersAPI')
/**
* GET /api/workspaces/[id]/members
*
* Returns lightweight member profiles (id, name, image) for a workspace.
* Intended for UI display (avatars, owner cells) without the overhead of
* full permission data.
*/
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id: workspaceId } = await params
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
if (permission === null) {
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
}
const members = await getWorkspaceMemberProfiles(workspaceId)
return NextResponse.json({ members })
} catch (error) {
logger.error('Error fetching workspace members:', error)
return NextResponse.json({ error: 'Failed to fetch workspace members' }, { status: 500 })
}
}

View File

@@ -1,2 +1,4 @@
export { ownerCell } from './resource/components/owner-cell/owner-cell'
export { timeCell } from './resource/components/time-cell/time-cell'
export type { ResourceCell, ResourceColumn, ResourceRow } from './resource/resource'
export { Resource } from './resource/resource'

View File

@@ -0,0 +1,2 @@
export * from './owner-cell'
export * from './time-cell'

View File

@@ -0,0 +1 @@
export { ownerCell } from './owner-cell'

View File

@@ -0,0 +1,43 @@
'use client'
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
import type { WorkspaceMember } from '@/hooks/queries/workspace'
function OwnerAvatar({ name, image }: { name: string; image: string | null }) {
if (image) {
return (
<img
src={image}
alt={name}
referrerPolicy='no-referrer'
className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
/>
)
}
return (
<span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{name.charAt(0).toUpperCase()}
</span>
)
}
/**
* Resolves a user ID into a ResourceCell with an avatar icon and display name.
* Returns null label while members are still loading to avoid flashing raw IDs.
*/
export function ownerCell(
userId: string | null | undefined,
members?: WorkspaceMember[]
): ResourceCell {
if (!userId) return { label: null }
if (!members) return { label: null }
const member = members.find((m) => m.userId === userId)
if (!member) return { label: null }
return {
icon: <OwnerAvatar name={member.name} image={member.image} />,
label: member.name,
}
}

View File

@@ -0,0 +1 @@
export { timeCell } from './time-cell'

View File

@@ -0,0 +1,81 @@
import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource'
const SECOND = 1000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
const ORDINAL_RULES: [number, string][] = [
[1, 'st'],
[2, 'nd'],
[3, 'rd'],
]
function ordinalSuffix(day: number): string {
if (day >= 11 && day <= 13) return 'th'
return ORDINAL_RULES.find(([d]) => day % 10 === d)?.[1] ?? 'th'
}
const MONTH_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const
function formatFullDate(date: Date): string {
const month = MONTH_NAMES[date.getMonth()]
const day = date.getDate()
const year = date.getFullYear()
return `${month} ${day}${ordinalSuffix(day)}, ${year}`
}
function pluralize(value: number, unit: string): string {
return `${value} ${unit}${value === 1 ? '' : 's'}`
}
/**
* Formats a date string into a human-friendly relative time label.
*
* - Within ~1 minute of now: "Now"
* - Under 1 hour: "X minute(s) ago" / "X minute(s)"
* - Under 24 hours: "X hour(s) ago" / "X hour(s)"
* - Under 2 days: "X day(s) ago" / "X day(s)"
* - Beyond 2 days: full date (e.g. "March 4th, 2026")
*/
export function timeCell(dateValue: string | Date | null | undefined): ResourceCell {
if (!dateValue) return { label: null }
const date = dateValue instanceof Date ? dateValue : new Date(dateValue)
const now = new Date()
const diff = now.getTime() - date.getTime()
const absDiff = Math.abs(diff)
const isPast = diff > 0
if (absDiff < MINUTE) return { label: 'Now' }
if (absDiff < HOUR) {
const minutes = Math.floor(absDiff / MINUTE)
return { label: isPast ? `${pluralize(minutes, 'minute')} ago` : pluralize(minutes, 'minute') }
}
if (absDiff < DAY) {
const hours = Math.floor(absDiff / HOUR)
return { label: isPast ? `${pluralize(hours, 'hour')} ago` : pluralize(hours, 'hour') }
}
if (absDiff < 2 * DAY) {
const days = Math.floor(absDiff / DAY)
return { label: isPast ? `${pluralize(days, 'day')} ago` : pluralize(days, 'day') }
}
return { label: formatFullDate(date) }
}

View File

@@ -2,16 +2,7 @@
import type { ReactNode } from 'react'
import { ArrowUpDown, ListFilter, Plus, Search } from 'lucide-react'
import {
Button,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import { Button, Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
export interface ResourceColumn {
@@ -21,7 +12,7 @@ export interface ResourceColumn {
export interface ResourceCell {
icon?: ReactNode
label: string
label?: string | null
}
export interface ResourceRow {
@@ -54,6 +45,8 @@ interface ResourceProps {
onContextMenu?: (e: React.MouseEvent) => void
}
const EMPTY_CELL_PLACEHOLDER = '- - -'
/**
* Shared page shell for resource list pages (tables, files, knowledge, schedules).
* Renders the header, toolbar with search, and a data table from column/row definitions.
@@ -76,143 +69,136 @@ export function Resource({
}: ResourceProps) {
const hasOptionsBar = search || onSort || onFilter
return (
<div className='flex h-full flex-1 flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div
className='flex flex-1 flex-col overflow-auto bg-white dark:bg-[var(--bg)]'
onContextMenu={onContextMenu}
>
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
<h1 className='font-medium text-[14px] text-[var(--text-body)]'>{title}</h1>
</div>
{create && (
<Button
onClick={create.onClick}
disabled={create.disabled}
variant='subtle'
className='px-[8px] py-[4px] text-[12px]'
>
<Plus className='mr-[6px] h-[14px] w-[14px]' />
{create.label}
</Button>
)}
</div>
<div
className='flex h-full flex-1 flex-col overflow-hidden bg-white dark:bg-[var(--bg)]'
onContextMenu={onContextMenu}
>
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-[12px]'>
<Icon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
<h1 className='font-medium text-[14px] text-[var(--text-body)]'>{title}</h1>
</div>
{hasOptionsBar && (
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center justify-between'>
{search && (
<div className='relative flex-1'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-0 h-[14px] w-[14px] text-[var(--text-muted)]' />
<input
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
placeholder={search.placeholder ?? 'Search...'}
className='w-full bg-transparent py-[4px] pl-[24px] font-base text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
)}
<div className='flex items-center gap-[6px]'>
{onFilter && (
<Button
variant='subtle'
className='px-[8px] py-[4px] text-[12px]'
onClick={onFilter}
>
<ListFilter className='mr-[6px] h-[14px] w-[14px]' />
Filter
</Button>
)}
{onSort && (
<Button
variant='subtle'
className='px-[8px] py-[4px] text-[12px]'
onClick={onSort}
>
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px]' />
Sort
</Button>
)}
{toolbarActions}
</div>
</div>
</div>
{create && (
<Button
onClick={create.onClick}
disabled={create.disabled}
variant='subtle'
className='px-[8px] py-[4px] text-[12px]'
>
<Plus className='mr-[6px] h-[14px] w-[14px]' />
{create.label}
</Button>
)}
<div className='flex min-h-0 flex-1 flex-col'>
{isLoading ? (
<DataTableSkeleton columns={columns} rowCount={loadingRows} />
) : (
<Table className='table-fixed text-[13px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
{columns.map((col, colIdx) => (
<TableHead
key={col.id}
className={cn(
colIdx === 0 ? 'min-w-[400px]' : 'w-[160px]',
'px-[24px] py-[10px] font-base text-[var(--text-muted)]'
)}
>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow
key={row.id}
data-resource-row
className={cn(
onRowClick && 'cursor-pointer',
'border-b-0 bg-[var(--surface-2)] hover:bg-[var(--surface-3)]'
)}
onClick={() => onRowClick?.(row.id)}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
if (!cell) {
return <TableCell key={col.id} className='px-[24px] py-[10px]' />
}
return (
<TableCell key={col.id} className='px-[24px] py-[10px]'>
<CellContent cell={cell} primary={colIdx === 0} />
</TableCell>
)
})}
</TableRow>
))}
{create && (
<TableRow
className={cn(
'border-b-0',
create.disabled
? 'opacity-40'
: 'cursor-pointer hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<TableCell colSpan={columns.length} className='px-[24px] py-[10px]'>
<span className='flex items-center gap-[12px] font-medium text-[14px] text-[var(--text-secondary)]'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
</span>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
{hasOptionsBar && (
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
<div className='flex items-center justify-between'>
{search && (
<div className='relative flex-1'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-0 h-[14px] w-[14px] text-[var(--text-muted)]' />
<input
type='text'
value={search.value}
onChange={(e) => search.onChange(e.target.value)}
placeholder={search.placeholder ?? 'Search...'}
className='w-full bg-transparent py-[4px] pl-[24px] font-base text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
/>
</div>
)}
<div className='flex items-center gap-[6px]'>
{onFilter && (
<Button
variant='subtle'
className='px-[8px] py-[4px] text-[12px]'
onClick={onFilter}
>
<ListFilter className='mr-[6px] h-[14px] w-[14px]' />
Filter
</Button>
)}
{onSort && (
<Button variant='subtle' className='px-[8px] py-[4px] text-[12px]' onClick={onSort}>
<ArrowUpDown className='mr-[6px] h-[14px] w-[14px]' />
Sort
</Button>
)}
{toolbarActions}
</div>
</div>
</div>
)}
{isLoading ? (
<DataTableSkeleton columns={columns} rowCount={loadingRows} />
) : (
<>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{columns.map((col) => (
<th
key={col.id}
className='h-10 px-[24px] py-[10px] text-left align-middle font-base text-[var(--text-muted)]'
>
{col.header}
</th>
))}
</tr>
</thead>
</table>
<div className='min-h-0 flex-1 overflow-auto'>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<tbody>
{rows.map((row) => (
<tr
key={row.id}
data-resource-row
className={cn(
'transition-colors hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer'
)}
onClick={() => onRowClick?.(row.id)}
onContextMenu={(e) => onRowContextMenu?.(e, row.id)}
>
{columns.map((col, colIdx) => {
const cell = row.cells[col.id]
return (
<td key={col.id} className='px-[24px] py-[10px] align-middle'>
<CellContent
cell={{ ...cell, label: cell?.label || EMPTY_CELL_PLACEHOLDER }}
primary={colIdx === 0}
/>
</td>
)
})}
</tr>
))}
{create && (
<tr
className={cn(
'transition-colors',
create.disabled ? 'opacity-40' : 'cursor-pointer hover:bg-[var(--surface-3)]'
)}
onClick={create.disabled ? undefined : create.onClick}
>
<td colSpan={columns.length} className='px-[24px] py-[10px] align-middle'>
<span className='flex items-center gap-[12px] font-medium text-[14px] text-[var(--text-secondary)]'>
<Plus className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
{create.label}
</span>
</td>
</tr>
)}
</tbody>
</table>
</div>
</>
)}
</div>
)
}
@@ -231,40 +217,55 @@ function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean
)
}
function DataTableSkeleton({ columns, rowCount }: { columns: ResourceColumn[]; rowCount: number }) {
function ResourceColGroup({ columns }: { columns: ResourceColumn[] }) {
return (
<Table className='table-fixed text-[13px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
{columns.map((col, colIdx) => (
<TableHead
key={col.id}
className={cn(
colIdx === 0 ? 'min-w-[400px]' : 'w-[160px]',
'px-[24px] py-[10px] font-base text-[var(--text-muted)]'
)}
>
<div className='flex min-h-[20px] items-center'>
<Skeleton className='h-[12px] w-[56px]' />
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rowCount }, (_, i) => (
<TableRow key={i} className='border-b-0 hover:bg-transparent'>
{columns.map((col, colIdx) => (
<TableCell key={col.id} className='px-[24px] py-[10px]'>
<span className='flex min-h-[21px] items-center gap-[12px]'>
{colIdx === 0 && <Skeleton className='h-[14px] w-[14px] rounded-[2px]' />}
<Skeleton className='h-[14px] w-[128px]' />
</span>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<colgroup>
{columns.map((col, colIdx) => (
<col key={col.id} className={colIdx === 0 ? undefined : 'w-[160px]'} />
))}
</colgroup>
)
}
function DataTableSkeleton({ columns, rowCount }: { columns: ResourceColumn[]; rowCount: number }) {
return (
<>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<thead className='shadow-[inset_0_-1px_0_var(--border)]'>
<tr>
{columns.map((col) => (
<th
key={col.id}
className='h-10 px-[24px] py-[10px] text-left align-middle font-base text-[var(--text-muted)]'
>
<div className='flex min-h-[20px] items-center'>
<Skeleton className='h-[12px] w-[56px]' />
</div>
</th>
))}
</tr>
</thead>
</table>
<div className='min-h-0 flex-1 overflow-auto'>
<table className='w-full table-fixed text-[13px]'>
<ResourceColGroup columns={columns} />
<tbody>
{Array.from({ length: rowCount }, (_, i) => (
<tr key={i}>
{columns.map((col, colIdx) => (
<td key={col.id} className='px-[24px] py-[10px] align-middle'>
<span className='flex min-h-[21px] items-center gap-[12px]'>
{colIdx === 0 && <Skeleton className='h-[14px] w-[14px] rounded-[2px]' />}
<Skeleton className='h-[14px] w-[128px]' />
</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
)
}

View File

@@ -4,12 +4,12 @@ import { useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Files as FilesIcon } from 'lucide-react'
import { useParams } from 'next/navigation'
import { MoreHorizontal } from '@/components/emcn/icons'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import { Resource } from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useUploadWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
const logger = createLogger('Files')
@@ -50,8 +50,10 @@ const ACCEPT_ATTR =
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'size', header: 'Size' },
{ id: 'uploaded', header: 'Uploaded' },
{ id: 'actions', header: 'Actions' },
{ id: 'type', header: 'Type' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
function formatFileSize(bytes: number): string {
@@ -60,12 +62,34 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(date: Date | string): string {
const d = new Date(date)
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const yy = String(d.getFullYear()).slice(2)
return `${mm}/${dd}/${yy}`
const MIME_TYPE_LABELS: Record<string, string> = {
'application/pdf': 'PDF',
'application/msword': 'Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word',
'application/vnd.ms-excel': 'Excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
'application/vnd.ms-powerpoint': 'PowerPoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint',
'application/json': 'JSON',
'application/x-yaml': 'YAML',
'text/csv': 'CSV',
'text/plain': 'Text',
'text/html': 'HTML',
'text/markdown': 'Markdown',
}
function formatFileType(mimeType: string | null, filename: string): string {
if (mimeType && MIME_TYPE_LABELS[mimeType]) {
return MIME_TYPE_LABELS[mimeType]
}
if (mimeType?.startsWith('audio/')) return 'Audio'
if (mimeType?.startsWith('video/')) return 'Video'
const ext = filename.split('.').pop()?.toLowerCase()
if (ext) return ext.toUpperCase()
return mimeType ?? 'File'
}
export function Files() {
@@ -74,6 +98,7 @@ export function Files() {
const userPermissions = useUserPermissionsContext()
const { data: files = [], isLoading, error } = useWorkspaceFiles(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const uploadFile = useUploadWorkspaceFile()
if (error) {
@@ -105,17 +130,17 @@ export function Files() {
size: {
label: formatFileSize(file.size),
},
uploaded: {
label: formatDate(file.uploadedAt),
},
actions: {
icon: <MoreHorizontal className='h-[14px] w-[14px]' />,
label: '',
type: {
icon: <Icon className='h-[14px] w-[14px]' />,
label: formatFileType(file.type, file.name),
},
created: timeCell(file.uploadedAt),
owner: ownerCell(file.uploadedBy, members),
updated: timeCell(file.uploadedAt),
},
}
}),
[filteredFiles]
[filteredFiles, members]
)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -181,6 +206,8 @@ export function Files() {
onChange: setSearchTerm,
placeholder: 'Search files...',
}}
onSort={() => {}}
onFilter={() => {}}
columns={COLUMNS}
rows={rows}
isLoading={isLoading}

View File

@@ -1,230 +1,166 @@
import type React from 'react'
import type { SVGProps } from 'react'
import {
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
} from '@/lib/uploads/utils/validation'
interface IconProps {
className?: string
export function PdfIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='4' y='2' width='16' height='20' rx='2' stroke='currentColor' strokeWidth='1.5' />
<text
x='12'
y='12'
textAnchor='middle'
dominantBaseline='central'
fontSize='5.5'
fontWeight='bold'
fontFamily='Arial, sans-serif'
letterSpacing='0.5'
fill='currentColor'
>
PDF
</text>
</svg>
)
}
export const PdfIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#E53935'
/>
<path d='M14 2V8H20' fill='#EF5350' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#C62828'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='7'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
>
PDF
</text>
</svg>
)
export const DocxIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#2196F3'
/>
<path d='M14 2V8H20' fill='#64B5F6' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#1565C0'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='8'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
>
W
</text>
</svg>
)
export const XlsxIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#4CAF50'
/>
<path d='M14 2V8H20' fill='#81C784' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#2E7D32'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='8'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
>
X
</text>
</svg>
)
export const CsvIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#4CAF50'
/>
<path d='M14 2V8H20' fill='#81C784' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#2E7D32'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='6.5'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
>
CSV
</text>
</svg>
)
export const TxtIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#757575'
/>
<path d='M14 2V8H20' fill='#9E9E9E' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='var(--border-muted)'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='6'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
>
TXT
</text>
</svg>
)
export const AudioIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#0288D1'
/>
<path d='M14 2V8H20' fill='#29B6F6' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#01579B'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
{/* Speaker icon */}
<path d='M8.5 10.5v3c0 .28.22.5.5.5h1.5l2 2V8l-2 2H9c-.28 0-.5.22-.5.5z' fill='white' />
{/* Sound waves */}
<path
d='M14 10.5c.6.6.6 1.4 0 2M15.5 9c1.2 1.2 1.2 3.8 0 5'
stroke='white'
strokeWidth='0.8'
strokeLinecap='round'
/>
</svg>
)
export const VideoIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#D32F2F'
/>
<path d='M14 2V8H20' fill='#EF5350' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#B71C1C'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
{/* Video screen */}
<rect
x='7.5'
y='9.5'
width='9'
height='6'
rx='0.5'
stroke='white'
strokeWidth='0.8'
export function DocxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
/>
{/* Play button */}
<path d='M10.5 11.5l3 2-3 2v-4z' fill='white' />
</svg>
)
export const DefaultFileIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#607D8B'
/>
<path d='M14 2V8H20' fill='#90A4AE' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#37474F'
strokeWidth='0.5'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<rect x='8' y='13' width='8' height='1' fill='white' rx='0.5' />
<rect x='8' y='15' width='8' height='1' fill='white' rx='0.5' />
<rect x='8' y='17' width='5' height='1' fill='white' rx='0.5' />
</svg>
)
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M16 9H8' />
<path d='M16 13H8' />
<path d='M16 17H8' />
</svg>
)
}
export function getDocumentIcon(mimeType: string, filename: string): React.FC<IconProps> {
export function XlsxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='3' y='3' width='18' height='18' rx='2' stroke='currentColor' strokeWidth='1.5' />
<line x1='3' y1='9' x2='21' y2='9' stroke='currentColor' strokeWidth='1.5' />
<line x1='3' y1='15' x2='21' y2='15' stroke='currentColor' strokeWidth='1.5' />
<line x1='9' y1='3' x2='9' y2='21' stroke='currentColor' strokeWidth='1.5' />
<line x1='15' y1='3' x2='15' y2='21' stroke='currentColor' strokeWidth='1.5' />
</svg>
)
}
export function CsvIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='3' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='3' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='3' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
</svg>
)
}
export function TxtIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M16 13H8' />
<path d='M12 17H8' />
</svg>
)
}
export function PptxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<rect x='2' y='4' width='20' height='16' rx='2' />
<line x1='6' y1='9' x2='18' y2='9' />
<line x1='8' y1='14' x2='16' y2='14' />
</svg>
)
}
export function AudioIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<line x1='4' y1='14' x2='4' y2='10' />
<line x1='8' y1='17' x2='8' y2='7' />
<line x1='12' y1='15' x2='12' y2='9' />
<line x1='16' y1='18' x2='16' y2='6' />
<line x1='20' y1='14' x2='20' y2='10' />
</svg>
)
}
export function VideoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='2' y='4' width='20' height='16' rx='2' stroke='currentColor' strokeWidth='1.5' />
<path d='M10 9l5 3-5 3V9Z' fill='currentColor' />
</svg>
)
}
export function DefaultFileIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
</svg>
)
}
export function getDocumentIcon(
mimeType: string,
filename: string
): (props: SVGProps<SVGSVGElement>) => React.JSX.Element {
const extension = filename.split('.').pop()?.toLowerCase()
if (
@@ -273,5 +209,14 @@ export function getDocumentIcon(mimeType: string, filename: string): React.FC<Ic
return TxtIcon
}
if (
mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
mimeType === 'application/vnd.ms-powerpoint' ||
extension === 'pptx' ||
extension === 'ppt'
) {
return PptxIcon
}
return DefaultFileIcon
}

View File

@@ -4,10 +4,9 @@ import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Database } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { formatRelativeTime } from '@/lib/core/utils/formatting'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import { Resource } from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
CreateBaseModal,
@@ -21,6 +20,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
const logger = createLogger('Knowledge')
@@ -32,9 +32,10 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'documents', header: 'Documents' },
{ id: 'description', header: 'Description' },
{ id: 'updated', header: 'Updated' },
{ id: 'id', header: 'ID' },
{ id: 'tokens', header: 'Tokens' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
export function Knowledge() {
@@ -43,6 +44,7 @@ export function Knowledge() {
const workspaceId = params.workspaceId as string
const { knowledgeBases, isLoading, error } = useKnowledgeBasesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
if (error) {
logger.error('Failed to load knowledge bases:', error)
@@ -140,19 +142,16 @@ export function Knowledge() {
documents: {
label: String(kbWithCount.docCount || 0),
},
description: {
label: kb.description || 'No description',
},
updated: {
label: kb.updatedAt ? formatRelativeTime(kb.updatedAt) : '',
},
id: {
label: `kb-${kb.id.slice(0, 8)}`,
tokens: {
label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0',
},
created: timeCell(kb.createdAt),
owner: ownerCell(kb.userId, members),
updated: timeCell(kb.updatedAt),
},
}
}),
[filteredKnowledgeBases]
[filteredKnowledgeBases, members]
)
const handleRowClick = useCallback(

View File

@@ -3,11 +3,11 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { Calendar, MoreHorizontal } from '@/components/emcn/icons'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import { Calendar } from '@/components/emcn/icons'
import { formatAbsoluteDate } from '@/lib/core/utils/formatting'
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import { Resource } from '@/app/workspace/[workspaceId]/components'
import { Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import type { WorkspaceScheduleData } from '@/hooks/queries/schedules'
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
import { useDebounce } from '@/hooks/use-debounce'
@@ -22,11 +22,11 @@ function getHumanReadable(s: WorkspaceScheduleData) {
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'type', header: 'Type' },
{ id: 'schedule', header: 'Schedule' },
{ id: 'status', header: 'Status' },
{ id: 'nextRun', header: 'Next Run' },
{ id: 'actions', header: 'Actions' },
{ id: 'lastRun', header: 'Last Run' },
{ id: 'schedule', header: 'Schedule' },
{ id: 'from', header: 'From' },
{ id: 'lifecycle', header: 'Lifecycle' },
]
export function Schedules() {
@@ -64,7 +64,7 @@ export function Schedules() {
() =>
filteredItems.map((item) => {
const isJob = item.sourceType === 'job'
const name = isJob ? item.jobTitle || item.sourceTaskName || '—' : item.workflowName || '—'
const name = isJob ? item.jobTitle || item.sourceTaskName : item.workflowName
return {
id: item.id,
@@ -73,14 +73,11 @@ export function Schedules() {
icon: <Calendar className='h-[14px] w-[14px]' />,
label: name,
},
type: { label: isJob ? 'Scheduled Task' : 'Workflow' },
nextRun: timeCell(item.nextRunAt),
lastRun: timeCell(item.lastRanAt),
schedule: { label: getHumanReadable(item) },
status: { label: item.status },
nextRun: { label: item.nextRunAt ? formatRelativeTime(item.nextRunAt) : '—' },
actions: {
icon: <MoreHorizontal className='h-[14px] w-[14px]' />,
label: '',
},
from: { label: isJob ? item.prompt : item.workflowName },
lifecycle: { label: item.cronExpression ? 'Recurring' : 'One-time' },
},
}
}),

View File

@@ -8,14 +8,14 @@ import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from
import { Table as TableIcon } from '@/components/emcn/icons'
import type { TableDefinition } from '@/lib/table'
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
import { Resource } from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { SchemaModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
import { CreateModal, TablesListContextMenu } from '@/app/workspace/[workspaceId]/tables/components'
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
import { formatRelativeTime } from '@/app/workspace/[workspaceId]/tables/utils'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { useDeleteTable, useTablesList } from '@/hooks/queries/tables'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
const logger = createLogger('Tables')
@@ -23,8 +23,9 @@ const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
{ id: 'rows', header: 'Rows' },
{ id: 'updated', header: 'Updated' },
{ id: 'id', header: 'ID' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
export function Tables() {
@@ -34,6 +35,7 @@ export function Tables() {
const userPermissions = useUserPermissionsContext()
const { data: tables = [], isLoading, error } = useTablesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
if (error) {
logger.error('Failed to load tables:', error)
@@ -85,15 +87,12 @@ export function Tables() {
icon: <Rows3 className='h-[14px] w-[14px]' />,
label: String(table.rowCount),
},
updated: {
label: formatRelativeTime(table.updatedAt),
},
id: {
label: `tb-${table.id.slice(0, 8)}`,
},
created: timeCell(table.createdAt),
owner: ownerCell(table.createdBy, members),
updated: timeCell(table.updatedAt),
},
})),
[filteredTables]
[filteredTables, members]
)
const handleSort = useCallback(() => {}, [])

View File

@@ -1,171 +1,188 @@
import type React from 'react'
import type { SVGProps } from 'react'
import {
SUPPORTED_AUDIO_EXTENSIONS,
SUPPORTED_VIDEO_EXTENSIONS,
} from '@/lib/uploads/utils/validation'
interface IconProps {
className?: string
export function PdfIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='4' y='2' width='16' height='20' rx='2' stroke='currentColor' strokeWidth='1.5' />
<text
x='12'
y='12'
textAnchor='middle'
dominantBaseline='central'
fontSize='5.5'
fontWeight='bold'
fontFamily='Arial, sans-serif'
letterSpacing='0.5'
fill='currentColor'
>
PDF
</text>
</svg>
)
}
export const PdfIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#E53935'
/>
<path d='M14 2V8H20' fill='#EF5350' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#C62828'
strokeWidth='0.5'
export function DocxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='7'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
PDF
</text>
</svg>
)
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M16 9H8' />
<path d='M16 13H8' />
<path d='M16 17H8' />
</svg>
)
}
export const DocxIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#2196F3'
/>
<path d='M14 2V8H20' fill='#64B5F6' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#1565C0'
strokeWidth='0.5'
export function XlsxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='3' y='3' width='18' height='18' rx='2' stroke='currentColor' strokeWidth='1.5' />
<line x1='3' y1='9' x2='21' y2='9' stroke='currentColor' strokeWidth='1.5' />
<line x1='3' y1='15' x2='21' y2='15' stroke='currentColor' strokeWidth='1.5' />
<line x1='9' y1='3' x2='9' y2='21' stroke='currentColor' strokeWidth='1.5' />
<line x1='15' y1='3' x2='15' y2='21' stroke='currentColor' strokeWidth='1.5' />
</svg>
)
}
export function CsvIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='3' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='1' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='3' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='9' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='3' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
<rect x='13' y='17' width='8' height='6' rx='1.5' stroke='currentColor' strokeWidth='1.5' />
</svg>
)
}
export function TxtIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='8'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
W
</text>
</svg>
)
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
<path d='M16 13H8' />
<path d='M12 17H8' />
</svg>
)
}
export const XlsxIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#4CAF50'
/>
<path d='M14 2V8H20' fill='#81C784' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#2E7D32'
strokeWidth='0.5'
export function PptxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='8'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
X
</text>
</svg>
)
<rect x='2' y='4' width='20' height='16' rx='2' />
<line x1='6' y1='9' x2='18' y2='9' />
<line x1='8' y1='14' x2='16' y2='14' />
</svg>
)
}
export const CsvIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#4CAF50'
/>
<path d='M14 2V8H20' fill='#81C784' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#2E7D32'
strokeWidth='0.5'
export function AudioIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<g transform='translate(0, -1)'>
<rect x='8' y='11' width='8' height='0.5' fill='white' />
<rect x='8' y='13' width='8' height='0.5' fill='white' />
<rect x='8' y='15' width='8' height='0.5' fill='white' />
<rect x='11.75' y='11' width='0.5' height='6' fill='white' />
</g>
</svg>
)
export const TxtIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#757575'
/>
<path d='M14 2V8H20' fill='#9E9E9E' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#424242'
strokeWidth='0.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<text
x='12'
y='16'
textAnchor='middle'
fontSize='6'
fontWeight='bold'
fill='white'
fontFamily='Arial, sans-serif'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
TXT
</text>
</svg>
)
<line x1='4' y1='14' x2='4' y2='10' />
<line x1='8' y1='17' x2='8' y2='7' />
<line x1='12' y1='15' x2='12' y2='9' />
<line x1='16' y1='18' x2='16' y2='6' />
<line x1='20' y1='14' x2='20' y2='10' />
</svg>
)
}
export const DefaultFileIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' className={className}>
<path
d='M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z'
fill='#607D8B'
/>
<path d='M14 2V8H20' fill='#90A4AE' />
<path
d='M14 2L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2H14Z'
stroke='#37474F'
strokeWidth='0.5'
export function VideoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect x='2' y='4' width='20' height='16' rx='2' stroke='currentColor' strokeWidth='1.5' />
<path d='M10 9l5 3-5 3V9Z' fill='currentColor' />
</svg>
)
}
export function DefaultFileIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<rect x='8' y='13' width='8' height='1' fill='white' rx='0.5' />
<rect x='8' y='15' width='8' height='1' fill='white' rx='0.5' />
<rect x='8' y='17' width='5' height='1' fill='white' rx='0.5' />
</svg>
)
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
</svg>
)
}
// Helper function to get the appropriate icon component
export function getDocumentIcon(mimeType: string, filename: string): React.FC<IconProps> {
export function getDocumentIcon(
mimeType: string,
filename: string
): (props: SVGProps<SVGSVGElement>) => React.JSX.Element {
const extension = filename.split('.').pop()?.toLowerCase()
if (
mimeType.startsWith('audio/') ||
(extension &&
SUPPORTED_AUDIO_EXTENSIONS.includes(extension as (typeof SUPPORTED_AUDIO_EXTENSIONS)[number]))
) {
return AudioIcon
}
if (
mimeType.startsWith('video/') ||
(extension &&
SUPPORTED_VIDEO_EXTENSIONS.includes(extension as (typeof SUPPORTED_VIDEO_EXTENSIONS)[number]))
) {
return VideoIcon
}
if (mimeType === 'application/pdf' || extension === 'pdf') {
return PdfIcon
}
if (
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
mimeType === 'application/msword' ||
@@ -174,6 +191,7 @@ export function getDocumentIcon(mimeType: string, filename: string): React.FC<Ic
) {
return DocxIcon
}
if (
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
mimeType === 'application/vnd.ms-excel' ||
@@ -182,11 +200,23 @@ export function getDocumentIcon(mimeType: string, filename: string): React.FC<Ic
) {
return XlsxIcon
}
if (mimeType === 'text/csv' || extension === 'csv') {
return CsvIcon
}
if (mimeType === 'text/plain' || extension === 'txt') {
return TxtIcon
}
if (
mimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
mimeType === 'application/vnd.ms-powerpoint' ||
extension === 'pptx' ||
extension === 'ppt'
) {
return PptxIcon
}
return DefaultFileIcon
}

View File

@@ -12,6 +12,7 @@ export const workspaceKeys = {
detail: (id: string) => [...workspaceKeys.details(), id] as const,
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
members: (id: string) => [...workspaceKeys.detail(id), 'members'] as const,
adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
}
@@ -199,6 +200,40 @@ export function useWorkspacePermissionsQuery(workspaceId: string | null | undefi
})
}
/** Lightweight member profile for UI display (avatars, owner cells). */
export interface WorkspaceMember {
userId: string
name: string
image: string | null
}
async function fetchWorkspaceMembers(
workspaceId: string,
signal?: AbortSignal
): Promise<WorkspaceMember[]> {
const response = await fetch(`/api/workspaces/${workspaceId}/members`, { signal })
if (!response.ok) {
throw new Error('Failed to fetch workspace members')
}
const data = await response.json()
return data.members || []
}
/**
* Fetches lightweight member profiles (id, name, image) for a workspace.
* Use this for display purposes (avatars, owner cells) instead of the heavier permissions query.
*/
export function useWorkspaceMembersQuery(workspaceId: string | null | undefined) {
return useQuery({
queryKey: workspaceKeys.members(workspaceId ?? ''),
queryFn: ({ signal }) => fetchWorkspaceMembers(workspaceId as string, signal),
enabled: Boolean(workspaceId),
staleTime: 5 * 60 * 1000,
})
}
async function fetchWorkspaceSettings(workspaceId: string, signal?: AbortSignal) {
const [settingsResponse, permissionsResponse] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}`, { signal }),

View File

@@ -22,6 +22,7 @@ export async function getKnowledgeBases(
const knowledgeBasesWithCounts = await db
.select({
id: knowledgeBase.id,
userId: knowledgeBase.userId,
name: knowledgeBase.name,
description: knowledgeBase.description,
tokenCount: sql<number>`COALESCE(SUM(${document.tokenCount}), 0)`.mapWith(Number),
@@ -202,6 +203,7 @@ export async function updateKnowledgeBase(
const updatedKb = await db
.select({
id: knowledgeBase.id,
userId: knowledgeBase.userId,
name: knowledgeBase.name,
description: knowledgeBase.description,
tokenCount: sql<number>`COALESCE(SUM(${document.tokenCount}), 0)`.mapWith(Number),
@@ -245,6 +247,7 @@ export async function getKnowledgeBaseById(
const result = await db
.select({
id: knowledgeBase.id,
userId: knowledgeBase.userId,
name: knowledgeBase.name,
description: knowledgeBase.description,
tokenCount: sql<number>`COALESCE(SUM(${document.tokenCount}), 0)`.mapWith(Number),

View File

@@ -17,6 +17,7 @@ export interface ChunkingConfig {
export interface KnowledgeBaseWithCounts {
id: string
userId: string
name: string
description: string | null
tokenCount: number
@@ -116,6 +117,7 @@ export interface ExtendedChunkingConfig extends ChunkingConfig {
/** Knowledge base data for API responses */
export interface KnowledgeBaseData {
id: string
userId: string
name: string
description?: string
tokenCount: number

View File

@@ -207,6 +207,33 @@ export async function getUsersWithPermissions(workspaceId: string): Promise<
}))
}
/** Lightweight profile data for workspace member display (avatars, owner cells). */
export interface WorkspaceMemberProfile {
userId: string
name: string
image: string | null
}
/**
* Fetches minimal profile data (id, name, image) for all members of a workspace.
* Use this instead of getUsersWithPermissions when you only need display info.
*/
export async function getWorkspaceMemberProfiles(
workspaceId: string
): Promise<WorkspaceMemberProfile[]> {
const rows = await db
.select({
userId: user.id,
name: user.name,
image: user.image,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId)))
return rows
}
/**
* Check if a user has admin access to a specific workspace
*