Phone/merit authoring UI: NPC contacts + phone node + terminal NPC picker
Admin NPC editor gains phone number and email fields. The mystery graph editor gets Phone and Merit in the node palette; a phone node's terminals each get an NPC picker (bind the callee you reach by dialing their number), and merit nodes get an "awards achievement" field. Node summaries + type colors for both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { MysteryGraphEditor } from './mysteryGraph'
|
import { MysteryGraphEditor } from './mysteryGraph'
|
||||||
|
|
||||||
type Pose = { poseKey: string; assetId: string; url: string }
|
type Pose = { poseKey: string; assetId: string; url: string }
|
||||||
type Npc = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: Pose[]; inUse: boolean }
|
type Npc = { id: string; key: string; name: string; role: string; defaultPose: string | null; phoneNumber: string | null; email: string | null; poses: Pose[]; inUse: boolean }
|
||||||
type Mystery = { id: string; slug: string; title: string; nodes: number }
|
type Mystery = { id: string; slug: string; title: string; nodes: number }
|
||||||
|
|
||||||
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -172,15 +172,18 @@ function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?:
|
|||||||
const [name, setName] = useState(npc.name)
|
const [name, setName] = useState(npc.name)
|
||||||
const [role, setRole] = useState(npc.role)
|
const [role, setRole] = useState(npc.role)
|
||||||
const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '')
|
const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '')
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState(npc.phoneNumber || '')
|
||||||
|
const [email, setEmail] = useState(npc.email || '')
|
||||||
const [poseKey, setPoseKey] = useState('')
|
const [poseKey, setPoseKey] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose
|
const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose
|
||||||
|
|| (phoneNumber || null) !== npc.phoneNumber || (email || null) !== npc.email
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await json(`/api/admin/npcs/${npc.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role, defaultPose: defaultPose || null }) })
|
await json(`/api/admin/npcs/${npc.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role, defaultPose: defaultPose || null, phoneNumber: phoneNumber || null, email: email || null }) })
|
||||||
await onChanged(npc.key); setStatus(`Saved ${name}`)
|
await onChanged(npc.key); setStatus(`Saved ${name}`)
|
||||||
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
@@ -213,6 +216,8 @@ function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?:
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-field"><label>Display name</label><input value={name} onChange={event => setName(event.target.value)} /></div>
|
<div className="admin-field"><label>Display name</label><input value={name} onChange={event => setName(event.target.value)} /></div>
|
||||||
<div className="admin-field"><label>Role / affiliation</label><input value={role} onChange={event => setRole(event.target.value)} placeholder="Glitch University · Investigative Method" /></div>
|
<div className="admin-field"><label>Role / affiliation</label><input value={role} onChange={event => setRole(event.target.value)} placeholder="Glitch University · Investigative Method" /></div>
|
||||||
|
<div className="admin-field"><label>Phone number</label><input value={phoneNumber} onChange={event => setPhoneNumber(event.target.value)} placeholder="55501" /></div>
|
||||||
|
<div className="admin-field"><label>Email</label><input value={email} onChange={event => setEmail(event.target.value)} placeholder="hunter@glitch.university" /></div>
|
||||||
<div className="admin-field"><label>Default pose</label>
|
<div className="admin-field"><label>Default pose</label>
|
||||||
<select value={defaultPose} onChange={event => setDefaultPose(event.target.value)}>
|
<select value={defaultPose} onChange={event => setDefaultPose(event.target.value)}>
|
||||||
<option value="">— none —</option>
|
<option value="">— none —</option>
|
||||||
|
|||||||
+21
-7
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { UtteranceCanvas } from './utteranceCanvas'
|
import { UtteranceCanvas } from './utteranceCanvas'
|
||||||
import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
|
import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
|
||||||
|
|
||||||
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'
|
||||||
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number; npcId: string | null }
|
||||||
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number; terminals: Terminal[] }
|
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null; terminals: Terminal[] }
|
||||||
type AudioAsset = { id: string; originalName: string; mimeType: string }
|
type AudioAsset = { id: string; originalName: string; mimeType: string }
|
||||||
|
type Npc = { id: string; name: string; phoneNumber: string | null }
|
||||||
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
|
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
|
||||||
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
|
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ const NODE_W = 200, NODE_H = 88
|
|||||||
const TYPES: { type: StoryNodeType; label: string }[] = [
|
const TYPES: { type: StoryNodeType; label: string }[] = [
|
||||||
{ type: 'cutscene', label: 'Cutscene' }, { type: 'dialogue', label: 'Dialogue' }, { type: 'level', label: 'Level' },
|
{ type: 'cutscene', label: 'Cutscene' }, { type: 'dialogue', label: 'Dialogue' }, { type: 'level', label: 'Level' },
|
||||||
{ type: 'det_gate', label: 'Det gate' }, { type: 'llm_gate', label: 'LLM gate' },
|
{ type: 'det_gate', label: 'Det gate' }, { type: 'llm_gate', label: 'LLM gate' },
|
||||||
|
{ type: 'phone', label: 'Phone' }, { type: 'merit', label: 'Merit' },
|
||||||
]
|
]
|
||||||
const outPort = (node: StoryNode, index: number) => ({ x: node.xpos + NODE_W * (index + 0.5) / Math.max(1, node.terminals.length), y: node.ypos + NODE_H })
|
const outPort = (node: StoryNode, index: number) => ({ x: node.xpos + NODE_W * (index + 0.5) / Math.max(1, node.terminals.length), y: node.ypos + NODE_H })
|
||||||
const inPort = (node: StoryNode) => ({ x: node.xpos + NODE_W / 2, y: node.ypos })
|
const inPort = (node: StoryNode) => ({ x: node.xpos + NODE_W / 2, y: node.ypos })
|
||||||
@@ -28,6 +30,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
|||||||
const [graph, setGraph] = useState<Graph | null>(null)
|
const [graph, setGraph] = useState<Graph | null>(null)
|
||||||
const [templates, setTemplates] = useState<LevelTemplate[]>([])
|
const [templates, setTemplates] = useState<LevelTemplate[]>([])
|
||||||
const [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
|
const [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
|
||||||
|
const [npcs, setNpcs] = useState<Npc[]>([])
|
||||||
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
|
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
|
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
|
||||||
@@ -43,6 +46,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
|||||||
void reload()
|
void reload()
|
||||||
api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {})
|
api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {})
|
||||||
api<AudioAsset[]>('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).catch(() => {})
|
api<AudioAsset[]>('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).catch(() => {})
|
||||||
|
api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {})
|
||||||
}, [reload])
|
}, [reload])
|
||||||
|
|
||||||
const centerInBoard = () => {
|
const centerInBoard = () => {
|
||||||
@@ -155,7 +159,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected && <aside className="graph-inspector">
|
{selected && <aside className="graph-inspector">
|
||||||
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} audioAssets={audioAssets}
|
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} audioAssets={audioAssets} npcs={npcs}
|
||||||
onEditUtterances={() => setUtterancesNode(selected)}
|
onEditUtterances={() => setUtterancesNode(selected)}
|
||||||
onPatch={body => patchNode(selected.id, body)}
|
onPatch={body => patchNode(selected.id, body)}
|
||||||
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||||
@@ -170,21 +174,24 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
|||||||
|
|
||||||
function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
|
function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
|
||||||
if (node.nodeType === 'level') return templates.find(t => t.versionId === node.levelTemplateVersionId)?.name || '⚠ no level chosen'
|
if (node.nodeType === 'level') return templates.find(t => t.versionId === node.levelTemplateVersionId)?.name || '⚠ no level chosen'
|
||||||
|
if (node.nodeType === 'merit') return node.awardsFlag ? `🏅 ${node.awardsFlag}` : '⚠ no achievement'
|
||||||
|
if (node.nodeType === 'phone') return `${node.terminals.length} contact${node.terminals.length === 1 ? '' : 's'}`
|
||||||
if (node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate') return node.componentKey || '⚠ no component'
|
if (node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate') return node.componentKey || '⚠ no component'
|
||||||
if (node.nodeType === 'dialogue') return node.hasUtterances ? 'utterances' : 'no utterances'
|
if (node.nodeType === 'dialogue') return node.hasUtterances ? 'utterances' : 'no utterances'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
function NodeInspector({ node, graph, templates, audioAssets, npcs, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
||||||
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]
|
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]; npcs: Npc[]
|
||||||
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
|
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
|
||||||
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
|
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
|
||||||
}) {
|
}) {
|
||||||
const [label, setLabel] = useState(node.label)
|
const [label, setLabel] = useState(node.label)
|
||||||
const [componentKey, setComponentKey] = useState(node.componentKey || '')
|
const [componentKey, setComponentKey] = useState(node.componentKey || '')
|
||||||
|
const [awardsFlag, setAwardsFlag] = useState(node.awardsFlag || '')
|
||||||
const [volume, setVolume] = useState(node.musicVolume)
|
const [volume, setVolume] = useState(node.musicVolume)
|
||||||
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
|
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
|
||||||
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate'
|
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate' || node.nodeType === 'merit'
|
||||||
return <div className="inspector-body">
|
return <div className="inspector-body">
|
||||||
<div className="inspector-head"><span className="gnode-type">{node.nodeType}</span>{graph.entryNodeId !== node.id && <button className="ins-entry" onClick={onSetEntry}>Set entrypoint</button>}</div>
|
<div className="inspector-head"><span className="gnode-type">{node.nodeType}</span>{graph.entryNodeId !== node.id && <button className="ins-entry" onClick={onSetEntry}>Set entrypoint</button>}</div>
|
||||||
<label className="ins-field"><span>Label</span><input value={label} onChange={e => setLabel(e.target.value)} onBlur={() => label !== node.label && onPatch({ label })} /></label>
|
<label className="ins-field"><span>Label</span><input value={label} onChange={e => setLabel(e.target.value)} onBlur={() => label !== node.label && onPatch({ label })} /></label>
|
||||||
@@ -197,6 +204,9 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr
|
|||||||
<input list={node.nodeType === 'cutscene' ? 'cutscene-components' : undefined} value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} />
|
<input list={node.nodeType === 'cutscene' ? 'cutscene-components' : undefined} value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} />
|
||||||
{node.nodeType === 'cutscene' && <datalist id="cutscene-components">{CUTSCENE_COMPONENT_KEYS.map(k => <option key={k} value={k} />)}</datalist>}
|
{node.nodeType === 'cutscene' && <datalist id="cutscene-components">{CUTSCENE_COMPONENT_KEYS.map(k => <option key={k} value={k} />)}</datalist>}
|
||||||
</label>}
|
</label>}
|
||||||
|
{node.nodeType === 'merit' && <label className="ins-field"><span>Awards achievement{node.awardsFlag ? '' : <b className="ins-warn"> · required</b>}</span>
|
||||||
|
<input value={awardsFlag} placeholder="barricelli_luggage" onChange={e => setAwardsFlag(e.target.value)} onBlur={() => awardsFlag !== (node.awardsFlag || '') && onPatch({ awardsFlag: awardsFlag || null })} /></label>}
|
||||||
|
{node.nodeType === 'phone' && <p className="ins-hint">Add a terminal per contact, pick the NPC you reach by dialing their number, then wire it to the dialogue that answers.</p>}
|
||||||
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
|
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
|
||||||
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances →</button>}
|
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances →</button>}
|
||||||
<label className="ins-field"><span>Scene music</span>
|
<label className="ins-field"><span>Scene music</span>
|
||||||
@@ -213,6 +223,10 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr
|
|||||||
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
|
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
|
||||||
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
|
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
|
||||||
<input defaultValue={t.label} onBlur={e => e.target.value !== t.label && onTerminalPatch(t.id, { label: e.target.value })} />
|
<input defaultValue={t.label} onBlur={e => e.target.value !== t.label && onTerminalPatch(t.id, { label: e.target.value })} />
|
||||||
|
{node.nodeType === 'phone' && <select className="ins-terminal-npc" value={t.npcId || ''} onChange={e => onTerminalPatch(t.id, { npcId: e.target.value || null })}>
|
||||||
|
<option value="">— NPC —</option>
|
||||||
|
{npcs.map(n => <option key={n.id} value={n.id}>{n.name}{n.phoneNumber ? ` · ${n.phoneNumber}` : ' · (no number)'}</option>)}
|
||||||
|
</select>}
|
||||||
<span className="ins-terminal-to">→ {nodeName(t.toNodeId)}</span>
|
<span className="ins-terminal-to">→ {nodeName(t.toNodeId)}</span>
|
||||||
{t.toNodeId && <button className="ins-unwire" title="Unwire" onClick={() => onTerminalPatch(t.id, { toNodeId: null })}>⊘</button>}
|
{t.toNodeId && <button className="ins-unwire" title="Unwire" onClick={() => onTerminalPatch(t.id, { toNodeId: null })}>⊘</button>}
|
||||||
<button className="ins-term-del" title="Delete terminal" onClick={() => onTerminalDelete(t.id)}>×</button>
|
<button className="ins-term-del" title="Delete terminal" onClick={() => onTerminalDelete(t.id)}>×</button>
|
||||||
|
|||||||
@@ -459,6 +459,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.gnode-type { font: 600 8px IBM Plex Mono; letter-spacing: .1em; text-transform: uppercase; padding: 2px 5px; border-radius: 2px; background: #24413a; color: #9bd; }
|
.gnode-type { font: 600 8px IBM Plex Mono; letter-spacing: .1em; text-transform: uppercase; padding: 2px 5px; border-radius: 2px; background: #24413a; color: #9bd; }
|
||||||
.type-cutscene .gnode-type { color: #d7a0e0; } .type-dialogue .gnode-type { color: #7fc7b6; }
|
.type-cutscene .gnode-type { color: #d7a0e0; } .type-dialogue .gnode-type { color: #7fc7b6; }
|
||||||
.type-level .gnode-type { color: #e7b57e; } .type-det_gate .gnode-type { color: #d89a9a; } .type-llm_gate .gnode-type { color: #c9b06e; }
|
.type-level .gnode-type { color: #e7b57e; } .type-det_gate .gnode-type { color: #d89a9a; } .type-llm_gate .gnode-type { color: #c9b06e; }
|
||||||
|
.type-phone .gnode-type { color: #9fd020; } .type-merit .gnode-type { color: #cdea6a; }
|
||||||
|
.ins-hint { font: 10px IBM Plex Mono; color: #7f9a92; line-height: 1.5; margin: 2px 0 6px; }
|
||||||
|
.ins-terminal-npc { font: 10px IBM Plex Mono; background: #08201b; color: #cfe8df; border: 1px solid #3c5a52; max-width: 130px; }
|
||||||
.gnode-label { flex: 1; font: 11px IBM Plex Mono; color: #e4e9e4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.gnode-label { flex: 1; font: 11px IBM Plex Mono; color: #e4e9e4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.gnode-entry { color: #6fbf8b; font-size: 10px; }
|
.gnode-entry { color: #6fbf8b; font-size: 10px; }
|
||||||
.gnode-sub { height: 18px; padding: 0 10px; font: 8px IBM Plex Mono; color: #7f9a92; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.gnode-sub { height: 18px; padding: 0 10px; font: 8px IBM Plex Mono; color: #7f9a92; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|||||||
Reference in New Issue
Block a user