improvements(knowledge): ui/ux

This commit is contained in:
Emir Karabeg
2025-06-03 01:09:40 -07:00
parent 1eda44d605
commit f3a405364f
8 changed files with 158 additions and 97 deletions

View File

@@ -3,7 +3,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger'
import { getS3Client } from '@/lib/uploads/s3-client'
import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3-client'
import { S3_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
import { createErrorResponse, createOptionsResponse } from '../utils'
@@ -40,6 +40,9 @@ export async function POST(request: NextRequest) {
const safeFileName = fileName.replace(/\s+/g, '-')
const uniqueKey = `${Date.now()}-${uuidv4()}-${safeFileName}`
// Sanitize the original filename for S3 metadata to prevent header errors
const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName)
// Create the S3 command
const command = new PutObjectCommand({
Bucket: S3_CONFIG.bucket,

View File

@@ -47,7 +47,7 @@ export function DocumentLoading({
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Main Content */}
<div className='flex-1 overflow-auto pt-[4px]'>
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search Section */}
<div className='mb-4'>

View File

@@ -649,6 +649,9 @@ export function KnowledgeBase({
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Status</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Status</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>
Actions
@@ -718,6 +721,11 @@ export function KnowledgeBase({
<div className='text-muted-foreground text-xs'></div>
</td>
{/* Processing column */}
<td className='px-4 py-3'>
<div className='text-muted-foreground text-xs'></div>
</td>
{/* Status column */}
<td className='px-4 py-3'>
<div className='text-muted-foreground text-xs'></div>

View File

@@ -1,8 +1,8 @@
'use client'
import { LibraryBig, Search } from 'lucide-react'
import Link from 'next/link'
import { Search } from 'lucide-react'
import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from '../../components/knowledge-header/knowledge-header'
import { DocumentTableSkeleton } from '../../components/skeletons/table-skeleton'
interface KnowledgeBaseLoadingProps {
@@ -14,28 +14,29 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
const isSidebarCollapsed =
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
const breadcrumbs = [
{
id: 'knowledge-root',
label: 'Knowledge',
href: '/w/knowledge',
},
{
id: 'knowledge-base-loading',
label: knowledgeBaseName,
},
]
return (
<div
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
{/* Fixed Header with Breadcrumbs */}
<div className='flex items-center gap-2 px-6 pt-[14px] pb-6'>
<Link
href='/w/knowledge'
prefetch={true}
className='group flex items-center gap-2 font-medium text-sm transition-colors hover:text-muted-foreground'
>
<LibraryBig className='h-[18px] w-[18px] text-muted-foreground transition-colors group-hover:text-muted-foreground/70' />
<span>Knowledge</span>
</Link>
<span className='text-muted-foreground'>/</span>
<span className='font-medium text-sm'>{knowledgeBaseName}</span>
</div>
<KnowledgeHeader breadcrumbs={breadcrumbs} />
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Main Content */}
<div className='flex-1 overflow-auto pt-[4px]'>
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between'>
@@ -51,13 +52,16 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
</div>
</div>
{/* <button
disabled
className='flex items-center gap-1 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<Plus className='h-4 w-4 font-[480]' />
<span>Add Document</span>
</button> */}
<div className='flex items-center gap-3'>
{/* Add Documents Button - disabled state */}
<button
disabled
className='mt-1 mr-1 flex items-center gap-1.5 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_3px_rgba(127,47,255,0.12)] disabled:opacity-50'
>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
<span>Add Documents</span>
</button>
</div>
</div>
{/* Table container */}

View File

@@ -23,6 +23,8 @@ const HEADER_STYLES = {
link: 'group flex items-center gap-2 font-medium text-sm transition-colors hover:text-muted-foreground',
label: 'font-medium text-sm',
separator: 'text-muted-foreground',
// Always reserve consistent space for actions area
actionsContainer: 'flex h-8 w-8 items-center justify-center',
} as const
interface KnowledgeHeaderOptions {
@@ -60,30 +62,32 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
})}
</div>
{/* Actions Menu - only show if onDeleteKnowledgeBase is provided */}
{options?.onDeleteKnowledgeBase && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 w-8 p-0'
aria-label='Knowledge base actions menu'
>
<MoreHorizontal className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={options.onDeleteKnowledgeBase}
className='text-red-600 focus:text-red-600'
>
<Trash2 className='mr-2 h-4 w-4' />
Delete Knowledge Base
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Actions Area - always reserve consistent space */}
<div className={HEADER_STYLES.actionsContainer}>
{options?.onDeleteKnowledgeBase && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 w-8 p-0'
aria-label='Knowledge base actions menu'
>
<MoreHorizontal className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={options.onDeleteKnowledgeBase}
className='text-red-600 focus:text-red-600'
>
<Trash2 className='mr-2 h-4 w-4' />
Delete Knowledge Base
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
}

View File

@@ -37,7 +37,12 @@ export function DocumentTableRowSkeleton({ isSidebarCollapsed }: { isSidebarColl
</div>
</td>
{/* Status column */}
{/* Processing Status column */}
<td className='px-4 py-3'>
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
</td>
{/* Active Status column */}
<td className='px-4 py-3'>
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
</td>
@@ -47,6 +52,7 @@ export function DocumentTableRowSkeleton({ isSidebarCollapsed }: { isSidebarColl
<div className='flex items-center gap-1'>
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
</div>
</td>
</tr>
@@ -106,17 +112,18 @@ export function DocumentTableSkeleton({
return (
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Table header - fixed */}
<div className='sticky top-0 z-10 border-b bg-background'>
<table className='w-full table-fixed'>
<div className='sticky top-0 z-10 overflow-x-auto border-b bg-background'>
<table className='w-full min-w-[800px] table-fixed'>
<colgroup>
<col className='w-[5%]' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[20%]'}`} />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[20%]' : 'w-[22%]'}`} />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[20%]'}`} />
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[14%]'}`} />
<col className='w-[10%]' />
<col className='w-[16%]' />
<col className='w-[10%]' />
<col className='w-[12%]' />
</colgroup>
<thead>
<tr>
@@ -138,6 +145,9 @@ export function DocumentTableSkeleton({
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Uploaded</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Processing</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Status</span>
</th>
@@ -151,16 +161,17 @@ export function DocumentTableSkeleton({
{/* Table body - scrollable */}
<div className='flex-1 overflow-auto'>
<table className='w-full table-fixed'>
<table className='w-full min-w-[800px] table-fixed'>
<colgroup>
<col className='w-[5%]' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[20%]'}`} />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[20%]' : 'w-[22%]'}`} />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[20%]'}`} />
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[14%]'}`} />
<col className='w-[10%]' />
<col className='w-[16%]' />
<col className='w-[10%]' />
<col className='w-[12%]' />
</colgroup>
<tbody>
{Array.from({ length: rowCount }).map((_, i) => (

View File

@@ -1,7 +1,8 @@
'use client'
import { LibraryBig, Plus, Search } from 'lucide-react'
import { Plus, Search } from 'lucide-react'
import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from './components/knowledge-header/knowledge-header'
import { KnowledgeBaseCardSkeletonGrid } from './components/skeletons/knowledge-base-card-skeleton'
export default function KnowledgeLoading() {
@@ -9,44 +10,47 @@ export default function KnowledgeLoading() {
const isSidebarCollapsed =
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
return (
<div
className={`fixed inset-0 flex flex-col transition-all duration-200 ${isSidebarCollapsed ? 'left-14' : 'left-60'}`}
className={`flex h-screen flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
>
{/* Fixed Header */}
<div className='flex items-center gap-2 px-6 pt-4 pb-6'>
<LibraryBig className='h-[18px] w-[18px] text-muted-foreground' />
<h1 className='font-medium text-sm'>Knowledge</h1>
</div>
{/* Header */}
<KnowledgeHeader breadcrumbs={breadcrumbs} />
{/* Main Content */}
<div className='flex-1 overflow-auto pt-[6px]'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-6 flex items-center justify-between'>
<div className='relative max-w-md flex-1'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
<input
type='text'
placeholder='Search knowledge bases...'
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Main Content */}
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between'>
<div className='relative max-w-md flex-1'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
<input
type='text'
placeholder='Search knowledge bases...'
disabled
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
</div>
<button
disabled
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
/>
className='flex items-center gap-1 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<Plus className='h-4 w-4 font-[480]' />
<span>Create</span>
</button>
</div>
{/* Content Area */}
<KnowledgeBaseCardSkeletonGrid count={8} />
</div>
<button
disabled
className='flex items-center gap-1 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<Plus className='h-4 w-4 font-[480]' />
<span>Create</span>
</button>
</div>
{/* Content Area */}
<KnowledgeBaseCardSkeletonGrid count={8} />
</div>
</div>
</div>

View File

@@ -33,6 +33,27 @@ export function getS3Client(): S3Client {
return _s3Client
}
/**
* Sanitize a filename for use in S3 metadata headers
* S3 metadata headers must contain only ASCII printable characters (0x20-0x7E)
* and cannot contain certain special characters
*/
export function sanitizeFilenameForMetadata(filename: string): string {
return (
filename
// Remove non-ASCII characters (keep only printable ASCII 0x20-0x7E)
.replace(/[^\x20-\x7E]/g, '')
// Remove characters that are problematic in HTTP headers
.replace(/["\\]/g, '')
// Replace multiple spaces with single space
.replace(/\s+/g, ' ')
// Trim whitespace
.trim() ||
// Provide fallback if completely sanitized
'file'
)
}
/**
* File information structure
*/
@@ -110,6 +131,12 @@ export async function uploadToS3(
const safeFileName = fileName.replace(/\s+/g, '-') // Replace spaces with hyphens
const uniqueKey = `${Date.now()}-${safeFileName}`
// Sanitize filename for S3 metadata (only allow ASCII printable characters)
const sanitizedOriginalName = fileName
.replace(/[^\x20-\x7E]/g, '') // Remove non-ASCII characters
.replace(/["\\]/g, '') // Remove quotes and backslashes
.trim()
const s3Client = getS3Client()
// Upload the file to S3
@@ -119,7 +146,7 @@ export async function uploadToS3(
Key: uniqueKey,
Body: file,
ContentType: contentType,
// Add some useful metadata
// Add some useful metadata with sanitized values
Metadata: {
originalName: encodeURIComponent(fileName), // Encode filename to prevent invalid characters in HTTP headers
uploadedAt: new Date().toISOString(),
@@ -133,7 +160,7 @@ export async function uploadToS3(
return {
path: servePath,
key: uniqueKey,
name: fileName,
name: fileName, // Return the actual original filename in the response
size: fileSize,
type: contentType,
}