Compare commits

...

1 Commits

Author SHA1 Message Date
b04b099754 feat(frontend): channel type colors, triage/custom create fields, join/leave, dev mode
- per-x_type channel row colors (general/work/report/discuss/triage/custom)
- create-channel: required On-duty select (triage) / Listeners checklist (custom)
- channel Join/Leave in topbar; list carries isMember
- Developer mode toggle (Settings): show guild /ack + per-message wakeup
  metadata; off by default (metadata stays transparent)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:51:20 +01:00
2 changed files with 228 additions and 13 deletions

View File

@@ -341,32 +341,73 @@ button {
color: var(--text-h);
}
.chan-btn {
--xt: #9aa0a6;
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 6px;
text-align: left;
border: none;
background: transparent;
background: color-mix(in srgb, var(--xt) 9%, transparent);
border-radius: var(--radius-sm);
padding: 8px 10px;
margin: 1px 0;
color: var(--text-muted);
padding: 8px 10px 8px 14px;
margin: 2px 0;
color: color-mix(in srgb, var(--xt) 55%, var(--text));
font-size: 15px;
cursor: pointer;
}
.chan-btn::before {
content: '';
position: absolute;
left: 3px;
top: 7px;
bottom: 7px;
width: 3px;
border-radius: 3px;
background: var(--xt);
opacity: 0.7;
}
.chan-btn .hash {
color: var(--text-faint);
font-weight: 600;
color: var(--xt);
font-weight: 700;
}
.chan-btn .chan-tag {
color: var(--xt);
border-color: color-mix(in srgb, var(--xt) 60%, transparent);
background: color-mix(in srgb, var(--xt) 14%, transparent);
}
.chan-btn:hover {
background: var(--hover);
color: var(--text);
background: color-mix(in srgb, var(--xt) 18%, transparent);
color: var(--text-h);
}
.chan-btn.active {
background: var(--active);
background: color-mix(in srgb, var(--xt) 26%, transparent);
color: var(--text-h);
}
.chan-btn.active::before {
opacity: 1;
}
/* per x_type accent */
.chan-btn.xt-general {
--xt: #9aa0a6;
}
.chan-btn.xt-work {
--xt: #5865f2;
}
.chan-btn.xt-report {
--xt: #faa61a;
}
.chan-btn.xt-discuss {
--xt: #3ba55d;
}
.chan-btn.xt-triage {
--xt: #ed4245;
}
.chan-btn.xt-custom {
--xt: #c084fc;
}
.chan-tag {
margin-left: auto;
font-size: 10px;
@@ -515,6 +556,32 @@ button {
color: var(--text-faint);
font-style: italic;
}
.meta-badge {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
color: var(--text-faint);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 4px;
}
.meta-badge.on {
color: #fff;
background: var(--online);
border-color: var(--online);
}
.meta-raw {
margin: 4px 0 0;
padding: 6px 8px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-family: var(--mono);
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
}
.dc-empty-center {
margin: auto;
text-align: center;

View File

@@ -11,8 +11,13 @@ type MessageItem = {
isDeleted?: boolean
authorUserId?: string
createdAt?: string
// per-push metadata (only present on socket-delivered messages); the app is
// normally metadata-agnostic — only surfaced in developer mode
wakeup?: boolean
}
const DEV_KEY = 'fabric.debug'
function initials(s: string): string {
const t = (s || '?').trim()
return t.slice(0, 2).toUpperCase()
@@ -28,7 +33,7 @@ function timeOf(iso?: string): string {
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom'] as const
type XType = (typeof X_TYPES)[number]
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType }
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean }
type MemberItem = { userId: string; email: string; name: string; status: string }
export default function ChatPage() {
@@ -45,14 +50,23 @@ export default function ChatPage() {
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([])
const [newChannelPublic, setNewChannelPublic] = useState(false)
const [newChannelXType, setNewChannelXType] = useState<XType>('general')
const [newChannelOnDuty, setNewChannelOnDuty] = useState('')
const [newChannelListeners, setNewChannelListeners] = useState<string[]>([])
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [showAddGuildModal, setShowAddGuildModal] = useState(false)
const [settingsName, setSettingsName] = useState('')
const [showMembers, setShowMembers] = useState(true)
const [devMode, setDevMode] = useState(() => localStorage.getItem(DEV_KEY) === '1')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
function toggleDevMode(on: boolean) {
setDevMode(on)
if (on) localStorage.setItem(DEV_KEY, '1')
else localStorage.removeItem(DEV_KEY)
}
const guilds = session?.guilds ?? []
useEffect(() => {
@@ -170,6 +184,20 @@ export default function ChatPage() {
setSelectedMemberIds((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
}
function toggleListener(userId: string) {
setNewChannelListeners((prev) => (prev.includes(userId) ? prev.filter((x) => x !== userId) : [...prev, userId]))
}
function openCreateChannel() {
setNewChannelName('')
setSelectedMemberIds([])
setNewChannelPublic(false)
setNewChannelXType('general')
setNewChannelOnDuty(session?.user.id ?? '')
setNewChannelListeners([])
setShowCreateChannelModal(true)
}
async function createChannel() {
if (!guild || !guildToken || !newChannelName.trim()) return
const effectiveGuildId = guildDbId || selectedGuildId
@@ -177,6 +205,10 @@ export default function ChatPage() {
setError('Cannot create channel: no guild selected')
return
}
if (newChannelXType === 'triage' && !newChannelOnDuty) {
setError('Triage channels require an on-duty user')
return
}
setError('')
try {
const payload = {
@@ -185,6 +217,8 @@ export default function ChatPage() {
xType: newChannelXType,
isPublic: newChannelPublic,
memberUserIds: selectedMemberIds,
onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined,
listeners: newChannelXType === 'custom' ? newChannelListeners : [],
}
const res = await guildApi().post('/channels', payload)
const createdId = res.data?.id as string | undefined
@@ -192,6 +226,8 @@ export default function ChatPage() {
setSelectedMemberIds([])
setNewChannelPublic(false)
setNewChannelXType('general')
setNewChannelOnDuty(session?.user.id ?? '')
setNewChannelListeners([])
setShowCreateChannelModal(false)
await loadChannels()
if (createdId) setSelectedChannelId(createdId)
@@ -200,6 +236,32 @@ export default function ChatPage() {
}
}
async function joinChannel(c: GuildChannel) {
if (!guild || !guildToken) return
try {
await guildApi().post(`/channels/${c.id}/join`)
await loadChannels()
await loadMembers()
} catch {
setError('Failed to join channel')
}
}
async function leaveChannel(c: GuildChannel) {
if (!guild || !guildToken) return
try {
await guildApi().post(`/channels/${c.id}/leave`)
if (selectedChannelId === c.id) {
setSelectedChannelId('')
setMessages([])
}
await loadChannels()
await loadMembers()
} catch {
setError('Failed to leave channel')
}
}
async function saveName() {
const trimmed = settingsName.trim()
if (!trimmed) {
@@ -280,7 +342,7 @@ export default function ChatPage() {
<div className="dc-sidebar-scroll">
<div className="dc-section-label">
<span>Channels</span>
<button title="Create channel" onClick={() => setShowCreateChannelModal(true)}>
<button title="Create channel" onClick={openCreateChannel}>
+
</button>
</div>
@@ -288,7 +350,7 @@ export default function ChatPage() {
channels.map((c) => (
<button
key={c.id}
className={`chan-btn ${selectedChannelId === c.id ? 'active' : ''}`}
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
onClick={() => setSelectedChannelId(c.id)}
>
<span className="hash">#</span>
@@ -333,6 +395,17 @@ export default function ChatPage() {
<span className="title muted">Select a channel</span>
)}
<span className="spacer" />
{currentChannel ? (
currentChannel.isMember ? (
<button className="btn btn-secondary" onClick={() => leaveChannel(currentChannel)}>
Leave
</button>
) : (
<button className="btn" onClick={() => joinChannel(currentChannel)}>
Join
</button>
)
) : null}
<button
className="icon-btn"
title={showMembers ? 'Hide members' : 'Show members'}
@@ -349,15 +422,36 @@ export default function ChatPage() {
{currentChannel ? `This is the start of #${currentChannel.name}` : 'No channel selected'}
</div>
) : null}
{messages.map((m) => (
{messages
.filter((m) => devMode || m.authorUserId !== 'guild')
.map((m) => (
<div key={m.messageId} className={`msg ${m.isDeleted ? 'deleted' : ''}`}>
<div className="avatar">{initials(authorLabel(m.authorUserId))}</div>
<div className="body">
<div className="meta">
<span className="author">{authorLabel(m.authorUserId)}</span>
<span className="time">{timeOf(m.createdAt)} · #{m.seq}</span>
{devMode && m.wakeup !== undefined ? (
<span className={`meta-badge ${m.wakeup ? 'on' : ''}`}>wakeup={String(m.wakeup)}</span>
) : null}
</div>
<div className="text">{m.content}</div>
{devMode ? (
<pre className="meta-raw">
{JSON.stringify(
{
messageId: m.messageId,
seq: m.seq,
authorUserId: m.authorUserId,
createdAt: m.createdAt,
isDeleted: m.isDeleted ?? false,
wakeup: m.wakeup,
},
null,
0,
)}
</pre>
) : null}
</div>
</div>
))}
@@ -443,6 +537,52 @@ export default function ChatPage() {
))}
</select>
</div>
{newChannelXType === 'triage' ? (
<div className="field" style={{ marginTop: 10 }}>
<label>On-duty (required)</label>
<select
className="input"
value={newChannelOnDuty}
onChange={(e) => setNewChannelOnDuty(e.target.value)}
>
{members.map((m) => (
<option key={m.userId} value={m.userId}>
{(m.name || m.email) + (m.userId === session?.user.id ? ' (you)' : '')}
</option>
))}
</select>
<p className="muted" style={{ fontSize: 12 }}>
Added to the channel automatically and put into wake_mapping.
</p>
</div>
) : null}
{newChannelXType === 'custom' ? (
<div className="field" style={{ marginTop: 10 }}>
<label>Listeners (optional)</label>
<div className="modal-list">
{members.map((m) => (
<label key={m.userId} className="check-row">
<input
type="checkbox"
checked={newChannelListeners.includes(m.userId)}
onChange={() => toggleListener(m.userId)}
/>
<span>
{m.name || m.email}
{m.userId === session?.user.id ? ' (you)' : ''}
</span>
</label>
))}
{members.length === 0 ? <div className="dc-empty">No members in this guild.</div> : null}
</div>
<p className="muted" style={{ fontSize: 12 }}>
Each selected user gets a wake_mapping record for this channel.
</p>
</div>
) : null}
<label className="check-row" style={{ marginTop: 10 }}>
<input type="checkbox" checked={newChannelPublic} onChange={(e) => setNewChannelPublic(e.target.checked)} />
<span>Public visible to all guild members</span>
@@ -492,6 +632,14 @@ export default function ChatPage() {
placeholder="Your name"
/>
</div>
<label className="check-row" style={{ marginTop: 4 }}>
<input
type="checkbox"
checked={devMode}
onChange={(e) => toggleDevMode(e.target.checked)}
/>
<span>Developer mode show guild /ack &amp; message metadata</span>
</label>
<div className="modal-actions">
<button className="btn" onClick={saveName}>Save</button>
<button className="btn btn-secondary" onClick={() => setShowSettingsModal(false)}>Cancel</button>