Capture the current CyberLux UI, commerce, messaging, and Tor ops updates so local main can be pushed to the remote. Made-with: Cursor
175 lines
6.0 KiB
TypeScript
175 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import Link from "next/link";
|
|
import { useAccount } from "@/contexts/AccountContext";
|
|
|
|
type Msg = {
|
|
id: string;
|
|
from: string;
|
|
text: string;
|
|
ts: number;
|
|
};
|
|
|
|
function now(ts: number) {
|
|
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
export default function ChatWidget() {
|
|
const { user } = useAccount();
|
|
const handle = user?.displayName ?? user?.username ?? "anon";
|
|
|
|
const [messages, setMessages] = useState<Msg[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const [hydrated, setHydrated] = useState(false);
|
|
const endRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fetchMessages = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/messages?channel=hub");
|
|
const data = await res.json();
|
|
if (data.ok && data.messages) {
|
|
setMessages(data.messages);
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchMessages();
|
|
setHydrated(true);
|
|
const interval = setInterval(fetchMessages, 3000);
|
|
return () => clearInterval(interval);
|
|
}, [fetchMessages]);
|
|
|
|
useEffect(() => {
|
|
if (hydrated && messages.length > 0) {
|
|
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}, [messages, hydrated]);
|
|
|
|
const send = async () => {
|
|
const text = input.trim();
|
|
if (!text) return;
|
|
setInput("");
|
|
|
|
// Optimistic update
|
|
const optimisticMsg: Msg = { id: "temp-" + Date.now(), from: handle, text, ts: Date.now() };
|
|
setMessages((p) => [...p, optimisticMsg]);
|
|
|
|
try {
|
|
await fetch("/api/messages", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ from: handle, text, channel: "hub" }),
|
|
});
|
|
fetchMessages();
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="glass rounded-3xl border border-white/10 p-8">
|
|
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<h2 className="font-orbitron text-3xl font-bold">CHANNEL CHAT</h2>
|
|
<p className="mt-1 text-sm text-foreground/60">
|
|
Global hub channel — synced live.{" "}
|
|
{user ? (
|
|
<span className="text-neon-cyan">Posting as @{user.username}</span>
|
|
) : (
|
|
<Link href="/sign-in" className="text-neon-cyan underline">
|
|
Sign in to tag your handle
|
|
</Link>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3 text-[10px] font-mono uppercase tracking-wider text-foreground/40">
|
|
<span className="flex items-center gap-1">
|
|
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-neon-green" />
|
|
channel active
|
|
</span>
|
|
<span>live sync</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-4 h-72 overflow-y-auto rounded-2xl border border-white/10 bg-black/30 p-4 space-y-3">
|
|
{messages.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center text-sm text-foreground/40">No messages yet. Be the first!</div>
|
|
) : (
|
|
messages.map((msg) => {
|
|
const mine = msg.from === handle || (msg.from === "anon" && !user);
|
|
return (
|
|
<div key={msg.id} className={`flex flex-col ${mine ? "items-end" : "items-start"}`}>
|
|
<div className="mb-0.5 flex items-center gap-2">
|
|
<span
|
|
className={`text-xs font-medium ${
|
|
mine
|
|
? "text-neon-cyan"
|
|
: msg.from === "System"
|
|
? "text-neon-purple"
|
|
: "text-foreground/70"
|
|
}`}
|
|
>
|
|
{mine ? `@${handle}` : msg.from === "System" ? "⚡ System" : `@${msg.from}`}
|
|
</span>
|
|
<span className="text-[10px] text-foreground/30">{now(msg.ts)}</span>
|
|
</div>
|
|
<div
|
|
className={`max-w-[80%] rounded-2xl px-4 py-2 text-sm ${
|
|
mine
|
|
? "bg-neon-cyan/15 text-neon-cyan"
|
|
: msg.from === "System"
|
|
? "bg-neon-purple/10 text-neon-purple/90"
|
|
: "bg-white/5 text-foreground/90"
|
|
}`}
|
|
>
|
|
{msg.text}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
<div ref={endRef} />
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<input
|
|
type="text"
|
|
className="flex-1 rounded-2xl border border-white/10 bg-black/40 px-5 py-3 text-sm focus:border-neon-cyan/40 focus:outline-none"
|
|
placeholder={user ? `Message as @${user.username}…` : "Message the channel…"}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
|
maxLength={500}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={send}
|
|
disabled={!input.trim()}
|
|
className="rounded-2xl bg-gradient-to-r from-neon-cyan to-neon-purple px-6 py-3 text-sm font-bold text-background disabled:opacity-40"
|
|
>
|
|
Send
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
{[
|
|
{ icon: "🌍", label: "Global Sync", sub: "Talk to everyone" },
|
|
{ icon: "💬", label: "Open channel", sub: "Hub-wide thread" },
|
|
{ icon: "📬", label: "Forum threads", sub: "/forum for persistence" },
|
|
{ icon: "📦", label: "Market drops", sub: "/drops for schedule" },
|
|
].map((f) => (
|
|
<div key={f.label} className="glass rounded-2xl border border-white/10 p-4 text-center">
|
|
<div className="text-2xl">{f.icon}</div>
|
|
<div className="mt-2 text-sm font-bold">{f.label}</div>
|
|
<div className="text-xs text-foreground/50">{f.sub}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|