Skip to content

Commit 00878a4

Browse files
committed
Phase 244 addendum: Add Discuss/Show.tsx chat page
Full-height channel view with scrollable message list, avatar initials (color-hashed), timeAgo timestamps, edited/pinned indicators, reply count, Enter-to-send textarea. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent ef57a9d commit 00878a4

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { Head, Link } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import { useState, useRef, useEffect } from 'react';
4+
import axios from 'axios';
5+
6+
interface Message {
7+
id: number;
8+
body: string;
9+
is_edited: boolean;
10+
is_pinned: boolean;
11+
created_at: string;
12+
user: { id: number; name: string };
13+
replies_count: number;
14+
}
15+
16+
interface Channel {
17+
id: number;
18+
name: string;
19+
type: string;
20+
description: string | null;
21+
}
22+
23+
interface Member {
24+
id: number;
25+
name: string;
26+
}
27+
28+
interface Props {
29+
channel: Channel;
30+
messages: Message[];
31+
members: Member[];
32+
}
33+
34+
function timeAgo(dateStr: string): string {
35+
const d = new Date(dateStr);
36+
const now = new Date();
37+
const diff = Math.floor((now.getTime() - d.getTime()) / 1000);
38+
if (diff < 60) return 'just now';
39+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
40+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
41+
return d.toLocaleDateString();
42+
}
43+
44+
function initials(name: string): string {
45+
return name.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase();
46+
}
47+
48+
const COLORS = ['bg-blue-500', 'bg-green-500', 'bg-purple-500', 'bg-orange-500', 'bg-pink-500', 'bg-teal-500'];
49+
function avatarColor(name: string): string {
50+
return COLORS[name.charCodeAt(0) % COLORS.length];
51+
}
52+
53+
export default function DiscussShow({ channel, messages: initialMessages, members }: Props) {
54+
const [messages, setMessages] = useState<Message[]>(initialMessages);
55+
const [body, setBody] = useState('');
56+
const [sending, setSending] = useState(false);
57+
const bottomRef = useRef<HTMLDivElement>(null);
58+
59+
useEffect(() => {
60+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
61+
}, [messages]);
62+
63+
async function send(e: React.FormEvent) {
64+
e.preventDefault();
65+
if (!body.trim() || sending) return;
66+
setSending(true);
67+
try {
68+
const res = await axios.post(`/discuss/${channel.id}/messages`, { body });
69+
setMessages(prev => [...prev, res.data]);
70+
setBody('');
71+
} finally {
72+
setSending(false);
73+
}
74+
}
75+
76+
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
77+
if (e.key === 'Enter' && !e.shiftKey) {
78+
e.preventDefault();
79+
send(e as unknown as React.FormEvent);
80+
}
81+
}
82+
83+
return (
84+
<AppLayout>
85+
<Head title={`#${channel.name}`} />
86+
<div className="flex h-[calc(100vh-4rem)] flex-col">
87+
{/* Header */}
88+
<div className="flex items-center gap-3 border-b border-slate-200 bg-white px-6 py-3 shadow-sm">
89+
<Link href="/discuss" className="text-sm text-slate-500 hover:text-slate-700">← Channels</Link>
90+
<span className="text-slate-400">/</span>
91+
<span className="text-slate-400 font-medium">#</span>
92+
<h1 className="text-base font-semibold text-slate-800">{channel.name}</h1>
93+
{channel.description && (
94+
<span className="ml-2 text-sm text-slate-500">{channel.description}</span>
95+
)}
96+
<div className="ml-auto flex items-center gap-2 text-sm text-slate-500">
97+
<span>{members.length} members</span>
98+
</div>
99+
</div>
100+
101+
{/* Messages */}
102+
<div className="flex-1 overflow-y-auto bg-white px-6 py-4">
103+
{messages.length === 0 && (
104+
<div className="flex h-full items-center justify-center">
105+
<p className="text-sm text-slate-400">No messages yet. Say hello!</p>
106+
</div>
107+
)}
108+
<div className="space-y-4">
109+
{messages.map(msg => (
110+
<div key={msg.id} className="flex items-start gap-3">
111+
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white ${avatarColor(msg.user.name)}`}>
112+
{initials(msg.user.name)}
113+
</div>
114+
<div className="flex-1 min-w-0">
115+
<div className="flex items-baseline gap-2">
116+
<span className="text-sm font-semibold text-slate-800">{msg.user.name}</span>
117+
<span className="text-xs text-slate-400">{timeAgo(msg.created_at)}</span>
118+
{msg.is_edited && <span className="text-xs text-slate-400">(edited)</span>}
119+
{msg.is_pinned && <span className="text-xs text-yellow-600">📌</span>}
120+
</div>
121+
<p className="mt-0.5 text-sm text-slate-700 whitespace-pre-wrap break-words">{msg.body}</p>
122+
{msg.replies_count > 0 && (
123+
<button className="mt-1 text-xs text-blue-600 hover:underline">
124+
{msg.replies_count} {msg.replies_count === 1 ? 'reply' : 'replies'}
125+
</button>
126+
)}
127+
</div>
128+
</div>
129+
))}
130+
<div ref={bottomRef} />
131+
</div>
132+
</div>
133+
134+
{/* Input */}
135+
<div className="border-t border-slate-200 bg-white px-6 py-4">
136+
<form onSubmit={send} className="flex items-end gap-3">
137+
<textarea
138+
value={body}
139+
onChange={e => setBody(e.target.value)}
140+
onKeyDown={handleKeyDown}
141+
placeholder={`Message #${channel.name} (Enter to send, Shift+Enter for new line)`}
142+
rows={2}
143+
className="flex-1 resize-none rounded-lg border border-slate-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
144+
/>
145+
<button
146+
type="submit"
147+
disabled={!body.trim() || sending}
148+
className="rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
149+
>
150+
Send
151+
</button>
152+
</form>
153+
</div>
154+
</div>
155+
</AppLayout>
156+
);
157+
}

0 commit comments

Comments
 (0)