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
+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>
);
}