mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 23:36:37 +08:00
优化
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
LIBRARY_LIST_SORT_OPTIONS,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOption,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
|
|
||||||
|
type LibraryListSortDropdownProps = {
|
||||||
|
value: LibraryListSortField
|
||||||
|
order: LibraryListSortOrder
|
||||||
|
onChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryListSortDropdown({
|
||||||
|
value,
|
||||||
|
order,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
}: LibraryListSortDropdownProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const currentOption = LIBRARY_LIST_SORT_OPTIONS.find((opt) => opt.id === value) ?? LIBRARY_LIST_SORT_OPTIONS[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSelect = (option: LibraryListSortOption) => {
|
||||||
|
if (value === option.id) {
|
||||||
|
onChange(option.id, order === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
onChange(option.id, option.defaultOrder)
|
||||||
|
}
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-block text-left ${className}`} ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-xl border border-sand-200 bg-white/90 px-3 py-1.5 text-xs font-semibold text-ink-600 shadow-sm transition-all hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700 sm:h-10 sm:text-sm"
|
||||||
|
title="更改媒体库列表排序"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="text-sand-500" />
|
||||||
|
<span>排序: {currentOption.label}</span>
|
||||||
|
{order === 'asc' ? (
|
||||||
|
<ArrowUp size={13} className="font-bold text-brand-600" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={13} className="font-bold text-brand-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 z-50 mt-1.5 w-44 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
|
||||||
|
<div className="border-b border-sand-100 px-2.5 py-1.5 text-[11px] font-bold text-sand-500">
|
||||||
|
排序方式
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 py-1">
|
||||||
|
{LIBRARY_LIST_SORT_OPTIONS.map((option) => {
|
||||||
|
const isSelected = value === option.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className={`flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-medium transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-brand-50 font-semibold text-brand-700'
|
||||||
|
: 'text-ink-600 hover:bg-sand-50 hover:text-brand-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isSelected && <Check size={13} className="text-brand-600" />}
|
||||||
|
<span className={isSelected ? '' : 'pl-5'}>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex items-center text-brand-600">
|
||||||
|
{order === 'asc' ? (
|
||||||
|
<ArrowUp size={14} className="stroke-[2.5]" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown size={14} className="stroke-[2.5]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,10 +17,16 @@ import {
|
|||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
import type { Library } from '../types'
|
import type { Library } from '../types'
|
||||||
import type { SeriesCard } from '../utils/groupSeries'
|
import type { SeriesCard } from '../utils/groupSeries'
|
||||||
|
import {
|
||||||
|
readLibraryListSort,
|
||||||
|
sortLibraryPreviewsByField,
|
||||||
|
writeLibraryListSort,
|
||||||
|
type LibraryListSortField,
|
||||||
|
type LibraryListSortOrder,
|
||||||
|
} from '../utils/libraryListSort'
|
||||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||||
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
import { ALL_TAG_ID, buildLibraryTagTabs, filterLibrariesByTag } from '../utils/libraryTags'
|
||||||
import type { LibraryTag } from '../utils/libraryTags'
|
import type { LibraryTag } from '../utils/libraryTags'
|
||||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
|
||||||
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
import { partitionPreviewIDs } from '../utils/remoteEmby'
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
@@ -33,6 +39,8 @@ export function LibrariesPage() {
|
|||||||
const [repairing, setRepairing] = useState(false)
|
const [repairing, setRepairing] = useState(false)
|
||||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useEpisodeArtworkPreference()
|
||||||
const [repairMsg, setRepairMsg] = useState('')
|
const [repairMsg, setRepairMsg] = useState('')
|
||||||
|
const [sortField, setSortField] = useState<LibraryListSortField>(() => readLibraryListSort().field)
|
||||||
|
const [sortOrder, setSortOrder] = useState<LibraryListSortOrder>(() => readLibraryListSort().order)
|
||||||
|
|
||||||
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
// 缓存每个库已加载到的预览数量:入口网格只需要 2 张,横向货架需要 10 张。
|
||||||
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
const fetchedPreviewLimitsRef = useRef<Map<string, number>>(new Map())
|
||||||
@@ -146,7 +154,16 @@ export function LibrariesPage() {
|
|||||||
})
|
})
|
||||||
}, [libraries, libraryData])
|
}, [libraries, libraryData])
|
||||||
|
|
||||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
const sortedPreviews = useMemo(
|
||||||
|
() => sortLibraryPreviewsByField(previews, pinnedIds, sortField, sortOrder),
|
||||||
|
[previews, pinnedIds, sortField, sortOrder],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSortChange = useCallback((field: LibraryListSortField, order: LibraryListSortOrder) => {
|
||||||
|
setSortField(field)
|
||||||
|
setSortOrder(order)
|
||||||
|
writeLibraryListSort(field, order)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleTogglePin = useCallback((libraryId: string) => {
|
const handleTogglePin = useCallback((libraryId: string) => {
|
||||||
void togglePin(libraryId)
|
void togglePin(libraryId)
|
||||||
@@ -180,6 +197,9 @@ export function LibrariesPage() {
|
|||||||
repairMsg={repairMsg}
|
repairMsg={repairMsg}
|
||||||
repairEpisodeArtwork={repairEpisodeArtwork}
|
repairEpisodeArtwork={repairEpisodeArtwork}
|
||||||
repairing={repairing}
|
repairing={repairing}
|
||||||
|
sortField={sortField}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
onRepairEpisodeArtworkChange={setRepairEpisodeArtwork}
|
||||||
onRepairRescrape={handleRepairRescrape}
|
onRepairRescrape={handleRepairRescrape}
|
||||||
onManageLibraries={handleManageLibraries}
|
onManageLibraries={handleManageLibraries}
|
||||||
@@ -228,7 +248,7 @@ export function LibrariesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/手动排序)。 */
|
/** 按选中标签过滤媒体库预览(ALL_TAG_ID 时原样返回,保留置顶/列表排序)。 */
|
||||||
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
function filterPreviewsByTag(previews: LibraryPreview[], tags: LibraryTag[], tagId: string): LibraryPreview[] {
|
||||||
if (!tagId || tagId === ALL_TAG_ID) return previews
|
if (!tagId || tagId === ALL_TAG_ID) return previews
|
||||||
const ordered = filterLibrariesByTag(
|
const ordered = filterLibrariesByTag(
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { Link } from 'react-router-dom'
|
|||||||
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
|
import { Library as LibraryIcon, RefreshCw, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||||
|
import { LibraryListSortDropdown } from '../components/LibraryListSortDropdown'
|
||||||
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
import { useRememberedListPosition } from '../hooks/useListPositionMemory'
|
||||||
|
import type { LibraryListSortField, LibraryListSortOrder } from '../utils/libraryListSort'
|
||||||
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
import { HomeLibrariesSection, HomeLibraryRowSection } from './HomePageSections'
|
||||||
import type { LibraryPreview } from './librariesPageModel'
|
import type { LibraryPreview } from './librariesPageModel'
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ export function LibrariesHeader({
|
|||||||
repairMsg,
|
repairMsg,
|
||||||
repairEpisodeArtwork,
|
repairEpisodeArtwork,
|
||||||
repairing,
|
repairing,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
onSortChange,
|
||||||
onRepairEpisodeArtworkChange,
|
onRepairEpisodeArtworkChange,
|
||||||
onRepairRescrape,
|
onRepairRescrape,
|
||||||
onManageLibraries,
|
onManageLibraries,
|
||||||
@@ -28,6 +33,9 @@ export function LibrariesHeader({
|
|||||||
repairMsg: string
|
repairMsg: string
|
||||||
repairEpisodeArtwork: boolean
|
repairEpisodeArtwork: boolean
|
||||||
repairing: boolean
|
repairing: boolean
|
||||||
|
sortField: LibraryListSortField
|
||||||
|
sortOrder: LibraryListSortOrder
|
||||||
|
onSortChange: (field: LibraryListSortField, order: LibraryListSortOrder) => void
|
||||||
onRepairEpisodeArtworkChange: (value: boolean) => void
|
onRepairEpisodeArtworkChange: (value: boolean) => void
|
||||||
onRepairRescrape: () => void
|
onRepairRescrape: () => void
|
||||||
onManageLibraries: () => void
|
onManageLibraries: () => void
|
||||||
@@ -40,42 +48,45 @@ export function LibrariesHeader({
|
|||||||
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
<LibraryListSortDropdown value={sortField} order={sortOrder} onChange={onSortChange} />
|
||||||
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
{isAdmin && (
|
||||||
<EpisodeArtworkToggle
|
<>
|
||||||
checked={repairEpisodeArtwork}
|
{repairMsg && <span className="w-full text-xs text-ink-50">{repairMsg}</span>}
|
||||||
onChange={onRepairEpisodeArtworkChange}
|
<EpisodeArtworkToggle
|
||||||
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
checked={repairEpisodeArtwork}
|
||||||
className="h-9 sm:h-10 text-xs sm:text-sm"
|
onChange={onRepairEpisodeArtworkChange}
|
||||||
/>
|
title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片"
|
||||||
<button
|
className="h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
type="button"
|
/>
|
||||||
onClick={onRepairRescrape}
|
<button
|
||||||
disabled={repairing}
|
type="button"
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
onClick={onRepairRescrape}
|
||||||
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
disabled={repairing}
|
||||||
>
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
title="从媒体路径回填缺失/错误的外部 ID,再批量重刮整库"
|
||||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
>
|
||||||
</button>
|
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||||
<Link
|
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||||
to="/scraper/queue"
|
</button>
|
||||||
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
<Link
|
||||||
title="查看正在进行的刮削任务与进度"
|
to="/scraper/queue"
|
||||||
>
|
className="btn-outline inline-flex items-center gap-1.5 !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
<Sparkles size={14} className="text-brand-500" />
|
title="查看正在进行的刮削任务与进度"
|
||||||
<span>刮削队列</span>
|
>
|
||||||
</Link>
|
<Sparkles size={14} className="text-brand-500" />
|
||||||
<button
|
<span>刮削队列</span>
|
||||||
type="button"
|
</Link>
|
||||||
onClick={onManageLibraries}
|
<button
|
||||||
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
type="button"
|
||||||
>
|
onClick={onManageLibraries}
|
||||||
管理媒体库
|
className="btn-outline !px-3 !py-1.5 text-xs sm:!px-4 sm:!py-2.5 sm:text-sm"
|
||||||
</button>
|
>
|
||||||
</div>
|
管理媒体库
|
||||||
)}
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Library } from '../types'
|
||||||
|
|
||||||
|
export type LibraryListSortField = 'name' | 'sort_order'
|
||||||
|
export type LibraryListSortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
export type LibraryListSortOption = {
|
||||||
|
id: LibraryListSortField
|
||||||
|
label: string
|
||||||
|
defaultOrder: LibraryListSortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIBRARY_LIST_SORT_OPTIONS: LibraryListSortOption[] = [
|
||||||
|
{ id: 'name', label: '库名', defaultOrder: 'desc' },
|
||||||
|
{ id: 'sort_order', label: '手动顺序', defaultOrder: 'asc' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_LIBRARY_LIST_SORT_FIELD: LibraryListSortField = 'name'
|
||||||
|
export const DEFAULT_LIBRARY_LIST_SORT_ORDER: LibraryListSortOrder = 'desc'
|
||||||
|
|
||||||
|
const FIELD_KEY = 'mebox_libraries_sort_field'
|
||||||
|
const ORDER_KEY = 'mebox_libraries_sort_order'
|
||||||
|
|
||||||
|
export function readLibraryListSort(): { field: LibraryListSortField; order: LibraryListSortOrder } {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return { field: DEFAULT_LIBRARY_LIST_SORT_FIELD, order: DEFAULT_LIBRARY_LIST_SORT_ORDER }
|
||||||
|
}
|
||||||
|
const rawField = window.localStorage.getItem(FIELD_KEY)
|
||||||
|
const rawOrder = window.localStorage.getItem(ORDER_KEY)
|
||||||
|
const field: LibraryListSortField =
|
||||||
|
rawField === 'name' || rawField === 'sort_order' ? rawField : DEFAULT_LIBRARY_LIST_SORT_FIELD
|
||||||
|
const order: LibraryListSortOrder =
|
||||||
|
rawOrder === 'asc' || rawOrder === 'desc'
|
||||||
|
? rawOrder
|
||||||
|
: field === 'name'
|
||||||
|
? DEFAULT_LIBRARY_LIST_SORT_ORDER
|
||||||
|
: 'asc'
|
||||||
|
return { field, order }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeLibraryListSort(field: LibraryListSortField, order: LibraryListSortOrder): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(FIELD_KEY, field)
|
||||||
|
window.localStorage.setItem(ORDER_KEY, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNames(a: string, b: string, order: LibraryListSortOrder): number {
|
||||||
|
const cmp = a.localeCompare(b, 'zh-CN', { numeric: true, sensitivity: 'base' })
|
||||||
|
return order === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSortOrder(a: number, b: number, order: LibraryListSortOrder): number {
|
||||||
|
const cmp = a - b
|
||||||
|
return order === 'asc' ? cmp : -cmp
|
||||||
|
}
|
||||||
|
|
||||||
|
function librarySortKey(library: Library): { name: string; sortOrder: number } {
|
||||||
|
return {
|
||||||
|
name: (library.name || '').trim(),
|
||||||
|
sortOrder: library.sort_order ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLibraries(a: Library, b: Library, field: LibraryListSortField, order: LibraryListSortOrder): number {
|
||||||
|
const ka = librarySortKey(a)
|
||||||
|
const kb = librarySortKey(b)
|
||||||
|
if (field === 'name') {
|
||||||
|
const byName = compareNames(ka.name, kb.name, order)
|
||||||
|
if (byName !== 0) return byName
|
||||||
|
return compareSortOrder(ka.sortOrder, kb.sortOrder, 'asc') || a.id.localeCompare(b.id)
|
||||||
|
}
|
||||||
|
const byOrder = compareSortOrder(ka.sortOrder, kb.sortOrder, order)
|
||||||
|
if (byOrder !== 0) return byOrder
|
||||||
|
return compareNames(ka.name, kb.name, 'asc') || a.id.localeCompare(b.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 置顶组始终在前;组内按 field/order 排序。
|
||||||
|
* pinnedIds 的相对顺序只决定「是否置顶」,组内不再保留置顶拖拽序。
|
||||||
|
*/
|
||||||
|
export function sortLibraryPreviewsByField<T extends { library: Library }>(
|
||||||
|
items: T[],
|
||||||
|
pinnedIds: string[],
|
||||||
|
field: LibraryListSortField,
|
||||||
|
order: LibraryListSortOrder,
|
||||||
|
): T[] {
|
||||||
|
if (items.length < 2) return items
|
||||||
|
|
||||||
|
const pinnedRank = new Map(pinnedIds.map((id, index) => [id, index]))
|
||||||
|
const pinned: T[] = []
|
||||||
|
const rest: T[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (pinnedRank.has(item.library.id)) pinned.push(item)
|
||||||
|
else rest.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byField = (a: T, b: T) => compareLibraries(a.library, b.library, field, order)
|
||||||
|
pinned.sort(byField)
|
||||||
|
rest.sort(byField)
|
||||||
|
return [...pinned, ...rest]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user