diff --git a/_contract/MOBILE_API_SPEC.md b/_contract/MOBILE_API_SPEC.md index e511cbc..dc0964c 100644 --- a/_contract/MOBILE_API_SPEC.md +++ b/_contract/MOBILE_API_SPEC.md @@ -69,7 +69,7 @@ All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on - `POST /auth/logout` — revoke current token. - `GET /me` — profile. `PATCH /me` — edit profile. `POST /me/password` — change password. - `GET /me/devices` — list devices. `DELETE /me/devices/{id}` — revoke a device. -- `GET /catalog/search` — filters: `q`, `author`, `publisher`, `genre` (cascade id), `language`, `available` (bool); cursor pagination. +- `GET /catalog/search` — filters: `q`, `author`, `publisher`, `genre` (cascade id), `language`, `available` (bool); `sort`: `newest` (default), `oldest`, `title_asc`, `title_desc`; cursor pagination. - `GET /catalog/books/{id}` — full detail + personal history. - `GET /catalog/books/{id}/availability` — per-day availability calendar for the loan/reservation date picker. - `GET /catalog/books/{id}/reviews` — aggregate rating (`average`, `count`, `distribution`) + the user's own review (`mine`) + `can_review` (has the user borrowed the title) + cursor-paginated `items` (other users' reviews). Same feature as the web review view. diff --git a/_contract/openapi.json b/_contract/openapi.json index 7db58db..b0f94e2 100644 --- a/_contract/openapi.json +++ b/_contract/openapi.json @@ -1758,6 +1758,21 @@ }, "description": "If true, return only books with at least one loanable copy" }, + { + "name": "sort", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "newest", + "oldest", + "title_asc", + "title_desc" + ], + "default": "newest" + }, + "description": "Sort order: newest, oldest, title_asc, or title_desc" + }, { "name": "cursor", "in": "query", diff --git a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt index be07ebe..dbcf9da 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt @@ -97,6 +97,8 @@ interface PinakesApi { @Query("available") available: Boolean? = null, @Query("cursor") cursor: String? = null, @Query("limit") limit: Int? = null, // 1..50, default 20 + // newest (default) | oldest | title_asc | title_desc + @Query("sort") sort: String? = null, ): Envelope> @GET("catalog/books/{id}") diff --git a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt index fee579b..969461e 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt @@ -85,6 +85,8 @@ class CatalogRepository( filters: SearchFilters, cursor: String? = null, limit: Int = 20, + // newest (default) | oldest | title_asc | title_desc; null → server default (newest). + sort: String? = null, ): ApiResult { val api = network.api() return when ( @@ -98,6 +100,7 @@ class CatalogRepository( available = filters.availableOnly, cursor = cursor, limit = limit.coerceIn(1, 50), + sort = sort?.takeIf { it.isNotBlank() }, ) } ) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt index 3ceee92..6e40c14 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt @@ -70,7 +70,7 @@ fun HomeScreen( HomePhase.Loading -> Column(Modifier.fillMaxSize()) { HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode) Column(Modifier.padding(horizontal = Spacing.lg)) { - SectionHeader(showSeeAll = false, onSeeAll = {}) + SectionHeader(showSeeAll = false, catalogueMode = catalogueMode, onSeeAll = {}) Spacer(Modifier.height(Spacing.md)) repeat(4) { BookCardSkeleton() @@ -103,7 +103,7 @@ fun HomeScreen( item { HomeHeader(libraryName = state.libraryName, catalogueMode = catalogueMode) } item { Box(Modifier.padding(horizontal = Spacing.lg)) { - SectionHeader(showSeeAll = true, onSeeAll = onBrowseCatalog) + SectionHeader(showSeeAll = true, catalogueMode = catalogueMode, onSeeAll = onBrowseCatalog) } } items(state.available, key = { it.id }) { book -> @@ -178,20 +178,24 @@ private fun HomeHeader(libraryName: String?, catalogueMode: Boolean) { } @Composable -private fun SectionHeader(showSeeAll: Boolean, onSeeAll: () -> Unit) { +private fun SectionHeader(showSeeAll: Boolean, catalogueMode: Boolean, onSeeAll: () -> Unit) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { Text( - text = stringResource(R.string.home_section_available), + // In CATALOGUE-ONLY MODE the shelf isn't about borrowing — it's the newest + // titles added to the library, so label it "Recently added" to match. + text = if (catalogueMode) stringResource(R.string.home_section_recent) + else stringResource(R.string.home_section_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = stringResource(R.string.home_section_available_subtitle), + text = if (catalogueMode) stringResource(R.string.home_section_recent_subtitle) + else stringResource(R.string.home_section_available_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt index f3c2d35..07cfaa4 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt @@ -6,14 +6,19 @@ import com.pinakes.app.data.model.BookSummary import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.SearchFilters +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -31,6 +36,7 @@ data class HomeUiState( class HomeViewModel @Inject constructor( private val catalog: CatalogRepository, private val session: SessionStore, + private val features: FeatureStore, ) : ViewModel() { private val _state = MutableStateFlow( @@ -47,23 +53,52 @@ class HomeViewModel @Inject constructor( init { observeCache() - refresh() + observeCatalogueMode() } /** - * Offline fallback: render the "Available now" shelf from the locally-cached catalog - * snapshot immediately (works with no network), filtering to currently-loanable - * copies. Only a partial answer — the cache holds the first unfiltered page — so it is - * superseded as soon as [refresh] gets the server-side availability query through. + * `catalogueMode` alone, de-duplicated, so downstream only reacts when the lending↔catalogue + * flag actually flips — not on every unrelated `/health` emission that leaves it unchanged. + */ + private fun catalogueMode(): Flow = + features.features.map { it.catalogueMode }.distinctUntilChanged() + + /** + * Drive [refresh] off [catalogueMode]: once on the initial (persisted) value, then again each + * time the flag flips. This is what makes the shelf reactive — on a cold start `/health` can + * resolve AFTER the first refresh, and a mode switch can land at any time; either way the + * server-side shelf query (availableOnly vs unfiltered) is re-driven to match. [collectLatest] + * abandons a stale re-drive when the flag flips again mid-flight. + */ + private fun observeCatalogueMode() { + viewModelScope.launch { + catalogueMode().collectLatest { refresh() } + } + } + + /** + * Offline fallback: render the home shelf from the locally-cached catalog snapshot + * immediately. Loan mode keeps the "Available now" shelf filtered to currently-loanable + * copies; catalogue-only mode labels the shelf "Recently added", so the cache fallback + * must stay unfiltered. Only a partial answer — the cache holds the first unfiltered page — + * so it is superseded as soon as [refresh] gets the server-side query through. */ private fun observeCache() { viewModelScope.launch { - catalog.observeCachedCatalog().collectLatest { books -> + // Re-run the filter whenever EITHER the cache OR catalogueMode changes, so a mode flip + // that lands before the first server-side shelf resolves still re-labels/re-filters the + // offline fallback instead of leaving it wrong until [refresh] gets through. + combine( + catalog.observeCachedCatalog(), + catalogueMode(), + ) { books, catalogueMode -> books to catalogueMode }.collectLatest { (books, catalogueMode) -> if (hasFreshShelf) return@collectLatest - val available = books.filter { it.available } + val shelfBooks = + if (catalogueMode) books + else books.filter { it.available } _state.update { it.copy( - available = available, + available = shelfBooks, // Keep the loading skeleton until the first [refresh] // resolves when the cache is still empty (cold start): // an empty first emission must not flash the @@ -86,7 +121,14 @@ class HomeViewModel @Inject constructor( */ fun refresh() { viewModelScope.launch { - val shelf = async { catalog.search(SearchFilters(availableOnly = true), limit = SHELF_LIMIT) } + // Catalogue-only mode labels the shelf "Recently added / latest additions", so + // query the newest titles unfiltered (no availability filter) to make the label + // honest. Loan mode keeps the availability filter behind the "Available now" shelf. + // Both lean on the server's default newest-first sort. + val shelfFilters = + if (features.features.value.catalogueMode) SearchFilters() + else SearchFilters(availableOnly = true) + val shelf = async { catalog.search(shelfFilters, limit = SHELF_LIMIT) } launch { catalog.refreshCatalog() } when (val res = shelf.await()) { is ApiResult.Success -> { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchFilterSheet.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchFilterSheet.kt index 7c8b4a2..a7ccd44 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchFilterSheet.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchFilterSheet.kt @@ -107,9 +107,18 @@ fun SearchFilterSheet( Spacer(Modifier.height(Spacing.lg)) - // --- Genre (top-level catalog genres from /catalog/genres) --- + // --- Genre (cascade from /catalog/genres) --- + // The backend `genre` filter matches at ANY level, so whatever node the user + // selects — a top category or a sub-category several levels deep — its id is + // sent as `genre`. Selecting a top category still means "everything under it". if (state.genres.isNotEmpty()) { SectionLabel(stringResource(R.string.filters_section_genre)) + // Root→selected node path; drives which sub-category rows are revealed and + // which chip is highlighted at each level (a breadcrumb of the chosen branch). + val path: List = + state.selectedGenreId?.let { genrePath(state.genres, it) } ?: emptyList() + + // Level 0: "All" + top categories. FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { FilterChip( selected = state.selectedGenreId == null, @@ -119,13 +128,34 @@ fun SearchFilterSheet( ) state.genres.forEach { g: GenreNode -> FilterChip( - selected = state.selectedGenreId == g.id, + selected = path.getOrNull(0)?.id == g.id, onClick = { onGenreChange(g.id) }, label = { Text(g.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, colors = chipColors, ) } } + + // Deeper levels: for each selected node that has children, drill into its + // sub-categories. Tapping a sub-category narrows the filter to that id. + path.forEachIndexed { level, node -> + if (node.children.isNotEmpty()) { + Spacer(Modifier.height(Spacing.md)) + // Label the revealed child row with its parent's name so the user can + // tell which level of the drill-down each set of chips belongs to. + SectionLabel(stringResource(R.string.filters_section_within, node.name)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + node.children.forEach { child: GenreNode -> + FilterChip( + selected = path.getOrNull(level + 1)?.id == child.id, + onClick = { onGenreChange(child.id) }, + label = { Text(child.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + colors = chipColors, + ) + } + } + } + } Spacer(Modifier.height(Spacing.lg)) } @@ -194,6 +224,30 @@ fun SearchFilterSheet( } } +/** Guard against a pathological or cyclic genre tree overflowing the stack. */ +private const val MAX_GENRE_DEPTH = 8 + +/** + * Depth-first search for [id] in the genre tree, returning the root→node path (inclusive) + * or an empty list if not found. Used to reveal the selected branch's sub-category rows. + * + * Bounded by [MAX_GENRE_DEPTH] and a visited-id set so a cyclic or unexpectedly deep tree + * (e.g. from a malformed server payload) can't cause unbounded recursion / a StackOverflow. + */ +private fun genrePath(nodes: List, id: Int): List { + fun walk(level: List, depth: Int, visited: MutableSet): List { + if (depth > MAX_GENRE_DEPTH) return emptyList() + for (node in level) { + if (!visited.add(node.id)) continue // repeat id (cycle) → don't descend again + if (node.id == id) return listOf(node) + val sub = walk(node.children, depth + 1, visited) + if (sub.isNotEmpty()) return listOf(node) + sub + } + return emptyList() + } + return walk(nodes, 0, mutableSetOf()) +} + @Composable private fun SectionLabel(text: String) { Text( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt index 77841c7..750eba9 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt @@ -15,11 +15,15 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Sort +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -29,10 +33,15 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -95,6 +104,7 @@ fun SearchScreen(onBookClick: (Int) -> Unit) { modifier = Modifier.weight(1f), ) Spacer(Modifier.height(0.dp)) + SortMenu(current = state.sort, onSelect = vm::setSort) BadgedBox( badge = { if (state.hasActiveFilters) { @@ -188,9 +198,24 @@ fun SearchScreen(onBookClick: (Int) -> Unit) { ) } } - if (header != null) { + // Surface the active sort order alongside the result count so the + // current order is visible without opening the sort menu. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { Text( - header, + text = header.orEmpty(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f, fill = false), + ) + Text( + text = stringResource( + R.string.sort_label, + stringResource(state.sort.labelRes), + ), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -230,6 +255,51 @@ fun SearchScreen(onBookClick: (Int) -> Unit) { } } +/** + * Sort-order picker: an icon button that opens a dropdown of the four supported catalog + * orders. The active order carries a check. Choosing an order resets pagination and reloads + * from the first page (handled in the ViewModel); the choice survives filter changes. + */ +@Composable +private fun SortMenu(current: BookSort, onSelect: (BookSort) -> Unit) { + var expanded by remember { mutableStateOf(false) } + // Announce the active order to TalkBack as the button's state (e.g. "Sort, Newest"). + val activeSortLabel = stringResource(current.labelRes) + Box { + IconButton( + onClick = { expanded = true }, + modifier = Modifier.semantics { stateDescription = activeSortLabel }, + ) { + Icon( + Icons.AutoMirrored.Outlined.Sort, + contentDescription = stringResource(R.string.cd_sort), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + BookSort.entries.forEach { option -> + DropdownMenuItem( + modifier = Modifier.semantics { selected = (option == current) }, + text = { Text(stringResource(option.labelRes)) }, + onClick = { + expanded = false + onSelect(option) + }, + trailingIcon = if (option == current) { + { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else null, + ) + } + } + } +} + private enum class SearchPhase { Loading, Error, Initial, Empty, Results } fun BookSummary.availabilityStatus(): AvailabilityStatus = diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt index 8a708f2..081472c 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt @@ -19,6 +19,18 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +/** + * Catalog sort order. `apiValue` is the exact `sort` query param the backend accepts on + * `/catalog/search`; `labelRes` is the localized label shown in the sort picker. Author + * sorts are intentionally absent — the backend does not support them. + */ +enum class BookSort(val apiValue: String, @StringRes val labelRes: Int) { + NEWEST("newest", R.string.sort_newest), + OLDEST("oldest", R.string.sort_oldest), + TITLE_ASC("title_asc", R.string.sort_title_asc), + TITLE_DESC("title_desc", R.string.sort_title_desc), +} + data class SearchUiState( val query: String = "", val availableOnly: Boolean = false, @@ -26,6 +38,7 @@ data class SearchUiState( val author: String = "", val publisher: String = "", val language: String? = null, + val sort: BookSort = BookSort.NEWEST, val genres: List = emptyList(), val items: List = emptyList(), val nextCursor: String? = null, @@ -73,6 +86,15 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository private var searchJob: Job? = null + /** + * Monotonic request generation. Bumped on every reset ([runSearch] with `reset = true`, + * i.e. a new query/sort/filter set). Network coroutines capture the generation at launch + * and discard their result if it changed meanwhile, so a slow [loadMore] or debounced + * search that resolves AFTER a sort/filter reset can't append a stale-sort page or restore + * a mismatched cursor. + */ + private var searchGeneration = 0 + init { loadGenres() // Catalog lands on the full listing — browse-all (empty query, no filters). @@ -117,6 +139,16 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository fun setGenreDraft(id: Int?) = _state.update { it.copy(selectedGenreId = id) } + /** + * Change the catalog sort order. The cursor is sort-specific, so a change resets + * pagination and reloads from the first page. No-op if the order is unchanged. + */ + fun setSort(sort: BookSort) { + if (_state.value.sort == sort) return + _state.update { it.copy(sort = sort) } + runSearch(reset = true) + } + /** Run the search with the currently staged facet filters. */ fun applyFilters() = runSearch(reset = true) @@ -151,8 +183,13 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository val s = _state.value if (s.loadingMore || s.loading || !s.hasMore) return _state.update { it.copy(loadingMore = true) } + val generation = searchGeneration viewModelScope.launch { - when (val res = catalog.search(filters(), cursor = s.nextCursor)) { + val res = catalog.search(filters(), cursor = s.nextCursor, sort = s.sort.apiValue) + // A sort/filter reset superseded this page mid-flight: drop it so we neither append + // stale-sort items nor overwrite the fresh cursor. The reset already cleared loadingMore. + if (generation != searchGeneration) return@launch + when (res) { is ApiResult.Success -> _state.update { it.copy( items = it.items + res.data.items, @@ -167,9 +204,25 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository } private fun runSearch(reset: Boolean) { - _state.update { it.copy(loading = true, error = null, items = if (reset) emptyList() else it.items, nextCursor = null) } + if (reset) searchGeneration++ + val generation = searchGeneration + _state.update { + it.copy( + loading = true, + error = null, + items = if (reset) emptyList() else it.items, + nextCursor = null, + // A reset also cancels any in-flight pagination so a stale loadMore can't leave + // the spinner stuck (its result is dropped by the generation guard). + loadingMore = if (reset) false else it.loadingMore, + ) + } + val sort = _state.value.sort.apiValue viewModelScope.launch { - when (val res = catalog.search(filters())) { + val res = catalog.search(filters(), sort = sort) + // Discard if a newer reset (query/sort/filter change) has since superseded this run. + if (generation != searchGeneration) return@launch + when (res) { is ApiResult.Success -> _state.update { it.copy( items = res.data.items, diff --git a/i18n/de.json b/i18n/de.json index 813f0ba..ce3e8dd 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -22,6 +22,7 @@ "cd_clear": "Löschen", "cd_remove": "Entfernen", "cd_filters": "Filter", + "cd_sort": "Sortieren", "cd_show_password": "Passwort anzeigen", "cd_hide_password": "Passwort verbergen", "cd_add_to_wishlist": "Zur Merkliste hinzufügen", @@ -95,7 +96,9 @@ "home_subtitle_catalogue": "Durchstöbere den Bibliothekskatalog.", "home_browse_only_note": "Bibliothek nur zum Stöbern", "home_section_available": "Jetzt verfügbar", - "home_section_available_subtitle": "Heute ausleihbar", + "home_section_available_subtitle": "Die neuesten ausleihbaren Titel", + "home_section_recent": "Kürzlich hinzugefügt", + "home_section_recent_subtitle": "Die neuesten Titel der Bibliothek", "home_see_all": "Alle anzeigen", "home_browse_catalog": "Katalog durchsuchen", "home_loading": "Bibliothek wird geladen…", @@ -108,6 +111,8 @@ "filters_available_now": "Nur jetzt verfügbare", "filters_section_genre": "Genre", "filters_all_genres": "Alle", + "filters_section_subgenre": "Unterkategorie", + "filters_section_within": "Innerhalb von %1$s", "filters_section_author": "Autor", "filters_author_placeholder": "z. B. Calvino", "filters_section_publisher": "Verlag", @@ -116,6 +121,11 @@ "filters_all_languages": "Alle", "filters_apply": "Anwenden", "filters_apply_count": "Filter anwenden (%1$d)", + "sort_newest": "Neueste", + "sort_oldest": "Älteste", + "sort_title_asc": "Titel (A–Z)", + "sort_title_desc": "Titel (Z–A)", + "sort_label": "Sortierung: %1$s", "lang_italian": "Italienisch", "lang_english": "Englisch", "lang_french": "Französisch", diff --git a/i18n/en.json b/i18n/en.json index ec95117..8e67fc0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -22,6 +22,7 @@ "cd_clear": "Clear", "cd_remove": "Remove", "cd_filters": "Filters", + "cd_sort": "Sort", "cd_show_password": "Show password", "cd_hide_password": "Hide password", "cd_add_to_wishlist": "Add to wishlist", @@ -95,7 +96,9 @@ "home_subtitle_catalogue": "Browse the library catalog.", "home_browse_only_note": "Browse-only library", "home_section_available": "Available now", - "home_section_available_subtitle": "Ready to borrow today", + "home_section_available_subtitle": "The newest titles ready to borrow", + "home_section_recent": "Recently added", + "home_section_recent_subtitle": "The latest additions to the library", "home_see_all": "See all", "home_browse_catalog": "Browse the catalog", "home_loading": "Loading your library…", @@ -108,6 +111,8 @@ "filters_available_now": "Available now only", "filters_section_genre": "Genre", "filters_all_genres": "All", + "filters_section_subgenre": "Sub-category", + "filters_section_within": "Within %1$s", "filters_section_author": "Author", "filters_author_placeholder": "e.g. Calvino", "filters_section_publisher": "Publisher", @@ -116,6 +121,11 @@ "filters_all_languages": "All", "filters_apply": "Apply", "filters_apply_count": "Apply filters (%1$d)", + "sort_newest": "Newest", + "sort_oldest": "Oldest", + "sort_title_asc": "Title (A–Z)", + "sort_title_desc": "Title (Z–A)", + "sort_label": "Sort: %1$s", "lang_italian": "Italian", "lang_english": "English", "lang_french": "French", diff --git a/i18n/fr.json b/i18n/fr.json index 67c542e..d4ff1dd 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -22,6 +22,7 @@ "cd_clear": "Effacer", "cd_remove": "Supprimer", "cd_filters": "Filtres", + "cd_sort": "Trier", "cd_show_password": "Afficher le mot de passe", "cd_hide_password": "Masquer le mot de passe", "cd_add_to_wishlist": "Ajouter aux favoris", @@ -95,7 +96,9 @@ "home_subtitle_catalogue": "Parcourez le catalogue de la bibliothèque.", "home_browse_only_note": "Bibliothèque en consultation seule", "home_section_available": "Disponibles maintenant", - "home_section_available_subtitle": "Prêts à emprunter aujourd'hui", + "home_section_available_subtitle": "Les derniers titres empruntables", + "home_section_recent": "Ajouts récents", + "home_section_recent_subtitle": "Les derniers ajouts à la bibliothèque", "home_see_all": "Voir tout", "home_browse_catalog": "Parcourir le catalogue", "home_loading": "Chargement de votre bibliothèque…", @@ -108,6 +111,8 @@ "filters_available_now": "Disponibles maintenant uniquement", "filters_section_genre": "Genre", "filters_all_genres": "Tous", + "filters_section_subgenre": "Sous-catégorie", + "filters_section_within": "Dans %1$s", "filters_section_author": "Auteur", "filters_author_placeholder": "Ex. Calvino", "filters_section_publisher": "Éditeur", @@ -116,6 +121,11 @@ "filters_all_languages": "Toutes", "filters_apply": "Appliquer", "filters_apply_count": "Appliquer les filtres (%1$d)", + "sort_newest": "Plus récents", + "sort_oldest": "Plus anciens", + "sort_title_asc": "Titre (A–Z)", + "sort_title_desc": "Titre (Z–A)", + "sort_label": "Trier : %1$s", "lang_italian": "Italien", "lang_english": "Anglais", "lang_french": "Français", diff --git a/i18n/it.json b/i18n/it.json index d75794b..2d00d6a 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -22,6 +22,7 @@ "cd_clear": "Cancella", "cd_remove": "Rimuovi", "cd_filters": "Filtri", + "cd_sort": "Ordina", "cd_show_password": "Mostra password", "cd_hide_password": "Nascondi password", "cd_add_to_wishlist": "Aggiungi ai preferiti", @@ -95,7 +96,9 @@ "home_subtitle_catalogue": "Sfoglia il catalogo della biblioteca.", "home_browse_only_note": "Biblioteca in sola consultazione", "home_section_available": "Disponibili ora", - "home_section_available_subtitle": "Pronti da prendere in prestito oggi", + "home_section_available_subtitle": "Gli ultimi titoli disponibili al prestito", + "home_section_recent": "Aggiunti di recente", + "home_section_recent_subtitle": "Le ultime novità della biblioteca", "home_see_all": "Vedi tutti", "home_browse_catalog": "Sfoglia il catalogo", "home_loading": "Caricamento della tua biblioteca…", @@ -108,6 +111,8 @@ "filters_available_now": "Solo disponibili ora", "filters_section_genre": "Genere", "filters_all_genres": "Tutti", + "filters_section_subgenre": "Sottocategoria", + "filters_section_within": "In %1$s", "filters_section_author": "Autore", "filters_author_placeholder": "Es. Calvino", "filters_section_publisher": "Editore", @@ -116,6 +121,11 @@ "filters_all_languages": "Tutte", "filters_apply": "Applica", "filters_apply_count": "Applica filtri (%1$d)", + "sort_newest": "Più recenti", + "sort_oldest": "Meno recenti", + "sort_title_asc": "Titolo (A–Z)", + "sort_title_desc": "Titolo (Z–A)", + "sort_label": "Ordina: %1$s", "lang_italian": "Italiano", "lang_english": "Inglese", "lang_french": "Francese",