Skip to content
Merged
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
43 changes: 34 additions & 9 deletions apps/mobile/app/(tabs)/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Ionicons } from '@expo/vector-icons'
import {
libraryApi, readingProgressApi, userBooksApi, getStorageUrl,
} from '@textstack/shared'
import { getAnonymousReaderName, type UserLibraryItem, type UserBookDto, type ReadingProgressDto } from '@textstack/shared'
import { getAnonymousReaderName, collectionsApi, type UserLibraryItem, type UserBookDto, type ReadingProgressDto } from '@textstack/shared'
import { useAuth } from '../../src/context/AuthContext'
import { useTheme } from '../../src/context/ThemeContext'
import { useLanguage } from '../../src/context/LanguageContext'
Expand Down Expand Up @@ -57,6 +57,9 @@ export default function LibraryScreen() {
const [viewMode, setViewMode] = useState<ViewMode>('list')
const [source, setSource] = useState<LibrarySource>('all')
const [drawerOpen, setDrawerOpen] = useState(false)
const [activeCollectionId, setActiveCollectionId] = useState<string | null>(null)
const [collectionSavedIds, setCollectionSavedIds] = useState<Set<string> | null>(null)
const [collectionUploadIds, setCollectionUploadIds] = useState<Set<string> | null>(null)
const [library, setLibrary] = useState<UserLibraryItem[]>([])
const [userBooks, setUserBooks] = useState<UserBookDto[]>([])
const [progressMap, setProgressMap] = useState<Record<string, ReadingProgressDto>>({})
Expand Down Expand Up @@ -125,6 +128,24 @@ export default function LibraryScreen() {
if (isAuthenticated && !loading) loadData()
}, [isAuthenticated, loading, loadData]))

useEffect(() => {
if (!activeCollectionId) {
setCollectionSavedIds(null)
setCollectionUploadIds(null)
return
}
let cancelled = false
Promise.all([
collectionsApi.getCollectionBookIds(activeCollectionId, 'savedbook').catch(() => [] as string[]),
collectionsApi.getCollectionBookIds(activeCollectionId, 'userbook').catch(() => [] as string[]),
]).then(([s, u]) => {
if (cancelled) return
setCollectionSavedIds(new Set(s))
setCollectionUploadIds(new Set(u))
})
return () => { cancelled = true }
}, [activeCollectionId])

