feat(editor): build sidebar + slimmed items panel for standalone editor (#361)
Bring the open-source standalone editor closer to the community editor's v2 sidebar without pulling in any preset/catalog infrastructure. - Add a preset-less Build tab (apps/editor) that mirrors the community Build sidebar: wall, fence, slab, ceiling, roof, stair, elevator, door, window, column, spawn, plus the material-paint panel. Clicking a type activates the raw structure tool drawn with the kind's defaults. - Wire the Build tab into both editor mount points (local + saved scene) and give the left rail proper image icons (Scene/Build/Items/Settings) instead of letter fallbacks. - Gate the ItemsPanel Library/Community/Mine source chips and tag filter rows behind `showSourceFilter` / `showTagFilters` props (default true, so community and external consumers are unchanged). The standalone editor passes both off, leaving plain category tabs + full-width search. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7f49efce68
commit
e450d8b474
@@ -1,13 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Editor, ItemsPanel } from '@pascal-app/editor'
|
import { Editor, ItemsPanel } from '@pascal-app/editor'
|
||||||
import { Layers, Package, Settings } from 'lucide-react'
|
import { Hammer, Layers, Package, Settings } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { BuildTab } from '@/components/build-tab'
|
||||||
import {
|
import {
|
||||||
CommunityViewerToolbarLeft,
|
CommunityViewerToolbarLeft,
|
||||||
CommunityViewerToolbarRight,
|
CommunityViewerToolbarRight,
|
||||||
} from '@/components/viewer-toolbar'
|
} from '@/components/viewer-toolbar'
|
||||||
|
|
||||||
|
// The open-source editor only ships the built-in catalog (no uploaded items),
|
||||||
|
// so the Library/Community/Mine source chips and tag filters add nothing —
|
||||||
|
// drop them and keep the panel to plain categories.
|
||||||
|
function EditorItemsPanel() {
|
||||||
|
return <ItemsPanel showSourceFilter={false} showTagFilters={false} />
|
||||||
|
}
|
||||||
|
|
||||||
const SIDEBAR_TABS = [
|
const SIDEBAR_TABS = [
|
||||||
{
|
{
|
||||||
id: 'site',
|
id: 'site',
|
||||||
@@ -15,13 +24,47 @@ const SIDEBAR_TABS = [
|
|||||||
component: () => null,
|
component: () => null,
|
||||||
mobileDefaultSnap: 0.5,
|
mobileDefaultSnap: 0.5,
|
||||||
mobileIcon: <Layers className="h-5 w-5" />,
|
mobileIcon: <Layers className="h-5 w-5" />,
|
||||||
|
icon: (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
height={32}
|
||||||
|
src="/icons/scene.png"
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'build',
|
||||||
|
label: 'Build',
|
||||||
|
component: BuildTab,
|
||||||
|
mobileDefaultSnap: 0.5,
|
||||||
|
mobileIcon: <Hammer className="h-5 w-5" />,
|
||||||
|
icon: (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
height={32}
|
||||||
|
src="/icons/build.png"
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'items',
|
id: 'items',
|
||||||
label: 'Items',
|
label: 'Items',
|
||||||
component: ItemsPanel,
|
component: EditorItemsPanel,
|
||||||
mobileDefaultSnap: 0.5,
|
mobileDefaultSnap: 0.5,
|
||||||
mobileIcon: <Package className="h-5 w-5" />,
|
mobileIcon: <Package className="h-5 w-5" />,
|
||||||
|
icon: (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
height={32}
|
||||||
|
src="/icons/couch.png"
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
@@ -29,6 +72,15 @@ const SIDEBAR_TABS = [
|
|||||||
component: () => null,
|
component: () => null,
|
||||||
mobileDefaultSnap: 0.5,
|
mobileDefaultSnap: 0.5,
|
||||||
mobileIcon: <Settings className="h-5 w-5" />,
|
mobileIcon: <Settings className="h-5 w-5" />,
|
||||||
|
icon: (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
height={32}
|
||||||
|
src="/icons/settings.png"
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { MaterialPaintPanel, useEditor } from '@pascal-app/editor'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/toolbar-tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw structure-tool kinds the Build tab can activate. These map 1:1 to the
|
||||||
|
* editor's `StructureTool` ids.
|
||||||
|
*/
|
||||||
|
type BuildToolKind =
|
||||||
|
| 'wall'
|
||||||
|
| 'fence'
|
||||||
|
| 'slab'
|
||||||
|
| 'ceiling'
|
||||||
|
| 'roof'
|
||||||
|
| 'stair'
|
||||||
|
| 'elevator'
|
||||||
|
| 'door'
|
||||||
|
| 'window'
|
||||||
|
| 'column'
|
||||||
|
| 'spawn'
|
||||||
|
|
||||||
|
type BuildType = {
|
||||||
|
/** Selection id — equals `kind` for tool types, `'painting'` for paint mode. */
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
iconSrc: string
|
||||||
|
/** Present for structure-tool types (absent for the paint mode). */
|
||||||
|
kind?: BuildToolKind
|
||||||
|
/** Non-placement special mode. */
|
||||||
|
mode?: 'material-paint'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same icons + ordering as the community Build sidebar, minus presets.
|
||||||
|
const BUILD_TYPES: BuildType[] = [
|
||||||
|
{ id: 'wall', label: 'Wall', iconSrc: '/icons/wall.png', kind: 'wall' },
|
||||||
|
{ id: 'fence', label: 'Fence', iconSrc: '/icons/fence.png', kind: 'fence' },
|
||||||
|
{ id: 'slab', label: 'Slab', iconSrc: '/icons/floor.png', kind: 'slab' },
|
||||||
|
{ id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.png', kind: 'ceiling' },
|
||||||
|
{ id: 'roof', label: 'Roof', iconSrc: '/icons/roof.png', kind: 'roof' },
|
||||||
|
{ id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.png', kind: 'stair' },
|
||||||
|
{ id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.png', kind: 'elevator' },
|
||||||
|
{ id: 'door', label: 'Door', iconSrc: '/icons/door.png', kind: 'door' },
|
||||||
|
{ id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' },
|
||||||
|
{ id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' },
|
||||||
|
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/site.png', kind: 'spawn' },
|
||||||
|
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
|
||||||
|
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
|
||||||
|
*/
|
||||||
|
function activateBuildTool(kind: BuildToolKind): void {
|
||||||
|
const ed = useEditor.getState()
|
||||||
|
ed.setPhase('structure')
|
||||||
|
ed.setStructureLayer('elements')
|
||||||
|
ed.setCatalogCategory(null)
|
||||||
|
ed.setToolDefaults(kind, null)
|
||||||
|
ed.setMode('build')
|
||||||
|
ed.setTool(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enter material-paint mode — the Build tab's "Painting" category. */
|
||||||
|
function activatePaintMode(): void {
|
||||||
|
const ed = useEditor.getState()
|
||||||
|
ed.setPhase('structure')
|
||||||
|
ed.setStructureLayer('elements')
|
||||||
|
ed.setMode('material-paint')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build tab for the open-source standalone editor — a preset-less replica of
|
||||||
|
* the community Build sidebar. Clicking a type activates its raw tool, drawn
|
||||||
|
* with the kind's own `def.defaults()`. The "Painting" type swaps in the
|
||||||
|
* material-paint panel.
|
||||||
|
*/
|
||||||
|
export function BuildTab() {
|
||||||
|
const activeTool = useEditor((s) => s.tool)
|
||||||
|
const mode = useEditor((s) => s.mode)
|
||||||
|
|
||||||
|
const isTypeActive = (type: BuildType) =>
|
||||||
|
type.mode === 'material-paint'
|
||||||
|
? mode === 'material-paint'
|
||||||
|
: mode === 'build' && activeTool === type.kind
|
||||||
|
|
||||||
|
const handleTypeClick = useCallback((type: BuildType) => {
|
||||||
|
if (type.mode === 'material-paint') {
|
||||||
|
activatePaintMode()
|
||||||
|
} else if (type.kind) {
|
||||||
|
activateBuildTool(type.kind)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// On open, land on the first build tool — parity with the community Build
|
||||||
|
// sidebar, so switching to Build immediately arms a usable tool.
|
||||||
|
const didInitRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInitRef.current) return
|
||||||
|
didInitRef.current = true
|
||||||
|
const firstType = BUILD_TYPES.find((t) => t.kind)
|
||||||
|
if (firstType) handleTypeClick(firstType)
|
||||||
|
}, [handleTypeClick])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||||
|
<div
|
||||||
|
className="grid gap-1.5"
|
||||||
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
|
||||||
|
>
|
||||||
|
{BUILD_TYPES.map((type) => {
|
||||||
|
const active = isTypeActive(type)
|
||||||
|
return (
|
||||||
|
<Tooltip key={type.id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'group relative flex aspect-square items-center justify-center rounded-xl p-1 transition-all duration-200',
|
||||||
|
active
|
||||||
|
? 'bg-primary/10 ring-1 ring-primary/50'
|
||||||
|
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
|
||||||
|
)}
|
||||||
|
onClick={() => handleTypeClick(type)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt={type.label}
|
||||||
|
className="size-full object-contain transition-transform duration-200 group-hover:scale-110"
|
||||||
|
height={48}
|
||||||
|
src={type.iconSrc}
|
||||||
|
width={48}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="pointer-events-none" side="top">
|
||||||
|
{type.label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{mode === 'material-paint' ? (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<MaterialPaintPanel />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,9 +9,12 @@ import {
|
|||||||
type SceneGraph,
|
type SceneGraph,
|
||||||
type SidebarTab,
|
type SidebarTab,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
|
import { Hammer, Layers } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { BuildTab } from './build-tab'
|
||||||
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
|
import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight } from './viewer-toolbar'
|
||||||
|
|
||||||
export interface SceneMeta {
|
export interface SceneMeta {
|
||||||
@@ -32,6 +35,33 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [
|
|||||||
id: 'site',
|
id: 'site',
|
||||||
label: 'Scene',
|
label: 'Scene',
|
||||||
component: () => null, // Built-in SitePanel handles this
|
component: () => null, // Built-in SitePanel handles this
|
||||||
|
mobileDefaultSnap: 0.5,
|
||||||
|
mobileIcon: <Layers className="h-5 w-5" />,
|
||||||
|
icon: (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
height={32}
|
||||||
|
src="/icons/scene.png"
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'build',
|
||||||
|
label: 'Build',
|
||||||
|
component: BuildTab,
|
||||||
|
mobileDefaultSnap: 0.5,
|
||||||
|
mobileIcon: <Hammer className="h-5 w-5" />,
|
||||||
|
icon: (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 object-contain"
|
||||||
|
height={32}
|
||||||
|
src="/icons/build.png"
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
@@ -20,6 +20,8 @@ export function ItemsPanel({
|
|||||||
leadingTile,
|
leadingTile,
|
||||||
emptyState,
|
emptyState,
|
||||||
functionTree,
|
functionTree,
|
||||||
|
showSourceFilter = true,
|
||||||
|
showTagFilters = true,
|
||||||
}: {
|
}: {
|
||||||
items?: AssetInput[]
|
items?: AssetInput[]
|
||||||
/** Called when the search query changes (community edition uses this for server-side search) */
|
/** Called when the search query changes (community edition uses this for server-side search) */
|
||||||
@@ -41,6 +43,16 @@ export function ItemsPanel({
|
|||||||
* hierarchical tree browse instead of the legacy hardcoded category tabs.
|
* hierarchical tree browse instead of the legacy hardcoded category tabs.
|
||||||
*/
|
*/
|
||||||
functionTree?: FunctionTreeNode[]
|
functionTree?: FunctionTreeNode[]
|
||||||
|
/**
|
||||||
|
* Library/Community/Mine source chips. The open-source editor has no
|
||||||
|
* uploaded items (only the built-in catalog), so it hides these.
|
||||||
|
*/
|
||||||
|
showSourceFilter?: boolean
|
||||||
|
/**
|
||||||
|
* Placement/functional tag filter chips under the search row. The
|
||||||
|
* open-source editor hides these to keep the panel to plain categories.
|
||||||
|
*/
|
||||||
|
showTagFilters?: boolean
|
||||||
}) {
|
}) {
|
||||||
// When the embedder supplies a function taxonomy, the hierarchical browse
|
// When the embedder supplies a function taxonomy, the hierarchical browse
|
||||||
// replaces the legacy `furnishTools` category tabs entirely.
|
// replaces the legacy `furnishTools` category tabs entirely.
|
||||||
@@ -63,6 +75,8 @@ export function ItemsPanel({
|
|||||||
leadingTile={leadingTile}
|
leadingTile={leadingTile}
|
||||||
onSearchChange={onSearchChange}
|
onSearchChange={onSearchChange}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
|
showSourceFilter={showSourceFilter}
|
||||||
|
showTagFilters={showTagFilters}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,12 +86,16 @@ function LegacyItemsPanel({
|
|||||||
searchResults,
|
searchResults,
|
||||||
leadingTile,
|
leadingTile,
|
||||||
emptyState,
|
emptyState,
|
||||||
|
showSourceFilter = true,
|
||||||
|
showTagFilters = true,
|
||||||
}: {
|
}: {
|
||||||
items?: AssetInput[]
|
items?: AssetInput[]
|
||||||
onSearchChange?: (query: string) => void
|
onSearchChange?: (query: string) => void
|
||||||
searchResults?: AssetInput[] | null
|
searchResults?: AssetInput[] | null
|
||||||
leadingTile?: React.ReactNode
|
leadingTile?: React.ReactNode
|
||||||
emptyState?: React.ReactNode
|
emptyState?: React.ReactNode
|
||||||
|
showSourceFilter?: boolean
|
||||||
|
showTagFilters?: boolean
|
||||||
}) {
|
}) {
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const catalogCategory = useEditor((s) => s.catalogCategory)
|
const catalogCategory = useEditor((s) => s.catalogCategory)
|
||||||
@@ -89,8 +107,11 @@ function LegacyItemsPanel({
|
|||||||
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
|
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
|
||||||
// Library / Community / Mine. Default to Library so first-time users see
|
// Library / Community / Mine. Default to Library so first-time users see
|
||||||
// the curated catalog rather than every uploaded item; clicking the chip
|
// the curated catalog rather than every uploaded item; clicking the chip
|
||||||
// again clears the filter (`null` = show everything).
|
// again clears the filter (`null` = show everything). With the chips hidden
|
||||||
const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
|
// there is nothing to filter by, so start unfiltered.
|
||||||
|
const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>(
|
||||||
|
showSourceFilter ? 'library' : null,
|
||||||
|
)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const isServerSearch = onSearchChange !== undefined
|
const isServerSearch = onSearchChange !== undefined
|
||||||
// True when server search is active but results haven't come back yet
|
// True when server search is active but results haven't come back yet
|
||||||
@@ -152,7 +173,7 @@ function LegacyItemsPanel({
|
|||||||
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
|
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
|
||||||
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
|
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
|
||||||
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
|
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
|
||||||
const hasFilters = allTags.length > 1
|
const hasFilters = showTagFilters && allTags.length > 1
|
||||||
|
|
||||||
const placementCount = (tag: string | null) =>
|
const placementCount = (tag: string | null) =>
|
||||||
categoryItems.filter((item) => {
|
categoryItems.filter((item) => {
|
||||||
@@ -205,9 +226,13 @@ function LegacyItemsPanel({
|
|||||||
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
|
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Search and source filter take 50/50 of the row. `min-w-0` on
|
{/* Search and source filter take 50/50 of the row. `min-w-0` on
|
||||||
both sides lets each half shrink to fit when the panel narrows. */}
|
both sides lets each half shrink to fit when the panel narrows.
|
||||||
|
With the source chips hidden, search spans the full row. */}
|
||||||
<input
|
<input
|
||||||
className="w-1/2 min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none"
|
className={cn(
|
||||||
|
'min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none',
|
||||||
|
showSourceFilter ? 'w-1/2' : 'w-full',
|
||||||
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value)
|
setSearch(e.target.value)
|
||||||
onSearchChange?.(e.target.value)
|
onSearchChange?.(e.target.value)
|
||||||
@@ -216,7 +241,7 @@ function LegacyItemsPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
/>
|
/>
|
||||||
{sourceChips.length > 0 && (
|
{showSourceFilter && sourceChips.length > 0 && (
|
||||||
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
|
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
|
||||||
{sourceChips.map((chip) => {
|
{sourceChips.map((chip) => {
|
||||||
const isActive = activeSource === chip.id
|
const isActive = activeSource === chip.id
|
||||||
|
|||||||
Reference in New Issue
Block a user