Editor: font-size presets + image insert/export support
Two editor features that were in flight alongside the sound work: - FontSize TipTap extension (rides on textStyle) with Small/Normal/Large/ Title presets in the toolbar; StatusBar + CSS support - Image handling: internal/images handler, upload route + config, client API, EditorCore wiring, and md/html/docx export support for images Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { useEditorState } from '@tiptap/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { uploadImageInto } from '../Editor/EditorCore'
|
||||
|
||||
interface Props {
|
||||
editor: Editor | null
|
||||
@@ -15,18 +17,21 @@ function TBtn({
|
||||
active,
|
||||
disabled,
|
||||
label,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
label: string
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={title ?? label}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onMouseDown={(e) => e.preventDefault()} // keep editor selection
|
||||
@@ -47,10 +52,125 @@ const Divider = () => (
|
||||
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
|
||||
)
|
||||
|
||||
// A popover anchored under its trigger. The trigger + panel share a relative
|
||||
// wrapper; `open`/`onClose` are owned by the toolbar so only one is open at once.
|
||||
// A pointer-down outside the wrapper closes it.
|
||||
function Popover({
|
||||
open,
|
||||
onClose,
|
||||
trigger,
|
||||
children,
|
||||
width = 200,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
trigger: React.ReactNode
|
||||
children: React.ReactNode
|
||||
width?: number
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open, onClose])
|
||||
return (
|
||||
<div ref={ref} className="relative flex items-center">
|
||||
{trigger}
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 top-full z-40 mt-1.5 p-2"
|
||||
style={{
|
||||
width,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Text-color swatches. `null` clears the color back to the theme default.
|
||||
const TEXT_COLORS: { label: string; value: string | null }[] = [
|
||||
{ label: 'Default', value: null },
|
||||
{ label: 'Rose', value: '#D98AAF' },
|
||||
{ label: 'Coral', value: '#E0785C' },
|
||||
{ label: 'Honey', value: '#C68B2E' },
|
||||
{ label: 'Green', value: '#4F9E7F' },
|
||||
{ label: 'Sky', value: '#4E8FCC' },
|
||||
{ label: 'Lavender', value: '#7D63C4' },
|
||||
{ label: 'Plum', value: '#3D2E39' },
|
||||
]
|
||||
|
||||
// Highlighter colors — soft pastels so dark text stays readable on top.
|
||||
const HIGHLIGHTS: { label: string; value: string | null }[] = [
|
||||
{ label: 'None', value: null },
|
||||
{ label: 'Yellow', value: '#FFF1A8' },
|
||||
{ label: 'Pink', value: '#FAD4E4' },
|
||||
{ label: 'Mint', value: '#CDEFE2' },
|
||||
{ label: 'Peach', value: '#FBE0CF' },
|
||||
{ label: 'Lavender', value: '#E6DCFA' },
|
||||
{ label: 'Sky', value: '#D6E8FB' },
|
||||
]
|
||||
|
||||
// Font-size presets. `null` clears back to the document default.
|
||||
const SIZES: { label: string; value: string | null; em: string }[] = [
|
||||
{ label: 'Small', value: '0.85em', em: '0.85em' },
|
||||
{ label: 'Normal', value: null, em: '1em' },
|
||||
{ label: 'Large', value: '1.3em', em: '1.3em' },
|
||||
{ label: 'Title', value: '1.7em', em: '1.7em' },
|
||||
]
|
||||
|
||||
// A round color chip used in the color/highlight palettes.
|
||||
function Swatch({
|
||||
color,
|
||||
active,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
color: string | null
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className="flex h-7 w-7 items-center justify-center"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: color ?? 'var(--color-surface)',
|
||||
border: active ? '2px solid var(--color-accent)' : '1px solid var(--color-border)',
|
||||
// The "clear" chip (no color) shows a tiny diagonal stroke.
|
||||
backgroundImage: color
|
||||
? undefined
|
||||
: 'linear-gradient(135deg, transparent 44%, var(--color-accent) 44%, var(--color-accent) 56%, transparent 56%)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
|
||||
// useEditorState subscribes to just the flags it reads, so the buttons reflect
|
||||
// the current selection without re-rendering the whole tree on every keystroke.
|
||||
export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
// Which popover (if any) is open. Only one at a time.
|
||||
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | null>(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) =>
|
||||
@@ -59,27 +179,72 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
bold: editor.isActive('bold'),
|
||||
italic: editor.isActive('italic'),
|
||||
underline: editor.isActive('underline'),
|
||||
strike: editor.isActive('strike'),
|
||||
h1: editor.isActive('heading', { level: 1 }),
|
||||
h2: editor.isActive('heading', { level: 2 }),
|
||||
h3: editor.isActive('heading', { level: 3 }),
|
||||
bullet: editor.isActive('bulletList'),
|
||||
ordered: editor.isActive('orderedList'),
|
||||
left: editor.isActive({ textAlign: 'left' }),
|
||||
center: editor.isActive({ textAlign: 'center' }),
|
||||
right: editor.isActive({ textAlign: 'right' }),
|
||||
link: editor.isActive('link'),
|
||||
inTable: editor.isActive('table'),
|
||||
color: editor.getAttributes('textStyle').color ?? null,
|
||||
highlight: editor.getAttributes('highlight').color ?? null,
|
||||
fontSize: editor.getAttributes('textStyle').fontSize ?? null,
|
||||
canUndo: editor.can().undo(),
|
||||
canRedo: editor.can().redo(),
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
if (!editor || !state) return null
|
||||
|
||||
const close = () => setMenu(null)
|
||||
|
||||
// Open the link popover, pre-filling the field with any link already on the
|
||||
// selection so it can be edited rather than retyped.
|
||||
const openLink = () => {
|
||||
setLinkUrl(editor.getAttributes('link').href ?? '')
|
||||
setMenu(menu === 'link' ? null : 'link')
|
||||
}
|
||||
|
||||
const applyLink = () => {
|
||||
const url = linkUrl.trim()
|
||||
if (!url) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
} else {
|
||||
// Default to https:// when the writer omits a scheme.
|
||||
const href = /^(https?:|mailto:|\/)/i.test(url) ? url : `https://${url}`
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const onPickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadImageInto(editor.view, file)
|
||||
e.target.value = '' // allow re-picking the same file
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
|
||||
className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
}}
|
||||
>
|
||||
<TBtn label="Undo" disabled={!state.canUndo} onClick={() => editor.chain().focus().undo().run()}>
|
||||
↶
|
||||
</TBtn>
|
||||
<TBtn label="Redo" disabled={!state.canRedo} onClick={() => editor.chain().focus().redo().run()}>
|
||||
↷
|
||||
</TBtn>
|
||||
<Divider />
|
||||
|
||||
<TBtn label="Bold" active={state.bold} onClick={() => editor.chain().focus().toggleBold().run()}>
|
||||
<span className="font-extrabold">B</span>
|
||||
</TBtn>
|
||||
@@ -93,51 +258,257 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
>
|
||||
<span className="underline">U</span>
|
||||
</TBtn>
|
||||
<TBtn label="Strikethrough" active={state.strike} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
||||
<span className="line-through">S</span>
|
||||
</TBtn>
|
||||
<Divider />
|
||||
<TBtn
|
||||
label="Heading 1"
|
||||
active={state.h1}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
|
||||
{/* Text color */}
|
||||
<Popover
|
||||
open={menu === 'color'}
|
||||
onClose={close}
|
||||
width={188}
|
||||
trigger={
|
||||
<TBtn label="Text color" active={menu === 'color'} onClick={() => setMenu(menu === 'color' ? null : 'color')}>
|
||||
<span className="flex flex-col items-center leading-none">
|
||||
<span className="text-sm font-bold">A</span>
|
||||
<span
|
||||
className="mt-0.5 h-1 w-4 rounded-full"
|
||||
style={{ background: state.color ?? 'var(--color-accent)' }}
|
||||
/>
|
||||
</span>
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{TEXT_COLORS.map((c) => (
|
||||
<Swatch
|
||||
key={c.label}
|
||||
color={c.value}
|
||||
title={c.label}
|
||||
active={state.color === c.value}
|
||||
onClick={() => {
|
||||
if (c.value) editor.chain().focus().setColor(c.value).run()
|
||||
else editor.chain().focus().unsetColor().run()
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
{/* Highlight */}
|
||||
<Popover
|
||||
open={menu === 'highlight'}
|
||||
onClose={close}
|
||||
width={170}
|
||||
trigger={
|
||||
<TBtn
|
||||
label="Highlight"
|
||||
active={menu === 'highlight' || !!state.highlight}
|
||||
onClick={() => setMenu(menu === 'highlight' ? null : 'highlight')}
|
||||
>
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-xs font-bold"
|
||||
style={{ background: state.highlight ?? '#FFF1A8', color: 'var(--color-plum)' }}
|
||||
>
|
||||
H
|
||||
</span>
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{HIGHLIGHTS.map((c) => (
|
||||
<Swatch
|
||||
key={c.label}
|
||||
color={c.value}
|
||||
title={c.label}
|
||||
active={state.highlight === c.value}
|
||||
onClick={() => {
|
||||
if (c.value) editor.chain().focus().setHighlight({ color: c.value }).run()
|
||||
else editor.chain().focus().unsetHighlight().run()
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
{/* Font size */}
|
||||
<Popover
|
||||
open={menu === 'size'}
|
||||
onClose={close}
|
||||
width={140}
|
||||
trigger={
|
||||
<TBtn label="Text size" active={menu === 'size'} onClick={() => setMenu(menu === 'size' ? null : 'size')}>
|
||||
<span className="text-xs font-bold tracking-tight">
|
||||
<span className="text-[0.7rem]">A</span>
|
||||
<span className="text-sm">A</span>
|
||||
</span>
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{SIZES.map((s) => {
|
||||
const active = state.fontSize === s.value
|
||||
return (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
if (s.value) editor.chain().focus().setFontSize(s.value).run()
|
||||
else editor.chain().focus().unsetFontSize().run()
|
||||
close()
|
||||
}}
|
||||
className="flex items-center justify-between rounded px-2 py-1 text-left"
|
||||
style={{
|
||||
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||
color: active ? 'var(--color-accent-hover)' : 'var(--color-plum)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: s.em }}>{s.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Popover>
|
||||
<Divider />
|
||||
|
||||
<TBtn label="Heading 1" active={state.h1} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>
|
||||
H1
|
||||
</TBtn>
|
||||
<TBtn
|
||||
label="Heading 2"
|
||||
active={state.h2}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<TBtn label="Heading 2" active={state.h2} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>
|
||||
H2
|
||||
</TBtn>
|
||||
<TBtn
|
||||
label="Bullet list"
|
||||
active={state.bullet}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<TBtn label="Heading 3" active={state.h3} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>
|
||||
H3
|
||||
</TBtn>
|
||||
<TBtn label="Bullet list" active={state.bullet} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
||||
•
|
||||
</TBtn>
|
||||
<TBtn label="Numbered list" active={state.ordered} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
||||
1.
|
||||
</TBtn>
|
||||
<Divider />
|
||||
<TBtn
|
||||
label="Align left"
|
||||
active={state.left}
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
>
|
||||
|
||||
<TBtn label="Align left" active={state.left} onClick={() => editor.chain().focus().setTextAlign('left').run()}>
|
||||
⇤
|
||||
</TBtn>
|
||||
<TBtn
|
||||
label="Align center"
|
||||
active={state.center}
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
>
|
||||
<TBtn label="Align center" active={state.center} onClick={() => editor.chain().focus().setTextAlign('center').run()}>
|
||||
↔
|
||||
</TBtn>
|
||||
<TBtn
|
||||
label="Align right"
|
||||
active={state.right}
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
>
|
||||
<TBtn label="Align right" active={state.right} onClick={() => editor.chain().focus().setTextAlign('right').run()}>
|
||||
⇥
|
||||
</TBtn>
|
||||
<Divider />
|
||||
|
||||
{/* Link */}
|
||||
<Popover
|
||||
open={menu === 'link'}
|
||||
onClose={close}
|
||||
width={236}
|
||||
trigger={
|
||||
<TBtn label="Link" active={state.link || menu === 'link'} onClick={openLink}>
|
||||
🔗
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') applyLink()
|
||||
if (e.key === 'Escape') close()
|
||||
}}
|
||||
placeholder="https://…"
|
||||
className="w-full px-2 py-1.5 text-sm focus:outline-none"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
{state.link ? (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
close()
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-semibold"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={applyLink}
|
||||
className="px-3 py-1 text-xs font-bold"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-accent)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
{/* Image */}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={onPickImage} />
|
||||
<TBtn label="Insert image" onClick={() => fileInputRef.current?.click()}>
|
||||
🖼️
|
||||
</TBtn>
|
||||
|
||||
{/* Table */}
|
||||
<Popover
|
||||
open={menu === 'table'}
|
||||
onClose={close}
|
||||
width={state.inTable ? 188 : 150}
|
||||
trigger={
|
||||
<TBtn label="Table" active={state.inTable || menu === 'table'} onClick={() => setMenu(menu === 'table' ? null : 'table')}>
|
||||
▦
|
||||
</TBtn>
|
||||
}
|
||||
>
|
||||
{state.inTable ? (
|
||||
<div className="flex flex-col gap-0.5 text-sm" style={{ color: 'var(--color-plum)' }}>
|
||||
<TableAction label="Add row below" onClick={() => editor.chain().focus().addRowAfter().run()} />
|
||||
<TableAction label="Add column right" onClick={() => editor.chain().focus().addColumnAfter().run()} />
|
||||
<TableAction label="Toggle header row" onClick={() => editor.chain().focus().toggleHeaderRow().run()} />
|
||||
<TableAction label="Delete row" onClick={() => editor.chain().focus().deleteRow().run()} />
|
||||
<TableAction label="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()} />
|
||||
<TableAction
|
||||
label="Delete table"
|
||||
danger
|
||||
onClick={() => {
|
||||
editor.chain().focus().deleteTable().run()
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<GridPicker
|
||||
onPick={(rows, cols) => {
|
||||
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run()
|
||||
close()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
<Divider />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Check my voice"
|
||||
@@ -162,3 +533,58 @@ export function Toolbar({ editor, onVoiceCheck, voicing }: Props) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A row in the in-table editing menu.
|
||||
function TableAction({ label, onClick, danger }: { label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className="rounded px-2 py-1 text-left"
|
||||
style={{ color: danger ? 'var(--color-accent-hover)' : 'var(--color-plum)' }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// A hover-to-size grid for inserting a table. Hovering a cell highlights the
|
||||
// rectangle from the top-left; clicking inserts that many rows × columns.
|
||||
function GridPicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||
const MAX = 6
|
||||
const [hover, setHover] = useState<{ r: number; c: number }>({ r: 0, c: 0 })
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="grid gap-0.5"
|
||||
style={{ gridTemplateColumns: `repeat(${MAX}, 1fr)` }}
|
||||
onMouseLeave={() => setHover({ r: 0, c: 0 })}
|
||||
>
|
||||
{Array.from({ length: MAX * MAX }).map((_, i) => {
|
||||
const r = Math.floor(i / MAX) + 1
|
||||
const c = (i % MAX) + 1
|
||||
const on = r <= hover.r && c <= hover.c
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHover({ r, c })}
|
||||
onClick={() => onPick(r, c)}
|
||||
className="h-4 w-4"
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
background: on ? 'var(--color-accent)' : 'var(--color-surface-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1.5 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
|
||||
{hover.r > 0 ? `${hover.r} × ${hover.c}` : 'Pick a size'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user