first commit
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSdk } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Sparkles, X, Copy, Send, RefreshCw, MessageSquareReply, FileText, WandSparkles, Languages } from "lucide-react";
|
||||
|
||||
interface Turn {
|
||||
role: "user" | "ai";
|
||||
text: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
const QUICK = [
|
||||
{ label: "Summarize message", icon: FileText, prompt: "Summarize the message above in one or two sentences." },
|
||||
{ label: "What should I reply?", icon: MessageSquareReply, prompt: "Suggest a thoughtful reply to the message above. Write it in first person, ready to send." },
|
||||
{ label: "Improve my writing", icon: WandSparkles, prompt: "Rewrite the message above to be clearer and more professional, keeping the meaning." },
|
||||
{ label: "Explain simply", icon: Languages, prompt: "Explain the message above in simple, plain language." },
|
||||
];
|
||||
|
||||
export function AiPanel() {
|
||||
const { aiPanel, closeAi, toast } = useUi();
|
||||
const { client } = useSdk();
|
||||
const [context, setContext] = useState<string>("");
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// seed from the message that opened the panel
|
||||
useEffect(() => {
|
||||
if (!aiPanel) return;
|
||||
setContext(aiPanel.context ?? "");
|
||||
setTurns([]);
|
||||
setInput("");
|
||||
if (aiPanel.prompt) void ask(aiPanel.prompt, aiPanel.context ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [aiPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [turns, busy]);
|
||||
|
||||
if (!aiPanel) return null;
|
||||
|
||||
async function ask(prompt: string, ctx: string) {
|
||||
const p = prompt.trim();
|
||||
if (!p || busy) return;
|
||||
setInput("");
|
||||
setTurns((t) => [...t, { role: "user", text: p }]);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await client.ai(p, ctx || undefined);
|
||||
if (res.ok && res.text) {
|
||||
setTurns((t) => [...t, { role: "ai", text: res.text as string }]);
|
||||
} else {
|
||||
setTurns((t) => [...t, { role: "ai", text: res.error || "The AI couldn't respond.", error: true }]);
|
||||
}
|
||||
} catch (e) {
|
||||
setTurns((t) => [...t, { role: "ai", text: `Request failed: ${(e as Error).message}`, error: true }]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async (text: string) => {
|
||||
try { await navigator.clipboard?.writeText(text); toast("Copied to clipboard", "success"); }
|
||||
catch { toast("Couldn't access the clipboard", "danger"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end bg-black/40 animate-fade-in" onClick={closeAi}>
|
||||
<div role="dialog" aria-modal="true" aria-label="AI assistant" className="flex h-full w-[min(420px,100vw)] flex-col border-l border-border bg-surface shadow-pop" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-control bg-accent-soft text-accent"><Sparkles size={16} /></span>
|
||||
<div>
|
||||
<div className="text-[14px] font-bold leading-none">AI Assistant</div>
|
||||
<div className="text-[11px] text-dim">Powered by Gemini</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={closeAi} className="text-muted hover:text-ink"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{context && (
|
||||
<div className="rounded-control border border-border bg-panel p-3">
|
||||
<div className="mb-1 text-[11px] font-bold uppercase tracking-wide text-dim">Message context</div>
|
||||
<div className="max-h-32 overflow-y-auto whitespace-pre-wrap text-[13px] text-ink/80">{context}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{turns.length === 0 && !busy && (
|
||||
<div className="pt-2 text-center text-[13px] text-muted">
|
||||
{context ? "Pick a quick action below, or ask anything about this message." : "Ask me anything — summaries, replies, rewrites, and more."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{turns.map((t, i) => (
|
||||
<div key={i} className={t.role === "user" ? "flex justify-end" : "flex justify-start"}>
|
||||
<div className={
|
||||
t.role === "user"
|
||||
? "max-w-[85%] rounded-2xl rounded-br-sm bg-accent px-3 py-2 text-[13.5px] text-accent-fg"
|
||||
: `max-w-[90%] rounded-2xl rounded-bl-sm border px-3 py-2 text-[13.5px] ${t.error ? "border-danger/40 bg-danger/10 text-danger" : "border-border bg-panel text-ink/90"}`
|
||||
}>
|
||||
<div className="whitespace-pre-wrap break-words">{t.text}</div>
|
||||
{t.role === "ai" && !t.error && (
|
||||
<button onClick={() => copy(t.text)} className="mt-1.5 flex items-center gap-1 text-[11px] font-semibold text-muted hover:text-ink">
|
||||
<Copy size={11} /> Copy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{busy && (
|
||||
<div className="flex items-center gap-2 text-[12.5px] text-muted">
|
||||
<RefreshCw size={13} className="animate-spin" /> Thinking…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-border px-3 pt-2.5">
|
||||
{QUICK.map((a) => (
|
||||
<button
|
||||
key={a.label}
|
||||
disabled={busy}
|
||||
onClick={() => ask(a.prompt, context)}
|
||||
className="inline-flex items-center gap-1.5 rounded-pill border border-border bg-panel px-2.5 py-1 text-[12px] font-semibold hover:bg-hover disabled:opacity-40"
|
||||
>
|
||||
<a.icon size={12} /> {a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="flex items-end gap-2 rounded-control border border-border bg-panel p-1.5 focus-within:border-border-strong">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(input, context); } }}
|
||||
rows={1}
|
||||
placeholder="Ask the AI…"
|
||||
className="max-h-28 min-h-[32px] w-full resize-none bg-transparent px-2 py-1.5 text-[13.5px] outline-none placeholder:text-dim"
|
||||
/>
|
||||
<button
|
||||
disabled={busy || !input.trim()}
|
||||
onClick={() => ask(input, context)}
|
||||
className="grid h-8 w-8 shrink-0 place-items-center rounded-control bg-accent text-accent-fg disabled:opacity-40"
|
||||
title="Send"
|
||||
>
|
||||
<Send size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSearch, type SearchResult } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Search, Hash, User, MessageSquare, FileText, LifeBuoy, CornerDownLeft, Inbox } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const icons: Record<SearchResult["type"], typeof Hash> = {
|
||||
channel: Hash, person: User, message: MessageSquare, file: FileText, ticket: LifeBuoy,
|
||||
};
|
||||
|
||||
type Item = { key: string; icon: typeof Hash; title: string; subtitle?: string; run: () => void };
|
||||
|
||||
export function CommandPalette() {
|
||||
const { paletteOpen, setPaletteOpen, openProfile, openThread } = useUi();
|
||||
const { query, setQuery, results, loading } = useSearch();
|
||||
const router = useRouter();
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const go = (r: SearchResult) => {
|
||||
setPaletteOpen(false);
|
||||
if (r.type === "person") openProfile(r.id);
|
||||
else if (r.type === "ticket") router.push(`/support/${r.id}`);
|
||||
else if (r.type === "message" && r.channelId) {
|
||||
// a reply lives inside a thread (not the main list) → open the thread;
|
||||
// otherwise deep-link so the channel view scrolls to + flashes it
|
||||
if (r.parentId) { router.push(`/c/${r.channelId}`); openThread(r.channelId, r.parentId); }
|
||||
else router.push(`/c/${r.channelId}?msg=${r.id}`);
|
||||
}
|
||||
else if (r.channelId) router.push(`/c/${r.channelId}`);
|
||||
};
|
||||
|
||||
const items: Item[] = useMemo(() => {
|
||||
if (!query) {
|
||||
return [
|
||||
{ key: "q1", icon: Hash, title: "Jump to #general", run: () => { setPaletteOpen(false); router.push("/c/c_general"); } },
|
||||
{ key: "q2", icon: Inbox, title: "Open Inbox", run: () => { setPaletteOpen(false); router.push("/inbox"); } },
|
||||
{ key: "q3", icon: LifeBuoy, title: "Support Center", run: () => { setPaletteOpen(false); router.push("/support"); } },
|
||||
];
|
||||
}
|
||||
if (loading) return [];
|
||||
return results.map((r) => ({ key: `${r.type}-${r.id}`, icon: icons[r.type], title: r.title, subtitle: r.subtitle, run: () => go(r) }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, loading, results]);
|
||||
|
||||
useEffect(() => { setActive(0); }, [query, results]);
|
||||
|
||||
if (!paletteOpen) return null;
|
||||
|
||||
const onKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); items[active]?.run(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-start justify-center bg-black/50 p-4 pt-[12vh] backdrop-blur-sm animate-fade-in" onClick={() => setPaletteOpen(false)}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Search" className="w-full max-w-xl overflow-hidden rounded-card border border-border bg-elevated shadow-pop" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-3 border-b border-border px-4">
|
||||
<Search size={18} className="text-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="Search messages, channels, people, tickets…"
|
||||
className="w-full bg-transparent py-4 text-[15px] outline-none placeholder:text-dim"
|
||||
/>
|
||||
<kbd className="rounded bg-hover px-1.5 py-0.5 font-mono text-[10px] text-dim">esc</kbd>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="max-h-[52vh] overflow-y-auto p-2">
|
||||
{!query && <div className="px-2 py-1 text-[11px] font-bold uppercase text-dim">Quick actions</div>}
|
||||
{query && loading && <div className="px-3 py-6 text-center text-[13px] text-muted">Searching…</div>}
|
||||
{query && !loading && items.length === 0 && <div className="px-3 py-6 text-center text-[13px] text-muted">No results for “{query}”</div>}
|
||||
{items.map((it, i) => (
|
||||
<button
|
||||
key={it.key}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={it.run}
|
||||
className={clsx("flex w-full items-center gap-3 rounded-control px-3 py-2 text-left", i === active ? "bg-hover" : "hover:bg-hover")}
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-control bg-hover text-muted"><it.icon size={16} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[14px] text-ink">{it.title}</span>
|
||||
{it.subtitle && <span className="block truncate text-[12px] text-muted">{it.subtitle}</span>}
|
||||
</span>
|
||||
{i === active && <CornerDownLeft size={14} className="shrink-0 text-dim" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSdk, useChannel } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Avatar } from "@/components/ui/primitives";
|
||||
import { Mic, MicOff, Video, VideoOff, MonitorUp, PhoneOff } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function HuddleBar() {
|
||||
const { huddleChannelId, endHuddle } = useUi();
|
||||
const channel = useChannel(huddleChannelId ?? null);
|
||||
const { userById } = useSdk();
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [video, setVideo] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const startRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!huddleChannelId) return;
|
||||
setSeconds(0);
|
||||
setMuted(false);
|
||||
setVideo(false);
|
||||
setSharing(false);
|
||||
startRef.current = Date.now();
|
||||
const t = setInterval(() => setSeconds(Math.floor((Date.now() - startRef.current) / 1000)), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [huddleChannelId]);
|
||||
|
||||
if (!huddleChannelId || !channel) return null;
|
||||
|
||||
const members = channel.memberIds.slice(0, 4).map((id) => userById(id)).filter(Boolean);
|
||||
const total = channel.memberIds.length;
|
||||
const mmss = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 w-[min(440px,92vw)] -translate-x-1/2 rounded-card border border-border bg-elevated p-3 shadow-pop animate-slide-up">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="relative flex h-9 w-9 items-center justify-center rounded-control bg-success/20 text-success">
|
||||
<Video size={18} />
|
||||
<span className="absolute inset-0 animate-ping rounded-control border border-success/40" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13.5px] font-bold">Huddle · {channel.kind === "channel" ? `#${channel.name}` : channel.name}</div>
|
||||
<div className="text-[12px] text-muted">{total} {total === 1 ? "person" : "people"} · {mmss}</div>
|
||||
</div>
|
||||
<div className="flex -space-x-2">
|
||||
{members.map((u) => (u ? <Avatar key={u.id} user={u} size={26} showPresence={false} /> : null))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<HuddleBtn icon={muted ? MicOff : Mic} label={muted ? "Unmute" : "Mute"} active={muted} onClick={() => setMuted((m) => !m)} />
|
||||
<HuddleBtn icon={video ? Video : VideoOff} label={video ? "Stop video" : "Start video"} active={video} onClick={() => setVideo((v) => !v)} />
|
||||
<HuddleBtn icon={MonitorUp} label={sharing ? "Stop sharing" : "Share screen"} active={sharing} onClick={() => setSharing((s) => !s)} />
|
||||
<button onClick={endHuddle} className="focus-ring flex items-center gap-2 rounded-control bg-danger px-4 py-2 text-[13px] font-semibold text-white">
|
||||
<PhoneOff size={15} /> Leave
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HuddleBtn({ icon: Icon, label, onClick, active }: { icon: typeof Mic; label: string; onClick: () => void; active?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={clsx("focus-ring grid h-9 w-11 place-items-center rounded-control border transition-colors", active ? "border-accent bg-accent-soft text-accent" : "border-border bg-panel text-ink hover:bg-hover")}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSdk, useChannel } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Modal, Field, inputCls, PrimaryBtn, GhostBtn } from "@/components/ui/Modal";
|
||||
import { Avatar } from "@/components/ui/primitives";
|
||||
import { EmojiPicker } from "@/components/message/EmojiPicker";
|
||||
import { Hash, Lock, Smile, Upload, Trash2 } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const AVATAR_COLORS = ["#FDA913", "#5b9dff", "#3ecf8e", "#bb98ff", "#f2555a", "#ff7a3d", "#54e7ff", "#f5b544", "#8c8c94"];
|
||||
|
||||
export function Modals() {
|
||||
const { modal } = useUi();
|
||||
if (!modal) return null;
|
||||
return (
|
||||
<>
|
||||
{modal === "create-channel" && <CreateChannelModal />}
|
||||
{modal === "new-message" && <NewMessageModal />}
|
||||
{modal === "invite" && <InviteModal />}
|
||||
{modal === "create-workspace" && <CreateWorkspaceModal />}
|
||||
{modal === "callback" && <CallbackModal />}
|
||||
{modal === "channel-details" && <ChannelDetailsModal />}
|
||||
{modal === "channel-members" && <ChannelMembersModal />}
|
||||
{modal === "set-status" && <StatusModal />}
|
||||
{modal === "edit-profile" && <EditProfileModal />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelMembersModal() {
|
||||
const { modalArg, closeModal, toast } = useUi();
|
||||
const { channelById, users, client, refresh, me } = useSdk();
|
||||
const channel = useChannel(modalArg ?? null);
|
||||
const [q, setQ] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!channel) return <Modal title="Members" onClose={closeModal}><p className="text-[13px] text-muted">No channel selected.</p></Modal>;
|
||||
|
||||
const memberIds = channelById(channel.id)?.memberIds ?? channel.memberIds;
|
||||
const members = memberIds.map((id) => users.find((u) => u.id === id)).filter(Boolean) as typeof users;
|
||||
const candidates = users.filter((u) => !memberIds.includes(u.id) && (u.name.toLowerCase().includes(q.toLowerCase()) || !q));
|
||||
|
||||
const add = async (userId: string, name: string) => { setBusy(true); await client.addMember(channel.id, userId); refresh(); setBusy(false); toast(`Added ${name} to #${channel.name}`, "success"); };
|
||||
const remove = async (userId: string, name: string) => { setBusy(true); await client.removeMember(channel.id, userId); refresh(); setBusy(false); toast(`Removed ${name}`, "default"); };
|
||||
|
||||
return (
|
||||
<Modal title={`Members of ${channel.kind === "channel" ? "#" + channel.name : channel.name}`} subtitle={`${members.length} member${members.length === 1 ? "" : "s"}`} onClose={closeModal} wide>
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 text-[12px] font-bold uppercase text-dim">In this channel</div>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{members.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-3 rounded-control px-2 py-1.5 hover:bg-hover">
|
||||
<Avatar user={u} size={30} />
|
||||
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}{u.id === me?.id ? " (you)" : ""}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
|
||||
{u.id !== me?.id && <button disabled={busy} onClick={() => remove(u.id, u.name)} className="rounded-control px-2 py-1 text-[12px] font-semibold text-danger hover:bg-hover disabled:opacity-40">Remove</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-[12px] font-bold uppercase text-dim">Add people</div>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people to add…" className={inputCls} />
|
||||
<div className="mt-2 max-h-52 space-y-1 overflow-y-auto">
|
||||
{candidates.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-3 rounded-control px-2 py-1.5 hover:bg-hover">
|
||||
<Avatar user={u} size={30} />
|
||||
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
|
||||
<button disabled={busy} onClick={() => add(u.id, u.name)} className="rounded-control border border-border px-2.5 py-1 text-[12px] font-semibold hover:bg-hover disabled:opacity-40">Add</button>
|
||||
</div>
|
||||
))}
|
||||
{candidates.length === 0 && <div className="py-4 text-center text-[12.5px] text-muted">Everyone's already here.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const CLEAR_OPTIONS: { label: string; ms: number | null }[] = [
|
||||
{ label: "Don't clear", ms: null },
|
||||
{ label: "30 minutes", ms: 30 * 60_000 },
|
||||
{ label: "1 hour", ms: 60 * 60_000 },
|
||||
{ label: "4 hours", ms: 4 * 60 * 60_000 },
|
||||
{ label: "Today", ms: -1 }, // end of today
|
||||
];
|
||||
|
||||
function StatusModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const { me, client, refresh } = useSdk();
|
||||
const [emoji, setEmoji] = useState(me?.statusEmoji ?? "");
|
||||
const [text, setText] = useState(me?.statusText ?? "");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [clearIdx, setClearIdx] = useState(0);
|
||||
const [customAt, setCustomAt] = useState("");
|
||||
const presets = [
|
||||
{ e: "🏗️", t: "On site" }, { e: "📅", t: "In a meeting" }, { e: "🎧", t: "Focusing" },
|
||||
{ e: "🌴", t: "On vacation" }, { e: "🤒", t: "Out sick" }, { e: "🏠", t: "Working remotely" },
|
||||
{ e: "🍽️", t: "Out for lunch" }, { e: "🚗", t: "Commuting" },
|
||||
];
|
||||
const computeClearAt = (): number | undefined => {
|
||||
if (customAt) { const t = Date.parse(customAt); return Number.isNaN(t) ? undefined : t; }
|
||||
const opt = CLEAR_OPTIONS[clearIdx];
|
||||
if (opt.ms === null) return undefined;
|
||||
if (opt.ms === -1) { const d = new Date(); d.setHours(23, 59, 0, 0); return d.getTime(); }
|
||||
return Date.now() + opt.ms;
|
||||
};
|
||||
const save = async () => { await client.setStatus(text.trim() || undefined, emoji || undefined, computeClearAt()); refresh(); closeModal(); toast("Status updated", "success"); };
|
||||
const clear = async () => { await client.setStatus(undefined, undefined); refresh(); closeModal(); toast("Status cleared", "default"); };
|
||||
return (
|
||||
<Modal title="Set a status" onClose={closeModal} footer={<><GhostBtn onClick={clear}>Clear</GhostBtn><PrimaryBtn onClick={save}>Save</PrimaryBtn></>}>
|
||||
<div className="relative mb-3 flex items-center gap-2 rounded-control border border-border bg-panel pl-1.5 pr-3">
|
||||
<button onClick={() => setPickerOpen((o) => !o)} className="grid h-9 w-9 place-items-center rounded-control text-lg hover:bg-hover" title="Pick an emoji">
|
||||
{emoji || <Smile size={18} className="text-muted" />}
|
||||
</button>
|
||||
{pickerOpen && <EmojiPicker onPick={(e) => { setEmoji(e); setPickerOpen(false); }} onClose={() => setPickerOpen(false)} />}
|
||||
<input autoFocus value={text} onChange={(e) => setText(e.target.value)} placeholder="What's your status?" className="w-full bg-transparent py-2 text-[14px] outline-none placeholder:text-dim" onKeyDown={(e) => e.key === "Enter" && save()} />
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-2 gap-2">
|
||||
{presets.map((p) => (
|
||||
<button key={p.t} onClick={() => { setEmoji(p.e); setText(p.t); }} className="flex items-center gap-2 rounded-control border border-border px-3 py-2 text-left text-[13px] hover:bg-hover">
|
||||
<span className="text-base">{p.e}</span> {p.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Field label="Clear after">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CLEAR_OPTIONS.map((o, i) => (
|
||||
<button key={o.label} onClick={() => { setClearIdx(i); setCustomAt(""); }} className={clsx("rounded-pill border px-2.5 py-1 text-[12px] font-medium", !customAt && clearIdx === i ? "border-accent text-accent" : "border-border text-muted hover:text-ink")}>{o.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<input type="datetime-local" value={customAt} onChange={(e) => setCustomAt(e.target.value)} className={clsx(inputCls, "mt-2")} title="Custom clear time" />
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditProfileModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const { me, client, refresh } = useSdk();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState(me?.name ?? "");
|
||||
const [title, setTitle] = useState(me?.title ?? "");
|
||||
const [color, setColor] = useState(me?.avatarColor ?? AVATAR_COLORS[0]);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(me?.avatarUrl ?? null);
|
||||
|
||||
const onFile = (f: File | undefined) => {
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setAvatarUrl(String(reader.result));
|
||||
reader.readAsDataURL(f);
|
||||
};
|
||||
const save = async () => {
|
||||
await client.updateMe({ name: name.trim() || undefined, title: title.trim() || undefined, avatarColor: color, avatarUrl: avatarUrl });
|
||||
refresh();
|
||||
closeModal();
|
||||
toast("Profile updated", "success");
|
||||
};
|
||||
|
||||
const preview = { avatarText: (name || "?").slice(0, 2).toUpperCase(), avatarColor: color, presence: (me?.presence ?? "active") as any, name, avatarUrl: avatarUrl ?? undefined };
|
||||
|
||||
return (
|
||||
<Modal title="Edit profile" onClose={closeModal} footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={save}>Save changes</PrimaryBtn></>}>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<Avatar user={preview} size={72} showPresence={false} rounded="20px" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<button onClick={() => fileRef.current?.click()} className="focus-ring flex items-center gap-2 rounded-control border border-border px-3 py-1.5 text-[13px] font-semibold hover:bg-hover"><Upload size={14} /> Upload image</button>
|
||||
{avatarUrl && <button onClick={() => setAvatarUrl(null)} className="flex items-center gap-1.5 text-[12px] text-danger hover:underline"><Trash2 size={12} /> Remove image</button>}
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(e) => { onFile(e.target.files?.[0]); e.target.value = ""; }} />
|
||||
</div>
|
||||
</div>
|
||||
{!avatarUrl && (
|
||||
<Field label="Avatar color">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AVATAR_COLORS.map((c) => (
|
||||
<button key={c} onClick={() => setColor(c)} className="h-8 w-8 rounded-full ring-2 ring-transparent transition-all hover:scale-110" style={{ background: c, boxShadow: color === c ? "0 0 0 2px var(--c-bg), 0 0 0 4px " + c : undefined }} />
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Display name"><input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} /></Field>
|
||||
<Field label="Title"><input value={title} onChange={(e) => setTitle(e.target.value)} className={inputCls} /></Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateChannelModal() {
|
||||
const { client, refresh, users, me } = useSdk();
|
||||
const { closeModal, toast } = useUi();
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [priv, setPriv] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const others = users.filter((u) => u.id !== me?.id);
|
||||
const candidates = others.filter((u) => u.name.toLowerCase().includes(q.toLowerCase()) || u.handle.toLowerCase().includes(q.toLowerCase()));
|
||||
const toggle = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||
|
||||
async function create() {
|
||||
const clean = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
if (!clean) return;
|
||||
setBusy(true);
|
||||
// Public channels include the whole team automatically; private channels
|
||||
// include only the members you explicitly select.
|
||||
const memberIds = priv ? selected : others.map((u) => u.id);
|
||||
const { channel } = await client.createChannel({ name: clean, topic: topic.trim() || undefined, kind: priv ? "private" : "channel", memberIds });
|
||||
refresh();
|
||||
closeModal();
|
||||
toast(`Created #${channel.name}`, "success");
|
||||
router.push(`/c/${channel.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create a channel"
|
||||
subtitle="Channels are where your team communicates."
|
||||
onClose={closeModal}
|
||||
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={create} disabled={!name.trim() || busy || (priv && selected.length === 0)}>Create channel</PrimaryBtn></>}
|
||||
>
|
||||
<Field label="Name">
|
||||
<div className="flex items-center gap-2 rounded-control border border-border bg-panel px-3">
|
||||
{priv ? <Lock size={15} className="text-muted" /> : <Hash size={15} className="text-muted" />}
|
||||
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. marketing" className="w-full bg-transparent py-2 text-[14px] outline-none placeholder:text-dim" onKeyDown={(e) => e.key === "Enter" && create()} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Description (optional)">
|
||||
<input value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="What's this channel about?" className={inputCls} />
|
||||
</Field>
|
||||
<label className="flex items-center gap-2.5 rounded-control border border-border bg-panel px-3 py-2.5 text-[13px]">
|
||||
<input type="checkbox" checked={priv} onChange={(e) => setPriv(e.target.checked)} className="accent-[var(--c-accent)]" />
|
||||
<span><b>Make private</b> — only invited members can view</span>
|
||||
</label>
|
||||
|
||||
{priv ? (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 text-[12px] font-bold uppercase text-dim">Add members · {selected.length} selected</div>
|
||||
{selected.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{selected.map((id) => {
|
||||
const u = users.find((x) => x.id === id);
|
||||
if (!u) return null;
|
||||
return (
|
||||
<span key={id} className="inline-flex items-center gap-1.5 rounded-pill bg-accent-soft py-1 pl-1.5 pr-2 text-[12px] font-medium text-accent">
|
||||
<Avatar user={u} size={16} showPresence={false} /> {u.name}
|
||||
<button onClick={() => toggle(id)} className="text-accent/70 hover:text-accent">✕</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people to add…" className={inputCls} />
|
||||
<div className="mt-2 max-h-44 space-y-1 overflow-y-auto">
|
||||
{candidates.map((u) => (
|
||||
<button key={u.id} onClick={() => toggle(u.id)} className="flex w-full items-center gap-3 rounded-control px-2 py-1.5 text-left hover:bg-hover">
|
||||
<input type="checkbox" readOnly checked={selected.includes(u.id)} className="accent-[var(--c-accent)]" />
|
||||
<Avatar user={u} size={28} />
|
||||
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
|
||||
</button>
|
||||
))}
|
||||
{candidates.length === 0 && <div className="py-4 text-center text-[12.5px] text-muted">No people match “{q}”.</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 flex items-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[12.5px] text-muted">
|
||||
<Hash size={14} className="text-accent" /> Everyone on the team ({others.length}) will be added automatically.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function NewMessageModal() {
|
||||
const { users, me, channels, client, refresh } = useSdk();
|
||||
const { closeModal, toast } = useUi();
|
||||
const router = useRouter();
|
||||
const [q, setQ] = useState("");
|
||||
const people = users.filter((u) => u.id !== me?.id && u.name.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
async function openDm(userId: string, name: string) {
|
||||
const existing = channels.find((c) => c.kind === "dm" && c.memberIds.includes(userId));
|
||||
closeModal();
|
||||
if (existing) { router.push(`/c/${existing.id}`); return; }
|
||||
const { channel } = await client.createChannel({ name, kind: "dm", memberIds: [userId] });
|
||||
refresh();
|
||||
toast(`Started a conversation with ${name}`, "success");
|
||||
router.push(`/c/${channel.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="New message" subtitle="Start a direct message" onClose={closeModal}>
|
||||
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" className={inputCls} />
|
||||
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
{people.map((u) => (
|
||||
<button key={u.id} onClick={() => openDm(u.id, u.name)} className="flex w-full items-center gap-3 rounded-control px-2 py-2 text-left hover:bg-hover">
|
||||
<Avatar user={u} size={34} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-medium">{u.name}</div>
|
||||
<div className="truncate text-[12px] text-muted">{u.title}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{people.length === 0 && <div className="py-6 text-center text-[13px] text-muted">No people match “{q}”.</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const [chips, setChips] = useState<string[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const v = raw.trim().replace(/,$/, "").trim();
|
||||
if (!v) return;
|
||||
if (!isEmail(v)) { toast(`“${v}” doesn't look like an email`, "default"); return; }
|
||||
setChips((c) => (c.includes(v) ? c : [...c, v]));
|
||||
setDraft("");
|
||||
};
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "," || e.key === "Enter" || e.key === " ") { e.preventDefault(); commit(draft); }
|
||||
else if (e.key === "Backspace" && !draft && chips.length) { setChips((c) => c.slice(0, -1)); }
|
||||
};
|
||||
const total = chips.length + (isEmail(draft.trim()) ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Invite people"
|
||||
subtitle="Invite teammates to LynkedUp Pro"
|
||||
onClose={closeModal}
|
||||
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn disabled={total === 0} onClick={() => { const all = [...chips, ...(isEmail(draft.trim()) ? [draft.trim()] : [])]; closeModal(); toast(`${all.length} invitation${all.length === 1 ? "" : "s"} sent (demo)`, "success"); }}>Send invites</PrimaryBtn></>}
|
||||
>
|
||||
<Field label="Email addresses">
|
||||
<div className="flex flex-wrap items-center gap-1.5 rounded-control border border-border bg-panel px-2 py-2">
|
||||
{chips.map((email) => (
|
||||
<span key={email} className="inline-flex items-center gap-1.5 rounded-pill bg-accent-soft py-1 pl-2.5 pr-1.5 text-[12.5px] font-medium text-accent">
|
||||
{email}
|
||||
<button onClick={() => setChips((c) => c.filter((x) => x !== email))} className="grid h-4 w-4 place-items-center rounded-full text-accent/70 hover:bg-accent/20 hover:text-accent">✕</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => { const v = e.target.value; if (v.endsWith(",")) commit(v); else setDraft(v); }}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => commit(draft)}
|
||||
placeholder={chips.length ? "" : "name@company.com, another@company.com"}
|
||||
className="min-w-[160px] flex-1 bg-transparent py-1 text-[14px] outline-none placeholder:text-dim"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<p className="text-[12px] text-muted">Type an email and press <b>,</b> or <b>Enter</b> to add it as a chip.</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateWorkspaceModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const [name, setName] = useState("");
|
||||
return (
|
||||
<Modal
|
||||
title="Create a workspace"
|
||||
subtitle="Spin up a new workspace"
|
||||
onClose={closeModal}
|
||||
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn disabled={!name.trim()} onClick={() => { closeModal(); toast(`Workspace “${name}” created (demo)`, "success"); }}>Create</PrimaryBtn></>}
|
||||
>
|
||||
<Field label="Workspace name"><input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Acme Field Ops" className={inputCls} /></Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CallbackModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const [channel, setChannel] = useState("PHONE");
|
||||
const [time, setTime] = useState("");
|
||||
return (
|
||||
<Modal
|
||||
title="Request a callback"
|
||||
subtitle="We'll schedule a call with the customer"
|
||||
onClose={closeModal}
|
||||
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={() => { closeModal(); toast("Callback requested — scheduled follow-up created", "success"); }}>Request callback</PrimaryBtn></>}
|
||||
>
|
||||
<Field label="Preferred channel">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["PHONE", "ZOOM", "IN_APP"].map((c) => (
|
||||
<button key={c} onClick={() => setChannel(c)} className={clsx("rounded-control border py-2 text-[12.5px] font-semibold", channel === c ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover")}>
|
||||
{c.replace("_", "-")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Preferred time (optional)"><input value={time} onChange={(e) => setTime(e.target.value)} placeholder="e.g. Tomorrow 2–4pm CT" className={inputCls} /></Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelDetailsModal() {
|
||||
const { modalArg, closeModal, openModal } = useUi();
|
||||
const { userById } = useSdk();
|
||||
const channel = useChannel(modalArg ?? null);
|
||||
if (!channel) { return <Modal title="Details" onClose={closeModal}><p className="text-[13px] text-muted">No channel selected.</p></Modal>; }
|
||||
return (
|
||||
<Modal title={channel.kind === "channel" ? `#${channel.name}` : channel.name} subtitle={channel.topic} onClose={closeModal} wide>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-control border border-border bg-panel p-3">
|
||||
<div className="text-[11px] font-semibold uppercase text-dim">Topic</div>
|
||||
<div className="mt-1 text-[13.5px]">{channel.topic || "No topic set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold uppercase text-dim">Members · {channel.memberIds.length}</span>
|
||||
<button onClick={() => openModal("channel-members", channel.id)} className="focus-ring rounded-control border border-border px-2 py-1 text-[12px] font-semibold hover:bg-hover">+ Add people</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{channel.memberIds.map((id) => {
|
||||
const u = userById(id);
|
||||
return u ? (
|
||||
<div key={id} className="flex items-center gap-2 rounded-control border border-border bg-panel px-2.5 py-2">
|
||||
<Avatar user={u} size={28} />
|
||||
<div className="min-w-0"><div className="truncate text-[13px] font-medium">{u.name}</div><div className="truncate text-[11px] text-muted">{u.title}</div></div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useNotifications } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { relativeTime } from "@/lib/format";
|
||||
import { AtSign, Heart, LifeBuoy, Bell, MessageSquare, X, CheckCheck } from "lucide-react";
|
||||
|
||||
const kindIcon: Record<string, typeof Bell> = {
|
||||
MENTION: AtSign, REACTION: Heart, SUPPORT_UPDATE: LifeBuoy, THREAD_REPLY: MessageSquare, SYSTEM: Bell,
|
||||
};
|
||||
|
||||
export function NotificationsPanel() {
|
||||
const { notifOpen, setNotifOpen } = useUi();
|
||||
const { notifications } = useNotifications();
|
||||
const router = useRouter();
|
||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||
if (!notifOpen) return null;
|
||||
|
||||
const isRead = (id: string, base?: boolean) => base || readIds.has(id);
|
||||
const markAll = () => setReadIds(new Set(notifications.map((n) => n.id)));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40" onClick={() => setNotifOpen(false)}>
|
||||
<div className="absolute right-3 top-[58px] w-[min(360px,calc(100vw-1.5rem))] overflow-hidden rounded-card border border-border bg-elevated shadow-pop animate-slide-up" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-[15px] font-bold">Notifications</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={markAll} title="Mark all as read" className="flex items-center gap-1 rounded-control px-2 py-1 text-[11.5px] font-semibold text-muted hover:bg-hover hover:text-ink"><CheckCheck size={14} /> Mark all read</button>
|
||||
<button onClick={() => setNotifOpen(false)} className="text-muted hover:text-ink"><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{notifications.map((n) => {
|
||||
const Icon = kindIcon[n.kind] ?? Bell;
|
||||
const read = isRead(n.id, n.read);
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => { setReadIds((s) => new Set(s).add(n.id)); setNotifOpen(false); if (n.channelId) router.push(`/c/${n.channelId}${n.messageId ? `?msg=${n.messageId}` : ""}`); else router.push("/inbox"); }}
|
||||
className="flex w-full items-start gap-3 border-b border-border px-4 py-3 text-left hover:bg-hover"
|
||||
>
|
||||
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-control bg-accent-soft text-accent"><Icon size={15} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-[13px]"><b className="text-ink">{n.title}</b> <span className="text-muted">{n.body}</span></span>
|
||||
<span className="mt-0.5 block text-[11px] text-dim">{relativeTime(n.ts)}</span>
|
||||
</span>
|
||||
{!read && <span className="mt-1 h-2 w-2 shrink-0 rounded-full bg-accent" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button onClick={() => { setNotifOpen(false); router.push("/inbox"); }} className="w-full py-2.5 text-center text-[13px] font-semibold text-accent hover:bg-hover">Open Inbox →</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSdk } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Avatar, Chip } from "@/components/ui/primitives";
|
||||
import { X, MessageSquare, Phone, Clock, Mail, AtSign, Smile, Pencil } from "lucide-react";
|
||||
|
||||
export function ProfileDrawer() {
|
||||
const { profileUserId, closeProfile, startHuddle, toast, openModal } = useUi();
|
||||
const { userById, me, channels, client, refresh } = useSdk();
|
||||
const router = useRouter();
|
||||
if (!profileUserId) return null;
|
||||
const user = userById(profileUserId);
|
||||
if (!user) return null;
|
||||
const isMe = user.id === me?.id;
|
||||
|
||||
async function openDm() {
|
||||
if (!user) return;
|
||||
const existing = channels.find((c) => c.kind === "dm" && c.memberIds.includes(user.id));
|
||||
closeProfile();
|
||||
if (existing) { router.push(`/c/${existing.id}`); return; }
|
||||
const { channel } = await client.createChannel({ name: user.name, kind: "dm", memberIds: [user.id] });
|
||||
refresh();
|
||||
router.push(`/c/${channel.id}`);
|
||||
}
|
||||
|
||||
async function huddle() {
|
||||
if (!user) return;
|
||||
const existing = channels.find((c) => c.kind === "dm" && c.memberIds.includes(user.id));
|
||||
closeProfile();
|
||||
if (existing) startHuddle(existing.id);
|
||||
else toast(`Starting a huddle with ${user.name}…`, "success");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end bg-black/40 animate-fade-in" onClick={closeProfile}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Profile" className="flex h-full w-[min(380px,100vw)] flex-col border-l border-border bg-surface shadow-pop" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-[15px] font-bold">Profile</span>
|
||||
<button onClick={closeProfile} className="text-muted hover:text-ink"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Avatar user={user} size={96} showPresence={false} rounded="24px" />
|
||||
<h2 className="mt-3 text-[20px] font-black">{user.name}</h2>
|
||||
<div className="text-[13px] text-muted">{user.title}</div>
|
||||
{user.statusText && <div className="mt-2 rounded-pill bg-hover px-3 py-1 text-[12.5px]">{user.statusEmoji} {user.statusText}</div>}
|
||||
<div className="mt-2"><Chip tone={user.presence === "active" ? "success" : user.presence === "dnd" ? "danger" : "neutral"}>{user.presence}</Chip></div>
|
||||
</div>
|
||||
|
||||
{!isMe && (
|
||||
<div className="mt-5 grid grid-cols-2 gap-2">
|
||||
<button onClick={openDm} className="focus-ring flex items-center justify-center gap-2 rounded-control bg-accent px-3 py-2 text-[13px] font-semibold text-accent-fg"><MessageSquare size={15} /> Message</button>
|
||||
<button onClick={huddle} className="focus-ring flex items-center justify-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[13px] font-semibold hover:bg-hover"><Phone size={15} /> Huddle</button>
|
||||
</div>
|
||||
)}
|
||||
{isMe && (
|
||||
<>
|
||||
<div className="mt-5">
|
||||
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-wide text-dim">Availability</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
{ p: "active", label: "Active", dot: "bg-success" },
|
||||
{ p: "away", label: "Away", dot: "bg-warning" },
|
||||
{ p: "dnd", label: "Do not disturb", dot: "bg-danger" },
|
||||
] as const).map((o) => (
|
||||
<button
|
||||
key={o.p}
|
||||
onClick={async () => { await client.updateMe({ presence: o.p }); refresh(); }}
|
||||
className={`focus-ring flex items-center justify-center gap-1.5 rounded-control border px-2 py-2 text-[12px] font-semibold ${user.presence === o.p ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover"}`}
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full ${o.dot}`} /> {o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button onClick={() => { closeProfile(); openModal("set-status"); }} className="focus-ring flex items-center justify-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[13px] font-semibold hover:bg-hover">
|
||||
<Smile size={15} /> {user.statusText ? "Status" : "Set status"}
|
||||
</button>
|
||||
<button onClick={() => { closeProfile(); openModal("edit-profile"); }} className="focus-ring flex items-center justify-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[13px] font-semibold hover:bg-hover">
|
||||
<Pencil size={15} /> Edit profile
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-3 text-[13px]">
|
||||
<Field icon={AtSign} label="Handle" value={`@${user.handle}`} />
|
||||
{user.email && <Field icon={Mail} label="Email" value={user.email} />}
|
||||
{user.timezone && <Field icon={Clock} label="Local time" value={`${user.localTime ?? ""} · ${user.timezone}`} />}
|
||||
{user.pronouns && <Field icon={AtSign} label="Pronouns" value={user.pronouns} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ icon: Icon, label, value }: { icon: typeof Mail; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-control border border-border bg-panel px-3 py-2.5">
|
||||
<Icon size={16} className="text-muted" />
|
||||
<div><div className="text-[11px] uppercase tracking-wide text-dim">{label}</div><div className="text-ink">{value}</div></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { X, RotateCcw, Sun, Moon, Monitor, Check } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const ACCENTS = ["#FDA913", "#5b9dff", "#3ecf8e", "#bb98ff", "#f2555a", "#ff7a3d", "#54e7ff", "#f5b544"];
|
||||
const RADII = [
|
||||
{ label: "Sharp", card: "8px", control: "6px" },
|
||||
{ label: "Default", card: "20px", control: "10px" },
|
||||
{ label: "Round", card: "28px", control: "14px" },
|
||||
];
|
||||
const GRADIENTS = [
|
||||
"linear-gradient(135deg, #FDA913 0%, #ff7a3d 55%, #ff4d6d 100%)",
|
||||
"linear-gradient(135deg, #5b9dff 0%, #bb98ff 100%)",
|
||||
"linear-gradient(135deg, #3ecf8e 0%, #54e7ff 100%)",
|
||||
"linear-gradient(135deg, #f2555a 0%, #ff7a3d 100%)",
|
||||
];
|
||||
|
||||
type Mode = "light" | "dark" | "system";
|
||||
|
||||
export function ThemingPanel() {
|
||||
const { themingOpen, setThemingOpen } = useUi();
|
||||
const { theme, scheme, setScheme, setThemeOverrides, resetTheme } = useTheme();
|
||||
const [mode, setMode] = useState<Mode>(scheme);
|
||||
|
||||
// when in "system" mode, follow OS changes live
|
||||
useEffect(() => {
|
||||
if (mode !== "system" || typeof window === "undefined") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const apply = () => setScheme(mq.matches ? "dark" : "light");
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
return () => mq.removeEventListener("change", apply);
|
||||
}, [mode, setScheme]);
|
||||
|
||||
if (!themingOpen) return null;
|
||||
|
||||
const activeAccent = scheme === "light" ? theme.light.accent : theme.dark.accent;
|
||||
|
||||
const pickMode = (m: Mode) => {
|
||||
setMode(m);
|
||||
if (m !== "system") setScheme(m);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end bg-black/40 animate-fade-in" onClick={() => setThemingOpen(false)}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Appearance" className="flex h-full w-[min(360px,100vw)] flex-col border-l border-border bg-surface shadow-pop" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<div className="text-[15px] font-bold">Appearance</div>
|
||||
<div className="text-[12px] text-muted">Live theme — persists to this browser</div>
|
||||
</div>
|
||||
<button onClick={() => setThemingOpen(false)} className="text-muted hover:text-ink"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<Group title="Color mode">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([{ v: "light", icon: Sun, label: "Light" }, { v: "dark", icon: Moon, label: "Dark" }, { v: "system", icon: Monitor, label: "System" }] as const).map((m) => (
|
||||
<button key={m.v} onClick={() => pickMode(m.v)} className={clsx("flex flex-col items-center gap-1.5 rounded-control border py-3 text-[12px]", mode === m.v ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover")}>
|
||||
<m.icon size={18} />
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Accent color">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ACCENTS.map((c) => (
|
||||
<button key={c} onClick={() => setThemeOverrides({ accent: c })} className="grid h-9 w-9 place-items-center rounded-full transition-all hover:scale-110" style={{ background: c, boxShadow: activeAccent === c ? "0 0 0 2px var(--c-bg), 0 0 0 4px " + c : undefined }}>
|
||||
{activeAccent === c && <Check size={16} className="text-black/70" />}
|
||||
</button>
|
||||
))}
|
||||
<label className="flex items-center gap-1.5 rounded-control border border-border px-2 text-[12px] text-muted">
|
||||
Hex
|
||||
<input type="color" value={/^#[0-9a-f]{6}$/i.test(activeAccent) ? activeAccent : "#FDA913"} onChange={(e) => setThemeOverrides({ accent: e.target.value })} className="h-7 w-8 cursor-pointer border-0 bg-transparent" />
|
||||
</label>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Corner radius">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{RADII.map((r) => (
|
||||
<button key={r.label} onClick={() => setThemeOverrides({ radiusCard: r.card, radiusControl: r.control })} className={clsx("border py-3 text-[12px]", theme.radiusCard === r.card ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover")} style={{ borderRadius: r.control }}>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group title="Brand gradient">
|
||||
<div className="flex gap-2">
|
||||
{GRADIENTS.map((g) => (
|
||||
<button key={g} onClick={() => setThemeOverrides({ gradient: g })} className={clsx("h-10 flex-1 rounded-control ring-2 ring-offset-2 ring-offset-surface", theme.gradient === g ? "ring-accent" : "ring-transparent")} style={{ background: g }} />
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<div className="rounded-control border border-border bg-panel p-3 text-[12px] text-muted">
|
||||
All tokens here are also settable at build time via <code className="text-accent">NEXT_PUBLIC_THEME_*</code> env vars. See <code className="text-accent">docs/THEMING.md</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<button onClick={() => { resetTheme(); setMode("dark"); }} className="focus-ring flex w-full items-center justify-center gap-2 rounded-control border border-border py-2.5 text-[13px] font-semibold hover:bg-hover">
|
||||
<RotateCcw size={15} /> Reset to defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Group({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="mb-2 text-[12px] font-bold uppercase tracking-wide text-dim">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts, dismissToast } = useUi();
|
||||
if (!toasts.length) return null;
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[70] flex flex-col gap-2">
|
||||
{toasts.map((t) => {
|
||||
const Icon = t.tone === "success" ? CheckCircle2 : t.tone === "danger" ? AlertTriangle : Info;
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={clsx(
|
||||
"flex items-center gap-2.5 rounded-control border bg-elevated px-3.5 py-2.5 text-[13px] shadow-pop animate-slide-up",
|
||||
t.tone === "success" ? "border-success/40" : t.tone === "danger" ? "border-danger/40" : "border-border",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} className={clsx(t.tone === "success" ? "text-success" : t.tone === "danger" ? "text-danger" : "text-info")} />
|
||||
<span className="max-w-xs">{t.message}</span>
|
||||
<button onClick={() => dismissToast(t.id)} className="ml-1 text-muted hover:text-ink"><X size={14} /></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user