feat(chat): file upload + attachments + pinned canvas
- composer 📎 attach (multi-file) with removable chips; uploads to /files then sends a message with attachments; image preview / download chips via ?access_token. - pinned per-channel canvas panel below the topbar (fixed; independent of message scroll): md→renderMarkdown, text→<pre>, html→sandboxed iframe; collapse/expand. - share/edit canvas modal (sharer-only Edit/Remove); live via canvas.updated/canvas.removed sockets; channel & messages context menus get canvas actions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
src/index.css
131
src/index.css
@@ -864,3 +864,134 @@ button {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- canvas (pinned document) ---- */
|
||||||
|
.dc-canvas {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--elevated);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 46vh;
|
||||||
|
}
|
||||||
|
.dc-canvas-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.dc-canvas-head .cv-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-h);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 40%;
|
||||||
|
}
|
||||||
|
.dc-canvas-head .cv-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dc-canvas-head .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.dc-canvas-body {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.dc-canvas-body .cv-frame {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.dc-canvas-body .cv-text {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.dc-canvas-body .cv-md {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- message attachments ---- */
|
||||||
|
.attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.att-img {
|
||||||
|
max-width: 320px;
|
||||||
|
max-height: 240px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.att-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.att-chip:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.pf-x {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.pf-x:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- composer with attachments ---- */
|
||||||
|
.dc-composer-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.pending-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px 0;
|
||||||
|
}
|
||||||
|
.dc-composer-wrap .dc-composer {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.attach-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.attach-btn[aria-disabled='true'] {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
textarea.canvas-src {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.modal-card.modal-wide {
|
||||||
|
max-width: 720px;
|
||||||
|
width: 92vw;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from '../auth/auth-context'
|
|||||||
import { renderMarkdown } from '../lib/markdown'
|
import { renderMarkdown } from '../lib/markdown'
|
||||||
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
||||||
|
|
||||||
|
type Attachment = { url: string; name?: string; mimeType?: string }
|
||||||
type MessageItem = {
|
type MessageItem = {
|
||||||
messageId: string
|
messageId: string
|
||||||
seq: number
|
seq: number
|
||||||
@@ -12,11 +13,23 @@ type MessageItem = {
|
|||||||
isDeleted?: boolean
|
isDeleted?: boolean
|
||||||
authorUserId?: string
|
authorUserId?: string
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
|
attachments?: Attachment[]
|
||||||
// per-push metadata (only present on socket-delivered messages); the app is
|
// per-push metadata (only present on socket-delivered messages); the app is
|
||||||
// normally metadata-agnostic — only surfaced in developer mode
|
// normally metadata-agnostic — only surfaced in developer mode
|
||||||
wakeup?: boolean
|
wakeup?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CanvasFormat = 'md' | 'html' | 'text'
|
||||||
|
type CanvasDoc = {
|
||||||
|
channelId: string
|
||||||
|
sharerUserId: string
|
||||||
|
title: string
|
||||||
|
format: CanvasFormat
|
||||||
|
source: string
|
||||||
|
version: number
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
const DEV_KEY = 'fabric.debug'
|
const DEV_KEY = 'fabric.debug'
|
||||||
|
|
||||||
function initials(s: string): string {
|
function initials(s: string): string {
|
||||||
@@ -70,6 +83,15 @@ export default function ChatPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [canvas, setCanvas] = useState<CanvasDoc | null>(null)
|
||||||
|
const [canvasCollapsed, setCanvasCollapsed] = useState(false)
|
||||||
|
const [showCanvasModal, setShowCanvasModal] = useState(false)
|
||||||
|
const [canvasTitle, setCanvasTitle] = useState('')
|
||||||
|
const [canvasFormat, setCanvasFormat] = useState<CanvasFormat>('md')
|
||||||
|
const [canvasSource, setCanvasSource] = useState('')
|
||||||
|
const [canvasEditMode, setCanvasEditMode] = useState(false)
|
||||||
|
|
||||||
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
|
function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -212,18 +234,93 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file: File): Promise<Attachment> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const res = await guildApi().post(
|
||||||
|
`/files?channelId=${encodeURIComponent(selectedChannelId)}`,
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
return { url: res.data.url as string, name: res.data.name as string, mimeType: res.data.mimeType as string }
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
if (!selectedChannelId || !content.trim() || !guild || !guildToken) return
|
if (!selectedChannelId || !guild || !guildToken) return
|
||||||
|
if (!content.trim() && pendingFiles.length === 0) return
|
||||||
setError('')
|
setError('')
|
||||||
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
|
const attachments: Attachment[] = []
|
||||||
|
for (const f of pendingFiles) attachments.push(await uploadFile(f))
|
||||||
await guildApi().post(`/channels/${selectedChannelId}/messages`, {
|
await guildApi().post(`/channels/${selectedChannelId}/messages`, {
|
||||||
content,
|
content: content.trim() || (attachments.length ? `[${attachments.length} file(s)]` : ''),
|
||||||
authorUserId: session?.user.id ?? 'unknown',
|
authorUserId: session?.user.id ?? 'unknown',
|
||||||
|
attachments,
|
||||||
})
|
})
|
||||||
setContent('')
|
setContent('')
|
||||||
|
setPendingFiles([])
|
||||||
await pullMessages()
|
await pullMessages()
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to send message')
|
setError('Failed to send message')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- canvas (single pinned document per channel) ----
|
||||||
|
async function loadCanvas() {
|
||||||
|
if (!guild || !guildToken || !selectedChannelId) {
|
||||||
|
setCanvas(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await guildApi().get(`/channels/${selectedChannelId}/canvas`)
|
||||||
|
setCanvas(res.data && res.data.channelId ? (res.data as CanvasDoc) : null)
|
||||||
|
} catch {
|
||||||
|
setCanvas(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCanvasShare() {
|
||||||
|
setCanvasEditMode(false)
|
||||||
|
setCanvasTitle('')
|
||||||
|
setCanvasFormat('md')
|
||||||
|
setCanvasSource('')
|
||||||
|
setShowCanvasModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCanvasEdit() {
|
||||||
|
if (!canvas) return
|
||||||
|
setCanvasEditMode(true)
|
||||||
|
setCanvasTitle(canvas.title)
|
||||||
|
setCanvasFormat(canvas.format)
|
||||||
|
setCanvasSource(canvas.source)
|
||||||
|
setShowCanvasModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCanvas() {
|
||||||
|
if (!guild || !guildToken || !selectedChannelId) return
|
||||||
|
const body = { title: canvasTitle, format: canvasFormat, source: canvasSource }
|
||||||
|
try {
|
||||||
|
if (canvasEditMode) {
|
||||||
|
await guildApi().patch(`/channels/${selectedChannelId}/canvas`, body)
|
||||||
|
} else {
|
||||||
|
await guildApi().put(`/channels/${selectedChannelId}/canvas`, body)
|
||||||
|
}
|
||||||
|
setShowCanvasModal(false)
|
||||||
|
await loadCanvas()
|
||||||
|
} catch {
|
||||||
|
setError(canvasEditMode ? 'Failed to update canvas' : 'Failed to share canvas')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCanvas() {
|
||||||
|
if (!guild || !guildToken || !selectedChannelId) return
|
||||||
|
try {
|
||||||
|
await guildApi().delete(`/channels/${selectedChannelId}/canvas`)
|
||||||
|
setCanvas(null)
|
||||||
|
} catch {
|
||||||
|
setError('Failed to remove canvas')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +497,9 @@ export default function ChatPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void pullMessages()
|
void pullMessages()
|
||||||
void loadChannelMembers()
|
void loadChannelMembers()
|
||||||
|
void loadCanvas()
|
||||||
|
setPendingFiles([])
|
||||||
|
setCanvasCollapsed(false)
|
||||||
}, [selectedChannelId])
|
}, [selectedChannelId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -412,6 +512,12 @@ export default function ChatPage() {
|
|||||||
socket.on('message.deleted', (m: MessageItem) =>
|
socket.on('message.deleted', (m: MessageItem) =>
|
||||||
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x))),
|
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x))),
|
||||||
)
|
)
|
||||||
|
socket.on('canvas.updated', (c: CanvasDoc) => {
|
||||||
|
if (c.channelId === selectedChannelId) setCanvas(c)
|
||||||
|
})
|
||||||
|
socket.on('canvas.removed', (p: { channelId: string }) => {
|
||||||
|
if (p.channelId === selectedChannelId) setCanvas(null)
|
||||||
|
})
|
||||||
|
|
||||||
socket.connect()
|
socket.connect()
|
||||||
socket.emit('join_channel', { channelId: selectedChannelId })
|
socket.emit('join_channel', { channelId: selectedChannelId })
|
||||||
@@ -432,6 +538,12 @@ export default function ChatPage() {
|
|||||||
? session?.user.name || session?.user.email || 'you'
|
? session?.user.name || session?.user.email || 'you'
|
||||||
: nameById.get(id) || id.slice(0, 8)
|
: nameById.get(id) || id.slice(0, 8)
|
||||||
|
|
||||||
|
// Browser <img>/<a> can't send Authorization; pass the guild token in the
|
||||||
|
// query (the guild ApiKeyGuard accepts ?access_token=).
|
||||||
|
const fileUrl = (u: string) =>
|
||||||
|
`${guild?.endpoint ?? ''}${u}${u.includes('?') ? '&' : '?'}access_token=${encodeURIComponent(guildToken)}`
|
||||||
|
const isCanvasSharer = !!canvas && canvas.sharerUserId === session?.user.id
|
||||||
|
|
||||||
const guildById = new Map(members.map((m) => [m.userId, m]))
|
const guildById = new Map(members.map((m) => [m.userId, m]))
|
||||||
const channelMembers = channelMemberIds.map(
|
const channelMembers = channelMemberIds.map(
|
||||||
(id) => guildById.get(id) ?? { userId: id, email: '', name: '', status: 'active' },
|
(id) => guildById.get(id) ?? { userId: id, email: '', name: '', status: 'active' },
|
||||||
@@ -492,6 +604,16 @@ export default function ChatPage() {
|
|||||||
items.push({ kind: 'item', label: 'Join channel', onClick: () => joinChannel(c) })
|
items.push({ kind: 'item', label: 'Join channel', onClick: () => joinChannel(c) })
|
||||||
if (!c.closed)
|
if (!c.closed)
|
||||||
items.push({ kind: 'item', label: 'Close channel', danger: true, onClick: () => closeChannel(c) })
|
items.push({ kind: 'item', label: 'Close channel', danger: true, onClick: () => closeChannel(c) })
|
||||||
|
if (c.id === selectedChannelId) {
|
||||||
|
items.push({ kind: 'sep' })
|
||||||
|
if (!canvas)
|
||||||
|
items.push({ kind: 'item', label: 'Share canvas…', onClick: openCanvasShare })
|
||||||
|
else if (isCanvasSharer)
|
||||||
|
items.push(
|
||||||
|
{ kind: 'item', label: 'Edit canvas…', onClick: openCanvasEdit },
|
||||||
|
{ kind: 'item', label: 'Remove canvas', danger: true, onClick: () => void removeCanvas() },
|
||||||
|
)
|
||||||
|
}
|
||||||
items.push(
|
items.push(
|
||||||
{ kind: 'sep' },
|
{ kind: 'sep' },
|
||||||
{ kind: 'item', label: 'Copy channel ID', onClick: () => copyText(c.id) },
|
{ kind: 'item', label: 'Copy channel ID', onClick: () => copyText(c.id) },
|
||||||
@@ -574,6 +696,16 @@ export default function ChatPage() {
|
|||||||
disabled: !selectedChannelId,
|
disabled: !selectedChannelId,
|
||||||
onClick: () => void pullMessages(),
|
onClick: () => void pullMessages(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'item',
|
||||||
|
label: canvas ? (isCanvasSharer ? 'Edit canvas…' : 'View canvas') : 'Share canvas…',
|
||||||
|
disabled: !selectedChannelId,
|
||||||
|
onClick: canvas
|
||||||
|
? isCanvasSharer
|
||||||
|
? openCanvasEdit
|
||||||
|
: () => setCanvasCollapsed(false)
|
||||||
|
: openCanvasShare,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
if (scope === 'members')
|
if (scope === 'members')
|
||||||
return [
|
return [
|
||||||
@@ -729,6 +861,15 @@ export default function ChatPage() {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
{currentChannel ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
title="Share a pinned document in this channel"
|
||||||
|
onClick={canvas ? (isCanvasSharer ? openCanvasEdit : () => setCanvasCollapsed(false)) : openCanvasShare}
|
||||||
|
>
|
||||||
|
{canvas ? (isCanvasSharer ? 'Edit canvas' : 'Canvas') : 'Share canvas'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
title={showMembers ? 'Hide members' : 'Show members'}
|
title={showMembers ? 'Hide members' : 'Show members'}
|
||||||
@@ -738,6 +879,57 @@ export default function ChatPage() {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{canvas ? (
|
||||||
|
<section className="dc-canvas">
|
||||||
|
<div className="dc-canvas-head">
|
||||||
|
<span className="cv-title">🗎 {canvas.title}</span>
|
||||||
|
<span className="cv-meta">
|
||||||
|
shared by {authorLabel(canvas.sharerUserId)} · v{canvas.version} ·{' '}
|
||||||
|
{timeOf(canvas.updatedAt)}
|
||||||
|
</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
{isCanvasSharer ? (
|
||||||
|
<>
|
||||||
|
<button className="icon-btn" title="Edit canvas" onClick={openCanvasEdit}>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button className="icon-btn" title="Remove canvas" onClick={removeCanvas}>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="icon-btn"
|
||||||
|
title={canvasCollapsed ? 'Expand' : 'Collapse'}
|
||||||
|
onClick={() => setCanvasCollapsed((v) => !v)}
|
||||||
|
>
|
||||||
|
{canvasCollapsed ? '▸' : '▾'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{canvasCollapsed ? null : (
|
||||||
|
<div className="dc-canvas-body">
|
||||||
|
{canvas.format === 'html' ? (
|
||||||
|
<iframe
|
||||||
|
className="cv-frame"
|
||||||
|
title={canvas.title}
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={canvas.source}
|
||||||
|
/>
|
||||||
|
) : canvas.format === 'text' ? (
|
||||||
|
<pre className="cv-text">{canvas.source}</pre>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="text md cv-md"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderMarkdown(canvas.source, { resolveMention: mentionName }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="dc-messages"
|
className="dc-messages"
|
||||||
onContextMenu={(e) => openCtx(e, 'Messages', blankMenu('messages'))}
|
onContextMenu={(e) => openCtx(e, 'Messages', blankMenu('messages'))}
|
||||||
@@ -769,6 +961,29 @@ export default function ChatPage() {
|
|||||||
className="text md"
|
className="text md"
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.content, { resolveMention: mentionName }) }}
|
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.content, { resolveMention: mentionName }) }}
|
||||||
/>
|
/>
|
||||||
|
{!m.isDeleted && m.attachments && m.attachments.length ? (
|
||||||
|
<div className="attachments">
|
||||||
|
{m.attachments.map((a, ai) => {
|
||||||
|
const isImg = (a.mimeType ?? '').startsWith('image/')
|
||||||
|
const href = fileUrl(a.url)
|
||||||
|
return isImg ? (
|
||||||
|
<a key={ai} href={href} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img className="att-img" src={href} alt={a.name ?? 'image'} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
key={ai}
|
||||||
|
className="att-chip"
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
📎 {a.name ?? 'file'}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{devMode ? (
|
{devMode ? (
|
||||||
<pre className="meta-raw">
|
<pre className="meta-raw">
|
||||||
{JSON.stringify(
|
{JSON.stringify(
|
||||||
@@ -793,6 +1008,24 @@ export default function ChatPage() {
|
|||||||
{currentChannel?.closed ? (
|
{currentChannel?.closed ? (
|
||||||
<div className="dc-closed-banner">This channel is closed — history is read-only.</div>
|
<div className="dc-closed-banner">This channel is closed — history is read-only.</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="dc-composer-wrap">
|
||||||
|
{pendingFiles.length ? (
|
||||||
|
<div className="pending-files">
|
||||||
|
{pendingFiles.map((f, fi) => (
|
||||||
|
<span key={fi} className="att-chip">
|
||||||
|
📎 {f.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pf-x"
|
||||||
|
title="Remove"
|
||||||
|
onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== fi))}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<form
|
<form
|
||||||
className="dc-composer"
|
className="dc-composer"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -800,6 +1033,24 @@ export default function ChatPage() {
|
|||||||
void sendMessage()
|
void sendMessage()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<label
|
||||||
|
className="icon-btn attach-btn"
|
||||||
|
title="Attach files"
|
||||||
|
aria-disabled={!currentChannel}
|
||||||
|
>
|
||||||
|
📎
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={!currentChannel}
|
||||||
|
onChange={(e) => {
|
||||||
|
const files = Array.from(e.target.files ?? [])
|
||||||
|
if (files.length) setPendingFiles((prev) => [...prev, ...files])
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={content}
|
value={content}
|
||||||
@@ -807,10 +1058,19 @@ export default function ChatPage() {
|
|||||||
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel first'}
|
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel first'}
|
||||||
disabled={!currentChannel}
|
disabled={!currentChannel}
|
||||||
/>
|
/>
|
||||||
<button className="btn" type="submit" disabled={!currentChannel || !content.trim()}>
|
<button
|
||||||
Send
|
className="btn"
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
!currentChannel ||
|
||||||
|
uploading ||
|
||||||
|
(!content.trim() && pendingFiles.length === 0)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{uploading ? 'Sending…' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -1022,6 +1282,54 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showCanvasModal ? (
|
||||||
|
<div className="modal-backdrop" onClick={() => setShowCanvasModal(false)}>
|
||||||
|
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{canvasEditMode ? 'Edit canvas' : 'Share canvas'}</h3>
|
||||||
|
<p className="muted" style={{ marginBottom: 10, fontSize: 13 }}>
|
||||||
|
A single pinned document per channel. Only you (the sharer) can
|
||||||
|
update it. HTML is rendered in a sandboxed frame.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={canvasTitle}
|
||||||
|
onChange={(e) => setCanvasTitle(e.target.value)}
|
||||||
|
placeholder="Document title"
|
||||||
|
/>
|
||||||
|
<div className="field" style={{ marginTop: 10 }}>
|
||||||
|
<label>Format</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={canvasFormat}
|
||||||
|
onChange={(e) => setCanvasFormat(e.target.value as CanvasFormat)}
|
||||||
|
>
|
||||||
|
<option value="md">Markdown</option>
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
<option value="text">Plain text</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ marginTop: 10 }}>
|
||||||
|
<label>Content</label>
|
||||||
|
<textarea
|
||||||
|
className="input canvas-src"
|
||||||
|
value={canvasSource}
|
||||||
|
onChange={(e) => setCanvasSource(e.target.value)}
|
||||||
|
placeholder="Document source…"
|
||||||
|
rows={14}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn" onClick={submitCanvas}>
|
||||||
|
{canvasEditMode ? 'Save' : 'Share'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowCanvasModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="modal-backdrop" onClick={() => setError('')}>
|
<div className="modal-backdrop" onClick={() => setError('')}>
|
||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user