Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/console/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { SpacesList } from '@/components/SpacesList'
import { UpgradePrompt } from '@/components/UpgradePrompt'
import { usePrivateSpacesAccess } from '@/hooks/usePrivateSpacesAccess'
import { useFilteredSpaces } from '@/hooks/useFilteredSpaces'
import { useSpaceSort } from '@/hooks/useSpaceSort'
import { SpaceSortDropdown } from '@/components/SpaceSortDropdown'
import { NoticeBanner } from '@/components/NoticeBanner'
import { noticeConfig } from '@/config/notice'

Expand All @@ -25,7 +27,8 @@ export function SpacePage() {
const [activeTab, setActiveTab] = useState<'public' | 'private'>('public')
const [{ spaces }] = useW3()
const { canAccessPrivateSpaces, planLoading, shouldShowPrivateSpacesTab } = usePrivateSpacesAccess()
const { publicSpaces, privateSpaces, hasHiddenPrivateSpaces } = useFilteredSpaces()
const { sortOption, setSortOption } = useSpaceSort()
const { publicSpaces, privateSpaces, hasHiddenPrivateSpaces } = useFilteredSpaces(sortOption)

if (spaces.length === 0) {
return <div></div>
Expand Down Expand Up @@ -58,6 +61,9 @@ export function SpacePage() {
showPrivateTab={shouldShowPrivateSpacesTab}
privateTabLocked={!canAccessPrivateSpaces}
/>
<div className="mb-4">
<SpaceSortDropdown sortOption={sortOption} onSortChange={setSortOption} />
</div>
{activeTab === 'public' && (
<SpacesList spaces={publicSpaces} type="public" />
)}
Expand Down
3 changes: 2 additions & 1 deletion packages/console/src/components/SpaceCreator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ export function SpaceCreatorForm({
} catch (error) {
setSubmitted(false)
setCreated(false)
setErrorMessage(`Failed to create space: ${error.message}`)
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
setErrorMessage(`Failed to create space: ${errorMessage}`)
console.log(error)
/* eslint-disable-next-line no-console */
logAndCaptureError(error)
Expand Down
80 changes: 80 additions & 0 deletions packages/console/src/components/SpaceSortDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Fragment, type JSX } from 'react'
import { Listbox, Transition } from '@headlessui/react'
import { ChevronUpDownIcon, CheckIcon } from '@heroicons/react/20/solid'
import type { SortOption } from '@/hooks/useSpaceSort'

interface SpaceSortDropdownProps {
sortOption: SortOption
onSortChange: (option: SortOption) => void
}

const sortOptions: Array<{ value: SortOption; label: string }> = [
{ value: 'name-asc', label: 'Name (A–Z)' },
{ value: 'name-desc', label: 'Name (Z–A)' },
]

/**
* Dropdown component for sorting spaces list
* Follows Storacha Console UI patterns using Headless UI
*/
export function SpaceSortDropdown({ sortOption, onSortChange }: SpaceSortDropdownProps): JSX.Element {
const selectedOption = sortOptions.find(opt => opt.value === sortOption) || sortOptions[0]

return (
<div className="relative w-auto">
<Listbox value={sortOption} onChange={onSortChange}>
<div className="relative">
<Listbox.Button className="relative w-auto min-w-[150px] cursor-pointer rounded-md border border-hot-red bg-white py-2 pl-3 pr-10 text-left text-sm focus:outline-none focus:ring-1 focus:ring-hot-red focus:border-hot-red">
<span className="block truncate font-epilogue text-hot-red text-xs sm:text-sm">
{selectedOption.label}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon
className="h-5 w-5 text-hot-red"
aria-hidden="true"
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute left-0 z-10 mt-1 max-h-60 w-auto min-w-[150px] overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm border border-hot-red">
{sortOptions.map((option) => (
<Listbox.Option
key={option.value}
className={({ active }: { active: boolean }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
active ? 'bg-hot-yellow-light text-hot-red' : 'text-gray-900'
}`
}
value={option.value}
>
{({ selected }: { selected: boolean }) => (
<>
<span
className={`block truncate ${
selected ? 'font-medium font-epilogue' : 'font-normal'
}`}
>
{option.label}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-hot-red">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
)
}

2 changes: 1 addition & 1 deletion packages/console/src/components/SpacesTabNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function SpacesTabNavigation({
}

return (
<div className="flex border-b border-gray-200 mb-6">
<div className="flex border-b border-gray-200 mb-2">
<button
onClick={() => onTabChange('public')}
className={`flex items-center gap-2 px-4 py-2 border-b-2 transition-colors ${
Expand Down
12 changes: 9 additions & 3 deletions packages/console/src/hooks/useFilteredSpaces.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useW3 } from '@storacha/ui-react'
import { usePrivateSpacesAccess } from './usePrivateSpacesAccess'
import { sortSpaces, type SortOption } from './useSpaceSort'

export const useFilteredSpaces = () => {
export const useFilteredSpaces = (sortOption: SortOption = 'name-asc') => {
const [{ spaces }] = useW3()
const { canAccessPrivateSpaces } = usePrivateSpacesAccess()
const allPublicSpaces = spaces.filter(s => s.access.type === 'public')
Expand All @@ -10,9 +11,14 @@ export const useFilteredSpaces = () => {
// but they're still in the backend and will reappear if user upgrades to paid plan
const visiblePrivateSpaces = canAccessPrivateSpaces ? allPrivateSpaces : []
const hiddenPrivateSpaces = canAccessPrivateSpaces ? [] : allPrivateSpaces

// Apply sorting to filtered spaces
const sortedPublicSpaces = sortSpaces(allPublicSpaces, sortOption)
const sortedPrivateSpaces = sortSpaces(visiblePrivateSpaces, sortOption)

return {
publicSpaces: allPublicSpaces,
privateSpaces: visiblePrivateSpaces,
publicSpaces: sortedPublicSpaces,
privateSpaces: sortedPrivateSpaces,
hiddenPrivateSpaces, // For debugging/admin purposes
hasHiddenPrivateSpaces: hiddenPrivateSpaces.length > 0
}
Expand Down
116 changes: 116 additions & 0 deletions packages/console/src/hooks/useSpaceSort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { useState, useEffect } from 'react'
import type { Space } from '@storacha/ui-react'

/**
* Sort options for spaces list (name-based only)
*/
export type SortOption = 'name-asc' | 'name-desc'

const SORT_STORAGE_KEY = 'storacha_space_sort_option'

const VALID_SORT_OPTIONS: SortOption[] = ['name-asc', 'name-desc']

/**
* Hook to manage space sorting with localStorage persistence.
*
* - Persists the selected option in localStorage until logout.
* - Also updates URL query param when on home page for shareability.
*/
export function useSpaceSort() {
const [sortOption, setSortOption] = useState<SortOption>(() => {
if (typeof window === 'undefined') return 'name-asc'

try {
const stored = window.localStorage.getItem(SORT_STORAGE_KEY)
if (stored && VALID_SORT_OPTIONS.includes(stored as SortOption)) {
return stored as SortOption
}
} catch {
// ignore storage errors
}

try {
const url = new URL(window.location.href)
const sortParam = url.searchParams.get('sort')
if (sortParam && VALID_SORT_OPTIONS.includes(sortParam as SortOption)) {
try {
window.localStorage.setItem(SORT_STORAGE_KEY, sortParam)
} catch {
// ignore
}
return sortParam as SortOption
}
} catch {
// ignore URL errors
}

return 'name-asc'
})

useEffect(() => {
if (typeof window === 'undefined') return

try {
const stored = window.localStorage.getItem(SORT_STORAGE_KEY)
if (stored && VALID_SORT_OPTIONS.includes(stored as SortOption)) {
setSortOption((current) =>
current !== stored ? (stored as SortOption) : current
)
}
} catch {
// ignore
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

useEffect(() => {
if (typeof window === 'undefined') return
if (!VALID_SORT_OPTIONS.includes(sortOption)) return

try {
window.localStorage.setItem(SORT_STORAGE_KEY, sortOption)
} catch (e) {
console.warn('Failed to save sort option to localStorage:', e)
}

try {
const currentPath = window.location.pathname
if (currentPath === '/' || currentPath === '') {
const url = new URL(window.location.href)
if (sortOption === 'name-asc') {
url.searchParams.delete('sort')
} else {
url.searchParams.set('sort', sortOption)
}
window.history.replaceState(null, '', url.toString())
}
} catch (e) {
// ignore URL errors
}
}, [sortOption])

return {
sortOption,
setSortOption,
}
}

/**
* Sort spaces array by name (A-Z or Z-A).
*
* @param spaces - Array of spaces to sort
* @param sortOption - The sort option to apply
* @returns Sorted array of spaces
*/
export function sortSpaces(spaces: Space[], sortOption: SortOption): Space[] {
const sorted = [...spaces]

return sorted.sort((a, b) => {
const nameA = (a.name || a.did()).toLowerCase()
const nameB = (b.name || b.did()).toLowerCase()
return sortOption === 'name-desc'
? nameB.localeCompare(nameA)
: nameA.localeCompare(nameB)
})
}

Loading