Initial commit: BlackDice site rebuild with integrated CMS
Rebuilds content management around a single admin route (/admin — "BlackDice Studio"), replacing blackdice-studio.html. Publishing writes one JSON content document instead of regenerating HTML files, so it can no longer overwrite hand-made site changes the way the old tool did. - /admin: click-to-edit copy/images, article CRUD with a Word-safe rich text editor, demo clip management, per-page SEO, enquiry log, publish history with rollback - Real per-page URLs for all pages and articles, each with its own meta/canonical/OG/JSON-LD - Newsroom + article pages driven by the CMS post library, seeded from the old studio's export (17 articles) plus a drafted GSMA Open Gateway press release awaiting approval - Demo sections on Mobile SDK and Halo CPE, interactive by default and upgradable to an uploaded clip per slot - Dependency-free Node API server (auth, publish, uploads, snapshots, leads, live sitemap) - Deployment configs for Node/nginx/IIS, Vercel and Netlify See docs/ARCHITECTURE.md, docs/PROJECT-STRUCTURE.md, docs/CMS-GUIDE.md and docs/DEPLOYMENT.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
17
.claude/launch.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "website-react",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
},
|
||||
{
|
||||
"name": "standalone-preview",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["-y", "http-server", ".", "-p", "4599"],
|
||||
"port": 4599
|
||||
}
|
||||
]
|
||||
}
|
||||
28
.env.example
Normal file
@@ -0,0 +1,28 @@
|
||||
# Copy to .env and fill in before deploying. server/index.mjs reads these from
|
||||
# the real process environment — see docs/DEPLOYMENT.md for how to set them on
|
||||
# your host (systemd, Docker, IIS, etc.). A .env file is NOT loaded automatically
|
||||
# by the server; it's documentation of what to set, not a config loader.
|
||||
|
||||
# Password for /admin (BlackDice Studio). REQUIRED before deploying —
|
||||
# without it the server falls back to the development password "blackdice"
|
||||
# and prints a warning on startup.
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Signs admin session tokens. Optional — defaults to a hash derived from
|
||||
# ADMIN_PASSWORD. Set it separately if you want to be able to invalidate all
|
||||
# sessions without changing the password.
|
||||
ADMIN_SECRET=
|
||||
|
||||
# Port the server listens on. Default: 8787
|
||||
PORT=8787
|
||||
|
||||
# Where published content, uploads and enquiry leads are stored.
|
||||
# Put this on persistent storage — it's the one thing that can't be rebuilt
|
||||
# from git. Default: ./content
|
||||
CONTENT_DIR=
|
||||
|
||||
# The built site to serve. Default: ./dist
|
||||
DIST_DIR=
|
||||
|
||||
# Canonical origin used in sitemap.xml and absolute meta URLs.
|
||||
SITE_URL=https://www.blackdice.ai
|
||||
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Runtime content written by /admin. Lives on the server (and in the backup job),
|
||||
# not in git — see docs/DEPLOYMENT.md.
|
||||
content/site-content.json
|
||||
content/versions/
|
||||
content/uploads/
|
||||
content/leads.jsonl
|
||||
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
51
CHANGELOG.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-08-12 — CMS rebuild
|
||||
|
||||
Rebuilt the site's content management around a single admin route
|
||||
(`/admin` — "BlackDice Studio"), replacing `blackdice-studio.html`.
|
||||
|
||||
### Added
|
||||
- **`/admin`** — password-gated CMS with a live preview of the real site:
|
||||
click-to-edit copy and images, animated-stat editing, full article CRUD with
|
||||
a Word/Outlook-safe rich-text editor, demo-clip management, per-page SEO,
|
||||
enquiry-form log with CSV export, and publish history with one-click rollback.
|
||||
- **`server/index.mjs`** — dependency-free Node API: auth, content publish,
|
||||
uploads, snapshots, leads, and a live `/sitemap.xml`.
|
||||
- **Real per-page URLs** for all 12 pages and every article
|
||||
(`/mobile-sdk`, `/blog/<slug>`, …), each with its own title, meta description,
|
||||
canonical/OG tags and JSON-LD, working browser back/forward, and HubSpot
|
||||
virtual pageviews.
|
||||
- **Newsroom + article pages** driven by the CMS post library — the same 17
|
||||
articles imported from the old studio's export, plus a drafted press release
|
||||
announcing the GSMA Open Gateway channel partnership (awaiting approved quotes
|
||||
and dates before publishing).
|
||||
- **Demo sections** on Mobile SDK (scam call, scam SMS, SIM swap, per-device DNS,
|
||||
permissions checking — interactive by default, upgradable to an uploaded clip
|
||||
per slot with no code change) and Halo CPE (Retina + Angel walkthrough slots).
|
||||
- **`data-cms` / `data-cms-img` / `data-cms-num`** hooks injected into the page
|
||||
markup by `scripts/inject-cms-ids.mjs` (idempotent, 652 fields) — this is what
|
||||
makes every piece of copy independently editable.
|
||||
- **`scripts/seed-content.mjs`** — imports a `blackdice-studio.json` draft,
|
||||
extracting and downscaling base64 hero images and sanitising Word-pasted body
|
||||
HTML.
|
||||
- Enquiry forms across the site (Talk to us / Book a demonstration / mailto
|
||||
links) now record submissions server-side in addition to opening the visitor's
|
||||
mail client, addressed to a CMS-configurable recipient.
|
||||
- Deployment configs for Node/nginx/IIS, Vercel and Netlify.
|
||||
- `docs/ARCHITECTURE.md`, `docs/PROJECT-STRUCTURE.md`, `docs/CMS-GUIDE.md`,
|
||||
`docs/DEPLOYMENT.md`.
|
||||
|
||||
### Fixed
|
||||
- **Publishing could wipe hand-made site changes.** The old tool exported whole
|
||||
HTML files on every save, discarding any manual edits to links, forms or SEO
|
||||
tags made outside it. Content is now a separate JSON document that never
|
||||
touches the markup — see `docs/ARCHITECTURE.md` §2.
|
||||
- Articles, product pages and the CMS itself previously had no independent URLs,
|
||||
so they couldn't be shared, bookmarked, or indexed separately by Google.
|
||||
|
||||
### Changed
|
||||
- `src/main.tsx` now defines one real route per page instead of a single
|
||||
catch-all rendering the whole site.
|
||||
- `src/site/siteController.js`'s `go()` now pushes real history entries via a
|
||||
navigator injected by the router, instead of only toggling page visibility.
|
||||
169
README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# BlackDice Cyber — Website + Studio (React + TypeScript)
|
||||
|
||||
The BlackDice marketing site as a Vite + React + TypeScript app, with **BlackDice
|
||||
Studio** — the CMS — built in at a single admin route, and the **BlackDice Angel
|
||||
product demos** embedded as real React components.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # site on http://localhost:5173, CMS API on http://localhost:8787
|
||||
npm run build # production build → dist/
|
||||
npm start # serve dist/ + the CMS API from one Node process (port 8787)
|
||||
```
|
||||
|
||||
- Site: <http://localhost:5173>
|
||||
- Studio: <http://localhost:5173/admin> (dev password `blackdice` until `ADMIN_PASSWORD` is set — see `.env.example`)
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Audience | Covers |
|
||||
|---|---|---|
|
||||
| **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** | Developers | How the system works: content flow, the CMS hook system, routing, the API server, resilience, threat model |
|
||||
| **[docs/PROJECT-STRUCTURE.md](docs/PROJECT-STRUCTURE.md)** | Developers | Directory-by-directory reference; "where do I make this change" table |
|
||||
| **[docs/CMS-GUIDE.md](docs/CMS-GUIDE.md)** | Editors (Paul, Mark, Campbell) | How to use Studio — editing pages, publishing articles, demos, SEO |
|
||||
| **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** | Ops | Environment variables, hosting (Node/nginx/IIS/Docker/static), backups, release checklist |
|
||||
| **[CHANGELOG.md](CHANGELOG.md)** | Everyone | What changed in the CMS rebuild and why |
|
||||
|
||||
---
|
||||
|
||||
## Why the old studio kept wiping the site
|
||||
|
||||
`blackdice-studio.html` worked by **exporting HTML**: saving cloned the live DOM
|
||||
into a new `index.html` and rebuilt `blog.html` from a template. Anything edited in
|
||||
those files by hand — the contact-form links, SEO tags, routing tweaks — was
|
||||
overwritten by the next export, because the export was the source of truth.
|
||||
|
||||
Here, **content and markup are separate**:
|
||||
|
||||
| | Old studio | This project |
|
||||
| --- | --- | --- |
|
||||
| What "save" writes | whole `index.html` + `blog.html` (27MB) | one JSON document (~200KB) |
|
||||
| Where copy lives | inside the HTML | `content/site-content.json` |
|
||||
| Hand-made changes | lost on next export | untouched — they are in the code |
|
||||
| Images | base64 inside the HTML | files under `content/uploads/` |
|
||||
| Rollback | manual file copies | automatic snapshot per publish |
|
||||
|
||||
Publishing can no longer overwrite anything a developer wrote, so **Requirement 1
|
||||
disappears by construction**.
|
||||
|
||||
---
|
||||
|
||||
## How content flows
|
||||
|
||||
Three layers, each overriding the one before:
|
||||
|
||||
1. **The build** — `src/site/siteMarkup.txt` (the page markup) plus defaults in
|
||||
`src/cms/pages.ts` (routes, per-page SEO, demo slots).
|
||||
2. **Factory content** — `src/cms/generated/seedContent.json`, imported from the
|
||||
studio export. This is what the site shows before anything is published.
|
||||
3. **Published content** — `content/site-content.json`, written by `/admin`.
|
||||
|
||||
The site fetches layer 3 at load and merges it over 1–2 (`src/cms/store.tsx`). If
|
||||
the API is unreachable, the site still renders layers 1–2 rather than going blank.
|
||||
|
||||
### What the CMS can change
|
||||
|
||||
| Field type | Hook in the markup | Edited in |
|
||||
| --- | --- | --- |
|
||||
| Copy (652 fields) | `data-cms="c0001"` | Pages tab — click the text on the page |
|
||||
| Images | `data-cms-img="img001"` | Pages tab — click the image |
|
||||
| Animated stats | `data-cms-num="n001"` | Pages tab — value + suffix |
|
||||
| Articles | — | Articles tab |
|
||||
| Demo clips | — | Demos tab |
|
||||
| Titles, meta, form recipient | — | SEO tab |
|
||||
|
||||
Hooks are injected by a script, so re-running it after markup edits is safe —
|
||||
existing ids are preserved and only new elements get new ids:
|
||||
|
||||
```bash
|
||||
npm run cms:ids
|
||||
```
|
||||
|
||||
It also writes `src/cms/generated/cmsFields.json`, the field index the Studio uses
|
||||
to list and jump to every editable region.
|
||||
|
||||
### Importing from the old studio
|
||||
|
||||
```bash
|
||||
npm run cms:import -- "path/to/blackdice-studio.json"
|
||||
```
|
||||
|
||||
Pulls in the post library, writes base64 heroes out as real files under
|
||||
`public/content/posts/` (and downscales them — some originals were 8000px/12MB),
|
||||
and strips Word/Outlook formatting from article bodies. Studio drafts can also be
|
||||
imported at runtime from **Studio → History → Import a file**.
|
||||
|
||||
Posts authored in this repo rather than in the studio live in
|
||||
`content-seed/additional-posts.json` and survive re-imports.
|
||||
|
||||
---
|
||||
|
||||
## What's inside
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `src/main.tsx` | Router: one real route per page, `/blog/:slug`, `/admin`, demo routes |
|
||||
| `src/site/SiteApp.tsx` | Renders the site markup, applies CMS content, mounts the React islands, boots the ported controller |
|
||||
| `src/site/siteMarkup.txt` | Page markup with CMS hooks (imported as a raw string) |
|
||||
| `src/site/siteController.js` | Ported `js/main.js`: page switching, enquiry forms, live feed, count-ups |
|
||||
| `src/site/styles.css` | The original stylesheet + newsroom/article/demo styles |
|
||||
| `src/site/blog/` | Newsroom grid and article pages |
|
||||
| `src/site/demos/` | Demo section (Mobile SDK) and clip players (Halo CPE) |
|
||||
| `src/cms/` | Content model, API client, store, SEO helpers, sanitiser |
|
||||
| `src/cms/admin/` | BlackDice Studio — the `/admin` app |
|
||||
| `src/demo/` | The BlackDice Angel demo (cinematic player + interactive flows + threat demos) |
|
||||
| `server/index.mjs` | Site + CMS server: auth, content, uploads, snapshots, leads, sitemap (no dependencies) |
|
||||
| `scripts/` | `inject-cms-ids`, `seed-content`, `generate-sitemap`, `resize-images`, `dev` |
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | What it is |
|
||||
| --- | --- |
|
||||
| `/`, `/mobile-sdk`, `/dns-protect`, `/halo-cpe`, `/for-operators`, `/financial-services`, `/why-blackdice`, `/news`, `/investors`, `/contact`, `/cookie-policy`, `/privacy-policy` | The site — real, shareable, crawlable URLs |
|
||||
| `/blog/<slug>` | One page per article |
|
||||
| `/admin` | BlackDice Studio |
|
||||
| `/threat-demos`, `/threat-demo/:id` | Threat-detection demos (slider + single scenario) |
|
||||
| `/demo-video`, `/demo`, `/demo?mode=video` | Angel product video and interactive demo player |
|
||||
| `/sitemap.xml`, `/robots.txt` | Generated from live content by the server |
|
||||
|
||||
Every route updates `document.title`, the meta description, canonical and OG tags,
|
||||
and pushes a HubSpot virtual pageview. Articles also emit `BlogPosting` JSON-LD;
|
||||
the home page emits `Organization`.
|
||||
|
||||
Deep links need an SPA fallback on the host. `server/index.mjs` does it out of the
|
||||
box; `vercel.json`, `public/_redirects` and `public/web.config` cover Vercel,
|
||||
Netlify and IIS.
|
||||
|
||||
## Enquiry forms
|
||||
|
||||
Every "Talk to us", "Book a demonstration", "Request a demonstration" and
|
||||
`mailto:` link opens the enquiry form. On submit the details are POSTed to
|
||||
`/api/leads` (visible in **Studio → Enquiries**, exportable as CSV) *and* handed to
|
||||
the visitor's mail client addressed to `settings.formRecipient`
|
||||
(`campbell.ferrier@blackdice.ai` by default, changeable in the SEO tab).
|
||||
|
||||
## Demos on the product pages
|
||||
|
||||
The Mobile SDK page carries a tabbed demo section — scam call (voice/VOIP), scam
|
||||
SMS, SIM swap, per-device DNS analytics (iOS and Android) and permissions checking.
|
||||
Each tab plays the clip uploaded in **Studio → Demos** if there is one, and
|
||||
otherwise plays the interactive demo built into this project, shown as the phone
|
||||
mockup with no surrounding frame. Halo CPE has slots for the Retina dashboard and
|
||||
Angel web UI walkthroughs, which appear under their screenshots once clips are
|
||||
uploaded.
|
||||
|
||||
## CSS isolation
|
||||
|
||||
`SiteApp`, `DemoScreen`, `DemoVideoPage` and `AdminApp` are lazy-loaded as separate
|
||||
chunks, so the site's global `styles.css` only loads on site routes (and in the
|
||||
Studio, which previews the real site). The demo ships a scoped reset
|
||||
(`src/demo/demo.css`, applied via `.bd-demo-root`) that keeps the phone screens
|
||||
pixel-faithful to the original prototype. Studio styles are all prefixed `.bdcms-`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Fonts (Ubuntu / Ubuntu Mono) load from Google Fonts via `index.html`.
|
||||
- The site markup is rendered with `dangerouslySetInnerHTML`; its inline handlers
|
||||
resolve to the globals registered by `siteController.js`, and React features are
|
||||
portalled into named mount points inside it.
|
||||
- `standalone.html` is the old single-file build, kept for reference only.
|
||||
16
content-seed/additional-posts.json
Normal file
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"id": "blackdice-gsma-open-gateway-channel-partnership",
|
||||
"slug": "blackdice-gsma-open-gateway-channel-partnership",
|
||||
"title": "BlackDice joins GSMA Open Gateway as a channel partner",
|
||||
"category": "press",
|
||||
"date": "2026-08-12",
|
||||
"author": "BlackDice Cyber",
|
||||
"excerpt": "BlackDice Cyber has become a channel partner for GSMA Open Gateway, making its network-native fraud and threat intelligence available to operators and their enterprise customers through standardised, CAMARA-based network APIs.",
|
||||
"hero": "",
|
||||
"status": "draft",
|
||||
"metaTitle": "BlackDice joins GSMA Open Gateway as a channel partner | BlackDice Cyber",
|
||||
"metaDescription": "BlackDice Cyber becomes a GSMA Open Gateway channel partner, delivering network-native fraud and threat intelligence to operators and enterprises through standardised CAMARA network APIs.",
|
||||
"body": "<p><strong>Leeds, United Kingdom — [DATE FOR RELEASE]</strong></p><p>BlackDice Cyber, the telecom-native cybersecurity company, today announced that it has become a channel partner for <strong>GSMA Open Gateway</strong>, the industry initiative that exposes operator network capabilities to developers and enterprises through a common set of standardised APIs.</p><p>The partnership means the behavioural and network intelligence BlackDice already generates from inside operator infrastructure can be consumed through the same standardised interfaces enterprises are adopting for identity, fraud and anti-scam use cases — without bespoke integration work for each operator relationship.</p><h2>Why this matters now</h2><p>Fraud has moved to the point of interaction. Authorised push payment fraud, scam calls, smishing and SIM-swap attacks all begin outside the banking session and end inside it, and the signals that would have prevented them sit in the network rather than in the application.</p><p>GSMA Open Gateway, built on the open-source CAMARA API specifications, gives banks, fintechs and digital service providers one consistent way to reach those signals across participating operator networks. As a channel partner, BlackDice extends that model with the detection layer operators need behind the API: device behaviour, DNS activity, call and messaging risk, and account-integrity signals correlated in real time.</p><h2>What operators and enterprises get</h2><ul><li><strong>Standardised access.</strong> Fraud and threat signals delivered through common network APIs rather than one-off integrations.</li><li><strong>Network-native detection.</strong> Intelligence generated from within the operator estate, where the threat is visible first, instead of inferred after the fact by an over-the-top application.</li><li><strong>Faster commercial routes.</strong> A packaged way for operators to take security and anti-fraud propositions to their enterprise customers, and a new revenue line built on assets they already own.</li><li><strong>Coverage across the stack.</strong> The same platform serving mobile applications through the BlackDice Mobile SDK, subscriber broadband through Halo CPE, and full estates through DNS Protect.</li></ul><h2>Comment</h2><blockquote>[APPROVED QUOTE — BLACKDICE SPOKESPERSON, ROLE. Suggested substance: what the partnership unlocks for operators, and why network-level intelligence is the missing layer in fraud prevention.]</blockquote><p>[OPTIONAL: APPROVED QUOTE FROM GSMA OR A LAUNCH PARTNER OPERATOR — subject to their sign-off before publication.]</p><h2>Availability</h2><p>BlackDice capabilities will be made available through GSMA Open Gateway channels from [AVAILABILITY DATE], starting with [INITIAL MARKETS / OPERATOR GROUPS]. Operators and enterprises can request a technical briefing or a demonstration through the BlackDice website.</p><h2>About BlackDice Cyber</h2><p>BlackDice Cyber is a telecom-native cybersecurity company headquartered in Leeds, United Kingdom. Its AI-powered platform operates from within operator infrastructure — in the router, at the DNS layer and inside mobile applications — detecting threats before they reach subscribers and turning security into a commercial proposition for operators. BlackDice is protected by granted patents in three jurisdictions (EP3231153B1, GB2533101, AU2015359182).</p><h2>About GSMA Open Gateway</h2><p>GSMA Open Gateway is a framework of common network APIs, defined through the open-source CAMARA project, designed to give developers and cloud providers single points of access to operator networks worldwide.</p><p><strong>Media enquiries:</strong> please use the enquiry form on this site and a member of the communications team will respond.</p><p><em>[REVIEW NOTE — remove before publishing: this release is a draft prepared for approval. Confirm the partnership wording with GSMA, add approved quotes, set the release and availability dates, and attach a hero image before switching the article from Draft to Published.]</em></p>"
|
||||
}
|
||||
]
|
||||
353
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# BlackDice Website — Architecture
|
||||
|
||||
This document explains how the system is put together: the runtime pieces, how
|
||||
content flows from the CMS to a visitor's browser, and why the design avoids the
|
||||
failure mode that broke the old `blackdice-studio.html` tool.
|
||||
|
||||
For editors, see [CMS-GUIDE.md](CMS-GUIDE.md). For hosting, see
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md). For a directory-by-directory reference, see
|
||||
[PROJECT-STRUCTURE.md](PROJECT-STRUCTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. System overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Visitor's browser │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ React app (Vite build) │ │
|
||||
│ │ src/main.tsx router │ │
|
||||
│ └───────────┬─────────────┘ │
|
||||
└──────────────┼─────────────────┘
|
||||
│ HTTPS
|
||||
┌──────────────────┴───────────────────┐
|
||||
│ server/index.mjs │
|
||||
│ (Node, zero dependencies) │
|
||||
│ │
|
||||
│ • serves dist/ (the built SPA) │
|
||||
│ • serves /content/site-content.json │
|
||||
│ • serves /content/uploads/* │
|
||||
│ • /api/* (auth, publish, leads, …) │
|
||||
│ • /sitemap.xml, /robots.txt (generated) │
|
||||
└──────────────────┬───────────────────┘
|
||||
│ reads / writes
|
||||
┌──────────────────┴───────────────────┐
|
||||
│ content/ │
|
||||
│ site-content.json ← published copy │
|
||||
│ versions/*.json ← one per publish │
|
||||
│ uploads/* ← images & clips │
|
||||
│ leads.jsonl ← form submissions │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Two things run in production:
|
||||
|
||||
1. **A static build** (`dist/`) — the React app, compiled once.
|
||||
2. **A small Node server** (`server/index.mjs`) — serves that build, serves the
|
||||
published content document, and exposes the API that `/admin` calls to publish.
|
||||
|
||||
There is no database. The published state is one JSON file plus a folder of
|
||||
uploaded media, both on disk.
|
||||
|
||||
---
|
||||
|
||||
## 2. The core design decision: content is data, not markup
|
||||
|
||||
The previous tool (`blackdice-studio.html`) worked by **exporting HTML** — every
|
||||
save cloned the live DOM into a new `index.html` and rebuilt `blog.html` from a
|
||||
template string. The export *was* the site. Anything a developer had hand-edited
|
||||
into those files — contact-form wiring, SEO tags, routing — was silently discarded
|
||||
on the next export, because there was only one copy of the truth and the CMS owned
|
||||
it entirely.
|
||||
|
||||
This project splits that single copy into two layers that never overwrite each
|
||||
other:
|
||||
|
||||
| Layer | What it holds | Who owns it | Where it lives |
|
||||
|---|---|---|---|
|
||||
| **Markup & code** | Page structure, CSS, the router, the enquiry-form logic, the demo players | Developers, via git | `src/`, built into `dist/` |
|
||||
| **Content** | Copy, images, articles, demo clips, SEO text, settings | Editors, via `/admin` | `content/site-content.json` |
|
||||
|
||||
`/admin` never writes to `src/` or regenerates any HTML file. Publishing writes
|
||||
**one JSON document**. A developer can change the page structure at any time
|
||||
without touching content, and an editor can change all the content at any time
|
||||
without touching structure. Requirement 1 (publishing wiping developer work)
|
||||
is therefore impossible by construction, not by discipline.
|
||||
|
||||
### How the two layers merge
|
||||
|
||||
`src/cms/store.tsx` resolves content in three ordered layers, each overriding the
|
||||
one before:
|
||||
|
||||
1. **The build** — `src/site/siteMarkup.txt` (page HTML with CMS hooks baked in)
|
||||
and `src/cms/pages.ts` (routes, default SEO, demo slot definitions).
|
||||
2. **Factory content** — `src/cms/generated/seedContent.json`, the one-time import
|
||||
of the old studio's article library. This is what a fresh install shows before
|
||||
anyone publishes anything.
|
||||
3. **Published content** — `content/site-content.json`, fetched at page load and
|
||||
merged over the first two.
|
||||
|
||||
If the API is unreachable (offline, static hosting with no server), the site still
|
||||
renders layers 1–2 rather than failing — see §7.
|
||||
|
||||
---
|
||||
|
||||
## 3. The CMS hook system
|
||||
|
||||
Every editable region in the page markup carries a stable attribute, injected once
|
||||
by a script rather than hand-added:
|
||||
|
||||
| Attribute | Marks | Example |
|
||||
|---|---|---|
|
||||
| `data-cms="cNNNN"` | An editable rich-text region (its `innerHTML`) | `<h1 data-cms="c0013">` |
|
||||
| `data-cms-img="imgNNN"` | A replaceable image (its `src`) | `<img data-cms-img="img001">` |
|
||||
| `data-cms-num="nNNN"` | An animated statistic (`data-count` / `data-suffix`) | `<div data-cms-num="n001">` |
|
||||
|
||||
**`scripts/inject-cms-ids.mjs`** parses `siteMarkup.txt`, walks the DOM tree, and:
|
||||
|
||||
- Assigns an id to every heading, paragraph, list item, button label, image and
|
||||
animated stat that isn't already inside another editable region (so a `<button>`
|
||||
containing only text becomes one field, not one field per nested `<span>`).
|
||||
- Skips elements the runtime rewrites itself (the live threat feed, React mount
|
||||
points) — editing them there would be silently discarded.
|
||||
- **Is idempotent**: re-running it after a markup edit keeps every existing id and
|
||||
only assigns new ones to new elements. This is verified in CI-equivalent fashion
|
||||
by asserting the stripped output is byte-identical to the pre-injection source.
|
||||
- Emits `src/cms/generated/cmsFields.json` — a manifest of every field (id, page,
|
||||
tag, text preview) that the admin's Pages panel uses to list and jump to fields.
|
||||
|
||||
652 fields exist today across 12 pages (625 text, 15 image, 12 animated stats).
|
||||
|
||||
Content editing therefore never touches `siteMarkup.txt` — it only ever writes
|
||||
`{ "c0013": "<b>new copy</b>" }` into the published JSON, which is applied at
|
||||
render time by `applyContent()` (`src/cms/store.tsx`) doing
|
||||
`el.innerHTML = override` for each id present in the document.
|
||||
|
||||
---
|
||||
|
||||
## 4. Runtime rendering: legacy markup + React islands
|
||||
|
||||
The site's page markup was originally a single hand-authored HTML body (from the
|
||||
Claude-built prototype). Rather than rewrite ~4,000 lines of markup into JSX
|
||||
(high risk of visual regression, no material benefit), `SiteApp.tsx` renders it
|
||||
as a raw string via `dangerouslySetInnerHTML`, then:
|
||||
|
||||
1. **Applies CMS overrides** into that DOM (`applyContent`).
|
||||
2. **Boots the ported vanilla controller** (`siteController.js` — page
|
||||
show/hide, nav highlighting, the enquiry-form modal, the live threat-feed
|
||||
animation, count-up animations). Its functions are exposed on `window` because
|
||||
the legacy markup's inline `onclick="goPage(event)"` handlers need to resolve.
|
||||
3. **Portals React components into named mount points** left in the markup —
|
||||
`<div id="news-grid-mount">`, `<div id="demos-p2-mount">`, etc. — using
|
||||
`createPortal`. This is how genuinely dynamic UI (the newsroom grid, article
|
||||
pages, demo tabs, the embedded threat-demo slider) lives inside the legacy DOM
|
||||
as idiomatic React, sharing the router and the CMS content context.
|
||||
|
||||
```tsx
|
||||
// src/site/SiteApp.tsx (shape, simplified)
|
||||
<div dangerouslySetInnerHTML={{ __html: siteHtml }} />
|
||||
{mounts['news-grid-mount'] && createPortal(<NewsGrid />, mounts['news-grid-mount'])}
|
||||
{mounts['demos-p2-mount'] && createPortal(<DemoSection page="p2" .../>, mounts['demos-p2-mount'])}
|
||||
{articleSlug && createPortal(<ArticlePage slug={articleSlug} />, mounts['pg-article'])}
|
||||
```
|
||||
|
||||
`/admin` reuses the exact same `SiteApp` component to render its live preview,
|
||||
passing the in-memory draft instead of the published document
|
||||
(`previewContent={draft}`). **The preview is not a simulation — it is the real
|
||||
site**, which is why "what you see in Studio is what visitors get" is literally
|
||||
true rather than a design goal.
|
||||
|
||||
---
|
||||
|
||||
## 5. Routing
|
||||
|
||||
`src/main.tsx` defines one real `<Route>` per page (from `src/cms/pages.ts`), plus
|
||||
`/blog/:slug` for articles, `/admin` for the CMS, and the standalone demo routes
|
||||
inherited from the original Angel demo build.
|
||||
|
||||
Within the legacy markup, all twelve "pages" are `<div id="pg-p1">` … `<div
|
||||
id="pg-p12">` siblings that were originally shown/hidden by a hash-router. That
|
||||
internal switch is preserved (`showPage()` in `siteController.js`, driven by
|
||||
`data-page` attributes and `id="pg-*"`), but it now sits **behind** the real
|
||||
router rather than being the only router:
|
||||
|
||||
- Clicking a nav item calls the legacy `go('p2')` →
|
||||
`siteController.js` calls `history.pushState` via the injected navigator
|
||||
(`setSiteNavigator`, wired to `useNavigate()`).
|
||||
- React Router owns the URL; a route change re-renders `<SiteApp pageId="p2">`;
|
||||
an effect calls `showPage('p2')` to do the actual show/hide in the DOM.
|
||||
- Back/forward, direct URL loads and shared links all go through React Router
|
||||
first, so they work correctly without any special-casing in the legacy code.
|
||||
|
||||
Every route updates `document.title`, the meta description, canonical link, and
|
||||
OG/Twitter tags via `src/cms/seo.ts`, and pushes a HubSpot virtual pageview.
|
||||
Articles additionally emit `BlogPosting` JSON-LD; the home page emits
|
||||
`Organization` JSON-LD.
|
||||
|
||||
Because these are real paths (not hash fragments), a host needs an SPA fallback
|
||||
for deep links — provided for `server/index.mjs`, Vercel (`vercel.json`), Netlify
|
||||
(`public/_redirects`) and IIS (`public/web.config`).
|
||||
|
||||
---
|
||||
|
||||
## 6. The API server
|
||||
|
||||
`server/index.mjs` is a single file with **no npm dependencies**, using only
|
||||
Node's `http`, `fs`, `crypto` and `path` built-ins. That constraint is deliberate:
|
||||
it can run anywhere Node runs, with no install step beyond Node itself, no
|
||||
supply-chain surface, and no framework version to keep patched.
|
||||
|
||||
| Route | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `POST /api/login` | — | Password → session token (throttled 10/15min/IP) |
|
||||
| `GET /api/content` | — | Published content document |
|
||||
| `POST /api/content` | session | Publish a new content document (snapshots the previous one first) |
|
||||
| `GET/POST /api/versions*` | session | List / load publish snapshots |
|
||||
| `POST /api/upload` | session | Store a base64 data-URL as a file, return its URL |
|
||||
| `POST /api/leads` | — | Record an enquiry-form submission |
|
||||
| `GET /api/leads` | session | List recorded enquiries |
|
||||
| `GET /sitemap.xml` | — | Generated live from published + factory posts |
|
||||
| `GET /content/uploads/*` | — | Serves uploaded media (range requests supported, for `<video>` seeking) |
|
||||
| `*` (fallback) | — | Serves `dist/index.html` — the SPA fallback |
|
||||
|
||||
**Sessions** are HMAC-signed `expiry.signature` tokens (`crypto.createHmac`), not
|
||||
JWTs — there's exactly one claim (an expiry), so a JWT library would be pure
|
||||
overhead. **Publishing is atomic**: the new document is written to a `.tmp` file
|
||||
and `rename()`'d into place, so a reader can never observe a half-written file,
|
||||
and the previous document is snapshotted to `content/versions/` before being
|
||||
replaced (last 60 kept), which is what powers one-click rollback in the History
|
||||
tab.
|
||||
|
||||
**Uploads** are capped at 32MB, restricted to images/video/PDF by content type,
|
||||
and images are downscaled client-side before upload (`fileToOptimisedDataUrl` in
|
||||
`src/cms/api.ts`) — the old studio embedded 8000px camera originals as base64
|
||||
directly in the HTML, which is most of why its exports were 20–27MB.
|
||||
|
||||
---
|
||||
|
||||
## 7. Resilience
|
||||
|
||||
The site is designed to degrade gracefully rather than fail hard:
|
||||
|
||||
- **API unreachable** (`fetchPublished()` in `src/cms/api.ts`) → the site renders
|
||||
factory content instead of a blank page. A 4-second timeout prevents a slow or
|
||||
hung API from blocking first paint indefinitely.
|
||||
- **Nothing published yet** → same fallback; a fresh install is a working site
|
||||
from the moment `npm run build` finishes.
|
||||
- **Editor's browser closes mid-edit** → the draft is mirrored to `localStorage`
|
||||
~600ms after each change (`useDraft.ts`) and restored on next visit, keyed to
|
||||
the published document it was based on (so a stale local draft from before
|
||||
someone else's publish is never silently reapplied).
|
||||
- **A field is deleted from the markup** (e.g. a section is removed by a
|
||||
developer) → its override in the content document simply has no matching
|
||||
element and is a no-op; nothing throws.
|
||||
- **Two editors publish at once** → last write wins (there is no lock), but every
|
||||
publish is snapshotted, so the loser's work is one click away in History, not
|
||||
gone.
|
||||
|
||||
---
|
||||
|
||||
## 8. Content model
|
||||
|
||||
`src/cms/types.ts` defines the single document every publish writes:
|
||||
|
||||
```ts
|
||||
interface SiteContent {
|
||||
content: Record<string, string> // data-cms id → innerHTML
|
||||
images: Record<string, string> // data-cms-img id → src
|
||||
numbers: Record<string, {value:number,suffix?:string}> // data-cms-num id → value
|
||||
posts: Post[] // the article library
|
||||
demos: Record<string, DemoSlot> // demo-slot key → clip config
|
||||
seo: Record<string, PageSeo> // page id → title/description/OG image
|
||||
settings: SiteSettings // form recipient, site URL, ...
|
||||
}
|
||||
```
|
||||
|
||||
A `Post` carries `slug`, `title`, `category` (insights/news/press/events), `date`,
|
||||
`author`, `excerpt`, `hero`, sanitised HTML `body`, and `status`
|
||||
(`draft`/`published`). Drafts are reachable by direct link (for review) but
|
||||
excluded from listings, the sitemap, and get a `noindex` robots tag.
|
||||
|
||||
---
|
||||
|
||||
## 9. Article body sanitisation
|
||||
|
||||
Editors paste directly from Word and Outlook, which carries `MsoNormal` classes,
|
||||
`Aptos` fonts, and black-on-transparent inline colours that fight the site's dark
|
||||
theme. Two sanitisers — kept in step — strip this:
|
||||
|
||||
- `scripts/seed-content.mjs` (regex-based) for the one-time bulk import from the
|
||||
old studio's JSON export, where no DOM is available (a plain Node script).
|
||||
- `src/cms/sanitise.ts` (`DOMParser`-based) for live editing in the browser,
|
||||
run on paste and again on blur.
|
||||
|
||||
Both keep only semantic tags (`h2`–`h4`, `p`, lists, `strong`/`em`, links, images,
|
||||
tables, blockquotes), strip every inline style and class, demote `h1`→`h2` (an
|
||||
article shouldn't compete with the page's own heading), and collapse empty
|
||||
elements left behind by unwrapping `<span>`/`<div>` wrappers.
|
||||
|
||||
---
|
||||
|
||||
## 10. The demo system
|
||||
|
||||
Two things exist for demos so a page is never empty and a video can be added
|
||||
without a code change:
|
||||
|
||||
- **Interactive fallback** — the Mobile SDK's five detections (scam call, scam
|
||||
SMS, SIM swap, per-device DNS, permissions) reuse `ThreatCinematic`
|
||||
(`src/demo/threats/ThreatDemo.tsx`), the same phone-mockup component used in the
|
||||
standalone `/threat-demos` slider, rendered in a `bare` mode with no frame or
|
||||
backdrop.
|
||||
- **CMS clip** — once a video is uploaded in Studio → Demos for a slot, `DemoSlot`
|
||||
gains a `video` URL and `DemoSection` (`src/site/demos/DemoSection.tsx`) switches
|
||||
from the interactive fallback to `<video>` for that tab, no redeploy needed.
|
||||
|
||||
`src/cms/pages.ts` declares the slot ↔ page ↔ interactive-scenario mapping once,
|
||||
shared by the site and by the Demos admin panel.
|
||||
|
||||
---
|
||||
|
||||
## 11. Build & tooling
|
||||
|
||||
| Tool | Role |
|
||||
|---|---|
|
||||
| **Vite** | Dev server + production bundler. `SiteApp`, `DemoScreen`, `DemoVideoPage` and `AdminApp` are separate lazy chunks so the site's CSS/JS never loads on demo-only routes, and vice versa. |
|
||||
| **TypeScript** (`strict`) | `npm run build` runs `tsc --noEmit` first — a type error fails the build before Vite even starts. |
|
||||
| **React Router v6** | Client-side routing (§5). |
|
||||
| **Framer Motion** | Animation in the demo components only. |
|
||||
| **No CSS framework** | The site's look is the original hand-authored `styles.css`; Studio's chrome is a separate, fully namespaced (`.bdcms-`) stylesheet so the two can never collide. |
|
||||
|
||||
No test runner is configured; correctness is currently established by
|
||||
`tsc --noEmit` plus the manual/browser verification described in
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md)'s release checklist.
|
||||
|
||||
---
|
||||
|
||||
## 12. Threat model / trust boundaries
|
||||
|
||||
- `/admin` is the only privileged surface. It is password-gated
|
||||
(`ADMIN_PASSWORD`), sessions expire after 12 hours, and failed logins are
|
||||
throttled per-IP.
|
||||
- Article bodies and inline text are sanitised on the way *in* (§9), not just
|
||||
escaped on the way out — an editor pasting a compromised Word document can't
|
||||
inject a `<script>` into the published site.
|
||||
- Uploads are type-checked by declared MIME (not by trusting the filename
|
||||
extension) and size-capped.
|
||||
- There is deliberately no way for the CMS to write into `src/` or execute
|
||||
arbitrary code — the API's write surface is exactly `content/`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Deliberate non-goals
|
||||
|
||||
- **No database.** A JSON file plus a folder of uploads is sufficient at this
|
||||
scale, is trivial to back up (`docs/DEPLOYMENT.md`), and removes an entire class
|
||||
of operational dependency.
|
||||
- **No multi-user editing / locking.** Last publish wins; snapshots make that an
|
||||
acceptable trade-off for a small editorial team rather than a public wiki.
|
||||
- **No SSR/SSG.** The site is client-rendered; this trades some first-paint SEO
|
||||
performance for radical simplicity in the content pipeline. If that trade-off
|
||||
needs revisiting (e.g. Core Web Vitals pressure), the content model in
|
||||
`src/cms/types.ts` does not need to change — only the rendering entry point
|
||||
would.
|
||||
145
docs/CMS-GUIDE.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# BlackDice Studio — editor's guide
|
||||
|
||||
Studio is the one place the website is changed. It lives at
|
||||
**https://www.blackdice.ai/admin** (locally: <http://localhost:5173/admin>).
|
||||
|
||||
Nothing you do is visible to the public until you press **Publish changes**.
|
||||
|
||||
---
|
||||
|
||||
## The screen
|
||||
|
||||
- **Left:** the Studio panel — tabs, lists and settings.
|
||||
- **Right:** the real website, showing your unpublished draft. What you see is what
|
||||
visitors will get once you publish.
|
||||
- **Bottom left:** the Publish button, and a status line telling you whether you
|
||||
have unsaved changes.
|
||||
|
||||
Your work is saved in your browser as you type, so closing the tab by accident does
|
||||
not lose it. Reopen Studio and it comes back.
|
||||
|
||||
---
|
||||
|
||||
## Changing words and pictures — the Pages tab
|
||||
|
||||
1. Pick a page from the list (Home, Mobile SDK, News, Contact…).
|
||||
2. Make sure the toggle says **Edit page**.
|
||||
3. **Click any text on the page and type.** Enter makes a line break within the same
|
||||
heading or paragraph.
|
||||
4. **Click any image** to replace it. Pick a file — it is resized automatically, so
|
||||
photos straight from a camera are fine.
|
||||
5. Press **Publish changes**.
|
||||
|
||||
Useful details:
|
||||
|
||||
- The search box lists every piece of copy on the page. Click a line to jump
|
||||
straight to it on the page.
|
||||
- A line with an orange edge has been changed. Its **undo** button restores the
|
||||
original wording.
|
||||
- **Headline numbers** (the figures that count up as you scroll) are edited in the
|
||||
panel, as a number plus a suffix — e.g. `76` and `%`.
|
||||
- **Preview** turns editing off so you can click through the page like a visitor.
|
||||
|
||||
---
|
||||
|
||||
## Publishing an article — the Articles tab
|
||||
|
||||
Articles cover four categories: Insights, News, Press and Events. They appear in the
|
||||
newsroom at `/news`, in the "Latest news and insights" strip on the home page, and
|
||||
each gets its own page at `blackdice.ai/blog/<url>` that you can share.
|
||||
|
||||
**To add one:**
|
||||
|
||||
1. **+ New article**.
|
||||
2. Fill in the title, then press **from title** next to the URL so the web address
|
||||
matches the headline.
|
||||
3. Choose the category and date.
|
||||
4. **Author** — a credit in brackets is shown under the image, not next to the date.
|
||||
For example `Paul Hague (Image by Jorge Fernández Salas on Unsplash)`.
|
||||
5. **Excerpt** — the summary shown on cards and in Google results. Keep it under
|
||||
about 300 characters, or press "Use the opening of the article".
|
||||
6. **Hero image** — upload one; it is resized for you.
|
||||
7. **Body** — write in the editor, or paste straight from Word or Outlook. Pasted
|
||||
formatting is cleaned automatically so the article picks up the site's own fonts
|
||||
and colours. Use the toolbar for headings, lists, links and pull quotes.
|
||||
8. Leave the article as **Draft** while it is being reviewed. Draft articles are not
|
||||
listed anywhere and are kept out of Google, but you can still send the
|
||||
`/blog/<url>` link to a colleague for approval.
|
||||
9. Switch it to **Published**, then press **Publish changes**.
|
||||
|
||||
**Awaiting approval:** the GSMA Open Gateway press release is already in the library
|
||||
as a draft. It needs the approved quotes, the release and availability dates, a hero
|
||||
image, and the review note at the bottom removed — then switch it to Published.
|
||||
|
||||
**To remove an article:** open it and press Delete, then publish. Its URL will show
|
||||
a "that article has moved" page rather than an error.
|
||||
|
||||
---
|
||||
|
||||
## Demo clips — the Demos tab
|
||||
|
||||
Two pages carry demos:
|
||||
|
||||
- **Mobile SDK** — scam call (voice/VOIP), scam SMS, SIM swap, per-device DNS
|
||||
analytics, permissions checking. Until a clip is uploaded, each tab plays the
|
||||
interactive demo built into the site, so the page is never empty.
|
||||
- **Halo CPE** — the Retina dashboard and Angel web UI walkthroughs. These appear
|
||||
under the existing screenshots once a clip is uploaded.
|
||||
|
||||
For each slot you can set the title and caption, upload the clip (MP4 or WebM, up to
|
||||
32MB — for anything larger, host it and paste the URL), upload a poster still, and
|
||||
hide the slot entirely.
|
||||
|
||||
---
|
||||
|
||||
## Titles, Google and where enquiries go — the SEO tab
|
||||
|
||||
Per page: the browser/search title (aim for under 60 characters), the meta
|
||||
description (under 160), and an optional share image for LinkedIn and X.
|
||||
|
||||
Site-wide:
|
||||
|
||||
- **Enquiry forms send to** — every form on the site, currently
|
||||
`campbell.ferrier@blackdice.ai`.
|
||||
- **Copy enquiries to** — an optional second address.
|
||||
- **Public contact address** and the **canonical site URL**.
|
||||
|
||||
`sitemap.xml` is rebuilt automatically from whatever is published, so new articles
|
||||
become crawlable as soon as you publish them.
|
||||
|
||||
---
|
||||
|
||||
## Enquiries tab
|
||||
|
||||
Every form submission is recorded here — name, email, company, phone, the page they
|
||||
were on and their message — as well as being emailed. Export the list as CSV for
|
||||
HubSpot.
|
||||
|
||||
---
|
||||
|
||||
## History tab
|
||||
|
||||
- **Snapshots** — a copy of the site content is taken every time you publish. Click
|
||||
one to load it back into the editor; review it, then publish to roll back.
|
||||
- **Discard my draft** — throw away your unpublished edits and show exactly what is
|
||||
live.
|
||||
- **Download this draft** — a single JSON backup of all copy, articles, demos and
|
||||
settings.
|
||||
- **Import a file** — accepts one of those backups, or an old
|
||||
`blackdice-studio.json` draft (which brings its articles across).
|
||||
|
||||
---
|
||||
|
||||
## Questions people ask
|
||||
|
||||
**Will publishing undo the developers' work?**
|
||||
No. Studio only writes content — the copy, articles, images and settings. The page
|
||||
structure, links and forms live in the code and are never overwritten. That is the
|
||||
key difference from the old `blackdice-studio.html`.
|
||||
|
||||
**Two of us editing at once?**
|
||||
Avoid it. Studio publishes the whole content document, so the last publish wins.
|
||||
Agree who is editing, or publish in turns.
|
||||
|
||||
**Something looks wrong after publishing.**
|
||||
Open History, load the previous snapshot, and publish it.
|
||||
126
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Deployment and operations
|
||||
|
||||
The app ships as a static build plus one small Node process. The Node process is
|
||||
what makes `/admin` able to publish, so **host it wherever the CMS is needed**.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build # → dist/
|
||||
ADMIN_PASSWORD='…' npm start # serves dist/ + the API on port 8787
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `ADMIN_PASSWORD` | `blackdice` | Password for `/admin`. **Set this** — the server logs a warning while it is unset. |
|
||||
| `ADMIN_SECRET` | derived from the password | Signs session tokens. Set it to invalidate all sessions independently of the password. |
|
||||
| `PORT` | `8787` | Listen port. |
|
||||
| `CONTENT_DIR` | `./content` | Published content, uploads, snapshots, enquiries. Put this on persistent storage. |
|
||||
| `DIST_DIR` | `./dist` | The built site. |
|
||||
| `SITE_URL` | `https://www.blackdice.ai` | Canonical origin used in `sitemap.xml`. |
|
||||
|
||||
Sessions last 12 hours. Failed logins are throttled at 10 per IP per 15 minutes.
|
||||
Uploads are capped at 32MB and limited to images, MP4/WebM and PDF.
|
||||
|
||||
## What lives where
|
||||
|
||||
```
|
||||
content/
|
||||
site-content.json the published site: copy, articles, demos, settings
|
||||
versions/ one snapshot per publish (latest 60 kept)
|
||||
uploads/ images and clips uploaded through Studio
|
||||
leads.jsonl enquiry form submissions
|
||||
dist/ the built site (safe to delete and rebuild)
|
||||
public/content/posts/ article heroes imported from the old studio (in git)
|
||||
```
|
||||
|
||||
**Back up `content/`.** It is the only thing that cannot be rebuilt from the repo.
|
||||
A nightly copy of the directory is enough; `site-content.json` is a single small
|
||||
JSON file. Editors can also take their own backup from Studio → History → *Download
|
||||
this draft*.
|
||||
|
||||
## Behind IIS or nginx
|
||||
|
||||
Serve the app from Node and reverse-proxy to it — that keeps `/api`, uploads, the
|
||||
SPA fallback and the live sitemap working with no extra configuration.
|
||||
|
||||
nginx:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 40M; # Studio uploads
|
||||
}
|
||||
```
|
||||
|
||||
IIS: install URL Rewrite + ARR and proxy the site to `http://127.0.0.1:8787`, or run
|
||||
it under iisnode. Raise `maxAllowedContentLength` to ~40MB for uploads.
|
||||
|
||||
Keep the process alive with a Windows service (`nssm`), `pm2`, or systemd:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/blackdice-site.service
|
||||
[Service]
|
||||
WorkingDirectory=/srv/blackdice
|
||||
Environment=ADMIN_PASSWORD=…
|
||||
Environment=CONTENT_DIR=/srv/blackdice-content
|
||||
ExecStart=/usr/bin/node server/index.mjs
|
||||
Restart=always
|
||||
```
|
||||
|
||||
Docker:
|
||||
|
||||
```dockerfile
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
ENV PORT=8787 CONTENT_DIR=/data
|
||||
VOLUME /data
|
||||
EXPOSE 8787
|
||||
CMD ["node", "server/index.mjs"]
|
||||
```
|
||||
|
||||
## Static hosting without the CMS
|
||||
|
||||
`dist/` can be served by any static host. Deep links need an SPA fallback, which is
|
||||
already configured for Vercel (`vercel.json`), Netlify (`public/_redirects`) and IIS
|
||||
(`public/web.config`). Apache:
|
||||
|
||||
```apache
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.html [L]
|
||||
```
|
||||
|
||||
In that setup the site shows the content compiled into the build and `/admin` cannot
|
||||
publish. To make a change, run Studio locally (`npm run dev`), publish, then commit
|
||||
the resulting `content/site-content.json` and any `content/uploads/` files and
|
||||
redeploy — the build copies neither automatically, so they must be placed alongside
|
||||
`dist/` (`dist/content/site-content.json`, `dist/content/uploads/…`).
|
||||
|
||||
## Release checklist
|
||||
|
||||
1. `npm ci && npm run build` — the build fails on type errors, so it gates itself.
|
||||
2. `node scripts/generate-sitemap.mjs https://www.blackdice.ai` if the static
|
||||
sitemap needs refreshing (the server's live one needs nothing).
|
||||
3. Deploy `dist/`, `server/`, `package.json`, and keep `content/` in place.
|
||||
4. Check: `/`, `/mobile-sdk`, `/news`, one `/blog/<slug>` deep link, `/sitemap.xml`,
|
||||
`/admin` login.
|
||||
5. In Search Console, submit `https://www.blackdice.ai/sitemap.xml` and request
|
||||
indexing for the product pages — they now have their own URLs to index.
|
||||
|
||||
## After the first deploy
|
||||
|
||||
- Set `ADMIN_PASSWORD` and share it only with the people who edit the site.
|
||||
- Publish once from Studio so `content/site-content.json` exists and snapshots begin.
|
||||
- Approve and publish the GSMA Open Gateway release (Studio → Articles).
|
||||
- Upload the Retina and Angel walkthrough clips (Studio → Demos) to finish the Halo
|
||||
CPE demos.
|
||||
113
docs/PROJECT-STRUCTURE.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Project structure
|
||||
|
||||
A directory-by-directory map of the repo. See [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
for how these pieces fit together at runtime.
|
||||
|
||||
```
|
||||
website-react/
|
||||
├── src/
|
||||
│ ├── main.tsx Router entry point — one route per page + /blog/:slug + /admin
|
||||
│ ├── vite-env.d.ts
|
||||
│ │
|
||||
│ ├── site/ ── The marketing website ──
|
||||
│ │ ├── SiteApp.tsx Renders siteMarkup.txt, applies CMS content, mounts React islands
|
||||
│ │ ├── siteMarkup.txt Page HTML (12 pages) with data-cms hooks — imported as a raw string
|
||||
│ │ ├── siteController.js Ported vanilla JS: page show/hide, nav, enquiry forms, live feed
|
||||
│ │ ├── styles.css The site's stylesheet (+ newsroom/article/demo additions)
|
||||
│ │ ├── blog/
|
||||
│ │ │ ├── NewsGrid.tsx Newsroom grid (/news) + home page "latest" strip, same data source
|
||||
│ │ │ └── ArticlePage.tsx Single article view at /blog/<slug>
|
||||
│ │ └── demos/
|
||||
│ │ ├── DemoSection.tsx Tabbed demo panel (Mobile SDK) — clip or interactive fallback
|
||||
│ │ └── DemoPlayer.tsx <video> player for a CMS-configured demo slot
|
||||
│ │
|
||||
│ ├── demo/ ── The BlackDice Angel product demo (pre-existing) ──
|
||||
│ │ ├── DemoScreen.tsx Full interactive demo player (Flows A–D)
|
||||
│ │ ├── DemoVideoPage.tsx Cinematic video-only page (/demo-video)
|
||||
│ │ ├── demo.css Scoped reset so the phone UI is pixel-faithful
|
||||
│ │ ├── components/ Icons, ScoreRing
|
||||
│ │ ├── context/ DemoContext (interactive flow state)
|
||||
│ │ ├── data/ mock.ts — sample data for the interactive flows
|
||||
│ │ └── threats/ Threat-detection scenario library
|
||||
│ │ ├── scenarios.ts 5 scenario definitions (scam call, SMS, SIM swap, DNS, permissions)
|
||||
│ │ ├── ThreatDemo.tsx The phone-mockup cinematic player (ThreatCinematic)
|
||||
│ │ ├── ThreatDemosSlider.tsx Slider through all scenarios (/threat-demos, or embedded)
|
||||
│ │ └── ThreatDemoPlayerPage.tsx Single scenario at /threat-demo/:id
|
||||
│ │
|
||||
│ └── cms/ ── Content model, API client, and the admin app ──
|
||||
│ ├── types.ts SiteContent, Post, DemoSlot, SiteSettings — the published document's shape
|
||||
│ ├── pages.ts Route table, default SEO copy, demo-slot ↔ page ↔ scenario mapping
|
||||
│ ├── store.tsx Merges factory + published content; ContentProvider/useContent; applyContent()
|
||||
│ ├── api.ts Fetch wrappers for every /api/* route; client-side image downscaling
|
||||
│ ├── seo.ts Per-route <title>/meta/canonical/OG/JSON-LD writer
|
||||
│ ├── sanitise.ts DOM-based HTML sanitiser for pasted/edited article bodies
|
||||
│ ├── generated/ Build artefacts — do not hand-edit
|
||||
│ │ ├── cmsFields.json Field manifest (652 fields), written by scripts/inject-cms-ids.mjs
|
||||
│ │ └── seedContent.json Factory content, written by scripts/seed-content.mjs
|
||||
│ └── admin/ ── BlackDice Studio (/admin) ──
|
||||
│ ├── AdminApp.tsx Shell: login gate, tabs, publish button, renders SiteApp as the live preview
|
||||
│ ├── admin.css Studio's chrome — fully namespaced .bdcms-*
|
||||
│ ├── useDraft.ts Editor's working copy: localStorage mirror, publish, discard, snapshots
|
||||
│ ├── useInlineEditor.ts Click-to-edit wiring over the previewed site
|
||||
│ ├── originals.ts Parses the shipped markup for "revert to original" / edited-field markers
|
||||
│ ├── RichText.tsx Article body editor (contentEditable + toolbar + paste sanitising)
|
||||
│ ├── PagesPanel.tsx Pages tab — field list, image/stat editing
|
||||
│ ├── PostsPanel.tsx Articles tab — full post CRUD
|
||||
│ ├── DemosPanel.tsx Demos tab — clip/poster upload per slot
|
||||
│ ├── SeoPanel.tsx SEO tab — per-page meta + site-wide settings
|
||||
│ ├── LeadsPanel.tsx Enquiries tab — submitted leads, CSV export
|
||||
│ └── HistoryPanel.tsx History tab — snapshots, import/export, discard
|
||||
│
|
||||
├── server/
|
||||
│ └── index.mjs The whole backend: auth, content, uploads, leads, sitemap. No dependencies.
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── inject-cms-ids.mjs Adds data-cms/-img/-num hooks to siteMarkup.txt (idempotent)
|
||||
│ ├── seed-content.mjs Imports a blackdice-studio.json export into seedContent.json
|
||||
│ ├── resize-images.mjs Downscales oversized images (used by seed-content.mjs)
|
||||
│ ├── generate-sitemap.mjs Writes public/sitemap.xml + robots.txt from current content
|
||||
│ └── dev.mjs Runs the API server + Vite dev server together (npm run dev)
|
||||
│
|
||||
├── content-seed/
|
||||
│ └── additional-posts.json Posts authored in-repo (e.g. the GSMA release) — survive re-imports
|
||||
│
|
||||
├── content/ Runtime data — NOT in git (see .gitignore)
|
||||
│ ├── site-content.json The published document
|
||||
│ ├── versions/ One snapshot per publish
|
||||
│ ├── uploads/ Images/clips uploaded via Studio
|
||||
│ └── leads.jsonl Enquiry form submissions
|
||||
│
|
||||
├── public/ Static assets served as-is
|
||||
│ ├── logo.svg, *.png Brand assets and product screenshots
|
||||
│ ├── content/posts/ Article hero images imported from the studio export (in git)
|
||||
│ ├── sitemap.xml, robots.txt Static fallback (server generates a live version too)
|
||||
│ ├── _redirects Netlify SPA fallback
|
||||
│ └── web.config IIS SPA fallback
|
||||
│
|
||||
├── docs/
|
||||
│ ├── ARCHITECTURE.md How the system works (this is the deep-dive)
|
||||
│ ├── PROJECT-STRUCTURE.md This file
|
||||
│ ├── CMS-GUIDE.md For editors: how to use Studio
|
||||
│ └── DEPLOYMENT.md For ops: environment, hosting, backups, release checklist
|
||||
│
|
||||
├── index.html Vite entry HTML (default meta tags; overwritten per-route at runtime)
|
||||
├── vite.config.ts Dev server + API proxy config
|
||||
├── tsconfig.json
|
||||
├── vercel.json Vercel SPA fallback
|
||||
├── package.json
|
||||
└── README.md Start here
|
||||
```
|
||||
|
||||
## Where to make a given kind of change
|
||||
|
||||
| I want to… | Change this |
|
||||
|---|---|
|
||||
| Edit copy, images or an article | Don't touch code — use `/admin` |
|
||||
| Change page layout/structure | `src/site/siteMarkup.txt`, then `npm run cms:ids` |
|
||||
| Add a new page/route | `src/cms/pages.ts` (`PAGES` array) + a matching `id="pg-pN"` block in `siteMarkup.txt` + `Route` in `main.tsx` |
|
||||
| Change site-wide styling | `src/site/styles.css` |
|
||||
| Change Studio's own UI | `src/cms/admin/*` and `admin.css` |
|
||||
| Add an API endpoint | `server/index.mjs` (`handleApi`) |
|
||||
| Change what's in the published document | `src/cms/types.ts` (`SiteContent`) — update `mergeContent` in `store.tsx` too |
|
||||
| Add a demo slot | `src/cms/pages.ts` (`DEMO_SLOTS`) + a mount `<div>` in `siteMarkup.txt` + portal it in `SiteApp.tsx` |
|
||||
| Change article sanitisation rules | Both `src/cms/sanitise.ts` **and** `scripts/seed-content.mjs` — keep them in step |
|
||||
34
index.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Title, description, canonical and OG tags are rewritten per route by
|
||||
src/cms/seo.ts, so every page and article has its own metadata. -->
|
||||
<title>BlackDice | AI-Powered Cyber Defence</title>
|
||||
<meta name="description"
|
||||
content="BlackDice Cyber delivers AI-powered network protection for telecoms operators and financial institutions — confidence while connected, security of experience, security of economics.">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://www.blackdice.ai/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="BlackDice Cyber">
|
||||
<meta property="og:title" content="BlackDice | AI-Powered Cyber Defence">
|
||||
<meta property="og:description"
|
||||
content="AI-powered cyber defence for telecoms operators and financial institutions — protection embedded in the network.">
|
||||
<meta property="og:url" content="https://www.blackdice.ai/">
|
||||
<meta property="og:image" content="https://www.blackdice.ai/logo.svg">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="icon" href="/logo.svg" type="image/svg+xml">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="">
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;500;700&display=swap">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1862
package-lock.json
generated
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "blackdice-website-react",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:web": "vite",
|
||||
"dev:api": "node server/index.mjs",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"start": "node server/index.mjs",
|
||||
"preview": "vite preview",
|
||||
"cms:ids": "node scripts/inject-cms-ids.mjs",
|
||||
"cms:import": "node scripts/seed-content.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^11.18.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
BIN
public/BLACKDICE-RETINA.png
Normal file
|
After Width: | Height: | Size: 684 KiB |
BIN
public/BLACKDICE-UI.png
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
public/BlackDice-Halo.png
Normal file
|
After Width: | Height: | Size: 309 KiB |
3
public/_redirects
Normal file
@@ -0,0 +1,3 @@
|
||||
# Netlify: every real URL (/mobile-sdk, /blog/<slug>, /admin) boots the SPA.
|
||||
# The CMS API is only available when the site runs behind server/index.mjs.
|
||||
/* /index.html 200
|
||||
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 96 KiB |
BIN
public/content/posts/eu-6g-security-framework.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 360 KiB |
38
public/logo.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.blackdice.ai/sitemap.xml
|
||||
138
public/sitemap.xml
Normal file
@@ -0,0 +1,138 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/mobile-sdk</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/dns-protect</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/halo-cpe</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/for-operators</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/financial-services</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/why-blackdice</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/news</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/investors</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/contact</loc>
|
||||
<lastmod>2026-08-12</lastmod>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/organised-financial-crime-is-not-just-a-threat-its-an-industry</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/eu-6g-security-framework</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/blackdice-and-mercurius-partner-to-deliver-ai-powered-cybersecurity-across-latin</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/digital-sovereignty-who-controls-your-digital-future</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/the-fccs-router-extension-creates-a-wider-exploit-window</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/creating-trusted-digital-networks-in-south-east-asia</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/the-platform-layer-is-not-enough-why-online-safety-needs-the</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/three-cybersecurity-pillars-for-latam-telecom-operators</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/the-network-knows-first</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/mobile-cybersecurity-breeds-loyalty</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/from-speed-to-online-safety-cybersecurity-is-the-new-foundat</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/cybersecurity-blackdice-partners-with-bluecloud</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/stopping-vietnam-s-scam-epidemic-viettel-partners-with-black</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/connectivity-alone-isn-t-enough-value-and-trust-keeps-subscr</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/why-mvnos-can-t-afford-to-ignore-cybersecurity-as-a-growth-d</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/broadband-arpu-is-stalling-trust-might-be-the-fix</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.blackdice.ai/blog/subscriber-churn-is-a-symptom-here-s-the-cause</loc>
|
||||
<lastmod>2026-08-10</lastmod>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
27
public/web.config
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- IIS: SPA fallback so /mobile-sdk, /blog/<slug> and /admin resolve.
|
||||
When hosting the CMS, run server/index.mjs and reverse-proxy /api and
|
||||
/content/uploads to it instead of serving them from disk. -->
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<staticContent>
|
||||
<remove fileExtension=".json" />
|
||||
<mimeMap fileExtension=".json" mimeType="application/json" />
|
||||
<remove fileExtension=".webmanifest" />
|
||||
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||
</staticContent>
|
||||
<rewrite>
|
||||
<rules>
|
||||
<rule name="BlackDice SPA" stopProcessing="true">
|
||||
<match url=".*" />
|
||||
<conditions logicalGrouping="MatchAll">
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||
<add input="{REQUEST_URI}" pattern="^/api/" negate="true" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="/index.html" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
42
scripts/dev.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Dev runner: starts the CMS API (server/index.mjs) and the Vite dev server
|
||||
* together, so /admin can publish while you work on the site.
|
||||
*
|
||||
* npm run dev → API on 8787, site on 5173 (or $PORT)
|
||||
* npm run dev:web → Vite only (site reads factory content)
|
||||
* npm run dev:api → API only
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const API_PORT = process.env.API_PORT || '8787'
|
||||
|
||||
const children = []
|
||||
// Both processes are plain Node scripts, launched without a shell so paths with
|
||||
// spaces (C:\Program Files\nodejs\node.exe) are passed through intact.
|
||||
function run(name, args, env) {
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: ROOT,
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
if (code) console.error(`[${name}] exited with ${code}`)
|
||||
shutdown()
|
||||
})
|
||||
children.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
let closing = false
|
||||
function shutdown() {
|
||||
if (closing) return
|
||||
closing = true
|
||||
for (const child of children) child.kill()
|
||||
}
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
run('api', ['server/index.mjs'], { PORT: API_PORT })
|
||||
run('web', ['node_modules/vite/bin/vite.js'], { API_URL: `http://localhost:${API_PORT}` })
|
||||
47
scripts/generate-sitemap.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Writes public/sitemap.xml and public/robots.txt from the content that will ship
|
||||
* in the build. The server also serves a live sitemap at /sitemap.xml (which picks
|
||||
* up articles published after the build); this static copy is the fallback for
|
||||
* static hosting and for crawlers hitting the file directly.
|
||||
*
|
||||
* node scripts/generate-sitemap.mjs [siteUrl]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const SITE_URL = (process.argv[2] || process.env.SITE_URL || 'https://www.blackdice.ai').replace(/\/+$/, '')
|
||||
|
||||
const PATHS = [
|
||||
['/', '1.0'], ['/mobile-sdk', '0.9'], ['/dns-protect', '0.9'], ['/halo-cpe', '0.9'],
|
||||
['/for-operators', '0.8'], ['/financial-services', '0.8'], ['/why-blackdice', '0.7'],
|
||||
['/news', '0.8'], ['/investors', '0.6'], ['/contact', '0.7'],
|
||||
]
|
||||
|
||||
const seed = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/cms/generated/seedContent.json'), 'utf8'))
|
||||
const live = (() => {
|
||||
const file = path.join(ROOT, 'content/site-content.json')
|
||||
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null
|
||||
})()
|
||||
const posts = (live?.posts || seed.posts || []).filter((p) => p.status !== 'draft')
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const urls = [
|
||||
...PATHS.map(([p, pri]) => ({ loc: SITE_URL + p, lastmod: today, pri })),
|
||||
...posts.map((p) => ({
|
||||
loc: `${SITE_URL}/blog/${p.slug}`,
|
||||
lastmod: (p.updatedAt || p.date || today).slice(0, 10),
|
||||
pri: '0.6',
|
||||
})),
|
||||
]
|
||||
|
||||
const xml =
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
||||
urls
|
||||
.map((u) => ` <url>\n <loc>${u.loc}</loc>\n <lastmod>${u.lastmod}</lastmod>\n <priority>${u.pri}</priority>\n </url>`)
|
||||
.join('\n') +
|
||||
'\n</urlset>\n'
|
||||
|
||||
fs.writeFileSync(path.join(ROOT, 'public/sitemap.xml'), xml)
|
||||
fs.writeFileSync(path.join(ROOT, 'public/robots.txt'), `User-agent: *\nAllow: /\n\nSitemap: ${SITE_URL}/sitemap.xml\n`)
|
||||
console.log(`sitemap.xml: ${urls.length} URLs (${posts.length} articles) → ${SITE_URL}`)
|
||||
182
scripts/inject-cms-ids.mjs
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Injects CMS editing hooks into src/site/siteMarkup.txt.
|
||||
*
|
||||
* data-cms="cNNNN" → editable rich text (the element's innerHTML)
|
||||
* data-cms-img="imgNNN" → replaceable image (the element's src)
|
||||
* data-cms-num="nNNN" → editable animated stat (the element's data-count/data-suffix)
|
||||
*
|
||||
* Idempotent: elements that already carry an attribute keep their id, and new ids
|
||||
* continue from the highest number already present. Re-run it after editing the
|
||||
* markup by hand: node scripts/inject-cms-ids.mjs
|
||||
*
|
||||
* Also writes src/cms/generated/cmsFields.json — the field manifest the admin uses
|
||||
* to list and jump to every editable region.
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const MARKUP = path.join(ROOT, 'src/site/siteMarkup.txt')
|
||||
const MANIFEST = path.join(ROOT, 'src/cms/generated/cmsFields.json')
|
||||
|
||||
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
|
||||
// Never touch anything inside these — their internals are structural, not copy.
|
||||
const SKIP_SUBTREE = new Set(['svg', 'script', 'style', 'template', 'noscript'])
|
||||
// An element containing any of these is a container, not a text field: descend instead.
|
||||
const BLOCK = new Set([
|
||||
'div', 'section', 'article', 'aside', 'header', 'footer', 'nav', 'main',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'form', 'figure', 'figcaption', 'blockquote',
|
||||
'video', 'iframe', 'canvas', 'select', 'textarea', 'input', 'button', 'svg',
|
||||
])
|
||||
// Text holders worth exposing as a field.
|
||||
const CANDIDATE = new Set([
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'blockquote', 'cite', 'figcaption',
|
||||
'td', 'th', 'label', 'button', 'a', 'span', 'strong', 'em', 'small', 'dt', 'dd',
|
||||
'summary', 'div',
|
||||
])
|
||||
// Regions rewritten by the runtime (live threat feed, React mounts) — editing them
|
||||
// there would be silently discarded, so they are left alone.
|
||||
const SKIP_CLASS = /(?:^|\s)(?:feed-body|bd-mobile-demo-mount)(?:\s|$)/
|
||||
|
||||
const html = fs.readFileSync(MARKUP, 'utf8')
|
||||
|
||||
// ── Parse into a light tree with source offsets ────────────────────────────────
|
||||
const TOKEN = /<!--[\s\S]*?-->|<\/([a-zA-Z][\w:-]*)\s*>|<([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g
|
||||
|
||||
const root = { tag: '#root', children: [], parent: null, depth: 0 }
|
||||
const stack = [root]
|
||||
let last = 0
|
||||
let m
|
||||
while ((m = TOKEN.exec(html))) {
|
||||
if (html.slice(last, m.index).trim()) stack[stack.length - 1].hasText = true
|
||||
last = TOKEN.lastIndex
|
||||
const [, closeTag, openTag, attrs = '', selfClose] = m
|
||||
if (closeTag) {
|
||||
const tag = closeTag.toLowerCase()
|
||||
for (let i = stack.length - 1; i > 0; i--) {
|
||||
if (stack[i].tag === tag) {
|
||||
stack[i].contentEnd = m.index
|
||||
stack.length = i
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (openTag) {
|
||||
const tag = openTag.toLowerCase()
|
||||
const parent = stack[stack.length - 1]
|
||||
const node = {
|
||||
tag,
|
||||
attrs,
|
||||
nameEnd: m.index + 1 + openTag.length, // insertion point for new attributes
|
||||
contentStart: TOKEN.lastIndex,
|
||||
contentEnd: TOKEN.lastIndex,
|
||||
children: [],
|
||||
parent,
|
||||
hasText: false,
|
||||
}
|
||||
parent.children.push(node)
|
||||
if (!VOID.has(tag) && !selfClose) stack.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Existing ids (idempotency) ─────────────────────────────────────────────────
|
||||
const attrOf = (node, name) => {
|
||||
const hit = new RegExp(`${name}="([^"]*)"`).exec(node.attrs)
|
||||
return hit ? hit[1] : null
|
||||
}
|
||||
let maxText = 0, maxImg = 0, maxNum = 0
|
||||
const seen = { text: new Set(), img: new Set(), num: new Set() }
|
||||
const walkAll = (node, fn) => {
|
||||
fn(node)
|
||||
node.children.forEach((c) => walkAll(c, fn))
|
||||
}
|
||||
walkAll(root, (node) => {
|
||||
if (!node.attrs) return
|
||||
const t = attrOf(node, 'data-cms'), i = attrOf(node, 'data-cms-img'), n = attrOf(node, 'data-cms-num')
|
||||
if (t) { seen.text.add(t); maxText = Math.max(maxText, +t.slice(1) || 0) }
|
||||
if (i) { seen.img.add(i); maxImg = Math.max(maxImg, +i.slice(3) || 0) }
|
||||
if (n) { seen.num.add(n); maxNum = Math.max(maxNum, +n.slice(1) || 0) }
|
||||
})
|
||||
|
||||
// ── Decide what gets a hook ────────────────────────────────────────────────────
|
||||
const inserts = [] // { at, text }
|
||||
const fields = []
|
||||
|
||||
const textOf = (node) =>
|
||||
html.slice(node.contentStart, node.contentEnd).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
const pageOf = (node) => {
|
||||
for (let p = node; p; p = p.parent) {
|
||||
const id = p.attrs ? attrOf(p, 'id') : null
|
||||
if (id && id.startsWith('pg-')) return id.slice(3)
|
||||
}
|
||||
return 'global'
|
||||
}
|
||||
|
||||
const hasBlockDescendant = (node) =>
|
||||
node.children.some((c) => BLOCK.has(c.tag) || hasBlockDescendant(c))
|
||||
|
||||
const hasTextDeep = (node) => node.hasText || node.children.some(hasTextDeep)
|
||||
|
||||
function visit(node, { skipping = false, claimed = false } = {}) {
|
||||
for (const child of node.children) {
|
||||
const cls = child.attrs ? attrOf(child, 'class') || '' : ''
|
||||
const skip = skipping || SKIP_SUBTREE.has(child.tag) || SKIP_CLASS.test(cls)
|
||||
|
||||
if (!skip) {
|
||||
if (child.tag === 'img') {
|
||||
let id = attrOf(child, 'data-cms-img')
|
||||
if (!id) {
|
||||
id = 'img' + String(++maxImg).padStart(3, '0')
|
||||
inserts.push({ at: child.nameEnd, text: ` data-cms-img="${id}"` })
|
||||
}
|
||||
fields.push({ id, kind: 'image', page: pageOf(child), tag: 'img', preview: attrOf(child, 'alt') || attrOf(child, 'src') || '' })
|
||||
continue
|
||||
}
|
||||
|
||||
const isStat = child.attrs && attrOf(child, 'data-count') !== null
|
||||
if (isStat) {
|
||||
let id = attrOf(child, 'data-cms-num')
|
||||
if (!id) {
|
||||
id = 'n' + String(++maxNum).padStart(3, '0')
|
||||
inserts.push({ at: child.nameEnd, text: ` data-cms-num="${id}"` })
|
||||
}
|
||||
fields.push({
|
||||
id, kind: 'number', page: pageOf(child), tag: child.tag,
|
||||
preview: (attrOf(child, 'data-count') || '') + (attrOf(child, 'data-suffix') || ''),
|
||||
})
|
||||
continue // the animated value is the field; its text node is not
|
||||
}
|
||||
|
||||
const editable =
|
||||
!claimed && CANDIDATE.has(child.tag) && hasTextDeep(child) && !hasBlockDescendant(child)
|
||||
if (editable) {
|
||||
let id = attrOf(child, 'data-cms')
|
||||
if (!id) {
|
||||
id = 'c' + String(++maxText).padStart(4, '0')
|
||||
inserts.push({ at: child.nameEnd, text: ` data-cms="${id}"` })
|
||||
}
|
||||
fields.push({ id, kind: 'text', page: pageOf(child), tag: child.tag, preview: textOf(child).slice(0, 140) })
|
||||
visit(child, { skipping: skip, claimed: true })
|
||||
continue
|
||||
}
|
||||
}
|
||||
visit(child, { skipping: skip, claimed })
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────────────
|
||||
let out = html
|
||||
for (const ins of inserts.sort((a, b) => b.at - a.at)) {
|
||||
out = out.slice(0, ins.at) + ins.text + out.slice(ins.at)
|
||||
}
|
||||
fs.writeFileSync(MARKUP, out)
|
||||
fs.mkdirSync(path.dirname(MANIFEST), { recursive: true })
|
||||
fs.writeFileSync(MANIFEST, JSON.stringify(fields, null, 0) + '\n')
|
||||
|
||||
const byKind = (k) => fields.filter((f) => f.kind === k).length
|
||||
console.log(
|
||||
`siteMarkup.txt: +${inserts.length} new hooks (${fields.length} fields total — ` +
|
||||
`${byKind('text')} text, ${byKind('image')} image, ${byKind('number')} stat)`,
|
||||
)
|
||||
68
scripts/resize-images.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Downscales JPEG/PNG files in place — used by scripts/seed-content.mjs after it
|
||||
* extracts the base64 heroes out of a studio export (some originals are 8000px
|
||||
* wide and 12MB).
|
||||
*
|
||||
* node scripts/resize-images.mjs <dir> [maxWidth] [quality]
|
||||
*
|
||||
* Uses Windows' own imaging stack via PowerShell so the repo needs no native
|
||||
* image dependency. On other platforms it reports what it would have resized —
|
||||
* images uploaded through /admin are downscaled in the browser instead, so this
|
||||
* only affects the one-off import.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export function resizeDirectory(dir, maxWidth = 1600, quality = 82) {
|
||||
if (!fs.existsSync(dir)) return
|
||||
const files = fs.readdirSync(dir).filter((f) => /\.(jpe?g|png)$/i.test(f))
|
||||
const oversized = files.filter((f) => fs.statSync(path.join(dir, f)).size > 600 * 1024)
|
||||
if (!oversized.length) return
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
console.warn(
|
||||
` ! ${oversized.length} large image(s) in ${dir} were left as-is (resizing needs Windows imaging). ` +
|
||||
'Optimise them before deploying.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$enc = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' }
|
||||
$ps = New-Object System.Drawing.Imaging.EncoderParameters 1
|
||||
$ps.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, ${quality})
|
||||
Get-ChildItem -LiteralPath '${dir.replace(/'/g, "''")}' -Include *.jpg,*.jpeg,*.png -File -Recurse | ForEach-Object {
|
||||
$img = [System.Drawing.Image]::FromFile($_.FullName)
|
||||
if ($img.Width -le ${maxWidth}) { $img.Dispose(); return }
|
||||
$scale = ${maxWidth} / [double]$img.Width
|
||||
$nw = [int]($img.Width * $scale); $nh = [int]($img.Height * $scale)
|
||||
$bmp = New-Object System.Drawing.Bitmap $nw, $nh
|
||||
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||
$g.DrawImage($img, 0, 0, $nw, $nh)
|
||||
$g.Dispose(); $img.Dispose()
|
||||
$tmp = $_.FullName + '.tmp'
|
||||
$bmp.Save($tmp, $enc, $ps); $bmp.Dispose()
|
||||
Move-Item -Force $tmp $_.FullName
|
||||
Write-Output (" resized {0} -> {1}px, {2}KB" -f $_.Name, $nw, [int]((Get-Item $_.FullName).Length/1KB))
|
||||
}`
|
||||
|
||||
try {
|
||||
const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8' })
|
||||
if (out.trim()) console.log(out.trimEnd())
|
||||
} catch (err) {
|
||||
console.warn(` ! image resize failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone use
|
||||
if (process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]))) {
|
||||
const [dir, max, quality] = process.argv.slice(2)
|
||||
if (!dir) {
|
||||
console.error('usage: node scripts/resize-images.mjs <dir> [maxWidth] [quality]')
|
||||
process.exit(1)
|
||||
}
|
||||
resizeDirectory(path.resolve(dir), Number(max) || 1600, Number(quality) || 82)
|
||||
}
|
||||
161
scripts/seed-content.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Imports a blackdice-studio draft (blackdice-studio.json / any exported draft)
|
||||
* into this project's content model.
|
||||
*
|
||||
* node scripts/seed-content.mjs <path-to-blackdice-studio.json> [--content-dir <dir>]
|
||||
*
|
||||
* · base64 hero images are written out as real files under public/content/posts/
|
||||
* (the studio inlined them, which is what made its files 27MB)
|
||||
* · Word-pasted article bodies are sanitised down to semantic HTML so they inherit
|
||||
* the site's typography instead of carrying Aptos/black-on-dark inline styles
|
||||
* · the result is written to src/cms/generated/seedContent.json — the factory
|
||||
* content the site falls back to when nothing has been published yet
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { resizeDirectory } from './resize-images.mjs'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const OUT = path.join(ROOT, 'src/cms/generated/seedContent.json')
|
||||
const HERO_DIR = path.join(ROOT, 'public/content/posts')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const src = args.find((a) => !a.startsWith('--'))
|
||||
if (!src) {
|
||||
console.error('usage: node scripts/seed-content.mjs <path-to-blackdice-studio.json>')
|
||||
process.exit(1)
|
||||
}
|
||||
const contentDirIdx = args.indexOf('--content-dir')
|
||||
const studioContentDir = contentDirIdx > -1 ? args[contentDirIdx + 1] : path.join(path.dirname(src), 'content')
|
||||
|
||||
// ── Body sanitiser ─────────────────────────────────────────────────────────────
|
||||
// Word/Outlook paste carries inline styles that fight the site's design system.
|
||||
// Keep the structure, drop the decoration.
|
||||
const KEEP_TAGS = new Set([
|
||||
'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li', 'strong', 'em', 'b', 'i', 'u', 'a', 'br',
|
||||
'blockquote', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'hr',
|
||||
])
|
||||
const UNWRAP_TAGS = new Set(['span', 'div', 'font', 'section', 'article', 'o:p', 'st1:place', 'header', 'main'])
|
||||
const KEEP_ATTRS = { a: ['href'], img: ['src', 'alt'] }
|
||||
|
||||
function sanitiseBody(html) {
|
||||
let s = String(html || '')
|
||||
s = s.replace(/<!--[\s\S]*?-->/g, '')
|
||||
s = s.replace(/<\/?o:p[^>]*>/gi, '')
|
||||
s = s.replace(/<\/?(?:xml|style|script)[^>]*>[\s\S]*?<\/(?:xml|style|script)>/gi, '')
|
||||
// h1 in a body would compete with the page title
|
||||
s = s.replace(/<(\/?)h1\b/gi, '<$1h2')
|
||||
s = s.replace(/<(\/?)b\b/gi, '<$1strong').replace(/<(\/?)i\b/gi, '<$1em')
|
||||
|
||||
s = s.replace(/<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g, (_m, close, rawTag, attrs, selfClose) => {
|
||||
const tag = rawTag.toLowerCase()
|
||||
if (UNWRAP_TAGS.has(tag)) return ''
|
||||
if (!KEEP_TAGS.has(tag)) return ''
|
||||
if (close) return `</${tag}>`
|
||||
const keep = KEEP_ATTRS[tag] || []
|
||||
const kept = keep
|
||||
.map((name) => {
|
||||
const hit = new RegExp(`${name}\\s*=\\s*"([^"]*)"`, 'i').exec(attrs)
|
||||
return hit ? ` ${name}="${hit[1].trim()}"` : ''
|
||||
})
|
||||
.join('')
|
||||
const rel = tag === 'a' && /href\s*=\s*"https?:/i.test(attrs) ? ' target="_blank" rel="noopener"' : ''
|
||||
return `<${tag}${kept}${rel}${selfClose ? ' /' : ''}>`
|
||||
})
|
||||
|
||||
s = s.replace(/ /g, ' ')
|
||||
// Empty blocks left behind by unwrapping
|
||||
for (let i = 0; i < 4; i++) {
|
||||
s = s.replace(/<(p|h2|h3|h4|li|strong|em|u|blockquote)>\s*(?:<br\s*\/?>)*\s*<\/\1>/gi, '')
|
||||
}
|
||||
s = s.replace(/(<br\s*\/?>\s*){3,}/gi, '<br /><br />')
|
||||
s = s.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim()
|
||||
return s
|
||||
}
|
||||
|
||||
const excerptOf = (post) =>
|
||||
(post.excerpt || sanitiseBody(post.body).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 260)).trim()
|
||||
|
||||
// ── Hero extraction ────────────────────────────────────────────────────────────
|
||||
const EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg' }
|
||||
|
||||
function extractHero(post) {
|
||||
const hero = String(post.hero || '').trim()
|
||||
if (!hero) return ''
|
||||
if (/^https?:\/\//i.test(hero)) return hero
|
||||
const dataUrl = /^data:([^;]+);base64,(.*)$/s.exec(hero)
|
||||
if (dataUrl) {
|
||||
const ext = EXT[dataUrl[1].toLowerCase()] || 'bin'
|
||||
const file = `${post.slug}.${ext}`
|
||||
fs.mkdirSync(HERO_DIR, { recursive: true })
|
||||
fs.writeFileSync(path.join(HERO_DIR, file), Buffer.from(dataUrl[2], 'base64'))
|
||||
return `/content/posts/${file}`
|
||||
}
|
||||
// A studio "content/<file>" reference — copy it across if we were given the folder.
|
||||
const rel = hero.replace(/^\.?\/*/, '').replace(/^content\//, '')
|
||||
const from = path.join(studioContentDir, rel)
|
||||
if (fs.existsSync(from)) {
|
||||
fs.mkdirSync(HERO_DIR, { recursive: true })
|
||||
const file = `${post.slug}${path.extname(rel) || '.jpg'}`
|
||||
fs.copyFileSync(from, path.join(HERO_DIR, file))
|
||||
return `/content/posts/${file}`
|
||||
}
|
||||
console.warn(` ! hero not found for ${post.slug}: ${hero} — left empty`)
|
||||
return ''
|
||||
}
|
||||
|
||||
// ── Run ────────────────────────────────────────────────────────────────────────
|
||||
const draft = JSON.parse(fs.readFileSync(src, 'utf8'))
|
||||
if (!/^blackdice-(studio|cms)-draft$/.test(draft.format || '')) {
|
||||
console.warn(`warning: unexpected format "${draft.format}" — importing anyway`)
|
||||
}
|
||||
|
||||
// Posts authored in this repo rather than in the studio export (e.g. releases
|
||||
// prepared ahead of approval). They survive a re-import, and any slug clash is
|
||||
// resolved in their favour.
|
||||
const EXTRA = path.join(ROOT, 'content-seed/additional-posts.json')
|
||||
const extraPosts = fs.existsSync(EXTRA) ? JSON.parse(fs.readFileSync(EXTRA, 'utf8')) : []
|
||||
|
||||
const posts = (draft.posts || []).map((p) => ({
|
||||
id: p.slug,
|
||||
slug: p.slug,
|
||||
title: p.title || 'Untitled',
|
||||
category: ['insights', 'news', 'press', 'events'].includes(p.category) ? p.category : 'insights',
|
||||
date: (p.date || '').slice(0, 10),
|
||||
author: p.author || 'BlackDice Cyber',
|
||||
excerpt: excerptOf(p),
|
||||
hero: extractHero(p),
|
||||
body: sanitiseBody(p.body),
|
||||
status: 'published',
|
||||
updatedAt: draft.savedAt || new Date().toISOString(),
|
||||
}))
|
||||
|
||||
for (const extra of extraPosts) {
|
||||
const at = posts.findIndex((p) => p.slug === extra.slug)
|
||||
const normalised = { status: 'draft', author: 'BlackDice Cyber', hero: '', ...extra, id: extra.id || extra.slug }
|
||||
if (at > -1) posts[at] = normalised
|
||||
else posts.push(normalised)
|
||||
}
|
||||
posts.sort((a, b) => (a.date < b.date ? 1 : -1))
|
||||
|
||||
const seed = {
|
||||
format: 'blackdice-react-content',
|
||||
version: 1,
|
||||
savedAt: draft.savedAt || new Date().toISOString(),
|
||||
content: draft.content || {},
|
||||
images: draft.images || {},
|
||||
numbers: {},
|
||||
posts,
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true })
|
||||
fs.writeFileSync(OUT, JSON.stringify(seed, null, 2) + '\n')
|
||||
|
||||
// The studio inlined originals straight off a camera; serve something sensible.
|
||||
resizeDirectory(HERO_DIR)
|
||||
|
||||
const kb = (n) => (n / 1024).toFixed(0) + 'KB'
|
||||
console.log(`imported ${posts.length} posts → ${path.relative(ROOT, OUT)} (${kb(fs.statSync(OUT).size)})`)
|
||||
for (const p of posts) {
|
||||
console.log(` ${p.date} ${p.category.padEnd(8)} ${kb(p.body.length).padStart(6)} ${p.hero ? 'hero' : ' '} ${p.slug}`)
|
||||
}
|
||||
391
server/index.mjs
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* BlackDice site + CMS server.
|
||||
*
|
||||
* Serves the built site from dist/, the published content document from
|
||||
* CONTENT_DIR, and the small API that /admin writes through. Dependency-free
|
||||
* (node: built-ins only) so it runs anywhere Node runs — VPS, container, or
|
||||
* behind IIS/nginx as a reverse proxy.
|
||||
*
|
||||
* node server/index.mjs
|
||||
*
|
||||
* Environment
|
||||
* PORT listen port (default 8787)
|
||||
* ADMIN_PASSWORD password for /admin (required in production)
|
||||
* ADMIN_SECRET token signing secret (default: derived from password)
|
||||
* CONTENT_DIR where published content is kept (default ./content)
|
||||
* DIST_DIR built site to serve (default ./dist)
|
||||
* SITE_URL canonical origin for sitemap.xml (default https://www.blackdice.ai)
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const PORT = Number(process.env.PORT || 8787)
|
||||
const CONTENT_DIR = path.resolve(process.env.CONTENT_DIR || path.join(ROOT, 'content'))
|
||||
const DIST_DIR = path.resolve(process.env.DIST_DIR || path.join(ROOT, 'dist'))
|
||||
const UPLOAD_DIR = path.join(CONTENT_DIR, 'uploads')
|
||||
const VERSION_DIR = path.join(CONTENT_DIR, 'versions')
|
||||
const CONTENT_FILE = path.join(CONTENT_DIR, 'site-content.json')
|
||||
const LEADS_FILE = path.join(CONTENT_DIR, 'leads.jsonl')
|
||||
const SITE_URL = (process.env.SITE_URL || 'https://www.blackdice.ai').replace(/\/+$/, '')
|
||||
|
||||
const DEV_PASSWORD = 'blackdice'
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || DEV_PASSWORD
|
||||
const SECRET = process.env.ADMIN_SECRET || crypto.createHash('sha256').update('bd:' + ADMIN_PASSWORD).digest('hex')
|
||||
const SESSION_MS = 12 * 60 * 60 * 1000
|
||||
const MAX_BODY = 32 * 1024 * 1024 // uploads arrive as data URLs
|
||||
const KEEP_VERSIONS = 60
|
||||
|
||||
for (const dir of [CONTENT_DIR, UPLOAD_DIR, VERSION_DIR]) fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────────────
|
||||
const sign = (payload) => crypto.createHmac('sha256', SECRET).update(String(payload)).digest('base64url')
|
||||
|
||||
function issueToken() {
|
||||
const exp = Date.now() + SESSION_MS
|
||||
return { token: `${exp}.${sign(exp)}`, expiresAt: exp }
|
||||
}
|
||||
|
||||
function validToken(token) {
|
||||
const [exp, sig] = String(token || '').split('.')
|
||||
if (!exp || !sig || Number(exp) < Date.now()) return false
|
||||
const expected = sign(exp)
|
||||
const a = Buffer.from(sig)
|
||||
const b = Buffer.from(expected)
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
const bearer = (req) => (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
|
||||
const isAuthed = (req) => validToken(bearer(req))
|
||||
|
||||
// Crude but effective throttle: 10 failures per IP per 15 minutes.
|
||||
const failures = new Map()
|
||||
function tooManyAttempts(ip) {
|
||||
const rec = failures.get(ip)
|
||||
if (!rec) return false
|
||||
if (Date.now() - rec.first > 15 * 60 * 1000) {
|
||||
failures.delete(ip)
|
||||
return false
|
||||
}
|
||||
return rec.count >= 10
|
||||
}
|
||||
function noteFailure(ip) {
|
||||
const rec = failures.get(ip)
|
||||
if (!rec || Date.now() - rec.first > 15 * 60 * 1000) failures.set(ip, { first: Date.now(), count: 1 })
|
||||
else rec.count++
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
const send = (res, status, body, headers = {}) => {
|
||||
const payload = typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': typeof body === 'object' && !Buffer.isBuffer(body) ? 'application/json' : 'text/plain; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
...headers,
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0
|
||||
const chunks = []
|
||||
req.on('data', (c) => {
|
||||
size += c.length
|
||||
if (size > MAX_BODY) {
|
||||
reject(Object.assign(new Error('payload too large'), { status: 413 }))
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(c)
|
||||
})
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
|
||||
} catch {
|
||||
reject(Object.assign(new Error('invalid JSON'), { status: 400 }))
|
||||
}
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
const readContent = async () => {
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(CONTENT_FILE, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp', '.gif': 'image/gif', '.ico': 'image/x-icon', '.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8',
|
||||
'.xml': 'application/xml; charset=utf-8', '.pdf': 'application/pdf',
|
||||
}
|
||||
|
||||
/** Serves a file with range support (needed for <video> seeking). */
|
||||
async function serveFile(req, res, file, { immutable = false } = {}) {
|
||||
let stat
|
||||
try {
|
||||
stat = await fsp.stat(file)
|
||||
if (!stat.isFile()) return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const type = MIME[path.extname(file).toLowerCase()] || 'application/octet-stream'
|
||||
const cache = immutable ? 'public, max-age=31536000, immutable' : 'public, max-age=300'
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '')
|
||||
if (range) {
|
||||
const start = range[1] ? Number(range[1]) : 0
|
||||
const end = range[2] ? Number(range[2]) : stat.size - 1
|
||||
res.writeHead(206, {
|
||||
'Content-Type': type,
|
||||
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': end - start + 1,
|
||||
'Cache-Control': cache,
|
||||
})
|
||||
fs.createReadStream(file, { start, end }).pipe(res)
|
||||
return true
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': type,
|
||||
'Content-Length': stat.size,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': cache,
|
||||
})
|
||||
fs.createReadStream(file).pipe(res)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Resolves a URL path inside a root directory, refusing traversal. */
|
||||
function safeJoin(root, urlPath) {
|
||||
const decoded = decodeURIComponent(urlPath.split('?')[0])
|
||||
const target = path.resolve(root, '.' + path.posix.normalize(decoded))
|
||||
return target === root || target.startsWith(root + path.sep) ? target : null
|
||||
}
|
||||
|
||||
// ── API ────────────────────────────────────────────────────────────────────────
|
||||
const DATA_URL = /^data:([a-z0-9.+/-]+);base64,([A-Za-z0-9+/=\s]+)$/i
|
||||
const UPLOAD_EXT = {
|
||||
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif',
|
||||
'image/svg+xml': '.svg', 'video/mp4': '.mp4', 'video/webm': '.webm', 'application/pdf': '.pdf',
|
||||
}
|
||||
|
||||
async function handleApi(req, res, url) {
|
||||
const route = url.pathname
|
||||
const ip = req.socket.remoteAddress || 'unknown'
|
||||
|
||||
if (route === '/api/health') return send(res, 200, { ok: true, published: fs.existsSync(CONTENT_FILE) })
|
||||
|
||||
if (route === '/api/login' && req.method === 'POST') {
|
||||
if (tooManyAttempts(ip)) return send(res, 429, { error: 'Too many attempts. Try again later.' })
|
||||
const { password } = await readBody(req)
|
||||
const given = Buffer.from(String(password || ''))
|
||||
const expected = Buffer.from(ADMIN_PASSWORD)
|
||||
const ok = given.length === expected.length && crypto.timingSafeEqual(given, expected)
|
||||
if (!ok) {
|
||||
noteFailure(ip)
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
return send(res, 401, { error: 'Incorrect password.' })
|
||||
}
|
||||
failures.delete(ip)
|
||||
return send(res, 200, { ...issueToken(), usingDefaultPassword: ADMIN_PASSWORD === DEV_PASSWORD })
|
||||
}
|
||||
|
||||
if (route === '/api/content' && req.method === 'GET') {
|
||||
const doc = await readContent()
|
||||
return send(res, 200, doc || { published: false })
|
||||
}
|
||||
|
||||
if (route === '/api/leads' && req.method === 'POST') {
|
||||
const body = await readBody(req)
|
||||
const lead = {
|
||||
at: new Date().toISOString(),
|
||||
form: String(body.form || 'enquiry').slice(0, 40),
|
||||
name: String(body.name || '').slice(0, 200),
|
||||
email: String(body.email || '').slice(0, 200),
|
||||
company: String(body.company || '').slice(0, 200),
|
||||
phone: String(body.phone || '').slice(0, 60),
|
||||
message: String(body.message || '').slice(0, 4000),
|
||||
page: String(body.page || '').slice(0, 300),
|
||||
ip,
|
||||
}
|
||||
if (!lead.email) return send(res, 400, { error: 'email required' })
|
||||
await fsp.appendFile(LEADS_FILE, JSON.stringify(lead) + '\n')
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
// ── everything below requires a session ──
|
||||
if (!isAuthed(req)) return send(res, 401, { error: 'Not signed in.' })
|
||||
|
||||
if (route === '/api/session') return send(res, 200, { ok: true })
|
||||
|
||||
if (route === '/api/content' && req.method === 'POST') {
|
||||
const doc = await readBody(req)
|
||||
if (doc?.format !== 'blackdice-react-content') return send(res, 400, { error: 'Unexpected content format.' })
|
||||
if (!Array.isArray(doc.posts)) return send(res, 400, { error: 'posts must be an array.' })
|
||||
const previous = await readContent()
|
||||
if (previous) {
|
||||
await fsp.writeFile(path.join(VERSION_DIR, `site-content-${stamp()}.json`), JSON.stringify(previous))
|
||||
const files = (await fsp.readdir(VERSION_DIR)).filter((f) => f.endsWith('.json')).sort()
|
||||
for (const old of files.slice(0, Math.max(0, files.length - KEEP_VERSIONS))) {
|
||||
await fsp.rm(path.join(VERSION_DIR, old), { force: true })
|
||||
}
|
||||
}
|
||||
doc.savedAt = new Date().toISOString()
|
||||
const tmp = CONTENT_FILE + '.tmp'
|
||||
await fsp.writeFile(tmp, JSON.stringify(doc, null, 2))
|
||||
await fsp.rename(tmp, CONTENT_FILE) // atomic: readers never see a half-written file
|
||||
return send(res, 200, { ok: true, savedAt: doc.savedAt })
|
||||
}
|
||||
|
||||
if (route === '/api/versions' && req.method === 'GET') {
|
||||
const files = (await fsp.readdir(VERSION_DIR)).filter((f) => f.endsWith('.json')).sort().reverse()
|
||||
const list = await Promise.all(
|
||||
files.map(async (name) => ({ name, size: (await fsp.stat(path.join(VERSION_DIR, name))).size })),
|
||||
)
|
||||
return send(res, 200, { versions: list })
|
||||
}
|
||||
|
||||
if (route.startsWith('/api/versions/') && req.method === 'GET') {
|
||||
const name = path.basename(route.slice('/api/versions/'.length))
|
||||
if (!/^site-content-[\w-]+\.json$/.test(name)) return send(res, 400, { error: 'bad version name' })
|
||||
try {
|
||||
return send(res, 200, JSON.parse(await fsp.readFile(path.join(VERSION_DIR, name), 'utf8')))
|
||||
} catch {
|
||||
return send(res, 404, { error: 'not found' })
|
||||
}
|
||||
}
|
||||
|
||||
if (route === '/api/upload' && req.method === 'POST') {
|
||||
const { name, dataUrl } = await readBody(req)
|
||||
const parsed = DATA_URL.exec(String(dataUrl || ''))
|
||||
if (!parsed) return send(res, 400, { error: 'Expected a base64 data URL.' })
|
||||
const ext = UPLOAD_EXT[parsed[1].toLowerCase()]
|
||||
if (!ext) return send(res, 415, { error: `Unsupported type: ${parsed[1]}` })
|
||||
const base = String(name || 'asset')
|
||||
.replace(/\.[^.]*$/, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60) || 'asset'
|
||||
const file = `${stamp()}-${base}${ext}`
|
||||
await fsp.writeFile(path.join(UPLOAD_DIR, file), Buffer.from(parsed[2], 'base64'))
|
||||
return send(res, 200, { url: `/content/uploads/${file}` })
|
||||
}
|
||||
|
||||
if (route === '/api/uploads' && req.method === 'GET') {
|
||||
const files = (await fsp.readdir(UPLOAD_DIR)).sort().reverse()
|
||||
return send(res, 200, { uploads: files.map((f) => ({ name: f, url: `/content/uploads/${f}` })) })
|
||||
}
|
||||
|
||||
if (route === '/api/leads' && req.method === 'GET') {
|
||||
let text = ''
|
||||
try {
|
||||
text = await fsp.readFile(LEADS_FILE, 'utf8')
|
||||
} catch {}
|
||||
const leads = text.trim().split('\n').filter(Boolean).map((l) => {
|
||||
try { return JSON.parse(l) } catch { return null }
|
||||
}).filter(Boolean).reverse().slice(0, 200)
|
||||
return send(res, 200, { leads })
|
||||
}
|
||||
|
||||
return send(res, 404, { error: 'Unknown endpoint' })
|
||||
}
|
||||
|
||||
// ── sitemap / robots ───────────────────────────────────────────────────────────
|
||||
const SITE_PATHS = [
|
||||
['/', '1.0'], ['/mobile-sdk', '0.9'], ['/dns-protect', '0.9'], ['/halo-cpe', '0.9'],
|
||||
['/for-operators', '0.8'], ['/financial-services', '0.8'], ['/why-blackdice', '0.7'],
|
||||
['/news', '0.8'], ['/investors', '0.6'], ['/contact', '0.7'],
|
||||
]
|
||||
|
||||
async function sitemap() {
|
||||
const doc = await readContent()
|
||||
let posts = doc?.posts
|
||||
if (!posts) {
|
||||
// Fall back to the factory content shipped in the build.
|
||||
try {
|
||||
posts = JSON.parse(await fsp.readFile(path.join(ROOT, 'src/cms/generated/seedContent.json'), 'utf8')).posts
|
||||
} catch {
|
||||
posts = []
|
||||
}
|
||||
}
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const urls = [
|
||||
...SITE_PATHS.map(([p, pri]) => ({ loc: SITE_URL + p, lastmod: today, pri })),
|
||||
...posts
|
||||
.filter((p) => p.status !== 'draft')
|
||||
.map((p) => ({ loc: `${SITE_URL}/blog/${p.slug}`, lastmod: (p.updatedAt || p.date || today).slice(0, 10), pri: '0.6' })),
|
||||
]
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
|
||||
urls
|
||||
.map((u) => ` <url>\n <loc>${u.loc}</loc>\n <lastmod>${u.lastmod}</lastmod>\n <priority>${u.pri}</priority>\n </url>`)
|
||||
.join('\n') +
|
||||
'\n</urlset>\n'
|
||||
)
|
||||
}
|
||||
|
||||
// ── Server ─────────────────────────────────────────────────────────────────────
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
||||
try {
|
||||
if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url)
|
||||
|
||||
if (url.pathname === '/sitemap.xml') {
|
||||
return send(res, 200, await sitemap(), { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 'public, max-age=3600' })
|
||||
}
|
||||
if (url.pathname === '/robots.txt') {
|
||||
return send(res, 200, `User-agent: *\nAllow: /\n\nSitemap: ${SITE_URL}/sitemap.xml\n`)
|
||||
}
|
||||
|
||||
// Uploaded media lives outside the build so publishing never overwrites it.
|
||||
if (url.pathname.startsWith('/content/uploads/')) {
|
||||
const file = safeJoin(UPLOAD_DIR, url.pathname.slice('/content/uploads'.length))
|
||||
if (file && (await serveFile(req, res, file, { immutable: true }))) return
|
||||
return send(res, 404, 'Not found')
|
||||
}
|
||||
// The published content document, read by the site on load.
|
||||
if (url.pathname === '/content/site-content.json') {
|
||||
const doc = await readContent()
|
||||
return send(res, 200, doc || { published: false })
|
||||
}
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed')
|
||||
|
||||
const asset = safeJoin(DIST_DIR, url.pathname)
|
||||
if (asset) {
|
||||
const immutable = url.pathname.startsWith('/assets/')
|
||||
if (await serveFile(req, res, asset, { immutable })) return
|
||||
if (await serveFile(req, res, path.join(asset, 'index.html'))) return
|
||||
}
|
||||
// SPA fallback — every real URL (/mobile-sdk, /blog/slug, /admin) boots the app.
|
||||
if (await serveFile(req, res, path.join(DIST_DIR, 'index.html'))) return
|
||||
return send(res, 404, 'Not found — run `npm run build` first.')
|
||||
} catch (err) {
|
||||
const status = err?.status || 500
|
||||
if (status >= 500) console.error(err)
|
||||
return send(res, status, { error: err?.message || 'Server error' })
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`BlackDice server → http://localhost:${PORT}`)
|
||||
console.log(` content dir ${CONTENT_DIR}`)
|
||||
console.log(` serving ${DIST_DIR}`)
|
||||
if (ADMIN_PASSWORD === DEV_PASSWORD) {
|
||||
console.log(` ⚠ ADMIN_PASSWORD is unset — /admin accepts "${DEV_PASSWORD}". Set it before deploying.`)
|
||||
}
|
||||
})
|
||||
287
src/cms/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import SiteApp from '../../site/SiteApp'
|
||||
import { checkSession, fileToOptimisedDataUrl, getToken, login, setToken, uploadAsset } from '../api'
|
||||
import { useDraft } from './useDraft'
|
||||
import { useInlineEditor } from './useInlineEditor'
|
||||
import { originals } from './originals'
|
||||
import PagesPanel from './PagesPanel'
|
||||
import PostsPanel from './PostsPanel'
|
||||
import DemosPanel from './DemosPanel'
|
||||
import SeoPanel from './SeoPanel'
|
||||
import LeadsPanel from './LeadsPanel'
|
||||
import HistoryPanel from './HistoryPanel'
|
||||
import './admin.css'
|
||||
|
||||
type Tab = 'pages' | 'articles' | 'demos' | 'seo' | 'enquiries' | 'history'
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'pages', label: 'Pages' },
|
||||
{ id: 'articles', label: 'Articles' },
|
||||
{ id: 'demos', label: 'Demos' },
|
||||
{ id: 'seo', label: 'SEO' },
|
||||
{ id: 'enquiries', label: 'Enquiries' },
|
||||
{ id: 'history', label: 'History' },
|
||||
]
|
||||
|
||||
/**
|
||||
* BlackDice Studio — the single admin route.
|
||||
*
|
||||
* The editor sits beside a live copy of the real site: what you see is the site
|
||||
* rendering the draft you are editing. Publishing writes one JSON document; it
|
||||
* never regenerates index.html or blog.html, which is why it cannot wipe work the
|
||||
* way the old blackdice-studio.html export did.
|
||||
*/
|
||||
export default function AdminApp() {
|
||||
const [authed, setAuthed] = useState(Boolean(getToken()))
|
||||
const [checking, setChecking] = useState(Boolean(getToken()))
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) return
|
||||
checkSession()
|
||||
.then(() => setAuthed(true))
|
||||
.catch(() => {
|
||||
setToken('')
|
||||
setAuthed(false)
|
||||
})
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
|
||||
if (!authed) return <LoginScreen onSignedIn={() => setAuthed(true)} busy={checking} />
|
||||
return <Studio onSignOut={() => {
|
||||
setToken('')
|
||||
setAuthed(false)
|
||||
}} />
|
||||
}
|
||||
|
||||
// ── Sign in ────────────────────────────────────────────────────────────────────
|
||||
function LoginScreen({ onSignedIn, busy }: { onSignedIn: () => void; busy: boolean }) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [working, setWorking] = useState(false)
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setWorking(true)
|
||||
setError('')
|
||||
try {
|
||||
const { token, usingDefaultPassword } = await login(password)
|
||||
setToken(token)
|
||||
if (usingDefaultPassword) {
|
||||
console.warn('ADMIN_PASSWORD is not set on the server — /admin is using the development password.')
|
||||
}
|
||||
onSignedIn()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not sign in.')
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bdcms-login">
|
||||
<form className="bdcms-login-box" onSubmit={submit}>
|
||||
<h1>BlackDice Studio</h1>
|
||||
<p>Sign in to edit the website, publish articles and manage the product demos.</p>
|
||||
<div className="bdcms-field">
|
||||
<label>PASSWORD</label>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="bdcms-btn primary" type="submit" disabled={working || busy || !password}>
|
||||
{working ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
{error ? <p className="bdcms-err">{error}</p> : null}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Editor ─────────────────────────────────────────────────────────────────────
|
||||
function Studio({ onSignOut }: { onSignOut: () => void }) {
|
||||
const { draft, published, dirty, saving, status, error, update, replace, publishNow, discard } = useDraft(true)
|
||||
const [tab, setTab] = useState<Tab>('pages')
|
||||
const [page, setPage] = useState('p1')
|
||||
const [postSlug, setPostSlug] = useState('')
|
||||
const [editing, setEditing] = useState(true)
|
||||
const [toast, setToast] = useState('')
|
||||
const [focusedStat, setFocusedStat] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const pendingImage = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add('bd-admin')
|
||||
return () => document.body.classList.remove('bd-admin')
|
||||
}, [])
|
||||
|
||||
const flash = useCallback((message: string) => {
|
||||
setToast(message)
|
||||
window.setTimeout(() => setToast((current) => (current === message ? '' : current)), 3200)
|
||||
}, [])
|
||||
|
||||
// Warn before losing unpublished work.
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const warn = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
}, [dirty])
|
||||
|
||||
const pickImage = useCallback((id: string) => {
|
||||
pendingImage.current = id
|
||||
fileRef.current?.click()
|
||||
}, [])
|
||||
|
||||
const onImageFile = async (file?: File) => {
|
||||
const id = pendingImage.current
|
||||
pendingImage.current = null
|
||||
if (!file || !id) return
|
||||
if (!/^image\//.test(file.type)) {
|
||||
flash('Choose an image file (PNG, JPG, SVG or WebP).')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dataUrl = await fileToOptimisedDataUrl(file)
|
||||
const { url } = await uploadAsset(file.name, dataUrl)
|
||||
update((d) => {
|
||||
d.images[id] = url
|
||||
})
|
||||
flash('Image replaced — publish to make it live.')
|
||||
} catch (err) {
|
||||
flash(err instanceof Error ? err.message : 'Upload failed.')
|
||||
}
|
||||
}
|
||||
|
||||
// Inline editing is only live on the Pages tab, in Edit mode.
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
onText: (id: string, html: string) =>
|
||||
update((d) => {
|
||||
if (html === originals().text[id]) delete d.content[id]
|
||||
else d.content[id] = html
|
||||
}),
|
||||
onPickImage: pickImage,
|
||||
onPickNumber: (id: string) => {
|
||||
setTab('pages')
|
||||
setFocusedStat(id)
|
||||
flash('Edit that number in the panel — it animates as visitors scroll.')
|
||||
},
|
||||
}),
|
||||
[update, pickImage, flash],
|
||||
)
|
||||
useInlineEditor(tab === 'pages' && editing, handlers)
|
||||
|
||||
// What the preview shows follows the tab you are working in.
|
||||
const previewArticle = tab === 'articles' ? postSlug : ''
|
||||
const previewPage = tab === 'demos' && page !== 'p2' && page !== 'p3' ? 'p2' : page
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div className="bdcms-login">
|
||||
<p style={{ color: 'var(--cms-mut)' }}>Loading the site content…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="bdcms-side">
|
||||
<div className="bdcms-head">
|
||||
<div className="bdcms-title">
|
||||
BlackDice Studio<span>SITE + ARTICLES</span>
|
||||
</div>
|
||||
<div className="bdcms-status">
|
||||
<span className={`bdcms-dot${dirty ? ' dirty' : published?.savedAt ? ' saved' : ''}`} />
|
||||
{dirty ? status || 'UNSAVED CHANGES' : status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.id} className={tab === t.id ? 'on' : ''} onClick={() => setTab(t.id)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bdcms-body">
|
||||
{tab === 'pages' ? (
|
||||
<PagesPanel
|
||||
draft={draft}
|
||||
update={update}
|
||||
page={page}
|
||||
onPage={setPage}
|
||||
editing={editing}
|
||||
onEditing={setEditing}
|
||||
pickImage={pickImage}
|
||||
focusedStat={focusedStat}
|
||||
toast={flash}
|
||||
/>
|
||||
) : null}
|
||||
{tab === 'articles' ? (
|
||||
<PostsPanel draft={draft} update={update} selected={postSlug} onSelect={setPostSlug} toast={flash} />
|
||||
) : null}
|
||||
{tab === 'demos' ? (
|
||||
<DemosPanel draft={draft} update={update} onPreviewPage={setPage} toast={flash} />
|
||||
) : null}
|
||||
{tab === 'seo' ? <SeoPanel draft={draft} update={update} page={page} onPage={setPage} /> : null}
|
||||
{tab === 'enquiries' ? <LeadsPanel /> : null}
|
||||
{tab === 'history' ? (
|
||||
<HistoryPanel draft={draft} published={published} replace={replace} discard={discard} toast={flash} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="bdcms-foot">
|
||||
<button
|
||||
className="bdcms-btn primary"
|
||||
disabled={saving || !dirty}
|
||||
onClick={async () => {
|
||||
if (await publishNow()) flash('Published. The live site is updated.')
|
||||
}}>
|
||||
{saving ? 'Publishing…' : dirty ? 'Publish changes' : 'Everything is published'}
|
||||
</button>
|
||||
{error ? <p className="bdcms-err">{error}</p> : null}
|
||||
<div className="bdcms-row">
|
||||
<button className="bdcms-btn ghost" onClick={() => window.open('/', '_blank')}>
|
||||
Open live site
|
||||
</button>
|
||||
<button className="bdcms-btn ghost" onClick={onSignOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* The real site, rendering the draft. */}
|
||||
<SiteApp
|
||||
key={previewArticle ? 'article' : 'page'}
|
||||
pageId={previewPage}
|
||||
articleSlug={previewArticle || undefined}
|
||||
previewContent={draft}
|
||||
suppressMeta
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
void onImageFile(file)
|
||||
}}
|
||||
/>
|
||||
|
||||
{toast ? <div className="bdcms-toast">{toast}</div> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
166
src/cms/admin/DemosPanel.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import type { DemoSlot, SiteContent } from '../types'
|
||||
import { DEMO_SLOTS, pageById } from '../pages'
|
||||
import { fileToOptimisedDataUrl, uploadAsset } from '../api'
|
||||
|
||||
interface Props {
|
||||
draft: SiteContent
|
||||
update: (mutate: (draft: SiteContent) => void) => void
|
||||
onPreviewPage: (page: string) => void
|
||||
toast: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The demo panels on the Mobile SDK and Halo CPE pages. Each slot either plays an
|
||||
* uploaded clip or falls back to the interactive demo built into the site, so the
|
||||
* clips can be produced and dropped in without any code change.
|
||||
*/
|
||||
export default function DemosPanel({ draft, update, onPreviewPage, toast }: Props) {
|
||||
const [busy, setBusy] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const target = useRef<{ key: string; field: 'video' | 'poster' } | null>(null)
|
||||
|
||||
const slotOf = (key: string): DemoSlot =>
|
||||
draft.demos[key] || DEMO_SLOTS.find((s) => s.key === key)!.defaults
|
||||
|
||||
const patch = (key: string, changes: Partial<DemoSlot>) =>
|
||||
update((d) => {
|
||||
d.demos[key] = { ...slotOf(key), ...changes }
|
||||
})
|
||||
|
||||
const pick = (key: string, field: 'video' | 'poster') => {
|
||||
target.current = { key, field }
|
||||
if (fileRef.current) {
|
||||
fileRef.current.accept = field === 'video' ? 'video/mp4,video/webm' : 'image/*'
|
||||
fileRef.current.click()
|
||||
}
|
||||
}
|
||||
|
||||
const onFile = async (file?: File) => {
|
||||
const spec = target.current
|
||||
target.current = null
|
||||
if (!file || !spec) return
|
||||
setBusy(`${spec.key}:${spec.field}`)
|
||||
try {
|
||||
const dataUrl =
|
||||
spec.field === 'video'
|
||||
? await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(new Error('Could not read that file.'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
: await fileToOptimisedDataUrl(file, 1600, 0.82)
|
||||
const { url } = await uploadAsset(`${spec.key}-${spec.field}`, dataUrl)
|
||||
patch(spec.key, { [spec.field]: url } as Partial<DemoSlot>)
|
||||
toast(`${spec.field === 'video' ? 'Clip' : 'Poster'} uploaded — publish to make it live.`)
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Upload failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = ['p2', 'p3'].map((page) => ({ page, slots: DEMO_SLOTS.filter((s) => s.page === page) }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="bdcms-note">
|
||||
Clips are capped at 32MB per upload. For anything longer, host the file and paste its URL instead.
|
||||
</p>
|
||||
|
||||
{grouped.map(({ page, slots }) => (
|
||||
<div key={page}>
|
||||
<div className="bdcms-h">
|
||||
{pageById(page)?.name.toUpperCase()} · {pageById(page)?.path}
|
||||
</div>
|
||||
<button className="bdcms-btn ghost" onClick={() => onPreviewPage(page)}>
|
||||
Show this page in the preview →
|
||||
</button>
|
||||
|
||||
{slots.map((def) => {
|
||||
const slot = slotOf(def.key)
|
||||
return (
|
||||
<div key={def.key} style={{ marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--cms-line)' }}>
|
||||
<div className="bdcms-field">
|
||||
<label>
|
||||
{def.key}
|
||||
{def.scenario ? ' · HAS INTERACTIVE FALLBACK' : ''}
|
||||
</label>
|
||||
<input className="bdcms-inp" value={slot.title} onChange={(e) => patch(def.key, { title: e.target.value })} />
|
||||
</div>
|
||||
<div className="bdcms-field">
|
||||
<label>CAPTION</label>
|
||||
<textarea
|
||||
className="bdcms-inp"
|
||||
rows={3}
|
||||
value={slot.caption}
|
||||
onChange={(e) => patch(def.key, { caption: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="bdcms-field">
|
||||
<label>CLIP · MP4 OR WEBM</label>
|
||||
<div className="bdcms-row">
|
||||
<button className="bdcms-btn" disabled={busy === `${def.key}:video`} onClick={() => pick(def.key, 'video')}>
|
||||
{busy === `${def.key}:video` ? 'Uploading…' : slot.video ? 'Replace clip' : 'Upload clip'}
|
||||
</button>
|
||||
{slot.video ? (
|
||||
<button className="bdcms-btn danger" onClick={() => patch(def.key, { video: '' })}>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
style={{ marginTop: 8 }}
|
||||
placeholder="…or paste a video URL"
|
||||
value={slot.video}
|
||||
onChange={(e) => patch(def.key, { video: e.target.value.trim() })}
|
||||
/>
|
||||
</div>
|
||||
<div className="bdcms-field">
|
||||
<label>POSTER STILL</label>
|
||||
<div className="bdcms-row">
|
||||
<button className="bdcms-btn" disabled={busy === `${def.key}:poster`} onClick={() => pick(def.key, 'poster')}>
|
||||
{busy === `${def.key}:poster` ? 'Uploading…' : slot.poster ? 'Replace poster' : 'Upload poster'}
|
||||
</button>
|
||||
<button
|
||||
className={`bdcms-btn${slot.enabled ? '' : ' danger'}`}
|
||||
onClick={() => patch(def.key, { enabled: !slot.enabled })}>
|
||||
{slot.enabled ? 'Shown on the page' : 'Hidden'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
style={{ marginTop: 8 }}
|
||||
placeholder="…or paste an image URL"
|
||||
value={slot.poster}
|
||||
onChange={(e) => patch(def.key, { poster: e.target.value.trim() })}
|
||||
/>
|
||||
</div>
|
||||
<p className="bdcms-hint">
|
||||
{slot.video
|
||||
? 'Playing the uploaded clip.'
|
||||
: def.scenario
|
||||
? 'No clip uploaded — the interactive demo plays instead.'
|
||||
: 'No clip uploaded — nothing is shown on the page yet.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
void onFile(file)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
158
src/cms/admin/HistoryPanel.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Post, SiteContent } from '../types'
|
||||
import { getVersion, listVersions } from '../api'
|
||||
import { resolveContent } from '../store'
|
||||
import { slugify } from '../sanitise'
|
||||
|
||||
interface Props {
|
||||
draft: SiteContent
|
||||
published: SiteContent | null
|
||||
replace: (next: SiteContent, note?: string) => void
|
||||
discard: () => void
|
||||
toast: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Every publish snapshots the previous document server-side, so any change can be
|
||||
* rolled back. This panel also imports and exports drafts — including the old
|
||||
* blackdice-studio.html JSON exports, so nothing done in the old tool is stranded.
|
||||
*/
|
||||
export default function HistoryPanel({ draft, published, replace, discard, toast }: Props) {
|
||||
const [versions, setVersions] = useState<{ name: string; size: number }[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const refresh = () => {
|
||||
listVersions()
|
||||
.then((r) => setVersions(r.versions))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : 'Could not list versions.'))
|
||||
}
|
||||
useEffect(refresh, [])
|
||||
|
||||
const restore = async (name: string) => {
|
||||
if (!window.confirm(`Load the snapshot from ${label(name)} into the editor? Nothing goes live until you publish.`)) return
|
||||
try {
|
||||
const doc = await getVersion(name)
|
||||
replace(await resolveContent(doc), `SNAPSHOT ${label(name)} LOADED`)
|
||||
toast('Snapshot loaded into the editor. Review it, then publish.')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not load that snapshot.')
|
||||
}
|
||||
}
|
||||
|
||||
const exportDraft = () => {
|
||||
const blob = new Blob([JSON.stringify(draft, null, 2)], { type: 'application/json' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = `blackdice-content-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(a.href)
|
||||
a.remove()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
const importFile = async (file?: File) => {
|
||||
if (!file) return
|
||||
try {
|
||||
const parsed = JSON.parse(await file.text())
|
||||
if (parsed.format === 'blackdice-react-content') {
|
||||
replace(await resolveContent(parsed), 'CONTENT FILE IMPORTED')
|
||||
toast('Content imported into the editor.')
|
||||
return
|
||||
}
|
||||
// A studio-era draft: it carries posts (and possibly nothing else).
|
||||
if (/^blackdice-(studio|cms)-draft$/.test(parsed.format || '') || parsed.format === 'blackdice-blog-library') {
|
||||
const imported: Post[] = (parsed.posts || []).map((p: Record<string, string>) => ({
|
||||
id: p.slug || slugify(String(p.title || 'untitled')),
|
||||
slug: p.slug || slugify(String(p.title || 'untitled')),
|
||||
title: p.title || 'Untitled',
|
||||
category: ['insights', 'news', 'press', 'events'].includes(p.category) ? p.category : 'insights',
|
||||
date: (p.date || new Date().toISOString()).slice(0, 10),
|
||||
author: p.author || 'BlackDice Cyber',
|
||||
excerpt: p.excerpt || '',
|
||||
hero: p.hero || '',
|
||||
body: p.body || '',
|
||||
status: 'published',
|
||||
updatedAt: new Date().toISOString(),
|
||||
})) as Post[]
|
||||
if (!imported.length) {
|
||||
setError('That file contains no articles.')
|
||||
return
|
||||
}
|
||||
const merged = structuredClone(draft)
|
||||
for (const post of imported) {
|
||||
const existing = merged.posts.findIndex((p) => p.slug === post.slug)
|
||||
if (existing > -1) merged.posts[existing] = { ...merged.posts[existing], ...post }
|
||||
else merged.posts.push(post)
|
||||
}
|
||||
replace(merged, `${imported.length} ARTICLES IMPORTED FROM STUDIO`)
|
||||
toast(`${imported.length} articles imported from the studio draft.`)
|
||||
return
|
||||
}
|
||||
setError('That file is not a BlackDice content file or studio draft.')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not read that file.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bdcms-h">LIVE SITE</div>
|
||||
<p className="bdcms-hint">
|
||||
{published?.savedAt
|
||||
? `Last published ${new Date(published.savedAt).toLocaleString()}.`
|
||||
: 'Nothing has been published yet — the site is showing the content it shipped with.'}
|
||||
</p>
|
||||
<button className="bdcms-btn" onClick={discard}>
|
||||
Discard my draft
|
||||
<small>Throw away local edits and show exactly what is live</small>
|
||||
</button>
|
||||
|
||||
<div className="bdcms-h">SNAPSHOTS · {versions.length}</div>
|
||||
<p className="bdcms-hint">Taken automatically each time you publish. Loading one only fills the editor.</p>
|
||||
<div className="bdcms-list">
|
||||
{versions.map((v) => (
|
||||
<button key={v.name} className="bdcms-item" onClick={() => restore(v.name)}>
|
||||
{label(v.name)}
|
||||
<span className="meta">{(v.size / 1024).toFixed(0)}KB · click to load into the editor</span>
|
||||
</button>
|
||||
))}
|
||||
{!versions.length ? <p className="bdcms-hint">No snapshots yet.</p> : null}
|
||||
</div>
|
||||
<button className="bdcms-btn ghost" onClick={refresh}>
|
||||
Refresh list
|
||||
</button>
|
||||
|
||||
<div className="bdcms-h">BACKUP AND IMPORT</div>
|
||||
<button className="bdcms-btn" onClick={exportDraft}>
|
||||
Download this draft
|
||||
<small>A single JSON file with all copy, articles, demos and settings</small>
|
||||
</button>
|
||||
<button className="bdcms-btn" style={{ marginTop: 8 }} onClick={() => fileRef.current?.click()}>
|
||||
Import a file
|
||||
<small>Accepts a BlackDice content file or an old blackdice-studio draft</small>
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
void importFile(file)
|
||||
}}
|
||||
/>
|
||||
|
||||
{error ? <p className="bdcms-err">{error}</p> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const label = (name: string) => {
|
||||
const stamp = name.replace(/^site-content-|\.json$/g, '')
|
||||
const iso = stamp.replace(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})$/, '$1-$2-$3 $4:$5:$6')
|
||||
return iso || name
|
||||
}
|
||||
72
src/cms/admin/LeadsPanel.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { listLeads } from '../api'
|
||||
|
||||
/** Enquiries captured by the site's forms, newest first. */
|
||||
export default function LeadsPanel() {
|
||||
const [leads, setLeads] = useState<Record<string, string>[]>([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const refresh = () => {
|
||||
listLeads()
|
||||
.then((r) => setLeads(r.leads))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : 'Could not load enquiries.'))
|
||||
}
|
||||
useEffect(refresh, [])
|
||||
|
||||
const csv = () => {
|
||||
const cols = ['at', 'form', 'name', 'email', 'company', 'phone', 'page', 'message']
|
||||
const escape = (v: string) => `"${String(v || '').replace(/"/g, '""')}"`
|
||||
const body = [cols.join(','), ...leads.map((l) => cols.map((c) => escape(l[c])).join(','))].join('\n')
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(new Blob([body], { type: 'text/csv' }))
|
||||
a.download = `blackdice-enquiries-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(a.href)
|
||||
a.remove()
|
||||
}, 400)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bdcms-h">ENQUIRIES · {leads.length}</div>
|
||||
<p className="bdcms-hint">
|
||||
Every form submission is recorded here as well as being emailed, so nothing is lost if a visitor's mail client
|
||||
fails to send.
|
||||
</p>
|
||||
<div className="bdcms-row">
|
||||
<button className="bdcms-btn ghost" onClick={refresh}>
|
||||
Refresh
|
||||
</button>
|
||||
<button className="bdcms-btn ghost" onClick={csv} disabled={!leads.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-list" style={{ marginTop: 12 }}>
|
||||
{leads.map((lead, i) => (
|
||||
<div key={`${lead.at}-${i}`} className="bdcms-item" style={{ cursor: 'default' }}>
|
||||
<span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span className="bdcms-tag">{(lead.form || 'enquiry').toUpperCase()}</span>
|
||||
</span>
|
||||
<span style={{ display: 'block', marginTop: 5, fontWeight: 600 }}>{lead.name || lead.email}</span>
|
||||
<span className="meta">
|
||||
{new Date(lead.at).toLocaleString()} · {lead.email}
|
||||
{lead.company ? ` · ${lead.company}` : ''}
|
||||
{lead.phone ? ` · ${lead.phone}` : ''}
|
||||
</span>
|
||||
{lead.message ? (
|
||||
<span style={{ display: 'block', marginTop: 6, fontSize: 12, color: 'var(--cms-mut)', lineHeight: 1.6 }}>
|
||||
{lead.message}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="meta">{lead.page}</span>
|
||||
</div>
|
||||
))}
|
||||
{!leads.length && !error ? <p className="bdcms-hint">No enquiries recorded yet.</p> : null}
|
||||
</div>
|
||||
{error ? <p className="bdcms-err">{error}</p> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
202
src/cms/admin/PagesPanel.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { SiteContent } from '../types'
|
||||
import { PAGES } from '../pages'
|
||||
import { fieldsByPage, originals } from './originals'
|
||||
import { revealField } from './useInlineEditor'
|
||||
|
||||
interface Props {
|
||||
draft: SiteContent
|
||||
update: (mutate: (draft: SiteContent) => void) => void
|
||||
page: string
|
||||
onPage: (page: string) => void
|
||||
editing: boolean
|
||||
onEditing: (editing: boolean) => void
|
||||
/** Opens the file picker and stores the result against an image id. */
|
||||
pickImage: (id: string) => void
|
||||
/** Set when a stat on the page was clicked, so its input can be highlighted. */
|
||||
focusedStat: string
|
||||
toast: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Page copy and imagery. Everything is edited on the real page to the right —
|
||||
* this panel is navigation, a searchable index of every field, and the controls
|
||||
* for the things you cannot click (stats, image replacement, reverting).
|
||||
*/
|
||||
export default function PagesPanel({
|
||||
draft,
|
||||
update,
|
||||
page,
|
||||
onPage,
|
||||
editing,
|
||||
onEditing,
|
||||
pickImage,
|
||||
focusedStat,
|
||||
toast,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('')
|
||||
const statRef = useRef<HTMLInputElement>(null)
|
||||
const base = originals()
|
||||
|
||||
const pageFields = useMemo(() => fieldsByPage(page), [page])
|
||||
const textFields = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const all = pageFields.filter((f) => f.kind === 'text')
|
||||
return q ? all.filter((f) => f.preview.toLowerCase().includes(q) || f.id.includes(q)) : all
|
||||
}, [pageFields, query])
|
||||
|
||||
const editedCount = useMemo(() => {
|
||||
const ids = new Set(pageFields.map((f) => f.id))
|
||||
let n = 0
|
||||
for (const id of Object.keys(draft.content)) if (ids.has(id) && draft.content[id] !== base.text[id]) n++
|
||||
for (const id of Object.keys(draft.images)) if (ids.has(id) && draft.images[id] !== base.images[id]) n++
|
||||
for (const id of Object.keys(draft.numbers)) if (ids.has(id)) n++
|
||||
return n
|
||||
}, [draft, pageFields, base])
|
||||
|
||||
useEffect(() => {
|
||||
if (focusedStat) statRef.current?.focus()
|
||||
}, [focusedStat])
|
||||
|
||||
const stats = pageFields.filter((f) => f.kind === 'number')
|
||||
const images = pageFields.filter((f) => f.kind === 'image')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bdcms-seg">
|
||||
<button className={editing ? 'on' : ''} onClick={() => onEditing(true)}>
|
||||
Edit page
|
||||
</button>
|
||||
<button className={editing ? '' : 'on'} onClick={() => onEditing(false)}>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<p className="bdcms-hint">
|
||||
{editing
|
||||
? 'Click any text on the page to edit it. Click an image to replace it. Nothing goes live until you press Publish.'
|
||||
: 'Preview mode: the page behaves exactly as a visitor will experience it.'}
|
||||
</p>
|
||||
|
||||
<div className="bdcms-h">PAGES</div>
|
||||
<div className="bdcms-list">
|
||||
{PAGES.map((p) => (
|
||||
<button key={p.id} className={`bdcms-item${p.id === page ? ' on' : ''}`} onClick={() => onPage(p.id)}>
|
||||
{p.name}
|
||||
<span className="meta">{p.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bdcms-h">TEXT ON THIS PAGE {editedCount ? `· ${editedCount} EDITED` : ''}</div>
|
||||
<div className="bdcms-field">
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
placeholder="Search this page's copy…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="bdcms-list">
|
||||
{textFields.slice(0, 250).map((f) => {
|
||||
const edited = draft.content[f.id] !== undefined && draft.content[f.id] !== base.text[f.id]
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`bdcms-fieldrow${edited ? ' edited' : ''}`}
|
||||
onClick={() => {
|
||||
if (!revealField(f.id)) toast('That field is not on the page currently shown.')
|
||||
}}>
|
||||
<code>{f.tag}</code>
|
||||
<p>{f.preview || '(empty)'}</p>
|
||||
{edited ? (
|
||||
<button
|
||||
className="bdcms-btn ghost"
|
||||
style={{ width: 'auto', padding: '2px 6px', fontSize: 10 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
update((d) => {
|
||||
delete d.content[f.id]
|
||||
})
|
||||
const el = document.querySelector<HTMLElement>(`[data-cms="${f.id}"]`)
|
||||
if (el) el.innerHTML = base.text[f.id]
|
||||
toast('Reverted to the original wording.')
|
||||
}}>
|
||||
undo
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!textFields.length ? <p className="bdcms-hint">No matching copy.</p> : null}
|
||||
</div>
|
||||
|
||||
{stats.length ? (
|
||||
<>
|
||||
<div className="bdcms-h">HEADLINE NUMBERS</div>
|
||||
<p className="bdcms-hint">These count up as the visitor scrolls, so they are edited as a value plus a suffix.</p>
|
||||
{stats.map((f) => {
|
||||
const current = draft.numbers[f.id] || base.numbers[f.id] || { value: 0, suffix: '' }
|
||||
return (
|
||||
<div className="bdcms-field" key={f.id}>
|
||||
<label>
|
||||
{f.preview || f.id} · {f.id}
|
||||
</label>
|
||||
<div className="bdcms-row">
|
||||
<input
|
||||
ref={focusedStat === f.id ? statRef : undefined}
|
||||
className="bdcms-inp"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={current.value}
|
||||
onChange={(e) =>
|
||||
update((d) => {
|
||||
d.numbers[f.id] = { value: Number(e.target.value), suffix: current.suffix }
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
placeholder="suffix (%, +, ×)"
|
||||
value={current.suffix ?? ''}
|
||||
onChange={(e) =>
|
||||
update((d) => {
|
||||
d.numbers[f.id] = { value: current.value, suffix: e.target.value }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{images.length ? (
|
||||
<>
|
||||
<div className="bdcms-h">IMAGES ON THIS PAGE</div>
|
||||
<div className="bdcms-list">
|
||||
{images.map((f) => {
|
||||
const src = draft.images[f.id] || base.images[f.id]
|
||||
return (
|
||||
<div key={f.id} className="bdcms-fieldrow" style={{ alignItems: 'center' }}>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
style={{ width: 46, height: 32, objectFit: 'contain', background: 'rgba(0,0,0,.3)', flexShrink: 0 }}
|
||||
/>
|
||||
<p>{f.preview || f.id}</p>
|
||||
<button
|
||||
className="bdcms-btn ghost"
|
||||
style={{ width: 'auto', padding: '4px 8px', fontSize: 11 }}
|
||||
onClick={() => pickImage(f.id)}>
|
||||
replace
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
319
src/cms/admin/PostsPanel.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { POST_CATEGORIES, type Post, type PostCategory, type SiteContent } from '../types'
|
||||
import { slugify, textOf } from '../sanitise'
|
||||
import { fileToOptimisedDataUrl, uploadAsset } from '../api'
|
||||
import RichText from './RichText'
|
||||
|
||||
interface Props {
|
||||
draft: SiteContent
|
||||
update: (mutate: (draft: SiteContent) => void) => void
|
||||
/** Slug currently open in the preview. */
|
||||
selected: string
|
||||
onSelect: (slug: string) => void
|
||||
toast: (message: string) => void
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10)
|
||||
|
||||
const uniqueSlug = (posts: Post[], base: string, ignoreId: string) => {
|
||||
let slug = base
|
||||
let n = 2
|
||||
while (posts.some((p) => p.slug === slug && p.id !== ignoreId)) slug = `${base}-${n++}`
|
||||
return slug
|
||||
}
|
||||
|
||||
/** Blogs, news, press releases and event write-ups — the whole post library. */
|
||||
export default function PostsPanel({ draft, update, selected, onSelect, toast }: Props) {
|
||||
const [filter, setFilter] = useState<PostCategory | 'all'>('all')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const heroRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const posts = useMemo(
|
||||
() => draft.posts.slice().sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)),
|
||||
[draft.posts],
|
||||
)
|
||||
const shown = filter === 'all' ? posts : posts.filter((p) => p.category === filter)
|
||||
const post = posts.find((p) => p.slug === selected) || null
|
||||
|
||||
const patch = (id: string, changes: Partial<Post>) =>
|
||||
update((d) => {
|
||||
const target = d.posts.find((p) => p.id === id)
|
||||
if (!target) return
|
||||
Object.assign(target, changes, { updatedAt: new Date().toISOString() })
|
||||
})
|
||||
|
||||
const addPost = () => {
|
||||
const id = `post-${Date.now().toString(36)}`
|
||||
const fresh: Post = {
|
||||
id,
|
||||
slug: uniqueSlug(draft.posts, 'new-article', id),
|
||||
title: 'New article',
|
||||
category: 'insights',
|
||||
date: today(),
|
||||
author: 'BlackDice Cyber',
|
||||
excerpt: '',
|
||||
hero: '',
|
||||
body: '<p></p>',
|
||||
status: 'draft',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
update((d) => {
|
||||
d.posts.unshift(fresh)
|
||||
})
|
||||
onSelect(fresh.slug)
|
||||
toast('Draft article created. It stays hidden until you set it to Published.')
|
||||
}
|
||||
|
||||
const duplicate = (source: Post) => {
|
||||
const id = `post-${Date.now().toString(36)}`
|
||||
const copy: Post = {
|
||||
...source,
|
||||
id,
|
||||
slug: uniqueSlug(draft.posts, `${source.slug}-copy`, id),
|
||||
title: `${source.title} (copy)`,
|
||||
status: 'draft',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
update((d) => {
|
||||
d.posts.unshift(copy)
|
||||
})
|
||||
onSelect(copy.slug)
|
||||
}
|
||||
|
||||
const remove = (target: Post) => {
|
||||
if (!window.confirm(`Delete "${target.title}"? This cannot be undone once you publish.`)) return
|
||||
update((d) => {
|
||||
d.posts = d.posts.filter((p) => p.id !== target.id)
|
||||
})
|
||||
toast('Article removed from the draft.')
|
||||
}
|
||||
|
||||
const onHero = async (file?: File) => {
|
||||
if (!file || !post) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const dataUrl = await fileToOptimisedDataUrl(file, 1600, 0.82)
|
||||
const { url } = await uploadAsset(`${post.slug}-hero`, dataUrl)
|
||||
patch(post.id, { hero: url })
|
||||
toast('Hero image uploaded.')
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Upload failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="bdcms-btn primary" onClick={addPost}>
|
||||
+ New article
|
||||
</button>
|
||||
|
||||
<div className="bdcms-h">LIBRARY · {posts.length} ARTICLES</div>
|
||||
<div className="bdcms-tabs" style={{ padding: 0, border: 'none', marginBottom: 8 }}>
|
||||
{[{ id: 'all', label: 'All' }, ...POST_CATEGORIES].map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={filter === cat.id ? 'on' : ''}
|
||||
onClick={() => setFilter(cat.id as PostCategory | 'all')}>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="bdcms-list">
|
||||
{shown.map((p) => (
|
||||
<button key={p.id} className={`bdcms-item${p.slug === selected ? ' on' : ''}`} onClick={() => onSelect(p.slug)}>
|
||||
<span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span className={`bdcms-tag${p.status === 'draft' ? ' draft' : ''}`}>
|
||||
{p.status === 'draft' ? 'DRAFT' : p.category.toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ display: 'block', marginTop: 5 }}>{p.title}</span>
|
||||
<span className="meta">
|
||||
{p.date} · /blog/{p.slug}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{post ? (
|
||||
<>
|
||||
<div className="bdcms-h">EDITING · {post.status === 'draft' ? 'DRAFT' : 'LIVE'}</div>
|
||||
|
||||
<div className="bdcms-seg">
|
||||
<button className={post.status === 'published' ? 'on' : ''} onClick={() => patch(post.id, { status: 'published' })}>
|
||||
Published
|
||||
</button>
|
||||
<button className={post.status === 'draft' ? 'on' : ''} onClick={() => patch(post.id, { status: 'draft' })}>
|
||||
Draft
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>TITLE</label>
|
||||
<textarea
|
||||
className="bdcms-inp"
|
||||
rows={2}
|
||||
value={post.title}
|
||||
onChange={(e) => patch(post.id, { title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>URL · blackdice.ai/blog/…</label>
|
||||
<div className="bdcms-row">
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
value={post.slug}
|
||||
onChange={(e) => patch(post.id, { slug: slugify(e.target.value) })}
|
||||
onBlur={(e) => {
|
||||
const unique = uniqueSlug(draft.posts, slugify(e.target.value), post.id)
|
||||
patch(post.id, { slug: unique })
|
||||
onSelect(unique)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="bdcms-btn ghost"
|
||||
style={{ flex: '0 0 auto', width: 'auto' }}
|
||||
onClick={() => {
|
||||
const unique = uniqueSlug(draft.posts, slugify(post.title), post.id)
|
||||
patch(post.id, { slug: unique })
|
||||
onSelect(unique)
|
||||
}}>
|
||||
from title
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-row">
|
||||
<div className="bdcms-field">
|
||||
<label>CATEGORY</label>
|
||||
<select
|
||||
className="bdcms-inp"
|
||||
value={post.category}
|
||||
onChange={(e) => patch(post.id, { category: e.target.value as PostCategory })}>
|
||||
{POST_CATEGORIES.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="bdcms-field">
|
||||
<label>DATE</label>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
type="date"
|
||||
value={post.date}
|
||||
onChange={(e) => patch(post.id, { date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>AUTHOR · CREDIT IN BRACKETS IS SHOWN UNDER THE IMAGE</label>
|
||||
<input className="bdcms-inp" value={post.author} onChange={(e) => patch(post.id, { author: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>EXCERPT · SHOWN ON CARDS AND IN SEARCH RESULTS</label>
|
||||
<textarea
|
||||
className="bdcms-inp"
|
||||
rows={4}
|
||||
value={post.excerpt}
|
||||
onChange={(e) => patch(post.id, { excerpt: e.target.value })}
|
||||
/>
|
||||
<div className={`bdcms-count${post.excerpt.length > 300 ? ' over' : ''}`}>{post.excerpt.length} characters</div>
|
||||
{!post.excerpt ? (
|
||||
<button
|
||||
className="bdcms-btn ghost"
|
||||
onClick={() => patch(post.id, { excerpt: textOf(post.body).slice(0, 260) })}>
|
||||
Use the opening of the article
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>HERO IMAGE</label>
|
||||
{post.hero ? (
|
||||
<img
|
||||
src={post.hero}
|
||||
alt=""
|
||||
style={{ width: '100%', height: 120, objectFit: 'cover', borderRadius: 6, marginBottom: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="bdcms-row">
|
||||
<button className="bdcms-btn" disabled={busy} onClick={() => heroRef.current?.click()}>
|
||||
{busy ? 'Uploading…' : post.hero ? 'Replace image' : 'Upload image'}
|
||||
</button>
|
||||
{post.hero ? (
|
||||
<button className="bdcms-btn danger" onClick={() => patch(post.id, { hero: '' })}>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
style={{ marginTop: 8 }}
|
||||
placeholder="…or paste an image URL"
|
||||
value={post.hero}
|
||||
onChange={(e) => patch(post.id, { hero: e.target.value.trim() })}
|
||||
/>
|
||||
<input
|
||||
ref={heroRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
void onHero(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-h">ARTICLE BODY</div>
|
||||
<p className="bdcms-hint">
|
||||
Paste straight from Word or Outlook — the formatting is cleaned automatically so articles inherit the site's
|
||||
typography.
|
||||
</p>
|
||||
<RichText value={post.body} onChange={(html) => patch(post.id, { body: html })} />
|
||||
|
||||
<div className="bdcms-h">SEARCH ENGINE OVERRIDES · OPTIONAL</div>
|
||||
<div className="bdcms-field">
|
||||
<label>PAGE TITLE</label>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
placeholder={`${post.title} | BlackDice Cyber`}
|
||||
value={post.metaTitle || ''}
|
||||
onChange={(e) => patch(post.id, { metaTitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="bdcms-field">
|
||||
<label>META DESCRIPTION</label>
|
||||
<textarea
|
||||
className="bdcms-inp"
|
||||
rows={3}
|
||||
placeholder="Defaults to the excerpt"
|
||||
value={post.metaDescription || ''}
|
||||
onChange={(e) => patch(post.id, { metaDescription: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-row">
|
||||
<button className="bdcms-btn" onClick={() => duplicate(post)}>
|
||||
Duplicate
|
||||
</button>
|
||||
<button className="bdcms-btn danger" onClick={() => remove(post)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="bdcms-hint" style={{ marginTop: 16 }}>
|
||||
Choose an article to edit it, or create a new one. The preview on the right is the real article page.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
102
src/cms/admin/RichText.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { sanitiseBody } from '../sanitise'
|
||||
|
||||
const TOOLS: { label: string; title: string; run: () => void }[] = [
|
||||
{ label: 'H2', title: 'Section heading', run: () => document.execCommand('formatBlock', false, 'h2') },
|
||||
{ label: 'H3', title: 'Sub-heading', run: () => document.execCommand('formatBlock', false, 'h3') },
|
||||
{ label: '¶', title: 'Body paragraph', run: () => document.execCommand('formatBlock', false, 'p') },
|
||||
{ label: 'B', title: 'Bold', run: () => document.execCommand('bold') },
|
||||
{ label: 'I', title: 'Italic', run: () => document.execCommand('italic') },
|
||||
{ label: '• list', title: 'Bulleted list', run: () => document.execCommand('insertUnorderedList') },
|
||||
{ label: '1. list', title: 'Numbered list', run: () => document.execCommand('insertOrderedList') },
|
||||
{ label: '❝', title: 'Pull quote', run: () => document.execCommand('formatBlock', false, 'blockquote') },
|
||||
{
|
||||
label: 'link',
|
||||
title: 'Add a link',
|
||||
run: () => {
|
||||
const url = window.prompt('Link URL')
|
||||
if (url) document.execCommand('createLink', false, url)
|
||||
},
|
||||
},
|
||||
{ label: 'unlink', title: 'Remove link', run: () => document.execCommand('unlink') },
|
||||
{ label: '⌫ format', title: 'Strip formatting from the selection', run: () => document.execCommand('removeFormat') },
|
||||
]
|
||||
|
||||
/**
|
||||
* Article body editor. Word and Outlook paste is the normal case here, so pasted
|
||||
* HTML is sanitised on the way in rather than being carried into the site.
|
||||
*/
|
||||
export default function RichText({ value, onChange }: { value: string; onChange: (html: string) => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const lastValue = useRef(value)
|
||||
|
||||
// Only write into the DOM when the value changed elsewhere (post switch, import),
|
||||
// never on our own keystrokes — that would fight the caret.
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (value !== lastValue.current && value !== el.innerHTML) {
|
||||
el.innerHTML = value
|
||||
lastValue.current = value
|
||||
}
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (el && !el.innerHTML) el.innerHTML = value
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const flush = (clean = false) => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const html = clean ? sanitiseBody(el.innerHTML) : el.innerHTML
|
||||
if (clean && html !== el.innerHTML) el.innerHTML = html
|
||||
lastValue.current = html
|
||||
onChange(html)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bdcms-rt-bar">
|
||||
{TOOLS.map((tool) => (
|
||||
<button
|
||||
key={tool.label}
|
||||
title={tool.title}
|
||||
onMouseDown={(e) => e.preventDefault()} // keep the selection
|
||||
onClick={() => {
|
||||
tool.run()
|
||||
flush()
|
||||
}}>
|
||||
{tool.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className="bdcms-rt"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck
|
||||
onInput={() => flush()}
|
||||
onBlur={() => flush(true)}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault()
|
||||
const html = e.clipboardData.getData('text/html')
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (html) {
|
||||
document.execCommand('insertHTML', false, sanitiseBody(html))
|
||||
} else {
|
||||
// Blank lines become paragraphs so pasted plain text keeps its shape.
|
||||
const paragraphs = text
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => `<p>${p.replace(/\n/g, '<br />').replace(/</g, '<')}</p>`)
|
||||
.join('')
|
||||
document.execCommand('insertHTML', false, paragraphs)
|
||||
}
|
||||
flush()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
127
src/cms/admin/SeoPanel.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { SiteContent } from '../types'
|
||||
import { DEFAULT_SEO, PAGES } from '../pages'
|
||||
import { publishedPosts } from '../store'
|
||||
|
||||
interface Props {
|
||||
draft: SiteContent
|
||||
update: (mutate: (draft: SiteContent) => void) => void
|
||||
page: string
|
||||
onPage: (page: string) => void
|
||||
}
|
||||
|
||||
/** Page titles, meta descriptions, share images and the site-wide settings. */
|
||||
export default function SeoPanel({ draft, update, page, onPage }: Props) {
|
||||
const seo = draft.seo[page] || DEFAULT_SEO[page] || { title: '', description: '' }
|
||||
const current = PAGES.find((p) => p.id === page)
|
||||
const liveCount = publishedPosts(draft).length
|
||||
|
||||
const patch = (changes: Partial<typeof seo>) =>
|
||||
update((d) => {
|
||||
d.seo[page] = { ...seo, ...changes }
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bdcms-h">PAGE</div>
|
||||
<div className="bdcms-field">
|
||||
<select className="bdcms-inp" value={page} onChange={(e) => onPage(e.target.value)}>
|
||||
{PAGES.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {p.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>BROWSER / SEARCH TITLE</label>
|
||||
<textarea className="bdcms-inp" rows={2} value={seo.title} onChange={(e) => patch({ title: e.target.value })} />
|
||||
<div className={`bdcms-count${seo.title.length > 60 ? ' over' : ''}`}>{seo.title.length} / 60 characters</div>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>META DESCRIPTION</label>
|
||||
<textarea
|
||||
className="bdcms-inp"
|
||||
rows={4}
|
||||
value={seo.description}
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
/>
|
||||
<div className={`bdcms-count${seo.description.length > 160 ? ' over' : ''}`}>
|
||||
{seo.description.length} / 160 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>SHARE IMAGE URL · OPTIONAL</label>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
placeholder="/content/uploads/…"
|
||||
value={seo.ogImage || ''}
|
||||
onChange={(e) => patch({ ogImage: e.target.value.trim() })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="bdcms-note">
|
||||
This page is live at <strong>{draft.settings.siteUrl.replace(/\/+$/, '')}{current?.path}</strong> and
|
||||
{current?.indexable ? ' is listed in' : ' is deliberately kept out of'} sitemap.xml. The sitemap also lists all{' '}
|
||||
{liveCount} published articles and is regenerated on every request.
|
||||
</p>
|
||||
|
||||
<div className="bdcms-h">SITE-WIDE SETTINGS</div>
|
||||
|
||||
<div className="bdcms-field">
|
||||
<label>ENQUIRY FORMS SEND TO</label>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
value={draft.settings.formRecipient}
|
||||
onChange={(e) =>
|
||||
update((d) => {
|
||||
d.settings.formRecipient = e.target.value.trim()
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="bdcms-field">
|
||||
<label>COPY ENQUIRIES TO · OPTIONAL</label>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
value={draft.settings.formCc}
|
||||
onChange={(e) =>
|
||||
update((d) => {
|
||||
d.settings.formCc = e.target.value.trim()
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="bdcms-field">
|
||||
<label>PUBLIC CONTACT ADDRESS</label>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
value={draft.settings.contactEmail}
|
||||
onChange={(e) =>
|
||||
update((d) => {
|
||||
d.settings.contactEmail = e.target.value.trim()
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="bdcms-field">
|
||||
<label>CANONICAL SITE URL</label>
|
||||
<input
|
||||
className="bdcms-inp"
|
||||
value={draft.settings.siteUrl}
|
||||
onChange={(e) =>
|
||||
update((d) => {
|
||||
d.settings.siteUrl = e.target.value.trim()
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="bdcms-hint">
|
||||
Every "Book a demonstration", "Talk to us" and email link on the site opens the enquiry form, and each submission
|
||||
is recorded under Enquiries as well as being emailed.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
517
src/cms/admin/admin.css
Normal file
@@ -0,0 +1,517 @@
|
||||
/* ══════════════════════════════════════════════════════════════════════════════
|
||||
BlackDice Studio — the /admin CMS chrome.
|
||||
Everything is prefixed .bdcms- so it can never collide with the site's styles,
|
||||
which load alongside it because the editor previews the real site.
|
||||
══════════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
--cms-w: 420px;
|
||||
--cms-bg: #0b1418;
|
||||
--cms-panel: #11212a;
|
||||
--cms-panel2: #16303c;
|
||||
--cms-line: rgba(255, 255, 255, 0.1);
|
||||
--cms-txt: #e8f4f3;
|
||||
--cms-mut: rgba(232, 244, 243, 0.55);
|
||||
--cms-mut2: rgba(232, 244, 243, 0.35);
|
||||
--cms-teal: #3bb586;
|
||||
--cms-orange: #f37d1e;
|
||||
--cms-red: #e05252;
|
||||
}
|
||||
|
||||
/* ── Shell ─────────────────────────────────────────────────────────────────── */
|
||||
body.bd-admin {
|
||||
padding-left: var(--cms-w);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body.bd-admin .bd-nav {
|
||||
left: var(--cms-w);
|
||||
}
|
||||
body.bd-admin .modal-ov {
|
||||
left: var(--cms-w);
|
||||
}
|
||||
body.bd-admin .scroll-hint {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.bdcms-side {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--cms-w);
|
||||
background: var(--cms-bg);
|
||||
border-right: 1px solid var(--cms-line);
|
||||
color: var(--cms-txt);
|
||||
font-family: 'Ubuntu', system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10000;
|
||||
}
|
||||
.bdcms-head {
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid var(--cms-line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bdcms-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.bdcms-title span {
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--cms-teal);
|
||||
}
|
||||
.bdcms-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--cms-mut);
|
||||
}
|
||||
.bdcms-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--cms-mut2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bdcms-dot.dirty {
|
||||
background: var(--cms-orange);
|
||||
}
|
||||
.bdcms-dot.saved {
|
||||
background: var(--cms-teal);
|
||||
}
|
||||
|
||||
.bdcms-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--cms-line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bdcms-tabs button {
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--cms-mut);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 100px;
|
||||
padding: 6px 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bdcms-tabs button:hover {
|
||||
color: var(--cms-txt);
|
||||
}
|
||||
.bdcms-tabs button.on {
|
||||
background: rgba(59, 181, 134, 0.14);
|
||||
border-color: rgba(59, 181, 134, 0.35);
|
||||
color: var(--cms-teal);
|
||||
}
|
||||
.bdcms-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px 24px;
|
||||
}
|
||||
.bdcms-foot {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--cms-line);
|
||||
padding: 12px 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Controls ──────────────────────────────────────────────────────────────── */
|
||||
.bdcms-h {
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--cms-mut2);
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
.bdcms-h:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.bdcms-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--cms-mut);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.bdcms-btn {
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--cms-txt);
|
||||
background: var(--cms-panel);
|
||||
border: 1px solid var(--cms-line);
|
||||
border-radius: 6px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.bdcms-btn:hover {
|
||||
border-color: rgba(59, 181, 134, 0.4);
|
||||
}
|
||||
.bdcms-btn small {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
color: var(--cms-mut);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.bdcms-btn.primary {
|
||||
background: var(--cms-teal);
|
||||
border-color: var(--cms-teal);
|
||||
color: #06222b;
|
||||
text-align: center;
|
||||
}
|
||||
.bdcms-btn.primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.bdcms-btn.danger {
|
||||
color: var(--cms-red);
|
||||
border-color: rgba(224, 82, 82, 0.35);
|
||||
}
|
||||
.bdcms-btn.ghost {
|
||||
background: none;
|
||||
}
|
||||
.bdcms-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.bdcms-row > * {
|
||||
flex: 1;
|
||||
}
|
||||
.bdcms-seg {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border: 1px solid var(--cms-line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.bdcms-seg button {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--cms-mut);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 9px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bdcms-seg button.on {
|
||||
background: var(--cms-panel2);
|
||||
color: var(--cms-teal);
|
||||
}
|
||||
|
||||
.bdcms-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.bdcms-field label {
|
||||
display: block;
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--cms-mut2);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.bdcms-inp,
|
||||
.bdcms-side textarea.bdcms-inp,
|
||||
.bdcms-side select.bdcms-inp {
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--cms-txt);
|
||||
background: #0d1a20;
|
||||
border: 1px solid var(--cms-line);
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.bdcms-inp:focus {
|
||||
outline: none;
|
||||
border-color: rgba(59, 181, 134, 0.5);
|
||||
}
|
||||
.bdcms-inp[readonly] {
|
||||
color: var(--cms-mut);
|
||||
}
|
||||
.bdcms-count {
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 10px;
|
||||
color: var(--cms-mut2);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.bdcms-count.over {
|
||||
color: var(--cms-orange);
|
||||
}
|
||||
|
||||
/* ── Lists ─────────────────────────────────────────────────────────────────── */
|
||||
.bdcms-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.bdcms-item {
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--cms-txt);
|
||||
font-size: 12.5px;
|
||||
width: 100%;
|
||||
}
|
||||
.bdcms-item:hover {
|
||||
background: var(--cms-panel);
|
||||
}
|
||||
.bdcms-item.on {
|
||||
background: var(--cms-panel2);
|
||||
border-color: rgba(59, 181, 134, 0.3);
|
||||
}
|
||||
.bdcms-item .meta {
|
||||
display: block;
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--cms-mut2);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.bdcms-tag {
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(59, 181, 134, 0.14);
|
||||
color: var(--cms-teal);
|
||||
}
|
||||
.bdcms-tag.draft {
|
||||
background: rgba(243, 125, 30, 0.16);
|
||||
color: var(--cms-orange);
|
||||
}
|
||||
.bdcms-fieldrow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.bdcms-fieldrow:hover {
|
||||
background: var(--cms-panel);
|
||||
}
|
||||
.bdcms-fieldrow.edited {
|
||||
border-color: rgba(243, 125, 30, 0.35);
|
||||
}
|
||||
.bdcms-fieldrow code {
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 9.5px;
|
||||
color: var(--cms-mut2);
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.bdcms-fieldrow p {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--cms-mut);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.bdcms-note {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--cms-mut2);
|
||||
background: var(--cms-panel);
|
||||
border-left: 2px solid rgba(59, 181, 134, 0.5);
|
||||
padding: 9px 11px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.bdcms-err {
|
||||
font-size: 12px;
|
||||
color: var(--cms-red);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.bdcms-ok {
|
||||
font-size: 12px;
|
||||
color: var(--cms-teal);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Rich text editor ──────────────────────────────────────────────────────── */
|
||||
.bdcms-rt-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
padding: 6px;
|
||||
background: var(--cms-panel);
|
||||
border: 1px solid var(--cms-line);
|
||||
border-bottom: none;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.bdcms-rt-bar button {
|
||||
font-family: 'Ubuntu Mono', ui-monospace, monospace;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--cms-mut);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 4px 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bdcms-rt-bar button:hover {
|
||||
color: var(--cms-txt);
|
||||
border-color: var(--cms-line);
|
||||
}
|
||||
.bdcms-rt {
|
||||
min-height: 240px;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
background: #0d1a20;
|
||||
border: 1px solid var(--cms-line);
|
||||
border-radius: 0 0 6px 6px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--cms-txt);
|
||||
}
|
||||
.bdcms-rt:focus {
|
||||
outline: none;
|
||||
border-color: rgba(59, 181, 134, 0.5);
|
||||
}
|
||||
.bdcms-rt h2 {
|
||||
font-size: 16px;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.bdcms-rt h3 {
|
||||
font-size: 14px;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.bdcms-rt p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bdcms-rt ul,
|
||||
.bdcms-rt ol {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bdcms-rt blockquote {
|
||||
border-left: 2px solid var(--cms-teal);
|
||||
padding-left: 12px;
|
||||
margin: 10px 0;
|
||||
color: var(--cms-mut);
|
||||
}
|
||||
.bdcms-rt a {
|
||||
color: var(--cms-teal);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Inline editing on the previewed site ──────────────────────────────────── */
|
||||
body.bdcms-editing [data-cms] {
|
||||
outline: 1px dashed rgba(59, 181, 134, 0.45);
|
||||
outline-offset: 2px;
|
||||
cursor: text;
|
||||
min-width: 8px;
|
||||
}
|
||||
body.bdcms-editing [data-cms]:hover {
|
||||
outline-color: var(--cms-teal);
|
||||
background: rgba(59, 181, 134, 0.06);
|
||||
}
|
||||
body.bdcms-editing [data-cms]:focus {
|
||||
outline: 2px solid var(--cms-teal);
|
||||
background: rgba(59, 181, 134, 0.08);
|
||||
}
|
||||
body.bdcms-editing [data-cms-img],
|
||||
body.bdcms-editing [data-cms-num] {
|
||||
outline: 1px dashed rgba(243, 125, 30, 0.6);
|
||||
outline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bdcms-flash {
|
||||
animation: bdcms-flash 1.2s ease;
|
||||
}
|
||||
@keyframes bdcms-flash {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: none;
|
||||
}
|
||||
20% {
|
||||
box-shadow: 0 0 0 3px rgba(59, 181, 134, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Login ─────────────────────────────────────────────────────────────────── */
|
||||
.bdcms-login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--cms-bg);
|
||||
font-family: 'Ubuntu', system-ui, sans-serif;
|
||||
color: var(--cms-txt);
|
||||
padding: 24px;
|
||||
}
|
||||
.bdcms-login-box {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: var(--cms-panel);
|
||||
border: 1px solid var(--cms-line);
|
||||
border-radius: 10px;
|
||||
padding: 28px 26px;
|
||||
}
|
||||
.bdcms-login-box h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.bdcms-login-box p {
|
||||
font-size: 13px;
|
||||
color: var(--cms-mut);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Toast ─────────────────────────────────────────────────────────────────── */
|
||||
.bdcms-toast {
|
||||
position: fixed;
|
||||
left: calc(var(--cms-w) + 20px);
|
||||
bottom: 20px;
|
||||
z-index: 10001;
|
||||
background: #06222b;
|
||||
border: 1px solid rgba(59, 181, 134, 0.4);
|
||||
color: var(--cms-txt);
|
||||
font-family: 'Ubuntu', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
padding: 11px 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
:root {
|
||||
--cms-w: 360px;
|
||||
}
|
||||
}
|
||||
44
src/cms/admin/originals.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import siteHtml from '../../site/siteMarkup.txt?raw'
|
||||
import fields from '../generated/cmsFields.json'
|
||||
import type { CmsField } from '../types'
|
||||
|
||||
// The shipped markup is the baseline every CMS override sits on top of. Parsing it
|
||||
// once here gives the editor the original value of every field, which is what makes
|
||||
// "revert to original" and the edited/unedited markers in the field list possible.
|
||||
|
||||
let cache: {
|
||||
text: Record<string, string>
|
||||
images: Record<string, string>
|
||||
numbers: Record<string, { value: number; suffix: string }>
|
||||
} | null = null
|
||||
|
||||
function build() {
|
||||
const doc = new DOMParser().parseFromString(`<div id="bd-src">${siteHtml}</div>`, 'text/html')
|
||||
const root = doc.getElementById('bd-src')!
|
||||
const text: Record<string, string> = {}
|
||||
const images: Record<string, string> = {}
|
||||
const numbers: Record<string, { value: number; suffix: string }> = {}
|
||||
root.querySelectorAll('[data-cms]').forEach((el) => {
|
||||
text[el.getAttribute('data-cms')!] = el.innerHTML
|
||||
})
|
||||
root.querySelectorAll('[data-cms-img]').forEach((el) => {
|
||||
images[el.getAttribute('data-cms-img')!] = el.getAttribute('src') || ''
|
||||
})
|
||||
root.querySelectorAll('[data-cms-num]').forEach((el) => {
|
||||
numbers[el.getAttribute('data-cms-num')!] = {
|
||||
value: Number(el.getAttribute('data-count') || 0),
|
||||
suffix: el.getAttribute('data-suffix') || '',
|
||||
}
|
||||
})
|
||||
cache = { text, images, numbers }
|
||||
return cache
|
||||
}
|
||||
|
||||
export const originals = () => cache || build()
|
||||
|
||||
export const CMS_FIELDS = fields as CmsField[]
|
||||
|
||||
export const fieldsByPage = (page: string) => CMS_FIELDS.filter((f) => f.page === page)
|
||||
|
||||
/** Fields whose page is 'global' live in the nav, footer and enquiry modal. */
|
||||
export const GLOBAL_PAGE = 'global'
|
||||
137
src/cms/admin/useDraft.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SiteContent } from '../types'
|
||||
import { resolveContent } from '../store'
|
||||
import { publish } from '../api'
|
||||
|
||||
const DRAFT_KEY = 'bd_admin_draft_v1'
|
||||
|
||||
export interface DraftState {
|
||||
draft: SiteContent | null
|
||||
/** Published document as last loaded from the server (for diffing/discard). */
|
||||
published: SiteContent | null
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
status: string
|
||||
error: string
|
||||
update: (mutate: (draft: SiteContent) => void) => void
|
||||
replace: (next: SiteContent, note?: string) => void
|
||||
publishNow: () => Promise<boolean>
|
||||
discard: () => void
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's working copy. Kept in memory, mirrored to localStorage so a closed
|
||||
* tab or a crash never loses work, and only written to the server on Publish —
|
||||
* which is the whole reason the old studio's "save overwrites the site files"
|
||||
* failure mode cannot happen here.
|
||||
*/
|
||||
export function useDraft(active: boolean): DraftState {
|
||||
const [draft, setDraft] = useState<SiteContent | null>(null)
|
||||
const [published, setPublished] = useState<SiteContent | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [status, setStatus] = useState('LOADING')
|
||||
const [error, setError] = useState('')
|
||||
const saveTimer = useRef<number | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const live = await resolveContent()
|
||||
setPublished(live)
|
||||
let restored: SiteContent | null = null
|
||||
try {
|
||||
const raw = localStorage.getItem(DRAFT_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { savedAt?: string; draft?: SiteContent; baseSavedAt?: string }
|
||||
// Only restore a local draft that was started from the current publish.
|
||||
if (parsed?.draft && parsed.baseSavedAt === live.savedAt) restored = parsed.draft
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed local drafts */
|
||||
}
|
||||
setDraft(restored || structuredClone(live))
|
||||
setDirty(Boolean(restored))
|
||||
setStatus(restored ? 'LOCAL DRAFT RESTORED' : 'IN SYNC WITH LIVE SITE')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void reload()
|
||||
}, [active, reload])
|
||||
|
||||
// Mirror to localStorage shortly after each change.
|
||||
useEffect(() => {
|
||||
if (!dirty || !draft) return
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current)
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DRAFT_KEY,
|
||||
JSON.stringify({ savedAt: new Date().toISOString(), baseSavedAt: published?.savedAt || '', draft }),
|
||||
)
|
||||
setStatus(`DRAFT SAVED LOCALLY ${new Date().toLocaleTimeString()}`)
|
||||
} catch {
|
||||
setStatus('UNSAVED CHANGES (LOCAL SAVE UNAVAILABLE)')
|
||||
}
|
||||
}, 600)
|
||||
return () => {
|
||||
if (saveTimer.current) window.clearTimeout(saveTimer.current)
|
||||
}
|
||||
}, [draft, dirty, published])
|
||||
|
||||
const update = useCallback((mutate: (draft: SiteContent) => void) => {
|
||||
setDraft((current) => {
|
||||
if (!current) return current
|
||||
const next = structuredClone(current)
|
||||
mutate(next)
|
||||
return next
|
||||
})
|
||||
setDirty(true)
|
||||
setError('')
|
||||
}, [])
|
||||
|
||||
const replace = useCallback((next: SiteContent, note = 'DRAFT REPLACED') => {
|
||||
setDraft(next)
|
||||
setDirty(true)
|
||||
setStatus(note)
|
||||
setError('')
|
||||
}, [])
|
||||
|
||||
const publishNow = useCallback(async () => {
|
||||
if (!draft) return false
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await publish({ ...draft, format: 'blackdice-react-content', version: 1 })
|
||||
const live = { ...draft, savedAt: result.savedAt }
|
||||
setPublished(live)
|
||||
setDraft(structuredClone(live))
|
||||
setDirty(false)
|
||||
setStatus(`PUBLISHED ${new Date(result.savedAt).toLocaleTimeString()}`)
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY)
|
||||
} catch {
|
||||
/* nothing to clear */
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not publish.')
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [draft])
|
||||
|
||||
const discard = useCallback(() => {
|
||||
if (!published) return
|
||||
setDraft(structuredClone(published))
|
||||
setDirty(false)
|
||||
setStatus('DRAFT DISCARDED — SHOWING LIVE CONTENT')
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY)
|
||||
} catch {
|
||||
/* nothing to clear */
|
||||
}
|
||||
}, [published])
|
||||
|
||||
return { draft, published, dirty, saving, status, error, update, replace, publishNow, discard, reload }
|
||||
}
|
||||
128
src/cms/admin/useInlineEditor.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect } from 'react'
|
||||
import { sanitiseInline } from '../sanitise'
|
||||
|
||||
export interface InlineEditorHandlers {
|
||||
onText: (id: string, html: string) => void
|
||||
onPickImage: (id: string) => void
|
||||
onPickNumber: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-to-edit over the previewed site: the same interaction the old studio had,
|
||||
* but writing into the CMS draft instead of the page's own HTML.
|
||||
*
|
||||
* In edit mode the site's own click handlers are suppressed, so buttons and nav
|
||||
* items become editable text rather than navigating away mid-edit.
|
||||
*/
|
||||
export function useInlineEditor(enabled: boolean, handlers: InlineEditorHandlers) {
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('bdcms-editing', enabled)
|
||||
const editables = Array.from(document.querySelectorAll<HTMLElement>('[data-cms]'))
|
||||
for (const el of editables) {
|
||||
if (enabled) {
|
||||
el.setAttribute('contenteditable', 'true')
|
||||
el.setAttribute('spellcheck', 'true')
|
||||
} else {
|
||||
el.removeAttribute('contenteditable')
|
||||
el.removeAttribute('spellcheck')
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
document.body.classList.remove('bdcms-editing')
|
||||
for (const el of editables) {
|
||||
el.removeAttribute('contenteditable')
|
||||
el.removeAttribute('spellcheck')
|
||||
}
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const inSidebar = (el: Element | null) => Boolean(el?.closest('.bdcms-side, .bdcms-toast'))
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target || inSidebar(target)) return
|
||||
|
||||
const image = target.closest<HTMLElement>('[data-cms-img]')
|
||||
if (image) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handlers.onPickImage(image.getAttribute('data-cms-img')!)
|
||||
return
|
||||
}
|
||||
const stat = target.closest<HTMLElement>('[data-cms-num]')
|
||||
if (stat) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handlers.onPickNumber(stat.getAttribute('data-cms-num')!)
|
||||
return
|
||||
}
|
||||
const interactive = target.closest<HTMLElement>('[data-cms], [onclick], a, button')
|
||||
if (!interactive) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const editable = target.closest<HTMLElement>('[data-cms]')
|
||||
if (editable) editable.focus()
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const host = (e.target as HTMLElement | null)?.closest?.('[data-cms]')
|
||||
if (!host) return
|
||||
// Keep single fields single: Enter inserts a line break, never a new block.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
document.execCommand('insertLineBreak')
|
||||
}
|
||||
}
|
||||
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const host = (e.target as HTMLElement | null)?.closest?.('[data-cms]')
|
||||
if (!host) return
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData?.getData('text/plain') || ''
|
||||
document.execCommand('insertText', false, text)
|
||||
}
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const host = (e.target as HTMLElement | null)?.closest?.('[data-cms]')
|
||||
if (!host) return
|
||||
handlers.onText(host.getAttribute('data-cms')!, host.innerHTML)
|
||||
}
|
||||
|
||||
const onBlur = (e: FocusEvent) => {
|
||||
const host = (e.target as HTMLElement | null)?.closest?.('[data-cms]')
|
||||
if (!host) return
|
||||
// Normalise once the field is left, so stray markup from a paste or an
|
||||
// execCommand never reaches the published document.
|
||||
const clean = sanitiseInline(host.innerHTML)
|
||||
if (clean !== host.innerHTML) host.innerHTML = clean
|
||||
handlers.onText(host.getAttribute('data-cms')!, clean)
|
||||
}
|
||||
|
||||
document.addEventListener('click', onClick, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('paste', onPaste)
|
||||
document.addEventListener('input', onInput)
|
||||
document.addEventListener('blur', onBlur, true)
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('paste', onPaste)
|
||||
document.removeEventListener('input', onInput)
|
||||
document.removeEventListener('blur', onBlur, true)
|
||||
}
|
||||
}, [enabled, handlers])
|
||||
}
|
||||
|
||||
/** Scrolls a field into view in the preview and flashes it. */
|
||||
export function revealField(id: string, attr = 'data-cms') {
|
||||
const el = document.querySelector<HTMLElement>(`[${attr}="${id}"]`)
|
||||
if (!el) return false
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('bdcms-flash')
|
||||
window.setTimeout(() => el.classList.remove('bdcms-flash'), 1300)
|
||||
if (attr === 'data-cms' && el.isContentEditable) el.focus()
|
||||
return true
|
||||
}
|
||||
123
src/cms/api.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { SiteContent } from './types'
|
||||
|
||||
// Thin client for the CMS API (server/index.mjs). In dev, Vite proxies /api and
|
||||
// /content to it; in production the same server serves the built site.
|
||||
|
||||
const TOKEN_KEY = 'bd_admin_token'
|
||||
|
||||
export const getToken = () => {
|
||||
try {
|
||||
return sessionStorage.getItem(TOKEN_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
export const setToken = (token: string) => {
|
||||
try {
|
||||
if (token) sessionStorage.setItem(TOKEN_KEY, token)
|
||||
else sessionStorage.removeItem(TOKEN_KEY)
|
||||
} catch {
|
||||
/* private mode — the session just won't survive a reload */
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
const text = await res.text()
|
||||
const data = text ? JSON.parse(text) : {}
|
||||
if (!res.ok) throw Object.assign(new Error(data.error || `${res.status} ${res.statusText}`), { status: res.status })
|
||||
return data as T
|
||||
}
|
||||
|
||||
/** The published document, or null when nothing has been published yet. */
|
||||
export async function fetchPublished(timeoutMs = 4000): Promise<Partial<SiteContent> | null> {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch('/content/site-content.json', { signal: ctrl.signal, cache: 'no-cache' })
|
||||
if (!res.ok) return null
|
||||
const doc = await res.json()
|
||||
return doc && doc.format === 'blackdice-react-content' ? doc : null
|
||||
} catch {
|
||||
return null // offline, static host without the API, or nothing published
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const login = (password: string) =>
|
||||
request<{ token: string; expiresAt: number; usingDefaultPassword?: boolean }>('/api/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
export const checkSession = () => request<{ ok: boolean }>('/api/session')
|
||||
|
||||
export const publish = (doc: SiteContent) =>
|
||||
request<{ ok: boolean; savedAt: string }>('/api/content', { method: 'POST', body: JSON.stringify(doc) })
|
||||
|
||||
export const listVersions = () => request<{ versions: { name: string; size: number }[] }>('/api/versions')
|
||||
|
||||
export const getVersion = (name: string) => request<SiteContent>(`/api/versions/${encodeURIComponent(name)}`)
|
||||
|
||||
export const uploadAsset = (name: string, dataUrl: string) =>
|
||||
request<{ url: string }>('/api/upload', { method: 'POST', body: JSON.stringify({ name, dataUrl }) })
|
||||
|
||||
export const listUploads = () => request<{ uploads: { name: string; url: string }[] }>('/api/uploads')
|
||||
|
||||
export const listLeads = () => request<{ leads: Record<string, string>[] }>('/api/leads')
|
||||
|
||||
export const submitLead = (lead: Record<string, string>) =>
|
||||
request<{ ok: boolean }>('/api/leads', { method: 'POST', body: JSON.stringify(lead) })
|
||||
|
||||
export const apiHealth = () => request<{ ok: boolean; published: boolean }>('/api/health')
|
||||
|
||||
/**
|
||||
* Downscales and re-encodes an image in the browser before upload — the studio
|
||||
* inlined 12MB originals as base64, which is what made its files unusable.
|
||||
*/
|
||||
export function fileToOptimisedDataUrl(file: File, maxWidth = 1920, quality = 0.85): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!/^image\//.test(file.type)) {
|
||||
// Video/PDF: pass through untouched.
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(new Error('Could not read that file.'))
|
||||
reader.readAsDataURL(file)
|
||||
return
|
||||
}
|
||||
if (file.type === 'image/svg+xml') {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(new Error('Could not read that file.'))
|
||||
reader.readAsDataURL(file)
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const scale = Math.min(1, maxWidth / img.naturalWidth)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(img.naturalWidth * scale)
|
||||
canvas.height = Math.round(img.naturalHeight * scale)
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return reject(new Error('Canvas unavailable.'))
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
URL.revokeObjectURL(url)
|
||||
const type = file.type === 'image/png' && scale === 1 ? 'image/png' : 'image/jpeg'
|
||||
resolve(canvas.toDataURL(type, quality))
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('That image could not be read.'))
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
1
src/cms/generated/cmsFields.json
Normal file
245
src/cms/generated/seedContent.json
Normal file
200
src/cms/pages.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { DemoSlot, PageSeo } from './types'
|
||||
|
||||
// ── Pages ──────────────────────────────────────────────────────────────────────
|
||||
// Single source of truth for the site's real URLs, shared by the router, the
|
||||
// admin page-picker and the sitemap generator. The pN ids are the ones baked into
|
||||
// the markup (id="pg-p2", data-page="p2") and into every CMS field key.
|
||||
|
||||
export interface PageDef {
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
/** Include in sitemap.xml. */
|
||||
indexable: boolean
|
||||
priority: string
|
||||
}
|
||||
|
||||
export const PAGES: PageDef[] = [
|
||||
{ id: 'p1', path: '/', name: 'Home', indexable: true, priority: '1.0' },
|
||||
{ id: 'p2', path: '/mobile-sdk', name: 'Mobile SDK', indexable: true, priority: '0.9' },
|
||||
{ id: 'p10', path: '/dns-protect', name: 'DNS Protect', indexable: true, priority: '0.9' },
|
||||
{ id: 'p3', path: '/halo-cpe', name: 'Halo CPE', indexable: true, priority: '0.9' },
|
||||
{ id: 'p4', path: '/for-operators', name: 'For Operators', indexable: true, priority: '0.8' },
|
||||
{ id: 'p5', path: '/financial-services', name: 'Financial Services', indexable: true, priority: '0.8' },
|
||||
{ id: 'p6', path: '/why-blackdice', name: 'Why BlackDice?', indexable: true, priority: '0.7' },
|
||||
{ id: 'p7', path: '/news', name: 'News', indexable: true, priority: '0.8' },
|
||||
{ id: 'p8', path: '/investors', name: 'Investors', indexable: true, priority: '0.6' },
|
||||
{ id: 'p9', path: '/contact', name: 'Contact', indexable: true, priority: '0.7' },
|
||||
{ id: 'p11', path: '/cookie-policy', name: 'Cookie Policy', indexable: false, priority: '0.2' },
|
||||
{ id: 'p12', path: '/privacy-policy', name: 'Privacy Policy', indexable: false, priority: '0.2' },
|
||||
]
|
||||
|
||||
export const pageById = (id: string) => PAGES.find((p) => p.id === id)
|
||||
export const pageByPath = (path: string) => {
|
||||
const clean = path.replace(/\/+$/, '') || '/'
|
||||
return PAGES.find((p) => p.path === clean)
|
||||
}
|
||||
|
||||
/** Default per-page meta, carried over from the SEO pass on the legacy site. */
|
||||
export const DEFAULT_SEO: Record<string, PageSeo> = {
|
||||
p1: {
|
||||
title: 'BlackDice | AI-Powered Cyber Defence',
|
||||
description:
|
||||
'BlackDice Cyber delivers AI-powered network protection for telecoms operators and financial institutions — confidence while connected, security of experience, security of economics.',
|
||||
},
|
||||
p2: {
|
||||
title: 'Mobile SDK | BlackDice Cyber',
|
||||
description:
|
||||
'BlackDice Mobile SDK: real-time fraud signals embedded directly in your application, giving subscribers confidence while connected.',
|
||||
},
|
||||
p3: {
|
||||
title: 'Halo CPE | BlackDice Cyber',
|
||||
description:
|
||||
'BlackDice Halo CPE: industrial-grade cyber defence with zero hardware changes — security of economics for telecoms operators.',
|
||||
},
|
||||
p4: {
|
||||
title: 'For Operators | BlackDice Cyber',
|
||||
description:
|
||||
'AI-powered cybersecurity for telecoms operators and their subscribers — trusted connectivity as a competitive edge.',
|
||||
},
|
||||
p5: {
|
||||
title: 'Financial Services | BlackDice Cyber',
|
||||
description:
|
||||
'AI-powered fraud prevention for financial services institutions, stopping fraud before the transaction begins.',
|
||||
},
|
||||
p6: {
|
||||
title: 'Why BlackDice? | BlackDice Cyber',
|
||||
description:
|
||||
'Why BlackDice: a world where being connected means being protected, built on three pillars of network security.',
|
||||
},
|
||||
p7: {
|
||||
title: 'News & Insights | BlackDice Cyber',
|
||||
description: 'The latest news, announcements and insights from BlackDice Cyber.',
|
||||
},
|
||||
p8: {
|
||||
title: 'Investors | BlackDice Cyber',
|
||||
description: 'Investor information for BlackDice Cyber, part of Cyber Intelligence Group Ltd.',
|
||||
},
|
||||
p9: {
|
||||
title: 'Contact | BlackDice Cyber',
|
||||
description: 'Get in touch with BlackDice Cyber to safeguard your digital world.',
|
||||
},
|
||||
p10: {
|
||||
title: 'DNS Protect | BlackDice Cyber',
|
||||
description:
|
||||
'BlackDice DNS Protect: DNS-layer protection for every subscriber, live in days — security of experience.',
|
||||
},
|
||||
p11: { title: 'Cookie Policy | BlackDice Cyber', description: 'BlackDice Cyber cookie policy.' },
|
||||
p12: { title: 'Privacy Policy | BlackDice Cyber', description: 'BlackDice Cyber privacy policy.' },
|
||||
}
|
||||
|
||||
// ── Demo slots ─────────────────────────────────────────────────────────────────
|
||||
// Declared in code (so the pages have somewhere to render), configured in the CMS.
|
||||
// `scenario` links a slot to the interactive React demo that plays when no clip
|
||||
// has been uploaded yet; `poster` is the still shown for video-only slots.
|
||||
|
||||
export interface DemoSlotDef {
|
||||
key: string
|
||||
page: string
|
||||
/** Interactive threat-demo scenario id, when one exists. */
|
||||
scenario?: string
|
||||
defaults: DemoSlot
|
||||
}
|
||||
|
||||
export const DEMO_SLOTS: DemoSlotDef[] = [
|
||||
{
|
||||
key: 'sdk-callscam',
|
||||
page: 'p2',
|
||||
scenario: 'callscam',
|
||||
defaults: {
|
||||
title: 'Scam call detection — Voice / VOIP',
|
||||
caption:
|
||||
'A spoofed caller ID during an active banking session. The SDK correlates call, device and session signals and recommends blocking before the subscriber engages.',
|
||||
video: '',
|
||||
poster: '',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sdk-smsscam',
|
||||
page: 'p2',
|
||||
scenario: 'smsscam',
|
||||
defaults: {
|
||||
title: 'Scam SMS detection',
|
||||
caption:
|
||||
'A smishing message carrying a lookalike banking domain is flagged and the link neutralised at the point of delivery.',
|
||||
video: '',
|
||||
poster: '',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sdk-simswap',
|
||||
page: 'p2',
|
||||
scenario: 'simswap',
|
||||
defaults: {
|
||||
title: 'SIM swap detection',
|
||||
caption:
|
||||
'A SIM change on a trusted number raises the account risk score and forces identity re-verification before any transaction completes.',
|
||||
video: '',
|
||||
poster: '',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sdk-dns',
|
||||
page: 'p2',
|
||||
scenario: 'dns',
|
||||
defaults: {
|
||||
title: 'Per-device DNS analytics — iOS and Android',
|
||||
caption:
|
||||
'Device-level DNS visibility on both platforms: malicious lookups blocked in-line, with the per-device analytics behind the decision.',
|
||||
video: '',
|
||||
poster: '',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sdk-appperms',
|
||||
page: 'p2',
|
||||
scenario: 'appperms',
|
||||
defaults: {
|
||||
title: 'Permissions checking',
|
||||
caption:
|
||||
'An app requesting accessibility and SMS permissions it has no reason to hold — surfaced to the subscriber before install, with the risk explained in plain language.',
|
||||
video: '',
|
||||
poster: '',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'halo-retina',
|
||||
page: 'p3',
|
||||
defaults: {
|
||||
title: 'BlackDice Retina™ — operator dashboard walkthrough',
|
||||
caption:
|
||||
'Estate-wide visibility: network health, live threat activity and account status in one carrier-grade console.',
|
||||
video: '',
|
||||
poster: '/BLACKDICE-RETINA.png',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'halo-angel',
|
||||
page: 'p3',
|
||||
defaults: {
|
||||
title: 'BlackDice Angel™ — subscriber web UI walkthrough',
|
||||
caption:
|
||||
'The white-labelled subscriber experience: devices, blocked threats and household controls in one place.',
|
||||
video: '',
|
||||
poster: '/BLACKDICE-UI.png',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const demoSlotsForPage = (page: string) => DEMO_SLOTS.filter((s) => s.page === page)
|
||||
|
||||
export const DEFAULT_DEMOS: Record<string, DemoSlot> = Object.fromEntries(
|
||||
DEMO_SLOTS.map((s) => [s.key, s.defaults]),
|
||||
)
|
||||
107
src/cms/sanitise.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// Article bodies are written in the admin's rich-text editor, and most of the
|
||||
// existing ones were pasted out of Word/Outlook. Both routes bring along markup
|
||||
// that fights the site's design system (Aptos, black-on-dark text, MsoNormal),
|
||||
// so everything is normalised to semantic HTML before it is stored.
|
||||
//
|
||||
// This mirrors the sanitiser in scripts/seed-content.mjs; keep the two in step.
|
||||
|
||||
const KEEP_TAGS = new Set([
|
||||
'h2', 'h3', 'h4', 'p', 'ul', 'ol', 'li', 'strong', 'em', 'u', 'a', 'br',
|
||||
'blockquote', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'hr',
|
||||
])
|
||||
const UNWRAP_TAGS = new Set(['span', 'div', 'font', 'section', 'article', 'header', 'main', 'body', 'html'])
|
||||
const KEEP_ATTRS: Record<string, string[]> = { a: ['href'], img: ['src', 'alt'] }
|
||||
|
||||
/** Cleans a pasted or edited article body down to the tags the site styles. */
|
||||
export function sanitiseBody(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(`<div id="bd-root">${html}</div>`, 'text/html')
|
||||
const root = doc.getElementById('bd-root')
|
||||
if (!root) return ''
|
||||
|
||||
root.querySelectorAll('script, style, iframe, object, embed, link, meta, noscript').forEach((el) => el.remove())
|
||||
|
||||
const walk = (node: Element) => {
|
||||
// Snapshot: the list mutates as elements are unwrapped.
|
||||
for (const child of Array.from(node.children)) walk(child)
|
||||
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (tag === 'b') return rename(node, 'strong')
|
||||
if (tag === 'i') return rename(node, 'em')
|
||||
if (tag === 'h1' || tag === 'h5' || tag === 'h6') return rename(node, tag === 'h1' ? 'h2' : 'h4')
|
||||
|
||||
if (UNWRAP_TAGS.has(tag) || !KEEP_TAGS.has(tag)) {
|
||||
const parent = node.parentElement
|
||||
if (!parent) return
|
||||
while (node.firstChild) parent.insertBefore(node.firstChild, node)
|
||||
node.remove()
|
||||
return
|
||||
}
|
||||
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
const allowed = KEEP_ATTRS[tag] || []
|
||||
if (!allowed.includes(attr.name.toLowerCase())) node.removeAttribute(attr.name)
|
||||
}
|
||||
if (tag === 'a') {
|
||||
const href = node.getAttribute('href') || ''
|
||||
if (/^\s*javascript:/i.test(href)) node.removeAttribute('href')
|
||||
if (/^https?:/i.test(href)) {
|
||||
node.setAttribute('target', '_blank')
|
||||
node.setAttribute('rel', 'noopener')
|
||||
}
|
||||
}
|
||||
if (tag === 'img' && /^\s*javascript:/i.test(node.getAttribute('src') || '')) node.remove()
|
||||
}
|
||||
|
||||
const rename = (node: Element, tag: string) => {
|
||||
const replacement = node.ownerDocument.createElement(tag)
|
||||
while (node.firstChild) replacement.appendChild(node.firstChild)
|
||||
node.replaceWith(replacement)
|
||||
walk(replacement)
|
||||
}
|
||||
|
||||
for (const child of Array.from(root.children)) walk(child)
|
||||
|
||||
let out = root.innerHTML
|
||||
out = out.replace(/ /g, ' ')
|
||||
for (let i = 0; i < 4; i++) {
|
||||
out = out.replace(/<(p|h2|h3|h4|li|strong|em|u|blockquote)>\s*(?:<br\s*\/?>)*\s*<\/\1>/gi, '')
|
||||
}
|
||||
return out.replace(/\s+/g, ' ').replace(/>\s+</g, '><').trim()
|
||||
}
|
||||
|
||||
/** Inline rich text (headline fields) — no block structure, no attributes. */
|
||||
export function sanitiseInline(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(`<div id="bd-root">${html}</div>`, 'text/html')
|
||||
const root = doc.getElementById('bd-root')
|
||||
if (!root) return ''
|
||||
root.querySelectorAll('script, style, iframe').forEach((el) => el.remove())
|
||||
const allowed = new Set(['strong', 'em', 'b', 'i', 'u', 'br', 'span', 'a', 'sup', 'sub'])
|
||||
const walk = (node: Element) => {
|
||||
for (const child of Array.from(node.children)) walk(child)
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (!allowed.has(tag)) {
|
||||
const parent = node.parentElement
|
||||
if (!parent) return
|
||||
while (node.firstChild) parent.insertBefore(node.firstChild, node)
|
||||
node.remove()
|
||||
return
|
||||
}
|
||||
for (const attr of Array.from(node.attributes)) {
|
||||
if (!(tag === 'a' && attr.name === 'href')) node.removeAttribute(attr.name)
|
||||
}
|
||||
}
|
||||
for (const child of Array.from(root.children)) walk(child)
|
||||
return root.innerHTML.trim()
|
||||
}
|
||||
|
||||
/** Plain text preview of any HTML fragment. */
|
||||
export const textOf = (html: string) =>
|
||||
html.replace(/<[^>]*>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s+/g, ' ').trim()
|
||||
|
||||
export const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/['’]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'untitled'
|
||||
104
src/cms/seo.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Per-page metadata. Every page and article now has its own URL (see PAGES), so
|
||||
// title/description/canonical/OG have to move with the route rather than sitting
|
||||
// static in index.html.
|
||||
|
||||
export interface MetaInput {
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
image?: string
|
||||
type?: 'website' | 'article'
|
||||
siteUrl?: string
|
||||
robots?: string
|
||||
}
|
||||
|
||||
const DEFAULT_SITE_URL = 'https://www.blackdice.ai'
|
||||
|
||||
function upsert(selector: string, create: () => HTMLElement) {
|
||||
let el = document.head.querySelector<HTMLElement>(selector)
|
||||
if (!el) {
|
||||
el = create()
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
const metaByName = (name: string) =>
|
||||
upsert(`meta[name="${name}"]`, () => {
|
||||
const el = document.createElement('meta')
|
||||
el.setAttribute('name', name)
|
||||
return el
|
||||
})
|
||||
|
||||
const metaByProp = (property: string) =>
|
||||
upsert(`meta[property="${property}"]`, () => {
|
||||
const el = document.createElement('meta')
|
||||
el.setAttribute('property', property)
|
||||
return el
|
||||
})
|
||||
|
||||
export function applyMeta({ title, description, path, image, type = 'website', siteUrl, robots }: MetaInput) {
|
||||
const origin = (siteUrl || DEFAULT_SITE_URL).replace(/\/+$/, '')
|
||||
const url = origin + (path.startsWith('/') ? path : `/${path}`)
|
||||
const absImage = image ? (/^https?:/i.test(image) ? image : origin + image) : `${origin}/logo.svg`
|
||||
|
||||
document.title = title
|
||||
metaByName('description').setAttribute('content', description)
|
||||
metaByName('robots').setAttribute('content', robots || 'index, follow')
|
||||
|
||||
upsert('link[rel="canonical"]', () => {
|
||||
const el = document.createElement('link')
|
||||
el.setAttribute('rel', 'canonical')
|
||||
return el
|
||||
}).setAttribute('href', url)
|
||||
|
||||
metaByProp('og:type').setAttribute('content', type)
|
||||
metaByProp('og:title').setAttribute('content', title)
|
||||
metaByProp('og:description').setAttribute('content', description)
|
||||
metaByProp('og:url').setAttribute('content', url)
|
||||
metaByProp('og:image').setAttribute('content', absImage)
|
||||
metaByProp('og:site_name').setAttribute('content', 'BlackDice Cyber')
|
||||
|
||||
metaByName('twitter:card').setAttribute('content', 'summary_large_image')
|
||||
metaByName('twitter:title').setAttribute('content', title)
|
||||
metaByName('twitter:description').setAttribute('content', description)
|
||||
metaByName('twitter:image').setAttribute('content', absImage)
|
||||
}
|
||||
|
||||
/** Structured data for the current view; replaces any previous block. */
|
||||
export function applyJsonLd(data: object | null) {
|
||||
const id = 'bd-jsonld'
|
||||
document.getElementById(id)?.remove()
|
||||
if (!data) return
|
||||
const script = document.createElement('script')
|
||||
script.id = id
|
||||
script.type = 'application/ld+json'
|
||||
script.textContent = JSON.stringify(data)
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
export const organisationJsonLd = (siteUrl = DEFAULT_SITE_URL) => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'BlackDice Cyber',
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/logo.svg`,
|
||||
description:
|
||||
'AI-powered cyber defence for telecoms operators and financial institutions — protection embedded in the network.',
|
||||
})
|
||||
|
||||
export const articleJsonLd = (
|
||||
post: { title: string; excerpt: string; date: string; author: string; slug: string; hero: string; updatedAt?: string },
|
||||
siteUrl = DEFAULT_SITE_URL,
|
||||
) => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.title,
|
||||
description: post.excerpt,
|
||||
datePublished: post.date,
|
||||
dateModified: (post.updatedAt || post.date).slice(0, 10),
|
||||
author: { '@type': 'Organization', name: post.author || 'BlackDice Cyber' },
|
||||
publisher: { '@type': 'Organization', name: 'BlackDice Cyber', logo: { '@type': 'ImageObject', url: `${siteUrl}/logo.svg` } },
|
||||
mainEntityOfPage: `${siteUrl}/blog/${post.slug}`,
|
||||
...(post.hero ? { image: /^https?:/i.test(post.hero) ? post.hero : siteUrl + post.hero } : {}),
|
||||
})
|
||||
122
src/cms/store.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import type { Post, SiteContent } from './types'
|
||||
import { EMPTY_CONTENT } from './types'
|
||||
import { DEFAULT_DEMOS, DEFAULT_SEO } from './pages'
|
||||
import { fetchPublished } from './api'
|
||||
|
||||
// ── Content resolution ─────────────────────────────────────────────────────────
|
||||
// Three layers, each overriding the one before:
|
||||
// 1. the markup and defaults compiled into the build
|
||||
// 2. src/cms/generated/seedContent.json — factory content (the imported studio export)
|
||||
// 3. /content/site-content.json — whatever the admin last published
|
||||
// Layer 3 is a plain file, so a failed fetch degrades to a working site rather
|
||||
// than an empty one.
|
||||
|
||||
// The factory document carries every article body, so it is loaded on demand
|
||||
// rather than being bundled into the entry chunk.
|
||||
let factoryCache: Partial<SiteContent> | null = null
|
||||
export async function loadFactory(): Promise<Partial<SiteContent>> {
|
||||
if (!factoryCache) {
|
||||
factoryCache = (await import('./generated/seedContent.json')).default as unknown as Partial<SiteContent>
|
||||
}
|
||||
return factoryCache
|
||||
}
|
||||
|
||||
export function mergeContent(
|
||||
published: Partial<SiteContent> | null,
|
||||
factory: Partial<SiteContent> = factoryCache || {},
|
||||
): SiteContent {
|
||||
const demos = { ...DEFAULT_DEMOS }
|
||||
for (const [key, slot] of Object.entries(published?.demos || {})) {
|
||||
demos[key] = { ...(DEFAULT_DEMOS[key] || {}), ...slot }
|
||||
}
|
||||
return {
|
||||
...EMPTY_CONTENT,
|
||||
savedAt: published?.savedAt || factory.savedAt || '',
|
||||
content: { ...factory.content, ...published?.content },
|
||||
images: { ...factory.images, ...published?.images },
|
||||
numbers: { ...factory.numbers, ...published?.numbers },
|
||||
posts: (published?.posts as Post[]) ?? (factory.posts as Post[]) ?? [],
|
||||
demos,
|
||||
seo: { ...DEFAULT_SEO, ...published?.seo },
|
||||
settings: { ...EMPTY_CONTENT.settings, ...published?.settings },
|
||||
}
|
||||
}
|
||||
|
||||
/** Published document + factory fallback, resolved together. */
|
||||
export async function resolveContent(published?: Partial<SiteContent> | null): Promise<SiteContent> {
|
||||
const [factory, live] = await Promise.all([
|
||||
loadFactory(),
|
||||
published === undefined ? fetchPublished() : Promise.resolve(published),
|
||||
])
|
||||
return mergeContent(live, factory)
|
||||
}
|
||||
|
||||
// ── React plumbing ─────────────────────────────────────────────────────────────
|
||||
const ContentContext = createContext<SiteContent>(mergeContent(null, {}))
|
||||
|
||||
export const useContent = () => useContext(ContentContext)
|
||||
|
||||
export function ContentProvider({ children, value }: { children: ReactNode; value?: SiteContent }) {
|
||||
const [content, setContent] = useState<SiteContent | null>(value ?? null)
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setContent(value)
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
resolveContent().then((resolved) => {
|
||||
if (live) setContent(resolved)
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Hold first paint until content resolves so published copy never flashes.
|
||||
if (!content) return <div style={{ minHeight: '100vh', background: '#013B49' }} />
|
||||
return <ContentContext.Provider value={content}>{children}</ContentContext.Provider>
|
||||
}
|
||||
|
||||
// ── Applying content to the rendered markup ────────────────────────────────────
|
||||
|
||||
/** Writes text/image/stat overrides into the legacy markup that SiteApp renders. */
|
||||
export function applyContent(root: HTMLElement | Document, content: SiteContent) {
|
||||
for (const [id, html] of Object.entries(content.content)) {
|
||||
const el = root.querySelector<HTMLElement>(`[data-cms="${id}"]`)
|
||||
if (!el || el.innerHTML === html) continue
|
||||
// Never rewrite the element being typed into — it would reset the caret.
|
||||
if (el === document.activeElement || el.contains(document.activeElement)) continue
|
||||
el.innerHTML = html
|
||||
}
|
||||
for (const [id, src] of Object.entries(content.images)) {
|
||||
const el = root.querySelector<HTMLImageElement>(`[data-cms-img="${id}"]`)
|
||||
if (el && src && el.getAttribute('src') !== src) el.setAttribute('src', src)
|
||||
}
|
||||
for (const [id, num] of Object.entries(content.numbers)) {
|
||||
const el = root.querySelector<HTMLElement>(`[data-cms-num="${id}"]`)
|
||||
if (!el) continue
|
||||
el.dataset.count = String(num.value)
|
||||
if (num.suffix !== undefined) el.dataset.suffix = num.suffix
|
||||
// The count-up animation re-runs on navigation; seed the visible value now.
|
||||
el.textContent = `${num.value}${num.suffix ?? el.dataset.suffix ?? ''}`
|
||||
delete (el as HTMLElement & { _countDone?: boolean })._countDone
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post helpers ───────────────────────────────────────────────────────────────
|
||||
export const publishedPosts = (content: SiteContent) =>
|
||||
content.posts
|
||||
.filter((p) => p.status !== 'draft')
|
||||
.slice()
|
||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
|
||||
|
||||
export const postBySlug = (content: SiteContent, slug?: string) =>
|
||||
slug ? content.posts.find((p) => p.slug === slug) : undefined
|
||||
|
||||
export const formatPostDate = (iso: string) => {
|
||||
const d = new Date(`${iso}T00:00:00Z`)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
110
src/cms/types.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// ── The BlackDice content model ────────────────────────────────────────────────
|
||||
// One JSON document describes everything the admin can change. The site reads it;
|
||||
// /admin writes it. Nothing regenerates HTML files, so publishing can never wipe
|
||||
// hand-made changes the way the old blackdice-studio.html export did.
|
||||
|
||||
export type PostCategory = 'insights' | 'news' | 'press' | 'events'
|
||||
export type PostStatus = 'published' | 'draft'
|
||||
|
||||
export interface Post {
|
||||
/** Stable identity, independent of the slug so renames keep history. */
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
category: PostCategory
|
||||
/** ISO date (YYYY-MM-DD) — drives ordering and the displayed date. */
|
||||
date: string
|
||||
author: string
|
||||
excerpt: string
|
||||
/** URL of the hero image ('' for none). */
|
||||
hero: string
|
||||
/** Article body as sanitised HTML. */
|
||||
body: string
|
||||
status: PostStatus
|
||||
/** Optional SEO overrides; fall back to title/excerpt. */
|
||||
metaTitle?: string
|
||||
metaDescription?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/** A demo panel on a product page. Slots are declared in code, filled by the CMS. */
|
||||
export interface DemoSlot {
|
||||
/** Shown above the player. */
|
||||
title: string
|
||||
caption: string
|
||||
/** MP4/WebM URL. Empty → the slot falls back to its interactive demo or poster. */
|
||||
video: string
|
||||
/** Poster/still image URL. */
|
||||
poster: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface PageSeo {
|
||||
title: string
|
||||
description: string
|
||||
ogImage?: string
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
/** Where enquiry-form submissions are sent. */
|
||||
formRecipient: string
|
||||
/** Copied on enquiry submissions ('' for none). */
|
||||
formCc: string
|
||||
/** Public contact address shown on the site. */
|
||||
contactEmail: string
|
||||
siteUrl: string
|
||||
newsroomIntro: string
|
||||
}
|
||||
|
||||
export interface SiteContent {
|
||||
format: 'blackdice-react-content'
|
||||
version: 1
|
||||
savedAt: string
|
||||
/** data-cms id → innerHTML override. */
|
||||
content: Record<string, string>
|
||||
/** data-cms-img id → src override. */
|
||||
images: Record<string, string>
|
||||
/** data-cms-num id → animated stat value. */
|
||||
numbers: Record<string, { value: number; suffix?: string }>
|
||||
posts: Post[]
|
||||
/** demo slot key → configuration. */
|
||||
demos: Record<string, DemoSlot>
|
||||
/** page id (p1…p12) → meta overrides. */
|
||||
seo: Record<string, PageSeo>
|
||||
settings: SiteSettings
|
||||
}
|
||||
|
||||
/** Field manifest emitted by scripts/inject-cms-ids.mjs. */
|
||||
export interface CmsField {
|
||||
id: string
|
||||
kind: 'text' | 'image' | 'number'
|
||||
page: string
|
||||
tag: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
export const POST_CATEGORIES: { id: PostCategory; label: string; cls: string }[] = [
|
||||
{ id: 'insights', label: 'Insights', cls: 'ins' },
|
||||
{ id: 'news', label: 'News', cls: 'nws' },
|
||||
{ id: 'press', label: 'Press', cls: 'pr' },
|
||||
{ id: 'events', label: 'Events', cls: 'ev' },
|
||||
]
|
||||
|
||||
export const EMPTY_CONTENT: SiteContent = {
|
||||
format: 'blackdice-react-content',
|
||||
version: 1,
|
||||
savedAt: '',
|
||||
content: {},
|
||||
images: {},
|
||||
numbers: {},
|
||||
posts: [],
|
||||
demos: {},
|
||||
seo: {},
|
||||
settings: {
|
||||
formRecipient: 'campbell.ferrier@blackdice.ai',
|
||||
formCc: '',
|
||||
contactEmail: 'info@blackdice.ai',
|
||||
siteUrl: 'https://www.blackdice.ai',
|
||||
newsroomIntro: '',
|
||||
},
|
||||
}
|
||||
2052
src/demo/DemoScreen.tsx
Normal file
30
src/demo/DemoVideoPage.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { VideoPlayer } from './DemoScreen'
|
||||
import './demo.css'
|
||||
|
||||
/**
|
||||
* Standalone page for the cinematic BlackDice Angel product video.
|
||||
* Rendered in isolation from the marketing site's global CSS.
|
||||
*/
|
||||
export default function DemoVideoPage() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="bd-demo-root" style={{ position: 'relative', minHeight: '100vh', background: '#010e14' }}>
|
||||
{/* Back to site */}
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
position: 'fixed', top: 18, left: 22, zIndex: 80,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 7,
|
||||
fontFamily: "'Ubuntu', system-ui, sans-serif", fontSize: 13, fontWeight: 500,
|
||||
color: 'rgba(255,255,255,.75)', background: 'rgba(0,0,0,.45)',
|
||||
backdropFilter: 'blur(10px)', border: '1px solid rgba(255,255,255,.12)',
|
||||
borderRadius: 100, padding: '7px 14px', cursor: 'pointer',
|
||||
}}>
|
||||
← Back to site
|
||||
</button>
|
||||
|
||||
<VideoPlayer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
207
src/demo/components/Icons.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
interface IconProps {
|
||||
size?: number
|
||||
style?: CSSProperties
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function Ic({ size = 18, style, className, children }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
style={style}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconShield(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 3l7 3v5c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z"/></Ic>
|
||||
}
|
||||
|
||||
export function IconCheck(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M5 12l5 5 9-10"/></Ic>
|
||||
}
|
||||
|
||||
export function IconShieldCheck(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 3l7 3v5c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z"/><path d="M9 12l2.2 2.2L15.5 10"/></Ic>
|
||||
}
|
||||
|
||||
export function IconPause(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></Ic>
|
||||
}
|
||||
|
||||
export function IconPin(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 21s7-6.5 7-11a7 7 0 10-14 0c0 4.5 7 11 7 11z"/><circle cx="12" cy="10" r="2.4"/></Ic>
|
||||
}
|
||||
|
||||
export function IconBell(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M6 9a6 6 0 1112 0c0 5 2 6 2 6H4s2-1 2-6z"/><path d="M10 20a2 2 0 004 0"/></Ic>
|
||||
}
|
||||
|
||||
export function IconHome(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M4 11l8-7 8 7"/><path d="M6 10v9h12v-9"/></Ic>
|
||||
}
|
||||
|
||||
export function IconUsers(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><circle cx="9" cy="8" r="3"/><path d="M3 20a6 6 0 0112 0"/><path d="M16 6a3 3 0 010 6M21 20a6 6 0 00-5-5.9"/></Ic>
|
||||
}
|
||||
|
||||
export function IconWifi(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M2 8.5a15 15 0 0120 0"/><path d="M5 12a10 10 0 0114 0"/><path d="M8.5 15.5a5 5 0 017 0"/><circle cx="12" cy="19" r="1" fill="currentColor" stroke="none"/></Ic>
|
||||
}
|
||||
|
||||
export function IconPhone(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="7" y="3" width="10" height="18" rx="2"/><circle cx="12" cy="18" r="1" fill="currentColor" stroke="none"/></Ic>
|
||||
}
|
||||
|
||||
export function IconLaptop(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 20h20"/></Ic>
|
||||
}
|
||||
|
||||
export function IconTv(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="3" y="5" width="18" height="12" rx="2"/><path d="M8 21h8"/></Ic>
|
||||
}
|
||||
|
||||
export function IconQ(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><circle cx="12" cy="12" r="9"/><path d="M9.5 9.5a2.5 2.5 0 014.5 1.5c0 1.5-2 2-2 3.5"/><circle cx="12" cy="17.5" r=".6" fill="currentColor" stroke="none"/></Ic>
|
||||
}
|
||||
|
||||
export function IconAlert(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 4l9 16H3z"/><path d="M12 10v4"/><circle cx="12" cy="17" r=".6" fill="currentColor" stroke="none"/></Ic>
|
||||
}
|
||||
|
||||
export function IconChevron(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M9 6l6 6-6 6"/></Ic>
|
||||
}
|
||||
|
||||
export function IconArrow(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M5 12h14M13 6l6 6-6 6"/></Ic>
|
||||
}
|
||||
|
||||
export function IconRouter(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="3" y="13" width="18" height="6" rx="2"/><path d="M7 16h.01M11 16h.01"/><path d="M12 9V5M8.5 8a5 5 0 017 0"/></Ic>
|
||||
}
|
||||
|
||||
export function IconLock(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 018 0v3"/></Ic>
|
||||
}
|
||||
|
||||
export function IconEye(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="2.6"/></Ic>
|
||||
}
|
||||
|
||||
export function IconClock(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></Ic>
|
||||
}
|
||||
|
||||
export function IconGlobe(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 010 18M12 3a14 14 0 000 18"/></Ic>
|
||||
}
|
||||
|
||||
export function IconSliders(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M4 8h10M18 8h2M4 16h2M10 16h10"/><circle cx="16" cy="8" r="2" fill="var(--bg)"/><circle cx="8" cy="16" r="2" fill="var(--bg)"/></Ic>
|
||||
}
|
||||
|
||||
export function IconCard(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="2" y="6" width="20" height="13" rx="2"/><path d="M2 10h20"/></Ic>
|
||||
}
|
||||
|
||||
export function IconPlus(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 5v14M5 12h14"/></Ic>
|
||||
}
|
||||
|
||||
export function IconSettings(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/></Ic>
|
||||
}
|
||||
|
||||
export function IconApps(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/></Ic>
|
||||
}
|
||||
|
||||
export function IconMap(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M9 4L3 6v14l6-2 6 2 6-2V4l-6 2-6-2z"/><path d="M9 4v14M15 6v14"/></Ic>
|
||||
}
|
||||
|
||||
export function IconX(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M6 6l12 12M18 6L6 18"/></Ic>
|
||||
}
|
||||
|
||||
export function IconDownload(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 4v11M7 11l5 5 5-5M5 20h14"/></Ic>
|
||||
}
|
||||
|
||||
export function IconFinger(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 11v3a4 4 0 01-4 4M8 11a4 4 0 018 0v2a6 6 0 01-1 3M12 3a8 8 0 00-8 8v2"/></Ic>
|
||||
}
|
||||
|
||||
export function IconHourglass(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M6 3h12M6 21h12M7 3c0 5 5 5 5 9s-5 4-5 9M17 3c0 5-5 5-5 9s5 4 5 9"/></Ic>
|
||||
}
|
||||
|
||||
export function IconWifiOff(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M2 8.5a15 15 0 0119 0M5 12a10 10 0 0112 0M3 3l18 18"/><circle cx="12" cy="19" r="1" fill="currentColor" stroke="none"/></Ic>
|
||||
}
|
||||
|
||||
export function IconChild(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><circle cx="12" cy="7" r="3.5"/><path d="M6 21v-2a6 6 0 0112 0v2"/></Ic>
|
||||
}
|
||||
|
||||
export function IconSchool(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M3 9l9-5 9 5-9 5-9-5z"/><path d="M7 11v5c0 1.5 2.5 3 5 3s5-1.5 5-3v-5"/></Ic>
|
||||
}
|
||||
|
||||
export function IconStar(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 3l2.6 5.3 5.8.8-4.2 4.1 1 5.8L12 16.3 6.8 19l1-5.8L3.6 9.1l5.8-.8z"/></Ic>
|
||||
}
|
||||
|
||||
export function IconToaster(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="4" y="9" width="16" height="10" rx="2"/><path d="M8 9V7M12 9V6M16 9V7M7 19v1M17 19v1"/><path d="M16 12h2"/></Ic>
|
||||
}
|
||||
|
||||
export function IconCamera(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="3" y="7" width="18" height="13" rx="2"/><circle cx="12" cy="13.5" r="3.2"/><path d="M8 7l1.5-2h5L16 7"/></Ic>
|
||||
}
|
||||
|
||||
export function IconSpeaker(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="6" y="3" width="12" height="18" rx="2"/><circle cx="12" cy="14" r="3.2"/><circle cx="12" cy="6.5" r="1"/></Ic>
|
||||
}
|
||||
|
||||
export function IconConsole(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="3" y="8" width="18" height="9" rx="4"/><path d="M7 12h3M8.5 10.5v3"/><circle cx="16" cy="11.5" r="1"/><circle cx="17.5" cy="13.5" r="1"/></Ic>
|
||||
}
|
||||
|
||||
export function IconWatch(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="7" y="7" width="10" height="10" rx="3"/><path d="M9 7l.5-3h5L15 7M9 17l.5 3h5l.5-3"/></Ic>
|
||||
}
|
||||
|
||||
export function IconBolt(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M13 3L5 14h6l-1 7 8-11h-6z"/></Ic>
|
||||
}
|
||||
|
||||
export function IconGauge(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M4 18a8 8 0 1116 0"/><path d="M12 18l4-5"/><circle cx="12" cy="18" r="1.4" fill="currentColor" stroke="none"/></Ic>
|
||||
}
|
||||
|
||||
export function IconBroadcast(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><circle cx="12" cy="12" r="2"/><path d="M7.5 7.5a6 6 0 000 9M16.5 7.5a6 6 0 010 9M4.5 4.5a10 10 0 000 15M19.5 4.5a10 10 0 010 15"/></Ic>
|
||||
}
|
||||
|
||||
export function IconDoorLock(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="12" cy="12" r="2"/><path d="M12 14v3"/></Ic>
|
||||
}
|
||||
|
||||
export function IconThermostat(props: Omit<IconProps, 'children'>) {
|
||||
return <Ic {...props}><path d="M12 3v10.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7V3z"/><path d="M9 7h3M9 10h3"/></Ic>
|
||||
}
|
||||
125
src/demo/components/ScoreRing.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { animate } from 'framer-motion'
|
||||
|
||||
interface ScoreRingProps {
|
||||
score: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
/** Delay before animation starts, ms */
|
||||
delay?: number
|
||||
showLabel?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated SVG ring that fills from 0 → score over ~1.4s.
|
||||
* Colour interpolates red→amber→mint based on score value.
|
||||
*/
|
||||
export function ScoreRing({
|
||||
score,
|
||||
size = 96,
|
||||
strokeWidth = 8,
|
||||
delay = 200,
|
||||
showLabel = true,
|
||||
}: ScoreRingProps) {
|
||||
const r = (size - strokeWidth) / 2
|
||||
const circ = 2 * Math.PI * r
|
||||
const fillRef = useRef<SVGCircleElement>(null)
|
||||
const numRef = useRef<HTMLSpanElement>(null)
|
||||
|
||||
// colour ramp: ≥80 mint, ≥60 amber, <60 red
|
||||
const colour =
|
||||
score >= 80 ? '#10b981' :
|
||||
score >= 60 ? '#f59e0b' :
|
||||
'#ef4444'
|
||||
|
||||
useEffect(() => {
|
||||
const circle = fillRef.current
|
||||
const numEl = numRef.current
|
||||
if (!circle || !numEl) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
// Animate stroke-dashoffset
|
||||
animate(circ, circ - (circ * score) / 100, {
|
||||
duration: 1.4,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
onUpdate: v => {
|
||||
circle.style.strokeDashoffset = String(v)
|
||||
},
|
||||
})
|
||||
// Animate counter
|
||||
animate(0, score, {
|
||||
duration: 1.4,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
onUpdate: v => {
|
||||
numEl.textContent = Math.round(v).toString()
|
||||
},
|
||||
})
|
||||
}, delay)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [score, circ, delay])
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: size, height: size, flexShrink: 0 }}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{ transform: 'rotate(-90deg)' }}
|
||||
>
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--surface-3, #1a2236)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Fill */}
|
||||
<circle
|
||||
ref={fillRef}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={colour}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circ}
|
||||
strokeDashoffset={circ}
|
||||
style={{ transition: 'stroke 0.4s' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{showLabel && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
ref={numRef}
|
||||
style={{
|
||||
fontSize: size * 0.22,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
color: colour,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
0
|
||||
</span>
|
||||
<span style={{ fontSize: size * 0.115, color: 'var(--text-mute, #64748b)', marginTop: 1 }}>
|
||||
/100
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
365
src/demo/context/DemoContext.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FlowId = 'A' | 'B' | 'C' | 'D'
|
||||
|
||||
export interface DemoStep {
|
||||
id: string
|
||||
label: string
|
||||
screen: string
|
||||
caption: string
|
||||
durationMs: number
|
||||
camera?: string
|
||||
}
|
||||
|
||||
export interface DemoFlow {
|
||||
id: FlowId
|
||||
title: string
|
||||
subtitle: string
|
||||
durationSec: number
|
||||
color: string
|
||||
steps: DemoStep[]
|
||||
}
|
||||
|
||||
// ── Flow definitions ──────────────────────────────────────────────────────────
|
||||
|
||||
const FLOWS: Record<FlowId, DemoFlow> = {
|
||||
A: {
|
||||
id: 'A',
|
||||
title: 'Critical Alert → Resolution',
|
||||
subtitle: 'Malware detected — PS5 isolated in one tap',
|
||||
durationSec: 60,
|
||||
color: '#ef4444',
|
||||
steps: [
|
||||
{
|
||||
id: 'A1',
|
||||
label: 'Home Dashboard',
|
||||
screen: 'home',
|
||||
caption: 'Hughes Family · 12 Devices Protected · Score 92',
|
||||
camera: 'Static wide. All green.',
|
||||
durationMs: 4000,
|
||||
},
|
||||
{
|
||||
id: 'A2',
|
||||
label: 'Alert Toast',
|
||||
screen: 'alert-toast',
|
||||
caption: '🚨 CRITICAL · Malware detected · PlayStation 5 · Tap to view',
|
||||
camera: 'Slow zoom toward toast. Hold 2s.',
|
||||
durationMs: 6000,
|
||||
},
|
||||
{
|
||||
id: 'A3',
|
||||
label: 'Alert Detail',
|
||||
screen: 'alert-detail',
|
||||
caption: 'Trojan.GenericKD.71234 · C&C: 185.220.101.47 (Amsterdam 🇳🇱)',
|
||||
camera: 'Fade in. Scroll down slowly.',
|
||||
durationMs: 12000,
|
||||
},
|
||||
{
|
||||
id: 'A4',
|
||||
label: 'Device Detail — PS5',
|
||||
screen: 'device-ps5',
|
||||
caption: 'PlayStation 5 · THREAT DETECTED · Unusual traffic spike',
|
||||
camera: 'Slide right. Zoom on threat badge.',
|
||||
durationMs: 12000,
|
||||
},
|
||||
{
|
||||
id: 'A5',
|
||||
label: 'Isolation Modal',
|
||||
screen: 'isolate-modal',
|
||||
caption: 'Isolate Device from Network? · [Isolate Now]',
|
||||
camera: 'Modal scales up. Tap animation.',
|
||||
durationMs: 8000,
|
||||
},
|
||||
{
|
||||
id: 'A6',
|
||||
label: 'Isolation Progress',
|
||||
screen: 'isolate-progress',
|
||||
caption: 'Blocking network · Terminating C&C · Logging incident…',
|
||||
camera: 'Tight zoom on progress ring.',
|
||||
durationMs: 8000,
|
||||
},
|
||||
{
|
||||
id: 'A7',
|
||||
label: 'Resolved',
|
||||
screen: 'resolved',
|
||||
caption: '✅ Threat Neutralised · Family Score: 94 ▲',
|
||||
camera: 'Zoom out to full dashboard.',
|
||||
durationMs: 10000,
|
||||
},
|
||||
],
|
||||
},
|
||||
B: {
|
||||
id: 'B',
|
||||
title: 'Incident Investigation',
|
||||
subtitle: 'Suspicious login from Moscow — traced and blocked',
|
||||
durationSec: 45,
|
||||
color: '#f59e0b',
|
||||
steps: [
|
||||
{
|
||||
id: 'B1',
|
||||
label: 'Notification Panel',
|
||||
screen: 'notif-panel',
|
||||
caption: "CRITICAL · Suspicious login on Emma's Instagram · 02:31 · 🇷🇺",
|
||||
camera: 'Notification drawer slides open.',
|
||||
durationMs: 6000,
|
||||
},
|
||||
{
|
||||
id: 'B2',
|
||||
label: "Emma's Profile",
|
||||
screen: 'emma-profile',
|
||||
caption: 'Emma Hughes · Age 14 · ⚠️ Account Alert · Investigate →',
|
||||
camera: 'Tap into profile. Banner slides in.',
|
||||
durationMs: 8000,
|
||||
},
|
||||
{
|
||||
id: 'B3',
|
||||
label: 'Event Timeline',
|
||||
screen: 'timeline',
|
||||
caption: '02:31 — Login from Moscow 🇷🇺 · 02:32 — Password change blocked',
|
||||
camera: 'Scroll timeline top-to-bottom.',
|
||||
durationMs: 12000,
|
||||
},
|
||||
{
|
||||
id: 'B4',
|
||||
label: 'IP Investigation',
|
||||
screen: 'ip-detail',
|
||||
caption: '77.88.55.244 · Moscow · 94 abuse reports · Credential stuffing',
|
||||
camera: 'Card expands with geo + risk.',
|
||||
durationMs: 10000,
|
||||
},
|
||||
{
|
||||
id: 'B5',
|
||||
label: 'Affected Scope',
|
||||
screen: 'scope',
|
||||
caption: '1 account compromised · 0 other devices · Force password reset →',
|
||||
camera: 'Zoom out. Devices scan.',
|
||||
durationMs: 9000,
|
||||
},
|
||||
],
|
||||
},
|
||||
C: {
|
||||
id: 'C',
|
||||
title: 'Threat Remediation',
|
||||
subtitle: 'HP printer CVE patched automatically',
|
||||
durationSec: 45,
|
||||
color: '#8b5cf6',
|
||||
steps: [
|
||||
{
|
||||
id: 'C1',
|
||||
label: 'Devices List',
|
||||
screen: 'devices-list',
|
||||
caption: 'HP LaserJet Pro · ⚠️ Outdated Firmware · CVE-2024-8471',
|
||||
camera: 'Pan down list, settle on printer.',
|
||||
durationMs: 7000,
|
||||
},
|
||||
{
|
||||
id: 'C2',
|
||||
label: 'Vulnerability Detail',
|
||||
screen: 'vuln-detail',
|
||||
caption: 'CVE-2024-8471 · CVSS 7.8 · RCE via port 9100 · Fix: v22.6.1',
|
||||
camera: 'Fade to detail. Gauge fills to 7.8.',
|
||||
durationMs: 9000,
|
||||
},
|
||||
{
|
||||
id: 'C3',
|
||||
label: 'Full Network Scan',
|
||||
screen: 'scan-running',
|
||||
caption: 'Scanning 12 devices… ✅ × 11 · ⚠️ Printer — 1 issue',
|
||||
camera: 'Static. Devices check one by one.',
|
||||
durationMs: 10000,
|
||||
},
|
||||
{
|
||||
id: 'C4',
|
||||
label: 'Remediation Action',
|
||||
screen: 'remediation',
|
||||
caption: 'Auto-update firmware to v22.6.1 · No downtime · [Update Now]',
|
||||
camera: 'Zoom on CTA. Progress bar fills.',
|
||||
durationMs: 10000,
|
||||
},
|
||||
{
|
||||
id: 'C5',
|
||||
label: 'Verified — All Clear',
|
||||
screen: 'verified',
|
||||
caption: '✅ HP Printer · Patched · All 12 devices secure · Score 95 ▲',
|
||||
camera: 'Zoom out. Green sweep.',
|
||||
durationMs: 9000,
|
||||
},
|
||||
],
|
||||
},
|
||||
D: {
|
||||
id: 'D',
|
||||
title: 'Security Posture Reporting',
|
||||
subtitle: 'Score, trends, devices, PDF export',
|
||||
durationSec: 30,
|
||||
color: '#10b981',
|
||||
steps: [
|
||||
{
|
||||
id: 'D1',
|
||||
label: 'Security Score',
|
||||
screen: 'score-ring',
|
||||
caption: 'Family Security Score · 92/100 · Excellent · ▲ +4 this week',
|
||||
camera: 'Slow zoom in. Ring fills 0→92.',
|
||||
durationMs: 7000,
|
||||
},
|
||||
{
|
||||
id: 'D2',
|
||||
label: 'Threat Trends',
|
||||
screen: 'trends-chart',
|
||||
caption: '247 threats blocked · ↓12% vs last week · Mon–Sun bars',
|
||||
camera: 'Slight zoom. Bars grow up.',
|
||||
durationMs: 9000,
|
||||
},
|
||||
{
|
||||
id: 'D3',
|
||||
label: 'Device Grid',
|
||||
screen: 'device-grid',
|
||||
caption: '12 Devices Protected · 0 Critical · Last scan: Now',
|
||||
camera: 'Cards stagger in. Shield bounce.',
|
||||
durationMs: 7000,
|
||||
},
|
||||
{
|
||||
id: 'D4',
|
||||
label: 'Export PDF',
|
||||
screen: 'export',
|
||||
caption: 'Weekly Security Report · Hughes Family · Jun 23–29 · [Download]',
|
||||
camera: 'Sheet slides up from bottom.',
|
||||
durationMs: 7000,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const ALL_FLOWS = Object.values(FLOWS)
|
||||
|
||||
// ── Hook: useDemoPlayer ───────────────────────────────────────────────────────
|
||||
|
||||
export interface DemoPlayerState {
|
||||
flow: DemoFlow
|
||||
stepIndex: number
|
||||
step: DemoStep
|
||||
isPlaying: boolean
|
||||
progress: number // 0–1 within current step
|
||||
totalProgress: number // 0–1 across the whole flow
|
||||
play: () => void
|
||||
pause: () => void
|
||||
reset: () => void
|
||||
goTo: (index: number) => void
|
||||
next: () => void
|
||||
prev: () => void
|
||||
}
|
||||
|
||||
export function useDemoPlayer(flowId: FlowId): DemoPlayerState {
|
||||
const flow = FLOWS[flowId]
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const startRef = useRef<number>(0)
|
||||
const rafRef = useRef<number>(0)
|
||||
const stepRef = useRef(stepIndex)
|
||||
stepRef.current = stepIndex
|
||||
|
||||
const tick = useCallback(() => {
|
||||
const elapsed = Date.now() - startRef.current
|
||||
const dur = flow.steps[stepRef.current].durationMs
|
||||
const p = Math.min(elapsed / dur, 1)
|
||||
setProgress(p)
|
||||
|
||||
if (p < 1) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} else {
|
||||
const next = stepRef.current + 1
|
||||
if (next < flow.steps.length) {
|
||||
setStepIndex(next)
|
||||
startRef.current = Date.now()
|
||||
setProgress(0)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} else {
|
||||
setIsPlaying(false)
|
||||
setProgress(1)
|
||||
}
|
||||
}
|
||||
}, [flow])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
startRef.current = Date.now()
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} else {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [isPlaying, stepIndex, tick])
|
||||
|
||||
const play = useCallback(() => { setProgress(0); setIsPlaying(true) }, [])
|
||||
const pause = useCallback(() => setIsPlaying(false), [])
|
||||
const reset = useCallback(() => {
|
||||
setIsPlaying(false)
|
||||
setStepIndex(0)
|
||||
setProgress(0)
|
||||
}, [])
|
||||
|
||||
const goTo = useCallback((i: number) => {
|
||||
setIsPlaying(false)
|
||||
setStepIndex(Math.max(0, Math.min(i, flow.steps.length - 1)))
|
||||
setProgress(0)
|
||||
}, [flow.steps.length])
|
||||
|
||||
const next = useCallback(() => goTo(stepRef.current + 1), [goTo])
|
||||
const prev = useCallback(() => goTo(stepRef.current - 1), [goTo])
|
||||
|
||||
// Cumulative total progress
|
||||
const totalDuration = flow.steps.reduce((s, x) => s + x.durationMs, 0)
|
||||
const elapsed = flow.steps.slice(0, stepIndex).reduce((s, x) => s + x.durationMs, 0)
|
||||
+ progress * flow.steps[stepIndex].durationMs
|
||||
const totalProgress = elapsed / totalDuration
|
||||
|
||||
return {
|
||||
flow,
|
||||
stepIndex,
|
||||
step: flow.steps[stepIndex],
|
||||
isPlaying,
|
||||
progress,
|
||||
totalProgress,
|
||||
play,
|
||||
pause,
|
||||
reset,
|
||||
goTo,
|
||||
next,
|
||||
prev,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context (optional global usage) ──────────────────────────────────────────
|
||||
|
||||
interface DemoCtxValue {
|
||||
activeFlow: FlowId | null
|
||||
setActiveFlow: (f: FlowId | null) => void
|
||||
}
|
||||
|
||||
const DemoCtx = createContext<DemoCtxValue>({
|
||||
activeFlow: null,
|
||||
setActiveFlow: () => {},
|
||||
})
|
||||
|
||||
export function useDemoContext() {
|
||||
return useContext(DemoCtx)
|
||||
}
|
||||
|
||||
export function DemoProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeFlow, setActiveFlow] = useState<FlowId | null>(null)
|
||||
return (
|
||||
<DemoCtx.Provider value={{ activeFlow, setActiveFlow }}>
|
||||
{children}
|
||||
</DemoCtx.Provider>
|
||||
)
|
||||
}
|
||||
732
src/demo/data/mock.ts
Normal file
@@ -0,0 +1,732 @@
|
||||
// ── Base Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Child {
|
||||
id: 'E' | 'M' | 'J'
|
||||
name: string
|
||||
age: number
|
||||
filter: string
|
||||
status: string
|
||||
pill: 'ok' | 'warn' | 'off' | 'danger'
|
||||
screenTime: { used: number; limit: number }
|
||||
/** Screen-time breakdown by app (minutes) */
|
||||
apps?: ScreenTimeApp[]
|
||||
location?: string
|
||||
deviceId?: string
|
||||
}
|
||||
|
||||
export interface ScreenTimeApp {
|
||||
name: string
|
||||
icon: string
|
||||
minutes: number
|
||||
category: 'social' | 'gaming' | 'education' | 'streaming' | 'other'
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string
|
||||
icon: string
|
||||
name: string
|
||||
sub: string
|
||||
status: 'online' | 'offline' | 'blocked'
|
||||
pill: 'ok' | 'warn' | 'off' | 'danger'
|
||||
category: 'phones' | 'home' | 'guest'
|
||||
assignedTo?: string
|
||||
/** Extra metadata for demo flows */
|
||||
ip?: string
|
||||
mac?: string
|
||||
os?: string
|
||||
firmware?: string
|
||||
lastSeen?: string
|
||||
/** If a security alert is active on this device */
|
||||
alertId?: string
|
||||
}
|
||||
|
||||
export interface Place {
|
||||
id: string
|
||||
icon: string
|
||||
name: string
|
||||
who: string[]
|
||||
arriveAlert: boolean
|
||||
leaveAlert: boolean
|
||||
radius: number
|
||||
pinX: number
|
||||
pinY: number
|
||||
}
|
||||
|
||||
export interface ApprovalRequest {
|
||||
id: string
|
||||
childId: 'E' | 'M' | 'J'
|
||||
childName: string
|
||||
type: 'app' | 'site' | 'time'
|
||||
subject: string
|
||||
description: string
|
||||
time: string
|
||||
status: 'pending' | 'approved' | 'denied'
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
icon: string
|
||||
title: string
|
||||
body: string
|
||||
time: string
|
||||
read: boolean
|
||||
severity: 'info' | 'warn' | 'danger' | 'ok'
|
||||
}
|
||||
|
||||
export interface IdentityMember {
|
||||
id: 'S' | 'E' | 'M' | 'J'
|
||||
name: string
|
||||
email: string
|
||||
breaches: number
|
||||
lastChecked: string
|
||||
status: 'clean' | 'breached' | 'monitoring'
|
||||
}
|
||||
|
||||
// ── New: Security Alert ───────────────────────────────────────────────────────
|
||||
|
||||
export type AlertSeverity = 'critical' | 'high' | 'medium' | 'low'
|
||||
export type AlertType =
|
||||
| 'malware'
|
||||
| 'suspicious_login'
|
||||
| 'unsafe_wifi'
|
||||
| 'content_blocked'
|
||||
| 'scam_call'
|
||||
| 'vulnerability'
|
||||
| 'port_scan'
|
||||
|
||||
export interface SecurityAlert {
|
||||
id: string
|
||||
severity: AlertSeverity
|
||||
type: AlertType
|
||||
title: string
|
||||
body: string
|
||||
deviceId?: string
|
||||
deviceName?: string
|
||||
memberId?: string
|
||||
memberName?: string
|
||||
/** Geo origin of threat (for login / network attacks) */
|
||||
geo?: { city: string; country: string; flag: string; ip: string }
|
||||
/** CVE identifier for vulnerability alerts */
|
||||
cve?: string
|
||||
cvss?: number
|
||||
timestamp: string
|
||||
/** Human-readable relative time */
|
||||
timeAgo: string
|
||||
status: 'open' | 'investigating' | 'resolved' | 'dismissed'
|
||||
}
|
||||
|
||||
// ── New: Weekly Report ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WeeklyReport {
|
||||
period: string
|
||||
threatsBlocked: number
|
||||
threatsVsLastWeek: number // positive = worse, negative = better
|
||||
devicesProtected: number
|
||||
openIssues: number
|
||||
topThreats: { label: string; pct: number }[]
|
||||
daily: number[] // 7 values Mon–Sun
|
||||
}
|
||||
|
||||
// ── New: Security Score ───────────────────────────────────────────────────────
|
||||
|
||||
export interface SecurityScore {
|
||||
score: number
|
||||
previous: number
|
||||
change: number
|
||||
trend: 'up' | 'down' | 'stable'
|
||||
label: 'Excellent' | 'Good' | 'Fair' | 'Poor'
|
||||
history: number[] // last 8 weeks
|
||||
}
|
||||
|
||||
// ── New: Incident Timeline Event ─────────────────────────────────────────────
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string
|
||||
time: string
|
||||
label: string
|
||||
detail: string
|
||||
kind: 'normal' | 'anomaly' | 'blocked' | 'info'
|
||||
}
|
||||
|
||||
// ── Children ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const CHILDREN: Child[] = [
|
||||
{
|
||||
id: 'E',
|
||||
name: 'Emma',
|
||||
age: 14,
|
||||
filter: 'Teen filter',
|
||||
status: 'Online',
|
||||
pill: 'ok',
|
||||
screenTime: { used: 167, limit: 240 },
|
||||
location: 'School',
|
||||
deviceId: 'd3',
|
||||
apps: [
|
||||
{ name: 'TikTok', icon: '🎵', minutes: 72, category: 'social' },
|
||||
{ name: 'YouTube', icon: '▶️', minutes: 48, category: 'streaming' },
|
||||
{ name: 'Snapchat', icon: '👻', minutes: 31, category: 'social' },
|
||||
{ name: 'Instagram', icon: '📸', minutes: 16, category: 'social' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'M',
|
||||
name: 'Mia',
|
||||
age: 10,
|
||||
filter: 'Young child',
|
||||
status: 'Online',
|
||||
pill: 'ok',
|
||||
screenTime: { used: 45, limit: 120 },
|
||||
location: 'Home',
|
||||
deviceId: 'd2',
|
||||
apps: [
|
||||
{ name: 'Disney+', icon: '✨', minutes: 32, category: 'streaming' },
|
||||
{ name: 'Duolingo', icon: '🦜', minutes: 13, category: 'education' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'J',
|
||||
name: 'Jack',
|
||||
age: 8,
|
||||
filter: 'Young child',
|
||||
status: 'Limit reached',
|
||||
pill: 'warn',
|
||||
screenTime: { used: 90, limit: 90 },
|
||||
location: 'Home',
|
||||
deviceId: 'd4',
|
||||
apps: [
|
||||
{ name: 'Minecraft', icon: '⛏️', minutes: 60, category: 'gaming' },
|
||||
{ name: 'Roblox', icon: '🎮', minutes: 30, category: 'gaming' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Devices ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DEVICES: Device[] = [
|
||||
{
|
||||
id: 'd1',
|
||||
icon: 'phone',
|
||||
name: "Sarah's iPhone 15 Pro",
|
||||
sub: 'iPhone 15 Pro · iOS 17.4',
|
||||
status: 'online',
|
||||
pill: 'ok',
|
||||
category: 'phones',
|
||||
assignedTo: 'Sarah',
|
||||
ip: '192.168.1.2',
|
||||
mac: 'A4:C3:F0:11:22:33',
|
||||
lastSeen: 'Now',
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
icon: 'phone',
|
||||
name: "Mia's iPad Air",
|
||||
sub: 'iPad Air 5th Gen · iPadOS 17.4',
|
||||
status: 'online',
|
||||
pill: 'ok',
|
||||
category: 'phones',
|
||||
assignedTo: 'Mia',
|
||||
ip: '192.168.1.6',
|
||||
mac: 'F4:0F:24:35:22:AA',
|
||||
lastSeen: 'Now',
|
||||
},
|
||||
{
|
||||
id: 'd3',
|
||||
icon: 'phone',
|
||||
name: "Emma's iPhone 14",
|
||||
sub: 'iPhone 14 · iOS 17.2',
|
||||
status: 'online',
|
||||
pill: 'ok',
|
||||
category: 'phones',
|
||||
assignedTo: 'Emma',
|
||||
ip: '192.168.1.5',
|
||||
mac: 'B8:8D:12:66:4A:01',
|
||||
lastSeen: '14 min ago',
|
||||
},
|
||||
{
|
||||
id: 'd4',
|
||||
icon: 'phone',
|
||||
name: "Jack's Nintendo Switch",
|
||||
sub: 'Nintendo Switch · FW 17.0.1',
|
||||
status: 'online',
|
||||
pill: 'ok',
|
||||
category: 'phones',
|
||||
assignedTo: 'Jack',
|
||||
ip: '192.168.1.8',
|
||||
mac: 'C4:4B:D1:87:3C:22',
|
||||
lastSeen: '2 min ago',
|
||||
},
|
||||
{
|
||||
id: 'd5',
|
||||
icon: 'laptop',
|
||||
name: "Mark's MacBook Pro 16\"",
|
||||
sub: 'MacBook Pro M3 · macOS 14.5',
|
||||
status: 'online',
|
||||
pill: 'ok',
|
||||
category: 'home',
|
||||
ip: '192.168.1.3',
|
||||
lastSeen: 'Now',
|
||||
},
|
||||
{
|
||||
id: 'd6',
|
||||
icon: 'console',
|
||||
name: 'PlayStation 5',
|
||||
sub: 'Sony PS5 · Firmware 23.02',
|
||||
status: 'online',
|
||||
pill: 'danger',
|
||||
category: 'home',
|
||||
assignedTo: 'Jack',
|
||||
ip: '192.168.1.9',
|
||||
mac: 'BC:60:A7:F4:23:88',
|
||||
lastSeen: '09:14',
|
||||
alertId: 'alert-001',
|
||||
},
|
||||
{
|
||||
id: 'd7',
|
||||
icon: 'tv',
|
||||
name: 'Samsung 65" Smart TV',
|
||||
sub: 'Samsung QLED · Tizen 7.0',
|
||||
status: 'online',
|
||||
pill: 'ok',
|
||||
category: 'home',
|
||||
ip: '192.168.1.10',
|
||||
lastSeen: '1 hr ago',
|
||||
},
|
||||
{
|
||||
id: 'd8',
|
||||
icon: 'camera',
|
||||
name: 'Nest Cam (Front Door)',
|
||||
sub: 'Google Nest Cam · FW 3.5.2',
|
||||
status: 'online',
|
||||
pill: 'ok',
|
||||
category: 'home',
|
||||
ip: '192.168.1.15',
|
||||
lastSeen: 'Now',
|
||||
},
|
||||
{
|
||||
id: 'd9',
|
||||
icon: 'speaker',
|
||||
name: 'HomePod (Living Room)',
|
||||
sub: 'Apple HomePod 2nd Gen',
|
||||
status: 'online',
|
||||
pill: 'ok',
|
||||
category: 'home',
|
||||
ip: '192.168.1.11',
|
||||
lastSeen: '3 min ago',
|
||||
},
|
||||
{
|
||||
id: 'd10',
|
||||
icon: 'laptop',
|
||||
name: 'HP LaserJet Pro',
|
||||
sub: 'HP LaserJet Pro MFP · FW 22.5',
|
||||
status: 'online',
|
||||
pill: 'warn',
|
||||
category: 'home',
|
||||
ip: '192.168.1.25',
|
||||
firmware: '22.5',
|
||||
lastSeen: '22 min ago',
|
||||
alertId: 'alert-006',
|
||||
},
|
||||
{
|
||||
id: 'd11',
|
||||
icon: 'phone',
|
||||
name: 'Guest Phone',
|
||||
sub: 'Unknown Android · Android 13',
|
||||
status: 'blocked',
|
||||
pill: 'danger',
|
||||
category: 'guest',
|
||||
ip: '192.168.1.88',
|
||||
lastSeen: '2 days ago',
|
||||
},
|
||||
{
|
||||
id: 'd12',
|
||||
icon: 'laptop',
|
||||
name: "Guest Laptop",
|
||||
sub: 'Dell XPS 13 · Windows 11',
|
||||
status: 'offline',
|
||||
pill: 'off',
|
||||
category: 'guest',
|
||||
ip: '192.168.1.90',
|
||||
lastSeen: '5 days ago',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Places / Geofences ────────────────────────────────────────────────────────
|
||||
|
||||
export const PLACES: Place[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
icon: '🏠',
|
||||
name: 'Home',
|
||||
who: ['Emma', 'Mia', 'Jack'],
|
||||
arriveAlert: false,
|
||||
leaveAlert: true,
|
||||
radius: 100,
|
||||
pinX: 50,
|
||||
pinY: 50,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
icon: '🏫',
|
||||
name: 'School',
|
||||
who: ['Emma', 'Jack'],
|
||||
arriveAlert: true,
|
||||
leaveAlert: true,
|
||||
radius: 150,
|
||||
pinX: 68,
|
||||
pinY: 35,
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
icon: '👵',
|
||||
name: "Grandma's",
|
||||
who: ['Mia', 'Emma'],
|
||||
arriveAlert: true,
|
||||
leaveAlert: false,
|
||||
radius: 80,
|
||||
pinX: 30,
|
||||
pinY: 65,
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
icon: '⚽',
|
||||
name: 'Sports Centre',
|
||||
who: ['Jack'],
|
||||
arriveAlert: true,
|
||||
leaveAlert: true,
|
||||
radius: 120,
|
||||
pinX: 75,
|
||||
pinY: 70,
|
||||
},
|
||||
]
|
||||
|
||||
// ── Approvals ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const APPROVALS: ApprovalRequest[] = [
|
||||
{
|
||||
id: 'a1',
|
||||
childId: 'E',
|
||||
childName: 'Emma',
|
||||
type: 'app',
|
||||
subject: 'TikTok',
|
||||
description: 'Emma wants to install TikTok on her iPhone.',
|
||||
time: '10 min ago',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
childId: 'J',
|
||||
childName: 'Jack',
|
||||
type: 'time',
|
||||
subject: '30 more minutes',
|
||||
description: 'Jack has reached his daily screen time limit and is requesting extra time.',
|
||||
time: '25 min ago',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
childId: 'M',
|
||||
childName: 'Mia',
|
||||
type: 'site',
|
||||
subject: 'reddit.com',
|
||||
description: 'Mia tried to access reddit.com which is blocked by the Young child filter.',
|
||||
time: '1 hr ago',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'a4',
|
||||
childId: 'E',
|
||||
childName: 'Emma',
|
||||
type: 'app',
|
||||
subject: 'Snapchat',
|
||||
description: 'Emma requested access to Snapchat.',
|
||||
time: 'Yesterday',
|
||||
status: 'approved',
|
||||
},
|
||||
{
|
||||
id: 'a5',
|
||||
childId: 'J',
|
||||
childName: 'Jack',
|
||||
type: 'site',
|
||||
subject: 'roblox.com',
|
||||
description: 'Jack requested access to roblox.com.',
|
||||
time: 'Yesterday',
|
||||
status: 'approved',
|
||||
},
|
||||
{
|
||||
id: 'a6',
|
||||
childId: 'M',
|
||||
childName: 'Mia',
|
||||
type: 'app',
|
||||
subject: 'Discord',
|
||||
description: 'Mia requested Discord installation.',
|
||||
time: '2 days ago',
|
||||
status: 'denied',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Notifications (mock list, separate from NotificationContext) ──────────────
|
||||
|
||||
export const NOTIFICATIONS: Notification[] = [
|
||||
{
|
||||
id: 'n1',
|
||||
icon: 'alert',
|
||||
title: '🚨 Malware detected on PS5',
|
||||
body: 'Trojan.GenericKD.71234 found. Device calling home to Amsterdam.',
|
||||
time: '9 min ago',
|
||||
read: false,
|
||||
severity: 'danger',
|
||||
},
|
||||
{
|
||||
id: 'n2',
|
||||
icon: 'alert',
|
||||
title: '⚠️ Suspicious login — Emma',
|
||||
body: 'Instagram login from Moscow 🇷🇺 at 02:31. Access blocked.',
|
||||
time: '7 hr ago',
|
||||
read: false,
|
||||
severity: 'danger',
|
||||
},
|
||||
{
|
||||
id: 'n3',
|
||||
icon: 'pin',
|
||||
title: 'Emma arrived at School',
|
||||
body: 'Emma entered the School geofence at 8:42 AM.',
|
||||
time: '1 hr ago',
|
||||
read: false,
|
||||
severity: 'ok',
|
||||
},
|
||||
{
|
||||
id: 'n4',
|
||||
icon: 'shield',
|
||||
title: 'Unsafe WiFi auto-secured',
|
||||
body: 'Emma joined Starbucks_Free_WiFi — VPN activated automatically.',
|
||||
time: 'Yesterday',
|
||||
read: true,
|
||||
severity: 'warn',
|
||||
},
|
||||
{
|
||||
id: 'n5',
|
||||
icon: 'shield',
|
||||
title: 'Scam call blocked',
|
||||
body: '+44 7459 112233 flagged as HMRC scam. Call silenced.',
|
||||
time: '2 days ago',
|
||||
read: true,
|
||||
severity: 'warn',
|
||||
},
|
||||
{
|
||||
id: 'n6',
|
||||
icon: 'shield',
|
||||
title: 'Weekly protection report ready',
|
||||
body: '247 threats blocked this week across all devices.',
|
||||
time: '3 days ago',
|
||||
read: true,
|
||||
severity: 'ok',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Identity Members ──────────────────────────────────────────────────────────
|
||||
|
||||
export const IDENTITY_MEMBERS: IdentityMember[] = [
|
||||
{
|
||||
id: 'S',
|
||||
name: 'Sarah (You)',
|
||||
email: 'sarah.hughes@example.com',
|
||||
breaches: 0,
|
||||
lastChecked: '2 hours ago',
|
||||
status: 'clean',
|
||||
},
|
||||
{
|
||||
id: 'E',
|
||||
name: 'Emma',
|
||||
email: 'emma.hughes@example.com',
|
||||
breaches: 1,
|
||||
lastChecked: '2 hours ago',
|
||||
status: 'breached',
|
||||
},
|
||||
{
|
||||
id: 'M',
|
||||
name: 'Mia',
|
||||
email: 'mia.hughes@example.com',
|
||||
breaches: 0,
|
||||
lastChecked: '2 hours ago',
|
||||
status: 'monitoring',
|
||||
},
|
||||
{
|
||||
id: 'J',
|
||||
name: 'Jack',
|
||||
email: 'jack.hughes@example.com',
|
||||
breaches: 0,
|
||||
lastChecked: '2 hours ago',
|
||||
status: 'monitoring',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Security Alerts ───────────────────────────────────────────────────────────
|
||||
|
||||
export const SECURITY_ALERTS: SecurityAlert[] = [
|
||||
{
|
||||
id: 'alert-001',
|
||||
severity: 'critical',
|
||||
type: 'malware',
|
||||
title: 'Malware Detected',
|
||||
body: 'Trojan.GenericKD.71234 detected on PlayStation 5. Active C&C callbacks intercepted.',
|
||||
deviceId: 'd6',
|
||||
deviceName: 'PlayStation 5',
|
||||
memberId: 'J',
|
||||
memberName: 'Jack',
|
||||
geo: { city: 'Amsterdam', country: 'NL', flag: '🇳🇱', ip: '185.220.101.47' },
|
||||
timestamp: '2026-06-29T09:14:00Z',
|
||||
timeAgo: '9 min ago',
|
||||
status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'alert-002',
|
||||
severity: 'critical',
|
||||
type: 'suspicious_login',
|
||||
title: "Suspicious Login — Emma's Account",
|
||||
body: "Login attempt on Emma's Instagram from an unrecognised device in Moscow. Password change was blocked.",
|
||||
deviceId: 'd3',
|
||||
deviceName: "Emma's iPhone 14",
|
||||
memberId: 'E',
|
||||
memberName: 'Emma',
|
||||
geo: { city: 'Moscow', country: 'RU', flag: '🇷🇺', ip: '77.88.55.244' },
|
||||
timestamp: '2026-06-29T02:31:00Z',
|
||||
timeAgo: '7 hr ago',
|
||||
status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'alert-003',
|
||||
severity: 'medium',
|
||||
type: 'unsafe_wifi',
|
||||
title: 'Unsafe WiFi Network Joined',
|
||||
body: 'Emma connected to "Starbucks_Free_WiFi" (open, unencrypted). VPN activated automatically.',
|
||||
memberId: 'E',
|
||||
memberName: 'Emma',
|
||||
timestamp: '2026-06-28T15:42:00Z',
|
||||
timeAgo: 'Yesterday 15:42',
|
||||
status: 'resolved',
|
||||
},
|
||||
{
|
||||
id: 'alert-004',
|
||||
severity: 'medium',
|
||||
type: 'content_blocked',
|
||||
title: 'Adult Content Blocked',
|
||||
body: "Emma’s iPhone attempted to access a site blocked by the Teen content filter.",
|
||||
deviceId: 'd3',
|
||||
deviceName: "Emma's iPhone 14",
|
||||
memberId: 'E',
|
||||
memberName: 'Emma',
|
||||
timestamp: '2026-06-28T22:07:00Z',
|
||||
timeAgo: 'Yesterday 22:07',
|
||||
status: 'dismissed',
|
||||
},
|
||||
{
|
||||
id: 'alert-005',
|
||||
severity: 'low',
|
||||
type: 'scam_call',
|
||||
title: 'Scam Call Intercepted',
|
||||
body: "+44 7459 112233 flagged as HMRC impersonation scam. Call silenced on Sarah's iPhone.",
|
||||
deviceId: 'd1',
|
||||
deviceName: "Sarah's iPhone 15 Pro",
|
||||
memberId: 'S',
|
||||
memberName: 'Sarah',
|
||||
timestamp: '2026-06-27T11:20:00Z',
|
||||
timeAgo: '2 days ago',
|
||||
status: 'resolved',
|
||||
},
|
||||
{
|
||||
id: 'alert-006',
|
||||
severity: 'high',
|
||||
type: 'vulnerability',
|
||||
title: 'Firmware Vulnerability — HP Printer',
|
||||
body: 'CVE-2024-8471 (CVSS 7.8): Remote code execution via port 9100. Firmware update available.',
|
||||
deviceId: 'd10',
|
||||
deviceName: 'HP LaserJet Pro',
|
||||
cve: 'CVE-2024-8471',
|
||||
cvss: 7.8,
|
||||
timestamp: '2026-06-26T08:00:00Z',
|
||||
timeAgo: '3 days ago',
|
||||
status: 'open',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Incident Timeline (for Flow B demo) ──────────────────────────────────────
|
||||
|
||||
export const EMMA_INCIDENT_TIMELINE: TimelineEvent[] = [
|
||||
{
|
||||
id: 't1',
|
||||
time: '21:04',
|
||||
label: 'Normal login — Instagram',
|
||||
detail: 'Emma signed in from her iPhone 14 · London 🇬🇧 · known device',
|
||||
kind: 'normal',
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
time: '21:09',
|
||||
label: 'Browsed Instagram feed',
|
||||
detail: 'Standard session activity · 28 min · no anomalies',
|
||||
kind: 'info',
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
time: '21:37',
|
||||
label: 'Session ended',
|
||||
detail: 'Emma closed the app · device went to sleep',
|
||||
kind: 'info',
|
||||
},
|
||||
{
|
||||
id: 't4',
|
||||
time: '02:31',
|
||||
label: '⚠️ Login from unknown device',
|
||||
detail: 'IP 77.88.55.244 · Moscow, RU 🇷🇺 · Yandex LLC · 94 prior abuse reports',
|
||||
kind: 'anomaly',
|
||||
},
|
||||
{
|
||||
id: 't5',
|
||||
time: '02:32',
|
||||
label: '⚠️ Password change attempted',
|
||||
detail: 'Request to reset account password from the Moscow session',
|
||||
kind: 'anomaly',
|
||||
},
|
||||
{
|
||||
id: 't6',
|
||||
time: '02:32',
|
||||
label: '✅ Angel blocked the change',
|
||||
detail: 'Suspicious activity flag raised · change request denied · session terminated',
|
||||
kind: 'blocked',
|
||||
},
|
||||
{
|
||||
id: 't7',
|
||||
time: '02:33',
|
||||
label: 'Sarah notified',
|
||||
detail: "Push notification sent to Sarah's iPhone 15 Pro",
|
||||
kind: 'info',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Weekly Report ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const WEEKLY_REPORT: WeeklyReport = {
|
||||
period: 'Jun 23–29, 2026',
|
||||
threatsBlocked: 247,
|
||||
threatsVsLastWeek: -12,
|
||||
devicesProtected: 12,
|
||||
openIssues: 3,
|
||||
topThreats: [
|
||||
{ label: 'Phishing', pct: 41 },
|
||||
{ label: 'Malware', pct: 28 },
|
||||
{ label: 'Adware', pct: 21 },
|
||||
{ label: 'Other', pct: 10 },
|
||||
],
|
||||
daily: [28, 41, 19, 52, 37, 31, 39],
|
||||
}
|
||||
|
||||
// ── Security Score ────────────────────────────────────────────────────────────
|
||||
|
||||
export const SECURITY_SCORE: SecurityScore = {
|
||||
score: 92,
|
||||
previous: 88,
|
||||
change: 4,
|
||||
trend: 'up',
|
||||
label: 'Excellent',
|
||||
history: [74, 79, 81, 77, 83, 85, 88, 92],
|
||||
}
|
||||
36
src/demo/demo.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/* Scoped reset for the BlackDice Angel demo.
|
||||
Mirrors the prototype's base defaults so the phone screen renders exactly as
|
||||
designed — left-aligned, border-box, tight line-height — regardless of any
|
||||
ambient page styles. */
|
||||
|
||||
/* Kill the browser default body margin on the demo pages (the site's global
|
||||
stylesheet isn't loaded on these routes). */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bd-demo-root,
|
||||
.bd-demo-root *,
|
||||
.bd-demo-root *::before,
|
||||
.bd-demo-root *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bd-demo-root {
|
||||
text-align: left;
|
||||
line-height: normal;
|
||||
margin: 0;
|
||||
font-family: 'Ubuntu', -apple-system, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.bd-demo-root button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Show the scenario tabs above the stage instead of below it. */
|
||||
.bd-demo-tabs {
|
||||
order: -1;
|
||||
border-top: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
59
src/demo/threats/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# BlackDice Threat-Detection Demo Videos
|
||||
|
||||
Seven recordable, in-app demo players that simulate the BlackDice mobile app detecting a
|
||||
threat, raising a notification, moving the **BlackDice Angel risk score** in real time, showing the
|
||||
response, and resolving — ending on a "Book a Demo" card. Each is built to be screen-recorded
|
||||
into a 30–60s MP4.
|
||||
|
||||
## Where
|
||||
|
||||
| Route | What |
|
||||
| --- | --- |
|
||||
| `/threat-demos` | **Slider** of all 7 demos — cinematic `/demo-video`-style player, auto-plays each scenario, swipe via ‹ › arrows or the scenario tabs |
|
||||
| `/threat-demo/:id` | Full-screen player for one scenario — **one URL per video** (best for recording a single MP4) |
|
||||
|
||||
Both use the same cinematic player as `/demo-video`: Apple bezel-less phone with 3D tilt,
|
||||
breathing float, accent glow, Dynamic Island, glass sheen, the real BlackDice SVG icon set
|
||||
(`Icons.tsx`) and hexagon logo, a vertical control dock, and a spoken-voiceover subtitle.
|
||||
|
||||
## The 7 demos
|
||||
|
||||
| id | Product | Risk | Export file |
|
||||
| --- | --- | --- | --- |
|
||||
| `callscam` | Mobile SDK | 25 → 85 (red) | `blackdice-demo-01-call-scam.mp4` |
|
||||
| `smsscam` | Mobile SDK | 20 → 78 (red) | `blackdice-demo-02-sms-scam.mp4` |
|
||||
| `simswap` | Mobile SDK | 15 → 92 (red) | `blackdice-demo-03-sim-swap.mp4` |
|
||||
| `identity` | Halo Platform | 30 → 78 (yellow) | `blackdice-demo-04-identity-breach.mp4` |
|
||||
| `wifi` | Halo Platform | 28 → 70 (yellow) | `blackdice-demo-05-wifi-security.mp4` |
|
||||
| `dns` | DNS Shield | 35 → 45 (green) | `blackdice-demo-06-dns-block.mp4` |
|
||||
| `appperms` | Mobile SDK | 32 → 66 (yellow) | `blackdice-demo-07-app-permissions.mp4` |
|
||||
|
||||
## Phase timeline (~34s each)
|
||||
|
||||
1. **Setup** (0–4s) — calm home screen, low/green BlackDice Angel score, family/context
|
||||
2. **Detection** (4–9s) — notification banner slides in with the threat
|
||||
3. **Risk Impact** (9–15s) — BlackDice Angel badge counts up to the threat level; meter fills; colour shifts green → yellow → red
|
||||
4. **Response** (15–24s) — threat detail screen; the primary action auto-taps
|
||||
5. **Resolution** (24–30s) — calm/secured state (prevention) or elevated state needing action
|
||||
6. **End card** (30–34s) — BlackDice logo · "Powered by BlackDice" · **Book a Demo** · blackdice.com
|
||||
|
||||
## Voiceover
|
||||
|
||||
Each scenario carries 5 timed voiceover lines (setup / detection / risk / response / close),
|
||||
spoken via the browser Web Speech API when you press **▶**. A 🔊/🔇 toggle in the dock mutes
|
||||
it. For a polished recording, mute the in-app voice and record a professional VO over the
|
||||
same lines (stored in `scenarios.ts → voCues`).
|
||||
|
||||
## How to record
|
||||
|
||||
1. Open `/threat-demo/<id>` (e.g. `/threat-demo/callscam`).
|
||||
2. Set the browser to 1080p; the phone is the hero, centred.
|
||||
3. Start your screen recorder at 60fps.
|
||||
4. Press **▶** — it auto-plays all phases (~34s) and stops on the end card.
|
||||
5. Crop to the phone + (optional) controls; the "← All demos" link and REC chip can be cropped out.
|
||||
6. Export H.264 1080p using the file name from the table above.
|
||||
|
||||
## Editing the content
|
||||
|
||||
All copy, risk values, severities, detail lines, action buttons, and VO scripts live in
|
||||
[`scenarios.ts`](./scenarios.ts) — edit there and every player + the hub update automatically.
|
||||
496
src/demo/threats/ThreatDemo.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, animate, motion } from 'framer-motion'
|
||||
import {
|
||||
type ThreatScenario,
|
||||
type Severity,
|
||||
PHASES,
|
||||
RISK_ANIM_MS,
|
||||
scoreColor,
|
||||
} from './scenarios'
|
||||
import {
|
||||
IconHome, IconUsers, IconApps, IconSliders, IconBell,
|
||||
IconPhone, IconLock, IconEye, IconWifiOff, IconGlobe,
|
||||
IconAlert, IconCheck, IconShieldCheck,
|
||||
} from '../components/Icons'
|
||||
import { SceneHome } from '../DemoScreen'
|
||||
import '../demo.css'
|
||||
|
||||
const EASE_OUT = [0.23, 1, 0.32, 1] as const
|
||||
const SPRING = { type: 'spring', stiffness: 340, damping: 28 } as const
|
||||
|
||||
// Display scale for the phone in the cinematic stage (content still renders at full
|
||||
// 275×600 fidelity, just shown a touch smaller so it fits with the subtitle + chrome).
|
||||
const PHONE_SCALE = 0.88
|
||||
|
||||
const T = {
|
||||
bg: '#013B49',
|
||||
bgGrad: 'radial-gradient(140% 90% at 50% 0%, #075466 0%, #013B49 55%, #01303b 100%)',
|
||||
surface2: 'rgba(255,255,255,0.07)',
|
||||
surface3: 'rgba(255,255,255,0.10)',
|
||||
surfaceStr: '#0a4856',
|
||||
border: 'rgba(255,255,255,0.09)',
|
||||
text: '#e8f4f3',
|
||||
text2: '#b9cdd1',
|
||||
muted: '#8aa5ab',
|
||||
dim: '#5a7a82',
|
||||
accent: '#3BB586',
|
||||
accentSoft: 'rgba(59,181,134,0.16)',
|
||||
accentGlow: 'rgba(59,181,134,0.30)',
|
||||
danger: '#ff6b6b',
|
||||
dangerSoft: 'rgba(255,107,107,0.16)',
|
||||
warn: '#ffb56b',
|
||||
warnSoft: 'rgba(255,181,107,0.16)',
|
||||
success: '#4ec99a',
|
||||
successSoft: 'rgba(78,201,154,0.16)',
|
||||
}
|
||||
|
||||
const SEV: Record<Severity, { color: string; soft: string }> = {
|
||||
red: { color: T.danger, soft: T.dangerSoft },
|
||||
yellow: { color: T.warn, soft: T.warnSoft },
|
||||
green: { color: T.success, soft: T.successSoft },
|
||||
}
|
||||
|
||||
type IconCmp = React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
const THREAT_ICON: Record<string, IconCmp> = {
|
||||
callscam: IconPhone,
|
||||
smsscam: IconBell,
|
||||
simswap: IconLock,
|
||||
identity: IconEye,
|
||||
wifi: IconWifiOff,
|
||||
dns: IconGlobe,
|
||||
appperms: IconApps,
|
||||
}
|
||||
|
||||
function phaseOf(elapsed: number): number {
|
||||
if (elapsed < PHASES.setupEnd) return 1
|
||||
if (elapsed < PHASES.detectEnd) return 2
|
||||
if (elapsed < PHASES.riskEnd) return 3
|
||||
if (elapsed < PHASES.responseEnd) return 4
|
||||
if (elapsed < PHASES.resolutionEnd) return 5
|
||||
return 6
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ Icon: IconHome, label: 'Home' },
|
||||
{ Icon: IconUsers, label: 'Family' },
|
||||
{ Icon: IconApps, label: 'Devices' },
|
||||
{ Icon: IconSliders, label: 'Controls' },
|
||||
{ Icon: IconBell, label: 'Alerts' },
|
||||
] as const
|
||||
|
||||
// BlackDice hexagonal-die logo (same as the /demo-video end card)
|
||||
function HexLogo({ size = 52 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" style={{ color: T.accent, flexShrink: 0 }}>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="3" strokeLinejoin="round" strokeLinecap="round">
|
||||
<path d="M32 6 L56 20 L56 46 L32 60 L8 46 L8 20 Z" />
|
||||
<path d="M32 6 L32 32 M8 20 L32 32 M56 20 L32 32" />
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<circle cx="20" cy="26" r="2.4" /><circle cx="20" cy="34" r="2.4" /><circle cx="44" cy="34" r="2.4" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function NotifBanner({ s }: { s: ThreatScenario }) {
|
||||
const sev = SEV[s.severity]
|
||||
const Icon = THREAT_ICON[s.id] ?? IconAlert
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -18, opacity: 0, scale: 0.97 }} animate={{ y: 0, opacity: 1, scale: 1 }} transition={SPRING}
|
||||
style={{ background: sev.soft, border: `1px solid ${sev.color}55`, borderRadius: 12, padding: '11px 12px', display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 11, boxShadow: '0 6px 22px rgba(0,0,0,.35)' }}>
|
||||
<motion.div animate={{ scale: [1, 1.08, 1] }} transition={{ duration: 1.4, repeat: Infinity }}
|
||||
style={{ width: 32, height: 32, borderRadius: 9, background: sev.color, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon size={17} style={{ color: '#fff' }} />
|
||||
</motion.div>
|
||||
<div style={{ flex: 1, fontSize: 12, lineHeight: 1.45 }}>
|
||||
<strong style={{ color: T.text, display: 'block', marginBottom: 1 }}>{s.threatTitle}</strong>
|
||||
<span style={{ color: T.text2 }}>{s.threatBody}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeContent({ s, phase }: { s: ThreatScenario; phase: number }) {
|
||||
const sev = SEV[s.severity]
|
||||
const alerted = phase >= 2
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* The real BlackDice Angel home screen — dims once the threat hits */}
|
||||
<div style={{ opacity: alerted ? 0.32 : 1, filter: alerted ? 'blur(1.5px)' : 'none', transition: 'opacity .45s ease, filter .45s ease', pointerEvents: 'none' }}>
|
||||
<SceneHome />
|
||||
</div>
|
||||
|
||||
{/* Threat notification + alert slide in over the home from phase 2 */}
|
||||
<AnimatePresence>
|
||||
{alerted && (
|
||||
<motion.div key="alert" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
style={{ position: 'absolute', top: 0, left: 0, right: 0 }}>
|
||||
<NotifBanner s={s} />
|
||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: .12, ease: EASE_OUT }}
|
||||
style={{ background: sev.soft, borderRadius: 14, padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10, boxShadow: '0 6px 22px rgba(0,0,0,.35)' }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: sev.color, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<IconAlert size={16} style={{ color: '#fff' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: 12, color: sev.color, fontWeight: 500 }}>{s.detailHeadline}</div>
|
||||
</motion.div>
|
||||
|
||||
{phase >= 3 && (
|
||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ ease: EASE_OUT }}
|
||||
style={{ background: T.surfaceStr, border: `1px solid ${T.border}`, borderRadius: 14, padding: '12px 14px', boxShadow: '0 6px 22px rgba(0,0,0,.35)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 7 }}>
|
||||
<span style={{ fontSize: 11, color: T.muted }}>BlackDice Angel risk</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: sev.color }}>{s.severity === 'green' ? 'Contained' : s.severity === 'yellow' ? 'Elevated' : 'Critical'}</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: T.surface3, borderRadius: 4, overflow: 'hidden' }}>
|
||||
<motion.div initial={{ width: `${s.initialScore}%` }} animate={{ width: `${s.finalScore}%` }}
|
||||
transition={{ duration: RISK_ANIM_MS / 1000, ease: EASE_OUT }}
|
||||
style={{ height: '100%', borderRadius: 4, background: sev.color }} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailContent({ s, tapped }: { s: ThreatScenario; tapped: boolean }) {
|
||||
const sev = SEV[s.severity]
|
||||
const Icon = THREAT_ICON[s.id] ?? IconAlert
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, x: 14 }} animate={{ opacity: 1, x: 0 }} transition={{ ease: EASE_OUT }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 18, color: T.muted }}>‹</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: T.text }}>Threat details</span>
|
||||
</div>
|
||||
|
||||
<div style={{ background: sev.soft, borderRadius: 14, padding: '16px 14px', textAlign: 'center', marginBottom: 12 }}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 13, background: sev.color, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 10px' }}>
|
||||
<Icon size={24} style={{ color: '#fff' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: T.text }}>{s.threatTitle}</div>
|
||||
<div style={{ fontSize: 11, color: sev.color, marginTop: 3 }}>{s.detailHeadline}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: T.surface2, border: `1px solid ${T.border}`, borderRadius: 14, padding: '4px 14px', marginBottom: 12 }}>
|
||||
{s.detailLines.map((line, i) => (
|
||||
<motion.div key={line} initial={{ opacity: 0, x: -6 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.15 + i * 0.1 }}
|
||||
style={{ display: 'flex', alignItems: 'flex-start', gap: 9, padding: '9px 0', borderTop: i === 0 ? 'none' : `1px solid ${T.border}`, fontSize: 11.5, color: T.text2, lineHeight: 1.4 }}>
|
||||
<span style={{ width: 5, height: 5, borderRadius: '50%', background: sev.color, marginTop: 5, flexShrink: 0 }} />{line}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{s.actions.map((a) => (
|
||||
<motion.div key={a.label}
|
||||
animate={a.primary && tapped ? { scale: [1, 0.96, 1] } : {}} transition={{ duration: 0.4 }}
|
||||
style={{
|
||||
fontSize: 13, fontWeight: 600, borderRadius: 12, padding: '11px 14px',
|
||||
border: a.primary ? 'none' : `1px solid ${T.border}`,
|
||||
background: a.primary ? sev.color : 'transparent',
|
||||
color: a.primary ? '#fff' : T.text2,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7,
|
||||
boxShadow: a.primary ? `0 6px 20px ${sev.color}55` : 'none',
|
||||
}}>
|
||||
{a.icon && <span>{a.icon}</span>}{a.label}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolutionContent({ s }: { s: ThreatScenario }) {
|
||||
const tone = SEV[s.resolutionTone]
|
||||
const calm = s.resolutionTone === 'green'
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ paddingTop: 6 }}>
|
||||
<motion.div initial={{ scale: 0.94 }} animate={{ scale: 1 }} transition={SPRING}
|
||||
style={{ background: tone.soft, borderRadius: 16, padding: '20px 14px', textAlign: 'center', marginBottom: 12 }}>
|
||||
<motion.div initial={{ scale: 0, rotate: -20 }} animate={{ scale: 1, rotate: 0 }} transition={{ ...SPRING, delay: 0.1 }}
|
||||
style={{ width: 54, height: 54, borderRadius: '50%', background: tone.color, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' }}>
|
||||
{calm ? <IconCheck size={26} style={{ color: '#fff' }} /> : <IconShieldCheck size={26} style={{ color: '#fff' }} />}
|
||||
</motion.div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: T.text }}>{s.resolutionTitle}</div>
|
||||
<div style={{ fontSize: 11.5, color: tone.color, marginTop: 4 }}>{s.resolutionBody}</div>
|
||||
</motion.div>
|
||||
|
||||
{s.statusChange && (
|
||||
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}
|
||||
style={{ background: T.surface2, border: `1px solid ${T.border}`, borderRadius: 14, padding: '12px 14px', fontSize: 12, color: T.text2, display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span style={{ color: tone.color }}>›</span>{s.statusChange}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function EndCard() {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }}
|
||||
style={{ position: 'absolute', inset: 0, zIndex: 60, background: T.bg, backgroundImage: T.bgGrad, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, padding: '0 24px', textAlign: 'center' }}>
|
||||
<motion.div initial={{ scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={SPRING} style={{ position: 'relative' }}>
|
||||
<motion.div animate={{ scale: [1, 1.3, 1], opacity: [0.25, 0.05, 0.25] }} transition={{ duration: 3, repeat: Infinity }}
|
||||
style={{ position: 'absolute', inset: -16, borderRadius: '50%', background: T.accent, filter: 'blur(12px)' }} />
|
||||
<div style={{ position: 'relative' }}><HexLogo size={52} /></div>
|
||||
</motion.div>
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: T.text, letterSpacing: '-.4px' }}>BlackDice</div>
|
||||
<div style={{ fontSize: 11, color: T.muted, marginTop: 4, letterSpacing: '.3px' }}>Powered by BlackDice</div>
|
||||
</div>
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.35 }}
|
||||
style={{ background: T.accent, color: '#fff', fontSize: 13, fontWeight: 600, padding: '11px 28px', borderRadius: 100, boxShadow: `0 10px 28px ${T.accent}55` }}>
|
||||
Book a Demo
|
||||
</motion.div>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }}
|
||||
style={{ fontSize: 10.5, color: T.muted, letterSpacing: '.4px' }}>blackdice.com</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// Apple bezel-less phone (matches the /demo-video PhoneShell)
|
||||
export function ThreatPhone({ s, phase, score, tapped }: {
|
||||
s: ThreatScenario; phase: number; score: number; tapped: boolean
|
||||
}) {
|
||||
const activeTab = s.id === 'appperms' ? 4 : 0
|
||||
return (
|
||||
<div style={{
|
||||
width: 275, height: 600, flexShrink: 0, position: 'relative',
|
||||
borderRadius: 50, padding: 4,
|
||||
background: 'linear-gradient(150deg, #2c3a42 0%, #0c1418 45%, #1a262d 100%)',
|
||||
boxShadow: `0 50px 100px rgba(0,0,0,.75), 0 8px 30px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.06)`,
|
||||
}}>
|
||||
<div style={{ width: '100%', height: '100%', borderRadius: 46, overflow: 'hidden', position: 'relative', background: T.bg, backgroundImage: T.bgGrad, boxShadow: 'inset 0 0 0 1px rgba(0,0,0,.6)' }}>
|
||||
{/* status bar */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 26px 8px', fontSize: 11, color: T.text, fontWeight: 600 }}>
|
||||
<span>9:41</span><span style={{ fontSize: 10, color: T.muted }}>5G ▲ 100%</span>
|
||||
</div>
|
||||
{/* dynamic island */}
|
||||
<div style={{ position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', width: 92, height: 26, borderRadius: 14, background: '#000', zIndex: 30 }} />
|
||||
|
||||
<div style={{ position: 'absolute', top: 42, bottom: 58, left: 0, right: 0, overflowY: 'auto', padding: '6px 13px 8px' }}>
|
||||
<motion.div key={phase <= 3 ? 'home' : phase === 4 ? 'detail' : 'resolution'}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
|
||||
{phase <= 3 && <HomeContent s={s} phase={phase} />}
|
||||
{phase === 4 && <DetailContent s={s} tapped={tapped} />}
|
||||
{phase >= 5 && <ResolutionContent s={s} />}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 58, background: T.surfaceStr, borderTop: `1px solid ${T.border}`, display: 'flex', alignItems: 'center', justifyContent: 'space-around', paddingBottom: 6 }}>
|
||||
{TABS.map((t, i) => (
|
||||
<div key={t.label} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, fontSize: 9, color: i === activeTab ? T.accent : T.dim }}>
|
||||
<t.Icon size={18} style={{ color: i === activeTab ? T.accent : T.dim }} />{t.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{phase >= 6 && <EndCard />}
|
||||
|
||||
<div style={{ position: 'absolute', inset: 0, borderRadius: 46, pointerEvents: 'none', background: 'linear-gradient(135deg, rgba(255,255,255,.07) 0%, rgba(255,255,255,0) 32%)', zIndex: 40 }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PHASE_LABEL = ['', 'Setup', 'Detection', 'Risk Impact', 'Response', 'Resolution', 'Book a Demo']
|
||||
|
||||
// ── Cinematic player (matches /demo-video layout) ─────────────────────────────
|
||||
export function ThreatCinematic({ scenario, autoPlay = false, chrome = true, bare = false }: {
|
||||
scenario: ThreatScenario; autoPlay?: boolean; chrome?: boolean
|
||||
/** Drops the player's own backdrop so the phone sits directly on the page. */
|
||||
bare?: boolean
|
||||
}) {
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
const [started, setStarted] = useState(false)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [score, setScore] = useState(scenario.initialScore)
|
||||
const [tapped, setTapped] = useState(false)
|
||||
const rafRef = useRef(0)
|
||||
const t0Ref = useRef(0)
|
||||
const riskStarted = useRef(false)
|
||||
const spokenPhase = useRef(-1)
|
||||
|
||||
const phase = phaseOf(elapsed)
|
||||
const cancelSpeech = () => { try { window.speechSynthesis && window.speechSynthesis.cancel() } catch { /* noop */ } }
|
||||
|
||||
function start() {
|
||||
setStarted(true)
|
||||
t0Ref.current = performance.now() - elapsed
|
||||
const tick = () => {
|
||||
const now = performance.now() - t0Ref.current
|
||||
setElapsed(now)
|
||||
if (now < PHASES.total) rafRef.current = requestAnimationFrame(tick)
|
||||
else setStarted(false)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
function pause() { setStarted(false); cancelAnimationFrame(rafRef.current); cancelSpeech() }
|
||||
|
||||
// Auto-play on mount (used by the slider)
|
||||
useEffect(() => {
|
||||
if (!autoPlay) return
|
||||
const id = setTimeout(() => start(), 450)
|
||||
return () => clearTimeout(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => { cancelAnimationFrame(rafRef.current); cancelSpeech() }, [])
|
||||
|
||||
// Risk count-up (keyed on phase so it fires once)
|
||||
useEffect(() => {
|
||||
if (!started || phase < 3 || riskStarted.current) return
|
||||
riskStarted.current = true
|
||||
const controls = animate(scenario.initialScore, scenario.finalScore, {
|
||||
duration: RISK_ANIM_MS / 1000, ease: [0.22, 1, 0.36, 1],
|
||||
onUpdate: (v) => setScore(Math.round(v)),
|
||||
})
|
||||
return () => controls.stop()
|
||||
}, [phase, started, scenario])
|
||||
|
||||
// Auto-tap the primary action in phase 4
|
||||
useEffect(() => {
|
||||
if (phase === 4 && !tapped) {
|
||||
const id = setTimeout(() => setTapped(true), 2600)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
}, [phase, tapped])
|
||||
|
||||
// Muting mid-line only stops future cues by itself — the utterance already
|
||||
// in flight keeps talking until it ends. Cancel it the instant mute is toggled on.
|
||||
useEffect(() => {
|
||||
if (muted) cancelSpeech()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [muted])
|
||||
|
||||
// Voiceover per phase
|
||||
useEffect(() => {
|
||||
if (!started || muted) return
|
||||
const cueIndex = phase >= 6 ? 4 : phase - 1
|
||||
if (cueIndex < 0 || cueIndex > 4 || spokenPhase.current === cueIndex) return
|
||||
spokenPhase.current = cueIndex
|
||||
const synth = window.speechSynthesis
|
||||
if (!synth) return
|
||||
const cue = scenario.voCues[cueIndex]
|
||||
if (!cue) return
|
||||
|
||||
const speakNow = () => {
|
||||
synth.cancel()
|
||||
const u = new SpeechSynthesisUtterance(cue.text)
|
||||
u.lang = 'en-GB'; u.rate = 0.82; u.pitch = 0.84
|
||||
// Muting or pausing calls synth.cancel(), which fires a benign
|
||||
// "canceled"/"interrupted" error event on the utterance — swallow it.
|
||||
u.onerror = () => {}
|
||||
const vs = synth.getVoices()
|
||||
// Prefer real neural/online voices (Edge, iOS, Chrome-on-Android) — they hold
|
||||
// natural prosody even at a slowed rate, unlike legacy on-device synthetic voices.
|
||||
const v = vs.find((x) => /en-GB/i.test(x.lang) && /natural|neural|online/i.test(x.name) && /libby|sonia|hazel|serena/i.test(x.name))
|
||||
|| vs.find((x) => /en-GB/i.test(x.lang) && /natural|neural|online/i.test(x.name))
|
||||
|| vs.find((x) => /en-GB/i.test(x.lang) && /libby|sonia|hazel|serena/i.test(x.name))
|
||||
|| vs.find((x) => /en-GB/i.test(x.lang) && /female|google uk english female|samantha/i.test(x.name))
|
||||
|| vs.find((x) => /en-GB/i.test(x.lang))
|
||||
|| vs.find((x) => /^en/i.test(x.lang))
|
||||
if (v) u.voice = v
|
||||
synth.speak(u)
|
||||
}
|
||||
|
||||
// getVoices() often returns an empty list on the very first call — the browser
|
||||
// loads voices asynchronously, so without this the demo silently falls back to
|
||||
// whatever default (often the least natural-sounding) voice is installed.
|
||||
if (synth.getVoices().length === 0) {
|
||||
const onReady = () => { synth.removeEventListener('voiceschanged', onReady); speakNow() }
|
||||
synth.addEventListener('voiceschanged', onReady)
|
||||
} else {
|
||||
speakNow()
|
||||
}
|
||||
}, [phase, started, muted, scenario])
|
||||
|
||||
const curVo = scenario.voCues[phase >= 6 ? 4 : Math.max(0, phase - 1)]
|
||||
|
||||
return (
|
||||
<div className="bd-demo-root" style={{
|
||||
position: 'absolute', inset: 0, overflow: 'hidden',
|
||||
background: bare
|
||||
? 'transparent'
|
||||
: 'radial-gradient(130% 90% at 50% 0%, #075466 0%, #013B49 38%, #021b22 72%, #0a1a22 100%)',
|
||||
fontFamily: "'Ubuntu', -apple-system, system-ui, sans-serif", WebkitFontSmoothing: 'antialiased',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{!bare && (
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', background: 'radial-gradient(120% 80% at 50% 45%, transparent 50%, rgba(0,0,0,.5) 100%)' }} />
|
||||
)}
|
||||
|
||||
{chrome && (
|
||||
<div style={{ position: 'absolute', top: 20, left: 24, display: 'flex', alignItems: 'center', gap: 8, zIndex: 50 }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '1.5px', textTransform: 'uppercase', color: T.accent }}>{scenario.tagLabel}</span>
|
||||
<span style={{ width: 14, height: 1, background: 'rgba(255,255,255,.2)' }} />
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,.65)' }}>{PHASE_LABEL[phase]}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{started && chrome && (
|
||||
<div style={{ position: 'absolute', top: 18, right: 22, display: 'flex', alignItems: 'center', gap: 6, background: 'rgba(0,0,0,.5)', border: '1px solid rgba(255,255,255,.1)', borderRadius: 100, padding: '6px 12px', zIndex: 60 }}>
|
||||
<motion.span animate={{ opacity: [1, 0, 1] }} transition={{ duration: 1.2, repeat: Infinity }} style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: '#fff' }}>REC</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* stage: phone + VO subtitle, centred */}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1, padding: '8px 0' }}>
|
||||
{/* phone column: device + its subtitle directly beneath */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
|
||||
<motion.div style={{ perspective: 1300 }}>
|
||||
<motion.div animate={{ rotateY: -3, rotateX: 2 }} transition={{ duration: 1.1, ease: EASE_OUT }} style={{ transformStyle: 'preserve-3d' }}>
|
||||
<motion.div animate={{ y: [0, -7, 0] }} transition={{ duration: 5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
onClick={() => (started ? pause() : start())}
|
||||
style={{ position: 'relative', width: 275 * PHONE_SCALE, height: 600 * PHONE_SCALE, cursor: 'pointer' }}>
|
||||
<div style={{ position: 'absolute', inset: '8% 14%', borderRadius: '50%', background: T.accent, opacity: 0.16, filter: 'blur(55px)', zIndex: 0 }} />
|
||||
<div style={{ transform: `scale(${PHONE_SCALE})`, transformOrigin: 'top left' }}>
|
||||
<ThreatPhone s={scenario} phase={phase} score={score} tapped={tapped} />
|
||||
</div>
|
||||
<motion.button whileTap={{ scale: 0.92 }}
|
||||
onClick={(e) => { e.stopPropagation(); setMuted((m) => !m) }}
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16, zIndex: 55,
|
||||
width: 32, height: 32, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,.45)', backdropFilter: 'blur(10px)',
|
||||
border: `1px solid ${muted ? 'rgba(255,255,255,.16)' : T.accentGlow}`,
|
||||
color: muted ? 'rgba(255,255,255,.7)' : T.accent, fontSize: 13,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{muted ? '🔇' : '🔊'}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* VO subtitle — directly under the phone */}
|
||||
<div style={{ position: 'relative', width: 320, maxWidth: '86vw', height: 46 }}>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div key={phase} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -4 }} transition={{ duration: 0.5, ease: EASE_OUT }}
|
||||
style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', textAlign: 'center' }}>
|
||||
<span style={{ fontSize: 14.5, color: 'rgba(255,255,255,.9)', lineHeight: 1.5 }}>{curVo?.text}</span>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default export: full-screen single player (used by /threat-demo/:id)
|
||||
export default function ThreatDemo({ scenario }: { scenario: ThreatScenario }) {
|
||||
return (
|
||||
<div style={{ position: 'relative', height: '100vh' }}>
|
||||
<ThreatCinematic scenario={scenario} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/demo/threats/ThreatDemoPlayerPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import ThreatDemo from './ThreatDemo'
|
||||
import { getScenario } from './scenarios'
|
||||
|
||||
/** Full-screen player for a single threat scenario — one URL per demo video. */
|
||||
export default function ThreatDemoPlayerPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const scenario = getScenario(id)
|
||||
|
||||
if (!scenario) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#010e14', color: '#b9cdd1', fontFamily: "'Ubuntu', system-ui, sans-serif", flexDirection: 'column', gap: 16 }}>
|
||||
<div>Demo not found.</div>
|
||||
<button onClick={() => navigate('/threat-demos')} style={{ background: '#3BB586', color: '#fff', border: 'none', borderRadius: 100, padding: '10px 22px', fontFamily: 'inherit', fontWeight: 600, cursor: 'pointer' }}>← All demos</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => navigate('/threat-demos')}
|
||||
style={{
|
||||
position: 'fixed', top: 18, left: 24, zIndex: 80,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 7,
|
||||
fontFamily: "'Ubuntu', system-ui, sans-serif", fontSize: 13, fontWeight: 500,
|
||||
color: 'rgba(255,255,255,.75)', background: 'rgba(0,0,0,.45)', backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,.12)', borderRadius: 100, padding: '7px 14px', cursor: 'pointer',
|
||||
}}>
|
||||
← All demos
|
||||
</button>
|
||||
<ThreatDemo scenario={scenario} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
src/demo/threats/ThreatDemosSlider.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { THREAT_SCENARIOS } from './scenarios'
|
||||
import { ThreatCinematic } from './ThreatDemo'
|
||||
import '../demo.css'
|
||||
|
||||
const ACCENT = '#3BB586'
|
||||
|
||||
// Isolated so useNavigate() is only ever invoked when this is actually mounted
|
||||
// under a <Router> — the embedded (no-router) instance never renders it.
|
||||
function BackToSiteButton() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<button onClick={() => navigate('/')} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontFamily: 'inherit', fontSize: 13, fontWeight: 500, color: 'rgba(255,255,255,.8)', background: 'rgba(255,255,255,.05)', border: '1px solid rgba(255,255,255,.12)', borderRadius: 100, padding: '7px 14px', cursor: 'pointer', flexShrink: 0 }}>← Back to site</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ThreatDemosSlider({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const [idx, setIdx] = useState(0)
|
||||
const [dir, setDir] = useState(1)
|
||||
const scenario = THREAT_SCENARIOS[idx]
|
||||
const n = THREAT_SCENARIOS.length
|
||||
|
||||
const go = (next: number) => {
|
||||
setDir(next > idx ? 1 : -1)
|
||||
setIdx((next + n) % n)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bd-demo-root" style={{ position: 'relative', height: embedded ? '100%' : '100vh', overflow: 'hidden', background: '#010e14', fontFamily: "'Ubuntu', -apple-system, system-ui, sans-serif", display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
{/* ── Top bar (reserved) — standalone /threat-demos only ── */}
|
||||
{!embedded && (
|
||||
<div style={{ flexShrink: 0, height: 60, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 20px', borderBottom: '1px solid rgba(255,255,255,.06)', background: 'rgba(1,14,20,.7)', backdropFilter: 'blur(10px)', zIndex: 80 }}>
|
||||
<BackToSiteButton />
|
||||
<div style={{ textAlign: 'center', color: '#e8f4f3', minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{scenario.title}</div>
|
||||
<div style={{ fontSize: 10.5, color: '#8aa5ab' }}>{idx + 1} / {n} · {scenario.product}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'rgba(0,0,0,.4)', border: '1px solid rgba(255,255,255,.1)', borderRadius: 100, padding: '5px 11px', flexShrink: 0 }}>
|
||||
<motion.span animate={{ opacity: [1, 0, 1] }} transition={{ duration: 1.2, repeat: Infinity }} style={{ width: 7, height: 7, borderRadius: '50%', background: '#ef4444', display: 'inline-block' }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: '#fff', letterSpacing: '.5px' }}>REC</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Stage (flex, holds the sliding players) ── */}
|
||||
<div style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<AnimatePresence initial={false} custom={dir} mode="popLayout">
|
||||
<motion.div
|
||||
key={scenario.id}
|
||||
custom={dir}
|
||||
initial={{ x: dir > 0 ? '100%' : '-100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: dir > 0 ? '-100%' : '100%' }}
|
||||
transition={{ type: 'spring', stiffness: 260, damping: 30 }}
|
||||
style={{ position: 'absolute', inset: 0 }}>
|
||||
<ThreatCinematic scenario={scenario} autoPlay chrome={false} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Prev / Next arrows (inside the stage, vertically centred) */}
|
||||
<button onClick={() => go(idx - 1)} aria-label="Previous" style={navBtn('left')}>‹</button>
|
||||
<button onClick={() => go(idx + 1)} aria-label="Next" style={navBtn('right')}>›</button>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom: scenario tabs (reserved) — moves above the stage on mobile via .bd-demo-tabs media query ── */}
|
||||
<div className="bd-demo-tabs" style={{ flexShrink: 0, minHeight: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, flexWrap: 'wrap', padding: '10px 16px', borderTop: '1px solid rgba(255,255,255,.06)', background: 'rgba(1,14,20,.7)', backdropFilter: 'blur(10px)', zIndex: 80 }}>
|
||||
{THREAT_SCENARIOS.map((s, i) => (
|
||||
<button key={s.id} onClick={() => go(i)}
|
||||
style={{
|
||||
fontFamily: 'inherit', fontSize: 11, fontWeight: 500, cursor: 'pointer',
|
||||
padding: '6px 12px', borderRadius: 100,
|
||||
border: `1px solid ${i === idx ? ACCENT : 'rgba(255,255,255,.12)'}`,
|
||||
background: i === idx ? 'rgba(59,181,134,0.18)' : 'rgba(255,255,255,.03)',
|
||||
color: i === idx ? ACCENT : 'rgba(255,255,255,.6)', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function navBtn(side: 'left' | 'right'): React.CSSProperties {
|
||||
return {
|
||||
position: 'absolute', top: '50%', transform: 'translateY(-50%)', [side]: 16,
|
||||
zIndex: 70, width: 44, height: 44, borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,.45)', backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255,255,255,.14)', color: '#e8f4f3',
|
||||
fontSize: 22, lineHeight: 1, cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
252
src/demo/threats/scenarios.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
// ── Threat-detection demo scenarios ──────────────────────────────────────────
|
||||
// Each scenario drives a 5-phase, ~34s recordable demo video:
|
||||
// P1 Setup → P2 Detection → P3 Risk-score impact → P4 Response → P5 Resolution/CTA
|
||||
|
||||
export type Severity = 'red' | 'yellow' | 'green'
|
||||
export type ProductTag = 'sdk' | 'halo' | 'dns'
|
||||
|
||||
export interface ThreatAction {
|
||||
label: string
|
||||
primary?: boolean
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export interface VoCue {
|
||||
at: number // ms from start
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ThreatScenario {
|
||||
id: string
|
||||
file: string // export file name
|
||||
product: string // "Mobile SDK" | "Halo Platform" | "DNS Shield"
|
||||
tag: ProductTag
|
||||
tagLabel: string // "SDK · CALL"
|
||||
title: string
|
||||
blurb: string
|
||||
icon: string // emoji
|
||||
context: string // home-screen context line
|
||||
initialScore: number
|
||||
finalScore: number
|
||||
severity: Severity // colour at the final score
|
||||
threatTitle: string
|
||||
threatBody: string
|
||||
detailHeadline: string
|
||||
detailLines: string[]
|
||||
actions: ThreatAction[] // first with primary:true is the highlighted/tapped one
|
||||
resolutionTitle: string
|
||||
resolutionBody: string
|
||||
resolutionTone: Severity // calm green for prevention, else elevated
|
||||
statusChange?: string // e.g. family member status after the event
|
||||
voCues: VoCue[]
|
||||
}
|
||||
|
||||
// Phase timings (ms) — shared across scenarios
|
||||
export const PHASES = {
|
||||
setupEnd: 4000,
|
||||
detectEnd: 9000,
|
||||
riskEnd: 15000,
|
||||
responseEnd: 24000,
|
||||
resolutionEnd: 30000,
|
||||
endCard: 30000,
|
||||
total: 34500,
|
||||
}
|
||||
// When the risk number animates (start of phase 3) and for how long
|
||||
export const RISK_ANIM_AT = 9200
|
||||
export const RISK_ANIM_MS = 2000
|
||||
|
||||
export const THREAT_SCENARIOS: ThreatScenario[] = [
|
||||
{
|
||||
id: 'callscam',
|
||||
file: 'blackdice-demo-01-call-scam.mp4',
|
||||
product: 'Mobile SDK',
|
||||
tag: 'sdk',
|
||||
tagLabel: 'SDK · CALL',
|
||||
title: 'Scam Call Detection',
|
||||
blurb: 'A suspicious call with a spoofed caller ID. BlackDice Angel risk rises to 85, and blocking is recommended.',
|
||||
icon: '📞',
|
||||
context: 'Your family is protected',
|
||||
initialScore: 25,
|
||||
finalScore: 85,
|
||||
severity: 'red',
|
||||
threatTitle: 'Scam Call Detected',
|
||||
threatBody: 'Suspicious call from +44 7890 123456',
|
||||
detailHeadline: 'Caller ID appears spoofed',
|
||||
detailLines: [
|
||||
'Number matches a known scam pattern',
|
||||
'Caller ID does not match the carrier record',
|
||||
'Reported by 240+ BlackDice users',
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Block caller', primary: true, icon: '🚫' },
|
||||
{ label: 'Report', icon: '⚑' },
|
||||
],
|
||||
resolutionTitle: 'Caller blocked',
|
||||
resolutionBody: 'Scam number blocked · incident reported',
|
||||
resolutionTone: 'red',
|
||||
statusChange: 'Risk stays elevated until reviewed',
|
||||
voCues: [
|
||||
{ at: 300, text: 'We monitor every incoming call in real time.' },
|
||||
{ at: 4300, text: 'We detect a suspicious call with a spoofed caller ID.' },
|
||||
{ at: 9400, text: 'Our BlackDice Angel risk score rises from 25 to 85, so we recommend taking a closer look.' },
|
||||
{ at: 15500, text: 'With one tap, the user can block the caller and report the incident.' },
|
||||
{ at: 30300, text: 'We protect families from phone scams. Book a demo today.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'smsscam',
|
||||
file: 'blackdice-demo-02-sms-scam.mp4',
|
||||
product: 'Mobile SDK',
|
||||
tag: 'sdk',
|
||||
tagLabel: 'SDK · SMS',
|
||||
title: 'Scam SMS Detection',
|
||||
blurb: 'A phishing SMS with a malicious link. BlackDice Angel risk rises to 78, and it’s best not to click.',
|
||||
icon: '✉️',
|
||||
context: 'Your family is protected',
|
||||
initialScore: 20,
|
||||
finalScore: 78,
|
||||
severity: 'red',
|
||||
threatTitle: 'SMS Scam Detected',
|
||||
threatBody: '“Your bank account has been compromised…”',
|
||||
detailHeadline: 'Known phishing attempt',
|
||||
detailLines: [
|
||||
'Message contains a malicious shortened link',
|
||||
'Impersonates your bank’s security team',
|
||||
'Best not to tap the link or reply',
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Delete message', primary: true, icon: '🗑' },
|
||||
{ label: 'Report', icon: '⚑' },
|
||||
],
|
||||
resolutionTitle: 'Message removed',
|
||||
resolutionBody: 'Phishing SMS deleted · sender reported',
|
||||
resolutionTone: 'red',
|
||||
voCues: [
|
||||
{ at: 300, text: 'We scan messages for phishing in real time.' },
|
||||
{ at: 4300, text: 'A scam SMS tries to trick the user with a fake bank alert.' },
|
||||
{ at: 9400, text: 'We raise the BlackDice Angel risk score to 78, flagging the suspicious link.' },
|
||||
{ at: 15500, text: 'The user is gently warned not to click, and can delete and report the message.' },
|
||||
{ at: 30300, text: 'We protect families from phishing. Book a demo today.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'simswap',
|
||||
file: 'blackdice-demo-03-sim-swap.mp4',
|
||||
product: 'Mobile SDK',
|
||||
tag: 'sdk',
|
||||
tagLabel: 'SDK · SIM',
|
||||
title: 'SIM Swap Detection',
|
||||
blurb: 'Mia’s SIM was swapped to a new device. BlackDice Angel risk rises to 92, and we recommend acting quickly.',
|
||||
icon: '📱',
|
||||
context: 'Mia · Online',
|
||||
initialScore: 15,
|
||||
finalScore: 92,
|
||||
severity: 'red',
|
||||
threatTitle: 'SIM Swap Detected',
|
||||
threatBody: 'Mia’s SIM card was changed to a new device',
|
||||
detailHeadline: 'Worth reviewing as soon as you can',
|
||||
detailLines: [
|
||||
'SIM moved to an unrecognised device',
|
||||
'Could allow someone to access accounts using one-time codes',
|
||||
'Confirm it’s really Mia before continuing',
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Verify identity', primary: true, icon: '🛡' },
|
||||
{ label: 'Contact Mia', icon: '📞' },
|
||||
],
|
||||
resolutionTitle: 'Account locked',
|
||||
resolutionBody: 'Mia’s number secured · awaiting verification',
|
||||
resolutionTone: 'red',
|
||||
statusChange: 'Mia · ⚠️ SIM changed',
|
||||
voCues: [
|
||||
{ at: 300, text: 'We watch for SIM-swap attacks across the family.' },
|
||||
{ at: 4300, text: 'We detect a SIM swap on Mia’s account.' },
|
||||
{ at: 9400, text: 'Our BlackDice Angel risk score rises to 92, so we recommend acting quickly.' },
|
||||
{ at: 15500, text: 'The parent is gently prompted to confirm Mia’s identity and block unauthorised access.' },
|
||||
{ at: 30300, text: 'We help stop account takeover. Book a demo today.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dns',
|
||||
file: 'blackdice-demo-06-dns-block.mp4',
|
||||
product: 'DNS Shield',
|
||||
tag: 'dns',
|
||||
tagLabel: 'DNS · BLOCK',
|
||||
title: 'Malicious DNS Block',
|
||||
blurb: 'Request to a malicious domain blocked. BlackDice Angel risk stays low at 45 — prevention in action.',
|
||||
icon: '🌐',
|
||||
context: 'DNS Shield active',
|
||||
initialScore: 35,
|
||||
finalScore: 45,
|
||||
severity: 'green',
|
||||
threatTitle: 'Malicious DNS Blocked',
|
||||
threatBody: 'Blocked request to phishing-domain.com',
|
||||
detailHeadline: 'Threat blocked before it could load',
|
||||
detailLines: [
|
||||
'phishing-domain.com — known malicious domain',
|
||||
'Request intercepted at the DNS layer',
|
||||
'12 malicious domains blocked this week',
|
||||
],
|
||||
actions: [
|
||||
{ label: 'View dashboard', primary: true, icon: '📊' },
|
||||
{ label: 'Dismiss', icon: '×' },
|
||||
],
|
||||
resolutionTitle: 'Risk contained',
|
||||
resolutionBody: 'DNS Shield blocked the threat automatically',
|
||||
resolutionTone: 'green',
|
||||
voCues: [
|
||||
{ at: 300, text: 'Our DNS Shield filters every domain request.' },
|
||||
{ at: 4300, text: 'A device tries to reach a known malicious domain.' },
|
||||
{ at: 9400, text: 'We block it instantly — our risk score stays low at 45.' },
|
||||
{ at: 15500, text: 'The threat is stopped before any damage, and logged in the dashboard.' },
|
||||
{ at: 30300, text: 'We deliver prevention that just works. Book a demo today.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'appperms',
|
||||
file: 'blackdice-demo-07-app-permissions.mp4',
|
||||
product: 'Mobile SDK',
|
||||
tag: 'sdk',
|
||||
tagLabel: 'SDK · PERM',
|
||||
title: 'Risky App Permissions',
|
||||
blurb: 'An app requests more permissions than it needs. BlackDice Angel risk rises to 66, worth a review.',
|
||||
icon: '📲',
|
||||
context: 'Approvals · 1 pending',
|
||||
initialScore: 32,
|
||||
finalScore: 66,
|
||||
severity: 'yellow',
|
||||
threatTitle: 'Risky App Permissions',
|
||||
threatBody: '“PhotoEditor” requests contacts and location',
|
||||
detailHeadline: 'Asking for more than it needs',
|
||||
detailLines: [
|
||||
'Contacts and location access requested',
|
||||
'Not required for this app’s function',
|
||||
'Age rating: 12+',
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Decline', primary: true, icon: '×' },
|
||||
{ label: 'Approve & install', icon: '✓' },
|
||||
],
|
||||
resolutionTitle: 'Install declined',
|
||||
resolutionBody: 'PhotoEditor blocked · request closed',
|
||||
resolutionTone: 'yellow',
|
||||
statusChange: 'PhotoEditor · request reviewed',
|
||||
voCues: [
|
||||
{ at: 300, text: 'We review every app your child wants to install.' },
|
||||
{ at: 4300, text: 'PhotoEditor asks for more than it needs — contacts and location.' },
|
||||
{ at: 9400, text: 'We flag the risk and raise our BlackDice Angel score to 66.' },
|
||||
{ at: 15500, text: 'The parent can review the permissions and decline the install.' },
|
||||
{ at: 30300, text: 'We make apps safer for younger users. Book a demo today.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function getScenario(id: string | undefined): ThreatScenario | undefined {
|
||||
return THREAT_SCENARIOS.find((s) => s.id === id)
|
||||
}
|
||||
|
||||
export function scoreColor(score: number): Severity {
|
||||
if (score >= 70) return 'red'
|
||||
if (score >= 50) return 'yellow'
|
||||
return 'green'
|
||||
}
|
||||
66
src/main.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter, Routes, Route, useParams } from 'react-router-dom'
|
||||
import { ContentProvider } from './cms/store'
|
||||
import { PAGES } from './cms/pages'
|
||||
|
||||
// Lazy-loaded so each area's CSS stays isolated:
|
||||
// the site's global stylesheet only loads on site routes, never on the demo pages.
|
||||
const SiteApp = React.lazy(() => import('./site/SiteApp'))
|
||||
const DemoScreen = React.lazy(() => import('./demo/DemoScreen'))
|
||||
const DemoVideoPage = React.lazy(() => import('./demo/DemoVideoPage'))
|
||||
const ThreatDemosSlider = React.lazy(() => import('./demo/threats/ThreatDemosSlider'))
|
||||
const ThreatDemoPlayerPage = React.lazy(() => import('./demo/threats/ThreatDemoPlayerPage'))
|
||||
const AdminApp = React.lazy(() => import('./cms/admin/AdminApp'))
|
||||
|
||||
function ArticleRoute() {
|
||||
const { slug } = useParams()
|
||||
return <SiteApp articleSlug={slug} />
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#013B49' }} />}>
|
||||
<Routes>
|
||||
{/* Threat-detection demo slider + per-scenario players */}
|
||||
<Route path="/threat-demos" element={<ThreatDemosSlider />} />
|
||||
<Route path="/threat-demo/:id" element={<ThreatDemoPlayerPage />} />
|
||||
{/* Cinematic product video — its own page */}
|
||||
<Route path="/demo-video" element={<DemoVideoPage />} />
|
||||
{/* Full interactive demo player (Flows A–D, ?mode=video) */}
|
||||
<Route path="/demo" element={<DemoScreen />} />
|
||||
|
||||
{/* The CMS. Content is fetched inside, so it sits outside ContentProvider. */}
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#0b1418' }} />}>
|
||||
<AdminApp />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* The marketing site: one real URL per page, plus one per article. */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<ContentProvider>
|
||||
<Routes>
|
||||
{PAGES.map((page) => (
|
||||
<Route key={page.id} path={page.path} element={<SiteApp pageId={page.id} />} />
|
||||
))}
|
||||
<Route path="/blog/:slug" element={<ArticleRoute />} />
|
||||
{/* Legacy hash-era and mistyped URLs still land on a working page. */}
|
||||
<Route path="/blog" element={<SiteApp pageId="p7" />} />
|
||||
<Route path="/blog.html" element={<SiteApp pageId="p7" />} />
|
||||
<Route path="*" element={<SiteApp pageId="p1" />} />
|
||||
</Routes>
|
||||
</ContentProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
196
src/site/SiteApp.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
// The site's global stylesheet is imported here so it only loads on site routes.
|
||||
import './styles.css'
|
||||
// Raw markup of the original site <body> (everything except the trailing <script>).
|
||||
import siteHtml from './siteMarkup.txt?raw'
|
||||
// Ported vanilla controller (navigation, modal, live feed, count-up).
|
||||
import { configureForms, initSite, setSiteNavigator, showPage, teardownSite } from './siteController'
|
||||
// Live threat-detection demos, embedded in the Mobile SDK hero (same slider
|
||||
// — all scenarios, autoplaying with tabs/arrows — used at /threat-demos).
|
||||
import ThreatDemosSlider from '../demo/threats/ThreatDemosSlider'
|
||||
import NewsGrid from './blog/NewsGrid'
|
||||
import ArticlePage from './blog/ArticlePage'
|
||||
import DemoSection from './demos/DemoSection'
|
||||
import DemoPlayer from './demos/DemoPlayer'
|
||||
import { applyContent, useContent } from '../cms/store'
|
||||
import { pageById } from '../cms/pages'
|
||||
import { applyJsonLd, applyMeta, organisationJsonLd } from '../cms/seo'
|
||||
import { submitLead } from '../cms/api'
|
||||
import type { SiteContent } from '../cms/types'
|
||||
|
||||
// React islands rendered into the legacy markup. Portals (rather than extra
|
||||
// roots) keep them inside this tree, so they share the router and CMS content.
|
||||
const ISLANDS = [
|
||||
'mobile-sdk-demo-mount',
|
||||
'news-grid-mount',
|
||||
'home-news-mount',
|
||||
'demos-p2-mount',
|
||||
'demo-halo-retina-mount',
|
||||
'demo-halo-angel-mount',
|
||||
'pg-article',
|
||||
] as const
|
||||
|
||||
export interface SiteAppProps {
|
||||
/** Page to show (p1…p12). Ignored when `articleSlug` is set. */
|
||||
pageId?: string
|
||||
/** Renders the article view at /blog/<slug>. */
|
||||
articleSlug?: string
|
||||
/** Admin preview: content comes from the editor's draft, not the published doc. */
|
||||
previewContent?: SiteContent
|
||||
/** Suppress meta/JSON-LD writes (the admin owns the document head). */
|
||||
suppressMeta?: boolean
|
||||
}
|
||||
|
||||
export default function SiteApp({ pageId = 'p1', articleSlug, previewContent, suppressMeta = false }: SiteAppProps) {
|
||||
const navigate = useNavigate()
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const publishedContent = useContent()
|
||||
const content = previewContent || publishedContent
|
||||
const [mounts, setMounts] = useState<Partial<Record<(typeof ISLANDS)[number], HTMLElement>>>({})
|
||||
|
||||
// ── One-time DOM setup ──────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const wrap = wrapRef.current
|
||||
if (wrap) {
|
||||
// The source export nests a duplicated Contact page, leaving #pg-p9 unclosed —
|
||||
// which swallows the modal and the policy pages. Hoist them back to the top
|
||||
// level so the fixed-position modal and page navigation work correctly.
|
||||
;['modal-ov', 'pg-p11', 'pg-p12', 'pg-article'].forEach((id) => {
|
||||
const el = document.getElementById(id)
|
||||
if (el && el.parentElement !== wrap) wrap.appendChild(el)
|
||||
})
|
||||
}
|
||||
|
||||
// Wire the Mobile SDK page's "live demos" callout to the threat-demo slider.
|
||||
const feedCta = document.getElementById('feed-live-cta')
|
||||
if (feedCta) (feedCta as HTMLElement).onclick = () => navigate('/threat-demos')
|
||||
|
||||
// Resolve the island containers now that the markup is in the DOM.
|
||||
const found: Partial<Record<(typeof ISLANDS)[number], HTMLElement>> = {}
|
||||
for (const id of ISLANDS) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) found[id] = el
|
||||
}
|
||||
setMounts(found)
|
||||
|
||||
// Boot the ported controller and hand it the router.
|
||||
setSiteNavigator((path: string) => navigate(path))
|
||||
initSite(articleSlug ? 'article' : pageId)
|
||||
|
||||
// Reliably wire the "Talk to us" CTAs to open the Request-a-demonstration
|
||||
// modal (in case the inline onclick handler doesn't bind in some contexts).
|
||||
const openDemo = () => {
|
||||
const fn = (window as { showDemo?: () => void }).showDemo
|
||||
if (typeof fn === 'function') fn()
|
||||
}
|
||||
document.querySelectorAll('.bd-nav-cta').forEach((b) => {
|
||||
;(b as HTMLElement).onclick = openDemo
|
||||
})
|
||||
document.querySelectorAll('.bd-mob-cta').forEach((b) => {
|
||||
;(b as HTMLElement).onclick = () => {
|
||||
const m = document.getElementById('mob-nav')
|
||||
if (m) m.style.display = 'none'
|
||||
openDemo()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
teardownSite()
|
||||
setSiteNavigator(null)
|
||||
}
|
||||
// Deliberately once: the markup is static, and page switching is handled below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ── Enquiry routing: mailto/external contact links open the form ─────────────
|
||||
useEffect(() => {
|
||||
configureForms({
|
||||
formRecipient: content.settings.formRecipient,
|
||||
formCc: content.settings.formCc,
|
||||
onLead: (lead: Record<string, string>) => {
|
||||
// Best effort: a durable record even if the visitor's mail client fails.
|
||||
submitLead(lead).catch(() => {})
|
||||
},
|
||||
})
|
||||
}, [content.settings.formRecipient, content.settings.formCc])
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const link = (e.target as HTMLElement | null)?.closest?.('a[href^="mailto:"]')
|
||||
if (!link) return
|
||||
// Every route to a human goes through the enquiry form so the visitor's
|
||||
// details are captured rather than lost in a mail client.
|
||||
e.preventDefault()
|
||||
;(window as { showDemo?: () => void }).showDemo?.()
|
||||
}
|
||||
document.addEventListener('click', onClick)
|
||||
return () => document.removeEventListener('click', onClick)
|
||||
}, [])
|
||||
|
||||
// ── Apply CMS content to the markup ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (wrapRef.current) applyContent(document, content)
|
||||
}, [content])
|
||||
|
||||
// ── Route → page ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (articleSlug) showPage('article', 'p7')
|
||||
else showPage(pageId)
|
||||
}, [pageId, articleSlug])
|
||||
|
||||
// ── Metadata ────────────────────────────────────────────────────────────────
|
||||
const seo = useMemo(() => content.seo[pageId], [content, pageId])
|
||||
useEffect(() => {
|
||||
if (suppressMeta || articleSlug) return // ArticlePage owns article metadata
|
||||
const page = pageById(pageId)
|
||||
if (!page || !seo) return
|
||||
applyMeta({
|
||||
title: seo.title,
|
||||
description: seo.description,
|
||||
path: page.path,
|
||||
image: seo.ogImage,
|
||||
siteUrl: content.settings.siteUrl,
|
||||
robots: page.indexable ? 'index, follow' : 'noindex, follow',
|
||||
})
|
||||
applyJsonLd(pageId === 'p1' ? organisationJsonLd(content.settings.siteUrl) : null)
|
||||
}, [pageId, seo, articleSlug, suppressMeta, content.settings.siteUrl])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={wrapRef} dangerouslySetInnerHTML={{ __html: siteHtml }} />
|
||||
|
||||
{mounts['mobile-sdk-demo-mount'] && createPortal(<ThreatDemosSlider embedded />, mounts['mobile-sdk-demo-mount'])}
|
||||
|
||||
{mounts['news-grid-mount'] && createPortal(<NewsGrid />, mounts['news-grid-mount'])}
|
||||
|
||||
{mounts['home-news-mount'] &&
|
||||
createPortal(
|
||||
<NewsGrid limit={3} showFilters={false} heading="Latest news and insights" />,
|
||||
mounts['home-news-mount'],
|
||||
)}
|
||||
|
||||
{mounts['demos-p2-mount'] &&
|
||||
createPortal(
|
||||
<DemoSection
|
||||
page="p2"
|
||||
eyebrow="SEE IT IN ACTION"
|
||||
heading="Five detections, as the subscriber experiences them."
|
||||
blurb="Short walkthroughs of the signals the SDK reads and the action it recommends — scam calls and VOIP, scam SMS, SIM swap, per-device DNS analytics on iOS and Android, and risky app permissions."
|
||||
/>,
|
||||
mounts['demos-p2-mount'],
|
||||
)}
|
||||
|
||||
{mounts['demo-halo-retina-mount'] &&
|
||||
createPortal(<DemoPlayer slot={content.demos['halo-retina']} />, mounts['demo-halo-retina-mount'])}
|
||||
|
||||
{mounts['demo-halo-angel-mount'] &&
|
||||
createPortal(<DemoPlayer slot={content.demos['halo-angel']} />, mounts['demo-halo-angel-mount'])}
|
||||
|
||||
{articleSlug &&
|
||||
mounts['pg-article'] &&
|
||||
createPortal(<ArticlePage slug={articleSlug} suppressMeta={suppressMeta} />, mounts['pg-article'])}
|
||||
</>
|
||||
)
|
||||
}
|
||||
176
src/site/blog/ArticlePage.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { formatPostDate, publishedPosts, useContent } from '../../cms/store'
|
||||
import { applyJsonLd, applyMeta, articleJsonLd } from '../../cms/seo'
|
||||
import { categoryMeta, splitAuthor } from './NewsGrid'
|
||||
import type { Post } from '../../cms/types'
|
||||
|
||||
/**
|
||||
* A single article at /blog/<slug>. Rendered into the #pg-article container inside
|
||||
* the site markup, so it inherits the real nav, footer and enquiry modal — and the
|
||||
* white-section rule that makes the body copy readable.
|
||||
*/
|
||||
export default function ArticlePage({ slug, suppressMeta = false }: { slug: string; suppressMeta?: boolean }) {
|
||||
const content = useContent()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Drafts are reachable by direct link so they can be reviewed before going live,
|
||||
// but they never appear in listings or the sitemap.
|
||||
const post = useMemo(() => content.posts.find((p) => p.slug === slug), [content, slug])
|
||||
const related = useMemo(() => {
|
||||
const others = publishedPosts(content).filter((p) => p.slug !== slug)
|
||||
const sameCategory = others.filter((p) => p.category === post?.category)
|
||||
return [...sameCategory, ...others.filter((p) => p.category !== post?.category)].slice(0, 3)
|
||||
}, [content, post, slug])
|
||||
|
||||
useEffect(() => {
|
||||
if (suppressMeta) return // the admin preview leaves the document head alone
|
||||
if (!post) {
|
||||
applyMeta({
|
||||
title: 'Article not found | BlackDice Cyber',
|
||||
description: 'The article you are looking for is no longer available.',
|
||||
path: `/blog/${slug}`,
|
||||
siteUrl: content.settings.siteUrl,
|
||||
robots: 'noindex, follow',
|
||||
})
|
||||
applyJsonLd(null)
|
||||
return
|
||||
}
|
||||
applyMeta({
|
||||
title: post.metaTitle || `${post.title} | BlackDice Cyber`,
|
||||
description: post.metaDescription || post.excerpt,
|
||||
path: `/blog/${post.slug}`,
|
||||
image: post.hero || undefined,
|
||||
type: 'article',
|
||||
siteUrl: content.settings.siteUrl,
|
||||
robots: post.status === 'draft' ? 'noindex, nofollow' : 'index, follow',
|
||||
})
|
||||
applyJsonLd(post.status === 'draft' ? null : articleJsonLd(post, content.settings.siteUrl))
|
||||
}, [post, slug, content.settings.siteUrl, suppressMeta])
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<section className="bd-sect t">
|
||||
<div className="bd-si" style={{ maxWidth: 720 }}>
|
||||
<span className="ey">NEWSROOM</span>
|
||||
<h1 className="h1" style={{ fontSize: 'clamp(28px,3vw,40px)', marginBottom: 16 }}>
|
||||
That article has moved.
|
||||
</h1>
|
||||
<p style={{ color: 'var(--t2)', fontSize: 16, marginBottom: 24 }}>
|
||||
We could not find an article at <code>/blog/{slug}</code>. It may have been renamed or unpublished.
|
||||
</p>
|
||||
<button className="btn-p" onClick={() => navigate('/news')}>
|
||||
Back to the newsroom
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const cat = categoryMeta(post.category)
|
||||
const { name, credit } = splitAuthor(post.author)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="bd-hero-sh bd-article-hero">
|
||||
<div className="bd-hero-gl" />
|
||||
<div className="bd-si" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<button className="bd-article-back" onClick={() => navigate('/news')}>
|
||||
← Newsroom
|
||||
</button>
|
||||
<span className={`news-tag ${cat.cls}`} style={{ marginBottom: 16, display: 'inline-block' }}>
|
||||
{cat.label.toUpperCase()}
|
||||
</span>
|
||||
{post.status === 'draft' ? <span className="bd-news-draft" style={{ marginLeft: 8 }}>DRAFT — NOT PUBLISHED</span> : null}
|
||||
<h1 className="h1 bd-article-title">{post.title}</h1>
|
||||
<p className="bd-article-meta">
|
||||
{formatPostDate(post.date)}
|
||||
{name ? ` · ${name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bd-sect t">
|
||||
<div className="bd-si bd-article-wrap">
|
||||
{post.hero ? (
|
||||
<figure className="bd-article-hero-img">
|
||||
<img src={post.hero} alt={post.title} />
|
||||
{credit ? <figcaption>{credit}</figcaption> : null}
|
||||
</figure>
|
||||
) : null}
|
||||
<div className="bd-article-body" dangerouslySetInnerHTML={{ __html: post.body }} />
|
||||
<ArticleShare post={post} siteUrl={content.settings.siteUrl} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bd-sect t">
|
||||
<div className="bd-si">
|
||||
<div className="sect-hd">
|
||||
<span className="ey">KEEP READING</span>
|
||||
<h2 className="h2">More from BlackDice</h2>
|
||||
</div>
|
||||
<div className="news-grid">
|
||||
{related.map((other) => (
|
||||
<article key={other.id} className="news-card bd-news-card" onClick={() => navigate(`/blog/${other.slug}`)}>
|
||||
<span className={`news-tag ${categoryMeta(other.category).cls}`}>
|
||||
{categoryMeta(other.category).label.toUpperCase()}
|
||||
</span>
|
||||
<h3>{other.title}</h3>
|
||||
<p className="bd-news-meta">{formatPostDate(other.date)}</p>
|
||||
<p>{other.excerpt}</p>
|
||||
<span className="bd-news-more">Read more →</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="cta-band">
|
||||
<div className="cta-band-in">
|
||||
<div>
|
||||
<h2 className="h2">
|
||||
Want to see this working on your network? <em>Talk to us.</em>
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
Tell us about your subscriber base and we will show you the intelligence BlackDice surfaces from within
|
||||
your own infrastructure.
|
||||
</p>
|
||||
<div className="btn-row">
|
||||
<button className="btn-p" onClick={() => (window as { showDemo?: () => void }).showDemo?.()}>
|
||||
Request a demonstration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ArticleShare({ post, siteUrl }: { post: Post; siteUrl: string }) {
|
||||
const url = `${siteUrl.replace(/\/+$/, '')}/blog/${post.slug}`
|
||||
const share = [
|
||||
{ label: 'LinkedIn', href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}` },
|
||||
{ label: 'X', href: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(post.title)}` },
|
||||
]
|
||||
return (
|
||||
<div className="bd-article-share">
|
||||
<span className="ey">SHARE THIS ARTICLE</span>
|
||||
<div>
|
||||
{share.map((s) => (
|
||||
<a key={s.label} href={s.href} target="_blank" rel="noopener">
|
||||
{s.label}
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(url)
|
||||
}}>
|
||||
Copy link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
src/site/blog/NewsGrid.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { POST_CATEGORIES, type Post, type PostCategory } from '../../cms/types'
|
||||
import { formatPostDate, publishedPosts, useContent } from '../../cms/store'
|
||||
|
||||
export const categoryMeta = (id: string) => POST_CATEGORIES.find((c) => c.id === id) || POST_CATEGORIES[0]
|
||||
|
||||
/** Authors are stored as "Paul Hague (Image by … on Unsplash)" — split the credit out. */
|
||||
export function splitAuthor(author: string) {
|
||||
const match = /^(.*?)\s*\(([^)]*)\)\s*$/.exec(author || '')
|
||||
return match ? { name: match[1].trim(), credit: match[2].trim() } : { name: (author || '').trim(), credit: '' }
|
||||
}
|
||||
|
||||
function Card({ post, onOpen }: { post: Post; onOpen: (post: Post) => void }) {
|
||||
const cat = categoryMeta(post.category)
|
||||
const { name } = splitAuthor(post.author)
|
||||
return (
|
||||
<article
|
||||
className="news-card bd-news-card"
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(post)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpen(post)
|
||||
}
|
||||
}}>
|
||||
{post.hero ? (
|
||||
<div className="bd-news-thumb">
|
||||
<img src={post.hero} alt="" loading="lazy" />
|
||||
</div>
|
||||
) : null}
|
||||
<span className={`news-tag ${cat.cls}`}>{cat.label.toUpperCase()}</span>
|
||||
{post.status === 'draft' ? <span className="bd-news-draft">DRAFT</span> : null}
|
||||
<h3>{post.title}</h3>
|
||||
<p className="bd-news-meta">
|
||||
{formatPostDate(post.date)}
|
||||
{name ? ` · ${name}` : ''}
|
||||
</p>
|
||||
<p>{post.excerpt}</p>
|
||||
<span className="bd-news-more">Read more →</span>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The newsroom grid (/news) and the home page's latest-three strip. Both read the
|
||||
* same CMS post library, so publishing an article puts it everywhere it belongs.
|
||||
*/
|
||||
export default function NewsGrid({ limit, showFilters = true, heading }: { limit?: number; showFilters?: boolean; heading?: string }) {
|
||||
const content = useContent()
|
||||
const navigate = useNavigate()
|
||||
const [filter, setFilter] = useState<PostCategory | 'all'>('all')
|
||||
|
||||
const all = useMemo(() => publishedPosts(content), [content])
|
||||
const counts = useMemo(() => {
|
||||
const out: Record<string, number> = { all: all.length }
|
||||
for (const post of all) out[post.category] = (out[post.category] || 0) + 1
|
||||
return out
|
||||
}, [all])
|
||||
|
||||
const posts = useMemo(() => {
|
||||
const filtered = filter === 'all' ? all : all.filter((p) => p.category === filter)
|
||||
return limit ? filtered.slice(0, limit) : filtered
|
||||
}, [all, filter, limit])
|
||||
|
||||
const open = (post: Post) => navigate(`/blog/${post.slug}`)
|
||||
|
||||
if (!all.length) {
|
||||
return (
|
||||
<p style={{ color: 'var(--t2)', fontSize: 15 }}>
|
||||
No articles have been published yet.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{heading ? (
|
||||
<div className="sect-hd bd-news-head">
|
||||
<div>
|
||||
<span className="ey">FROM THE NEWSROOM</span>
|
||||
<h2 className="h2">{heading}</h2>
|
||||
</div>
|
||||
<button className="bd-news-all" onClick={() => navigate('/news')}>
|
||||
View all articles →
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showFilters ? (
|
||||
<div className="bd-news-filters">
|
||||
{[{ id: 'all', label: 'All' }, ...POST_CATEGORIES].map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`bd-news-chip${filter === cat.id ? ' on' : ''}`}
|
||||
onClick={() => setFilter(cat.id as PostCategory | 'all')}
|
||||
disabled={!counts[cat.id]}>
|
||||
{cat.label}
|
||||
<span>{counts[cat.id] || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="news-grid">
|
||||
{posts.map((post) => (
|
||||
<Card key={post.id} post={post} onOpen={open} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
src/site/demos/DemoPlayer.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DemoSlot } from '../../cms/types'
|
||||
|
||||
/**
|
||||
* A CMS-configured demo clip. Renders nothing until a video has been uploaded in
|
||||
* /admin → Demos, so a page never shows an empty player.
|
||||
*/
|
||||
export default function DemoPlayer({ slot, compact = false }: { slot?: DemoSlot; compact?: boolean }) {
|
||||
if (!slot || !slot.enabled || !slot.video) return null
|
||||
return (
|
||||
<figure className={`bd-demo-clip${compact ? ' compact' : ''}`}>
|
||||
<div className="dash-frame bd-demo-frame">
|
||||
<video
|
||||
controls
|
||||
playsInline
|
||||
preload="none"
|
||||
poster={slot.poster || undefined}
|
||||
style={{ display: 'block', width: '100%', height: 'auto', background: '#010e14' }}>
|
||||
<source src={slot.video} />
|
||||
Your browser cannot play this clip.
|
||||
</video>
|
||||
</div>
|
||||
{slot.title || slot.caption ? (
|
||||
<figcaption>
|
||||
{slot.title ? <strong>{slot.title}</strong> : null}
|
||||
{slot.caption ? <span>{slot.caption}</span> : null}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
74
src/site/demos/DemoSection.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { demoSlotsForPage } from '../../cms/pages'
|
||||
import { useContent } from '../../cms/store'
|
||||
import { THREAT_SCENARIOS } from '../../demo/threats/scenarios'
|
||||
import { ThreatCinematic } from '../../demo/threats/ThreatDemo'
|
||||
import DemoPlayer from './DemoPlayer'
|
||||
|
||||
/**
|
||||
* "See it in action" for a product page: one tab per demo. A tab plays the clip
|
||||
* uploaded in the CMS if there is one, and otherwise falls back to the live
|
||||
* interactive demo built into this project — so the section is never empty and
|
||||
* clips can be swapped in later without touching code.
|
||||
*/
|
||||
export default function DemoSection({ page, eyebrow, heading, blurb }: { page: string; eyebrow: string; heading: string; blurb: string }) {
|
||||
const content = useContent()
|
||||
const navigate = useNavigate()
|
||||
const slots = demoSlotsForPage(page).filter((def) => {
|
||||
const slot = content.demos[def.key]
|
||||
if (slot && !slot.enabled) return false
|
||||
// Keep a tab if it has either a clip or an interactive fallback.
|
||||
return Boolean(def.scenario || slot?.video)
|
||||
})
|
||||
const [active, setActive] = useState(0)
|
||||
|
||||
if (!slots.length) return null
|
||||
|
||||
const current = slots[Math.min(active, slots.length - 1)]
|
||||
const slot = content.demos[current.key] || current.defaults
|
||||
const scenario = current.scenario ? THREAT_SCENARIOS.find((s) => s.id === current.scenario) : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sect-hd">
|
||||
<span className="ey">{eyebrow}</span>
|
||||
<h2 className="h2">{heading}</h2>
|
||||
<p>{blurb}</p>
|
||||
</div>
|
||||
|
||||
<div className="bd-demo-tabstrip">
|
||||
{slots.map((def, i) => {
|
||||
const label = (content.demos[def.key] || def.defaults).title
|
||||
return (
|
||||
<button key={def.key} className={`bd-demo-tab${i === active ? ' on' : ''}`} onClick={() => setActive(i)}>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={`bd-demo-stage${slot.video ? '' : ' bare'}`}>
|
||||
{slot.video ? (
|
||||
<DemoPlayer slot={slot} />
|
||||
) : scenario ? (
|
||||
// No frame, no backdrop: the phone mockup sits straight on the page,
|
||||
// the same way the hero demo reads.
|
||||
<div className="bd-demo-interactive">
|
||||
<ThreatCinematic key={scenario.id} scenario={scenario} bare />
|
||||
</div>
|
||||
) : null}
|
||||
<p className="bd-demo-caption">{slot.caption}</p>
|
||||
</div>
|
||||
|
||||
<div className="btn-row" style={{ marginTop: 24 }}>
|
||||
<button className="btn-g" onClick={() => navigate('/threat-demos')}>
|
||||
Open the full demo player →
|
||||
</button>
|
||||
<button className="btn-p" onClick={() => (window as { showDemo?: () => void }).showDemo?.()}>
|
||||
Request a demonstration
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
460
src/site/siteController.js
Normal file
@@ -0,0 +1,460 @@
|
||||
// BlackDice site controller — ported from the original vanilla js/main.js.
|
||||
// Function declarations are exposed on window so the inline onclick handlers in the
|
||||
// legacy markup (goPage(event), showDemo(), etc.) continue to resolve.
|
||||
/* eslint-disable */
|
||||
|
||||
"use strict";
|
||||
|
||||
var PAGE_NAMES = {
|
||||
p1: "Home",
|
||||
p2: "Mobile SDK",
|
||||
p3: "Halo CPE",
|
||||
p4: "For Operators",
|
||||
p5: "Financial Services",
|
||||
p6: "Why BlackDice?",
|
||||
p7: "News",
|
||||
p8: "Investors",
|
||||
p9: "Contact",
|
||||
p10: "DNS Protect",
|
||||
p11: "Cookie Policy",
|
||||
p12: "Privacy Policy",
|
||||
};
|
||||
|
||||
var PAGE_PATHS = {
|
||||
p1: "/",
|
||||
p2: "/mobile-sdk",
|
||||
p3: "/halo-cpe",
|
||||
p4: "/for-operators",
|
||||
p5: "/financial-services",
|
||||
p6: "/why-blackdice",
|
||||
p7: "/news",
|
||||
p8: "/investors",
|
||||
p9: "/contact",
|
||||
p10: "/dns-protect",
|
||||
p11: "/cookie-policy",
|
||||
p12: "/privacy-policy",
|
||||
};
|
||||
|
||||
var FEED_EVENTS = [
|
||||
{
|
||||
lbl: "DNS exfiltration, IoT device, Subscriber #4471821",
|
||||
risk: "HIGH",
|
||||
cls: "h",
|
||||
bdg: "blocked",
|
||||
btxt: "BLOCKED",
|
||||
},
|
||||
{
|
||||
lbl: "Behavioural anomaly new device, 3am traffic spike",
|
||||
risk: "MED",
|
||||
cls: "m",
|
||||
bdg: "mon",
|
||||
btxt: "MONITORING",
|
||||
},
|
||||
{
|
||||
lbl: "C2 callback attempt, ZeroDay variant detected",
|
||||
risk: "HIGH",
|
||||
cls: "h",
|
||||
bdg: "blocked",
|
||||
btxt: "NEUTRALISED",
|
||||
},
|
||||
{
|
||||
lbl: "Remote access app active during banking session",
|
||||
risk: "HIGH",
|
||||
cls: "h",
|
||||
bdg: "blocked",
|
||||
btxt: "BLOCKED",
|
||||
},
|
||||
{
|
||||
lbl: "Phishing domain blocked — lookalike banking site",
|
||||
risk: "HIGH",
|
||||
cls: "h",
|
||||
bdg: "blocked",
|
||||
btxt: "BLOCKED",
|
||||
},
|
||||
{
|
||||
lbl: "Botnet C2 lookup — smart TV IoT cluster",
|
||||
risk: "HIGH",
|
||||
cls: "h",
|
||||
bdg: "blocked",
|
||||
btxt: "BLOCKED",
|
||||
},
|
||||
{
|
||||
lbl: "Newly registered domain flagged for analysis",
|
||||
risk: "MED",
|
||||
cls: "m",
|
||||
bdg: "mon",
|
||||
btxt: "MONITORING",
|
||||
},
|
||||
{
|
||||
lbl: "Device rooted pre-login, session terminated",
|
||||
risk: "HIGH",
|
||||
cls: "h",
|
||||
bdg: "blocked",
|
||||
btxt: "BLOCKED",
|
||||
},
|
||||
{
|
||||
lbl: "Scam call detected during active banking session",
|
||||
risk: "HIGH",
|
||||
cls: "h",
|
||||
bdg: "flag",
|
||||
btxt: "FLAGGED",
|
||||
},
|
||||
{
|
||||
lbl: "Standard session, device integrity clear",
|
||||
risk: "LOW",
|
||||
cls: "l",
|
||||
bdg: "clear",
|
||||
btxt: "CLEAR",
|
||||
},
|
||||
{
|
||||
lbl: "Incoming call — known scam number pattern",
|
||||
risk: "HIGH",
|
||||
cls: "h",
|
||||
bdg: "flag",
|
||||
btxt: "FLAGGED",
|
||||
},
|
||||
{
|
||||
lbl: "Network switch: Wi-Fi \u2192 cellular (rogue AP adjacent)",
|
||||
risk: "MED",
|
||||
cls: "m",
|
||||
bdg: "mon",
|
||||
btxt: "MONITORING",
|
||||
},
|
||||
];
|
||||
|
||||
var feedIdx = 0;
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
// Pages are real URLs (/mobile-sdk, /halo-cpe, …). The router owns the URL: the
|
||||
// legacy inline handlers call go(), which navigates, and the route change calls
|
||||
// showPage() to do the DOM work. That keeps back/forward, deep links and shared
|
||||
// links working without duplicating page-switching logic.
|
||||
|
||||
// Injected by SiteApp; falls back to direct rendering if the router is absent.
|
||||
var __navigate = null;
|
||||
|
||||
function setSiteNavigator(fn) {
|
||||
__navigate = fn;
|
||||
}
|
||||
|
||||
function go(id) {
|
||||
var path = PAGE_PATHS[id];
|
||||
if (__navigate && path) {
|
||||
__navigate(path);
|
||||
return;
|
||||
}
|
||||
showPage(id);
|
||||
}
|
||||
|
||||
/** Renders a page in the DOM. `navId` highlights a different nav item (articles → News). */
|
||||
function showPage(id, navId) {
|
||||
var active = navId || id;
|
||||
|
||||
// Hide all pages
|
||||
document.querySelectorAll(".pg").forEach(function (el) {
|
||||
el.style.display = "none";
|
||||
});
|
||||
// Show target page
|
||||
var pg = document.getElementById("pg-" + id);
|
||||
if (pg) pg.style.display = "block";
|
||||
|
||||
// Update nav active states
|
||||
document.querySelectorAll("[data-nav]").forEach(function (btn) {
|
||||
btn.classList.remove("on");
|
||||
});
|
||||
document.querySelectorAll('[data-nav="' + active + '"]').forEach(function (btn) {
|
||||
btn.classList.add("on");
|
||||
});
|
||||
|
||||
// Company dropdown parent
|
||||
var co = ["p6", "p7", "p8", "p9"].indexOf(active) >= 0;
|
||||
var coBtn = document.getElementById("nav-co");
|
||||
if (coBtn) {
|
||||
if (co) coBtn.classList.add("on");
|
||||
else coBtn.classList.remove("on");
|
||||
}
|
||||
|
||||
// Close mobile nav
|
||||
var mob = document.getElementById("mob-nav");
|
||||
if (mob) mob.style.display = "none";
|
||||
|
||||
// Scroll to top instantly
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// HubSpot page view tracking
|
||||
var hsq = (window._hsq = window._hsq || []);
|
||||
hsq.push(["setPath", PAGE_PATHS[id] || window.location.pathname]);
|
||||
hsq.push(["trackPageView"]);
|
||||
|
||||
setTimeout(initCountUp, 200);
|
||||
}
|
||||
|
||||
function goPage(e) {
|
||||
var btn = e.target.closest("[data-page]");
|
||||
if (btn) go(btn.dataset.page);
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
go("p1");
|
||||
}
|
||||
|
||||
function toggleMob() {
|
||||
var mob = document.getElementById("mob-nav");
|
||||
if (!mob) return;
|
||||
mob.style.display = mob.style.display === "none" ? "block" : "none";
|
||||
}
|
||||
|
||||
// ── Scroll ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function onScroll() {
|
||||
var nav = document.getElementById("bd-nav");
|
||||
if (!nav) return;
|
||||
if (window.scrollY > 50) {
|
||||
nav.style.background = "rgba(1,59,73,.97)";
|
||||
nav.style.borderBottomColor = "rgba(255,255,255,.07)";
|
||||
} else {
|
||||
nav.style.background = "rgba(1,59,73,.4)";
|
||||
nav.style.borderBottomColor = "transparent";
|
||||
}
|
||||
}
|
||||
|
||||
function scrollDown() {
|
||||
var hint = document.getElementById("scroll-hint-btn");
|
||||
if (hint) hint.classList.remove("vis");
|
||||
window.scrollBy({ top: window.innerHeight * 0.85, behavior: "smooth" });
|
||||
}
|
||||
|
||||
// ── Demo modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
function showDemo() {
|
||||
var ov = document.getElementById("modal-ov");
|
||||
var form = document.getElementById("modal-form");
|
||||
var sent = document.getElementById("modal-sent");
|
||||
var err = document.getElementById("modal-err");
|
||||
if (ov) ov.style.display = "flex";
|
||||
if (form) form.style.display = "block";
|
||||
if (sent) sent.style.display = "none";
|
||||
if (err) err.style.display = "none";
|
||||
}
|
||||
|
||||
function closeDemo() {
|
||||
var ov = document.getElementById("modal-ov");
|
||||
if (ov) ov.style.display = "none";
|
||||
}
|
||||
|
||||
function closeBg(e) {
|
||||
if (e.target === e.currentTarget) closeDemo();
|
||||
}
|
||||
|
||||
// \u2500\u2500 Enquiry forms \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
// Every route to a human \u2014 "Talk to us", "Book a demonstration", the contact page
|
||||
// and any mailto: link \u2014 funnels through these forms so the visitor's details are
|
||||
// captured. Recipients come from the CMS (settings.formRecipient), so they can be
|
||||
// changed without a code release; onLead records the submission server-side.
|
||||
|
||||
var FORM_CONFIG = {
|
||||
formRecipient: "campbell.ferrier@blackdice.ai",
|
||||
formCc: "",
|
||||
onLead: null,
|
||||
};
|
||||
|
||||
function configureForms(config) {
|
||||
FORM_CONFIG.formRecipient = config.formRecipient || FORM_CONFIG.formRecipient;
|
||||
FORM_CONFIG.formCc = config.formCc || "";
|
||||
FORM_CONFIG.onLead = config.onLead || null;
|
||||
}
|
||||
|
||||
function fieldValue(id) {
|
||||
var el = document.getElementById(id);
|
||||
return el ? el.value.trim() : "";
|
||||
}
|
||||
|
||||
function sendEnquiry(form, fields, subject, intro) {
|
||||
if (typeof FORM_CONFIG.onLead === "function") {
|
||||
FORM_CONFIG.onLead({
|
||||
form: form,
|
||||
name: (fields.fname + " " + fields.lname).trim(),
|
||||
email: fields.email,
|
||||
company: fields.company,
|
||||
phone: fields.phone || "",
|
||||
message: fields.message || intro,
|
||||
page: window.location.pathname,
|
||||
});
|
||||
}
|
||||
var enc = encodeURIComponent;
|
||||
var lines = [
|
||||
"Name: " + fields.fname + " " + fields.lname,
|
||||
"Email: " + fields.email,
|
||||
fields.phone ? "Mobile: " + fields.phone : "",
|
||||
"Company: " + fields.company,
|
||||
"Page: " + window.location.href,
|
||||
"",
|
||||
fields.message || intro,
|
||||
].filter(Boolean);
|
||||
var cc = FORM_CONFIG.formCc ? "&cc=" + enc(FORM_CONFIG.formCc) : "";
|
||||
var a = document.createElement("a");
|
||||
a.href =
|
||||
"mailto:" +
|
||||
FORM_CONFIG.formRecipient +
|
||||
"?subject=" +
|
||||
enc(subject) +
|
||||
cc +
|
||||
"&body=" +
|
||||
enc(lines.join("\n"));
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
function submitDemo() {
|
||||
var fields = {
|
||||
fname: fieldValue("df-fname"),
|
||||
lname: fieldValue("df-lname"),
|
||||
email: fieldValue("df-email"),
|
||||
phone: fieldValue("df-mobile"),
|
||||
company: fieldValue("df-company"),
|
||||
message: "",
|
||||
};
|
||||
if (!fields.fname || !fields.email || !fields.company) {
|
||||
var err = document.getElementById("modal-err");
|
||||
if (err) err.style.display = "block";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem("bd_demo_prefill", JSON.stringify(fields));
|
||||
} catch (ex) {}
|
||||
sendEnquiry(
|
||||
"demo-request",
|
||||
fields,
|
||||
"Demo Request \u2014 " + fields.fname + " " + fields.lname + ", " + fields.company,
|
||||
"Please send information about a BlackDice platform demonstration.",
|
||||
);
|
||||
var form = document.getElementById("modal-form");
|
||||
var sent = document.getElementById("modal-sent");
|
||||
if (form) form.style.display = "none";
|
||||
if (sent) sent.style.display = "block";
|
||||
}
|
||||
|
||||
function submitContact() {
|
||||
var fields = {
|
||||
fname: fieldValue("cf-fname"),
|
||||
lname: fieldValue("cf-lname"),
|
||||
email: fieldValue("cf-email"),
|
||||
phone: fieldValue("cf-mobile"),
|
||||
company: fieldValue("cf-company"),
|
||||
message: fieldValue("cf-msg"),
|
||||
};
|
||||
if (!fields.fname || !fields.email) {
|
||||
var cerr = document.getElementById("contact-err");
|
||||
if (cerr) cerr.style.display = "block";
|
||||
return;
|
||||
}
|
||||
sendEnquiry(
|
||||
"contact",
|
||||
fields,
|
||||
"Enquiry \u2014 " + fields.fname + " " + fields.lname + ", " + fields.company,
|
||||
"Enquiry from the BlackDice website.",
|
||||
);
|
||||
var cform = document.getElementById("contact-form");
|
||||
var csent = document.getElementById("contact-sent");
|
||||
if (cform && csent) {
|
||||
cform.style.display = "none";
|
||||
csent.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live feed cycling ──────────────────────────────────────────────────────
|
||||
|
||||
function cycleFeed() {
|
||||
feedIdx = (feedIdx + 1) % FEED_EVENTS.length;
|
||||
var item = FEED_EVENTS[feedIdx];
|
||||
document.querySelectorAll(".feed-body").forEach(function (feed) {
|
||||
var rows = feed.querySelectorAll(".feed-row");
|
||||
if (!rows.length) return;
|
||||
var row = rows[rows.length - 1];
|
||||
var lbl = row.querySelector(".feed-lbl");
|
||||
var risk = row.querySelector(".feed-risk");
|
||||
var bdg = row.querySelector(".feed-bdg");
|
||||
if (lbl) lbl.textContent = item.lbl;
|
||||
if (risk) {
|
||||
risk.textContent = item.risk;
|
||||
risk.className = "feed-risk " + item.cls;
|
||||
}
|
||||
if (bdg) {
|
||||
bdg.textContent = item.btxt;
|
||||
bdg.className = "feed-bdg " + item.bdg;
|
||||
}
|
||||
feed.insertBefore(row, rows[0]);
|
||||
row.style.animation = "none";
|
||||
void row.offsetHeight; // force reflow
|
||||
row.style.animation = "bd-slide .4s ease";
|
||||
});
|
||||
}
|
||||
|
||||
// ── Count-up animation ─────────────────────────────────────────────────────
|
||||
|
||||
function initCountUp() {
|
||||
if (!("IntersectionObserver" in window)) return;
|
||||
var obs = new IntersectionObserver(
|
||||
function (entries) {
|
||||
entries.forEach(function (en) {
|
||||
if (!en.isIntersecting) return;
|
||||
var el = en.target;
|
||||
if (el._countDone) return;
|
||||
el._countDone = true;
|
||||
var target = parseFloat(el.dataset.count);
|
||||
var suffix = el.dataset.suffix || "";
|
||||
var isInt = Number.isInteger(target);
|
||||
var dur = 1500;
|
||||
var t0 = performance.now();
|
||||
function tick(now) {
|
||||
var p = Math.min((now - t0) / dur, 1);
|
||||
var ease = 1 - Math.pow(1 - p, 3);
|
||||
el.textContent =
|
||||
(isInt ? Math.round(target * ease) : (target * ease).toFixed(1)) +
|
||||
suffix;
|
||||
if (p < 1) requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
obs.unobserve(el);
|
||||
});
|
||||
},
|
||||
{ threshold: 0.5 },
|
||||
);
|
||||
document.querySelectorAll("[data-count]").forEach(function (el) {
|
||||
el._countDone = false;
|
||||
obs.observe(el);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// ── Expose globals for inline handlers ─────────────────────────────────────
|
||||
var __w = /** @type {any} */ (window);
|
||||
__w.go = go; __w.goPage = goPage; __w.goHome = goHome; __w.toggleMob = toggleMob;
|
||||
__w.scrollDown = scrollDown; __w.showDemo = showDemo; __w.closeDemo = closeDemo;
|
||||
__w.closeBg = closeBg; __w.submitDemo = submitDemo; __w.submitContact = submitContact;
|
||||
|
||||
var __bdFeedTimer = null;
|
||||
|
||||
export { showPage, setSiteNavigator, configureForms, initCountUp, PAGE_NAMES, PAGE_PATHS };
|
||||
|
||||
export function initSite(startPage) {
|
||||
showPage(startPage || 'p1');
|
||||
var hint = document.getElementById('scroll-hint-btn');
|
||||
if (hint) {
|
||||
hint.classList.add('vis');
|
||||
var hideHint = function () { hint.classList.remove('vis'); window.removeEventListener('scroll', hideHint); };
|
||||
window.addEventListener('scroll', hideHint);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
if (__bdFeedTimer) clearInterval(__bdFeedTimer);
|
||||
__bdFeedTimer = setInterval(cycleFeed, 3400);
|
||||
initCountUp();
|
||||
}
|
||||
|
||||
export function teardownSite() {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
if (__bdFeedTimer) { clearInterval(__bdFeedTimer); __bdFeedTimer = null; }
|
||||
}
|
||||
4022
src/site/siteMarkup.txt
Normal file
2447
src/site/styles.css
Normal file
6
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.txt?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
28
standalone.html
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
6
vercel.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/api/(.*)", "destination": "/api/$1" },
|
||||
{ "source": "/((?!assets/|content/|logo.svg|robots.txt|sitemap.xml).*)", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
26
vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
declare const process: { env: Record<string, string | undefined> }
|
||||
|
||||
const API_TARGET = process.env.API_URL || 'http://localhost:8787'
|
||||
|
||||
// Vite config — React + TS. Honors the PORT env (used by the preview harness);
|
||||
// falls back to 5173 and auto-increments if busy.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// Keep logical paths under the project root even when served via a symlink/junction
|
||||
// (needed so the TS/JSX transform applies in the preview harness).
|
||||
resolve: { preserveSymlinks: true },
|
||||
server: {
|
||||
port: process.env.PORT ? Number(process.env.PORT) : 5173,
|
||||
// The CMS API and its uploaded media are served by server/index.mjs in dev too,
|
||||
// so publishing from /admin behaves exactly as it does in production.
|
||||
proxy: {
|
||||
'/api': { target: API_TARGET, changeOrigin: true },
|
||||
'/content/uploads': { target: API_TARGET, changeOrigin: true },
|
||||
'/content/site-content.json': { target: API_TARGET, changeOrigin: true },
|
||||
'/sitemap.xml': { target: API_TARGET, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
})
|
||||