Capture the current CyberLux UI, commerce, messaging, and Tor ops updates so local main can be pushed to the remote. Made-with: Cursor
164 lines
6.0 KiB
TypeScript
164 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import Link from "next/link";
|
|
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
|
import { useAccount } from "@/contexts/AccountContext";
|
|
|
|
type Msg = { id: string; from: string; text: string; ts: number };
|
|
|
|
export default function MessagesPage() {
|
|
const { user, hydrated } = useAccount();
|
|
const handle = user?.username ?? null;
|
|
|
|
const [msgs, setMsgs] = useState<Msg[]>([]);
|
|
const [input, setInput] = useState("");
|
|
const [ready, setReady] = useState(false);
|
|
const endRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fetchMessages = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/messages?channel=global");
|
|
const data = await res.json();
|
|
if (data.ok && data.messages) {
|
|
setMsgs(data.messages);
|
|
}
|
|
setReady(true);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!hydrated) return;
|
|
fetchMessages();
|
|
const interval = setInterval(fetchMessages, 3000);
|
|
return () => clearInterval(interval);
|
|
}, [hydrated, fetchMessages]);
|
|
|
|
useEffect(() => {
|
|
if (!ready) return;
|
|
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [msgs, ready]);
|
|
|
|
const send = async () => {
|
|
const text = input.trim();
|
|
if (!text) return;
|
|
setInput("");
|
|
|
|
const optimisticMsg: Msg = { id: "temp-" + Date.now(), from: handle ?? "anon", text, ts: Date.now() };
|
|
setMsgs((p) => [...p, optimisticMsg]);
|
|
|
|
try {
|
|
await fetch("/api/messages", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ from: handle ?? "anon", text, channel: "global" }),
|
|
});
|
|
fetchMessages();
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const ts = (n: number) =>
|
|
new Date(n).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
|
|
return (
|
|
<Sections>
|
|
<section className="container mx-auto max-w-2xl px-4 py-12">
|
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h1 className="font-orbitron text-2xl font-bold theme-accent">GLOBAL COMMS</h1>
|
|
<p className="mt-1 text-xs text-foreground/50">
|
|
{handle ? (
|
|
<>Channel: <span className="text-neon-cyan">@{handle}</span> · synced across network</>
|
|
) : (
|
|
<>
|
|
<Link href="/sign-in?next=/messages" className="text-neon-cyan underline">
|
|
Sign in
|
|
</Link>{" "}
|
|
to persist messages to your handle
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="theme-card rounded-2xl p-1">
|
|
<div className="h-[420px] overflow-y-auto rounded-xl p-4 space-y-3">
|
|
{!ready ? (
|
|
<div className="flex h-full items-center justify-center text-sm text-foreground/40">Loading…</div>
|
|
) : msgs.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center text-sm text-foreground/40">No messages yet. Be the first!</div>
|
|
) : (
|
|
msgs.map((m) => {
|
|
const mine = m.from === handle || (m.from === "anon" && !handle);
|
|
return (
|
|
<div key={m.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" : "text-foreground/60"}`}>
|
|
@{m.from}
|
|
</span>
|
|
<span className="text-[10px] text-foreground/30">{ts(m.ts)}</span>
|
|
</div>
|
|
<div
|
|
className={`max-w-[85%] rounded-2xl px-4 py-2 text-sm ${
|
|
mine ? "bg-neon-cyan/15 text-neon-cyan" : "bg-white/5"
|
|
}`}
|
|
>
|
|
{m.text}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
<div ref={endRef} />
|
|
</div>
|
|
|
|
<div className="mt-2 flex gap-3 p-2">
|
|
<input
|
|
type="text"
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
|
placeholder={ready ? "Message the network…" : "Loading…"}
|
|
disabled={!ready}
|
|
className="flex-1 rounded-xl border border-white/10 bg-transparent px-4 py-3 text-sm focus:border-neon-cyan/40 focus:outline-none disabled:opacity-50"
|
|
maxLength={500}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={send}
|
|
disabled={!input.trim() || !ready}
|
|
className="rounded-xl bg-gradient-to-r from-neon-cyan to-neon-purple px-5 py-3 text-sm font-bold text-background disabled:opacity-40"
|
|
>
|
|
SEND
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-3 gap-3 text-[10px] text-foreground/40 font-mono">
|
|
<div className="theme-card rounded-xl p-3 text-center">Global synced storage</div>
|
|
<div className="theme-card rounded-xl p-3 text-center">Real-time transport</div>
|
|
<div className="theme-card rounded-xl p-3 text-center">Encrypted at rest</div>
|
|
</div>
|
|
|
|
<div className="mt-6 flex flex-wrap gap-3">
|
|
<Link href="/forum" className="text-xs text-neon-cyan hover:underline">Forum (persisted threads) →</Link>
|
|
<Link href="/barter" className="text-xs text-foreground/50 hover:text-foreground/80">Barter board</Link>
|
|
<Link href="/dashboard" className="text-xs text-foreground/50 hover:text-foreground/80">Dashboard</Link>
|
|
</div>
|
|
</section>
|
|
</Sections>
|
|
);
|
|
}
|
|
|
|
function Sections({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<ThemedLayout theme="terminal" siteTitle="ENCRYPTED COMMS" siteSubtitle="Global channel">
|
|
{children}
|
|
</ThemedLayout>
|
|
);
|
|
}
|