mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(resources): layout and items
This commit is contained in:
@@ -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:
|
||||
|
||||
39
apps/sim/app/api/workspaces/[id]/members/route.ts
Normal file
39
apps/sim/app/api/workspaces/[id]/members/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './owner-cell'
|
||||
export * from './time-cell'
|
||||
@@ -0,0 +1 @@
|
||||
export { ownerCell } from './owner-cell'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { timeCell } from './time-cell'
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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(() => {}, [])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user