feat(frontend): terminal restyle + dm + grouping + brand override
- Restyle: ink 'blueprint' surfaces + IBM Plex Mono / Major Mono Display, hairline borders, restrained motion, 12px radii. Fabric purple accent kept. Layout unchanged. - Brand override (build-time VITE_ only): VITE_APP_NAME / VITE_LOGO_URL / VITE_FAVICON_URL via src/lib/brand.ts (applyBrand in main.tsx); login wordmark/logo + title + favicon. .env.example documented. - dm x-type: create modal participant becomes single-select (radio) for dm and forces private; member right-click menu gains 'Create new DM channel' (non-unique, fresh channel each time). - Channels sidebar grouped by x-type with group headers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,8 @@ VITE_CENTER_API_BASE=http://localhost:7001/api
|
||||
VITE_GUILD_API_BASE=http://localhost:7002/api
|
||||
VITE_GUILD_SOCKET_BASE=http://localhost:7002/realtime
|
||||
VITE_API_KEY=change-me-api-key
|
||||
|
||||
# Brand overrides (build-time only; baked at build). Leave empty for Fabric defaults.
|
||||
VITE_APP_NAME=Fabric
|
||||
VITE_LOGO_URL=
|
||||
VITE_FAVICON_URL=
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
<link rel="icon" type="image/png" sizes="256x256" href="/favicon.png?v=3" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=3" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="theme-color" content="#080a0d" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Fabric</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
:root {
|
||||
--rail: #18191c;
|
||||
--side: #1f2125;
|
||||
--main: #26282d;
|
||||
--elevated: #1a1b1e;
|
||||
--input: #1a1b1e;
|
||||
--hover: rgba(255, 255, 255, 0.04);
|
||||
--active: rgba(168, 85, 247, 0.16);
|
||||
/* Cryptic lab terminal: deep "blueprint" ink surfaces, monospace
|
||||
everything, restrained motion. Accent stays Fabric purple. */
|
||||
--ink: #080a0d;
|
||||
--rail: #0b0e12;
|
||||
--side: #0f141b;
|
||||
--main: #0a0d12;
|
||||
--elevated: #11161d;
|
||||
--input: #0b0e12;
|
||||
--hover: rgba(168, 85, 247, 0.1);
|
||||
--active: rgba(168, 85, 247, 0.18);
|
||||
|
||||
--text: #dbdee1;
|
||||
--text-muted: #949ba4;
|
||||
--text-faint: #6d7178;
|
||||
--text-h: #f2f3f5;
|
||||
--text: #c7d0dc;
|
||||
--text-muted: #6f7d92;
|
||||
--text-faint: #4f5b6b;
|
||||
--text-h: #e9f0fa;
|
||||
|
||||
--border: #303237;
|
||||
--border: #1d2733;
|
||||
--accent: #a855f7;
|
||||
--accent-strong: #9333ea;
|
||||
--accent-soft: rgba(168, 85, 247, 0.14);
|
||||
--danger: #f23f42;
|
||||
--accent-soft: rgba(168, 85, 247, 0.16);
|
||||
--danger: #ff5a52;
|
||||
--online: #23a55a;
|
||||
|
||||
--radius: 10px;
|
||||
--radius-sm: 7px;
|
||||
--sans: 'Inter', system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Consolas, monospace;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--display: 'Major Mono Display', ui-monospace, monospace;
|
||||
--sans: 'IBM Plex Mono', ui-monospace, 'SF Mono', Consolas, monospace;
|
||||
--mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Consolas, monospace;
|
||||
|
||||
color-scheme: dark;
|
||||
font-synthesis: none;
|
||||
@@ -198,6 +202,19 @@ button {
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.brand-wordmark {
|
||||
font-family: var(--display);
|
||||
text-transform: lowercase;
|
||||
font-size: 30px;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 32px rgba(168, 85, 247, 0.4);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.brand-logo {
|
||||
max-height: 44px;
|
||||
width: auto;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.login-card h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
@@ -415,6 +432,20 @@ button {
|
||||
.chan-btn.xt-custom {
|
||||
--xt: #c084fc;
|
||||
}
|
||||
.chan-btn.xt-dm {
|
||||
--xt: #a855f7;
|
||||
}
|
||||
/* x-type group headers in the channel sidebar */
|
||||
.dc-chan-group {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.dc-group-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
padding: 8px 8px 4px;
|
||||
}
|
||||
.chan-tag {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
|
||||
21
src/lib/brand.ts
Normal file
21
src/lib/brand.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Build-time brand overrides. Values are baked from VITE_ env at build
|
||||
// (not runtime-configurable by design). Empty/unset → Fabric defaults.
|
||||
const env = import.meta.env as unknown as Record<string, string | undefined>
|
||||
|
||||
export const APP_NAME = (env.VITE_APP_NAME ?? '').trim() || 'Fabric'
|
||||
export const LOGO_URL = (env.VITE_LOGO_URL ?? '').trim()
|
||||
const FAVICON_URL = (env.VITE_FAVICON_URL ?? '').trim()
|
||||
|
||||
// Apply the document title + favicon override once at startup.
|
||||
export function applyBrand(): void {
|
||||
document.title = APP_NAME
|
||||
if (FAVICON_URL) {
|
||||
document
|
||||
.querySelectorAll('link[rel~="icon"], link[rel="apple-touch-icon"]')
|
||||
.forEach((n) => n.remove())
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
link.href = FAVICON_URL
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { BrowserRouter, HashRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './auth/AuthContext'
|
||||
import { applyBrand } from './lib/brand'
|
||||
|
||||
applyBrand()
|
||||
|
||||
// The packaged desktop app loads the bundled SPA over file://, where the
|
||||
// History API has no server to back path routing — use HashRouter there.
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'react'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
import { renderMarkdown } from '../lib/markdown'
|
||||
import { APP_NAME } from '../lib/brand'
|
||||
import { guildMembersCenter, joinGuildCenter } from '../lib/center-auth-client'
|
||||
|
||||
type Attachment = { url: string; name?: string; mimeType?: string }
|
||||
@@ -67,7 +68,13 @@ function timeOf(iso?: string): string {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom'] as const
|
||||
const X_TYPES = ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'] as const
|
||||
// display order + labels for the x-type-grouped channel sidebar
|
||||
const XTYPE_ORDER = ['general', 'work', 'report', 'discuss', 'triage', 'custom', 'dm'] as const
|
||||
const XTYPE_LABEL: Record<string, string> = {
|
||||
general: 'General', work: 'Work', report: 'Report',
|
||||
discuss: 'Discussion', triage: 'Triage', custom: 'Custom', dm: 'Direct Messages',
|
||||
}
|
||||
type XType = (typeof X_TYPES)[number]
|
||||
|
||||
type GuildChannel = { id: string; name: string; guildId?: string; xType?: XType; isMember?: boolean; isPublic?: boolean; closed?: boolean }
|
||||
@@ -412,13 +419,17 @@ export default function ChatPage() {
|
||||
setError('Triage channels require an on-duty user')
|
||||
return
|
||||
}
|
||||
if (newChannelXType === 'dm' && selectedMemberIds.length !== 1) {
|
||||
setError('A DM needs exactly one other participant')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
try {
|
||||
const payload = {
|
||||
name: newChannelName.trim(),
|
||||
guildId: effectiveGuildId,
|
||||
xType: newChannelXType,
|
||||
isPublic: newChannelPublic,
|
||||
isPublic: newChannelXType === 'dm' ? false : newChannelPublic,
|
||||
memberUserIds: selectedMemberIds,
|
||||
onDuty: newChannelXType === 'triage' ? newChannelOnDuty : undefined,
|
||||
listeners: newChannelXType === 'custom' ? newChannelListeners : [],
|
||||
@@ -444,6 +455,33 @@ export default function ChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// DM channels are not unique: this always creates a fresh 1:1 dm channel
|
||||
// between the current user and `m` (private; no rotation/wakeup).
|
||||
async function createDmChannel(m: { userId: string; name?: string; email?: string }) {
|
||||
if (!guild || !guildToken) return
|
||||
const effectiveGuildId = guildDbId || selectedGuildId
|
||||
if (!effectiveGuildId) {
|
||||
setError('Cannot create DM: no guild selected')
|
||||
return
|
||||
}
|
||||
const label = m.name || m.email || m.userId.slice(0, 8)
|
||||
setError('')
|
||||
try {
|
||||
const res = await guildApi().post('/channels', {
|
||||
name: `DM · ${label}`,
|
||||
guildId: effectiveGuildId,
|
||||
xType: 'dm',
|
||||
isPublic: false,
|
||||
memberUserIds: [m.userId],
|
||||
})
|
||||
const createdId = res.data?.id as string | undefined
|
||||
await loadChannels()
|
||||
if (createdId) setSelectedChannelId(createdId)
|
||||
} catch {
|
||||
setError('Failed to create DM channel')
|
||||
}
|
||||
}
|
||||
|
||||
async function joinChannel(c: GuildChannel) {
|
||||
if (!guild || !guildToken) return
|
||||
try {
|
||||
@@ -700,6 +738,15 @@ export default function ChatPage() {
|
||||
{ kind: 'item', label: 'Copy user ID', onClick: () => copyText(m.userId) },
|
||||
{ kind: 'item', label: 'Copy name', onClick: () => copyText(label) },
|
||||
]
|
||||
if (!isSelf)
|
||||
items.push(
|
||||
{ kind: 'sep' },
|
||||
{
|
||||
kind: 'item',
|
||||
label: 'Create new DM channel',
|
||||
onClick: () => void createDmChannel(m),
|
||||
},
|
||||
)
|
||||
if (inChannel && rotationChannel && !bypassMemberIds.includes(m.userId))
|
||||
items.push(
|
||||
{ kind: 'sep' },
|
||||
@@ -920,7 +967,7 @@ export default function ChatPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dc-shell" onContextMenu={(e) => openCtx(e, 'Fabric', blankMenu('root'))}>
|
||||
<div className="dc-shell" onContextMenu={(e) => openCtx(e, APP_NAME, blankMenu('root'))}>
|
||||
<nav className="dc-rail" onContextMenu={(e) => openCtx(e, 'Guilds', blankMenu('guilds'))}>
|
||||
{guilds.map((g) => (
|
||||
<button
|
||||
@@ -952,17 +999,25 @@ export default function ChatPage() {
|
||||
</button>
|
||||
</div>
|
||||
{channels.length ? (
|
||||
channels.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedChannelId(c.id)}
|
||||
onContextMenu={(e) => openCtx(e, `#${c.name}`, channelMenu(c))}
|
||||
>
|
||||
<span className="hash">#</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
||||
{c.xType ? <span className="chan-tag">{c.xType}</span> : null}
|
||||
</button>
|
||||
XTYPE_ORDER.filter((xt) =>
|
||||
channels.some((c) => (c.xType ?? 'general') === xt),
|
||||
).map((xt) => (
|
||||
<div key={xt} className="dc-chan-group">
|
||||
<div className="dc-group-label">{XTYPE_LABEL[xt] ?? xt}</div>
|
||||
{channels
|
||||
.filter((c) => (c.xType ?? 'general') === xt)
|
||||
.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`chan-btn xt-${c.xType ?? 'general'} ${selectedChannelId === c.id ? 'active' : ''}`}
|
||||
onClick={() => setSelectedChannelId(c.id)}
|
||||
onContextMenu={(e) => openCtx(e, `#${c.name}`, channelMenu(c))}
|
||||
>
|
||||
<span className="hash">#</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="dc-empty">{guild ? 'No channels yet' : 'Pick a guild from the left'}</div>
|
||||
@@ -1373,33 +1428,61 @@ export default function ChatPage() {
|
||||
</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>
|
||||
</label>
|
||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
||||
{newChannelPublic
|
||||
? "You're added automatically; every guild member can see it."
|
||||
: "You're added automatically. Pick who else to include:"}
|
||||
</p>
|
||||
<div className="modal-list">
|
||||
{members
|
||||
.filter((m) => m.userId !== session?.user.id)
|
||||
.map((m) => (
|
||||
<label key={m.userId} className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMemberIds.includes(m.userId)}
|
||||
disabled={newChannelPublic}
|
||||
onChange={() => toggleMember(m.userId)}
|
||||
/>
|
||||
<span>{m.name || m.email}</span>
|
||||
</label>
|
||||
))}
|
||||
{members.filter((m) => m.userId !== session?.user.id).length === 0 ? (
|
||||
<div className="dc-empty">No other members in this guild.</div>
|
||||
) : null}
|
||||
</div>
|
||||
{newChannelXType === 'dm' ? (
|
||||
<>
|
||||
<p className="muted" style={{ marginTop: 10, fontSize: 13 }}>
|
||||
Direct message — private 1:1, not unique. Pick exactly one user:
|
||||
</p>
|
||||
<div className="modal-list">
|
||||
{members
|
||||
.filter((m) => m.userId !== session?.user.id)
|
||||
.map((m) => (
|
||||
<label key={m.userId} className="check-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="dm-target"
|
||||
checked={selectedMemberIds[0] === m.userId}
|
||||
onChange={() => setSelectedMemberIds([m.userId])}
|
||||
/>
|
||||
<span>{m.name || m.email}</span>
|
||||
</label>
|
||||
))}
|
||||
{members.filter((m) => m.userId !== session?.user.id).length === 0 ? (
|
||||
<div className="dc-empty">No other members in this guild.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</label>
|
||||
<p className="muted" style={{ marginTop: 8, fontSize: 13 }}>
|
||||
{newChannelPublic
|
||||
? "You're added automatically; every guild member can see it."
|
||||
: "You're added automatically. Pick who else to include:"}
|
||||
</p>
|
||||
<div className="modal-list">
|
||||
{members
|
||||
.filter((m) => m.userId !== session?.user.id)
|
||||
.map((m) => (
|
||||
<label key={m.userId} className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMemberIds.includes(m.userId)}
|
||||
disabled={newChannelPublic}
|
||||
onChange={() => toggleMember(m.userId)}
|
||||
/>
|
||||
<span>{m.name || m.email}</span>
|
||||
</label>
|
||||
))}
|
||||
{members.filter((m) => m.userId !== session?.user.id).length === 0 ? (
|
||||
<div className="dc-empty">No other members in this guild.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={createChannel}>Create</button>
|
||||
<button className="btn btn-secondary" onClick={() => setShowCreateChannelModal(false)}>Cancel</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../auth/auth-context'
|
||||
import { APP_NAME, LOGO_URL } from '../lib/brand'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -25,9 +26,14 @@ export default function LoginPage() {
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
{LOGO_URL ? (
|
||||
<img className="brand-logo" src={LOGO_URL} alt={APP_NAME} />
|
||||
) : (
|
||||
<div className="brand-wordmark">{APP_NAME}</div>
|
||||
)}
|
||||
<h1>Welcome back</h1>
|
||||
<p className="login-sub">
|
||||
{isAuthed ? `Signed in as ${session?.user.email}` : 'Sign in to continue to Fabric'}
|
||||
{isAuthed ? `Signed in as ${session?.user.email}` : `Sign in to continue to ${APP_NAME}`}
|
||||
</p>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="field">
|
||||
|
||||
Reference in New Issue
Block a user