const onRefresh = async () => {
setRefreshing(true)
await loadData()
Expand Down Expand Up @@ -209,19 +230,21 @@ export default function LibraryScreen() {
</View>

{effectiveTab === 'saved' ? (
<SavedList library={library} setLibrary={setLibrary} progressMap={progressMap} setProgressMap={setProgressMap} refreshing={refreshing} onRefresh={onRefresh} viewMode={viewMode} />
<SavedList library={library} setLibrary={setLibrary} progressMap={progressMap} setProgressMap={setProgressMap} refreshing={refreshing} onRefresh={onRefresh} viewMode={viewMode} collectionFilterIds={collectionSavedIds} />
) : (
<UploadsList books={userBooks} refreshing={refreshing} onRefresh={onRefresh} viewMode={viewMode} />
<UploadsList books={userBooks} refreshing={refreshing} onRefresh={onRefresh} viewMode={viewMode} collectionFilterIds={collectionUploadIds} />
)}
<LibrarySidebarDrawer
visible={drawerOpen}
source={source}
counts={{ all: library.length + userBooks.length, uploads: userBooks.length, catalog: library.length }}
activeCollectionId={activeCollectionId}
onSelect={(next) => {
setSource(next)
if (next === 'uploads') setTab('uploads')
else if (next === 'catalog') setTab('saved')
}}
onCollectionSelect={setActiveCollectionId}
onClose={() => setDrawerOpen(false)}
/>
</View>
Expand All @@ -240,8 +263,8 @@ function formatTimeAgo(dateStr: string): string {
return `${days}d ago`
}

function SavedList({ library, setLibrary, progressMap, setProgressMap, refreshing, onRefresh, viewMode }: {
library: UserLibraryItem[]; setLibrary: React.Dispatch<React.SetStateAction<UserLibraryItem[]>>; progressMap: Record<string, ReadingProgressDto>; setProgressMap: React.Dispatch<React.SetStateAction<Record<string, ReadingProgressDto>>>; refreshing: boolean; onRefresh: () => void; viewMode: ViewMode
function SavedList({ library, setLibrary, progressMap, setProgressMap, refreshing, onRefresh, viewMode, collectionFilterIds }: {
library: UserLibraryItem[]; setLibrary: React.Dispatch<React.SetStateAction<UserLibraryItem[]>>; progressMap: Record<string, ReadingProgressDto>; setProgressMap: React.Dispatch<React.SetStateAction<Record<string, ReadingProgressDto>>>; refreshing: boolean; onRefresh: () => void; viewMode: ViewMode; collectionFilterIds: Set<string> | null
}) {
const router = useRouter()
const { colors } = useTheme()
Expand Down Expand Up @@ -278,7 +301,8 @@ function SavedList({ library, setLibrary, progressMap, setProgressMap, refreshin

const filtered = filterLibraryItems(library, filter, progressMap)
const searched = debouncedQuery ? filtered.filter(i => matchesQuery({ title: i.title }, debouncedQuery)) : filtered
const sorted = sortLibraryItems(searched, sort, progressMap)
const collectionFiltered = collectionFilterIds ? searched.filter(i => collectionFilterIds.has(i.editionId)) : searched
const sorted = sortLibraryItems(collectionFiltered, sort, progressMap)

return (
<>
Expand Down Expand Up @@ -456,8 +480,8 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}

function UploadsList({ books, refreshing, onRefresh, viewMode }: {
books: UserBookDto[]; refreshing: boolean; onRefresh: () => void; viewMode: ViewMode
function UploadsList({ books, refreshing, onRefresh, viewMode, collectionFilterIds }: {
books: UserBookDto[]; refreshing: boolean; onRefresh: () => void; viewMode: ViewMode; collectionFilterIds: Set<string> | null
}) {
const router = useRouter()
const { colors } = useTheme()
Expand Down Expand Up @@ -501,7 +525,8 @@ function UploadsList({ books, refreshing, onRefresh, viewMode }: {

const filtered = filterUserBooks(books, filter)
const searched = debouncedQuery ? filtered.filter(b => matchesQuery({ title: b.title, author: b.author }, debouncedQuery)) : filtered
const sorted = sortUserBooks(searched, sort)
const collectionFiltered = collectionFilterIds ? searched.filter(b => collectionFilterIds.has(b.id)) : searched
const sorted = sortUserBooks(collectionFiltered, sort)

const listHeader = (
<>
Expand Down
77 changes: 57 additions & 20 deletions apps/mobile/src/components/library/LibrarySidebarDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import { useEffect, useRef } from 'react'
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { Animated, Modal, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import { useTheme } from '../../context/ThemeContext'
import { useLanguage } from '../../context/LanguageContext'
import { fonts } from '../../theme/typography'
import { useCollections } from '../../hooks/useCollections'

export type LibrarySource = 'all' | 'uploads' | 'catalog'

interface Props {
visible: boolean
source: LibrarySource
counts: { all: number; uploads: number; catalog: number }
activeCollectionId: string | null
onSelect: (next: LibrarySource) => void
onCollectionSelect: (id: string | null) => void
onClose: () => void
}

const DRAWER_WIDTH = 280

export function LibrarySidebarDrawer({ visible, source, counts, onSelect, onClose }: Props) {
export function LibrarySidebarDrawer({ visible, source, counts, activeCollectionId, onSelect, onCollectionSelect, onClose }: Props) {
const { colors } = useTheme()
const { t } = useLanguage()
const { collections } = useCollections()
const slide = useRef(new Animated.Value(-DRAWER_WIDTH)).current

useEffect(() => {
Expand Down Expand Up @@ -51,25 +55,51 @@ export function LibrarySidebarDrawer({ visible, source, counts, onSelect, onClos
<Ionicons name="close" size={22} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{items.map((item) => {
const active = source === item.key
return (
<TouchableOpacity
key={item.key}
style={[
styles.item,
active && { backgroundColor: colors.primaryLight },
]}
onPress={() => { onSelect(item.key); onClose() }}
>
<Ionicons name={item.icon} size={18} color={active ? colors.primary : colors.textSecondary} />
<Text style={[styles.label, { color: active ? colors.primary : colors.text }]} numberOfLines={1}>
{item.label}
<ScrollView style={{ flex: 1 }} showsVerticalScrollIndicator={false}>
{items.map((item) => {
const active = source === item.key && !activeCollectionId
return (
<TouchableOpacity
key={item.key}
style={[
styles.item,
active && { backgroundColor: colors.primaryLight },
]}
onPress={() => { onSelect(item.key); onCollectionSelect(null); onClose() }}
>
<Ionicons name={item.icon} size={18} color={active ? colors.primary : colors.textSecondary} />
<Text style={[styles.label, { color: active ? colors.primary : colors.text }]} numberOfLines={1}>
{item.label}
</Text>
<Text style={[styles.count, { color: colors.textSecondary }]}>{item.count}</Text>
</TouchableOpacity>
)
})}

{collections.length > 0 && (
<View style={{ marginTop: 16 }}>
<Text style={[styles.sectionHeading, { color: colors.textSecondary }]}>
{t('library.sidebar.collections').toUpperCase()}
</Text>
<Text style={[styles.count, { color: colors.textSecondary }]}>{item.count}</Text>
</TouchableOpacity>
)
})}
{collections.map((c) => {
const active = activeCollectionId === c.id
return (
<TouchableOpacity
key={c.id}
style={[styles.item, active && { backgroundColor: colors.primaryLight }]}
onPress={() => { onCollectionSelect(active ? null : c.id); onClose() }}
>
<Ionicons name="folder-outline" size={18} color={active ? colors.primary : colors.textSecondary} />
<Text style={[styles.label, { color: active ? colors.primary : colors.text }]} numberOfLines={1}>
{c.name}
</Text>
<Text style={[styles.count, { color: colors.textSecondary }]}>{c.count}</Text>
</TouchableOpacity>
)
})}
</View>
)}
</ScrollView>
</Animated.View>
</Modal>
)
Expand All @@ -93,4 +123,11 @@ const styles = StyleSheet.create({
},
label: { flex: 1, fontFamily: fonts.sansMedium, fontSize: 15 },
count: { fontFamily: fonts.sans, fontSize: 13 },
sectionHeading: {
fontFamily: fonts.sansMedium,
fontSize: 11,
letterSpacing: 0.6,
paddingHorizontal: 12,
paddingVertical: 8,
},
})
Loading