@@ -6,6 +6,7 @@ import { useParams, useRouter } from 'next/navigation'
66import {
77 Button ,
88 Columns2 ,
9+ type ComboboxOption ,
910 Download ,
1011 DropdownMenu ,
1112 DropdownMenuContent ,
@@ -25,7 +26,6 @@ import {
2526} from '@/components/emcn'
2627import { File as FilesIcon } from '@/components/emcn/icons'
2728import { getDocumentIcon } from '@/components/icons/document-icons'
28- import { cn } from '@/lib/core/utils/cn'
2929import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
3030import {
3131 downloadWorkspaceFile ,
@@ -173,8 +173,8 @@ export function Files() {
173173 column : string
174174 direction : 'asc' | 'desc'
175175 } | null > ( null )
176- const [ typeFilter , setTypeFilter ] = useState < 'all' | 'document' | 'audio' | 'video' > ( 'all' )
177- const [ sizeFilter , setSizeFilter ] = useState < 'all' | 'small' | 'medium' | 'large' > ( 'all' )
176+ const [ typeFilter , setTypeFilter ] = useState < string [ ] > ( [ ] )
177+ const [ sizeFilter , setSizeFilter ] = useState < string [ ] > ( [ ] )
178178 const [ uploadedByFilter , setUploadedByFilter ] = useState < string [ ] > ( [ ] )
179179
180180 const [ creatingFile , setCreatingFile ] = useState ( false )
@@ -215,21 +215,23 @@ export function Files() {
215215 ? files . filter ( ( f ) => f . name . toLowerCase ( ) . includes ( debouncedSearchTerm . toLowerCase ( ) ) )
216216 : files
217217
218- if ( typeFilter !== 'all' ) {
218+ if ( typeFilter . length > 0 ) {
219219 result = result . filter ( ( f ) => {
220220 const ext = getFileExtension ( f . name )
221- if ( typeFilter === 'document' ) return isSupportedExtension ( ext )
222- if ( typeFilter === 'audio' ) return isSupportedAudioExtension ( ext )
223- if ( typeFilter === 'video' ) return isSupportedVideoExtension ( ext )
224- return true
221+ if ( typeFilter . includes ( 'document' ) && isSupportedExtension ( ext ) ) return true
222+ if ( typeFilter . includes ( 'audio' ) && isSupportedAudioExtension ( ext ) ) return true
223+ if ( typeFilter . includes ( 'video' ) && isSupportedVideoExtension ( ext ) ) return true
224+ return false
225225 } )
226226 }
227227
228- if ( sizeFilter !== 'all' ) {
228+ if ( sizeFilter . length > 0 ) {
229229 result = result . filter ( ( f ) => {
230- if ( sizeFilter === 'small' ) return f . size < 1_048_576
231- if ( sizeFilter === 'medium' ) return f . size >= 1_048_576 && f . size <= 10_485_760
232- return f . size > 10_485_760 // large
230+ if ( sizeFilter . includes ( 'small' ) && f . size < 1_048_576 ) return true
231+ if ( sizeFilter . includes ( 'medium' ) && f . size >= 1_048_576 && f . size <= 10_485_760 )
232+ return true
233+ if ( sizeFilter . includes ( 'large' ) && f . size > 10_485_760 ) return true
234+ return false
233235 } )
234236 }
235237
@@ -803,6 +805,56 @@ export function Files() {
803805 [ handleNavigateToFiles ]
804806 )
805807
808+ const typeDisplayLabel = useMemo ( ( ) => {
809+ if ( typeFilter . length === 0 ) return 'All'
810+ if ( typeFilter . length === 1 ) {
811+ const labels : Record < string , string > = {
812+ document : 'Documents' ,
813+ audio : 'Audio' ,
814+ video : 'Video' ,
815+ }
816+ return labels [ typeFilter [ 0 ] ] ?? typeFilter [ 0 ]
817+ }
818+ return `${ typeFilter . length } selected`
819+ } , [ typeFilter ] )
820+
821+ const sizeDisplayLabel = useMemo ( ( ) => {
822+ if ( sizeFilter . length === 0 ) return 'All'
823+ if ( sizeFilter . length === 1 ) {
824+ const labels : Record < string , string > = { small : 'Small' , medium : 'Medium' , large : 'Large' }
825+ return labels [ sizeFilter [ 0 ] ] ?? sizeFilter [ 0 ]
826+ }
827+ return `${ sizeFilter . length } selected`
828+ } , [ sizeFilter ] )
829+
830+ const uploadedByDisplayLabel = useMemo ( ( ) => {
831+ if ( uploadedByFilter . length === 0 ) return 'All'
832+ if ( uploadedByFilter . length === 1 )
833+ return members ?. find ( ( m ) => m . userId === uploadedByFilter [ 0 ] ) ?. name ?? '1 member'
834+ return `${ uploadedByFilter . length } members`
835+ } , [ uploadedByFilter , members ] )
836+
837+ const memberOptions : ComboboxOption [ ] = useMemo (
838+ ( ) =>
839+ ( members ?? [ ] ) . map ( ( m ) => ( {
840+ value : m . userId ,
841+ label : m . name ,
842+ iconElement : m . image ? (
843+ < img
844+ src = { m . image }
845+ alt = { m . name }
846+ referrerPolicy = 'no-referrer'
847+ className = 'h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover'
848+ />
849+ ) : (
850+ < 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)]' >
851+ { m . name . charAt ( 0 ) . toUpperCase ( ) }
852+ </ span >
853+ ) ,
854+ } ) ) ,
855+ [ members ]
856+ )
857+
806858 const sortConfig : SortConfig = useMemo (
807859 ( ) => ( {
808860 options : [
@@ -818,122 +870,112 @@ export function Files() {
818870 [ activeSort ]
819871 )
820872
873+ const hasActiveFilters =
874+ typeFilter . length > 0 || sizeFilter . length > 0 || uploadedByFilter . length > 0
875+
821876 const filterContent = (
822- < div className = 'w-[200px] ' >
823- < div className = 'border-[var(--border-1)] border-b px-3 py-2 ' >
877+ < div className = 'flex w-[240px] flex-col gap-3 p-3 ' >
878+ < div className = 'flex flex-col gap-1.5 ' >
824879 < span className = 'font-medium text-[var(--text-secondary)] text-caption' > File Type</ span >
825- </ div >
826- < div className = 'flex flex-col gap-0.5 px-3 py-2' >
827- { (
828- [
829- { value : 'all' , label : 'All' } ,
880+ < Combobox
881+ options = { [
830882 { value : 'document' , label : 'Documents' } ,
831883 { value : 'audio' , label : 'Audio' } ,
832884 { value : 'video' , label : 'Video' } ,
833- ] as const
834- ) . map ( ( { value, label } ) => (
835- < button
836- key = { value }
837- type = 'button'
838- className = { cn (
839- 'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]' ,
840- typeFilter === value && 'bg-[var(--surface-active)]'
841- ) }
842- onClick = { ( ) => setTypeFilter ( value ) }
843- >
844- { label }
845- </ button >
846- ) ) }
885+ ] }
886+ multiSelect
887+ multiSelectValues = { typeFilter }
888+ onMultiSelectChange = { setTypeFilter }
889+ overlayContent = {
890+ < span className = 'truncate text-[var(--text-primary)]' > { typeDisplayLabel } </ span >
891+ }
892+ showAllOption
893+ allOptionLabel = 'All'
894+ size = 'sm'
895+ className = 'h-[32px] w-full rounded-md'
896+ />
847897 </ div >
848- < div className = 'border-[var(--border-1)] border-t border-b px-3 py-2 ' >
898+ < div className = 'flex flex-col gap-1.5 ' >
849899 < span className = 'font-medium text-[var(--text-secondary)] text-caption' > Size</ span >
850- </ div >
851- < div className = 'flex flex-col gap-0.5 px-3 py-2' >
852- { (
853- [
854- { value : 'all' , label : 'All' } ,
900+ < Combobox
901+ options = { [
855902 { value : 'small' , label : 'Small (< 1 MB)' } ,
856903 { value : 'medium' , label : 'Medium (1–10 MB)' } ,
857904 { value : 'large' , label : 'Large (> 10 MB)' } ,
858- ] as const
859- ) . map ( ( { value, label } ) => (
860- < button
861- key = { value }
862- type = 'button'
863- className = { cn (
864- 'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]' ,
865- sizeFilter === value && 'bg-[var(--surface-active)]'
866- ) }
867- onClick = { ( ) => setSizeFilter ( value ) }
868- >
869- { label }
870- </ button >
871- ) ) }
905+ ] }
906+ multiSelect
907+ multiSelectValues = { sizeFilter }
908+ onMultiSelectChange = { setSizeFilter }
909+ overlayContent = {
910+ < span className = 'truncate text-[var(--text-primary)]' > { sizeDisplayLabel } </ span >
911+ }
912+ showAllOption
913+ allOptionLabel = 'All'
914+ size = 'sm'
915+ className = 'h-[32px] w-full rounded-md'
916+ />
872917 </ div >
873- { members && members . length > 0 && (
874- < >
875- < div className = 'border-[var(--border-1)] border-t border-b px-3 py-2' >
876- < span className = 'font-medium text-[var(--text-secondary)] text-caption' >
877- Uploaded By
878- </ span >
879- </ div >
880- < div className = 'flex flex-col gap-0.5 px-3 py-2' >
881- < button
882- type = 'button'
883- className = { cn (
884- 'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]' ,
885- uploadedByFilter . length === 0 && 'bg-[var(--surface-active)]'
886- ) }
887- onClick = { ( ) => setUploadedByFilter ( [ ] ) }
888- >
889- All
890- </ button >
891- { members . map ( ( member ) => (
892- < button
893- key = { member . userId }
894- type = 'button'
895- className = { cn (
896- 'flex w-full cursor-pointer select-none items-center gap-1.5 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]' ,
897- uploadedByFilter . includes ( member . userId ) && 'bg-[var(--surface-active)]'
898- ) }
899- onClick = { ( ) =>
900- setUploadedByFilter ( ( prev ) =>
901- prev . includes ( member . userId )
902- ? prev . filter ( ( id ) => id !== member . userId )
903- : [ ...prev , member . userId ]
904- )
905- }
906- >
907- { member . image ? (
908- < img
909- src = { member . image }
910- alt = { member . name }
911- referrerPolicy = 'no-referrer'
912- className = 'h-[14px] w-[14px] shrink-0 rounded-full border border-[var(--border)] object-cover'
913- />
914- ) : (
915- < span className = 'flex h-[14px] w-[14px] shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]' >
916- { member . name . charAt ( 0 ) . toUpperCase ( ) }
917- </ span >
918- ) }
919- < span className = 'truncate' > { member . name } </ span >
920- </ button >
921- ) ) }
922- </ div >
923- </ >
918+ { memberOptions . length > 0 && (
919+ < div className = 'flex flex-col gap-1.5' >
920+ < span className = 'font-medium text-[var(--text-secondary)] text-caption' > Uploaded By</ span >
921+ < Combobox
922+ options = { memberOptions }
923+ multiSelect
924+ multiSelectValues = { uploadedByFilter }
925+ onMultiSelectChange = { setUploadedByFilter }
926+ overlayContent = {
927+ < span className = 'truncate text-[var(--text-primary)]' > { uploadedByDisplayLabel } </ span >
928+ }
929+ searchable
930+ searchPlaceholder = 'Search members...'
931+ showAllOption
932+ allOptionLabel = 'All'
933+ size = 'sm'
934+ className = 'h-[32px] w-full rounded-md'
935+ />
936+ </ div >
937+ ) }
938+ { hasActiveFilters && (
939+ < button
940+ type = 'button'
941+ onClick = { ( ) => {
942+ setTypeFilter ( [ ] )
943+ setSizeFilter ( [ ] )
944+ setUploadedByFilter ( [ ] )
945+ } }
946+ className = 'flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
947+ >
948+ Clear all filters
949+ </ button >
924950 ) }
925951 </ div >
926952 )
927953
928954 const filterTags : FilterTag [ ] = useMemo ( ( ) => {
929955 const tags : FilterTag [ ] = [ ]
930- if ( typeFilter !== 'all' ) {
931- const labels = { document : 'Type: Documents' , audio : 'Type: Audio' , video : 'Type: Video' }
932- tags . push ( { label : labels [ typeFilter ] , onRemove : ( ) => setTypeFilter ( 'all' ) } )
956+ if ( typeFilter . length > 0 ) {
957+ const typeLabels : Record < string , string > = {
958+ document : 'Documents' ,
959+ audio : 'Audio' ,
960+ video : 'Video' ,
961+ }
962+ const label =
963+ typeFilter . length === 1
964+ ? `Type: ${ typeLabels [ typeFilter [ 0 ] ] } `
965+ : `Type: ${ typeFilter . length } selected`
966+ tags . push ( { label, onRemove : ( ) => setTypeFilter ( [ ] ) } )
933967 }
934- if ( sizeFilter !== 'all' ) {
935- const labels = { small : 'Size: Small' , medium : 'Size: Medium' , large : 'Size: Large' }
936- tags . push ( { label : labels [ sizeFilter ] , onRemove : ( ) => setSizeFilter ( 'all' ) } )
968+ if ( sizeFilter . length > 0 ) {
969+ const sizeLabels : Record < string , string > = {
970+ small : 'Small' ,
971+ medium : 'Medium' ,
972+ large : 'Large' ,
973+ }
974+ const label =
975+ sizeFilter . length === 1
976+ ? `Size: ${ sizeLabels [ sizeFilter [ 0 ] ] } `
977+ : `Size: ${ sizeFilter . length } selected`
978+ tags . push ( { label, onRemove : ( ) => setSizeFilter ( [ ] ) } )
937979 }
938980 if ( uploadedByFilter . length > 0 ) {
939981 const label =
0 commit comments