diff --git a/src/index.css b/src/index.css index 8a73fb5..fbf5925 100644 --- a/src/index.css +++ b/src/index.css @@ -864,3 +864,134 @@ button { cursor: not-allowed; 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; +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index ef609e1..de82a15 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -5,6 +5,7 @@ import { useAuth } from '../auth/auth-context' import { renderMarkdown } from '../lib/markdown' import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client' +type Attachment = { url: string; name?: string; mimeType?: string } type MessageItem = { messageId: string seq: number @@ -12,11 +13,23 @@ type MessageItem = { isDeleted?: boolean authorUserId?: string createdAt?: string + attachments?: Attachment[] // per-push metadata (only present on socket-delivered messages); the app is // normally metadata-agnostic — only surfaced in developer mode 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' function initials(s: string): string { @@ -70,6 +83,15 @@ export default function ChatPage() { const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [ctxMenu, setCtxMenu] = useState(null) + const [pendingFiles, setPendingFiles] = useState([]) + const [uploading, setUploading] = useState(false) + const [canvas, setCanvas] = useState(null) + const [canvasCollapsed, setCanvasCollapsed] = useState(false) + const [showCanvasModal, setShowCanvasModal] = useState(false) + const [canvasTitle, setCanvasTitle] = useState('') + const [canvasFormat, setCanvasFormat] = useState('md') + const [canvasSource, setCanvasSource] = useState('') + const [canvasEditMode, setCanvasEditMode] = useState(false) function openCtx(e: ReactMouseEvent, title: string, items: MenuItem[]) { e.preventDefault() @@ -212,18 +234,93 @@ export default function ChatPage() { } } + async function uploadFile(file: File): Promise { + 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() { - if (!selectedChannelId || !content.trim() || !guild || !guildToken) return + if (!selectedChannelId || !guild || !guildToken) return + if (!content.trim() && pendingFiles.length === 0) return setError('') + setUploading(true) try { + const attachments: Attachment[] = [] + for (const f of pendingFiles) attachments.push(await uploadFile(f)) await guildApi().post(`/channels/${selectedChannelId}/messages`, { - content, + content: content.trim() || (attachments.length ? `[${attachments.length} file(s)]` : ''), authorUserId: session?.user.id ?? 'unknown', + attachments, }) setContent('') + setPendingFiles([]) await pullMessages() } catch { 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(() => { void pullMessages() void loadChannelMembers() + void loadCanvas() + setPendingFiles([]) + setCanvasCollapsed(false) }, [selectedChannelId]) useEffect(() => { @@ -412,6 +512,12 @@ export default function ChatPage() { socket.on('message.deleted', (m: MessageItem) => 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.emit('join_channel', { channelId: selectedChannelId }) @@ -432,6 +538,12 @@ export default function ChatPage() { ? session?.user.name || session?.user.email || 'you' : nameById.get(id) || id.slice(0, 8) + // Browser / 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 channelMembers = channelMemberIds.map( (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) }) if (!c.closed) 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( { kind: 'sep' }, { kind: 'item', label: 'Copy channel ID', onClick: () => copyText(c.id) }, @@ -574,6 +696,16 @@ export default function ChatPage() { disabled: !selectedChannelId, 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') return [ @@ -729,6 +861,15 @@ export default function ChatPage() { ) ) : null} + {currentChannel ? ( + + ) : null} + + + ) : null} + + + {canvasCollapsed ? null : ( +
+ {canvas.format === 'html' ? ( +