Дизайн Файлов (чанк 3): рескин каркаса главного экрана под макет
- FilesBrowserChrome.kt: поиск-пилюля + переключатель список⇄сетка, хлебные крошки (навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен) вместо десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки), пустое состояние (иконка+заголовок+подзаголовок). - Строки списка без разделителей (по макету). Токены F7Spacing/F7Radius. - material-icons-extended → feature:files (иконки chrome; точные из дизайна — потом). feature:files компилируется. Логика (загрузка/выбор/открытие) не менялась.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 3 — каркас списка): рескин главного экрана под мобильный макет (Figma files/*.png на инфре B). Новый FilesBrowserChrome.kt: поиск-пилюля + кнопка-переключатель список⇄сетка, хлебные крошки (🏠 › путь, навигация navigateToPath), фильтр-чипы (Тип файла/Участники/Изменен) ВМЕСТО десктопной колоночной шапки, вид СЕТКА (LazyVerticalGrid 2 кол., карточки), пустое состояние (иконка+заголовок+подзаголовок). Строки без разделителей (по макету). На токенах F7Spacing/F7Radius. material-icons-extended → feature:files (иконки chrome, потом точные из дизайна). Компилируется | контракты не менялись | средний: заметный UI-рескин Файлов; логика (загрузка/выбор/открытие) та же, сверка на устройстве
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Дизайн Файлов (чанк 2): локальные иконки типов файлов. 13 SVG из дизайна (PDF/Word/Spreadsheet/Presentation/Image/Audio/Video/Archive/Code/Text/Certificate/Eml/file) в assets/filetypes/, FileIcons переключён на них (file:///android_asset, coil-svg). Раньше иконки типов тянулись с сервера → офлайн ломался (аудит) + не гарантированно совпадали с дизайном. Папка пока с сервера (иконки папки в наборе нет — TODO из Figma). Дизайн-макеты (109 PNG) — на инфре B /home/foradmin/figma/, в git не бандлю (вес) | контракты не менялись | низкий: файловые иконки теперь офлайн и точно из дизайна
|
||||
|
||||
- 2026-07-08 | v0.5.114 (122) | Трек дизайна (старт): снятие дизайн-токенов из Figma роли design (файл ocVUCYCrfFUoqYYvlcLPBF, доступ по API-токену вне git). Цвета сверены — совпадают с F7Colors; типографика — Raleway 14/18/20/24; заведены F7Spacing (4dp-сетка) + F7Radius (аудит: не было spacing-токенов, ~1087 инлайновых .dp). docs/DESIGN-SOURCE.md — карта Figma + процесс попиксельной вёрстки. Источник — Figma, боевой forbion НЕ трогаю. Координация — mail/044 | дизайн-система (аддитивно, пока не используется) | нет: новые токены, поведение не меняется
|
||||
|
||||
@@ -33,6 +33,7 @@ dependencies {
|
||||
implementation composeBom
|
||||
implementation libs.compose.ui
|
||||
implementation libs.compose.material3
|
||||
implementation libs.compose.material.icons.extended
|
||||
implementation libs.compose.foundation
|
||||
implementation libs.lifecycle.viewmodel.compose
|
||||
implementation libs.coroutines.android
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package ru.forbion.f7cloud.feature.files
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Radius
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Spacing
|
||||
|
||||
enum class FilesViewMode { List, Grid }
|
||||
|
||||
/** Квадратная кнопка-переключатель вида список⇄сетка справа от поиска (макет F-17/F-22). */
|
||||
@Composable
|
||||
fun FilesViewToggleButton(viewMode: FilesViewMode, onToggle: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(F7Radius.md))
|
||||
.background(F7Colors.SecondaryButtonBg)
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.md))
|
||||
.clickable(onClick = onToggle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (viewMode == FilesViewMode.List) Icons.Filled.GridView else Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = "Сменить вид",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Хлебные крошки: домой + сегменты пути через ›, прокрутка по горизонтали (макет F-21/F-22). */
|
||||
@Composable
|
||||
fun FilesBreadcrumb(
|
||||
segments: List<String>,
|
||||
onHomeClick: () -> Unit,
|
||||
onSegmentClick: (index: Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = F7Spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Home,
|
||||
contentDescription = "В начало",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clickable(onClick = onHomeClick),
|
||||
)
|
||||
segments.forEachIndexed { i, seg ->
|
||||
Text(" › ", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
Text(
|
||||
text = seg,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (i == segments.lastIndex) F7Colors.TextPrimary else F7Colors.TextSecondary,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.clickable { onSegmentClick(i) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ряд фильтр-чипов: Тип файла / Участники / Изменен (макет F-18/F-22). */
|
||||
@Composable
|
||||
fun FilesFilterChips(
|
||||
onTypeClick: () -> Unit,
|
||||
onParticipantsClick: () -> Unit,
|
||||
onModifiedClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = F7Spacing.xs),
|
||||
horizontalArrangement = Arrangement.spacedBy(F7Spacing.sm),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterChip("Тип файла", onTypeClick)
|
||||
FilterChip("Участники", onParticipantsClick)
|
||||
FilterChip("Изменен", onModifiedClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterChip(label: String, onClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(F7Radius.pill))
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.pill))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = F7Spacing.md, vertical = F7Spacing.xs),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
/** Карточка сетки: чекбокс + звезда сверху, крупная иконка по центру, имя + ⋮ снизу (макет F-17). */
|
||||
@Composable
|
||||
fun FilesGridCard(
|
||||
serverUrl: String,
|
||||
item: FileItem,
|
||||
selected: Boolean,
|
||||
favorite: Boolean,
|
||||
onSelectedChange: () -> Unit,
|
||||
onOpenClick: () -> Unit,
|
||||
onMenuClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(F7Radius.lg))
|
||||
.background(if (selected) F7Colors.PrimaryLight else F7Colors.Surface)
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(F7Radius.lg))
|
||||
.clickable(onClick = onOpenClick)
|
||||
.padding(F7Spacing.md),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onSelectedChange() },
|
||||
colors = CheckboxDefaults.colors(checkedColor = F7Colors.Primary),
|
||||
)
|
||||
Spacer(Modifier.width(F7Spacing.sm))
|
||||
Box(modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = if (favorite) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
contentDescription = null,
|
||||
tint = if (favorite) F7Colors.Primary else F7Colors.TextMuted,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = F7Spacing.lg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = FileIcons.iconUrl(serverUrl, item.name, item.isDirectory),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = item.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = "Меню",
|
||||
tint = F7Colors.TextSecondary,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.clickable(onClick = onMenuClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Пустое состояние: круг-иконка + заголовок + подзаголовок + опц. кнопка (макет F-11..16). */
|
||||
@Composable
|
||||
fun FilesEmptyState(
|
||||
iconUrl: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 96.dp, start = F7Spacing.xxl, end = F7Spacing.xxl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(RoundedCornerShape(F7Radius.pill))
|
||||
.background(F7Colors.SurfaceMuted),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(model = iconUrl, contentDescription = null, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
Spacer(Modifier.size(F7Spacing.lg))
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
)
|
||||
Spacer(Modifier.size(F7Spacing.xs))
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
if (action != null) {
|
||||
Spacer(Modifier.size(F7Spacing.xl))
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,12 +547,11 @@ fun FilesSearchBar(
|
||||
serverUrl: String,
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
.shadow(
|
||||
elevation = 2.dp,
|
||||
|
||||
@@ -8,8 +8,14 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items as gridItems
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
@@ -61,6 +67,7 @@ fun FilesScreen(
|
||||
val vm: FilesViewModel = viewModel(factory = FilesViewModel.Factory(context))
|
||||
val state by vm.state.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var viewMode by remember { mutableStateOf(FilesViewMode.List) }
|
||||
var showCreateMenu by remember { mutableStateOf(false) }
|
||||
var showNewFolderDialog by remember { mutableStateOf(false) }
|
||||
var newFolderName by remember { mutableStateOf("") }
|
||||
@@ -167,23 +174,28 @@ fun FilesScreen(
|
||||
headerActions = {},
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.currentPath.isNotBlank() && state.browseMode == FilesBrowseMode.AllFiles) {
|
||||
F7SecondaryButton(
|
||||
text = "Назад",
|
||||
onClick = { vm.goUp(session) },
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = pathLabel,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = F7Colors.TextPrimary,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
// Поиск + переключатель вид список⇄сетка (макет F-17/F-22)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilesSearchBar(
|
||||
serverUrl = session.serverUrl,
|
||||
query = state.searchQuery,
|
||||
onQueryChange = { vm.setSearchQuery(session, it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilesViewToggleButton(viewMode) {
|
||||
viewMode = if (viewMode == FilesViewMode.List) FilesViewMode.Grid else FilesViewMode.List
|
||||
}
|
||||
}
|
||||
// Хлебные крошки
|
||||
val crumbSegments = state.currentPath.split("/").filter { it.isNotBlank() }
|
||||
FilesBreadcrumb(
|
||||
segments = crumbSegments,
|
||||
onHomeClick = { vm.navigateToPath(session, "") },
|
||||
onSegmentClick = { i -> vm.navigateToPath(session, crumbSegments.take(i + 1).joinToString("/")) },
|
||||
)
|
||||
if (selectionMode) {
|
||||
FilesBulkActionsBar(
|
||||
@@ -195,17 +207,11 @@ fun FilesScreen(
|
||||
},
|
||||
)
|
||||
} else {
|
||||
FilesListHeader(
|
||||
serverUrl = session.serverUrl,
|
||||
showMimeColumn = state.userConfig.showMimeColumn,
|
||||
sortColumn = state.sortColumn,
|
||||
sortDirection = state.sortDirection,
|
||||
selectionMode = false,
|
||||
allSelected = allSelected,
|
||||
onSelectAllToggle = {
|
||||
if (allSelected) vm.clearSelection() else vm.selectAllVisible()
|
||||
},
|
||||
onSortColumnClick = vm::setSortColumn,
|
||||
// Фильтр-чипы вместо десктопной колоночной шапки (макет F-18/F-22)
|
||||
FilesFilterChips(
|
||||
onTypeClick = { vm.setSortColumn(FilesSortColumn.Type) },
|
||||
onParticipantsClick = {},
|
||||
onModifiedClick = { vm.setSortColumn(FilesSortColumn.Modified) },
|
||||
)
|
||||
}
|
||||
if (state.openingFile != null) {
|
||||
@@ -223,43 +229,65 @@ fun FilesScreen(
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
) {
|
||||
val openItem: (FileItem) -> Unit = { item ->
|
||||
val openable = !item.isDirectory &&
|
||||
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
|
||||
when {
|
||||
item.isDirectory -> vm.openFolder(session, item)
|
||||
openable -> vm.openItem(session, item)
|
||||
}
|
||||
}
|
||||
if (displayItems.isEmpty() && !state.loading && state.error.isNullOrBlank()) {
|
||||
Text(
|
||||
text = if (state.searchQuery.isNotBlank()) "Ничего не найдено" else "Папка пуста",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
val searching = state.searchQuery.isNotBlank()
|
||||
FilesEmptyState(
|
||||
iconUrl = "file:///android_asset/filetypes/file.svg",
|
||||
title = if (searching) "Нет результатов поиска" else "Папка пуста",
|
||||
subtitle = if (searching) {
|
||||
"По запросу «${state.searchQuery}» ничего не найдено"
|
||||
} else {
|
||||
"Загрузите контент или синхронизируйте его своими устройствами"
|
||||
},
|
||||
)
|
||||
} else if (viewMode == FilesViewMode.Grid) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
gridItems(displayItems, key = { it.relativePath }) { item ->
|
||||
FilesGridCard(
|
||||
serverUrl = session.serverUrl,
|
||||
item = item,
|
||||
selected = item.relativePath in state.selectedPaths,
|
||||
favorite = item.favorite,
|
||||
onSelectedChange = { vm.toggleSelection(item.relativePath) },
|
||||
onOpenClick = { openItem(item) },
|
||||
onMenuClick = {
|
||||
contextMenuItem = item
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(displayItems, key = { _, item -> item.relativePath }) { index, item ->
|
||||
val openable = !item.isDirectory &&
|
||||
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
|
||||
items(displayItems, key = { it.relativePath }) { item ->
|
||||
FilesListRow(
|
||||
serverUrl = session.serverUrl,
|
||||
item = item,
|
||||
selected = item.relativePath in state.selectedPaths,
|
||||
showExtensions = state.userConfig.showExtensions,
|
||||
onSelectedChange = { vm.toggleSelection(item.relativePath) },
|
||||
onOpenClick = {
|
||||
when {
|
||||
item.isDirectory -> vm.openFolder(session, item)
|
||||
openable -> vm.openItem(session, item)
|
||||
}
|
||||
},
|
||||
onOpenClick = { openItem(item) },
|
||||
onMenuClick = { rect ->
|
||||
contextMenuItem = item
|
||||
contextMenuAnchor = rect
|
||||
},
|
||||
)
|
||||
if (index < displayItems.lastIndex) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
thickness = 1.dp,
|
||||
color = F7Colors.Border.copy(alpha = 0.55f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user