commit f85599dac51d30502fb1bcc563e4d4e2ee071059 Author: kaushik Date: Fri Jul 17 21:48:37 2026 +0530 first commit diff --git a/.claude/ONBOARDING.md b/.claude/ONBOARDING.md new file mode 100644 index 0000000..82b8088 --- /dev/null +++ b/.claude/ONBOARDING.md @@ -0,0 +1,76 @@ +# Onboarding — Messaging/Inbox/Support Web SDK + +Welcome. This is a **static demo** of a Slack-grade messaging + inbox + support product, +shipped as a **React SDK** (`@lynkd/messaging-inbox-sdk`) consumed by a **Next.js demo app** +(`apps/web`), styled to match the LynkedUp Pro dashboard. Read this first; deep dives are in +[`../docs`](../docs). + +## Run it + +```bash +pnpm install # Node >= 20, pnpm >= 8 +pnpm dev # http://localhost:4300 +``` + +No DB, no env setup needed — mock data ships in code and every feature works. + +## Mental model (read once, saves hours) + +1. **UI is headless-first.** Product logic lives in SDK hooks (`useMessages`, `useInbox`, + `useTickets`…). Components in `apps/web/components` are just presentation. +2. **The UI never calls a backend directly.** It calls `BffClient` → the **BFF** + (`apps/web/app/api/bff/**`) → the **mock store** (`apps/web/lib/mock/store.ts`). + This is the one seam you replace to go real. This is the **BFF pattern**. +3. **Config is resolved once on the server** (`apps/web/lib/config.ts` → `getServerConfig()`) + from `NEXT_PUBLIC_*` env, then handed to the client provider as plain JSON. That's how + feature flags + theme reach the browser without dynamic `process.env` access. +4. **Theme = CSS variables.** `theme.ts` writes `--c-*`, `--r-*`, `--font-*` onto ``. + Tailwind colors map to those vars (`tailwind.config.ts`). Light/dark + brand swap are + just different variable values. + +## Where things are + +| I want to… | Go to | +|---|---| +| Add / change a domain type | `packages/messaging-inbox-sdk/src/types.ts` | +| Add a feature flag | `packages/messaging-inbox-sdk/src/config.ts` (`FeatureFlags` + `defaultFlags`) | +| Change default theme | `packages/messaging-inbox-sdk/src/config.ts` (`darkTokens`/`lightTokens`/`defaultTheme`) | +| Add a data hook | `packages/messaging-inbox-sdk/src/hooks.ts` | +| Add a BFF endpoint | `apps/web/app/api/bff//route.ts` + a method on `store` + a method on `BffClient` | +| Change seed data | `apps/web/lib/mock/data.ts` | +| Edit the sidebar/header | `apps/web/components/nav/*` | +| Edit a message/composer/thread | `apps/web/components/message/*` | +| Edit the Support console | `apps/web/components/support/SupportView.tsx` | +| Add a page/route | `apps/web/app/(app)//page.tsx` | + +## Add a feature end-to-end (worked example) + +Say you want a `/pins` page listing pinned messages: + +1. **Flag** (optional): add `pinsPage: boolean` to `FeatureFlags` + `defaultFlags`. +2. **BFF**: add `GET /api/bff/pins` returning `store.messages.filter(m => m.isPinned)`. +3. **Client**: add `listPinned()` to `BffClient`. +4. **Hook**: add `usePinned()` in `hooks.ts`. +5. **UI**: `apps/web/app/(app)/pins/page.tsx` renders a list, gated by `useFeature("pinsPage")`. + +## Conventions + +- **Client vs server components:** anything using SDK hooks/`useUi` needs `"use client"`. + Pages are thin server components that render a client view component. +- **Styling:** Tailwind utility classes + the semantic tokens (`bg-panel`, `text-muted`, + `border-border`, `rounded-card`, `bg-accent`). Don't hard-code hex — use tokens so + light/dark + theming keep working. +- **Icons:** `lucide-react`. +- **Time:** render relative/clock time with `lib/format.ts` (client-only, avoids SSR drift). + +## Gotchas + +- The Next.js version is **pinned for the demo** and has a known CVE — bump before real use. +- The mock store is **in-memory**: it resets on server restart (that's expected). +- Message timestamps are seeded relative to server start, so "just now / 4m" stays sensible. + +## Next + +- [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) — the full picture +- [`../docs/IIOS_INTEGRATION.md`](../docs/IIOS_INTEGRATION.md) — make it real +- [`./memory/`](./memory) — durable project decisions (also loaded by Claude Code) diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 0000000..dfc566a --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,33 @@ +# `.claude/` — project-local knowledge + +This folder is **committed to the repo** on purpose. It holds knowledge that helps both human +developers and Claude Code sessions work in this codebase — kept **local to the project**, not +in anyone's global profile, so it travels with the code and everyone benefits. + +## Contents + +- **[`ONBOARDING.md`](ONBOARDING.md)** — start here. How to run it, the mental model, where + things live, and how to add a feature end-to-end. +- **[`memory/`](memory/)** — durable, one-fact-per-file notes about *why* things are the way + they are (decisions, the design system sampled from LynkedUp Pro, conventions, the IIOS + relationship). `memory/MEMORY.md` is the index. +- **`settings.json`** — project Claude Code settings (a permission allowlist for common dev + commands so you get fewer prompts). + +## Conventions for memory notes + +Each note in `memory/` is a single Markdown file with frontmatter: + +```markdown +--- +name: kebab-case-slug +description: one line used to judge relevance +metadata: + type: project | reference | feedback | user +--- + +The fact. Link related notes with [[their-slug]]. +``` + +When you learn something non-obvious about this project, add or update a note and add a line to +`memory/MEMORY.md`. Prefer updating an existing note over creating a duplicate. diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md new file mode 100644 index 0000000..b86c137 --- /dev/null +++ b/.claude/memory/MEMORY.md @@ -0,0 +1,12 @@ +# Project memory index + +Durable, project-local notes for this repo. Loaded by Claude Code sessions and useful for +any developer. One line per note; full content lives in the linked file. + +- [project-overview.md](project-overview.md) — what this repo is and its non-obvious goals +- [architecture-decisions.md](architecture-decisions.md) — SDK+BFF+workspace choices and why +- [design-system.md](design-system.md) — LynkedUp Pro tokens sampled from the live site +- [conventions.md](conventions.md) — coding conventions and gotchas specific to this repo +- [iios-relationship.md](iios-relationship.md) — how this maps to the IIOS service at D:\iios +- [qa-and-fixes.md](qa-and-fixes.md) — the deep-QA pass and what was fixed (reactions, WYSIWYG composer, dead controls, responsive, backend seam) +- [features-and-modes.md](features-and-modes.md) — full feature set, mock/iios modes, how to run IIOS live (no Docker), seed script diff --git a/.claude/memory/architecture-decisions.md b/.claude/memory/architecture-decisions.md new file mode 100644 index 0000000..e1a50b7 --- /dev/null +++ b/.claude/memory/architecture-decisions.md @@ -0,0 +1,39 @@ +--- +name: architecture-decisions +description: Key architecture choices for the SDK + BFF + workspace and the reasons +metadata: + type: project +--- + +**pnpm workspace, two packages.** `packages/messaging-inbox-sdk` (the shareable SDK) and +`apps/web` (the demo). The app depends on the SDK via `workspace:*` and Next `transpilePackages` +transpiles the SDK's TS source directly (SDK `main`/`types` point at `src/index.ts`, no build +step). This mirrors the IIOS layout (packages + apps) and keeps the SDK genuinely reusable. + +**BFF pattern (explicit client requirement).** The UI/SDK only ever calls `BffClient`, which +hits `/api/bff/**` (Next route handlers). Every route calls `getBackend()` (a formal `Backend` +interface in `apps/web/lib/backend/`), never the store directly. `BFF_BACKEND` env picks the impl: +`mock` (default, wraps `lib/mock/store.ts`) or `iios` (`lib/backend/iios.ts` → real IIOS via +`lib/iios/client.ts` + `map.ts`). Messages/inbox/tickets are wired to IIOS; bootstrap/channels/ +reactions/search/notifications delegate to mock until IIOS exposes those endpoints. Flipping to +live IIOS is one env var (needs the IIOS service + Docker/Postgres running — see +`docs/IIOS_INTEGRATION.md`). This is the single swap seam; the SDK and components never change. + +**Config resolved on the server.** `getServerConfig()` runs in the root layout (server +component), reads `NEXT_PUBLIC_*` from `process.env`, returns a plain `SdkConfig`, and passes +it to the client ``. Avoids the Next.js gotcha that dynamic +`process.env[key]` doesn't work in the browser bundle. Theme vars are also inlined into +`` on first paint to prevent a flash. + +**Theme = CSS variables + Tailwind mapping.** All Tailwind colors resolve to `--c-*` vars set +on `` at runtime by the provider. Light/dark and brand re-skin are just different +variable values; nothing is hard-coded. `darkMode: 'class'`. + +**Feature flags are data, not conditionals scattered around.** One `FeatureFlags` interface; +`NEXT_PUBLIC_FEATURE_` overrides each. Components call `useFeature("x")`. + +**Why not build the NestJS/DB backend?** The ask was a static demo with everything working. +A NestJS BFF + Postgres adds setup cost with no demo benefit; it's documented as the +production path instead (`docs/IIOS_INTEGRATION.md`, `docs/BFF.md`). + +Related: [[design-system]], [[conventions]], [[iios-relationship]]. diff --git a/.claude/memory/conventions.md b/.claude/memory/conventions.md new file mode 100644 index 0000000..8b21a57 --- /dev/null +++ b/.claude/memory/conventions.md @@ -0,0 +1,23 @@ +--- +name: conventions +description: Repo-specific coding conventions and gotchas +metadata: + type: project +--- + +- **Client boundary:** any component using SDK hooks or `useUi` must start with `"use client"`. + Route `page.tsx` files stay thin server components that render a client view. +- **Never hard-code colors/radii.** Use semantic Tailwind tokens (`bg-panel`, `text-muted`, + `border-border`, `bg-accent`, `rounded-card`, `rounded-control`) so light/dark + live theming + keep working. Tokens are defined in `apps/web/tailwind.config.ts` → CSS vars. +- **Time formatting** is client-only via `apps/web/lib/format.ts` to avoid SSR hydration drift; + data (messages/tickets) is fetched client-side through the BFF, not server-rendered. +- **BFF params are async in Next 15:** handlers use `{ params }: { params: Promise<…> }` and + `await params`. +- **Next config** sets `typescript.ignoreBuildErrors` + `eslint.ignoreDuringBuilds` so the demo + runs even with strict-mode nits; run `pnpm typecheck` for a clean check. +- **No `packageManager` pin** in root `package.json` on purpose — a pin triggered corepack to + fetch a non-installed pnpm version and fail offline. Use the local pnpm. +- **Mock store is in-memory** and resets on restart; persistence isn't a goal for the demo. +- Adding a data feature touches 4 files in order: `store.ts` → `bff route` → `client.ts` → + `hooks.ts` → then the component. See [[architecture-decisions]]. diff --git a/.claude/memory/design-system.md b/.claude/memory/design-system.md new file mode 100644 index 0000000..b7d68d5 --- /dev/null +++ b/.claude/memory/design-system.md @@ -0,0 +1,28 @@ +--- +name: design-system +description: LynkedUp Pro design tokens sampled from the live dashboard (defaults for this app) +metadata: + type: reference +--- + +Tokens sampled live from `https://lynkeduppro-crmnew.vercel.app/dashboard` (computed styles), +used as the **default theme**. All are overridable via `NEXT_PUBLIC_THEME_*` or the in-app +Appearance panel. Source of truth in code: `packages/messaging-inbox-sdk/src/config.ts`. + +**Accent:** `#FDA913` (golden amber). Solid usage ~0.92 opacity; soft tints at 0.12 +(`rgba(253,169,19,0.12)`). + +**Dark surfaces:** app bg `#060608`, sidebar `#08080b`, panel/card `#0e0e13`, elevated +`#15151c`. Borders `rgba(255,255,255,0.08)`. Text: ink `#ededf1`, muted `#8c8c94`, dim `#63636b`. + +**Light surfaces:** app bg `#f6f7f9`, surface/panel `#ffffff`, borders `rgba(9,10,14,0.10)`, +ink `#14151a`, muted `#5c6069`. Accent nudged to `#e8920a` for contrast on white. + +**Radii:** cards **20px**, controls/buttons **10px**, pills 999px. +**Font:** Inter (system-ui fallback). **Layout:** dark sectioned sidebar + user card footer; +top header with search, theme toggle, notifications, user menu (matches the reference exactly). + +**Brand gradient:** `linear-gradient(135deg,#FDA913,#ff7a3d,#ff4d6d)`. + +If the reference site restyles, re-sample with the browser devtools/`getComputedStyle` and +update the token blocks in `config.ts` + defaults in `app/globals.css`. diff --git a/.claude/memory/features-and-modes.md b/.claude/memory/features-and-modes.md new file mode 100644 index 0000000..f176182 --- /dev/null +++ b/.claude/memory/features-and-modes.md @@ -0,0 +1,76 @@ +--- +name: features-and-modes +description: The full Slack-grade feature set, the two backend modes, and how to run IIOS live +metadata: + type: project +--- + +**Two backend modes** (env `BFF_BACKEND` in `apps/web/.env.local`, read server-side): +- `mock` (default) — rich in-memory JSON workspace; every feature works & persists in-process. + Best for demos. Data in `apps/web/lib/mock/data.ts` + `store.ts`. +- `iios` — proxies the live IIOS NestJS service (real Postgres). Seeded via + `node scripts/seed-iios.mjs` which writes `apps/web/lib/iios/seed-manifest.json` (real thread + ids → channel directory). Bootstrap/channels come from the manifest; messages/inbox/tickets + from IIOS live. Verified: 5 channels + 6 users + messages render through the app BFF. + +**Running IIOS live (no Docker):** native PostgreSQL 18, role `iios`/`iios`, db `iios` on 5432 +(a separate 5434 cluster crashes under the sandbox's process limits). `packages/iios-service/.env` +→ `DATABASE_URL=postgresql://iios:iios@localhost:5432/iios?schema=public`, `IIOS_DEV_TOKENS=1`. +Build: allow prisma/esbuild in `D:\iios\pnpm-workspace.yaml`, `pnpm install && pnpm -r build`, +prisma `generate` + `migrate deploy`, `node packages/iios-service/dist/main.js`. Redis NOT needed +(in-memory socket adapter without `REDIS_URL`). Full steps: `docs/IIOS_INTEGRATION.md`. + +**Slack-grade features (all working, verified via Playwright):** channels/DMs/threads; +WYSIWYG composer (bold/italic/code/lists); **@mention + #channel autocomplete** (typing pops a +picker; rendered as blue chips by `lib/rich-text.tsx`); **full searchable emoji picker** (168, +categorized, `lib/emoji.ts`) in composer + reactions + status; **`:shortcode:` → emoji** (`:tick:` +→ ✅); reactions (hover picker fixed); pins; **save** (+ Saved page); **message edit + delete**; +**attachment upload** (file → data-URL preview → send → image preview + Open/Download); scheduled +send; huddles; **status** (emoji picker + clear-after incl. custom datetime); **avatar change** +(color swatches + image upload); **copy message link + jump-to-message** (scroll); **thread "also +send to channel"**; command palette (⌘K, arrow-nav); notifications; sidebar collapse; inbox; +Support Center; light/dark + live theming. + +**AI + translation (Gemini, server-side only):** `apps/web/lib/gemini.ts` calls Gemini +(`?key=` auth, model `gemini-2.0-flash`) and returns a discriminated result so 429/errors degrade +gracefully. Two BFF routes: `POST /api/bff/ai` (assistant) and `POST /api/bff/translate`. Client +methods `client.ai(prompt, context?)` / `client.translate(text, lang)` use `reqSoft` (does NOT +throw on non-2xx — returns the `{ok:false,error}` body so the UI shows a clean message). UI: +`AiPanel` right-drawer (open via `openAi({context})` from a message's ✨ toolbar button; quick +actions Summarize / What-should-I-reply / Improve / Explain + free prompt); per-message Translate +(`LanguageMenu` searchable dropdown → inline translated card w/ "Show original"); whole-chat +Translate (header ✈ button → banner + `autoTranslateTo` prop on every MessageItem); composer +translate-before-send (✈ toolbar btn → pick language → preview popover → "Use translation" replaces +draft). Flags `aiAssist` + `translation`. **KEY is server-side only** (`GEMINI_API_KEY`, no +`NEXT_PUBLIC_`; verified not in client bundle). The provided free-tier key is **quota-exhausted +(429)** — wiring is proven end-to-end; real output needs a fresh/paid key. + +**More Slack features added:** presence toggle Active/Away/DND (ProfileDrawer → Availability → +`client.updateMe({presence})`); typing indicator moved to a fixed bar ABOVE the composer (incl. +DMs); channel create — **public adds the whole team automatically, private shows a multi-user +selector**; invite modal turns emails into **chips** on `,`/Enter; thread composer is the FULL +editor, thread replies have edit/delete; editor lists: Enter=new bullet (doesn't send), Tab/Shift- +Tab indent/outdent; cross-view live sync via `notifyChannelChanged` mutation bus (delete + also- +send reflect without refresh); `threadRootId` "Replied to a thread → open" chip; copy-message +toolbar button. + +**Jump-to-message flash (working in a production build):** `pnpm --filter @lynkd/web build` + +`start`; StrictMode double-mount is gone so the WAAPI amber `animate()` on `[data-mid-body]` fires. +ChannelView reads `?msg` via `useSearchParams()` (`jumpMsg`) and the effect is keyed on +`[hasMessages, channelId, jumpMsg]` — **gate on the boolean `hasMessages = messages.length>0`, NOT +the raw count**, or every send/incoming message re-fires the jump and yanks the viewport back to +the old message. As soon as the target resolves (or after ~2.5s of not finding it) the effect +strips `?msg` with `router.replace(pathname,{scroll:false})` so it's one-shot AND re-clicking the +same result re-fires (identical-URL push is a no-op otherwise). **Search + notification results now +deep-link with `?msg=`** (CommandPalette.go, NotificationsPanel; Notification/mock data gained an +optional `messageId`; the Liam-mention notif/inbox point at `m_21` in `d_liam`, not `m_23` which is +`d_ava`). A **search hit on a thread reply** (msg with `parentId`) opens the thread panel +(`openThread`) instead of a `?msg=` jump that would never find a main-list node — `SearchResult` +gained `parentId`. In dev the flash is unreliable (node churn); the scroll always works. + +**Editor lists:** inside a list BOTH Enter (native) and Shift+Enter (`execCommand insertParagraph`) +make the next bullet/number; Tab/Shift-Tab indent 3 levels. Outside a list Enter sends, Shift+Enter +soft-newlines. **Composer has a visible amber "✨ AI" button** (`flags.aiAssist`) → `openAi()`. + +**Gotchas:** `reactStrictMode:false` in next.config. Bump `__lynkdStore_vN` in `store.ts` after +adding store methods (HMR singleton). See [[qa-and-fixes]], [[iios-relationship]], [[architecture-decisions]]. diff --git a/.claude/memory/iios-relationship.md b/.claude/memory/iios-relationship.md new file mode 100644 index 0000000..ddb41e1 --- /dev/null +++ b/.claude/memory/iios-relationship.md @@ -0,0 +1,32 @@ +--- +name: iios-relationship +description: How this demo relates to the IIOS service/SDKs at D:\iios (reference only) +metadata: + type: reference +--- + +The **IIOS** monorepo lives at `D:\iios` — a NestJS service (`packages/iios-service`, port +3200) plus React SDKs (`iios-message-web`, `iios-inbox-web`, `iios-support-web`, +`iios-community-web`, `iios-ai-web`, `iios-meeting-web`) over a low-level `iios-kernel-client`. +It treats every interaction (message, ticket, route decision, AI proposal, meeting) as one +governed `Interaction` behind fail-closed policy/consent gates. + +**This repo is standalone and does NOT import or call IIOS.** IIOS was used only to model the +domain vocabulary and the surfaces to build. Deliberate parallels so a later swap is easy: + +| This repo | IIOS analogue | +|---|---| +| `Message` / `Channel` / thread | `Message` / `Conversation`(`IiosThread`) / parent-child interactions | +| `InboxItem` (NEEDS_REPLY, MENTION…) | inbox projector items (`iios-inbox-web`) | +| `Ticket` (state/priority, escalate, callback) | `iios-support-web` tickets/escalate/callbacks | +| `BffClient` → `/api/bff/**` | `RestClient` → `iios-service` `/v1/**` | +| feature flags / theming | app-level concerns (IIOS is backend-governed) | + +**Live wiring is built AND proven.** The BFF uses a `Backend` interface (`apps/web/lib/backend/`); +`BFF_BACKEND=iios` swaps in `iios.ts` → real `iios-service` via `lib/iios/client.ts` (dev-token +auth) + `map.ts` (shape mappers). This was run end-to-end: IIOS service on :3200 against a native +Postgres (role `iios`/`iios`, db `iios` on **:5432** — no Docker needed; a separate :5434 cluster +crashed under the sandbox's process/shared-memory limits, so the existing service on 5432 was used), +9 migrations applied, an ingested message read back through this app's BFF. Default stays mock +(rich demo data; fresh IIOS DB is empty and has no channel-directory endpoint). Exact steps: +`docs/IIOS_INTEGRATION.md`. Related: [[architecture-decisions]], [[qa-and-fixes]]. diff --git a/.claude/memory/project-overview.md b/.claude/memory/project-overview.md new file mode 100644 index 0000000..559f00e --- /dev/null +++ b/.claude/memory/project-overview.md @@ -0,0 +1,31 @@ +--- +name: project-overview +description: What this repo is, who it's for, and the non-obvious constraints +metadata: + type: project +--- + +This repo (`message-inbox-web-frontend-sdk`) is a **static frontend demo** of a Slack-grade +team-messaging + inbox + support product, delivered as a reusable **React SDK** +(`@lynkd/messaging-inbox-sdk`) plus a **Next.js demo app** (`apps/web`). + +**Non-obvious goals (from the requesting stakeholder):** +- It must **look like** the LynkedUp Pro CRM dashboard (dark + amber accent `#FDA913`), and + support **light AND dark** modes. The "Support Center" page especially should feel native + to that CRM. +- It must match **Slack's feature depth** (channels, DMs, threads, reactions, pins, mentions, + presence, huddles, rich composer, scheduled send, search, saved) — as a **static** demo + first (mock data, no real backend). +- **Every feature toggled by an env flag** (`NEXT_PUBLIC_FEATURE_*`), and the **UI fully + themeable** (accent/radius/gradient) via env or a live in-app panel, defaulting to the + LynkedUp look. +- Must use the **BFF pattern** (client requested this explicitly). Here the BFF = Next.js + route handlers under `app/api/bff/**`. +- Stack the stakeholder named: Next.js (frontend), NestJS (backend), Postgres/Supabase. + For the static demo we use Next.js + the Next route-handler BFF only; NestJS/DB are a + documented later swap, **not** built. +- The real IIOS SDK/service lives at `D:\iios` and was used **only as reference** — this app + is deliberately standalone and does not import or call it. See [[iios-relationship]]. + +Deadline context: originally requested as a ~2-hour demo. Prioritize a working, good-looking +demo over completeness. See [[architecture-decisions]] and [[design-system]]. diff --git a/.claude/memory/qa-and-fixes.md b/.claude/memory/qa-and-fixes.md new file mode 100644 index 0000000..6c5d24e --- /dev/null +++ b/.claude/memory/qa-and-fixes.md @@ -0,0 +1,31 @@ +--- +name: qa-and-fixes +description: The deep-QA pass and what was fixed (reactions, composer, dead controls, responsive) +metadata: + type: project +--- + +A deep QA pass (a 5-agent static audit → 68 findings, plus live Playwright driving) fixed the +issues the stakeholder reported and more. All verified in-browser + typecheck clean. + +**Headline fixes:** +- **Reactions** — the hover toolbar's emoji picker used to unmount on `mouseleave` (it floats in a + gap outside the row), so reacting was impossible for messages without existing reactions. Fixed: + toolbar stays while the picker/menu is open; picker also works in the thread panel (which had + no handlers wired). `useThread` now exposes react/pin/save. +- **Composer** — was a plain textarea inserting literal `**` markers. Now a **contentEditable + WYSIWYG** (`Composer.tsx`) where Bold/Italic/Strike/Code/lists format live and serialize to + markdown on send; `lib/rich-text.tsx` renders it (added `~~strike~~` + `[label](url)`). +- **Dead controls → real** — create-channel (real BFF create + navigate + appears in sidebar), + new-message, invite, add-workspace, callback, channel-details are modals (`overlays/Modals.tsx` + + `ui/Modal.tsx`); huddle mute/video/share toggle + live timer; profile Message/Huddle; palette + person/file; workspace switcher; a toast system (`Toaster.tsx`, `useUi().toast`). +- **Layout/scroll "not fit"** — responsive down to ~400px: sidebar drawer closes on nav; thread + panel is a mobile overlay; Support master-detail stacks (list ↔ detail + back button); no + horizontal overflow. +- **Support state** — list + detail + stats now share one `useTickets` source, so Resolve/reply + update everywhere at once. +- **Theme flash** — pre-paint script in `layout.tsx` + light tokens under `html:not(.dark)` in + `globals.css`; ThemingPanel System mode works (matchMedia). + +See [[architecture-decisions]] and [[conventions]]. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..6176fce --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(pnpm install)", + "Bash(pnpm dev)", + "Bash(pnpm build)", + "Bash(pnpm typecheck)", + "Bash(pnpm --filter *)", + "Bash(pnpm exec next *)", + "Read", + "Grep", + "Glob", + "Bash(curl -s -o /dev/null -w \"home:%{http_code} \" http://localhost:4300/)", + "Bash(curl -s -o /dev/null -w \"channel:%{http_code} \" http://localhost:4300/c/c_general)", + "Bash(curl -s -o /dev/null -w \"inbox:%{http_code} \" http://localhost:4300/inbox)", + "Bash(curl -s -o /dev/null -w \"support:%{http_code} \" http://localhost:4300/support)", + "Bash(curl -s -o /dev/null -w \"bootstrap-api:%{http_code}\" http://localhost:4300/api/bff/bootstrap)", + "PowerShell($ps = \\(Get-NetTCPConnection -LocalPort 4300 -State Listen -ErrorAction SilentlyContinue\\).OwningProcess | Select-Object -Unique; foreach \\($procId in $ps\\) { Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 1; if \\(Get-NetTCPConnection -LocalPort 4300 -State Listen -ErrorAction SilentlyContinue\\) { \"still listening\" } else { \"4300 free\" })", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:4300/c/c_general)", + "Bash(cd d:\\\\\\\\message-inbox-web-frontend-sdk)", + "Bash(rm -f .playwright-mcp/demo-*.png)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69f2957 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +.next/ +dist/ +out/ +.turbo/ +*.log +.DS_Store +.env +.env.local +.env*.local +.playwright-mcp/ +coverage/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..61768ad --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +shamefully-hoist=false +strict-peer-dependencies=false +auto-install-peers=true diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f14a964 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +Guidance for Claude Code (and humans) working in this repo. + +## What this is + +A **static demo** (mock data, no real backend) of a Slack-grade **messaging + inbox + support** +product, shipped as a reusable **React SDK** + a **Next.js demo app**, styled to match the +LynkedUp Pro dashboard (dark + amber `#FDA913`, plus full light mode). Built on the **BFF pattern**: +the UI only ever calls its own BFF (Next route handlers over an in-memory store), so swapping to a +real backend (IIOS / NestJS / Supabase) touches only the BFF. + +## Commands + +```bash +pnpm install # Node >= 20, pnpm >= 8 +pnpm dev # dev server → http://localhost:4300 +pnpm build # production build (all packages) +pnpm typecheck # type-check SDK + app +pnpm --filter @lynkd/web dev # run just the web app +``` + +There is **no `packageManager` pin** on purpose (a pin made corepack try to fetch a pnpm build +and fail offline). If corepack nags, run `corepack disable` once. `sharp`'s build is intentionally +skipped (`allowBuilds: sharp: false` in `pnpm-workspace.yaml`) — not needed for the static demo. + +**Two backend modes** — `BFF_BACKEND` in `apps/web/.env.local`: +- `mock` (default): rich in-memory JSON workspace, everything works. Best for demos. +- `iios`: proxies the live IIOS service. Run IIOS + `node scripts/seed-iios.mjs` to populate it, + then flip the flag. Full steps in `docs/IIOS_INTEGRATION.md`. Redis not required. + +`reactStrictMode` is **off** (next.config) so dev matches prod render behaviour (avoids the +double-mount that broke imperative DOM effects). + +## Layout + +``` +packages/messaging-inbox-sdk/src/ the SDK (@lynkd/messaging-inbox-sdk) + types.ts domain model config.ts feature flags + theme tokens + env resolution + client.ts BffClient (only fetch seam) theme.ts CSS-var applier + provider.tsx + useSdk/useTheme/useFeature + hooks.ts useMessages/useThread/useInbox/useTickets/useSearch/useNotifications/… +apps/web/ + app/layout.tsx server: resolves config, no-flash theme script + app/(app)/… shell routes: c/[id], inbox, support/[id], threads, saved, … + app/api/bff/** the BFF (route handlers) — 1:1 with BffClient methods + components/nav|message|inbox|support|overlays|ui presentation + lib/mock/{data,store}.ts seed data + in-memory mutable store (the swap seam) + lib/{config,ui-state,format,rich-text}.ts +``` + +## Conventions + +- Components using SDK hooks or `useUi` need `"use client"`. Route `page.tsx` are thin server + components that render a client view. +- **Never hard-code colors/radii.** Use semantic Tailwind tokens (`bg-panel`, `text-muted`, + `border-border`, `bg-accent`, `rounded-card`, `rounded-control`) → they map to CSS vars, which is + what keeps light/dark + live theming working. +- **Feature flags** are data: add to `FeatureFlags`/`defaultFlags` in `config.ts`; env override is + automatic (`NEXT_PUBLIC_FEATURE_`); read with `useFeature("x")`. +- Overlays/modals/toasts live in `lib/ui-state.tsx` (`useUi`): `openModal`, `toast`, `openThread`, + `openProfile`, `startHuddle`, etc. Escape closes the top-most surface. +- The composer is a **contentEditable WYSIWYG** that serializes to markdown; `lib/rich-text.tsx` + renders that markdown. Keep the two in sync if you add a marker. +- Adding a data feature touches, in order: `lib/mock/store.ts` → `app/api/bff/**/route.ts` → + `client.ts` → `hooks.ts` → the component. + +## Gotchas + +- The mock store is a `globalThis` singleton to survive HMR. **If you add/rename a store method and + get "X is not a function" at runtime, bump the cache key** (`__lynkdStore_vN`) in + `lib/mock/store.ts` or restart `pnpm dev`. +- Next 15 route-handler `params` are async: `{ params }: { params: Promise<…> }` + `await params`. +- Next.js is pinned at a demo version with a known CVE — bump before any real deploy. +- BFF params/mock timestamps are relative to server start; the store resets on restart. + +## Verifying changes + +Drive the real app (Playwright MCP or a browser) — don't rely on types alone. Quick checks: +send a message, react via hover, create a channel, resolve a ticket, toggle theme, resize to +~400px. See `docs/` for deep dives and `.claude/` for project memory. + +## Docs + +`README.md` · `docs/SETUP_LOCAL.md` · `docs/ARCHITECTURE.md` · `docs/BFF.md` · `docs/SDK.md` · +`docs/THEMING.md` · `docs/FEATURE_FLAGS.md` · `docs/IIOS_INTEGRATION.md` · `.claude/ONBOARDING.md` diff --git a/README.md b/README.md new file mode 100644 index 0000000..5924588 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# Messaging · Inbox · Support — Web Frontend SDK + Demo + +A **Slack-grade** team-messaging experience delivered as a reusable **React SDK** plus a +**Next.js demo app**, styled to match the [LynkedUp Pro](https://lynkeduppro-crmnew.vercel.app/dashboard) +dashboard (dark + amber, with full **light/dark** support). + +Everything runs as a **static demo** — no database, no external services. A **BFF** +(Backend-For-Frontend) layer serves realistic mock data, so every feature *works* +(send, react, thread, resolve tickets, search, theme…) with nothing to stand up. + +> **Why an SDK + a BFF?** The product surfaces (chat, inbox, support) are headless +> React hooks in `@lynkd/messaging-inbox-sdk`. They never talk to a backend directly — +> they call *your* BFF. Today the BFF is a set of Next.js route handlers returning mock +> data; tomorrow the same routes proxy the real IIOS service (or NestJS, Supabase, etc.) +> with **zero changes to the UI**. See [`docs/IIOS_INTEGRATION.md`](docs/IIOS_INTEGRATION.md). + +--- + +## 1. Quick start + +```bash +# from the repo root +pnpm install +pnpm dev +# → http://localhost:4300 +``` + +That's it. The app boots into `#general` with a full workspace of mock data. + +> **Requirements:** Node ≥ 20, pnpm ≥ 8. If `pnpm dev` complains about a pinned +> package manager, run `corepack disable` once (this repo intentionally has **no** +> `packageManager` pin so your local pnpm is used). +> +> **Full step-by-step local setup, troubleshooting, and a demo tour:** +> [`docs/SETUP_LOCAL.md`](docs/SETUP_LOCAL.md). + +**What to click in a demo (2-minute tour):** +1. **Send a message** in `#general` — it appears instantly (BFF persists it in-memory). +2. **Hover a message** → react 🔥, **reply in thread** (opens the right panel), pin, save. +3. **⌘K / Ctrl+K** → command palette; search "sync", "ava", "general". +4. **Inbox** (sidebar) → a unified "needs-reply / mentions / reactions" queue; mark done / snooze. +5. **Support Center** (rail) → tickets master-detail; open **TCK-1042** (SLA breached), reply, resolve. +6. **Theme toggle** (top-right ☀️/🌙) and the **Appearance panel** (⚙️ next to your name) → + switch accent, radius, gradient live. +7. Start a **huddle** (video icon in a channel header) → floating call bar. + +--- + +## 2. Repository layout + +``` +message-inbox-web-frontend-sdk/ +├─ packages/ +│ └─ messaging-inbox-sdk/ # ← the SDK (publishable): @lynkd/messaging-inbox-sdk +│ └─ src/ +│ ├─ types.ts # domain model (User, Channel, Message, InboxItem, Ticket…) +│ ├─ config.ts # feature flags + theme tokens + env resolution +│ ├─ theme.ts # CSS-variable applier (light/dark, runtime) +│ ├─ client.ts # BffClient — typed fetch wrapper (the only network seam) +│ ├─ provider.tsx # + useSdk/useTheme/useFeature +│ ├─ hooks.ts # useChannels/useMessages/useInbox/useTickets/useSearch… +│ └─ index.ts # public API barrel +│ +├─ apps/ +│ └─ web/ # ← the Next.js demo (@lynkd/web) +│ ├─ app/ +│ │ ├─ layout.tsx # resolves config on the server, injects theme, no-flash +│ │ ├─ (app)/ # the authenticated shell (rail + sidebar + header) +│ │ │ ├─ c/[id]/ # channel & DM view +│ │ │ ├─ inbox/ # inbox queue +│ │ │ └─ support/[id]/ # Support Center (master-detail) +│ │ └─ api/bff/** # ← the BFF: route handlers over the mock store +│ ├─ components/ # nav, message, inbox, support, overlays, ui primitives +│ └─ lib/ +│ ├─ mock/data.ts # the seed dataset (users, channels, messages, tickets…) +│ ├─ mock/store.ts # in-memory mutable store (swap for real backend) +│ ├─ config.ts # getServerConfig() — env → SdkConfig +│ └─ ui-state.tsx # app UI state (thread panel, palette, drawers, huddle) +│ +├─ docs/ # deep-dive docs (start with ARCHITECTURE.md) +└─ .claude/ # project-local memory + notes for future devs & Claude Code +``` + +--- + +## 3. The three product surfaces + +| Surface | Route | What it is | Slack analogue | +|---|---|---|---| +| **Messaging** | `/c/[id]` | Channels, private channels, DMs, group DMs, threads, reactions, pins, mentions, presence, typing, huddles, rich composer, scheduled send | Channels + DMs | +| **Inbox** | `/inbox` | One queue for everything needing you — needs-reply, mentions, reactions, thread replies, saved, support & meeting follow-ups; snooze / done / archive | Activity + Later | +| **Support Center** | `/support` | Ticket console: list + filters + SLA, ticket detail with conversation, escalate / resolve / callback, agent availability | Intercom-style support | + +All three are built on the **same kernel** (the SDK + BFF), mirroring the IIOS idea that +*everything is an interaction* behind the same gates. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + +--- + +## 4. Feature flags (turn anything on/off) + +Every feature is gated by a `NEXT_PUBLIC_FEATURE_*` env var (defaults **on**). +Copy `apps/web/.env.example` → `apps/web/.env.local` and flip flags: + +```bash +NEXT_PUBLIC_FEATURE_HUDDLES="false" # hides huddle buttons + bar +NEXT_PUBLIC_FEATURE_SUPPORT="false" # removes the whole Support surface +NEXT_PUBLIC_FEATURE_SCHEDULED_SEND="false" +``` + +Full list + behaviour: [`docs/FEATURE_FLAGS.md`](docs/FEATURE_FLAGS.md). + +--- + +## 5. Theming (match any brand, light + dark) + +The default theme is sampled from LynkedUp Pro (accent `#FDA913`, dark surfaces, 20px cards). +Restyle via env **or** the live **Appearance** panel in-app: + +```bash +NEXT_PUBLIC_THEME_MODE="light" +NEXT_PUBLIC_THEME_ACCENT="#5b9dff" +NEXT_PUBLIC_THEME_RADIUS_CARD="28px" +``` + +Every color/radius/gradient is a CSS variable, so a brand swap is one env change. +Full token reference: [`docs/THEMING.md`](docs/THEMING.md). + +--- + +## 6. Using the SDK in your own app + +```tsx +import { + MessagingInboxProvider, + useChannels, + useMessages, +} from "@lynkd/messaging-inbox-sdk"; + + + +; + +function YourChat() { + const { channels } = useChannels(); + const { messages, send } = useMessages(channels[0]?.id ?? null); + // …render your own UI, or reuse the demo components +} +``` + +API reference + composition patterns: [`docs/SDK.md`](docs/SDK.md). + +--- + +## 7. Going to a real backend + +The UI only ever calls the BFF. To make it real, edit the handlers in +`apps/web/app/api/bff/**` (or point `NEXT_PUBLIC_BFF_BASE_URL` at a standalone NestJS BFF) +to call the **IIOS service** instead of the mock store. The request/response shapes already +line up with the IIOS API. Step-by-step: [`docs/IIOS_INTEGRATION.md`](docs/IIOS_INTEGRATION.md) +and [`docs/BFF.md`](docs/BFF.md). + +--- + +## 8. Scripts + +| Command | What | +|---|---| +| `pnpm dev` | Run the demo at :4300 | +| `pnpm build` | Production build of every package | +| `pnpm typecheck` | Type-check the SDK + app | +| `pnpm start` | Serve the production build | + +--- + +## 9. Docs index + +- [`docs/SETUP_LOCAL.md`](docs/SETUP_LOCAL.md) — full local setup, config, demo tour, troubleshooting +- [`CLAUDE.md`](CLAUDE.md) — project guide for Claude Code / contributors (commands, conventions, gotchas) +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — layers, data flow, BFF pattern, why-this +- [`docs/FEATURE_FLAGS.md`](docs/FEATURE_FLAGS.md) — every flag and what it toggles +- [`docs/THEMING.md`](docs/THEMING.md) — token reference, light/dark, brand swap +- [`docs/SDK.md`](docs/SDK.md) — hooks + provider API, composition +- [`docs/BFF.md`](docs/BFF.md) — BFF contract + how the mock store works +- [`docs/IIOS_INTEGRATION.md`](docs/IIOS_INTEGRATION.md) — wire it to the real IIOS service +- [`.claude/ONBOARDING.md`](.claude/ONBOARDING.md) — start-here for new developers + +> **Status:** static demo (mock data). Not production-hardened. The Next.js version is +> pinned for the demo; upgrade to a patched release before any real deployment. diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..782067f --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,82 @@ +# ───────────────────────────────────────────────────────────────────────── +# @lynkd/web — environment configuration +# Copy to .env.local. Everything is optional; sensible defaults ship in code. +# ───────────────────────────────────────────────────────────────────────── + +# Branding +NEXT_PUBLIC_BRAND_NAME="LynkedUp Pro" + +# BFF base URL. Empty = same-origin Next route handlers (/api/bff). +# Point this at a standalone NestJS BFF in production if you split it out. +NEXT_PUBLIC_BFF_BASE_URL="" + +# ── BFF backend selection (server-only, NOT exposed to the browser) ────────── +# "mock" (default) = in-memory demo store. "iios" = proxy the real IIOS service. +BFF_BACKEND="mock" +# Used only when BFF_BACKEND=iios (IIOS service must be running, dev tokens on): +IIOS_URL="http://localhost:3200" +IIOS_APP_ID="portal-demo" +IIOS_USER_ID="james" +IIOS_ORG_ID="org_A" + +# Mock realtime poll interval (ms) +NEXT_PUBLIC_POLL_MS="4000" + +# ── Gemini (powers the AI assistant + translation) ────────────────────────── +# SERVER-ONLY — never prefix with NEXT_PUBLIC_ (that would ship the key to the +# browser). Calls go through /api/bff/ai and /api/bff/translate. Without a key, +# those features surface a graceful "not configured" message. +GEMINI_API_KEY="" +GEMINI_MODEL="gemini-2.0-flash" + +# ── Theme ──────────────────────────────────────────────────────────────── +# mode: light | dark | system +NEXT_PUBLIC_THEME_MODE="dark" +# Any CSS color. Overrides the accent in both light & dark. +NEXT_PUBLIC_THEME_ACCENT="#FDA913" +NEXT_PUBLIC_THEME_ACCENT_FG="#1a1205" +NEXT_PUBLIC_THEME_RADIUS_CARD="20px" +NEXT_PUBLIC_THEME_RADIUS_CONTROL="10px" +NEXT_PUBLIC_THEME_FONT_SANS="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif" +NEXT_PUBLIC_THEME_GRADIENT="linear-gradient(135deg, #FDA913 0%, #ff7a3d 55%, #ff4d6d 100%)" + +# ── Feature flags ────────────────────────────────────────────────────────── +# true/false (also accepts 1/0, on/off, yes/no). Any omitted flag defaults ON. +# Top-level surfaces +NEXT_PUBLIC_FEATURE_MESSAGING="true" +NEXT_PUBLIC_FEATURE_INBOX="true" +NEXT_PUBLIC_FEATURE_SUPPORT="true" +NEXT_PUBLIC_FEATURE_ACTIVITY="true" +# Messaging sub-features +NEXT_PUBLIC_FEATURE_THREADS="true" +NEXT_PUBLIC_FEATURE_REACTIONS="true" +NEXT_PUBLIC_FEATURE_MENTIONS="true" +NEXT_PUBLIC_FEATURE_PINS="true" +NEXT_PUBLIC_FEATURE_SAVED_ITEMS="true" +NEXT_PUBLIC_FEATURE_FILE_UPLOAD="true" +NEXT_PUBLIC_FEATURE_RICH_COMPOSER="true" +NEXT_PUBLIC_FEATURE_SLASH_COMMANDS="true" +NEXT_PUBLIC_FEATURE_SCHEDULED_SEND="true" +NEXT_PUBLIC_FEATURE_DRAFTS="true" +NEXT_PUBLIC_FEATURE_HUDDLES="true" +NEXT_PUBLIC_FEATURE_PRESENCE="true" +NEXT_PUBLIC_FEATURE_TYPING_INDICATOR="true" +NEXT_PUBLIC_FEATURE_READ_RECEIPTS="true" +NEXT_PUBLIC_FEATURE_EMOJI_PICKER="true" +NEXT_PUBLIC_FEATURE_CHANNEL_BROWSER="true" +NEXT_PUBLIC_FEATURE_DIRECT_MESSAGES="true" +NEXT_PUBLIC_FEATURE_GROUP_DMS="true" +# Support sub-features +NEXT_PUBLIC_FEATURE_SUPPORT_TICKETS="true" +NEXT_PUBLIC_FEATURE_SUPPORT_ESCALATE="true" +NEXT_PUBLIC_FEATURE_SUPPORT_CALLBACK="true" +NEXT_PUBLIC_FEATURE_SUPPORT_AVAILABILITY="true" +# Global +NEXT_PUBLIC_FEATURE_SEARCH="true" +NEXT_PUBLIC_FEATURE_COMMAND_PALETTE="true" +NEXT_PUBLIC_FEATURE_NOTIFICATIONS="true" +NEXT_PUBLIC_FEATURE_THEME_TOGGLE="true" +NEXT_PUBLIC_FEATURE_THEMING_PANEL="true" +NEXT_PUBLIC_FEATURE_WORKSPACE_SWITCHER="true" +NEXT_PUBLIC_FEATURE_AI_ASSIST="true" +NEXT_PUBLIC_FEATURE_TRANSLATION="true" diff --git a/apps/web/app/(app)/browse/page.tsx b/apps/web/app/(app)/browse/page.tsx new file mode 100644 index 0000000..60071f5 --- /dev/null +++ b/apps/web/app/(app)/browse/page.tsx @@ -0,0 +1,14 @@ +import { Placeholder } from "@/components/Placeholder"; +import { Hash } from "lucide-react"; + +export default function BrowsePage() { + return ( + } + body="A channel directory with search, categories and one-click join. Gated by the `channelBrowser` feature flag." + /> + ); +} diff --git a/apps/web/app/(app)/c/[id]/page.tsx b/apps/web/app/(app)/c/[id]/page.tsx new file mode 100644 index 0000000..927aab9 --- /dev/null +++ b/apps/web/app/(app)/c/[id]/page.tsx @@ -0,0 +1,6 @@ +import { ChannelView } from "@/components/message/ChannelView"; + +export default async function ChannelPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/apps/web/app/(app)/dms/page.tsx b/apps/web/app/(app)/dms/page.tsx new file mode 100644 index 0000000..f5779e8 --- /dev/null +++ b/apps/web/app/(app)/dms/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function DmsPage() { + redirect("/c/d_liam"); +} diff --git a/apps/web/app/(app)/drafts/page.tsx b/apps/web/app/(app)/drafts/page.tsx new file mode 100644 index 0000000..2fb823d --- /dev/null +++ b/apps/web/app/(app)/drafts/page.tsx @@ -0,0 +1,14 @@ +import { Placeholder } from "@/components/Placeholder"; +import { Send } from "lucide-react"; + +export default function DraftsPage() { + return ( + } + body="Scheduled sends from the composer land here until they're delivered. Gated by the `drafts` and `scheduledSend` feature flags." + /> + ); +} diff --git a/apps/web/app/(app)/inbox/page.tsx b/apps/web/app/(app)/inbox/page.tsx new file mode 100644 index 0000000..4776c49 --- /dev/null +++ b/apps/web/app/(app)/inbox/page.tsx @@ -0,0 +1,5 @@ +import { InboxView } from "@/components/inbox/InboxView"; + +export default function InboxPage() { + return ; +} diff --git a/apps/web/app/(app)/layout.tsx b/apps/web/app/(app)/layout.tsx new file mode 100644 index 0000000..5b0e534 --- /dev/null +++ b/apps/web/app/(app)/layout.tsx @@ -0,0 +1,5 @@ +import { AppShell } from "@/components/AppShell"; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx new file mode 100644 index 0000000..5ac3f62 --- /dev/null +++ b/apps/web/app/(app)/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useChannels, useSdk } from "@lynkd/messaging-inbox-sdk"; + +/** Land on the first available channel (works for both mock + live IIOS ids). */ +export default function HomePage() { + const router = useRouter(); + const { loading } = useSdk(); + const { starred, channels, dms } = useChannels(); + + useEffect(() => { + if (loading) return; + const first = starred[0] ?? channels[0] ?? dms[0]; + if (first) router.replace(`/c/${first.id}`); + }, [loading, starred, channels, dms, router]); + + return ( +
+
+
+ Opening your workspace… +
+
+ ); +} diff --git a/apps/web/app/(app)/saved/page.tsx b/apps/web/app/(app)/saved/page.tsx new file mode 100644 index 0000000..ae2611e --- /dev/null +++ b/apps/web/app/(app)/saved/page.tsx @@ -0,0 +1,5 @@ +import { SavedView } from "@/components/saved/SavedView"; + +export default function SavedPage() { + return ; +} diff --git a/apps/web/app/(app)/support/[id]/page.tsx b/apps/web/app/(app)/support/[id]/page.tsx new file mode 100644 index 0000000..ac073d9 --- /dev/null +++ b/apps/web/app/(app)/support/[id]/page.tsx @@ -0,0 +1,6 @@ +import { SupportView } from "@/components/support/SupportView"; + +export default async function SupportTicketPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/apps/web/app/(app)/support/page.tsx b/apps/web/app/(app)/support/page.tsx new file mode 100644 index 0000000..d1942e9 --- /dev/null +++ b/apps/web/app/(app)/support/page.tsx @@ -0,0 +1,5 @@ +import { SupportView } from "@/components/support/SupportView"; + +export default function SupportPage() { + return ; +} diff --git a/apps/web/app/(app)/threads/page.tsx b/apps/web/app/(app)/threads/page.tsx new file mode 100644 index 0000000..f61a6d1 --- /dev/null +++ b/apps/web/app/(app)/threads/page.tsx @@ -0,0 +1,5 @@ +import { ThreadsView } from "@/components/threads/ThreadsView"; + +export default function ThreadsPage() { + return ; +} diff --git a/apps/web/app/api/bff/ai/route.ts b/apps/web/app/api/bff/ai/route.ts new file mode 100644 index 0000000..9ce31ce --- /dev/null +++ b/apps/web/app/api/bff/ai/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { geminiGenerate } from "@/lib/gemini"; + +export const dynamic = "force-dynamic"; + +/** + * AI assistant. The UI sends a user prompt plus optional message context + * (e.g. a copied message to summarize / draft a reply to). The Gemini key + * never leaves the server. + */ +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const prompt: string = (body.prompt ?? "").toString().trim(); + const context: string | undefined = body.context ? String(body.context) : undefined; + + if (!prompt && !context) { + return NextResponse.json({ ok: false, error: "Nothing to ask." }, { status: 400 }); + } + + const parts: string[] = []; + if (context) parts.push(`Here is a chat message for context:\n"""\n${context}\n"""`); + parts.push(prompt || "Summarize the message above."); + + const result = await geminiGenerate(parts.join("\n\n"), { + system: + "You are a concise, helpful assistant embedded in a team-messaging app. " + + "Answer directly in a professional but friendly tone. When drafting a reply, write it in first person, ready to send. Keep responses tight.", + temperature: 0.5, + }); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error, retryable: result.retryable }, { status: result.status || 502 }); + } + return NextResponse.json({ ok: true, text: result.text }); +} diff --git a/apps/web/app/api/bff/bootstrap/route.ts b/apps/web/app/api/bff/bootstrap/route.ts new file mode 100644 index 0000000..88a1c71 --- /dev/null +++ b/apps/web/app/api/bff/bootstrap/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +// GET /api/bff/bootstrap — the initial client payload (me, workspace, users, channels). +export async function GET() { + return NextResponse.json(await getBackend().bootstrap()); +} diff --git a/apps/web/app/api/bff/channels/[id]/members/route.ts b/apps/web/app/api/bff/channels/[id]/members/route.ts new file mode 100644 index 0000000..f8dbfc0 --- /dev/null +++ b/apps/web/app/api/bff/channels/[id]/members/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(req: NextRequest, { params }: Params) { + const { id } = await params; + const body = await req.json().catch(() => ({})); + const channel = await getBackend().addMember(id, String(body.userId ?? "")); + if (!channel) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ channel }); +} + +export async function DELETE(req: NextRequest, { params }: Params) { + const { id } = await params; + const body = await req.json().catch(() => ({})); + const channel = await getBackend().removeMember(id, String(body.userId ?? "")); + if (!channel) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ channel }); +} diff --git a/apps/web/app/api/bff/channels/[id]/messages/[messageId]/pin/route.ts b/apps/web/app/api/bff/channels/[id]/messages/[messageId]/pin/route.ts new file mode 100644 index 0000000..dfc4647 --- /dev/null +++ b/apps/web/app/api/bff/channels/[id]/messages/[messageId]/pin/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string; messageId: string }> }, +) { + const { id, messageId } = await params; + const message = await getBackend().pin(id, messageId); + if (!message) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ message }); +} diff --git a/apps/web/app/api/bff/channels/[id]/messages/[messageId]/reactions/route.ts b/apps/web/app/api/bff/channels/[id]/messages/[messageId]/reactions/route.ts new file mode 100644 index 0000000..a000f84 --- /dev/null +++ b/apps/web/app/api/bff/channels/[id]/messages/[messageId]/reactions/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string; messageId: string }> }, +) { + const { id, messageId } = await params; + const body = await req.json().catch(() => ({})); + const message = await getBackend().react(id, messageId, String(body.emoji ?? "👍")); + if (!message) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ message }); +} diff --git a/apps/web/app/api/bff/channels/[id]/messages/[messageId]/route.ts b/apps/web/app/api/bff/channels/[id]/messages/[messageId]/route.ts new file mode 100644 index 0000000..ca36a4c --- /dev/null +++ b/apps/web/app/api/bff/channels/[id]/messages/[messageId]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string; messageId: string }> }; + +export async function PATCH(req: NextRequest, { params }: Params) { + const { id, messageId } = await params; + const body = await req.json().catch(() => ({})); + const message = await getBackend().editMessage(id, messageId, String(body.body ?? "")); + if (!message) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ message }); +} + +export async function DELETE(_req: NextRequest, { params }: Params) { + const { id, messageId } = await params; + const r = await getBackend().deleteMessage(id, messageId); + return NextResponse.json(r); +} diff --git a/apps/web/app/api/bff/channels/[id]/messages/[messageId]/save/route.ts b/apps/web/app/api/bff/channels/[id]/messages/[messageId]/save/route.ts new file mode 100644 index 0000000..069b49a --- /dev/null +++ b/apps/web/app/api/bff/channels/[id]/messages/[messageId]/save/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string; messageId: string }> }, +) { + const { id, messageId } = await params; + const message = await getBackend().save(id, messageId); + if (!message) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ message }); +} diff --git a/apps/web/app/api/bff/channels/[id]/messages/route.ts b/apps/web/app/api/bff/channels/[id]/messages/route.ts new file mode 100644 index 0000000..b7bad5d --- /dev/null +++ b/apps/web/app/api/bff/channels/[id]/messages/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { id } = await params; + return NextResponse.json({ messages: await getBackend().channelMessages(id) }); +} + +export async function POST(req: NextRequest, { params }: Params) { + const { id } = await params; + const body = await req.json().catch(() => ({})); + const message = await getBackend().send(id, String(body.body ?? ""), { + parentId: body.parentId, + scheduledFor: body.scheduledFor, + attachments: body.attachments, + threadRootId: body.threadRootId, + }); + return NextResponse.json({ message }, { status: 201 }); +} diff --git a/apps/web/app/api/bff/channels/[id]/read/route.ts b/apps/web/app/api/bff/channels/[id]/read/route.ts new file mode 100644 index 0000000..8b64c0b --- /dev/null +++ b/apps/web/app/api/bff/channels/[id]/read/route.ts @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const channel = await getBackend().markRead(id); + return NextResponse.json({ channel }); +} diff --git a/apps/web/app/api/bff/channels/[id]/threads/[parentId]/route.ts b/apps/web/app/api/bff/channels/[id]/threads/[parentId]/route.ts new file mode 100644 index 0000000..181fbf4 --- /dev/null +++ b/apps/web/app/api/bff/channels/[id]/threads/[parentId]/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string; parentId: string }> }, +) { + const { id, parentId } = await params; + return NextResponse.json(await getBackend().thread(id, parentId)); +} diff --git a/apps/web/app/api/bff/channels/route.ts b/apps/web/app/api/bff/channels/route.ts new file mode 100644 index 0000000..7f07fc1 --- /dev/null +++ b/apps/web/app/api/bff/channels/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json({ channels: await getBackend().listChannels() }); +} + +// POST /api/bff/channels — create a channel or DM +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const channel = await getBackend().createChannel({ + name: String(body.name ?? "new-channel"), + kind: body.kind, + topic: body.topic, + memberIds: body.memberIds, + }); + return NextResponse.json({ channel }, { status: 201 }); +} diff --git a/apps/web/app/api/bff/inbox/[id]/route.ts b/apps/web/app/api/bff/inbox/[id]/route.ts new file mode 100644 index 0000000..533cafe --- /dev/null +++ b/apps/web/app/api/bff/inbox/[id]/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; +import type { InboxState } from "@lynkd/messaging-inbox-sdk"; + +export const dynamic = "force-dynamic"; + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const body = await req.json().catch(() => ({})); + const item = await getBackend().patchInbox(id, (body.state as InboxState) ?? "DONE"); + if (!item) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ item }); +} diff --git a/apps/web/app/api/bff/inbox/route.ts b/apps/web/app/api/bff/inbox/route.ts new file mode 100644 index 0000000..ae83373 --- /dev/null +++ b/apps/web/app/api/bff/inbox/route.ts @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; +import type { InboxState } from "@lynkd/messaging-inbox-sdk"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const state = (req.nextUrl.searchParams.get("state") as InboxState | null) ?? undefined; + return NextResponse.json({ items: await getBackend().listInbox(state) }); +} diff --git a/apps/web/app/api/bff/me/profile/route.ts b/apps/web/app/api/bff/me/profile/route.ts new file mode 100644 index 0000000..9aaec0b --- /dev/null +++ b/apps/web/app/api/bff/me/profile/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function PATCH(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const user = await getBackend().updateMe({ + name: body.name, + title: body.title, + avatarColor: body.avatarColor, + avatarUrl: body.avatarUrl, + presence: body.presence, + }); + return NextResponse.json({ user }); +} diff --git a/apps/web/app/api/bff/me/status/route.ts b/apps/web/app/api/bff/me/status/route.ts new file mode 100644 index 0000000..10995ff --- /dev/null +++ b/apps/web/app/api/bff/me/status/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function PATCH(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const user = await getBackend().setStatus( + body.statusText ?? undefined, + body.statusEmoji ?? undefined, + body.clearAt ?? undefined, + ); + return NextResponse.json({ user }); +} diff --git a/apps/web/app/api/bff/notifications/route.ts b/apps/web/app/api/bff/notifications/route.ts new file mode 100644 index 0000000..bb383fb --- /dev/null +++ b/apps/web/app/api/bff/notifications/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json({ notifications: await getBackend().notifications() }); +} diff --git a/apps/web/app/api/bff/saved/route.ts b/apps/web/app/api/bff/saved/route.ts new file mode 100644 index 0000000..289b477 --- /dev/null +++ b/apps/web/app/api/bff/saved/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json({ messages: await getBackend().savedMessages() }); +} diff --git a/apps/web/app/api/bff/search/route.ts b/apps/web/app/api/bff/search/route.ts new file mode 100644 index 0000000..2569b7b --- /dev/null +++ b/apps/web/app/api/bff/search/route.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const q = req.nextUrl.searchParams.get("q") ?? ""; + return NextResponse.json({ results: await getBackend().search(q) }); +} diff --git a/apps/web/app/api/bff/support/availability/route.ts b/apps/web/app/api/bff/support/availability/route.ts new file mode 100644 index 0000000..c1c911d --- /dev/null +++ b/apps/web/app/api/bff/support/availability/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json({ agents: await getBackend().availability() }); +} diff --git a/apps/web/app/api/bff/support/tickets/[id]/reply/route.ts b/apps/web/app/api/bff/support/tickets/[id]/reply/route.ts new file mode 100644 index 0000000..b97e720 --- /dev/null +++ b/apps/web/app/api/bff/support/tickets/[id]/reply/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const body = await req.json().catch(() => ({})); + const ticket = await getBackend().replyTicket(id, String(body.body ?? "")); + if (!ticket) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ ticket }); +} diff --git a/apps/web/app/api/bff/support/tickets/[id]/route.ts b/apps/web/app/api/bff/support/tickets/[id]/route.ts new file mode 100644 index 0000000..4b26074 --- /dev/null +++ b/apps/web/app/api/bff/support/tickets/[id]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; +import type { TicketState } from "@lynkd/messaging-inbox-sdk"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { id } = await params; + const ticket = await getBackend().getTicket(id); + if (!ticket) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ ticket }); +} + +export async function PATCH(req: NextRequest, { params }: Params) { + const { id } = await params; + const body = await req.json().catch(() => ({})); + const ticket = await getBackend().patchTicket(id, (body.state as TicketState) ?? "OPEN"); + if (!ticket) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json({ ticket }); +} diff --git a/apps/web/app/api/bff/support/tickets/route.ts b/apps/web/app/api/bff/support/tickets/route.ts new file mode 100644 index 0000000..49403d6 --- /dev/null +++ b/apps/web/app/api/bff/support/tickets/route.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const scope = req.nextUrl.searchParams.get("scope") ?? undefined; + return NextResponse.json({ tickets: await getBackend().listTickets(scope ?? undefined) }); +} diff --git a/apps/web/app/api/bff/threads/route.ts b/apps/web/app/api/bff/threads/route.ts new file mode 100644 index 0000000..60c4b56 --- /dev/null +++ b/apps/web/app/api/bff/threads/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { getBackend } from "@/lib/backend"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return NextResponse.json({ messages: await getBackend().threadParents() }); +} diff --git a/apps/web/app/api/bff/translate/route.ts b/apps/web/app/api/bff/translate/route.ts new file mode 100644 index 0000000..25fd8b7 --- /dev/null +++ b/apps/web/app/api/bff/translate/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { geminiGenerate } from "@/lib/gemini"; + +export const dynamic = "force-dynamic"; + +/** + * Translate arbitrary text to a target language. Used for per-message + * translation and composer "translate before send". Server-side Gemini only. + */ +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const text: string = (body.text ?? "").toString(); + const targetLang: string = (body.targetLang ?? "").toString().trim(); + + if (!text.trim()) return NextResponse.json({ ok: false, error: "Nothing to translate." }, { status: 400 }); + if (!targetLang) return NextResponse.json({ ok: false, error: "No target language." }, { status: 400 }); + + const result = await geminiGenerate( + `Translate the following text into ${targetLang}. Preserve markdown, emoji, @mentions and #channel references exactly. ` + + `Return ONLY the translated text with no quotes, notes, or preamble.\n\n${text}`, + { + system: "You are a professional translator. Output only the translation.", + temperature: 0.2, + }, + ); + + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error, retryable: result.retryable }, { status: result.status || 502 }); + } + return NextResponse.json({ ok: true, text: result.text }); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..a1742f3 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,147 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Default token values (dark). The SDK provider overwrites these on at + runtime from the resolved ThemeConfig — this block just prevents a flash and + keeps the app styled even before hydration. */ +:root { + --c-bg: #060608; + --c-surface: #08080b; + --c-panel: #0e0e13; + --c-elevated: #15151c; + --c-hover: rgba(255, 255, 255, 0.055); + --c-border: rgba(255, 255, 255, 0.08); + --c-border-strong: rgba(255, 255, 255, 0.14); + --c-ink: #ededf1; + --c-muted: #8c8c94; + --c-dim: #63636b; + --c-accent: #fda913; + --c-accent-fg: #1a1205; + --c-accent-soft: rgba(253, 169, 19, 0.12); + --c-success: #3ecf8e; + --c-warning: #f5b544; + --c-danger: #f2555a; + --c-info: #5b9dff; + --r-card: 20px; + --r-control: 10px; + --r-pill: 999px; + --font-sans: Inter, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --brand-gradient: linear-gradient(135deg, #fda913 0%, #ff7a3d 55%, #ff4d6d 100%); +} + +/* Light scheme (no `dark` class). More specific than :root, so it wins before + hydration — the provider then applies inline vars post-mount for live theming. + This is what makes the light/dark toggle flash-free for returning users. */ +html:not(.dark) { + --c-bg: #f6f7f9; + --c-surface: #ffffff; + --c-panel: #ffffff; + --c-elevated: #ffffff; + --c-hover: rgba(9, 10, 14, 0.045); + --c-border: rgba(9, 10, 14, 0.1); + --c-border-strong: rgba(9, 10, 14, 0.16); + --c-ink: #14151a; + --c-muted: #5c6069; + --c-dim: #8b909a; + --c-accent: #e8920a; + --c-accent-fg: #ffffff; + --c-accent-soft: rgba(232, 146, 10, 0.12); + --c-success: #12915c; + --c-warning: #b3730a; + --c-danger: #d0353b; + --c-info: #2f6fe0; +} + +* { + border-color: var(--c-border); +} + +html, +body { + height: 100%; +} + +/* transient highlight when jumping to a message via a shared link. + Plain CSS (outside @layer) so Tailwind never purges it. */ +[data-jump-highlight="1"] { + background-color: rgba(253, 169, 19, 0.18) !important; + transition: background-color 0.3s ease; +} + +body { + background: var(--c-bg); + color: var(--c-ink); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +::selection { + background: var(--c-accent-soft); + color: var(--c-ink); +} + +/* thin, theme-aware scrollbars */ +* { + scrollbar-width: thin; + scrollbar-color: var(--c-border-strong) transparent; +} +*::-webkit-scrollbar { + width: 9px; + height: 9px; +} +*::-webkit-scrollbar-thumb { + background: var(--c-border-strong); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--c-dim); + background-clip: padding-box; +} + +@layer components { + .brand-mark { + background: var(--brand-gradient); + } + + .card { + background: var(--c-panel); + border: 1px solid var(--c-border); + border-radius: var(--r-card); + } + + .focus-ring { + outline: none; + } + .focus-ring:focus-visible { + box-shadow: 0 0 0 2px var(--c-bg), 0 0 0 4px var(--c-accent); + } + + /* subtle hover row used across lists / messages */ + .row-hover:hover { + background: var(--c-hover); + } + + /* transient highlight when jumping to a message via a shared link */ + .msg-jump { + background: var(--c-accent-soft); + transition: background 0.3s ease; + } +} + +@layer utilities { + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + .text-balance { + text-wrap: balance; + } +} diff --git a/apps/web/app/icon.svg b/apps/web/app/icon.svg new file mode 100644 index 0000000..3a942d5 --- /dev/null +++ b/apps/web/app/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..acc8e86 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { getServerConfig } from "@/lib/config"; +import { Providers } from "@/components/Providers"; + +export const metadata: Metadata = { + title: "LynkedUp Pro — Messages", + description: + "Slack-grade team messaging, inbox and support — a demo of the @lynkd/messaging-inbox-sdk.", +}; + +// Runs before paint: restores the user's saved scheme + accent so returning +// users never see a flash of the wrong theme. +const NO_FLASH = ` +try { + var s = localStorage.getItem('lynkd.scheme'); + if (s === 'dark') document.documentElement.classList.add('dark'); + else if (s === 'light') document.documentElement.classList.remove('dark'); + document.documentElement.style.colorScheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; + var t = localStorage.getItem('lynkd.theme'); + if (t) { var p = JSON.parse(t); if (p && p.dark && p.dark.accent) { + document.documentElement.style.setProperty('--c-accent', p.dark.accent); + } } +} catch (e) {} +`; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const config = getServerConfig(); + const initialScheme = config.theme.mode === "light" ? "light" : "dark"; + + return ( + + + {/* eslint-disable-next-line @next/next/no-before-interactive-script-outside-document */} +