first commit

This commit is contained in:
2026-07-17 21:48:37 +05:30
commit f85599dac5
124 changed files with 10775 additions and 0 deletions
+160
View File
@@ -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>
);
}
+433
View File
@@ -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 24pm 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>
);
}
+31
View File
@@ -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>
);
}