tag suggestion
This commit is contained in:
@@ -18,15 +18,15 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.datetime.Clock.System.now
|
import kotlinx.datetime.Clock.System.now
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
|
||||||
import java.nio.IntBuffer
|
import java.nio.IntBuffer
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
const val domain = "ltn.hitomi.la"
|
const val domain = "ltn.hitomi.la"
|
||||||
const val galleryBlockExtension = ".html"
|
|
||||||
const val galleryBlockDir = "galleryblock"
|
|
||||||
const val nozomiExtension = ".nozomi"
|
const val nozomiExtension = ".nozomi"
|
||||||
|
|
||||||
const val compressedNozomiPrefix = "n"
|
const val compressedNozomiPrefix = "n"
|
||||||
@@ -35,8 +35,10 @@ const val B = 16
|
|||||||
const val indexDir = "tagindex"
|
const val indexDir = "tagindex"
|
||||||
const val maxNodeSize = 464
|
const val maxNodeSize = 464
|
||||||
const val galleriesIndexDir = "galleriesindex"
|
const val galleriesIndexDir = "galleriesindex"
|
||||||
const val languagesIndexDir = "languagesindex"
|
const val tagIndexDomain = "tagindex.hitomi.la"
|
||||||
const val nozomiURLIndexDir = "nozomiurlindex"
|
|
||||||
|
const val separator = "-"
|
||||||
|
const val extension = ".html"
|
||||||
|
|
||||||
data class Suggestion(
|
data class Suggestion(
|
||||||
val tag: SearchQuery.Tag,
|
val tag: SearchQuery.Tag,
|
||||||
@@ -183,37 +185,16 @@ object HitomiHttpClient {
|
|||||||
return getURLAtRange(url, offset until (offset + length)).asIntBuffer()
|
return getURLAtRange(url, offset until (offset + length)).asIntBuffer()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSuggestionsFromData(field: String, data: Node.Data): List<Suggestion> {
|
private fun encodeSearchQueryForUrl(s: Char) =
|
||||||
val url = "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.data"
|
when (s) {
|
||||||
val (offset, length) = data
|
' ' -> "_"
|
||||||
|
'/' -> "slash"
|
||||||
check(data.length in 1..10000) { "Invalid length ${data.length}" }
|
'.' -> "dot"
|
||||||
|
else -> s.toString()
|
||||||
val buffer = getURLAtRange(url, offset..<offset + length).order(ByteOrder.BIG_ENDIAN)
|
|
||||||
|
|
||||||
val numberOfSuggestions = buffer.int
|
|
||||||
|
|
||||||
check(numberOfSuggestions in 1..100) { "Number of suggestions $numberOfSuggestions is too long" }
|
|
||||||
|
|
||||||
return buildList {
|
|
||||||
for (i in 0..<numberOfSuggestions) {
|
|
||||||
val namespaceLen = buffer.int
|
|
||||||
val namespace = ByteArray(namespaceLen).apply {
|
|
||||||
buffer.get(this)
|
|
||||||
}.toString(charset("UTF-8"))
|
|
||||||
|
|
||||||
val tagLen = buffer.int
|
|
||||||
val tag = ByteArray(tagLen).apply {
|
|
||||||
buffer.get(this)
|
|
||||||
}.toString(charset("UTF-8"))
|
|
||||||
|
|
||||||
val count = buffer.int
|
|
||||||
|
|
||||||
add(Suggestion(SearchQuery.Tag(namespace, tag), count))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun sanitize(s: String) = s.replace(Regex("[/#]"), "")
|
||||||
|
|
||||||
private suspend fun getGalleryIDsFromNozomi(
|
private suspend fun getGalleryIDsFromNozomi(
|
||||||
area: String?,
|
area: String?,
|
||||||
tag: String,
|
tag: String,
|
||||||
@@ -258,11 +239,44 @@ object HitomiHttpClient {
|
|||||||
suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> =
|
suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> =
|
||||||
runCatching {
|
runCatching {
|
||||||
val field = query.namespace ?: "global"
|
val field = query.namespace ?: "global"
|
||||||
val key = Node.Key(query.tag)
|
val chars = query.tag.map(::encodeSearchQueryForUrl)
|
||||||
val node = getNodeAtAddress(field, 0)
|
|
||||||
val data = bSearch(field, key, node)
|
|
||||||
|
|
||||||
data?.let { getSuggestionsFromData(field, data) } ?: emptyList()
|
val suggestions = json.parseToJsonElement(
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
httpClient.get(
|
||||||
|
"https://$tagIndexDomain/$field${
|
||||||
|
if (chars.isNotEmpty()) "/${
|
||||||
|
chars.joinToString(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
|
}" else ""
|
||||||
|
}.json"
|
||||||
|
).bodyAsText()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
buildList {
|
||||||
|
suggestions.jsonArray.forEach { suggestionRaw ->
|
||||||
|
val suggestion = suggestionRaw.jsonArray
|
||||||
|
if (suggestion.size < 3) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val namespace = suggestion[2].jsonPrimitive.contentOrNull ?: ""
|
||||||
|
|
||||||
|
val tag =
|
||||||
|
sanitize(suggestion[0].jsonPrimitive.contentOrNull ?: return@forEach)
|
||||||
|
|
||||||
|
add(
|
||||||
|
Suggestion(
|
||||||
|
SearchQuery.Tag(
|
||||||
|
namespace,
|
||||||
|
tag
|
||||||
|
),
|
||||||
|
suggestion[1].jsonPrimitive.contentOrNull?.toIntOrNull() ?: 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getGalleryInfo(galleryID: Int) = runCatching {
|
suspend fun getGalleryInfo(galleryID: Int) = runCatching {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.AddCircleOutline
|
|||||||
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardColors
|
import androidx.compose.material3.CardColors
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -195,7 +196,14 @@ fun TagSuggestionList(
|
|||||||
|
|
||||||
val suggestionListSnapshot = suggestionList
|
val suggestionListSnapshot = suggestionList
|
||||||
if (suggestionListSnapshot == null) {
|
if (suggestionListSnapshot == null) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(Modifier.size(24.dp))
|
||||||
Text("Loading")
|
Text("Loading")
|
||||||
|
}
|
||||||
} else if (suggestionListSnapshot.isNotEmpty()) {
|
} else if (suggestionListSnapshot.isNotEmpty()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(8.dp),
|
modifier = Modifier.padding(8.dp),
|
||||||
@@ -272,7 +280,8 @@ fun EditableTagChip(
|
|||||||
positionY = it.positionInRoot().y
|
positionY = it.positionInRoot().y
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
color = surfaceColor
|
color = surfaceColor,
|
||||||
|
shadowElevation = 4.dp
|
||||||
) {
|
) {
|
||||||
AnimatedContent(targetState = expanded, label = "open tag editor") { targetExpanded ->
|
AnimatedContent(targetState = expanded, label = "open tag editor") { targetExpanded ->
|
||||||
if (!targetExpanded) {
|
if (!targetExpanded) {
|
||||||
@@ -424,7 +433,7 @@ fun NewQueryChip(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(shape = RoundedCornerShape(16.dp)) {
|
Surface(shape = RoundedCornerShape(16.dp), shadowElevation = 4.dp) {
|
||||||
AnimatedContent(targetState = opened, label = "add new query") { targetOpened ->
|
AnimatedContent(targetState = opened, label = "add new query") { targetOpened ->
|
||||||
if (targetOpened) {
|
if (targetOpened) {
|
||||||
Column {
|
Column {
|
||||||
|
|||||||
@@ -236,26 +236,32 @@ fun TagChip(
|
|||||||
shape = shape,
|
shape = shape,
|
||||||
color = surfaceColor,
|
color = surfaceColor,
|
||||||
onClick = { onClick(tag) },
|
onClick = { onClick(tag) },
|
||||||
content = inner
|
content = inner,
|
||||||
|
shadowElevation = 4.dp
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Surface(
|
Surface(
|
||||||
modifier,
|
modifier,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
color = surfaceColor,
|
color = surfaceColor,
|
||||||
content = inner
|
content = inner,
|
||||||
|
shadowElevation = 4.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun QueryView(
|
fun QueryView(
|
||||||
query: SearchQuery?,
|
query: SearchQuery?,
|
||||||
topLevel: Boolean = true
|
topLevel: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val modifier = if (topLevel) {
|
val modifier = if (topLevel) {
|
||||||
Modifier
|
Modifier
|
||||||
} else {
|
} else {
|
||||||
Modifier.border(width = 0.5.dp, color = LocalContentColor.current, shape = CardDefaults.shape)
|
Modifier.border(
|
||||||
|
width = 0.5.dp,
|
||||||
|
color = LocalContentColor.current,
|
||||||
|
shape = CardDefaults.shape
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (query) {
|
when (query) {
|
||||||
@@ -269,24 +275,29 @@ fun QueryView(
|
|||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is SearchQuery.Tag -> {
|
is SearchQuery.Tag -> {
|
||||||
TagChip(
|
TagChip(
|
||||||
query,
|
query,
|
||||||
enabled = false
|
enabled = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is SearchQuery.Or -> {
|
is SearchQuery.Or -> {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.padding(vertical=2.dp, horizontal=4.dp),
|
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
query.queries.forEachIndexed { index, subQuery ->
|
query.queries.forEachIndexed { index, subQuery ->
|
||||||
if (index != 0) { Text("+") }
|
if (index != 0) {
|
||||||
|
Text("+")
|
||||||
|
}
|
||||||
QueryView(subQuery, topLevel = false)
|
QueryView(subQuery, topLevel = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is SearchQuery.And -> {
|
is SearchQuery.And -> {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
@@ -297,9 +308,10 @@ fun QueryView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is SearchQuery.Not -> {
|
is SearchQuery.Not -> {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.padding(vertical=2.dp, horizontal=4.dp),
|
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -318,10 +330,13 @@ fun SearchBar(
|
|||||||
onSearchBarPositioned: (Int) -> Unit,
|
onSearchBarPositioned: (Int) -> Unit,
|
||||||
topOffset: Int,
|
topOffset: Int,
|
||||||
onTopOffsetChange: (Int) -> Unit,
|
onTopOffsetChange: (Int) -> Unit,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
val scrimAlpha: Float by animateFloatAsState(if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f, label = "scrim alpha")
|
val scrimAlpha: Float by animateFloatAsState(
|
||||||
|
if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f,
|
||||||
|
label = "scrim alpha"
|
||||||
|
)
|
||||||
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
@@ -351,7 +366,10 @@ fun SearchBar(
|
|||||||
focused = false
|
focused = false
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
val height: Dp by animateDpAsState(if (focused) maxHeight else 60.dp, label = "searchbar height")
|
val height: Dp by animateDpAsState(
|
||||||
|
if (focused) maxHeight else 60.dp,
|
||||||
|
label = "searchbar height"
|
||||||
|
)
|
||||||
val cardShape = RoundedCornerShape(30.dp)
|
val cardShape = RoundedCornerShape(30.dp)
|
||||||
|
|
||||||
content()
|
content()
|
||||||
@@ -359,7 +377,8 @@ fun SearchBar(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black.copy(alpha = scrimAlpha)))
|
.background(Color.Black.copy(alpha = scrimAlpha))
|
||||||
|
)
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -381,7 +400,11 @@ fun SearchBar(
|
|||||||
elevation = CardDefaults.cardElevation(6.dp)
|
elevation = CardDefaults.cardElevation(6.dp)
|
||||||
) {
|
) {
|
||||||
Box {
|
Box {
|
||||||
androidx.compose.animation.AnimatedVisibility(!focused, enter = fadeIn(), exit = fadeOut()) {
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
!focused,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.heightIn(min = 60.dp)
|
.heightIn(min = 60.dp)
|
||||||
@@ -393,11 +416,16 @@ fun SearchBar(
|
|||||||
Box(Modifier.size(8.dp))
|
Box(Modifier.size(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
androidx.compose.animation.AnimatedVisibility(focused, enter = fadeIn(), exit = fadeOut()) {
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
focused,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(top = 8.dp, start = 8.dp, end = 8.dp)) {
|
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
|
||||||
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
focused = false
|
focused = false
|
||||||
@@ -433,7 +461,7 @@ fun GalleryList(
|
|||||||
var topOffset by remember { mutableIntStateOf(0) }
|
var topOffset by remember { mutableIntStateOf(0) }
|
||||||
var searchBarPosition by remember { mutableIntStateOf(0) }
|
var searchBarPosition by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val listModifier = Modifier.nestedScroll(object: NestedScrollConnection {
|
val listModifier = Modifier.nestedScroll(object : NestedScrollConnection {
|
||||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||||
topOffset = (topOffset + available.y.roundToInt()).coerceIn(-searchBarPosition, 0)
|
topOffset = (topOffset + available.y.roundToInt()).coerceIn(-searchBarPosition, 0)
|
||||||
return Offset.Zero
|
return Offset.Zero
|
||||||
@@ -453,7 +481,7 @@ fun GalleryList(
|
|||||||
topOffset = topOffset,
|
topOffset = topOffset,
|
||||||
onTopOffsetChange = { topOffset = it },
|
onTopOffsetChange = { topOffset = it },
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility (loading, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(loading, enter = fadeIn(), exit = fadeOut()) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
}
|
}
|
||||||
@@ -465,7 +493,11 @@ fun GalleryList(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text("(´∇`)", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
|
Text(
|
||||||
|
"(´∇`)",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
|
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -474,11 +506,12 @@ fun GalleryList(
|
|||||||
OverscrollPager(
|
OverscrollPager(
|
||||||
prevPage = if (currentPage != 0) currentPage else null,
|
prevPage = if (currentPage != 0) currentPage else null,
|
||||||
nextPage = if (currentPage < maxPage) currentPage + 2 else null,
|
nextPage = if (currentPage < maxPage) currentPage + 2 else null,
|
||||||
onPageTurn = { onPageChange(it-1) }
|
onPageTurn = { onPageChange(it - 1) }
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = listModifier,
|
modifier = listModifier,
|
||||||
contentPadding = WindowInsets.systemBars.asPaddingValues().let { systemBarPaddingValues ->
|
contentPadding = WindowInsets.systemBars.asPaddingValues()
|
||||||
|
.let { systemBarPaddingValues ->
|
||||||
val layoutDirection = LocalLayoutDirection.current
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
PaddingValues(
|
PaddingValues(
|
||||||
top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
|
top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
|
||||||
@@ -490,7 +523,7 @@ fun GalleryList(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
state = listState
|
state = listState
|
||||||
) {
|
) {
|
||||||
items(galleries, key = { it.id }) {galleryInfo ->
|
items(galleries, key = { it.id }) { galleryInfo ->
|
||||||
DetailedGalleryInfo(
|
DetailedGalleryInfo(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -509,7 +542,7 @@ fun GalleryList(
|
|||||||
fun DetailScreen(
|
fun DetailScreen(
|
||||||
galleryInfo: GalleryInfo,
|
galleryInfo: GalleryInfo,
|
||||||
closeGalleryDetails: () -> Unit = { },
|
closeGalleryDetails: () -> Unit = { },
|
||||||
openGallery: (GalleryInfo) -> Unit = { }
|
openGallery: (GalleryInfo) -> Unit = { },
|
||||||
) {
|
) {
|
||||||
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
@@ -577,7 +610,7 @@ fun SearchScreen(
|
|||||||
closeGalleryDetails: () -> Unit,
|
closeGalleryDetails: () -> Unit,
|
||||||
onQueryChange: (SearchQuery?) -> Unit,
|
onQueryChange: (SearchQuery?) -> Unit,
|
||||||
loadSearchResult: (IntRange) -> Unit,
|
loadSearchResult: (IntRange) -> Unit,
|
||||||
openGallery: (GalleryInfo) -> Unit
|
openGallery: (GalleryInfo) -> Unit,
|
||||||
) {
|
) {
|
||||||
val itemsPerPage by remember { mutableIntStateOf(20) }
|
val itemsPerPage by remember { mutableIntStateOf(20) }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user