mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b1fe52e24 | |||
| 2fc2c17b05 | |||
| be96acb035 | |||
| 6a72e7ab82 | |||
| 4ebd477ca2 |
@@ -8,16 +8,15 @@ import {
|
||||
LayoutWorkspace,
|
||||
} from './LayoutSections'
|
||||
import { MobileBottomNav } from './MobileBottomNav'
|
||||
import { shouldShowMobileBottomNav } from './layoutNavigation'
|
||||
import { isAdminEntrySearch, isPlayerRoute, shouldShowMobileBottomNav } from './layoutNavigation'
|
||||
import { useLayoutPermissions } from './useLayoutPermissions'
|
||||
import { useLayoutProfiles } from './useLayoutProfiles'
|
||||
import { useLayoutSidebar } from './useLayoutSidebar'
|
||||
import { useThemeMode } from './useThemeMode'
|
||||
|
||||
function isMediaView(pathname: string, search: string): boolean {
|
||||
const params = new URLSearchParams(search)
|
||||
// 从设置/管理后台菜单进入(携带 from=admin 或 from=settings 或 manage=1)时展示左侧栏
|
||||
if (params.get('from') === 'admin' || params.get('from') === 'settings' || params.get('manage') === '1') {
|
||||
// 从设置/管理后台菜单进入时展示左侧栏
|
||||
if (isAdminEntrySearch(search)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -26,7 +25,7 @@ function isMediaView(pathname: string, search: string): boolean {
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library') ||
|
||||
pathname.startsWith('/media') ||
|
||||
pathname.startsWith('/play') ||
|
||||
isPlayerRoute(pathname) ||
|
||||
pathname === '/favourites' ||
|
||||
pathname === '/playlists' ||
|
||||
pathname.startsWith('/playlist') ||
|
||||
@@ -52,7 +51,7 @@ export function Layout() {
|
||||
|
||||
const showSidebar = !isMediaView(location.pathname, location.search)
|
||||
const hideSearch = location.pathname.startsWith('/settings')
|
||||
const isPlayPage = location.pathname.startsWith('/play')
|
||||
const isPlayPage = isPlayerRoute(location.pathname)
|
||||
const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname)
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
import { isPlayerRoute } from './layoutNavigation'
|
||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||
|
||||
@@ -39,7 +40,7 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'hidden lg:flex flex-col h-full shrink-0 transition-all duration-300 ease-out',
|
||||
'hidden lg:flex min-h-0 h-full shrink-0 flex-col transition-all duration-300 ease-out',
|
||||
isSidebarOpen ? 'w-64' : 'w-20',
|
||||
)}
|
||||
>
|
||||
@@ -65,7 +66,7 @@ export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileS
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||
className="relative flex w-64 max-w-xs flex-col h-full z-10 shadow-xl"
|
||||
className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
@@ -125,7 +126,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
|
||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||
: ''
|
||||
|
||||
if (routeKey.startsWith('/play')) {
|
||||
if (isPlayerRoute(routeKey)) {
|
||||
return (
|
||||
<main className="flex flex-1 h-full w-full overflow-hidden">
|
||||
<RouteErrorBoundary>
|
||||
|
||||
@@ -34,25 +34,27 @@ export function LayoutSidebarContent({
|
||||
const adminItems = visibleSidebarItems({ isAdmin, can, items: LAYOUT_NAV_ITEMS })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
<div className="flex h-full min-h-0 flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
<LayoutSidebarHeader
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onCloseMobileDrawer={onCloseMobileDrawer}
|
||||
/>
|
||||
{variant === 'media' ? (
|
||||
<>
|
||||
<LayoutSidebarNav items={mediaItems} sidebarExpanded={sidebarExpanded} grow />
|
||||
{adminItems.length > 0 && (
|
||||
<div className="border-t border-[var(--app-border)]">
|
||||
<LayoutSidebarSectionLabel sidebarExpanded={sidebarExpanded} label="管理" />
|
||||
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} grow />
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-hide">
|
||||
{variant === 'media' ? (
|
||||
<>
|
||||
<LayoutSidebarNav items={mediaItems} sidebarExpanded={sidebarExpanded} />
|
||||
{adminItems.length > 0 && (
|
||||
<div className="border-t border-[var(--app-border)]">
|
||||
<LayoutSidebarSectionLabel sidebarExpanded={sidebarExpanded} label="管理" />
|
||||
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} compactTop />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
|
||||
)}
|
||||
</div>
|
||||
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
||||
</div>
|
||||
)
|
||||
@@ -78,7 +80,7 @@ function LayoutSidebarHeader({
|
||||
onCloseMobileDrawer: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<div className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/logo-192.png"
|
||||
@@ -123,17 +125,17 @@ function LayoutSidebarSectionLabel({
|
||||
function LayoutSidebarNav({
|
||||
items,
|
||||
sidebarExpanded,
|
||||
grow = false,
|
||||
compactTop = false,
|
||||
}: {
|
||||
items: LayoutNavItem[]
|
||||
sidebarExpanded: boolean
|
||||
grow?: boolean
|
||||
compactTop?: boolean
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
className={clsx(
|
||||
'overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide',
|
||||
grow && 'flex-1',
|
||||
'px-4 py-5 space-y-1',
|
||||
compactTop && 'pt-2',
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
@@ -155,7 +157,7 @@ function LayoutSidebarNav({
|
||||
|
||||
function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) {
|
||||
return (
|
||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<div className="shrink-0 border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<Link
|
||||
to="/"
|
||||
className={clsx(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { isSidebarLinkActive } from './layoutNavigation'
|
||||
|
||||
type SidebarGroupProps = {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
@@ -79,44 +81,41 @@ type SidebarLinkProps = {
|
||||
}
|
||||
|
||||
export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
|
||||
const location = useLocation()
|
||||
const isActive = isSidebarLinkActive(to, location.pathname, location.search, end)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
<Link
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
||||
child && 'py-2.5 text-[13px]',
|
||||
isActive
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={clsx(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
||||
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={clsx(
|
||||
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
||||
child && 'py-2.5 text-[13px]',
|
||||
isActive
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
)}
|
||||
</NavLink>
|
||||
>
|
||||
<span className={clsx(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
||||
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,14 +76,60 @@ export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
|
||||
|
||||
const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
|
||||
|
||||
/** True only for the fullscreen player route (`/play` or `/play/:id`), not `/playlists`. */
|
||||
export function isPlayerRoute(pathname: string): boolean {
|
||||
return pathname === '/play' || pathname.startsWith('/play/')
|
||||
}
|
||||
|
||||
export function isRootMediaPath(pathname: string): boolean {
|
||||
return ROOT_MEDIA_PATHS.has(pathname)
|
||||
}
|
||||
|
||||
/** Admin-entry query used to open the management sidebar on library pages. */
|
||||
export function isAdminEntrySearch(search: string): boolean {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
return (
|
||||
params.get('from') === 'admin' ||
|
||||
params.get('from') === 'settings' ||
|
||||
params.get('manage') === '1'
|
||||
)
|
||||
}
|
||||
|
||||
export function isLibraryPath(pathname: string): boolean {
|
||||
return pathname === '/libraries' || pathname.startsWith('/library')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar active state must honor query strings. NavLink only matches pathname,
|
||||
* so `/libraries` and `/libraries?from=admin` would otherwise both highlight.
|
||||
*/
|
||||
export function isSidebarLinkActive(
|
||||
to: string,
|
||||
pathname: string,
|
||||
search: string,
|
||||
end?: boolean,
|
||||
): boolean {
|
||||
const [toPath, toQuery = ''] = to.split('?')
|
||||
const toIsAdminLibrary = toPath === '/libraries' && isAdminEntrySearch(toQuery)
|
||||
const toIsMediaLibrary = toPath === '/libraries' && !toIsAdminLibrary
|
||||
|
||||
if (toIsAdminLibrary) {
|
||||
return isLibraryPath(pathname) && isAdminEntrySearch(search)
|
||||
}
|
||||
if (toIsMediaLibrary) {
|
||||
return isLibraryPath(pathname) && !isAdminEntrySearch(search)
|
||||
}
|
||||
|
||||
return end ? pathname === toPath : pathname === toPath || pathname.startsWith(`${toPath}/`)
|
||||
}
|
||||
|
||||
export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
||||
if (pathname.startsWith('/library/')) {
|
||||
return { to: '/libraries', label: '媒体库' }
|
||||
}
|
||||
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
|
||||
return { to: '/', label: '首页' }
|
||||
}
|
||||
if (pathname.startsWith('/playlist/')) {
|
||||
return { to: '/playlists', label: '播放列表' }
|
||||
}
|
||||
@@ -118,7 +164,7 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
||||
}
|
||||
|
||||
export function shouldShowMobileBottomNav(pathname: string): boolean {
|
||||
if (pathname.startsWith('/play')) return false
|
||||
if (isPlayerRoute(pathname)) return false
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/libraries' ||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ListPlus, Trash2 } from 'lucide-react'
|
||||
import { ChevronRight, ListMusic, ListPlus, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import type { Playlist } from '../types'
|
||||
|
||||
// Landing page for playlists. Lists every playlist owned by the current
|
||||
// user and lets them create / delete one.
|
||||
export function PlaylistsPage() {
|
||||
const [items, setItems] = useState<Playlist[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const refresh = () =>
|
||||
playbackAPI
|
||||
@@ -26,67 +26,124 @@ export function PlaylistsPage() {
|
||||
|
||||
const onCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await playbackAPI.createPlaylist(name)
|
||||
await playbackAPI.createPlaylist(trimmed)
|
||||
toast.success('已创建')
|
||||
setName('')
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error('创建失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = !loading && items.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">播放列表</h1>
|
||||
<PageHeader
|
||||
title="播放列表"
|
||||
description="整理你想稍后观看的影片,随时继续播放"
|
||||
/>
|
||||
|
||||
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<form
|
||||
onSubmit={onCreate}
|
||||
className="glass-panel flex flex-col gap-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
className="input-base min-w-0 flex-1"
|
||||
placeholder="新播放列表名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
<button type="submit" className="neon-button">
|
||||
<ListPlus size={16} /> 新建
|
||||
<button type="submit" className="neon-button shrink-0" disabled={creating}>
|
||||
<ListPlus size={16} />
|
||||
{creating ? '创建中…' : '新建'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">还没有任何播放列表。</p>}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-ink-50">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="glass-panel flex items-center justify-between !p-4"
|
||||
>
|
||||
<Link
|
||||
to={`/playlist/${p.id}`}
|
||||
className="font-medium text-ink-600 transition hover:text-brand-500"
|
||||
>
|
||||
{p.name}
|
||||
{p.is_public && (
|
||||
<span className="ml-2 rounded-lg border border-primary-400/40 px-1.5 py-0.5 text-xs text-brand-500">
|
||||
public
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!(await confirmAction({ title: '删除播放列表', message: `删除「${p.name}」?`, confirmText: '删除' }))) return
|
||||
await playbackAPI.deletePlaylist(p.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
{isEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-4 p-10 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-400/10 text-brand-500">
|
||||
<ListMusic size={28} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-medium text-ink-100">还没有任何播放列表</p>
|
||||
<p className="text-sm text-sand-500">
|
||||
在上方输入名称创建列表,或在媒体详情页点击「加入播放列表」
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="glass-panel group flex items-center gap-3 !p-4 transition hover:border-primary-400/30"
|
||||
>
|
||||
<Link
|
||||
to={`/playlist/${p.id}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary-400/10 text-brand-500 transition group-hover:bg-primary-400/20">
|
||||
<ListMusic size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-ink-600 transition group-hover:text-brand-500">
|
||||
{p.name}
|
||||
</p>
|
||||
{p.is_public && (
|
||||
<span className="mt-0.5 inline-block rounded-md border border-primary-400/40 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-brand-500">
|
||||
公开
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className="shrink-0 text-sand-500 transition group-hover:text-brand-500"
|
||||
/>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await confirmAction({
|
||||
title: '删除播放列表',
|
||||
message: `删除「${p.name}」?`,
|
||||
confirmText: '删除',
|
||||
}))
|
||||
) {
|
||||
return
|
||||
}
|
||||
await playbackAPI.deletePlaylist(p.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
className="shrink-0 rounded-lg border border-red-400/40 p-2 text-red-400 transition hover:bg-red-400/10"
|
||||
title="删除播放列表"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user