Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
5
apps/web/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
13
apps/web/next.config.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
async redirects() {
|
||||
return [{ source: "/", destination: "/streams", permanent: false }];
|
||||
},
|
||||
async rewrites() {
|
||||
const api = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||
return [{ source: "/media-control/preview/:path*", destination: `${api}/media-control/preview/:path*` }];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
33
apps/web/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "@tactical-media-broadcast-hub/web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tactical-media-broadcast-hub/connector-sdk": "workspace:^",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@tiptap/extension-character-count": "^3.31.4",
|
||||
"@tiptap/extension-placeholder": "^3.31.4",
|
||||
"@tiptap/react": "^3.31.4",
|
||||
"@tiptap/starter-kit": "^3.31.4",
|
||||
"cmdk": "^1.1.1",
|
||||
"next": "^14.2.5",
|
||||
"postcss": "^8.5.29",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"playwright": "^1.63.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
1
apps/web/postcss.config.mjs
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||
11
apps/web/public/brand/empty-state.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 1700 1700">
|
||||
<g transform="rotate(-17.5 850 850)" opacity="0.25">
|
||||
<circle cx="850" cy="850" r="350" fill="none" stroke="#FF1610" stroke-width="60" stroke-linecap="round"/>
|
||||
<path d="M 850 405 L 464.77 1269.52 M 850 405 L 1235.23 1269.52 M 345 850 L 1355 850"
|
||||
fill="none" stroke="#FF1610" stroke-width="75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<g opacity="0.6">
|
||||
<polygon points="1450,850 1550,810 1550,890" fill="#FF1610"/>
|
||||
<line x1="1280" y1="850" x2="1470" y2="850" stroke="#FF1610" stroke-width="30" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 665 B |
7
apps/web/public/brand/favicon.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<circle cx="850" cy="850" r="350" fill="none" stroke="#FF1610" stroke-width="74" stroke-linecap="round"/>
|
||||
<path d="M 850 405 L 464.77 1269.52 M 850 405 L 1235.23 1269.52 M 345 850 L 1355 850"
|
||||
fill="none" stroke="#FF1610" stroke-width="88" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
BIN
apps/web/public/brand/hero.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
6
apps/web/public/brand/icon-bluesky.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Bluesky</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 350 450 Q 400 350 500 400 L 600 480 L 700 400 Q 800 350 850 450 Q 850 550 750 650 L 600 780 L 450 650 Q 350 550 350 450 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 483 B |
6
apps/web/public/brand/icon-discord.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Discord</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 450 450 Q 550 400 600 400 Q 650 400 750 450 L 800 500 Q 850 600 800 700 L 750 750 L 700 700 L 650 720 L 550 720 L 500 700 L 450 750 L 400 700 Q 350 600 400 500 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/><circle cx="520" cy="570" r="18" fill="#FF1610"/><circle cx="680" cy="570" r="18" fill="#FF1610"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 598 B |
6
apps/web/public/brand/icon-facebook.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Facebook</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 500 450 L 500 550 L 450 550 L 450 650 L 500 650 L 500 850 L 600 850 L 600 650 L 680 650 L 700 550 L 600 550 L 600 480 Q 600 450 630 450 L 700 450 L 700 380 L 620 380 Q 500 380 500 450 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 548 B |
6
apps/web/public/brand/icon-instagram.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Instagram</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<rect x="350" y="350" width="500" height="500" rx="120" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="600" cy="600" r="130" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="770" cy="430" r="25" fill="#FF1610"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 491 B |
6
apps/web/public/brand/icon-linkedin.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Linkedin</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<rect x="350" y="350" width="500" height="100" rx="20" fill="none" stroke="#FF1610" stroke-width="35"/><line x1="400" y1="500" x2="400" y2="750" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="500" y1="500" x2="500" y2="750" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><path d="M 500 500 Q 550 450 600 500 L 600 600 Q 650 550 700 600 Q 750 650 700 700 Q 650 750 500 750" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 757 B |
6
apps/web/public/brand/icon-mastodon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Mastodon</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 400 500 L 400 700 Q 400 780 480 780 L 520 780 Q 560 780 560 740 L 560 560 Q 560 540 580 540 Q 600 540 600 560 L 600 740 Q 600 780 640 780 L 680 780 Q 760 780 760 700 L 760 500 Q 760 420 680 420 L 520 420 Q 440 420 400 500 Z M 400 500 L 360 460 Q 320 420 320 500 L 320 660 Q 320 740 380 760" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 652 B |
6
apps/web/public/brand/icon-peertube.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Peertube</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 400 350 L 400 850 L 800 600 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
6
apps/web/public/brand/icon-reddit.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Reddit</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<circle cx="600" cy="500" r="50" fill="#FF1610"/><path d="M 550 500 L 400 450" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/><path d="M 650 500 L 800 450" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/><ellipse cx="600" cy="650" rx="200" ry="140" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="500" cy="620" r="20" fill="#FF1610"/><circle cx="700" cy="620" r="20" fill="#FF1610"/><path d="M 520 700 Q 600 740 680 700" fill="none" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 780 B |
6
apps/web/public/brand/icon-slack.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Slack</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<rect x="450" y="350" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="670" y="350" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="350" y="530" width="140" height="80" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="710" y="530" width="140" height="80" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="450" y="700" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="670" y="700" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><path d="M 530 570 L 670 570" stroke="#FF1610" stroke-width="30" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 953 B |
6
apps/web/public/brand/icon-telegram.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Telegram</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 300 600 L 850 400 L 700 800 L 550 650 L 300 600 Z M 550 650 L 550 750 L 620 680" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 442 B |
6
apps/web/public/brand/icon-threads.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Threads</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 600 350 Q 450 350 400 500 Q 350 650 400 750 Q 450 850 600 850 Q 750 850 800 750 Q 850 650 800 500 Q 750 350 600 350 Z M 550 550 Q 600 500 650 550 Q 700 600 650 650 Q 600 700 550 650 Q 500 600 550 550 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 563 B |
6
apps/web/public/brand/icon-twitch.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
|
||||
<title>Twitch</title>
|
||||
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
|
||||
transform="rotate(-17.5 850 850)"/>
|
||||
<path d="M 400 350 L 800 350 L 800 600 L 650 750 L 500 750 L 400 850 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/><line x1="550" y1="450" x2="550" y2="550" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="670" y1="450" x2="670" y2="550" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 599 B |
6
apps/web/public/brand/logo-new.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="20.9 18.0 587.8 592.0" width="588" height="592" fill="none">
|
||||
<title>Tactical Media Broadcast Hub — broadcast logo — circle with signal arrows</title>
|
||||
<g fill="#FF1610" fill-rule="evenodd">
|
||||
<path d="M 441.4,600.2 C 437.1,598.0 431.7,595.5 429.5,594.5 C 427.3,593.5 418.1,589.1 409.1,584.8 C 400.1,580.5 392.6,577.0 392.4,577.0 C 392.1,577.0 387.8,575.0 382.7,572.7 C 377.7,570.3 370.6,567.0 367.0,565.4 C 359.3,562.1 354.0,559.1 354.0,558.2 C 354.0,557.8 357.2,555.7 361.2,553.5 C 379.6,543.2 387.0,538.7 387.0,537.9 C 387.0,537.3 361.2,493.8 355.9,485.5 C 354.8,483.9 350.9,477.3 347.1,471.0 C 343.3,464.7 339.6,459.6 338.8,459.7 C 338.1,459.7 333.2,460.9 328.0,462.3 C 310.2,466.9 305.4,467.5 284.5,467.4 C 266.8,467.4 263.1,467.1 252.5,464.8 C 238.0,461.6 227.3,458.1 216.0,452.9 C 199.5,445.2 183.1,434.1 171.2,422.6 C 167.7,419.2 164.5,416.3 163.9,416.2 C 163.1,415.9 160.4,417.2 151.0,422.7 C 149.1,423.8 138.5,429.9 127.5,436.3 C 116.6,442.6 107.2,448.4 106.7,449.2 C 105.8,450.5 112.9,463.6 120.6,475.1 C 124.1,480.2 123.8,482.2 119.8,481.6 C 118.0,481.3 112.9,480.6 108.5,480.0 C 104.1,479.4 97.8,478.5 94.5,478.0 C 91.2,477.5 78.4,475.7 66.0,474.0 C 53.6,472.4 41.1,470.5 38.1,470.0 C 35.1,469.4 31.3,469.0 29.6,469.0 C 24.0,469.0 23.7,468.1 26.9,459.4 C 29.9,451.4 32.4,445.1 34.0,441.5 C 34.5,440.4 35.6,437.5 36.4,435.0 C 37.7,430.8 42.1,419.4 54.2,388.5 C 57.0,381.4 59.5,375.3 59.7,375.1 C 60.4,374.2 62.7,377.4 71.6,391.8 C 77.6,401.5 81.3,406.5 82.3,406.4 C 83.2,406.3 91.5,402.0 100.7,396.7 C 118.8,386.5 123.0,384.1 130.0,380.2 C 132.5,378.9 134.9,377.4 135.4,376.9 C 135.9,376.4 134.8,372.9 132.8,368.3 C 128.6,359.0 124.6,346.9 121.8,334.5 C 119.2,322.9 118.2,294.7 120.0,281.6 C 124.7,246.6 140.4,213.4 163.9,188.7 L 166.1,179.7 C 164.8,177.4 162.3,173.2 160.5,170.5 C 158.7,167.8 155.5,162.6 153.3,159.0 C 146.7,148.1 129.0,119.8 125.3,114.2 C 123.4,111.4 121.4,109.0 120.7,109.0 C 120.0,109.0 115.5,111.3 110.5,114.1 C 97.6,121.4 85.9,127.2 85.3,126.6 C 85.0,126.4 85.3,123.1 85.9,119.3 C 87.2,111.3 89.8,93.4 91.1,83.5 C 91.6,79.7 92.4,73.3 93.0,69.5 C 93.6,65.7 94.4,59.1 95.0,55.0 C 96.1,46.5 97.5,35.7 98.6,28.7 C 99.0,25.8 99.8,24.0 100.6,24.0 C 103.2,24.0 194.0,68.6 194.0,69.9 C 194.0,70.4 188.2,73.9 181.1,77.6 C 164.0,86.5 163.4,87.0 165.0,90.1 C 166.1,92.2 180.5,115.5 199.8,146.3 C 202.8,151.1 205.9,155.0 206.6,155.0 C 207.3,155.0 209.4,154.1 211.2,153.1 C 218.5,148.8 237.6,142.0 249.7,139.4 C 261.6,136.8 264.1,136.6 286.0,136.6 C 306.3,136.6 310.9,136.9 319.6,138.8 C 351.3,145.6 379.3,159.5 401.0,179.1 L 414.5,182.6 C 421.5,178.8 440.0,168.2 448.0,163.5 C 451.0,161.8 456.1,158.9 459.2,157.1 C 462.4,155.3 465.0,153.5 465.0,153.2 C 465.0,152.8 460.7,145.3 455.5,136.6 C 450.3,127.8 446.0,120.2 446.0,119.7 C 446.0,119.1 448.1,119.0 451.2,119.4 C 454.1,119.8 460.1,120.5 464.5,121.0 C 468.9,121.6 476.2,122.4 480.8,123.0 C 485.3,123.5 492.6,124.4 497.0,125.0 C 513.1,127.0 521.7,128.0 531.5,129.1 C 537.0,129.7 542.0,130.6 542.7,131.2 C 543.5,131.9 543.1,134.2 540.8,140.3 C 539.2,144.8 537.0,150.8 536.0,153.5 C 535.0,156.2 533.1,161.4 531.7,165.0 C 530.3,168.6 526.4,179.2 523.0,188.5 C 510.7,222.7 510.4,223.3 508.8,222.8 C 508.2,222.6 503.9,216.1 499.3,208.4 C 494.6,200.8 490.5,194.2 490.1,193.8 C 489.7,193.4 484.8,195.7 479.1,199.0 C 473.4,202.3 468.6,205.0 468.4,205.0 C 468.3,205.0 464.6,207.2 460.3,209.8 C 456.0,212.5 449.4,216.4 445.5,218.4 C 435.0,224.1 435.1,223.9 438.2,230.7 C 444.9,245.4 449.8,261.6 451.5,275.0 C 451.9,278.0 452.6,281.5 453.1,282.8 L 479.7,285.0 C 493.9,285.0 507.9,284.7 510.8,284.4 L 516.0,265.9 C 516.0,251.9 516.3,248.0 517.3,248.0 C 518.5,248.0 522.4,250.6 544.0,265.5 C 548.1,268.3 555.8,273.5 561.0,277.1 C 566.2,280.6 571.0,283.8 571.5,284.3 C 572.0,284.7 579.2,289.7 587.4,295.4 C 595.7,301.0 602.5,306.1 602.7,306.7 C 602.9,307.3 600.7,309.5 597.8,311.6 C 587.7,318.8 533.3,358.6 523.3,366.1 C 516.3,371.2 516.1,370.7 515.8,349.8 L 492.5,331.2 C 479.9,331.0 465.4,331.2 460.4,331.5 L 450.0,337.7 C 442.1,373.2 419.6,408.3 389.3,432.5 C 385.8,435.3 383.0,438.0 383.0,438.6 C 383.0,439.3 387.7,447.6 393.5,457.1 C 399.3,466.7 409.3,483.4 415.8,494.2 C 422.2,505.0 428.1,514.1 428.8,514.4 C 429.5,514.7 431.5,514.1 433.3,513.1 C 465.4,494.7 465.8,494.6 464.6,500.2 C 464.2,502.0 463.5,507.1 463.0,511.5 C 462.4,515.9 461.6,522.9 461.0,527.0 C 460.4,531.1 459.3,540.6 458.5,548.0 C 457.2,559.9 454.0,587.4 452.4,599.8 C 452.1,602.2 451.3,604.0 450.6,604.0 C 450.0,604.0 445.8,602.3 441.4,600.2 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
44
apps/web/public/brand/logo.svg
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1700" height="1700" viewBox="0 0 1700 1700">
|
||||
<title>Tactical Media Broadcast Hub — broadcast mark</title>
|
||||
<desc>Red anarchist arrow A rotated 17.5 degrees counterclockwise so its top arrow points upper-left.</desc>
|
||||
|
||||
<rect width="100%" height="100%" fill="#000000"/>
|
||||
|
||||
<!-- Rotate the complete symbol 35 degrees to the left -->
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
|
||||
<circle cx="850" cy="850" r="350"
|
||||
fill="none" stroke="#FF1610" stroke-width="74"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<g fill="none" stroke="#FF1610" stroke-width="88"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M 850 405
|
||||
L 464.77 1269.52"/>
|
||||
<path d="M 850 405
|
||||
L 1235.23 1269.52"/>
|
||||
<path d="M 850 405
|
||||
L 850.00 350.00"/>
|
||||
<path d="M 345.00 850.00
|
||||
L 1355.00 850.00"/>
|
||||
</g>
|
||||
|
||||
<g fill="#FF1610">
|
||||
<polygon points="850.00,240.00 925.00,385.00 775.00,385.00"/>
|
||||
<polygon points="235.00,850.00 380.00,775.00 380.00,925.00"/>
|
||||
<polygon points="1465.00,850.00 1320.00,925.00 1320.00,775.00"/>
|
||||
<polygon points="420.00,1370.00 410.51,1207.03 547.52,1268.08"/>
|
||||
<polygon points="1280.00,1370.00 1152.48,1268.08 1289.49,1207.03"/>
|
||||
</g>
|
||||
|
||||
<g fill="#000000" opacity="0.95">
|
||||
<path d="M 630 640 l 20 4 -28 85 -18 -8 z"/>
|
||||
<path d="M 1052 650 l 19 -5 27 82 -18 8 z"/>
|
||||
<path d="M 655 1052 l 24 5 -26 78 -20 -9 z"/>
|
||||
<path d="M 1025 1052 l 23 -6 28 78 -20 9 z"/>
|
||||
<path d="M 454 838 l 60 -5 2 18 -61 5 z"/>
|
||||
<path d="M 1186 849 l 60 4 -2 18 -61 -4 z"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
4
apps/web/public/brand/nav-analytics.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Analytics</title>
|
||||
<line x1="350" y1="850" x2="850" y2="850" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/><line x1="420" y1="700" x2="420" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="600" y1="500" x2="600" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="780" y1="400" x2="780" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 532 B |
4
apps/web/public/brand/nav-audit.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Audit</title>
|
||||
<path d="M 500 350 L 700 350 L 700 500 L 800 500 L 600 850 L 400 500 L 500 500 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
4
apps/web/public/brand/nav-calendar.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Calendar</title>
|
||||
<rect x="350" y="400" width="500" height="450" rx="30" fill="none" stroke="#FF1610" stroke-width="40"/><line x1="350" y1="520" x2="850" y2="520" stroke="#FF1610" stroke-width="30"/><line x1="450" y1="400" x2="450" y2="350" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="750" y1="400" x2="750" y2="350" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><circle cx="500" cy="620" r="20" fill="#FF1610"/><circle cx="600" cy="620" r="20" fill="#FF1610"/><circle cx="700" cy="620" r="20" fill="#FF1610"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 657 B |
4
apps/web/public/brand/nav-compose.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Compose</title>
|
||||
<path d="M 400 800 L 350 850 L 400 850 L 850 400 L 800 350 L 350 800" fill="none" stroke="#FF1610" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/><line x1="750" y1="400" x2="800" y2="450" stroke="#FF1610" stroke-width="30"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
4
apps/web/public/brand/nav-connections.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Connections</title>
|
||||
<circle cx="450" cy="500" r="80" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="750" cy="700" r="80" fill="none" stroke="#FF1610" stroke-width="35"/><line x1="500" y1="550" x2="700" y2="650" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 393 B |
4
apps/web/public/brand/nav-home.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Home</title>
|
||||
<path d="M 600 350 L 850 550 L 850 850 L 350 850 L 350 550 Z" fill="none" stroke="#FF1610" stroke-width="40" stroke-linejoin="round"/><line x1="600" y1="350" x2="600" y2="850" stroke="#FF1610" stroke-width="30"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 335 B |
4
apps/web/public/brand/nav-inbox.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Inbox</title>
|
||||
<path d="M 350 400 L 850 400 L 850 700 L 700 700 L 600 800 L 500 700 L 350 700 Z" fill="none" stroke="#FF1610" stroke-width="40" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
4
apps/web/public/brand/nav-more.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav More</title>
|
||||
<circle cx="500" cy="600" r="30" fill="#FF1610"/><circle cx="600" cy="600" r="30" fill="#FF1610"/><circle cx="700" cy="600" r="30" fill="#FF1610"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
4
apps/web/public/brand/nav-search.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Search</title>
|
||||
<circle cx="550" cy="550" r="200" fill="none" stroke="#FF1610" stroke-width="40"/><line x1="700" y1="700" x2="850" y2="850" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
4
apps/web/public/brand/nav-settings.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Settings</title>
|
||||
<circle cx="600" cy="600" r="150" fill="none" stroke="#FF1610" stroke-width="40"/><g stroke="#FF1610" stroke-width="35" stroke-linecap="round"><line x1="600" y1="350" x2="600" y2="420"/><line x1="600" y1="780" x2="600" y2="850"/><line x1="350" y1="600" x2="420" y2="600"/><line x1="780" y1="600" x2="850" y2="600"/><line x1="420" y1="420" x2="470" y2="470"/><line x1="730" y1="730" x2="780" y2="780"/><line x1="420" y1="780" x2="470" y2="730"/><line x1="730" y1="470" x2="780" y2="420"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 618 B |
4
apps/web/public/brand/nav-team.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Team</title>
|
||||
<circle cx="500" cy="500" r="70" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="700" cy="500" r="70" fill="none" stroke="#FF1610" stroke-width="35"/><path d="M 400 750 Q 400 650 500 650 Q 600 650 600 750" fill="none" stroke="#FF1610" stroke-width="35"/><path d="M 600 750 Q 600 650 700 650 Q 800 650 800 750" fill="none" stroke="#FF1610" stroke-width="35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
4
apps/web/public/brand/nav-video.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
|
||||
<title>Nav Video</title>
|
||||
<rect x="260" y="400" width="680" height="400" rx="70" fill="none" stroke="#FF1610" stroke-width="70"/><path d="M 940 600 L 1120 480 L 1120 720 Z" fill="#FF1610"/><path d="M 360 470 L 360 730" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/><path d="M 480 470 L 480 730" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
24
apps/web/public/brand/pattern.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
|
||||
<rect width="1920" height="1080" fill="#0a0a0b"/>
|
||||
<defs>
|
||||
<g id="ca">
|
||||
<g transform="rotate(-17.5 0 0)">
|
||||
<circle cx="0" cy="0" r="45" fill="none" stroke="#FF1610" stroke-width="10"/>
|
||||
<path d="M 0 -38 L -28 38 M 0 -38 L 28 38 M -34 0 L 34 0" fill="none" stroke="#FF1610" stroke-width="12" stroke-linecap="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</defs>
|
||||
<use href="#ca" transform="translate(200,200) scale(1.2)" opacity="0.03"/>
|
||||
<use href="#ca" transform="translate(600,150) scale(0.8)" opacity="0.05"/>
|
||||
<use href="#ca" transform="translate(1100,220) scale(1.5)" opacity="0.02"/>
|
||||
<use href="#ca" transform="translate(1600,180) scale(1.0)" opacity="0.04"/>
|
||||
<use href="#ca" transform="translate(300,500) scale(0.6)" opacity="0.06"/>
|
||||
<use href="#ca" transform="translate(900,450) scale(1.1)" opacity="0.03"/>
|
||||
<use href="#ca" transform="translate(1400,550) scale(0.7)" opacity="0.05"/>
|
||||
<use href="#ca" transform="translate(1750,480) scale(1.3)" opacity="0.02"/>
|
||||
<use href="#ca" transform="translate(150,800) scale(1.4)" opacity="0.03"/>
|
||||
<use href="#ca" transform="translate(650,850) scale(0.9)" opacity="0.04"/>
|
||||
<use href="#ca" transform="translate(1200,780) scale(0.5)" opacity="0.07"/>
|
||||
<use href="#ca" transform="translate(1700,850) scale(1.0)" opacity="0.03"/>
|
||||
<use href="#ca" transform="translate(960,540) scale(2.5)" opacity="0.015"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
8
apps/web/public/brand/spinner.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 1700 1700">
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<path d="M 850 500 A 350 350 0 0 1 1200 850" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round"/>
|
||||
<path d="M 1200 850 A 350 350 0 0 1 850 1200" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
<path d="M 850 1200 A 350 350 0 0 1 500 850" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
<path d="M 500 850 A 350 350 0 0 1 850 500" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 667 B |
152
apps/web/scripts/control-room-audit.cjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
const { chromium } = require("playwright");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
require("./control-room-register.cjs");
|
||||
const { fixtureChannel } = require("../src/components/control-room/fixtures.ts");
|
||||
const web = path.resolve(__dirname, "..");
|
||||
const origin = "http://audit.local";
|
||||
const workspaceId = "50000000-0000-4000-8000-000000000001";
|
||||
const mime = { ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".html": "text/html", ".rsc": "text/x-component" };
|
||||
const results = [];
|
||||
const cors = { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Headers": "Authorization,Content-Type,Idempotency-Key", "Access-Control-Allow-Methods": "GET,POST,OPTIONS" };
|
||||
const json = (route, body, status = 200) => route.fulfill({ status, headers: cors, json: body });
|
||||
async function auditViewport(browser, viewport) {
|
||||
const context = await browser.newContext({ viewport, isMobile: viewport.width < 1024, hasTouch: viewport.width < 1024 });
|
||||
const page = await context.newPage();
|
||||
const errors = []; page.on("pageerror", error => errors.push(error.message));
|
||||
let channel = fixtureChannel(), mode = "live", seq = 0, socket;
|
||||
const requests = [], keys = new Set();
|
||||
const observation = version => ({ workspaceId, channelId: channel.channelId, workerId: channel.workerId, assignmentEpoch: channel.assignmentEpoch, state: "RUNNING", realizedVersion: version, updatedAt: new Date().toISOString(), reason: "Audit worker" });
|
||||
channel.observed = observation(1);
|
||||
const inject = (kind, data) => socket.send(JSON.stringify({ type: "state", workspaceId, channelId: channel.channelId, seq: ++seq, kind, data }));
|
||||
await context.addInitScript(() => localStorage.setItem("tmbh.session", "audit-token"));
|
||||
await page.routeWebSocket(/\/realtime$/, ws => {
|
||||
socket = ws;
|
||||
ws.onMessage(raw => {
|
||||
const message = JSON.parse(String(raw));
|
||||
if (message.type === "auth") { assert.equal(message.token, "audit-token"); assert.equal(message.workspaceId, workspaceId); ws.send(JSON.stringify({ type: "auth_ack", workspaceId })); }
|
||||
if (message.type === "subscribe") {
|
||||
ws.send(JSON.stringify({ type: "subscribe_ack", channelId: channel.channelId, seq }));
|
||||
inject("desired", channel.desired); inject("observed", channel.observed);
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.route("**/*", async route => {
|
||||
const request = route.request(), url = new URL(request.url());
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors });
|
||||
if (url.pathname === "/auth/me") return json(route, { user: { id: workspaceId, email: "operator@audit.local", displayName: "Operator" }, workspace: { id: workspaceId, name: "Audit studio", slug: "audit" }, role: "owner", permissions: ["director.take", "director.view", "director.lock_program", "director.override_auto", "director.change_layout", "media.view", "stream.view"], expiresAt: Date.now() + 3600000 });
|
||||
if (url.pathname === "/media-control/status") {
|
||||
if (mode === "offline") return route.abort();
|
||||
if (mode === "forbidden") return json(route, { message: "Missing media.view" }, 403);
|
||||
return json(route, { channels: mode === "empty" ? [] : [channel], workers: [] });
|
||||
}
|
||||
const action = /\/media-control\/channels\/[^/]+\/(pst-select|cut|take|lock|override)$/.exec(url.pathname)?.[1];
|
||||
if (action && request.method() === "POST" || url.pathname.startsWith("/production/overlays/") && request.method() === "POST") {
|
||||
const key = request.headers()["idempotency-key"]; assert.match(key, /^[\x20-\x7e]{1,128}$/); assert.match(key, /^[0-9a-f-]{36}$/); assert.ok(!keys.has(key)); keys.add(key);
|
||||
const body = request.postDataJSON(); requests.push({ action: action ?? "overlay", body, key });
|
||||
if (action === "pst-select") channel.desired.production.buses.preview = body.sceneId;
|
||||
if (action === "cut" || action === "take") channel.desired.production.buses.program = channel.desired.production.buses.preview;
|
||||
if (action === "lock") channel.desired.director.locked = body.enabled;
|
||||
if (action === "override") channel.desired.director.override = body.mode ? { mode: body.mode, expiresAt: null } : null;
|
||||
if (!action) channel.desired.production.overlays.find(item => url.pathname.endsWith(item.id)).revisionId = "60000000-0000-4000-8000-000000000001";
|
||||
channel.desired.desiredVersion += 1;
|
||||
await json(route, { desiredVersion: channel.desired.desiredVersion, commandId: key });
|
||||
inject("desired", channel.desired); return;
|
||||
}
|
||||
if (url.pathname.includes("/preview/")) return json(route, { message: "No audit media" }, 404);
|
||||
if (url.hostname !== "audit.local") return route.abort();
|
||||
let file;
|
||||
if (url.pathname.startsWith("/_next/")) file = path.join(web, ".next", url.pathname.slice(7));
|
||||
else if (url.pathname.startsWith("/brand/")) file = path.join(web, "public", url.pathname);
|
||||
else {
|
||||
const name = url.pathname === "/" ? "index" : url.pathname.slice(1);
|
||||
const rsc = request.headers().rsc === "1" || url.searchParams.has("_rsc");
|
||||
file = path.join(web, ".next/server/app", `${name}.${rsc ? "rsc" : "html"}`);
|
||||
}
|
||||
if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" });
|
||||
return route.fulfill({ contentType: mime[path.extname(file)] || "application/octet-stream", body: fs.readFileSync(file) });
|
||||
});
|
||||
const first = channel.desired.production.sources[0].id, second = channel.desired.production.sources[1].id;
|
||||
const sceneB = channel.desired.production.scenes[1].id;
|
||||
const tally = id => page.locator(`[data-source='${id}']`).getAttribute("data-tally");
|
||||
const waitTally = (id, value) => page.waitForFunction(({ id, value }) => document.querySelector(`[data-source='${id}']`)?.getAttribute("data-tally") === value, { id, value });
|
||||
const checkLayout = async stage => {
|
||||
const layout = await page.evaluate(() => {
|
||||
const root = document.querySelector(".control-room");
|
||||
const undersized = Array.from(root.querySelectorAll("button, input, select, textarea, a[href]")).filter(el => { const r = el.getBoundingClientRect(); return r.width && r.height && (r.width < 43.5 || r.height < 43.5); }).map(el => ({ text: el.getAttribute("aria-label") || el.textContent.trim().slice(0, 50), width: el.getBoundingClientRect().width, height: el.getBoundingClientRect().height }));
|
||||
return { width: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, rootWidth: root.clientWidth, rootScrollWidth: root.scrollWidth, undersized, liveDecodes: document.querySelectorAll("[data-live-decode]").length };
|
||||
});
|
||||
assert.ok(layout.scrollWidth <= layout.width, `${stage}: document overflow`);
|
||||
assert.ok(layout.rootScrollWidth <= layout.rootWidth, `${stage}: control room overflow`);
|
||||
assert.deepEqual(layout.undersized, [], `${stage}: tap targets`);
|
||||
assert.ok(layout.liveDecodes <= (viewport.width < 1024 ? 1 : 4));
|
||||
results.push({ viewport, stage, ...layout });
|
||||
};
|
||||
await page.goto(`${origin}/control-room`, { waitUntil: "networkidle" });
|
||||
await waitTally(first, "program"); await checkLayout("initial");
|
||||
await page.getByRole("combobox", { name: "PST scene", exact: true }).selectOption(sceneB);
|
||||
await page.getByRole("status").filter({ hasText: "Request accepted" }).waitFor();
|
||||
assert.equal(await tally(first), "program"); assert.equal(await tally(second), "none");
|
||||
inject("observed", observation(channel.desired.desiredVersion));
|
||||
await waitTally(second, "preview");
|
||||
await page.getByRole("button", { name: "CUT", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector("[role='status']")?.textContent.includes("version 3"));
|
||||
assert.equal(await tally(first), "program"); assert.equal(await tally(second), "preview");
|
||||
const injectedAt = Date.now();
|
||||
inject("observed", observation(3)); await waitTally(second, "program"); assert.equal(await tally(first), "none");
|
||||
const observedToTallyMs = Date.now() - injectedAt;
|
||||
assert.ok(observedToTallyMs < 300, "observed frame to tally must take under 300 ms");
|
||||
await page.getByRole("button", { name: "TAKE", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Lock program", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Unlock program", exact: true }).waitFor();
|
||||
await page.getByRole("combobox", { name: "Operator override" }).selectOption("five_minutes");
|
||||
await page.getByRole("combobox", { name: "Operator override" }).selectOption("auto");
|
||||
for (const template of ["lower_third", "chyron", "logo"]) {
|
||||
const slot = channel.desired.production.overlays.find(item => item.kind === template);
|
||||
await page.getByRole("combobox", { name: "Overlay slot" }).selectOption(slot.id);
|
||||
if (template === "logo") await page.getByRole("textbox", { name: "Logo SVG" }).fill('<svg width="64" height="64"><rect width="64" height="64" fill="#FF1610"/></svg>');
|
||||
else await page.getByRole("textbox", { name: template === "chyron" ? /Chyron text/ : /Title/ }).fill("Audit title");
|
||||
await page.getByRole("button", { name: "Render overlay" }).click();
|
||||
await page.getByRole("status").filter({ hasText: "Render accepted" }).waitFor();
|
||||
assert.equal(requests.at(-1).body.templateId, template);
|
||||
}
|
||||
for (const tier of ["2x1", "2x2", "3x3", "4x4", "custom"]) {
|
||||
await page.getByRole("combobox", { name: "Multiview layout" }).selectOption(tier);
|
||||
await checkLayout(tier);
|
||||
}
|
||||
if (viewport.width >= 1024) {
|
||||
await page.getByRole("combobox", { name: "Multiview layout" }).selectOption("4x4");
|
||||
await page.getByRole("button", { name: "Focus Sample source 1", exact: true }).click();
|
||||
const upgrades = page.getByRole("button", { name: "Upgrade live", exact: true });
|
||||
await upgrades.nth(0).click(); await upgrades.nth(0).click();
|
||||
assert.ok(await upgrades.nth(0).isDisabled(), "5th decode must be refused");
|
||||
}
|
||||
const resyncBefore = requests.length;
|
||||
socket.send(JSON.stringify({ type: "RESYNC_REQUIRED", channelId: channel.channelId }));
|
||||
await page.waitForTimeout(100); assert.equal(requests.length, resyncBefore);
|
||||
mode = "empty"; await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "No production scenes configured" }).waitFor();
|
||||
assert.equal(await page.locator("[data-tally='program']").count(), 0); assert.equal(await page.getByText("FIXTURE · sample data", { exact: true }).count(), 0);
|
||||
await checkLayout("empty");
|
||||
mode = "forbidden"; await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
||||
await page.getByRole("status").filter({ hasText: "Missing media.view" }).waitFor();
|
||||
assert.equal(await page.getByText("FIXTURE · sample data", { exact: true }).count(), 0);
|
||||
mode = "offline"; await page.getByRole("button", { name: "Reconnect", exact: true }).click();
|
||||
await page.getByText("FIXTURE · sample data", { exact: true }).waitFor();
|
||||
assert.equal(await page.locator("[data-tally='program']").count(), 0);
|
||||
const before = requests.length;
|
||||
await page.getByRole("button", { name: "CUT", exact: true }).click();
|
||||
assert.equal(requests.length, before); assert.equal(await page.locator("[data-tally='program']").count(), 0);
|
||||
await checkLayout("offline-fixture");
|
||||
assert.deepEqual(errors, [], "browser runtime errors");
|
||||
results.push({ viewport, flow: "PST→observed preview→CUT→observed program", actions: requests.map(item => item.action), uniqueKeys: keys.size, observedToTallyMs, runtimeErrors: errors });
|
||||
await context.close();
|
||||
}
|
||||
async function run() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try { for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) await auditViewport(browser, viewport); }
|
||||
finally { await browser.close(); }
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
}
|
||||
run().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
11
apps/web/scripts/control-room-register.cjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Existing TypeScript compiler only; node:test remains the test runner. No runtime dependency.
|
||||
const ts = require("typescript");
|
||||
const fs = require("node:fs");
|
||||
function compile(module, filename) {
|
||||
const result = ts.transpileModule(fs.readFileSync(filename, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true }, fileName: filename,
|
||||
});
|
||||
module._compile(result.outputText, filename);
|
||||
}
|
||||
require.extensions[".ts"] = compile;
|
||||
require.extensions[".tsx"] = compile;
|
||||
85
apps/web/scripts/manual-shots.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = "http://localhost:3111";
|
||||
const OUT = "runs/manual/shots";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
|
||||
const page = await ctx.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e).slice(0, 150)));
|
||||
|
||||
async function shot(name) {
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png` });
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
async function safeClick(text, fallback) {
|
||||
try { await page.getByRole("button", { name: text, timeout: 5000 }).click(); }
|
||||
catch { try { await page.getByText(text, { timeout: 3000 }).first().click(); }
|
||||
catch { if (fallback) fallback(); else console.log(` (skip: ${text})`); } }
|
||||
}
|
||||
|
||||
async function safeType(selector, text) {
|
||||
try { const el = page.locator(selector).first(); await el.click({ timeout: 5000 }); await el.fill(text); }
|
||||
catch { console.log(` (skip type: ${selector})`); }
|
||||
}
|
||||
|
||||
// ONBOARDING
|
||||
await page.goto(`${BASE}/onboard`); await page.waitForTimeout(1500);
|
||||
// redirect to /onboarding
|
||||
await page.goto(`${BASE}/onboarding`); await page.waitForTimeout(1500);
|
||||
await shot("onboard-01-start");
|
||||
safeType("input", "Global News Desk");
|
||||
await shot("onboard-02-named");
|
||||
safeClick("Continue");
|
||||
await shot("onboard-03-invite");
|
||||
safeType("textarea", "sarah@globalnews.io, marcus@globalnews.io");
|
||||
await shot("onboard-04-emails-filled");
|
||||
safeClick("Continue");
|
||||
await shot("onboard-05-connect");
|
||||
|
||||
// STREAMS
|
||||
await page.goto(`${BASE}/streams`); await page.waitForTimeout(1000);
|
||||
await shot("streams-01-overview");
|
||||
await page.keyboard.press("j"); await page.keyboard.press("j"); await page.keyboard.press("j");
|
||||
await shot("streams-02-item-selected");
|
||||
await page.keyboard.press("r");
|
||||
await shot("streams-03-repost-toast");
|
||||
await page.keyboard.press("q");
|
||||
await shot("streams-04-quote-popover");
|
||||
try { const ta = page.locator("textarea").first(); if (await ta.isVisible({ timeout: 3000 })) { await ta.fill("This analysis is essential reading for anyone tracking the Fed's next move."); } } catch {}
|
||||
await shot("streams-05-quote-typed");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// COMPOSE
|
||||
await page.goto(`${BASE}/compose`); await page.waitForTimeout(1000);
|
||||
await shot("compose-01-blank");
|
||||
try { const ed = page.locator(".ProseMirror").first(); await ed.click({ timeout: 5000 }); await page.keyboard.type("Breaking: Central bank signals first rate cut of the year as inflation cools to 2.1%. Full analysis at globalnews.io/fed-rates #markets #economy"); } catch { console.log(" (compose editor not found)"); }
|
||||
await shot("compose-02-typed");
|
||||
|
||||
// CONNECTIONS
|
||||
await page.goto(`${BASE}/connections`); await page.waitForTimeout(800);
|
||||
await shot("connections-01-overview");
|
||||
|
||||
// TEAM
|
||||
await page.goto(`${BASE}/team`); await page.waitForTimeout(800);
|
||||
await shot("team-01-overview");
|
||||
|
||||
// INBOX
|
||||
await page.goto(`${BASE}/inbox`); await page.waitForTimeout(800);
|
||||
await shot("inbox-01-overview");
|
||||
|
||||
// AUDIT
|
||||
await page.goto(`${BASE}/audit`); await page.waitForTimeout(800);
|
||||
await shot("audit-01-overview");
|
||||
try { await page.locator("tr").filter({ hasText: "credential" }).first().click({ timeout: 5000 }); } catch {}
|
||||
await shot("audit-02-diff-open");
|
||||
|
||||
await browser.close();
|
||||
console.log(`\nShots: ${OUT}/`);
|
||||
console.log(`Errors: ${errors.length}`);
|
||||
for (const e of errors.slice(0, 5)) console.log(` ${e}`);
|
||||
58
apps/web/scripts/mobile-phase1-audit.cjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const { chromium } = require("playwright");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const origin = "http://audit.local";
|
||||
const mime = { ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".html": "text/html", ".rsc": "text/x-component", ".json": "application/json" };
|
||||
|
||||
const routes = ["/streams", "/compose", "/inbox", "/approvals", "/connections", "/team", "/video", "/audit", "/onboarding", "/analytics", "/calendar", "/settings", "/more", "/search"];
|
||||
|
||||
async function run() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
|
||||
await page.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (url.hostname !== "audit.local") return route.abort();
|
||||
let file;
|
||||
if (url.pathname.startsWith("/_next/")) file = path.join(process.cwd(), ".next", url.pathname.slice(7));
|
||||
else if (url.pathname.startsWith("/brand/")) file = path.join(process.cwd(), "public", url.pathname);
|
||||
else {
|
||||
const name = url.pathname === "/" ? "index" : url.pathname.slice(1);
|
||||
const isRsc = request.headers().rsc === "1" || url.searchParams.has("_rsc");
|
||||
file = path.join(process.cwd(), ".next/server/app", `${name}.${isRsc ? "rsc" : "html"}`);
|
||||
}
|
||||
if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" });
|
||||
return route.fulfill({ status: 200, contentType: mime[path.extname(file)] || "application/octet-stream", body: fs.readFileSync(file) });
|
||||
});
|
||||
const results = [];
|
||||
for (const route of routes) {
|
||||
await page.goto(`${origin}${route}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(200);
|
||||
const result = await page.evaluate(() => {
|
||||
const width = document.documentElement.clientWidth;
|
||||
const all = Array.from(document.querySelectorAll("button, a[href], input, select, textarea, [role='tab']"));
|
||||
const undersized = all.filter((el) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
return rect.width > 0 && rect.height > 0 && rect.left < innerWidth && rect.right > 0 && rect.top < innerHeight && rect.bottom > 0 && style.visibility !== "hidden" && style.display !== "none" && (rect.width < 43.5 || rect.height < 43.5);
|
||||
}).map((el) => ({ tag: el.tagName, text: (el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 40), width: Math.round(el.getBoundingClientRect().width), height: Math.round(el.getBoundingClientRect().height) }));
|
||||
return { title: document.title, width, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, nav: !!document.querySelector("nav[aria-label='Primary navigation']"), undersized };
|
||||
});
|
||||
results.push({ route, ...result });
|
||||
}
|
||||
await page.goto(`${origin}/more`, { waitUntil: "networkidle" });
|
||||
const scroller = page.locator("[data-mobile-main]");
|
||||
await scroller.evaluate((el) => { el.scrollTop = 300; });
|
||||
const before = await scroller.evaluate((el) => el.scrollTop);
|
||||
await page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "Home" }).click();
|
||||
await page.waitForURL(`${origin}/streams`);
|
||||
await page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "More" }).click();
|
||||
await page.waitForURL(`${origin}/more`);
|
||||
await page.waitForTimeout(100);
|
||||
const after = await scroller.evaluate((el) => el.scrollTop);
|
||||
console.log(JSON.stringify({ routes: results, scrollPreservation: { before, after } }, null, 2));
|
||||
await browser.close();
|
||||
if (results.some((r) => r.scrollWidth > r.width || r.bodyScrollWidth > r.width || r.undersized.length > 0 || !r.nav) || before < 1 || Math.abs(before - after) > 1) process.exitCode = 1;
|
||||
}
|
||||
run().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
121
apps/web/scripts/mobile-phase2-audit.cjs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// The repo's Node 18 runtime can run this Chromium audit; Playwright 1.63's
|
||||
// bootstrap refuses it before launch, so bypass only that version gate here.
|
||||
if (Number(process.versions.node.split(".")[0]) < 20) Object.defineProperty(process.versions, "node", { value: "20.0.0" });
|
||||
const { chromium } = require("playwright");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const origin = "http://audit.local";
|
||||
const routes = ["/streams", "/search", "/inbox", "/inbox/1", "/approvals", "/connections", "/compose", "/audit"];
|
||||
const mime = { ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".html": "text/html", ".rsc": "text/x-component", ".json": "application/json" };
|
||||
|
||||
async function serveBuild(context) {
|
||||
await context.route(`${origin}/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
let file;
|
||||
if (url.pathname.startsWith("/_next/")) file = path.join(process.cwd(), ".next", url.pathname.slice(7));
|
||||
else if (url.pathname.startsWith("/brand/")) file = path.join(process.cwd(), "public", url.pathname);
|
||||
else {
|
||||
const name = url.pathname === "/" ? "index" : url.pathname.slice(1);
|
||||
const isRsc = request.headers().rsc === "1" || url.searchParams.has("_rsc");
|
||||
file = path.join(process.cwd(), ".next/server/app", `${name}.${isRsc ? "rsc" : "html"}`);
|
||||
}
|
||||
if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" });
|
||||
return route.fulfill({ status: 200, contentType: mime[path.extname(file)] || "application/octet-stream", body: fs.readFileSync(file) });
|
||||
});
|
||||
}
|
||||
|
||||
async function inspect(page) {
|
||||
return page.evaluate(() => {
|
||||
const width = document.documentElement.clientWidth;
|
||||
const undersized = Array.from(document.querySelectorAll("button, a[href], input, select, textarea, [role='tab']")).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return rect.width > 0 && rect.height > 0 && rect.left < innerWidth && rect.right > 0 && rect.top < innerHeight && rect.bottom > 0 && style.visibility !== "hidden" && style.display !== "none" && style.opacity !== "0" && !element.classList.contains("sr-only") && (rect.width < 43.5 || rect.height < 43.5);
|
||||
}).map((element) => ({ text: (element.getAttribute("aria-label") || element.textContent || "").trim().slice(0, 36), width: Math.round(element.getBoundingClientRect().width), height: Math.round(element.getBoundingClientRect().height) }));
|
||||
return { width, scrollWidth: document.documentElement.scrollWidth, bodyScrollWidth: document.body.scrollWidth, undersized };
|
||||
});
|
||||
}
|
||||
|
||||
async function routeAudit(browser) {
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
|
||||
await serveBuild(context);
|
||||
await context.route("http://localhost:3000/**", (route) => route.abort());
|
||||
const page = await context.newPage();
|
||||
const results = [];
|
||||
for (const route of routes) {
|
||||
const parent = route.startsWith("/inbox/") || route === "/approvals" ? "/inbox" : "/more";
|
||||
await page.goto(`${origin}${parent}`, { waitUntil: "networkidle" });
|
||||
await page.goto(`${origin}${route}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(150);
|
||||
const sizes = await inspect(page);
|
||||
const back = page.getByRole("button", { name: "Go back" });
|
||||
if (await back.count()) await back.click();
|
||||
else await page.goBack({ waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(100);
|
||||
results.push({ route, ...sizes, backResolved: new URL(page.url()).pathname === parent });
|
||||
}
|
||||
await page.goto(`${origin}/streams`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("tab", { name: /Mastodon/ }).click();
|
||||
await page.getByRole("button", { name: /Amplify @/ }).first().click();
|
||||
const mastodonLabels = await page.getByRole("dialog").innerText();
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await page.getByRole("tab", { name: /Telegram/ }).click();
|
||||
await page.getByRole("button", { name: /Amplify @/ }).first().click();
|
||||
const telegramLabels = await page.getByRole("dialog").innerText();
|
||||
await context.close();
|
||||
return { routes: results, capabilityLabels: { mastodonBoost: mastodonLabels.includes("Boost"), mastodonQuote: mastodonLabels.includes("Quote"), telegramForward: telegramLabels.includes("Forward"), telegramNoQuote: !telegramLabels.includes("Quote") } };
|
||||
}
|
||||
|
||||
async function approvalAudit(browser) {
|
||||
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
await serveBuild(context);
|
||||
await context.addInitScript(() => localStorage.setItem("tmbh.session", "audit-token"));
|
||||
const drafts = ["approve", "reject"].map((id) => ({ id, workspaceId: "w", authorUserId: "contributor-1", state: "in_review", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), post: { text: `${id} this draft`, mediaAssetIds: [], visibility: "public", providerMetadata: { targetConnectionIds: [] } } }));
|
||||
const audit = [];
|
||||
const calls = [];
|
||||
await context.route("http://localhost:3000/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const headers = { "access-control-allow-origin": origin, "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "authorization,content-type", "content-type": "application/json" };
|
||||
if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers, body: "" });
|
||||
let body;
|
||||
if (url.pathname === "/auth/me") body = { user: { id: "editor-1", email: "editor@example.test", displayName: "Editor" }, workspace: { id: "w", name: "Test", slug: "test" }, role: "Editor", permissions: ["approval.grant", "draft.view"], expiresAt: Date.now() + 60000 };
|
||||
else if (url.pathname === "/publishing/drafts" && request.method() === "GET") body = drafts.filter((draft) => draft.state === "in_review");
|
||||
else if (url.pathname.match(/^\/publishing\/drafts\/(approve|reject)\/(approve|reject)$/) && request.method() === "POST") {
|
||||
const [, id, decision] = url.pathname.match(/^\/publishing\/drafts\/(approve|reject)\/(approve|reject)$/);
|
||||
const draft = drafts.find((item) => item.id === id);
|
||||
draft.state = decision === "approve" ? "approved" : "rejected";
|
||||
calls.push({ id, decision, path: url.pathname });
|
||||
audit.unshift({ id: `audit-${audit.length}`, seq: String(audit.length + 1), at: new Date().toISOString(), actorUserId: "editor-1", actorEmail: "editor@example.test", actorRole: "Editor", action: `draft.${decision === "approve" ? "approved" : "rejected"}`, targetType: "draft", targetId: id, diff: null });
|
||||
body = draft;
|
||||
} else if (url.pathname === "/audit") body = { entries: audit, nextCursor: null, facets: { actions: [...new Set(audit.map((item) => item.action))], actors: [{ id: "editor-1", email: "editor@example.test" }] } };
|
||||
else if (url.pathname === "/connections") body = [];
|
||||
else body = [];
|
||||
return route.fulfill({ status: 200, headers, body: JSON.stringify(body) });
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/approvals`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "Approve", exact: true }).first().click();
|
||||
await page.getByRole("button", { name: "Reject", exact: true }).first().click();
|
||||
await page.getByRole("button", { name: "Needs edits" }).click();
|
||||
await page.getByRole("button", { name: "Reject with reason" }).click();
|
||||
await page.goto(`${origin}/audit`, { waitUntil: "networkidle" });
|
||||
const text = await page.locator("[data-mobile-main]").innerText();
|
||||
await context.close();
|
||||
return { calls, auditActions: audit.map((item) => item.action), visibleInAudit: text.includes("draft.approved") && text.includes("draft.rejected") };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const routes = await routeAudit(browser);
|
||||
const approvals = await approvalAudit(browser);
|
||||
const result = { ...routes, approvals };
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
if (routes.routes.some((row) => row.scrollWidth > row.width || row.bodyScrollWidth > row.width || row.undersized.length > 0 || !row.backResolved) || Object.values(routes.capabilityLabels).some((value) => !value) || approvals.calls.length !== 2 || !approvals.visibleInAudit) process.exitCode = 1;
|
||||
} finally { await browser?.close(); }
|
||||
}
|
||||
main().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
33
apps/web/scripts/mobile-swipe.test.cjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const Module = require("node:module");
|
||||
const ts = require("typescript");
|
||||
function load(file) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
const loaded = new Module(file, module);
|
||||
loaded._compile(compiled, file);
|
||||
return loaded.exports;
|
||||
}
|
||||
const { swipeIntent, swipeResult } = load("src/lib/gestures.ts");
|
||||
const { nativeAmplifyActions } = load("src/lib/amplify.ts");
|
||||
const { PLATFORMS } = load("src/lib/platforms.ts");
|
||||
|
||||
test("vertical scroll never locks to horizontal reveal", () => {
|
||||
for (const [dx, dy] of [[0, 12], [9, 20], [-20, 60], [15, 50], [-40, 120]]) assert.equal(swipeIntent(dx, dy), "vertical");
|
||||
});
|
||||
test("small motion remains pending and deliberate horizontal motion reveals", () => {
|
||||
assert.equal(swipeIntent(6, 3), "pending");
|
||||
assert.equal(swipeIntent(-40, 10), "horizontal");
|
||||
assert.equal(swipeResult(-50, 0.2, "left"), "closed");
|
||||
assert.equal(swipeResult(-90, 0.2, "left"), "open");
|
||||
assert.equal(swipeResult(-180, 0.2, "left"), "full");
|
||||
});
|
||||
test("native amplify labels follow platform capabilities", () => {
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.mastodon).map((item) => item.label), ["Boost", "Quote"]);
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.telegram).map((item) => item.label), ["Forward"]);
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.facebook).map((item) => item.label), ["Share link"]);
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.instagram), []);
|
||||
assert.deepEqual(nativeAmplifyActions(PLATFORMS.limited), []);
|
||||
});
|
||||
75
apps/web/scripts/visual-qa.mjs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const BASE = process.env.QA_BASE ?? "http://localhost:3111";
|
||||
const OUT = "runs/qa";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
async function shot(name, { fullPage = false } = {}) {
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage });
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
console.log("console errors will be collected at the end");
|
||||
const errors = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") errors.push(msg.text().slice(0, 200));
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(`PAGEERROR: ${String(err).slice(0, 200)}`));
|
||||
|
||||
await page.goto(`${BASE}/streams`);
|
||||
await shot("01-streams");
|
||||
|
||||
await page.keyboard.press("j");
|
||||
await page.keyboard.press("j");
|
||||
await page.keyboard.press("j");
|
||||
await shot("02-streams-selected");
|
||||
|
||||
await page.keyboard.press("r");
|
||||
await shot("03-streams-repost-toast");
|
||||
|
||||
await page.keyboard.press("q");
|
||||
await shot("04-streams-quote-popover");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.keyboard.press("j");
|
||||
await page.keyboard.press("k");
|
||||
await page.keyboard.press("k");
|
||||
await shot("05-streams-keyboard-moved");
|
||||
|
||||
await page.goto(`${BASE}/compose`);
|
||||
await page.locator(".ProseMirror").click();
|
||||
await page.keyboard.type("Shipping the visual QA pass — capability-gated amplify, keyboard-first streams.");
|
||||
await shot("06-compose-typed");
|
||||
|
||||
await page.goto(`${BASE}/connections`);
|
||||
await shot("07-connections");
|
||||
|
||||
await page.goto(`${BASE}/inbox`);
|
||||
await shot("08-inbox");
|
||||
|
||||
await page.goto(`${BASE}/team`);
|
||||
await shot("09-team");
|
||||
|
||||
await page.goto(`${BASE}/audit`);
|
||||
await shot("10-audit");
|
||||
await page.locator("tr", { hasText: "credential.rotated" }).first().click();
|
||||
await shot("11-audit-diff-open");
|
||||
|
||||
await page.goto(`${BASE}/onboarding`);
|
||||
await shot("12-onboarding-step1");
|
||||
await page.getByPlaceholder("Studio, Newsroom, Acme Social…").fill("Studio");
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await shot("13-onboarding-step2");
|
||||
|
||||
await page.goto(`${BASE}/streams`);
|
||||
await page.keyboard.press("Meta+k");
|
||||
await shot("14-command-palette");
|
||||
|
||||
await browser.close();
|
||||
console.log(`\nconsole errors: ${errors.length}`);
|
||||
for (const e of errors.slice(0, 10)) console.log(` - ${e}`);
|
||||
481
apps/web/src/app/(main)/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { Badge, Button, Card } from "../../../components/ui/primitives";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import type { DataSource } from "../../../lib/use-api";
|
||||
|
||||
/**
|
||||
* Analytics wiring: GET /analytics/summary (current vs previous month with
|
||||
* deltas) and GET /analytics/snapshots (month history, keyset-paginated by
|
||||
* cursor). Response types mirror apps/api/src/analytics/snapshot.service.ts.
|
||||
*
|
||||
* Honesty rules (audit-page pattern): no analytics fixtures exist, so the
|
||||
* offline fallback is an empty screen behind the offline badge — never invented
|
||||
* numbers — and on a live API error the data is withheld rather than faked.
|
||||
* The production API may not serve /analytics yet, in which case the page
|
||||
* degrades to the error state with a retry.
|
||||
*/
|
||||
|
||||
interface SnapshotRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
month: string; // YYYY-MM
|
||||
publicationsTotal: number;
|
||||
publicationsByProvider: Record<string, number>;
|
||||
postsArchivedTotal: number;
|
||||
activeConnections: number;
|
||||
draftsCreatedTotal: number;
|
||||
publishingAttemptsTotal: number;
|
||||
publishFailuresTotal: number;
|
||||
snapshotStartedAt: string;
|
||||
snapshotCompletedAt: string;
|
||||
}
|
||||
|
||||
type SnapshotMetrics = Pick<
|
||||
SnapshotRecord,
|
||||
| "publicationsTotal"
|
||||
| "postsArchivedTotal"
|
||||
| "activeConnections"
|
||||
| "draftsCreatedTotal"
|
||||
| "publishingAttemptsTotal"
|
||||
| "publishFailuresTotal"
|
||||
>;
|
||||
|
||||
/** Deltas from GET /analytics/summary (monthDeltas shape); provider deltas stay per-provider. */
|
||||
interface MetricDeltas {
|
||||
publicationsTotal: number;
|
||||
publicationsByProvider: Record<string, number>;
|
||||
postsArchivedTotal: number;
|
||||
activeConnections: number;
|
||||
draftsCreatedTotal: number;
|
||||
publishingAttemptsTotal: number;
|
||||
publishFailuresTotal: number;
|
||||
}
|
||||
|
||||
interface SummaryResponse {
|
||||
currentMonth: string;
|
||||
previousMonth: string;
|
||||
current: SnapshotRecord | null;
|
||||
previous: SnapshotRecord | null;
|
||||
deltas: MetricDeltas | null;
|
||||
}
|
||||
|
||||
interface SnapshotPage {
|
||||
items: SnapshotRecord[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
const identity = <T,>(value: T): T => value;
|
||||
const PAGE_SIZE = 12; // server default; hard cap is 24
|
||||
|
||||
interface MetricDef {
|
||||
key: keyof SnapshotMetrics;
|
||||
label: string;
|
||||
/** Fewer failures is good, so invert the up/down coloring for that metric. */
|
||||
invert?: boolean;
|
||||
}
|
||||
|
||||
const METRICS: MetricDef[] = [
|
||||
{ key: "publicationsTotal", label: "Publications" },
|
||||
{ key: "postsArchivedTotal", label: "Posts archived" },
|
||||
{ key: "activeConnections", label: "Active channels" },
|
||||
{ key: "draftsCreatedTotal", label: "Drafts created" },
|
||||
{ key: "publishingAttemptsTotal", label: "Publish attempts" },
|
||||
{ key: "publishFailuresTotal", label: "Publish failures", invert: true },
|
||||
];
|
||||
|
||||
function monthLabel(month: string): string {
|
||||
return new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
function numberDelta(deltas: MetricDeltas | null, key: keyof SnapshotMetrics): number | null {
|
||||
return deltas === null ? null : deltas[key];
|
||||
}
|
||||
|
||||
function providerDeltas(deltas: MetricDeltas | null): Record<string, number> | null {
|
||||
return deltas === null ? null : deltas.publicationsByProvider;
|
||||
}
|
||||
|
||||
function Delta({
|
||||
value,
|
||||
previousMonth,
|
||||
invert,
|
||||
}: {
|
||||
value: number;
|
||||
previousMonth: string;
|
||||
invert?: boolean;
|
||||
}): React.ReactElement {
|
||||
if (value === 0) {
|
||||
return (
|
||||
<span className="text-2xs text-text-faint" title={`No change vs ${monthLabel(previousMonth)}`}>
|
||||
—
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const up = value > 0;
|
||||
const good = invert ? !up : up;
|
||||
return (
|
||||
<span
|
||||
title={`${up ? "+" : "−"}${Math.abs(value).toLocaleString()} vs ${monthLabel(previousMonth)}`}
|
||||
className={`text-2xs font-medium tabular-nums ${good ? "text-live" : "text-danger"}`}
|
||||
>
|
||||
{up ? "▲" : "▼"} {Math.abs(value).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
metric,
|
||||
value,
|
||||
delta,
|
||||
previousMonth,
|
||||
}: {
|
||||
metric: MetricDef;
|
||||
value: number;
|
||||
delta: number | null;
|
||||
previousMonth: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div className="text-2xs uppercase tracking-wide text-text-faint">{metric.label}</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums">{value.toLocaleString()}</span>
|
||||
{delta !== null && <Delta value={delta} previousMonth={previousMonth} invert={metric.invert} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Provider breakdown chips with a relative bar; optional per-provider deltas. */
|
||||
function ProviderBreakdown({
|
||||
byProvider,
|
||||
deltas,
|
||||
previousMonth,
|
||||
}: {
|
||||
byProvider: Record<string, number>;
|
||||
deltas: Record<string, number> | null;
|
||||
previousMonth: string;
|
||||
}): React.ReactElement {
|
||||
const entries = Object.entries(byProvider).sort((a, b) => b[1] - a[1]);
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-2xs text-text-faint">No publications by any provider.</p>;
|
||||
}
|
||||
const max = Math.max(...entries.map(([, n]) => n), 1);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{entries.map(([provider, count]) => {
|
||||
const delta = deltas === null ? undefined : deltas[provider];
|
||||
return (
|
||||
<span
|
||||
key={provider}
|
||||
className="inline-flex min-w-[7.5rem] flex-col gap-1 rounded-md border border-border-subtle bg-surface-2 px-2 py-1.5"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-3 text-2xs">
|
||||
<span className="font-medium text-text-dim">{provider}</span>
|
||||
<span className="tabular-nums">{count.toLocaleString()}</span>
|
||||
</span>
|
||||
<span
|
||||
className="h-1 rounded-full bg-accent"
|
||||
style={{ width: `${Math.max(8, Math.round((count / max) * 100))}%` }}
|
||||
/>
|
||||
{delta !== undefined && <Delta value={delta} previousMonth={previousMonth} />}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page(): React.ReactElement {
|
||||
const { status, can } = useAuth();
|
||||
const live = status === "authenticated";
|
||||
// Offline screens render sample data (house pattern); permission checks only
|
||||
// bite against a real session — can() is false for everyone while offline.
|
||||
const allowed = !live || can("analytics.view");
|
||||
|
||||
// Summary panel: current month vs previous month, via the house hook.
|
||||
const summary = useApiResource<SummaryResponse, SummaryResponse | null>("/analytics/summary", identity, null);
|
||||
|
||||
// Snapshot history: audit-page-style manual fetch because keyset "load older"
|
||||
// appends pages instead of replacing the view.
|
||||
const [historySource, setHistorySource] = useState<DataSource>("loading");
|
||||
const [rows, setRows] = useState<SnapshotRecord[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
||||
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading" || !allowed) return;
|
||||
if (status !== "authenticated") {
|
||||
setHistorySource("sample");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setHistorySource("loading");
|
||||
api
|
||||
.get<SnapshotPage>("/analytics/snapshots", { limit: PAGE_SIZE }, controller.signal)
|
||||
.then((page) => {
|
||||
setRows(page.items);
|
||||
setCursor(page.nextCursor);
|
||||
setHistorySource("live");
|
||||
setHistoryError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof ApiUnreachableError) setHistorySource("sample");
|
||||
else {
|
||||
setHistorySource("live");
|
||||
setHistoryError(errorMessage(err));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [status, allowed, nonce]);
|
||||
|
||||
async function loadOlder(): Promise<void> {
|
||||
if (cursor === null || historySource !== "live") return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await api.get<SnapshotPage>("/analytics/snapshots", { cursor, limit: PAGE_SIZE });
|
||||
setRows((current) => [...current, ...page.items]);
|
||||
setCursor(page.nextCursor);
|
||||
} catch (err) {
|
||||
setHistoryError(errorMessage(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Manual trigger for the current month (POST /analytics/snapshot). */
|
||||
async function generateSnapshot(): Promise<void> {
|
||||
setGenerating(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const record = await api.post<SnapshotRecord>("/analytics/snapshot", {});
|
||||
setMessage({ tone: "ok", text: `Snapshot generated for ${monthLabel(record.month)}.` });
|
||||
reload();
|
||||
} catch (err) {
|
||||
setMessage({ tone: "error", text: errorMessage(err) });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<EmptyState
|
||||
title="No access"
|
||||
hint="Analytics needs the analytics.view permission, which your role does not have. Ask an Owner or Admin for access."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sampleMode = summary.source === "sample" || historySource === "sample";
|
||||
const view = summary.data;
|
||||
const canGenerate = live && historySource !== "loading";
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 scrollbar-thin">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Analytics</h1>
|
||||
<OfflineBadge source={sampleMode ? "sample" : historySource} />
|
||||
</div>
|
||||
<p className="mt-1 text-2xs text-text-faint">
|
||||
Monthly rollups of publishing activity · deltas compare the current month with the previous one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary: current month + previous-month deltas */}
|
||||
{summary.source === "loading" ? (
|
||||
<Card className="mb-6 flex items-center gap-2 p-4 text-xs text-text-faint">
|
||||
<Loading size={14} label="Loading summary" /> Loading summary…
|
||||
</Card>
|
||||
) : summary.error !== null ? (
|
||||
<Card className="mb-6 p-4">
|
||||
<p className="text-xs text-danger">Could not load the monthly summary — {summary.error}</p>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={reload}>
|
||||
Retry
|
||||
</Button>
|
||||
</Card>
|
||||
) : view !== null ? (
|
||||
<Card className="mb-6 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold">{monthLabel(view.currentMonth)}</h2>
|
||||
<Badge tone="neutral">vs {monthLabel(view.previousMonth)}</Badge>
|
||||
</div>
|
||||
{view.current === null ? (
|
||||
<div className="rounded-md border border-border-subtle bg-surface-2 p-3 text-xs text-text-faint">
|
||||
No snapshot exists for {monthLabel(view.currentMonth)} yet — it is generated on schedule, or
|
||||
immediately via <code className="text-accent">POST /analytics/snapshot</code>.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
{METRICS.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.key}
|
||||
metric={metric}
|
||||
value={view.current![metric.key]}
|
||||
delta={numberDelta(view.deltas, metric.key)}
|
||||
previousMonth={view.previousMonth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-text-faint">
|
||||
Publications by provider
|
||||
</h3>
|
||||
<ProviderBreakdown
|
||||
byProvider={view.current.publicationsByProvider}
|
||||
deltas={providerDeltas(view.deltas)}
|
||||
previousMonth={view.previousMonth}
|
||||
/>
|
||||
</div>
|
||||
{view.previous === null && (
|
||||
<p className="mt-3 text-2xs text-text-faint">
|
||||
No snapshot for {monthLabel(view.previousMonth)} — deltas appear once the previous month has one.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* Snapshot history */}
|
||||
<Card className="p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">Snapshot history</h2>
|
||||
{rows.length > 0 && <span className="text-2xs text-text-faint">{rows.length} months</span>}
|
||||
</div>
|
||||
|
||||
{message !== null && (
|
||||
<p className={`mb-3 text-2xs ${message.tone === "ok" ? "text-live" : "text-danger"}`}>{message.text}</p>
|
||||
)}
|
||||
|
||||
{historySource === "loading" ? (
|
||||
<div className="flex items-center gap-2 py-3 text-xs text-text-faint">
|
||||
<Loading size={14} label="Loading snapshots" /> Loading snapshots…
|
||||
</div>
|
||||
) : historyError !== null && rows.length === 0 ? (
|
||||
<div className="py-2">
|
||||
<p className="text-xs text-danger">Failed to load analytics snapshots — {historyError}</p>
|
||||
<Button variant="outline" size="sm" className="mt-2" onClick={reload}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<>
|
||||
<EmptyState
|
||||
title={sampleMode ? "No analytics data while offline" : "No analytics snapshots yet"}
|
||||
hint={
|
||||
sampleMode
|
||||
? "The API is unreachable, so there is no snapshot data to show. Analytics is live-API only — reconnect to see real numbers."
|
||||
: "Snapshots roll up one row per month. They are generated on schedule, or immediately via POST /analytics/snapshot."
|
||||
}
|
||||
/>
|
||||
{!sampleMode && historyError === null && (
|
||||
<div className="flex justify-center pb-2">
|
||||
<Button variant="outline" size="sm" disabled={!canGenerate || generating} onClick={() => void generateSnapshot()}>
|
||||
{generating ? "Generating…" : "Generate current-month snapshot"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{historyError !== null && (
|
||||
<p className="mb-3 text-xs text-danger">Could not load older months — {historyError}</p>
|
||||
)}
|
||||
<div className="rounded-lg border border-border-subtle">
|
||||
<table className="mobile-stack-table w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-left text-2xs text-text-faint">
|
||||
<th className="px-4 py-2 font-medium">Month</th>
|
||||
<th className="px-4 py-2 font-medium">Publications</th>
|
||||
<th className="px-4 py-2 font-medium">Archived</th>
|
||||
<th className="px-4 py-2 font-medium">Drafts</th>
|
||||
<th className="px-4 py-2 font-medium">Attempts</th>
|
||||
<th className="px-4 py-2 font-medium">Failures</th>
|
||||
<th className="px-4 py-2 font-medium">Channels</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((record) => (
|
||||
<Fragment key={record.id}>
|
||||
<tr
|
||||
className="cursor-pointer border-b border-border-subtle transition-colors duration-150 hover:bg-surface-2"
|
||||
onClick={() => setExpanded(expanded === record.id ? null : record.id)}
|
||||
>
|
||||
<td className="px-4 py-2.5 text-xs font-medium">{monthLabel(record.month)}</td>
|
||||
<td className="px-4 py-2.5 text-xs tabular-nums">{record.publicationsTotal.toLocaleString()}</td>
|
||||
<td className="px-4 py-2.5 text-xs tabular-nums">{record.postsArchivedTotal.toLocaleString()}</td>
|
||||
<td className="px-4 py-2.5 text-xs tabular-nums">{record.draftsCreatedTotal.toLocaleString()}</td>
|
||||
<td className="px-4 py-2.5 text-xs tabular-nums">{record.publishingAttemptsTotal.toLocaleString()}</td>
|
||||
<td className="px-4 py-2.5 text-xs tabular-nums">
|
||||
<span className={record.publishFailuresTotal > 0 ? "text-danger" : undefined}>
|
||||
{record.publishFailuresTotal.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs tabular-nums">{record.activeConnections.toLocaleString()}</td>
|
||||
<td className="px-4 py-2.5 text-right text-2xs text-text-faint">{expanded === record.id ? "▾" : "▸"}</td>
|
||||
</tr>
|
||||
{expanded === record.id && (
|
||||
<tr className="border-b border-border-subtle bg-bg">
|
||||
<td colSpan={8} className="px-4 py-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{METRICS.map((metric) => (
|
||||
<div key={metric.key}>
|
||||
<div className="text-2xs text-text-faint">{metric.label}</div>
|
||||
<div className="text-sm tabular-nums">{record[metric.key].toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<ProviderBreakdown byProvider={record.publicationsByProvider} deltas={null} previousMonth={record.month} />
|
||||
</div>
|
||||
<p className="mt-3 text-2xs text-text-faint">
|
||||
Generated {new Date(record.snapshotCompletedAt).toLocaleString()}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{historySource === "live" && cursor !== null && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadOlder()}>
|
||||
{loadingMore ? "Loading…" : "Load older months"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<p className="mt-3 text-2xs text-text-faint">
|
||||
Snapshots are recomputed per (workspace, month) from source tables — re-running a month overwrites it in place.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/(main)/approvals/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import InboxPage from "../inbox/page";
|
||||
|
||||
export default function ApprovalsPage(): React.ReactElement {
|
||||
return <InboxPage defaultSegment="approvals" />;
|
||||
}
|
||||
139
apps/web/src/app/(main)/archive/page.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { isBotConnection } from "../../../lib/connections-client";
|
||||
import { Badge, Button, Card } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import type { ConnectionSummary, DraftRecord, DraftState } from "../../../lib/api-types";
|
||||
|
||||
interface MediaRef { fileId: string; status: string; thumbUrl: string | null; contentType: string }
|
||||
interface ArchiveItem {
|
||||
id: string; remoteRef: string; text: string; media: MediaRef[];
|
||||
attribution: { channelTitle?: string | null; channelUsername?: string | null; authorSignature?: string | null; forwardOrigin?: Record<string, unknown> | null };
|
||||
protectedContent: boolean; postedAt: string; revision: number;
|
||||
}
|
||||
interface Page { items: ArchiveItem[]; nextCursor: string | null }
|
||||
const SAMPLE: Page = { items: [{
|
||||
id: "sample", remoteRef: "-100123456789:42", text: "A new channel post appears here for review.",
|
||||
media: [], attribution: { channelTitle: "Example channel", authorSignature: "Editor" },
|
||||
protectedContent: false, postedAt: "2026-10-07T12:00:00.000Z", revision: 1,
|
||||
}], nextCursor: null };
|
||||
const identity = (page: Page): Page => page;
|
||||
const noConnections: ConnectionSummary[] = [];
|
||||
const same = (value: ConnectionSummary[]): ConnectionSummary[] => value;
|
||||
|
||||
export default function ArchivePage(): React.ReactElement {
|
||||
const { can } = useAuth();
|
||||
const { data, source, error, reload } = useApiResource<Page, Page>("/archive/items", identity, SAMPLE);
|
||||
const { data: connections } = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", same, noConnections);
|
||||
const [more, setMore] = useState<ArchiveItem[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [draftStates, setDraftStates] = useState<Record<string, DraftState>>({});
|
||||
const [connectionId, setConnectionId] = useState("");
|
||||
const [attribution, setAttribution] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const telegramConnections = connections.filter(isBotConnection);
|
||||
const nextCursor = cursor ?? data.nextCursor;
|
||||
const items = [...data.items, ...more];
|
||||
|
||||
async function createDraft(item: ArchiveItem): Promise<void> {
|
||||
setBusy(item.id); setActionError(null);
|
||||
try {
|
||||
const draft = await api.post<DraftRecord>(`/archive/items/${item.id}/drafts`, {});
|
||||
setDrafts((current) => ({ ...current, [item.id]: draft.id }));
|
||||
setDraftStates((current) => ({ ...current, [item.id]: draft.state }));
|
||||
setNotice("Draft created. Submit it for review, then approve it before amplifying.");
|
||||
} catch (err) { setActionError(errorMessage(err)); }
|
||||
finally { setBusy(null); }
|
||||
}
|
||||
async function submitDraft(item: ArchiveItem): Promise<void> {
|
||||
const draftId = drafts[item.id];
|
||||
if (!draftId) return;
|
||||
setBusy(item.id); setActionError(null);
|
||||
try {
|
||||
const draft = await api.post<DraftRecord>(`/publishing/drafts/${draftId}/submit`);
|
||||
setDraftStates((current) => ({ ...current, [item.id]: draft.state }));
|
||||
setNotice("Submitted for review.");
|
||||
} catch (err) { setActionError(errorMessage(err)); }
|
||||
finally { setBusy(null); }
|
||||
}
|
||||
async function approveDraft(item: ArchiveItem): Promise<void> {
|
||||
const draftId = drafts[item.id];
|
||||
if (!draftId) return;
|
||||
setBusy(item.id); setActionError(null);
|
||||
try {
|
||||
const draft = await api.post<DraftRecord>(`/publishing/drafts/${draftId}/approve`);
|
||||
setDraftStates((current) => ({ ...current, [item.id]: draft.state }));
|
||||
setNotice("Draft approved — it can now be amplified.");
|
||||
} catch (err) { setActionError(errorMessage(err)); }
|
||||
finally { setBusy(null); }
|
||||
}
|
||||
async function amplify(item: ArchiveItem): Promise<void> {
|
||||
const draftId = drafts[item.id];
|
||||
if (!draftId || !connectionId) return;
|
||||
setBusy(item.id); setActionError(null);
|
||||
try {
|
||||
await api.post(`/archive/items/${item.id}/amplify`, { draftId, connectionId, attribution });
|
||||
setNotice("Amplified to Telegram.");
|
||||
} catch (err) { setActionError(errorMessage(err)); }
|
||||
finally { setBusy(null); }
|
||||
}
|
||||
async function loadMore(): Promise<void> {
|
||||
if (!nextCursor) return;
|
||||
setBusy("more");
|
||||
try {
|
||||
const page = await api.get<Page>("/archive/items", { cursor: nextCursor });
|
||||
setMore((current) => [...current, ...page.items]); setCursor(page.nextCursor);
|
||||
} catch (err) { setActionError(errorMessage(err)); }
|
||||
finally { setBusy(null); }
|
||||
}
|
||||
|
||||
return <div className="h-full overflow-y-auto p-4 pb-24 lg:p-6">
|
||||
<div className="mb-5 flex items-center gap-3"><h1 className="text-lg font-semibold">Archive</h1><OfflineBadge source={source} /></div>
|
||||
<p className="mb-4 text-sm text-text-dim">Posts from channels where your Telegram bot is an admin arrive here. Check that you have the right to reuse someone else's work before sharing it.</p>
|
||||
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
|
||||
{actionError && <p role="alert" className="mb-3 text-sm text-danger">{actionError}</p>}
|
||||
{notice && <p role="status" className="mb-3 text-sm text-live">{notice}</p>}
|
||||
{items.length === 0 && <EmptyState title="No archived posts yet" hint="New channel posts appear after your bot joins a subscribed channel." />}
|
||||
<div className="space-y-3">{items.map((item) => <Card key={item.id} className="p-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-text-faint">
|
||||
<strong className="text-text">{item.attribution.channelTitle ?? item.attribution.channelUsername ?? item.remoteRef}</strong>
|
||||
{item.attribution.authorSignature && <span>by {item.attribution.authorSignature}</span>}
|
||||
<time dateTime={item.postedAt}>{new Date(item.postedAt).toLocaleString()}</time>
|
||||
{item.revision > 1 && <Badge>edited</Badge>}
|
||||
{item.protectedContent && <Badge tone="warn">amplify unavailable</Badge>}
|
||||
</div>
|
||||
{item.text && <p className="mb-3 whitespace-pre-wrap text-sm">{item.text}</p>}
|
||||
{item.attribution.forwardOrigin && <p className="mb-2 text-xs text-text-faint">Forwarded origin retained in attribution.</p>}
|
||||
{item.media.length > 0 && <div className="mb-3 flex flex-wrap gap-2">{item.media.map((media: MediaRef) => typeof media.thumbUrl === "string" && media.contentType.startsWith("image/")
|
||||
? <img key={media.fileId} src={media.thumbUrl} alt="Archived Telegram media" className="h-24 w-24 rounded object-cover" />
|
||||
: <span key={media.fileId} className="rounded border border-border p-2 text-xs">{media.status}</span>)}</div>}
|
||||
{source === "live" && <div className="flex flex-wrap items-center gap-2">
|
||||
{can("draft.create") && <Button size="sm" onClick={() => void createDraft(item)} disabled={busy === item.id}>Create draft</Button>}
|
||||
{drafts[item.id] && <>
|
||||
<span className="text-xs text-text-faint">Draft {drafts[item.id]?.slice(0, 8)} · state: {draftStates[item.id] ?? "editing"}</span>
|
||||
{(draftStates[item.id] === undefined || draftStates[item.id] === "editing" || draftStates[item.id] === "rejected") && (
|
||||
<Button size="sm" variant="outline" disabled={busy === item.id} onClick={() => void submitDraft(item)}>Submit</Button>
|
||||
)}
|
||||
{draftStates[item.id] === "in_review" && can("approval.grant") && (
|
||||
<Button size="sm" variant="outline" disabled={busy === item.id} onClick={() => void approveDraft(item)}>Approve</Button>
|
||||
)}
|
||||
<select aria-label="Telegram target" value={connectionId} onChange={(event) => setConnectionId(event.target.value)} className="rounded border border-border bg-surface p-2 text-xs">
|
||||
<option value="">Choose target</option>{telegramConnections.map((c) => <option key={c.id} value={c.id}>{c.channelId}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-1 text-xs"><input type="checkbox" checked={attribution} onChange={(event) => setAttribution(event.target.checked)} /> Include attribution</label>
|
||||
{can("publication.schedule") && <Button size="sm" disabled={item.protectedContent || !connectionId || busy === item.id || draftStates[item.id] !== "approved"} title={draftStates[item.id] !== "approved" ? "Submit and approve the draft first" : undefined} onClick={() => void amplify(item)}>Amplify approved draft</Button>}
|
||||
</>}
|
||||
</div>}
|
||||
</Card>)}</div>
|
||||
{nextCursor && source === "live" && <Button className="mt-4" disabled={busy === "more"} onClick={() => void loadMore()}>Load more</Button>}
|
||||
{source === "live" && <button type="button" onClick={() => { setMore([]); setCursor(null); reload(); }} className="mt-4 block text-xs text-text-faint underline">Refresh archive</button>}
|
||||
</div>;
|
||||
}
|
||||
267
apps/web/src/app/(main)/audit/page.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { Badge, Button } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { AUDIT_ENTRIES, ageLabel } from "../../../lib/fixtures";
|
||||
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
|
||||
import type { AuditEntryDto, AuditPage as AuditPageDto } from "../../../lib/api-types";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import type { DataSource } from "../../../lib/use-api";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
|
||||
type DiffValue = string | number | boolean | null | unknown[] | Record<string, unknown>;
|
||||
|
||||
function DiffCell({ label, value }: { label: string; value: DiffValue }): React.ReactElement {
|
||||
return (
|
||||
<div className="grid grid-cols-[110px_1fr] gap-2 text-xs">
|
||||
<span className="text-text-faint">{label}</span>
|
||||
<span className="font-mono text-text-dim">{JSON.stringify(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffViewer({ diff }: { diff: Record<string, unknown> }): React.ReactElement {
|
||||
return (
|
||||
<div className="space-y-1.5 rounded-md border border-border-subtle bg-surface-2 p-3">
|
||||
{Object.entries(diff).map(([key, value]) => {
|
||||
if (Array.isArray(value) && value.length === 2 && typeof value[0] !== "object") {
|
||||
return (
|
||||
<div key={key} className="grid grid-cols-[110px_1fr] gap-2 text-xs">
|
||||
<span className="text-text-faint">{key}</span>
|
||||
<span className="font-mono">
|
||||
<span className="text-removed line-through">{JSON.stringify(value[0])}</span>
|
||||
<span className="mx-1 text-text-faint">→</span>
|
||||
<span className="text-added">{JSON.stringify(value[1])}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DiffCell key={key} label={key} value={value as DiffValue} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
actor: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
target: string;
|
||||
minutes: number;
|
||||
diff: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const ALL = "all";
|
||||
|
||||
function fromApi(e: AuditEntryDto): AuditRow {
|
||||
return {
|
||||
id: e.id,
|
||||
actor: e.actorEmail ?? (e.actorUserId === null ? "system" : e.actorUserId),
|
||||
action: e.action,
|
||||
targetType: e.targetType,
|
||||
target: e.targetId,
|
||||
minutes: Math.max(0, Math.floor((Date.now() - Date.parse(e.at)) / 60_000)),
|
||||
diff: e.diff,
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_ROWS: AuditRow[] = AUDIT_ENTRIES.map((e) => ({ ...e, id: String(e.id) }));
|
||||
|
||||
function csvCell(value: string | number | null): string {
|
||||
const text = value === null ? "" : String(value);
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
function toCsv(rows: AuditRow[]): string {
|
||||
const header = ["id", "actor", "action", "target_type", "target", "minutes_ago", "diff"];
|
||||
const lines = rows.map((e) =>
|
||||
[e.id, e.actor, e.action, e.targetType, e.target, e.minutes, e.diff === null ? null : JSON.stringify(e.diff)].map(csvCell).join(","));
|
||||
return [header.join(","), ...lines].join("\n");
|
||||
}
|
||||
|
||||
function downloadCsv(rows: AuditRow[]): void {
|
||||
const blob = new Blob([toCsv(rows)], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `audit-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function AuditPage(): React.ReactElement {
|
||||
const { status } = useAuth();
|
||||
const [actor, setActor] = useState(ALL);
|
||||
const [action, setAction] = useState(ALL);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<DataSource>("loading");
|
||||
const [rows, setRows] = useState<AuditRow[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [actorOptions, setActorOptions] = useState<FilterOption[]>([]);
|
||||
const [actionOptions, setActionOptions] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// Live: filters are applied server-side and pages are fetched by seq cursor.
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (status !== "authenticated") {
|
||||
setSource("sample");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
api
|
||||
.get<AuditPageDto>("/audit", { action: action === ALL ? undefined : action, actor: actor === ALL ? undefined : actor, limit: PAGE_SIZE }, controller.signal)
|
||||
.then((page) => {
|
||||
setRows(page.entries.map(fromApi));
|
||||
setCursor(page.nextCursor);
|
||||
setActionOptions(page.facets.actions);
|
||||
setActorOptions(page.facets.actors.map((a) => ({ value: a.id ?? "system", label: a.email ?? (a.id === null ? "system" : a.id) })));
|
||||
setSource("live");
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof ApiUnreachableError) setSource("sample");
|
||||
else {
|
||||
setSource("live");
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [status, action, actor]);
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (cursor === null) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await api.get<AuditPageDto>("/audit", {
|
||||
action: action === ALL ? undefined : action,
|
||||
actor: actor === ALL ? undefined : actor,
|
||||
before: cursor,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
setRows((current) => [...current, ...page.entries.map(fromApi)]);
|
||||
setCursor(page.nextCursor);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
const live = source === "live";
|
||||
const actors: FilterOption[] = live
|
||||
? actorOptions
|
||||
: [...new Set(SAMPLE_ROWS.map((e) => e.actor))].map((a) => ({ value: a, label: a }));
|
||||
const actions = live ? actionOptions : [...new Set(SAMPLE_ROWS.map((e) => e.action))];
|
||||
const visible = live
|
||||
? rows
|
||||
: SAMPLE_ROWS.filter((e) => (actor === ALL || e.actor === actor) && (action === ALL || e.action === action));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 scrollbar-thin">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Audit trail</h1>
|
||||
<OfflineBadge source={source} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={source === "loading" || visible.length === 0}
|
||||
onClick={() => downloadCsv(visible)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs text-text-dim transition-colors duration-150 hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-dim"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<select value={actor} onChange={(e) => setActor(e.target.value)} className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs">
|
||||
<option value={ALL}>All actors</option>
|
||||
{actors.map((a) => (
|
||||
<option key={a.value} value={a.value}>{a.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={action} onChange={(e) => setAction(e.target.value)} className="rounded-md border border-border bg-surface-2 px-2 py-1.5 text-xs">
|
||||
<option value={ALL}>All actions</option>
|
||||
{actions.map((a) => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-auto self-center text-2xs text-text-faint">hash-chained · tamper-evident</span>
|
||||
</div>
|
||||
|
||||
{error !== null && <p className="mb-3 text-xs text-danger">{error}</p>}
|
||||
|
||||
<div className="rounded-lg border border-border-subtle">
|
||||
<table className="mobile-stack-table w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-left text-2xs text-text-faint">
|
||||
<th className="px-4 py-2 font-medium">Actor</th>
|
||||
<th className="px-4 py-2 font-medium">Action</th>
|
||||
<th className="px-4 py-2 font-medium">Target</th>
|
||||
<th className="px-4 py-2 font-medium">When</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{source === "loading" && (
|
||||
<tr><td colSpan={5} className="px-4 py-3"><span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading audit trail" /> Loading audit trail…</span></td></tr>
|
||||
)}
|
||||
{source !== "loading" && visible.length === 0 && (
|
||||
error !== null
|
||||
? <tr><td colSpan={5} className="px-4 py-3 text-xs text-danger">Failed to load audit entries — {error}</td></tr>
|
||||
: <tr><td colSpan={5} className="px-4 py-3 text-xs text-text-faint">No audit entries match.</td></tr>
|
||||
)}
|
||||
{source !== "loading" && visible.map((e) => (
|
||||
<Fragment key={e.id}>
|
||||
<tr
|
||||
className="cursor-pointer border-b border-border-subtle transition-colors duration-150 hover:bg-surface-2"
|
||||
onClick={() => setExpanded(expanded === e.id ? null : e.id)}
|
||||
>
|
||||
<td className="px-4 py-2.5 text-xs">{e.actor}</td>
|
||||
<td className="px-4 py-2.5"><code className="text-xs text-accent">{e.action}</code></td>
|
||||
<td className="px-4 py-2.5 text-xs text-text-dim">{e.targetType}:{e.target}</td>
|
||||
<td className="px-4 py-2.5 text-2xs text-text-faint">{ageLabel(e.minutes)} ago</td>
|
||||
<td className="px-4 py-2.5 text-right text-2xs text-text-faint">{expanded === e.id ? "▾" : "▸"}</td>
|
||||
</tr>
|
||||
{expanded === e.id && (
|
||||
<tr className="border-b border-border-subtle bg-bg">
|
||||
<td colSpan={5} className="px-4 py-3">
|
||||
{e.diff === null ? (
|
||||
<span className="text-xs text-text-faint">no payload</span>
|
||||
) : (
|
||||
<DiffViewer diff={e.diff} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{live && cursor !== null && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadMore()}>
|
||||
{loadingMore ? "Loading…" : "Load older entries"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-2xs text-text-faint">
|
||||
Secret-shaped keys render as <Badge tone="danger">[REDACTED]</Badge> by construction — they never enter the chain.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
apps/web/src/app/(main)/calendar/page.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Badge, Button } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { LoadingPanel } from "../../../components/brand/loading";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import { PLATFORMS } from "../../../lib/platforms";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import type { ConnectionSummary, DraftRecord } from "../../../lib/api-types";
|
||||
|
||||
/**
|
||||
* Calendar = the scheduled slice of the publishing pipeline. There is no
|
||||
* dedicated schedule-list endpoint, so this reads the same draft list the
|
||||
* approvals queue uses (`GET /publishing/drafts?state=scheduled`) and joins
|
||||
* each draft to its target connection for a readable label.
|
||||
*/
|
||||
|
||||
interface ScheduledRow {
|
||||
id: string;
|
||||
text: string;
|
||||
/** ISO — the queue's `post.scheduledAt` when recorded, else the draft's last update. */
|
||||
publishAt: string;
|
||||
fromScheduledAt: boolean;
|
||||
targetLabel: string;
|
||||
}
|
||||
|
||||
interface DayGroup {
|
||||
key: number;
|
||||
label: string;
|
||||
rows: ScheduledRow[];
|
||||
}
|
||||
|
||||
type Notice = { tone: "ok" | "error"; text: string };
|
||||
|
||||
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
|
||||
const same = (connections: ConnectionSummary[]): ConnectionSummary[] => connections;
|
||||
const noConnections: ConnectionSummary[] = [];
|
||||
|
||||
/** Offline / unauthenticated fallback — one future-dated row so the layout is visible. */
|
||||
const SAMPLE: DraftRecord[] = [
|
||||
{
|
||||
id: "sample-scheduled",
|
||||
workspaceId: "sample",
|
||||
authorUserId: "sample-author",
|
||||
post: {
|
||||
text: "Sample scheduled post — the weekly roundup goes out to every connected channel.",
|
||||
mediaAssetIds: [],
|
||||
visibility: "public",
|
||||
providerMetadata: { connectionId: "sample-connection" },
|
||||
scheduledAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
},
|
||||
state: "scheduled",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
function connectionIdOf(draft: DraftRecord): string | null {
|
||||
const id = draft.post.providerMetadata.connectionId;
|
||||
return typeof id === "string" && id !== "" ? id : null;
|
||||
}
|
||||
|
||||
function labelFor(connection: ConnectionSummary | undefined, id: string): string {
|
||||
if (connection === undefined) return `unknown connection ${id.slice(0, 8)}`;
|
||||
const handle = typeof connection.fields.handle === "string" ? connection.fields.handle : connection.channelId;
|
||||
return `${PLATFORMS[connection.provider]?.displayName ?? connection.provider} · ${handle}`;
|
||||
}
|
||||
|
||||
function toRows(drafts: DraftRecord[], connections: ConnectionSummary[]): ScheduledRow[] {
|
||||
return drafts
|
||||
.map((draft): ScheduledRow => {
|
||||
const connectionId = connectionIdOf(draft);
|
||||
const connection = connectionId === null ? undefined : connections.find((c) => c.id === connectionId);
|
||||
return {
|
||||
id: draft.id,
|
||||
text: draft.post.text,
|
||||
publishAt: draft.post.scheduledAt ?? draft.updatedAt,
|
||||
fromScheduledAt: draft.post.scheduledAt !== undefined,
|
||||
targetLabel: connectionId === null ? "no target recorded" : labelFor(connection, connectionId),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => Date.parse(a.publishAt) - Date.parse(b.publishAt));
|
||||
}
|
||||
|
||||
/** Group by the viewer's local calendar day — the list equivalent of a week view. */
|
||||
function groupByDay(rows: ScheduledRow[]): DayGroup[] {
|
||||
const groups = new Map<number, ScheduledRow[]>();
|
||||
for (const row of rows) {
|
||||
const date = new Date(row.publishAt);
|
||||
const key = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const bucket = groups.get(key);
|
||||
if (bucket === undefined) groups.set(key, [row]);
|
||||
else bucket.push(row);
|
||||
}
|
||||
return [...groups.entries()].map(([key, dayRows]) => ({
|
||||
key,
|
||||
label: new Date(key).toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }),
|
||||
rows: dayRows,
|
||||
}));
|
||||
}
|
||||
|
||||
const localTime = (iso: string): string =>
|
||||
new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
const utcTime = (iso: string): string => `${new Date(iso).toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
||||
|
||||
function relativeLabel(iso: string, now: number): { text: string; overdue: boolean } {
|
||||
const delta = Date.parse(iso) - now;
|
||||
const minutes = Math.max(0, Math.round(Math.abs(delta) / 60_000));
|
||||
const span =
|
||||
minutes < 60
|
||||
? `${minutes}m`
|
||||
: minutes < 1_440
|
||||
? `${Math.floor(minutes / 60)}h ${minutes % 60}m`
|
||||
: `${Math.floor(minutes / 1_440)}d ${Math.floor((minutes % 1_440) / 60)}h`;
|
||||
return { text: delta >= 0 ? `in ${span}` : `${span} overdue`, overdue: delta < 0 };
|
||||
}
|
||||
|
||||
export default function CalendarPage(): React.ReactElement {
|
||||
const { can } = useAuth();
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, SAMPLE, {
|
||||
state: "scheduled",
|
||||
limit: 100,
|
||||
});
|
||||
const connections = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", same, noConnections);
|
||||
const live = drafts.source === "live";
|
||||
const canSchedule = can("publication.schedule");
|
||||
|
||||
const [cancelling, setCancelling] = useState<ScheduledRow | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [now, setNow] = useState<number>(Date.now());
|
||||
|
||||
// Same cadence as the streams wall: refresh on a 30s timer.
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
drafts.reload();
|
||||
connections.reload();
|
||||
setNow(Date.now());
|
||||
}, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [drafts.reload, connections.reload]);
|
||||
|
||||
const rows = useMemo(() => toRows(drafts.data, connections.data), [drafts.data, connections.data]);
|
||||
const groups = useMemo(() => groupByDay(rows), [rows]);
|
||||
|
||||
async function cancel(row: ScheduledRow): Promise<void> {
|
||||
if (!live) {
|
||||
setNotice({ tone: "error", text: "Offline — sample mode, nothing was sent." });
|
||||
setCancelling(null);
|
||||
return;
|
||||
}
|
||||
setBusyId(row.id);
|
||||
setNotice(null);
|
||||
try {
|
||||
const draft = await api.del<DraftRecord>(`/publishing/drafts/${row.id}/schedule`);
|
||||
drafts.setData((current) => current.filter((item) => item.id !== row.id));
|
||||
setNotice({ tone: "ok", text: `Schedule cancelled — the draft is back to ${draft.state.replace("_", " ")}.` });
|
||||
setCancelling(null);
|
||||
drafts.reload();
|
||||
} catch (err) {
|
||||
setNotice({ tone: "error", text: errorMessage(err) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 pb-24 lg:p-6">
|
||||
<div className="mb-1 flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Calendar</h1>
|
||||
<OfflineBadge source={drafts.source} />
|
||||
<span className="ml-auto text-2xs text-text-faint">
|
||||
{rows.length} scheduled · refreshes every 30s
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => { drafts.reload(); connections.reload(); setNow(Date.now()); }}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-4 text-xs text-text-faint">
|
||||
Publications queued by the publishing pipeline, grouped by your local day. Each row shows the local time with the
|
||||
UTC time beside it; execution state lives in the audit log, not this list.
|
||||
</p>
|
||||
|
||||
{notice !== null && (
|
||||
<p role="status" className={`mb-3 text-xs ${notice.tone === "ok" ? "text-live" : "text-danger"}`}>
|
||||
{notice.text}
|
||||
</p>
|
||||
)}
|
||||
{drafts.error !== null && <p role="alert" className="mb-3 text-xs text-danger">{drafts.error}</p>}
|
||||
{!live && drafts.error === null && (
|
||||
<p className="mb-3 text-xs text-text-faint">Offline sample — connect to the API to see and cancel real schedules.</p>
|
||||
)}
|
||||
|
||||
{drafts.source === "loading" && <LoadingPanel label="Loading scheduled publications…" />}
|
||||
{drafts.source !== "loading" && rows.length === 0 && drafts.error === null && (
|
||||
<EmptyState
|
||||
title="Nothing scheduled"
|
||||
hint="Queue a post from Compose (or approve one with a schedule time) and it appears here until it publishes or is cancelled."
|
||||
/>
|
||||
)}
|
||||
{drafts.source !== "loading" &&
|
||||
groups.map((group) => (
|
||||
<section key={group.key} className="mb-6">
|
||||
<h2 className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-text-faint">
|
||||
{group.label}
|
||||
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-2xs font-medium normal-case tracking-normal text-text-dim">
|
||||
{group.rows.length}
|
||||
</span>
|
||||
</h2>
|
||||
<ul className="divide-y divide-border-subtle rounded-lg border border-border-subtle bg-surface">
|
||||
{group.rows.map((row) => {
|
||||
const relative = relativeLabel(row.publishAt, now);
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-2 p-4 lg:flex-row lg:items-start lg:gap-4">
|
||||
<div className="flex w-44 shrink-0 flex-col gap-0.5">
|
||||
<span className="font-mono text-sm text-text">{localTime(row.publishAt)}</span>
|
||||
<span className="text-2xs text-text-faint">{utcTime(row.publishAt)}</span>
|
||||
<span className={`text-2xs ${relative.overdue ? "text-warn" : "text-text-faint"}`}>{relative.text}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 whitespace-pre-wrap break-words text-sm text-text">{row.text}</p>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs text-text-faint">
|
||||
<Badge tone="accent">scheduled</Badge>
|
||||
<span>{row.targetLabel}</span>
|
||||
{!row.fromScheduledAt && (
|
||||
<span title="The queue keeps its own timestamp, but the API only exposes the draft — this is the draft's last update.">
|
||||
time from last draft update
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
{live && canSchedule && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={busyId !== null}
|
||||
onClick={() => { setNotice(null); setCancelling(row); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{live && !canSchedule && (
|
||||
<span className="text-2xs text-text-faint" title="Cancelling a schedule requires the publication.schedule permission.">
|
||||
cancelling needs publication.schedule
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{cancelling !== null && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60"
|
||||
onClick={() => { if (busyId === null) setCancelling(null); }}
|
||||
>
|
||||
<div
|
||||
className="w-[480px] max-w-[90vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-semibold">Cancel this scheduled publication?</h3>
|
||||
<p className="mt-1 text-2xs text-text-faint">
|
||||
{utcTime(cancelling.publishAt)} · {cancelling.targetLabel}
|
||||
</p>
|
||||
<p className="mt-3 max-h-24 overflow-y-auto whitespace-pre-wrap break-words rounded-md border border-border-subtle bg-surface-2 p-2 text-xs leading-relaxed text-text-dim scrollbar-thin">
|
||||
{cancelling.text}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-text-dim">
|
||||
Nothing is published. The draft returns to editing so it can be rescheduled or changed, and the cancellation
|
||||
is recorded in the audit log.
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" disabled={busyId !== null} onClick={() => setCancelling(null)}>Keep it</Button>
|
||||
<Button variant="danger" disabled={busyId !== null} onClick={() => void cancel(cancelling)}>
|
||||
{busyId === cancelling.id ? "Cancelling…" : "Cancel publication"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
apps/web/src/app/(main)/compose/page.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import { Badge, Button, Input } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { MOCK_TARGETS, PLATFORMS } from "../../../lib/platforms";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import type { ConnectionSummary, DraftPost, DraftRecord, DraftState, ScheduledPublicationRecord, Visibility } from "../../../lib/api-types";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { Sheet } from "../../../components/mobile/sheet";
|
||||
|
||||
/** A publish target: a real connection when live, a fixture platform when offline. */
|
||||
interface Target {
|
||||
key: string;
|
||||
provider: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function connectionTargets(connections: ConnectionSummary[]): Target[] {
|
||||
return connections.map((c) => {
|
||||
const handle = typeof c.fields.handle === "string" ? c.fields.handle : c.channelId;
|
||||
return { key: c.id, provider: c.provider, label: `${PLATFORMS[c.provider]?.displayName ?? c.provider} · ${handle}` };
|
||||
});
|
||||
}
|
||||
|
||||
const SAMPLE_TARGETS: Target[] = MOCK_TARGETS.map((t) => ({ key: t, provider: t, label: PLATFORMS[t]?.displayName ?? t }));
|
||||
|
||||
const identity = <T,>(value: T): T => value;
|
||||
const NO_DRAFTS: DraftRecord[] = [];
|
||||
|
||||
const STATE_TONE: Record<DraftState, "neutral" | "accent" | "live" | "warn" | "danger"> = {
|
||||
editing: "neutral",
|
||||
in_review: "warn",
|
||||
approved: "accent",
|
||||
rejected: "danger",
|
||||
scheduled: "accent",
|
||||
published: "live",
|
||||
};
|
||||
|
||||
type Notice = { tone: "ok" | "error"; text: string };
|
||||
|
||||
export default function ComposePage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const { can } = useAuth();
|
||||
const targetsResource = useApiResource<ConnectionSummary[], Target[]>("/connections", connectionTargets, SAMPLE_TARGETS);
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, NO_DRAFTS, { limit: 10 });
|
||||
const live = targetsResource.source === "live";
|
||||
const available = targetsResource.data;
|
||||
|
||||
const [picked, setPicked] = useState<string[] | null>(null);
|
||||
const [scheduledAt, setScheduledAt] = useState("");
|
||||
const [visibility, setVisibility] = useState<Visibility>("public");
|
||||
const [approval, setApproval] = useState("draft");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [keyboardOffset, setKeyboardOffset] = useState(0);
|
||||
const [media, setMedia] = useState<Array<{ name: string; url: string; alt: string }>>([]);
|
||||
const mediaRef = useRef(media);
|
||||
mediaRef.current = media;
|
||||
|
||||
// Default selection: the first available target, until the user picks.
|
||||
const targets = (picked ?? available.slice(0, 1).map((t) => t.key)).filter((k) => available.some((t) => t.key === k));
|
||||
const selected = available.filter((t) => targets.includes(t.key));
|
||||
const limit = selected.length === 0 ? 500 : Math.min(...selected.map((t) => PLATFORMS[t.provider]?.charLimit ?? 500));
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder: "What's shipping? (bold **so** / heading # supported)" }),
|
||||
CharacterCount,
|
||||
],
|
||||
content: "",
|
||||
immediatelyRender: false,
|
||||
// Tiptap v3 no longer re-renders per keystroke by default; the char counter,
|
||||
// previews and button enablement all read editor state during render.
|
||||
shouldRerenderOnTransaction: true,
|
||||
});
|
||||
|
||||
const chars = editor?.storage.characterCount.characters() ?? 0;
|
||||
const over = chars > limit;
|
||||
const mediaBlocked = media.length > 0;
|
||||
useEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return;
|
||||
const update = (): void => setKeyboardOffset(Math.max(0, Math.round(window.innerHeight - viewport.height - viewport.offsetTop)));
|
||||
update();
|
||||
viewport.addEventListener("resize", update);
|
||||
viewport.addEventListener("scroll", update);
|
||||
return () => { viewport.removeEventListener("resize", update); viewport.removeEventListener("scroll", update); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const text = params.get("text");
|
||||
if (text) editor.commands.setContent(text);
|
||||
const requested = params.get("targets")?.split(",").filter(Boolean);
|
||||
if (requested?.length) setPicked(requested);
|
||||
}, [editor]);
|
||||
useEffect(() => () => { mediaRef.current.forEach((item) => URL.revokeObjectURL(item.url)); }, []);
|
||||
|
||||
function postFor(providerMetadata: Record<string, unknown>): DraftPost {
|
||||
return {
|
||||
text: editor?.getText() ?? "",
|
||||
mediaAssetIds: [],
|
||||
visibility,
|
||||
providerMetadata,
|
||||
...(scheduledAt !== "" ? { scheduledAt: new Date(scheduledAt).toISOString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function run(action: () => Promise<string>): Promise<void> {
|
||||
if (!live) {
|
||||
setNotice({ tone: "error", text: "Offline — sample mode, nothing was sent." });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
setNotice({ tone: "ok", text: await action() });
|
||||
drafts.reload();
|
||||
} catch (err) {
|
||||
setNotice({ tone: "error", text: errorMessage(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const saveDraft = (): Promise<void> =>
|
||||
run(async () => {
|
||||
await api.post<DraftRecord>("/publishing/drafts", { post: postFor({ targetConnectionIds: targets }) });
|
||||
return "Draft saved.";
|
||||
});
|
||||
|
||||
/**
|
||||
* A scheduled publication binds one draft to one connection (UNIQUE draft_id),
|
||||
* so multi-target posts become one draft per target. Each runs the pipeline as
|
||||
* far as the caller's permissions allow: create → submit → approve → schedule.
|
||||
*/
|
||||
const publish = (): Promise<void> =>
|
||||
run(async () => {
|
||||
const stopAtReview = approval === "review" || !can("approval.grant");
|
||||
const canSchedule = can("publication.schedule");
|
||||
const publishAt = scheduledAt !== "" ? new Date(scheduledAt).toISOString() : new Date().toISOString();
|
||||
const outcomes: string[] = [];
|
||||
for (const target of selected) {
|
||||
const draft = await api.post<DraftRecord>("/publishing/drafts", { post: postFor({ connectionId: target.key }) });
|
||||
await api.post<DraftRecord>(`/publishing/drafts/${draft.id}/submit`);
|
||||
if (stopAtReview) {
|
||||
outcomes.push(`${target.label}: submitted for review`);
|
||||
continue;
|
||||
}
|
||||
await api.post<DraftRecord>(`/publishing/drafts/${draft.id}/approve`);
|
||||
if (!canSchedule) {
|
||||
outcomes.push(`${target.label}: approved (you lack publication.schedule)`);
|
||||
continue;
|
||||
}
|
||||
await api.post<ScheduledPublicationRecord>(`/publishing/drafts/${draft.id}/schedule`, { connectionId: target.key, publishAt });
|
||||
outcomes.push(`${target.label}: ${scheduledAt !== "" ? `scheduled for ${new Date(publishAt).toLocaleString()}` : "queued to publish now"}`);
|
||||
}
|
||||
return outcomes.join(" · ");
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-0 pb-40 lg:h-full lg:pb-0">
|
||||
<div className="flex min-w-0 flex-1 flex-col p-4 lg:p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Compose</h1>
|
||||
<OfflineBadge source={targetsResource.source} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2 overflow-x-auto pb-2 lg:flex-wrap lg:overflow-visible">
|
||||
{live && available.length === 0 && (
|
||||
<span className="text-xs text-text-faint">No channels connected — add one under Channels to publish.</span>
|
||||
)}
|
||||
{available.map((t) => {
|
||||
const active = targets.includes(t.key);
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setPicked(active ? targets.filter((x) => x !== t.key) : [...targets, t.key])}
|
||||
className={`min-h-11 shrink-0 rounded-full border px-3 py-1 text-xs font-medium transition-colors duration-150 ${
|
||||
active ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim hover:border-text-faint"
|
||||
}`}
|
||||
>
|
||||
{t.label} · {PLATFORMS[t.provider]?.charLimit ?? 500}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[220px] flex-1 overflow-y-auto rounded-lg border border-border-subtle bg-surface p-4 scrollbar-thin">
|
||||
<EditorContent editor={editor} className="prose-invert max-w-none text-sm [&_.ProseMirror]:min-h-[180px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-text-faint [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 lg:hidden">
|
||||
<div className="flex items-center gap-3"><div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-4 text-xs font-bold ${over ? "border-danger text-danger" : chars >= limit * 0.9 ? "border-warn text-warn" : "border-accent text-accent"}`}>{Math.max(0, limit - chars)}</div><div className="min-w-0 flex-1 overflow-x-auto"><div className="flex gap-2">{selected.map((target) => { const max = PLATFORMS[target.provider]?.charLimit ?? 500; return <button key={target.key} type="button" onClick={() => setPreviewOpen(true)} className={`min-h-11 shrink-0 rounded-md border px-3 text-xs ${chars > max ? "border-danger text-danger" : "border-border text-text-dim"}`}>{PLATFORMS[target.provider]?.displayName ?? target.provider} {chars}/{max}</button>; })}</div></div></div>
|
||||
{media.length > 0 && <div className="mt-2 flex gap-2 overflow-x-auto">{media.map((item) => <div key={item.url} className="w-28 shrink-0 rounded-md border border-border p-2"><div className="h-16 overflow-hidden rounded bg-surface-2"><img src={item.url} alt={item.alt || item.name} className="h-full w-full object-cover" /></div><input aria-label={`Alt text for ${item.name}`} value={item.alt} onChange={(event) => setMedia((current) => current.map((value) => value.url === item.url ? { ...value, alt: event.target.value } : value))} placeholder="ALT text" className="mt-1 min-h-11 w-full bg-transparent text-2xs outline-none" /><span className={`text-2xs ${item.alt ? "text-live" : "text-warn"}`}>{item.alt ? "ALT ready" : "Add ALT"}</span><button type="button" className="min-h-11 w-full text-2xs text-danger" onClick={() => { URL.revokeObjectURL(item.url); setMedia((current) => current.filter((value) => value.url !== item.url)); }}>Remove</button></div>)}</div>}
|
||||
{mediaBlocked && <p className="mt-2 text-xs text-warn">Media upload is not available in the publishing API yet. Remove previews to save or post text.</p>}
|
||||
<button type="button" onClick={() => setScheduleOpen(true)} className="mt-3 flex min-h-12 w-full items-center justify-between border-b border-border-subtle text-left text-sm"><span>Schedule</span><span className="text-xs text-text-faint">{scheduledAt ? new Date(scheduledAt).toLocaleString() : "Post now"} ›</span></button>
|
||||
<label className="flex min-h-12 items-center justify-between border-b border-border-subtle text-sm"><span>Request approval</span><input type="checkbox" className="h-6 w-6 accent-accent" checked={approval === "review" || !can("approval.grant")} disabled={!can("approval.grant")} onChange={(event) => setApproval(event.target.checked ? "review" : "draft")} /></label>
|
||||
{!can("approval.grant") && <p className="mt-1 text-2xs text-text-faint">Your role submits posts for editor approval.</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 hidden flex-wrap items-center gap-3 lg:flex">
|
||||
<span className={`font-mono text-xs ${over ? "text-danger" : "text-text-faint"}`}>
|
||||
{chars} / {limit}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-xs text-text-dim">
|
||||
<label htmlFor="visibility" className="text-text-faint">Visibility</label>
|
||||
<select
|
||||
id="visibility"
|
||||
value={visibility}
|
||||
onChange={(e) => setVisibility(e.target.value as Visibility)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="public">public</option>
|
||||
<option value="unlisted">unlisted</option>
|
||||
<option value="followers">followers</option>
|
||||
<option value="private">private</option>
|
||||
<option value="provider_default">provider default</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-xs text-text-dim">
|
||||
<label htmlFor="approval" className="text-text-faint">Approval</label>
|
||||
<select
|
||||
id="approval"
|
||||
value={approval}
|
||||
onChange={(e) => setApproval(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="draft">draft</option>
|
||||
<option value="review">submit for review</option>
|
||||
{can("approval.grant") && <option value="approved">approved</option>}
|
||||
</select>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-xs text-text-dim">
|
||||
<label htmlFor="schedule" className="text-text-faint">Schedule</label>
|
||||
<Input
|
||||
id="schedule"
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="w-52 py-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" disabled={busy || chars === 0} onClick={() => void saveDraft()}>Save draft</Button>
|
||||
<Button variant="primary" disabled={busy || targets.length === 0 || chars === 0 || over} onClick={() => void publish()}>
|
||||
{scheduledAt !== "" ? "Schedule" : approval === "review" ? "Submit" : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{notice !== null && (
|
||||
<p className={`mt-2 text-xs ${notice.tone === "ok" ? "text-live" : "text-danger"}`}>{notice.text}</p>
|
||||
)}
|
||||
<div className="fixed left-0 right-0 z-30 border-t border-border bg-surface px-4 py-2 lg:hidden" style={{ bottom: `calc(var(--mobile-tab-height) + var(--mobile-safe-bottom) + ${keyboardOffset}px)` }}>
|
||||
<p className="mb-2 truncate text-2xs text-text-faint">{approval === "review" || !can("approval.grant") ? "→ Editors approve" : "→ Ready to publish"} · {scheduledAt ? `publishes ${new Date(scheduledAt).toLocaleString()}` : "post now"} · {selected.length} accounts</p>
|
||||
<div className="flex gap-2"><Button variant="ghost" className="min-h-11" onClick={() => router.back()}>Cancel</Button><Button variant="outline" className="min-h-11 flex-1" disabled={busy || chars === 0 || mediaBlocked} onClick={() => void saveDraft()}>Draft</Button><Button variant="primary" className="min-h-11 flex-1" disabled={busy || selected.length === 0 || chars === 0 || over || mediaBlocked} onClick={() => void publish()}>{scheduledAt ? "Schedule" : approval === "review" || !can("approval.grant") ? "Submit" : "Post"}</Button></div>
|
||||
</div>
|
||||
<Sheet open={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule post" footer={<Button variant="primary" className="min-h-11 w-full justify-center" onClick={() => setScheduleOpen(false)}>Done</Button>}><label className="block text-xs text-text-faint" htmlFor="mobile-schedule">Date and time</label><Input id="mobile-schedule" type="datetime-local" value={scheduledAt} onChange={(event) => setScheduledAt(event.target.value)} className="mt-2 min-h-12" /><div className="mt-4 flex gap-2">{[1, 24, 48].map((hours) => <button key={hours} type="button" className="min-h-11 rounded-md border border-border px-3 text-xs" onClick={() => { const date = new Date(Date.now() + hours * 3600000); setScheduledAt(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`); }}>{hours === 1 ? "In 1 hour" : hours === 24 ? "Tomorrow" : "In 2 days"}</button>)}</div><button type="button" onClick={() => setScheduledAt("")} className="mt-4 min-h-11 text-xs text-text-faint">Clear schedule</button></Sheet>
|
||||
<Sheet open={previewOpen} onClose={() => setPreviewOpen(false)} title="Target previews">{selected.map((target) => <div key={target.key} className="mb-3 rounded-md border border-border p-3"><p className="text-xs text-text-faint">{target.label}</p><p className="mt-2 whitespace-pre-wrap text-sm">{editor?.getText() || "Your post preview appears here…"}</p></div>)}</Sheet>
|
||||
{/* Anchor target for the mobile "Resume drafts" shortcut — rendered at every breakpoint. */}
|
||||
{drafts.source === "live" && drafts.data.length > 0 && <section id="drafts" className="mt-6 scroll-mt-4">
|
||||
<h2 className="mb-3 text-sm font-semibold">Recent drafts</h2>
|
||||
{drafts.data.map((draft) => <button key={draft.id} onClick={() => editor?.commands.setContent(draft.post.text)} className="mb-2 block min-h-14 w-full rounded-md border border-border-subtle p-3 text-left text-xs">
|
||||
<span className="mb-1 block text-text-faint">{draft.state.replace("_", " ")}</span>
|
||||
<span className="line-clamp-2 text-text-dim">{draft.post.text}</span>
|
||||
</button>)}
|
||||
</section>}
|
||||
</div>
|
||||
|
||||
<aside className="hidden w-80 shrink-0 overflow-y-auto border-l border-border-subtle p-6 scrollbar-thin lg:block">
|
||||
<h2 className="mb-4 text-sm font-semibold">Previews</h2>
|
||||
{targets.length === 0 && <p className="text-xs text-text-faint">Select target platforms to preview.</p>}
|
||||
{selected.map((t) => {
|
||||
const charLimit = PLATFORMS[t.provider]?.charLimit ?? 500;
|
||||
return (
|
||||
<div key={t.key} className="mb-4 rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="truncate text-2xs font-medium text-text-faint">{t.label}</span>
|
||||
<Badge tone={chars > charLimit ? "danger" : "neutral"}>
|
||||
{chars}/{charLimit}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="line-clamp-6 text-xs leading-relaxed text-text-dim">
|
||||
{editor?.getText() || "Your post preview appears here…"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{drafts.source === "live" && (
|
||||
<>
|
||||
<h2 className="mb-3 mt-6 text-sm font-semibold">Recent drafts</h2>
|
||||
{drafts.error !== null && <p className="text-xs text-danger">{drafts.error}</p>}
|
||||
{drafts.data.length === 0 && <p className="text-xs text-text-faint">No drafts yet.</p>}
|
||||
{drafts.data.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => editor?.commands.setContent(d.post.text)}
|
||||
title="Load this text into the editor"
|
||||
className="mb-2 block w-full rounded-md border border-border-subtle p-2 text-left transition-colors duration-150 hover:border-accent"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<Badge tone={STATE_TONE[d.state]}>{d.state.replace("_", " ")}</Badge>
|
||||
<span className="text-2xs text-text-faint">{new Date(d.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-2xs text-text-dim">{d.post.text}</p>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
apps/web/src/app/(main)/connections/page.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { capacitySnapshot, humanizeMs } from "@tactical-media-broadcast-hub/connector-sdk/rate-policy";
|
||||
import { Badge, Button, Card } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { CONNECTED_ACCOUNTS, CONNECTION_CAPACITIES, DOCUMENTED_RATES, PLATFORMS, type ConnectionCapacity } from "../../../lib/platforms";
|
||||
import { CapacityChip } from "../../../components/connections/capacity-chip";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
import type { ConnectionSummary } from "../../../lib/api-types";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { reissueSession } from "../../../lib/api-client";
|
||||
import { setSessionToken } from "../../../lib/session-token";
|
||||
import { linkBotToken, type ConnectProvider } from "../../../lib/connections-client";
|
||||
|
||||
const NO_PROVIDERS: ConnectProvider[] = [];
|
||||
const providersFromApi = (providers: ConnectProvider[]): ConnectProvider[] => providers;
|
||||
|
||||
interface AccountView {
|
||||
key: string;
|
||||
provider: string;
|
||||
handle: string;
|
||||
health: "healthy" | "expiring" | "needs_credentials" | "unavailable";
|
||||
capabilities?: string[];
|
||||
instanceUrl?: string;
|
||||
capacity: ConnectionCapacity;
|
||||
}
|
||||
|
||||
function handleOf(c: ConnectionSummary): string {
|
||||
const fromFields = c.fields.handle ?? c.fields.displayName;
|
||||
return typeof fromFields === "string" && fromFields !== "" ? fromFields : c.channelId;
|
||||
}
|
||||
|
||||
function fromApi(connections: ConnectionSummary[]): AccountView[] {
|
||||
return connections.map((c) => {
|
||||
const handle = handleOf(c);
|
||||
return {
|
||||
key: c.id,
|
||||
provider: c.provider,
|
||||
handle,
|
||||
health: c.health,
|
||||
capabilities: c.capabilities,
|
||||
instanceUrl: typeof c.fields.instanceUrl === "string" ? c.fields.instanceUrl : undefined,
|
||||
capacity: {
|
||||
handle,
|
||||
platform: c.provider,
|
||||
resolvedCap: DOCUMENTED_RATES[c.provider]?.dailyPostCap,
|
||||
used: c.recentPublishes.length,
|
||||
recentPublishes: c.recentPublishes,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const SAMPLE: AccountView[] = CONNECTED_ACCOUNTS.map((a) => ({
|
||||
key: a.handle,
|
||||
provider: a.platform,
|
||||
handle: a.handle,
|
||||
health: a.status,
|
||||
capacity: CONNECTION_CAPACITIES.find((c) => c.handle === a.handle) ?? { handle: a.handle, platform: a.platform, used: 0, recentPublishes: [] },
|
||||
}));
|
||||
|
||||
function HealthBadge({ health }: { health: AccountView["health"] }): React.ReactElement {
|
||||
if (health === "unavailable") return <Badge tone="warn">provider unavailable</Badge>;
|
||||
if (health === "expiring") return <Badge tone="warn">token expiring</Badge>;
|
||||
if (health === "needs_credentials") return <Badge tone="warn">needs credentials</Badge>;
|
||||
return <Badge tone="live">healthy</Badge>;
|
||||
}
|
||||
|
||||
|
||||
export default function ConnectionsPage(): React.ReactElement {
|
||||
const { data: accounts, source, error, reload } = useApiResource<ConnectionSummary[], AccountView[]>("/connections", fromApi, SAMPLE);
|
||||
const live = source === "live";
|
||||
const { can } = useAuth();
|
||||
const { data: providers } = useApiResource<ConnectProvider[], ConnectProvider[]>("/connections/providers", providersFromApi, NO_PROVIDERS);
|
||||
const [selected, setSelected] = useState<ConnectProvider | null>(null);
|
||||
const [reconnectId, setReconnectId] = useState<string | undefined>();
|
||||
const [instanceUrl, setInstanceUrl] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [target, setTarget] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const manageable = live && can("connection.manage") && !busy;
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const result = url.searchParams.get("connect");
|
||||
if (result === null) return;
|
||||
if (result === "success") void reissueSession().then(() => { reload(); }).catch(() => setNotice("Sign in again to refresh account access."));
|
||||
setNotice(result === "success" ? "Account connected successfully." : "Account linking failed or expired. Please try again.");
|
||||
url.searchParams.delete("connect");
|
||||
window.history.replaceState(null, "", url);
|
||||
}, []);
|
||||
|
||||
// Onboarding (and any deep link) opens the connect dialog via /connections?start=<providerId>.
|
||||
useEffect(() => {
|
||||
if (source !== "live") return;
|
||||
const url = new URL(window.location.href);
|
||||
const startId = url.searchParams.get("start");
|
||||
if (startId === null) return;
|
||||
const provider = providers.find((item) => item.id === startId);
|
||||
url.searchParams.delete("start");
|
||||
window.history.replaceState(null, "", url);
|
||||
if (provider !== undefined) { setSelected(provider); setNotice(null); }
|
||||
}, [source, providers]);
|
||||
|
||||
function openConnect(provider: ConnectProvider, account?: AccountView): void {
|
||||
setSelected(provider); setReconnectId(account?.key); setInstanceUrl(account?.instanceUrl ?? ""); setToken(""); setTarget(""); setNotice(null);
|
||||
}
|
||||
async function submitConnect(event: React.FormEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!selected || !manageable) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (selected.kind === "token") {
|
||||
await linkBotToken(token, target, reconnectId);
|
||||
setToken(""); setSelected(null); reload(); setNotice("Bot connected successfully.");
|
||||
} else {
|
||||
const result = reconnectId
|
||||
? await api.post<{ redirectUrl: string }>(`/connections/${reconnectId}/reconnect`)
|
||||
: await api.post<{ redirectUrl: string }>("/connections/authorize", { provider: selected.id, instanceUrl });
|
||||
window.location.assign(result.redirectUrl);
|
||||
}
|
||||
} catch (err) { setNotice(errorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
function reconnect(account: AccountView): void {
|
||||
const provider = providers.find((item) => item.id === account.provider);
|
||||
if (provider) openConnect(provider, account);
|
||||
}
|
||||
async function remove(account: AccountView): Promise<void> {
|
||||
if (!window.confirm(`Remove ${account.handle}? This removes local schedules and publication records for this account. Published posts stay on the provider.`)) return;
|
||||
setBusy(true);
|
||||
try { const result = await api.del<{ removed: true; token: string }>(`/connections/${account.key}`); setSessionToken(result.token); reload(); setNotice("Account removed."); }
|
||||
catch (err) { setNotice(errorMessage(err)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
const [filter, setFilter] = useState<"all" | "attention" | "capacity">("all");
|
||||
const atCapacity = (account: AccountView): boolean => account.capacity.resolvedCap !== undefined && account.capacity.used >= account.capacity.resolvedCap;
|
||||
const urgent = [...accounts].sort((a, b) => Number(b.health !== "healthy") - Number(a.health !== "healthy") || Number(atCapacity(b)) - Number(atCapacity(a)));
|
||||
const filtered = urgent.filter((account) => filter === "all" || (filter === "attention" && account.health !== "healthy") || (filter === "capacity" && atCapacity(account)));
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Channels</h1>
|
||||
<OfflineBadge source={source} />
|
||||
</div>
|
||||
<Button variant="primary" size="sm" disabled={!manageable} onClick={() => { const provider = providers.find((item) => item.enabled); if (provider) openConnect(provider); }}>Connect account</Button>
|
||||
</div>
|
||||
|
||||
{notice && <div role="status" className="mb-4 rounded-lg border border-accent p-3 text-sm">{notice}<button type="button" className="ml-4 text-xs underline" onClick={() => setNotice(null)}>Dismiss</button></div>}
|
||||
{selected && <form onSubmit={(event) => void submitConnect(event)} className="mb-6 space-y-3 rounded-lg border border-accent bg-surface p-4">
|
||||
<h2 className="font-semibold">{reconnectId ? "Reconnect" : "Connect"} {selected.displayName}</h2>
|
||||
{!reconnectId && <label className="block text-sm">Provider<select className="ml-3 rounded border border-border bg-bg p-2" value={selected.id} disabled={busy} onChange={(event) => { const provider = providers.find((item) => item.id === event.target.value); if (provider) openConnect(provider); }}>{providers.map((provider) => <option key={provider.id} value={provider.id} disabled={!provider.enabled}>{provider.displayName}{!provider.enabled ? " (unavailable)" : ""}</option>)}</select></label>}
|
||||
{selected.instanceRequired && <label className="block text-sm">Instance URL<input className="mt-1 block w-full rounded border border-border bg-bg p-2" value={instanceUrl} onChange={(event) => setInstanceUrl(event.target.value)} placeholder="https://your-instance.example" required disabled={Boolean(reconnectId) || busy} /></label>}
|
||||
{selected.kind === "token" && <><label className="block text-sm">Bot token<input className="mt-1 block w-full rounded border border-border bg-bg p-2" type="password" autoComplete="off" value={token} onChange={(event) => setToken(event.target.value)} required disabled={busy} /></label><label className="block text-sm">Chat or channel ID (optional)<input className="mt-1 block w-full rounded border border-border bg-bg p-2" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="@channel or numeric chat ID" disabled={busy} /></label></>}
|
||||
{selected.note && <p className="text-xs text-text-faint">{selected.note}</p>}
|
||||
<div className="flex gap-2"><Button type="submit" variant="primary" disabled={!manageable || !selected.enabled}>{busy ? "Connecting…" : selected.kind === "token" ? "Validate and connect" : "Continue to provider"}</Button><Button type="button" disabled={busy} onClick={() => { setSelected(null); setToken(""); }}>Cancel</Button></div>
|
||||
</form>}
|
||||
<div className="mb-4 grid grid-cols-3 gap-2 lg:hidden">{([
|
||||
{ id: "all", label: "Connected", count: accounts.length },
|
||||
{ id: "attention", label: "Need attention", count: accounts.filter((account) => account.health !== "healthy").length },
|
||||
{ id: "capacity", label: "At capacity", count: accounts.filter(atCapacity).length },
|
||||
] as const).map((tile) => <button key={tile.id} type="button" onClick={() => setFilter(tile.id)} className={`min-h-20 min-w-0 rounded-lg border p-2 text-left ${filter === tile.id ? "border-accent bg-accent-dim" : "border-border bg-surface"}`}><span className="block text-xl font-bold">{tile.count}</span><span className="text-2xs text-text-dim">{tile.label}</span></button>)}</div>
|
||||
<div className="space-y-2 lg:hidden">{filtered.map((account) => {
|
||||
const platform = PLATFORMS[account.provider];
|
||||
const cap = account.capacity.resolvedCap;
|
||||
const snapshot = capacitySnapshot({ used: account.capacity.used, cap, recentPublishes: account.capacity.recentPublishes }, { requestsPerSecond: DOCUMENTED_RATES[account.provider]?.requestsPerSecond ?? 1, dailyPostCap: cap });
|
||||
return <article key={account.key} className="rounded-lg border border-border bg-surface p-4"><div className="flex items-start gap-3"><span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-2 border-accent bg-black text-lg font-bold text-accent">{(platform?.displayName ?? account.provider).slice(0, 1)}</span><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h2 className="truncate text-sm font-semibold">{platform?.displayName ?? account.provider}</h2><span className={`h-2 w-2 shrink-0 rounded-full ${account.health === "healthy" ? "bg-live" : "bg-warn"}`} aria-label={account.health} /></div><p className="truncate text-xs text-text-faint">{account.handle}</p></div>{account.health !== "healthy" && <button type="button" disabled={!manageable || !providers.find((item) => item.id === account.provider)?.enabled} onClick={() => reconnect(account)} className="min-h-11 rounded-md border border-warn px-3 text-xs text-warn">Fix</button>}</div>
|
||||
<div className="mt-4 flex items-center justify-between text-xs"><span className="text-text-faint">{cap === undefined ? `${account.capacity.used} posts · no documented cap` : `${account.capacity.used}/${cap} posts in 24h`}</span>{snapshot.remaining === 0 && snapshot.nextSlotInMs !== undefined && <span className="text-accent">frees in {humanizeMs(snapshot.nextSlotInMs)}</span>}</div>
|
||||
<div className="mt-3 flex gap-2"><Button size="sm" disabled={!manageable || !providers.find((item) => item.id === account.provider)?.enabled} onClick={() => reconnect(account)}>Reconnect</Button><Button size="sm" variant="danger" disabled={!manageable} onClick={() => void remove(account)}>Remove</Button></div>
|
||||
<div className={`mt-2 h-2 overflow-hidden rounded-full ${cap === undefined ? "border border-dashed border-border" : "bg-surface-2"}`}>{cap !== undefined && <div className={`h-full ${snapshot.remaining === 0 ? "bg-accent" : "bg-live"}`} style={{ width: `${Math.min(100, Math.round((account.capacity.used / Math.max(1, cap)) * 100))}%` }} />}</div>
|
||||
</article>;
|
||||
})}{filtered.length === 0 && <p className="py-8 text-center text-xs text-text-faint">No accounts in this filter.</p>}</div>
|
||||
|
||||
{error !== null && <p className="mb-4 text-xs text-danger">{error}</p>}
|
||||
{source === "loading" && (
|
||||
<div className="mb-4 flex items-center gap-2 text-xs text-text-faint">
|
||||
<Loading size={16} label="Loading channels" /> Loading channels…
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden grid-cols-1 gap-4 md:grid-cols-2 lg:grid">
|
||||
{live && accounts.length === 0 && (
|
||||
<div className="md:col-span-2">
|
||||
<EmptyState
|
||||
title="No channels connected yet"
|
||||
hint="Connect an account below to start publishing. Account passwords never touch this system."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{source !== "loading" && accounts.map((account) => {
|
||||
const platform = PLATFORMS[account.provider];
|
||||
const caps = account.capabilities ?? [];
|
||||
return (
|
||||
<Card key={account.key} className="p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-semibold">{platform?.displayName ?? account.provider}</span>
|
||||
<HealthBadge health={account.health} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-text-faint">{account.handle}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CapacityChip cap={account.capacity} />
|
||||
<Button size="sm" variant="outline" disabled={!manageable || !providers.find((item) => item.id === account.provider)?.enabled} onClick={() => reconnect(account)}>Reconnect</Button>
|
||||
<Button size="sm" variant="danger" disabled={!manageable} onClick={() => void remove(account)}>Remove</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{caps.map((cap) => (
|
||||
<Badge key={cap} tone="accent">{cap}</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{providers.map((provider) => <Card key={provider.id} className="border-dashed p-4"><div className="flex items-center justify-between gap-3"><span className="font-semibold">{provider.displayName}</span><Button size="sm" variant="outline" disabled={!manageable || !provider.enabled} onClick={() => openConnect(provider)}>Connect</Button></div>{provider.note && <p className="mt-2 text-xs text-text-faint">{provider.note}</p>}</Card>)}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-2xs leading-relaxed text-text-faint">
|
||||
Connections store OAuth tokens or bot tokens — passwords never transit this system. Tokens live in the
|
||||
workspace vault (envelope-encrypted); rotate or revoke any time.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/app/(main)/control-room/control-room.css
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.control-room .cr-field {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--color-border, #444);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-2, #222);
|
||||
padding: 8px 10px;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
.control-room label { max-width: 100%; }
|
||||
.control-room button { min-width: 44px; }
|
||||
.control-room :focus-visible { outline: 2px solid #ff1610; outline-offset: 3px; }
|
||||
111
apps/web/src/app/(main)/control-room/page.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { API_URL, ApiError, ApiUnreachableError } from "../../../lib/api-client";
|
||||
import { getSessionToken } from "../../../lib/session-token";
|
||||
import { Badge, Button } from "../../../components/ui/primitives";
|
||||
import { AudioMonitor } from "../../../components/control-room/audio-monitor";
|
||||
import { MultiviewGrid, liveTapIds, type GridTier } from "../../../components/control-room/multiview-grid";
|
||||
import { OverlayEditor, type RenderInput } from "../../../components/control-room/overlay-editor";
|
||||
import { PstPreview } from "../../../components/control-room/pst-preview";
|
||||
import { SwitcherBar, type DirectorAction } from "../../../components/control-room/switcher-bar";
|
||||
import { controlRequest } from "../../../components/control-room/client";
|
||||
import { fixtureChannel } from "../../../components/control-room/fixtures";
|
||||
import { RealtimeClient } from "../../../components/control-room/state-store";
|
||||
import { initialTally, refetchTally, tallyReducer, type TallyState } from "../../../components/control-room/tally";
|
||||
import type { Receipt, Status } from "../../../components/control-room/types";
|
||||
import "./control-room.css";
|
||||
|
||||
export default function ControlRoom(): React.ReactElement {
|
||||
const { session, can, status: authStatus } = useAuth();
|
||||
const [channels, setChannels] = useState<Status["channels"]>([]);
|
||||
const [selected, setSelected] = useState("");
|
||||
const [state, setState] = useState<TallyState | null>(null);
|
||||
const [fixture, setFixture] = useState(false);
|
||||
const [connection, setConnection] = useState("loading");
|
||||
const [message, setMessage] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [mobile, setMobile] = useState(true);
|
||||
const [tier, setTier] = useState<GridTier>("2x2");
|
||||
const [focused, setFocused] = useState<string | null>(null);
|
||||
const [upgrades, setUpgrades] = useState<string[]>([]);
|
||||
const [reload, setReload] = useState(0);
|
||||
useEffect(() => {
|
||||
const query = matchMedia("(max-width: 1023px)"); const update = (): void => setMobile(query.matches);
|
||||
update(); query.addEventListener("change", update); return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (authStatus === "loading") return;
|
||||
let cancelled = false;
|
||||
const abort = new AbortController();
|
||||
setConnection("loading"); setState(null); setFixture(false); setMessage("");
|
||||
void controlRequest<Status>("/media-control/status", undefined, abort.signal).then(result => {
|
||||
if (cancelled) return;
|
||||
setChannels(result.channels); setSelected(result.channels[0]?.channelId ?? ""); setConnection(result.channels.length ? "connecting" : "empty");
|
||||
}).catch(error => {
|
||||
if (cancelled) return;
|
||||
if (error instanceof ApiUnreachableError || error instanceof ApiError && error.status >= 500) {
|
||||
const sample = fixtureChannel(); setFixture(true); setChannels([sample]); setSelected(sample.channelId); setConnection("fixture");
|
||||
} else { setChannels([]); setSelected(""); setConnection("error"); setMessage(error instanceof Error ? error.message : "Status unavailable"); }
|
||||
});
|
||||
return () => { cancelled = true; abort.abort(); };
|
||||
}, [authStatus, session?.workspace.id, reload]);
|
||||
useEffect(() => {
|
||||
const channel = channels.find(item => item.channelId === selected);
|
||||
setFocused(null); setUpgrades([]);
|
||||
if (!channel) { setState(null); return; }
|
||||
setState(initialTally(channel));
|
||||
if (fixture || !session) return;
|
||||
const url = new URL(`${API_URL}/realtime`); url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
const client = new RealtimeClient({ url: url.href, token: getSessionToken, workspaceId: session.workspace.id, channelId: selected,
|
||||
socket: address => new WebSocket(address), status: () => controlRequest<Status>("/media-control/status"),
|
||||
onFrame: frame => setState(previous => previous ? tallyReducer(previous, frame) : previous),
|
||||
onStatus: result => { const current = result.channels.find(item => item.channelId === selected); setState(previous => current ? refetchTally(current, previous) : null); },
|
||||
onConnection: setConnection });
|
||||
client.start(); return () => client.stop();
|
||||
}, [channels, selected, fixture, session]);
|
||||
const production = state?.channel.desired?.production;
|
||||
const liveIds = useMemo(() => liveTapIds(production?.sources ?? [], tier, focused, upgrades, mobile), [production, tier, focused, upgrades, mobile]);
|
||||
const action = async (operation: DirectorAction, body: object): Promise<void> => {
|
||||
if (!state?.channel.desired) return;
|
||||
setBusy(true); setMessage("");
|
||||
try {
|
||||
if (fixture) {
|
||||
const desired = structuredClone(state.channel.desired);
|
||||
if (desired.production) {
|
||||
if (operation === "pst-select" && "sceneId" in body) desired.production.buses.preview = String(body.sceneId);
|
||||
if (operation === "cut" || operation === "take") desired.production.buses.program = desired.production.buses.preview;
|
||||
if (operation === "lock" && "enabled" in body && desired.director) desired.director.locked = Boolean(body.enabled);
|
||||
if (operation === "override" && "mode" in body && desired.director) desired.director.override = body.mode === null ? null : { mode: body.mode as NonNullable<typeof desired.director.override>["mode"], expiresAt: null };
|
||||
}
|
||||
desired.desiredVersion += 1;
|
||||
setState(previous => previous ? tallyReducer(previous, { type: "state", workspaceId: "fixture", channelId: selected, kind: "desired", seq: previous.lastSeq + 1, data: desired }) : previous);
|
||||
setMessage("Fixture request recorded. Tally awaits a simulated observed frame.");
|
||||
} else {
|
||||
const receipt = await controlRequest<Receipt>(`/media-control/channels/${selected}/${operation}`, body);
|
||||
setMessage(`Request accepted · version ${receipt.desiredVersion}. Waiting for observed state.`);
|
||||
}
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : "Command failed"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const render = async (id: string, input: RenderInput): Promise<void> => {
|
||||
if (fixture) throw new Error("Fixture mode has no render service. Reconnect to render an overlay.");
|
||||
await controlRequest<Receipt>(`/production/overlays/${id}`, input);
|
||||
};
|
||||
const simulate = (): void => setState(previous => previous ? tallyReducer(previous, { type: "state", workspaceId: "fixture", channelId: selected, kind: "observed", seq: previous.lastSeq + 1, data: { workspaceId: "fixture", channelId: selected, workerId: previous.channel.workerId, assignmentEpoch: previous.channel.assignmentEpoch, realizedVersion: previous.channel.desired?.desiredVersion ?? null, state: "RUNNING", reason: "Fixture simulation", updatedAt: new Date().toISOString() } }) : previous);
|
||||
const offline = connection !== "live" && connection !== "polling" && !fixture;
|
||||
return <div className="control-room h-full min-w-0 overflow-y-auto p-3 sm:p-5">
|
||||
<header className="mb-4 flex flex-wrap items-center justify-between gap-3"><div><h1 className="text-xl font-semibold">Control Room</h1><p className="mt-1 text-xs text-text-faint">Preview taps · server composition · observed tally</p></div><div className="flex flex-wrap items-center gap-2"><Badge tone={fixture ? "warn" : connection === "live" ? "live" : "neutral"}>{connection === "fixture" ? "FIXTURE · sample data" : connection}</Badge><Button className="min-h-11" variant="outline" onClick={() => setReload(value => value + 1)}>Reconnect</Button></div></header>
|
||||
{fixture && <div className="mb-4 rounded-lg border border-warn/40 bg-warn/10 p-3 text-sm"><p>API unavailable. This is a fixture rehearsal with sample scenes, no broadcast, and no actual program state. Actions stay local.</p><Button className="mt-2 min-h-11" variant="outline" onClick={simulate}>Simulate observed frame</Button></div>}
|
||||
{message && <p role="status" className="mb-4 break-words rounded border border-border p-3 text-sm">{message}</p>}
|
||||
{channels.length > 0 && <label className="mb-4 block max-w-lg text-xs">Production channel<select className="cr-field mt-1" value={selected} onChange={event => setSelected(event.target.value)}>{channels.map(channel => <option key={channel.channelId} value={channel.channelId}>{fixture ? "Sample channel" : channel.channelId}</option>)}</select></label>}
|
||||
{!production || production.scenes.length === 0 ? <section className="rounded-lg border border-border-subtle bg-surface p-6"><h2 className="text-lg font-semibold">{connection === "loading" ? "Loading production status…" : connection === "error" ? "Production status unavailable" : "No production scenes configured"}</h2><p className="mt-3 max-w-xl text-sm text-text-dim">{connection === "error" ? "Reconnect or ask a workspace administrator to check your media.view permission. Production state cannot be determined from this response." : "Configure a virtual channel, assign a media worker, add ingest sources and production scenes, then select the preview bus. Program tally appears only after the worker confirms a realized version."}</p></section> : <>
|
||||
<div className="mb-4 flex flex-wrap gap-3 text-xs text-text-dim"><span>Desired v{state?.channel.desired?.desiredVersion}</span><span>Observed {state?.channel.observed?.realizedVersion === null || !state?.channel.observed ? "unconfirmed" : `v${state.channel.observed.realizedVersion} · ${state.channel.observed.state}`}</span><span>{state?.observedProduction ? "Tally confirmed" : "Tally unknown · awaiting matching observed version"}</span></div>
|
||||
<SwitcherBar desired={state?.channel.desired ?? { desiredVersion: 0, desiredStatus: "STOPPED" }} disabled={offline || !fixture && !can("director.take")} busy={busy} onAction={(operation, body) => { if (!fixture && !can(operation === "lock" ? "director.lock_program" : operation === "override" ? "director.override_auto" : "director.take")) { setMessage("Your role cannot perform this action."); return; } void action(operation, body); }} />
|
||||
<div className="mt-4 grid min-w-0 gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(260px,1fr)]">
|
||||
<MultiviewGrid production={production} observed={state?.observedProduction ?? null} channelId={selected} fixture={fixture} mobile={mobile} liveIds={liveIds} focused={focused} onFocus={setFocused} tier={tier} onTier={value => { setTier(value); setUpgrades([]); }} upgrades={upgrades} onUpgrade={id => setUpgrades(previous => previous.includes(id) ? previous.filter(item => item !== id) : liveIds.length < 4 ? [...previous, id] : previous)} />
|
||||
<div className="min-w-0 space-y-4"><PstPreview production={production} channelId={selected} fixture={fixture} /><AudioMonitor production={production} observed={state?.observedProduction ?? null} liveIds={liveIds} focused={focused} channelId={selected} fixture={fixture} mobile={mobile} /><OverlayEditor overlays={production.overlays} disabled={fixture || offline || !can("director.change_layout")} onRender={render} /></div>
|
||||
</div>
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CONVERSATIONS, THREADS, ageLabel } from "../../../../lib/fixtures";
|
||||
import { Avatar } from "../../../../components/ui/primitives";
|
||||
|
||||
export function ConversationView({ conversationId }: { conversationId: string }): React.ReactElement {
|
||||
const [reply, setReply] = useState("");
|
||||
const id = Number(conversationId);
|
||||
const conversation = CONVERSATIONS.find((item) => item.id === id);
|
||||
const thread = THREADS[id] ?? [];
|
||||
return <div className="flex min-h-full flex-col">
|
||||
<header className="flex min-h-16 items-center gap-3 border-b border-border-subtle px-4"><Link href="/inbox" aria-label="Back to Inbox" className="flex h-11 w-11 items-center justify-center text-xl">←</Link>{conversation && <Avatar handle={conversation.handle} />}<div className="min-w-0"><h1 className="truncate text-sm font-semibold">{conversation?.participant ?? "Conversation unavailable"}</h1><p className="text-xs text-text-faint">{conversation?.handle}</p></div></header>
|
||||
<div className="min-h-[40vh] flex-1 space-y-3 p-4">{thread.map((message) => <div key={message.id} className={`flex ${message.mine ? "justify-end" : "justify-start"}`}><div className={`max-w-[85%] rounded-lg p-3 text-sm ${message.mine ? "bg-accent-dim" : "border border-border-subtle bg-surface"}`}><span className="mb-1 block text-2xs text-text-faint">{message.from} · {ageLabel(message.minutes)} ago</span>{message.text}</div></div>)}</div>
|
||||
<footer className="sticky bottom-0 border-t border-border-subtle bg-bg p-4"><div className="flex gap-2"><input value={reply} onChange={(event) => setReply(event.target.value)} placeholder={`Reply to ${conversation?.handle ?? "conversation"}`} className="min-h-11 min-w-0 flex-1 rounded-md border border-border bg-surface-2 px-3 text-sm" /><button type="button" disabled className="min-h-11 rounded-md bg-accent px-4 text-xs text-white opacity-40">Send</button></div><p className="mt-2 text-2xs text-text-faint">Sample thread · messaging API is not connected yet.</p></footer>
|
||||
</div>;
|
||||
}
|
||||
5
apps/web/src/app/(main)/inbox/[conversationId]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ConversationView } from "./conversation-view";
|
||||
|
||||
export default function ConversationPage({ params }: { params: { conversationId: string } }): React.ReactElement {
|
||||
return <ConversationView conversationId={params.conversationId} />;
|
||||
}
|
||||
61
apps/web/src/app/(main)/inbox/page.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Avatar } from "../../../components/ui/primitives";
|
||||
import { CONVERSATIONS, THREADS, ageLabel, type Conversation, type FeedItem } from "../../../lib/fixtures";
|
||||
import { getPlatform, MOCK_COLUMN_DEFS, PLATFORMS } from "../../../lib/platforms";
|
||||
import { Segmented } from "../../../components/mobile/segmented";
|
||||
import { SwipeItem } from "../../../components/mobile/swipe-item";
|
||||
import { ApprovalsSegment } from "../../../components/mobile/approvals-segment";
|
||||
import { AmplifySheet } from "../../../components/mobile/amplify-sheet";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import type { DraftRecord } from "../../../lib/api-types";
|
||||
|
||||
type Segment = "all" | "mentions" | "approvals" | "dms";
|
||||
type Kind = "spike" | "mention" | "approval" | "reply" | "dm" | "health";
|
||||
interface NoticeRow { id: string; kind: Kind; text: string; conversation?: Conversation; unread: boolean; }
|
||||
const extra: NoticeRow[] = [
|
||||
{ id: "spike", kind: "spike", text: "Sample spike alert · a post is getting attention", unread: true },
|
||||
{ id: "approval-sample", kind: "approval", text: "Sample draft waiting for review", unread: true },
|
||||
{ id: "health", kind: "health", text: "Facebook connection needs attention", unread: true },
|
||||
];
|
||||
const initial: NoticeRow[] = [...extra, ...CONVERSATIONS.map((conversation, i) => ({ id: `conversation-${conversation.id}`, kind: (i === 0 ? "mention" : i === 1 ? "dm" : "reply") as Kind, text: conversation.preview, conversation, unread: conversation.unread > 0 }))];
|
||||
const identity = (value: DraftRecord[]): DraftRecord[] => value;
|
||||
const empty: DraftRecord[] = [];
|
||||
const spikePost: FeedItem = { id: -1, platform: MOCK_COLUMN_DEFS[0]?.platformId ?? "unknown", author: "Sample author", handle: "@sample", text: "Sample spike alert post", ageMinutes: 4, likes: 120, reposts: 30, replies: 12, hasMedia: false };
|
||||
|
||||
export default function InboxPage({ defaultSegment = "all" }: { defaultSegment?: "all" | "approvals" }): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const [segment, setSegment] = useState<Segment>(defaultSegment);
|
||||
const [rows, setRows] = useState(initial);
|
||||
const [backup, setBackup] = useState<NoticeRow[] | null>(null);
|
||||
const [activeId, setActiveId] = useState(CONVERSATIONS[0]?.id ?? 1);
|
||||
const [amplify, setAmplify] = useState(false);
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, empty, { state: "in_review", limit: 100 });
|
||||
const approvals = drafts.data.length;
|
||||
// Fixture rows are only shown while the API is unreachable (sample mode).
|
||||
const sample = drafts.source === "sample";
|
||||
useEffect(() => { const requested = new URLSearchParams(window.location.search).get("segment"); if (requested === "approvals" || requested === "mentions" || requested === "dms") setSegment(requested); }, []);
|
||||
function change(value: Segment): void { setSegment(value); window.history.replaceState(window.history.state, "", value === "all" ? "/inbox" : `/inbox?segment=${value}`); }
|
||||
function changeRows(update: (current: NoticeRow[]) => NoticeRow[]): void { setBackup(rows); setRows(update); }
|
||||
const visible = sample ? rows.filter((row) => segment === "all" || (segment === "mentions" && row.kind === "mention") || (segment === "dms" && row.kind === "dm")) : [];
|
||||
const selected = CONVERSATIONS.find((conversation) => conversation.id === activeId) ?? CONVERSATIONS[0];
|
||||
return <div className="flex h-full min-w-0 flex-col lg:flex-row">
|
||||
<div className="min-w-0 flex-1 lg:hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2"><h1 className="text-lg font-semibold">Inbox</h1>{sample && <button type="button" className="min-h-11 rounded-md px-3 text-xs text-accent" onClick={() => changeRows((current) => current.map((row) => ({ ...row, unread: false })))}>✓ Mark read</button>}</div>
|
||||
{backup && <div role="status" className="flex min-h-11 items-center justify-between bg-surface-2 px-4 text-xs">Inbox updated<button type="button" className="min-h-11 min-w-11 text-accent" onClick={() => { setRows(backup); setBackup(null); }}>Undo</button></div>}
|
||||
<div className="border-b border-border-subtle px-2 pb-2"><Segmented options={[{ value: "all", label: "All", count: visible.filter((row) => row.unread).length + approvals }, { value: "mentions", label: "Mentions", count: visible.filter((row) => row.unread && row.kind === "mention").length }, { value: "approvals", label: "Approvals", count: approvals }, { value: "dms", label: "DMs", count: visible.filter((row) => row.unread && row.kind === "dm").length }]} value={segment} onChange={change} /></div>
|
||||
{segment === "approvals" ? <ApprovalsSegment /> : <div>{visible.map((row) => <SwipeItem key={row.id} leftActions={[{ label: "Dismiss", tone: "accent", onClick: () => changeRows((current) => current.filter((item) => item.id !== row.id)) }]} rightActions={row.kind === "mention" || row.kind === "dm" ? [{ label: "Reply", onClick: () => { if (row.conversation) router.push(`/inbox/${row.conversation.id}`); } }] : []}>
|
||||
<div className={`flex min-h-20 items-start gap-3 border-b border-border-subtle px-4 py-3 ${row.kind === "spike" ? "border-l-4 border-l-accent" : row.kind === "health" ? "border-l-4 border-l-warn" : row.unread ? "border-l-4 border-l-accent/60" : ""}`}>
|
||||
{row.conversation ? <Avatar handle={row.conversation.handle} /> : <span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${row.kind === "spike" || row.kind === "approval" ? "bg-accent" : "bg-warn/20 text-warn"}`}>{row.kind === "spike" ? "↗" : "!"}</span>}
|
||||
<div className="min-w-0 flex-1"><div className="flex items-center gap-2"><span className="text-xs font-semibold capitalize">{row.kind}</span><span className="rounded border border-border px-1 text-2xs uppercase tracking-wide text-text-faint">sample</span>{row.conversation && <span className="truncate text-xs text-text-faint">{row.conversation.participant}</span>}{row.conversation && <span className="ml-auto text-2xs text-text-faint">{ageLabel(row.conversation.lastMinutes)}</span>}</div><p className="mt-1 text-xs text-text-dim">{row.text}</p><div className="mt-2 flex gap-2">{row.conversation && <button type="button" className="min-h-11 rounded-md border border-border px-3 text-xs" onClick={() => router.push(`/inbox/${row.conversation?.id}`)}>Open thread</button>}{row.kind === "spike" && <button type="button" className="min-h-11 rounded-md bg-accent px-3 text-xs text-white" onClick={() => setAmplify(true)}>Crosspost to…</button>}{row.kind === "approval" && <button type="button" className="min-h-11 rounded-md border border-accent px-3 text-xs text-accent" onClick={() => router.push("/approvals")}>Review</button>}{row.kind === "health" && <button type="button" className="min-h-11 rounded-md border border-warn px-3 text-xs text-warn" onClick={() => router.push("/connections")}>View connection</button>}</div></div>
|
||||
</div></SwipeItem>)}
|
||||
{!sample && segment === "all" && <p className="px-4 py-6 text-xs text-text-faint">No messages yet — direct messages arrive when connector messaging support lands.</p>}
|
||||
</div>}
|
||||
</div>
|
||||
{segment === "approvals" ? <div className="hidden min-w-0 flex-1 lg:flex"><ApprovalsSegment /></div> : sample ? <><aside className="hidden w-80 shrink-0 overflow-y-auto border-r border-border-subtle lg:block"><div className="border-b border-border-subtle p-4 text-sm font-semibold">Inbox · sample messages</div>{CONVERSATIONS.map((conversation) => <button key={conversation.id} type="button" onClick={() => setActiveId(conversation.id)} className={`flex min-h-16 w-full items-start gap-3 border-b border-border-subtle p-3 text-left ${conversation.id === activeId ? "bg-accent-dim" : ""}`}><Avatar handle={conversation.handle} /><span className="min-w-0"><span className="block truncate text-sm font-semibold">{conversation.participant}</span><span className="block truncate text-xs text-text-faint">{conversation.preview}</span></span></button>)}</aside>
|
||||
<section className="hidden min-w-0 flex-1 flex-col lg:flex"><div className="border-b border-border-subtle p-4 text-sm font-semibold">{selected?.participant} · {PLATFORMS[selected?.platform ?? ""]?.displayName}</div><div className="flex-1 overflow-y-auto p-4">{(THREADS[selected?.id ?? 1] ?? []).map((message, index) => <div key={index} className={`mb-3 flex ${message.mine ? "justify-end" : "justify-start"}`}><p className="max-w-[70%] rounded-lg border border-border-subtle bg-surface p-3 text-sm">{message.text}</p></div>)}</div><p className="border-t border-border-subtle p-4 text-xs text-text-faint">Sample thread · replies await connector messaging support.</p></section></> : <section className="hidden min-w-0 flex-1 flex-col lg:flex"><div className="border-b border-border-subtle p-4 text-sm font-semibold">Inbox</div><div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-text-faint">No messages yet — direct messages arrive when connector messaging support lands.</div></section>}
|
||||
<AmplifySheet post={spikePost} platform={getPlatform(spikePost.platform)} open={amplify} onClose={() => setAmplify(false)} />
|
||||
</div>;
|
||||
}
|
||||
73
apps/web/src/app/(main)/layout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sidebar } from "../../components/shell/sidebar";
|
||||
import { CommandPalette } from "../../components/shell/command-palette";
|
||||
import { BottomNav } from "../../components/mobile/bottom-nav";
|
||||
import { MobileTopBar } from "../../components/mobile/top-bar";
|
||||
import { AuthProvider, useAuth } from "../../lib/auth-store";
|
||||
import { AuthGate, SessionStatus } from "../../components/auth/auth-gate";
|
||||
import { useApiResource } from "../../lib/use-api";
|
||||
import type { DraftRecord } from "../../lib/api-types";
|
||||
import { useIsMobile } from "../../hooks/use-is-mobile";
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
|
||||
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
|
||||
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive",
|
||||
};
|
||||
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
|
||||
const scrollPositions = new Map<string, { main: number; nested: number[] }>();
|
||||
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
|
||||
const emptyDrafts: DraftRecord[] = [];
|
||||
|
||||
function scrollableChildren(main: HTMLElement): HTMLElement[] {
|
||||
return Array.from(main.querySelectorAll<HTMLElement>("*")).filter((element) =>
|
||||
element.scrollHeight > element.clientHeight && /auto|scroll/.test(getComputedStyle(element).overflowY));
|
||||
}
|
||||
|
||||
function Shell({ children }: { children: ReactNode }): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const mobile = useIsMobile();
|
||||
const { can } = useAuth();
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, emptyDrafts, { state: "in_review", limit: 100 });
|
||||
// Badge comes from live approvals-in-review only — 0 while loading/sample/forbidden, never fixture counts.
|
||||
const approvals = can("approval.grant") && drafts.source === "live" ? drafts.data.length : 0;
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const main = mainRef.current;
|
||||
if (main === null || !mobile) return;
|
||||
const saved = scrollPositions.get(pathname);
|
||||
const restore = window.setTimeout(() => {
|
||||
main.scrollTop = saved?.main ?? 0;
|
||||
scrollableChildren(main).forEach((element, index) => { element.scrollTop = saved?.nested[index] ?? 0; });
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(restore);
|
||||
scrollPositions.set(pathname, { main: main.scrollTop, nested: scrollableChildren(main).map((element) => element.scrollTop) });
|
||||
};
|
||||
}, [pathname, mobile]);
|
||||
const title = titles[pathname.split("/")[1] ?? ""] ?? "Tactical Media Broadcast Hub";
|
||||
return (
|
||||
<div className="flex h-dvh min-w-0 overflow-hidden lg:h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="hidden h-11 shrink-0 items-center justify-between border-b border-border-subtle bg-surface/60 px-4 backdrop-blur lg:flex">
|
||||
<div className="text-2xs text-text-faint">Press <kbd className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono">⌘K</kbd> for commands</div>
|
||||
<div className="text-2xs text-text-faint"><SessionStatus /></div>
|
||||
</div>
|
||||
{mobile && <MobileTopBar title={title} back={!rootTabs.has(pathname)} />}
|
||||
<main ref={mainRef} data-mobile-main className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto bg-bg lg:overflow-hidden">
|
||||
<AuthGate>{children}</AuthGate>
|
||||
</main>
|
||||
{mobile && <BottomNav badges={{ inbox: approvals }} />}
|
||||
</div>
|
||||
{!mobile && <CommandPalette />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }): React.ReactNode {
|
||||
return <AuthProvider><Shell>{children}</Shell></AuthProvider>;
|
||||
}
|
||||
70
apps/web/src/app/(main)/more/page.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { Avatar } from "../../../components/ui/primitives";
|
||||
import type { DraftRecord } from "../../../lib/api-types";
|
||||
|
||||
interface GroupSummary { id: string; name: string; }
|
||||
const identity = (groups: GroupSummary[]): GroupSummary[] => groups;
|
||||
const identityDrafts = (drafts: DraftRecord[]): DraftRecord[] => drafts;
|
||||
const emptyDrafts: DraftRecord[] = [];
|
||||
|
||||
function Tile({ href, title, detail }: { href: string; title: string; detail: string }): React.ReactElement {
|
||||
return <Link href={href} className="flex min-h-[92px] flex-col justify-between rounded-lg border border-border bg-surface p-3.5 hover:border-accent">
|
||||
<span className="text-accent">✦</span><span><strong className="block text-sm">{title}</strong><span className="text-xs text-text-faint">{detail}</span></span>
|
||||
</Link>;
|
||||
}
|
||||
|
||||
function Row({ href, title, detail }: { href: string; title: string; detail?: string }): React.ReactElement {
|
||||
return <Link href={href} className="flex min-h-[56px] items-center gap-3 border-b border-border-subtle px-4 py-2">
|
||||
<span className="min-w-0 flex-1"><strong className="block text-sm font-medium">{title}</strong>{detail && <span className="text-xs text-text-faint">{detail}</span>}</span>
|
||||
<span aria-hidden="true" className="text-text-faint">›</span>
|
||||
</Link>;
|
||||
}
|
||||
|
||||
export default function MorePage(): React.ReactElement {
|
||||
const { session, can, logout } = useAuth();
|
||||
const groups = useApiResource<GroupSummary[], GroupSummary[]>("/groups/mine", identity, []);
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identityDrafts, emptyDrafts, { limit: 100 });
|
||||
const canApprove = can("approval.grant") || session?.role === "Editor";
|
||||
const canManage = can("workspace.members.manage");
|
||||
const canAudit = can("audit.view");
|
||||
return <div className="mx-auto max-w-2xl pb-8">
|
||||
<section className="flex items-center gap-4 px-5 py-5">
|
||||
<div className="scale-125"><Avatar handle={session?.user.displayName || session?.user.username || "AP"} /></div>
|
||||
<div className="min-w-0 flex-1 pl-2">
|
||||
<h1 className="truncate text-xl font-bold">{session?.user.displayName || session?.user.username || "Tactical Media Broadcast Hub"}</h1>
|
||||
<p className="truncate text-xs text-text-faint">@{session?.user.username || "offline"}{session?.user.email ? ` · ${session.user.email}` : ""}</p>
|
||||
<div className="mt-2 flex items-center gap-2"><span className="rounded-full bg-accent px-2.5 py-1 text-[11px] font-bold uppercase text-white">{session?.role || "Preview"}</span><span className="truncate text-xs text-text-dim">{session?.workspace.name || "Studio"}</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<h2 className="px-4 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Your groups</h2>
|
||||
<div className="flex min-h-11 flex-wrap gap-2 px-4 pb-3">{groups.data.length ? groups.data.map((group) => <span key={group.id} className="flex min-h-11 items-center rounded-full border border-border px-3 text-xs">{group.name}</span>) : <span className="text-xs text-text-faint">No groups yet</span>}</div>
|
||||
{(canApprove || canManage || canAudit) && <>
|
||||
<h2 className="px-4 py-3 text-xs font-bold uppercase tracking-wider text-text-faint">Team</h2>
|
||||
<div className="grid grid-cols-2 gap-2.5 px-4">
|
||||
{canApprove && <Tile href="/approvals" title="Approvals" detail={`${drafts.data.filter((draft) => draft.state === "in_review").length} waiting`} />}
|
||||
{canManage && <Tile href="/team" title="Invite member" detail="Manage invitations" />}
|
||||
{canManage && <Tile href="/team" title="Roles" detail={`${groups.data.length} groups`} />}
|
||||
{canAudit && <Tile href="/audit" title="Audit log" detail="Recent activity" />}
|
||||
</div>
|
||||
</>}
|
||||
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Help</h2>
|
||||
<div className="grid grid-cols-2 gap-2.5 px-4">
|
||||
<Tile href="/wiki" title="Help & docs" detail="Manuals · EN / ES" />
|
||||
</div>
|
||||
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Settings</h2>
|
||||
<div className="border-y border-border-subtle bg-surface">
|
||||
<Row href="/connections" title="Connections" detail="Accounts and health" />
|
||||
<Row href="/settings" title="Settings" />
|
||||
<Row href="/settings" title="Security & sessions" />
|
||||
<Row href="/calendar" title="Calendar" />
|
||||
<Row href="/analytics" title="Analytics" />
|
||||
<Row href="/video" title="Video library" />
|
||||
<Row href="/onboarding" title="Onboarding" />
|
||||
<button onClick={() => void logout()} className="flex min-h-[56px] w-full items-center px-4 text-left text-sm font-medium text-danger">Sign out</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
363
apps/web/src/app/(main)/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { ArrowGlyph } from "../../../components/brand/logo";
|
||||
import { PLATFORMS } from "../../../lib/platforms";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import type { MemberMutationResult } from "../../../lib/api-types";
|
||||
import type { ConnectProvider } from "../../../lib/connections-client";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
|
||||
const STEPS = ["Create workspace", "Invite team", "Connect accounts"];
|
||||
const MIN_PASSWORD = 10;
|
||||
const INVITE_ROLE_CHOICES = ["Owner", "Editor", "Viewer"] as const;
|
||||
const DEFAULT_INVITE_ROLE = "Editor";
|
||||
const NO_PROVIDERS: ConnectProvider[] = [];
|
||||
|
||||
function platformName(id: string): string {
|
||||
return PLATFORMS[id]?.displayName ?? id;
|
||||
}
|
||||
|
||||
function SignInForm({ onSwitch }: { onSwitch: () => void }): React.ReactElement {
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
window.location.assign("/streams");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => void submit(e)}>
|
||||
<h2 className="mb-1 text-base font-semibold">Sign in</h2>
|
||||
<p className="mb-4 text-xs text-text-faint">Use the username and password you registered with.</p>
|
||||
<div className="space-y-2">
|
||||
<Input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" autoComplete="username" autoFocus />
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" autoComplete="current-password" />
|
||||
</div>
|
||||
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={onSwitch}>Create a workspace instead</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || username.trim() === "" || password === ""}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnboardingPage(): React.ReactElement {
|
||||
const { status, session, register, adoptToken, logout } = useAuth();
|
||||
const offline = status === "offline";
|
||||
const [mode, setMode] = useState<"register" | "signin">("register");
|
||||
const [step, setStep] = useState(0);
|
||||
const [name, setName] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [emails, setEmails] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<string>(DEFAULT_INVITE_ROLE);
|
||||
const { data: providers, source: providerSource } = useApiResource<ConnectProvider[], ConnectProvider[]>("/connections/providers", (p) => p, NO_PROVIDERS);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
|
||||
// The API client redirects here with ?reason=session on a 401: default to sign-in.
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(window.location.search).get("reason") === "session") {
|
||||
setSessionExpired(true);
|
||||
setMode("signin");
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function createWorkspace(): Promise<void> {
|
||||
if (offline) {
|
||||
setStep(1);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await register({
|
||||
workspaceName: name.trim(),
|
||||
username: username.trim(),
|
||||
password,
|
||||
...(displayName.trim() === "" ? {} : { displayName: displayName.trim() }),
|
||||
...(email.trim() === "" ? {} : { email: email.trim() }),
|
||||
});
|
||||
setStep(1);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendInvites(): Promise<void> {
|
||||
const list = emails.split(/[\s,;]+/).map((e) => e.trim()).filter((e) => e.includes("@"));
|
||||
if (offline || list.length === 0) {
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const failures: string[] = [];
|
||||
// Sequential: each invite bumps the authz epoch and returns the token the next call must use.
|
||||
for (const invitee of list) {
|
||||
try {
|
||||
const result = await api.post<MemberMutationResult>("/team/members/invite", { email: invitee, role: inviteRole });
|
||||
adoptToken(result.token);
|
||||
} catch (err) {
|
||||
failures.push(`${invitee}: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
if (failures.length > 0) setError(failures.join(" · "));
|
||||
else setStep(2);
|
||||
}
|
||||
|
||||
const usernameValid = /^[a-z0-9][a-z0-9_.-]{1,31}$/.test(username.trim());
|
||||
const registerReady =
|
||||
name.trim().length >= 2 && (offline || (usernameValid && password.length >= MIN_PASSWORD));
|
||||
|
||||
// Signed-in visitors who land here (e.g. from the sidebar) get a way out rather than a second registration form.
|
||||
if (status === "authenticated" && session !== null && step === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<Card className="w-[520px] max-w-full p-6">
|
||||
<h2 className="mb-1 text-base font-semibold">You're signed in</h2>
|
||||
<p className="mb-6 text-xs text-text-faint">
|
||||
@{session.user.username} · {session.role} of <span className="text-text">{session.workspace.name}</span>
|
||||
</p>
|
||||
<div className="flex justify-between">
|
||||
<Button variant="ghost" onClick={() => void logout()}>Sign out</Button>
|
||||
<Link href="/streams" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
|
||||
Continue to Streams
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<Card className="w-[520px] max-w-full p-6">
|
||||
<div className="mb-4 flex items-center justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => { setMode(mode === "signin" ? "register" : "signin"); setStep(0); }}
|
||||
>
|
||||
{mode === "signin" ? "Create a workspace" : "Existing account? Log in"}
|
||||
</Button>
|
||||
</div>
|
||||
{offline && (
|
||||
<div className="mb-4 flex justify-end">
|
||||
<OfflineBadge source="sample" />
|
||||
</div>
|
||||
)}
|
||||
{sessionExpired && step === 0 && (
|
||||
<p className="mb-4 rounded-md border border-warn/30 bg-warn/10 px-3 py-2 text-xs text-warn">
|
||||
Your session ended (expired, or your role changed). Sign in again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mode === "signin" && step === 0 && !offline ? (
|
||||
<SignInForm onSwitch={() => setMode("register")} />
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s} className="flex flex-1 items-center gap-2">
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-2xs font-semibold ${
|
||||
i < step ? "bg-live/20 text-live" : i === step ? "bg-accent text-white" : "bg-surface-2 text-text-faint"
|
||||
}`}
|
||||
>
|
||||
{i < step ? "✓" : i + 1}
|
||||
</span>
|
||||
<span className={`text-xs ${i === step ? "text-text" : "text-text-faint"}`}>{s}</span>
|
||||
{i < STEPS.length - 1 && <span className="h-px flex-1 bg-border-subtle" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void createWorkspace();
|
||||
}}
|
||||
>
|
||||
<div className="mb-5">
|
||||
<div className="mb-4 overflow-hidden rounded-lg border border-border-subtle">
|
||||
<img
|
||||
src="/brand/hero.png"
|
||||
alt="Tactical Media Broadcast Hub — arrows from a circle, warm red on black"
|
||||
width={1024}
|
||||
height={1024}
|
||||
className="h-36 w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Tactical Media Broadcast Hub</h1>
|
||||
<p className="mt-1 text-2xs text-text-faint">Your platform. Their feeds. No algorithm in between.</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mb-1 text-base font-semibold">Name your workspace</h2>
|
||||
<p className="mb-4 text-xs text-text-faint">The tenant boundary for accounts, drafts, and audit.</p>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your collective, your studio…" autoFocus />
|
||||
{!offline && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs text-text-faint">Your owner account</p>
|
||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your name (optional)" autoComplete="name" />
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase())}
|
||||
placeholder="Username — this is how you sign in"
|
||||
autoComplete="username"
|
||||
pattern="[a-z0-9][a-z0-9_.-]{1,31}"
|
||||
title="2-32 characters: lowercase letters, digits, dot, underscore or hyphen"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={`Password (min ${MIN_PASSWORD} characters)`}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Email (optional)"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
{offline ? <span /> : (
|
||||
<Button type="button" variant="ghost" onClick={() => setMode("signin")}>I already have an account</Button>
|
||||
)}
|
||||
<Button type="submit" variant="primary" disabled={busy || !registerReady}>
|
||||
{busy ? "Creating…" : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h2 className="mb-1 text-base font-semibold">Invite your team</h2>
|
||||
<p className="mb-4 text-xs text-text-faint">
|
||||
Comma-separated emails. Invitees join at the chosen role; adjust roles on the Team page.
|
||||
</p>
|
||||
<textarea
|
||||
value={emails}
|
||||
onChange={(e) => setEmails(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="priya@studio.io, yuki@studio.io"
|
||||
className="w-full resize-none rounded-md border border-border bg-surface-2 p-2 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<label className="mt-3 flex items-center gap-2 text-xs text-text-faint">
|
||||
Invite role
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
>
|
||||
{INVITE_ROLE_CHOICES.map((role) => (
|
||||
<option key={role} value={role}>{role}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-6 flex justify-between">
|
||||
{/* The workspace already exists once step 0 succeeds; going back would re-register. */}
|
||||
<Button variant="ghost" onClick={() => setStep(2)}>Skip</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void sendInvites()}>
|
||||
{busy ? "Inviting…" : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 className="mb-1 text-base font-semibold">Connect your first accounts</h2>
|
||||
<p className="mb-4 text-xs text-text-faint">
|
||||
{providerSource === "live" && providers.length > 0
|
||||
? `Available: ${providers.map((p) => platformName(p.id)).join(", ")}. Follow each linking flow — bot tokens where required, OAuth on your instance otherwise.`
|
||||
: "Connect at least one account so your workspace can publish."}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{providerSource === "live" && providers.length > 0 ? (
|
||||
providers.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between rounded-md border border-border-subtle px-3 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowGlyph size={11} className="text-accent" />
|
||||
<span className="text-sm font-medium">{platformName(p.id)}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.location.assign(`/connections?start=${p.id}`)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className={`rounded-md border px-3 py-2.5 text-xs ${providerSource === "loading" || providerSource === "live" ? "border-border-subtle text-text-faint" : "border-danger text-danger"}`}>
|
||||
{providerSource === "loading"
|
||||
? "Loading connectable platforms…"
|
||||
: providerSource === "live"
|
||||
? "No connectable platforms are available yet."
|
||||
: "Could not load the platform list — check that the API is reachable, or continue and connect later from Connections."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-2xs text-text-faint">Connecting opens the account linking flow — you can also do this later from Connections.</p>
|
||||
<div className="mt-6 flex justify-between">
|
||||
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
|
||||
<Button variant="primary" onClick={() => setStep(3)}>Finish setup</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="py-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-live/20 text-lg text-live">✓</div>
|
||||
<h2 className="text-base font-semibold">{session?.workspace.name ?? (name || "Workspace")} is ready</h2>
|
||||
<p className="mt-1 text-xs text-text-faint">
|
||||
Link accounts any time from <Link href="/connections" className="text-accent hover:underline">Connections</Link> — or head to{" "}
|
||||
<Link href="/streams" className="text-accent hover:underline">Streams</Link> or{" "}
|
||||
<Link href="/compose" className="text-accent hover:underline">Compose</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/app/(main)/search/page.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { generateFeed, type FeedItem } from "../../../lib/fixtures";
|
||||
import { getPlatform, MOCK_COLUMN_DEFS, PLATFORMS, type Platform } from "../../../lib/platforms";
|
||||
import { readSavedSearches, saveSearches } from "../../../lib/saved-searches";
|
||||
import { PostCard } from "../../../components/mobile/post-card";
|
||||
import { SourceChips } from "../../../components/mobile/source-chips";
|
||||
import { AmplifySheet } from "../../../components/mobile/amplify-sheet";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import type { ConnectionSummary } from "../../../lib/api-types";
|
||||
|
||||
const identity = (value: ConnectionSummary[]): ConnectionSummary[] => value;
|
||||
const empty: ConnectionSummary[] = [];
|
||||
const sample = MOCK_COLUMN_DEFS.flatMap((column, i) => generateFeed(column.platformId, 80, (i + 1) * 11));
|
||||
// Trending values are local fixtures until tenant-scoped aggregation exists.
|
||||
const trends = [{ label: "product", bars: [2, 3, 4, 3, 5, 8, 12] }, { label: "connector", bars: [3, 3, 5, 4, 6, 7, 8] }, { label: "audit", bars: [2, 2, 3, 3, 4, 4, 6] }];
|
||||
const RECENT_KEY = "tmbh.recent-searches";
|
||||
|
||||
export default function SearchPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const connections = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", identity, empty);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState("all");
|
||||
const [saved, setSaved] = useState<string[]>([]);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
const [amplify, setAmplify] = useState<{ item: FeedItem; platform: Platform } | null>(null);
|
||||
useEffect(() => {
|
||||
setSaved(readSavedSearches());
|
||||
try { const value: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]"); if (Array.isArray(value)) setRecent(value.filter((part): part is string => typeof part === "string")); } catch { /* corrupt local preference */ }
|
||||
const focus = (): void => input.current?.focus();
|
||||
window.addEventListener("mobile-search-focus", focus);
|
||||
return () => window.removeEventListener("mobile-search-focus", focus);
|
||||
}, []);
|
||||
const results = useMemo(() => query.trim() === "" ? [] : sample.filter((item) => `${item.text} ${item.handle} ${item.author}`.toLowerCase().includes(query.trim().toLowerCase())), [query]);
|
||||
const visible = active === "all" ? results : results.filter((item) => item.platform === active);
|
||||
const localProviders = new Set(MOCK_COLUMN_DEFS.map((column) => column.platformId));
|
||||
function remember(term: string): void { const values = [term.trim(), ...recent.filter((part) => part !== term.trim())].filter(Boolean).slice(0, 8); setRecent(values); localStorage.setItem(RECENT_KEY, JSON.stringify(values)); }
|
||||
function save(term: string): void { saveSearches([...saved, term]); setSaved(readSavedSearches()); remember(term); }
|
||||
return <div className="mx-auto min-h-full max-w-2xl pb-6">
|
||||
<div className="sticky top-0 z-10 border-b border-border-subtle bg-bg px-4 py-3"><div className="flex items-center gap-2"><button type="button" className="min-h-11 min-w-11 text-xl" aria-label="Clear search" onClick={() => { setQuery(""); setActive("all"); }}>←</button><span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-2xs text-text-faint">sample results</span><input ref={input} id="mobile-search" data-search-field value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { remember(query); input.current?.blur(); } }} placeholder="Search posts and accounts" className="min-h-[54px] min-w-0 flex-1 rounded-lg border border-border bg-surface-2 px-4 text-sm outline-none focus:border-accent" /></div></div>
|
||||
{query.trim() === "" ? <div className="space-y-6 p-4">
|
||||
<p className="text-xs text-text-faint">Search and trends use sample feeds. Cross-platform search is not connected yet. {connections.source === "live" ? `${connections.data.length} accounts connected.` : ""}</p>
|
||||
<section><h2 className="mb-2 text-sm font-semibold">Saved searches</h2><div className="flex flex-wrap gap-2">{saved.map((term) => <button key={term} type="button" onClick={() => setQuery(term)} className="min-h-11 rounded-full border border-border px-4 text-xs">{term} · pinned on Home</button>)}</div></section>
|
||||
<section><h2 className="mb-2 text-sm font-semibold">Trending in sample feeds</h2>{trends.map((trend) => <button key={trend.label} type="button" onClick={() => { setQuery(trend.label); remember(trend.label); }} className="flex min-h-14 w-full items-center justify-between border-b border-border-subtle text-left text-sm"><span>#{trend.label}</span><span className="flex h-8 items-end gap-0.5" aria-hidden="true">{trend.bars.map((bar, i) => <span key={i} className={`w-1.5 ${bar > 9 ? "bg-accent" : "bg-text-faint"}`} style={{ height: `${bar * 2}px` }} />)}</span></button>)}</section>
|
||||
<section><h2 className="mb-2 text-sm font-semibold">Recent</h2>{recent.map((term) => <div key={term} className="flex items-center"><button type="button" onClick={() => setQuery(term)} className="min-h-11 flex-1 text-left text-sm">{term}</button><button type="button" aria-label={`Clear ${term}`} className="min-h-11 min-w-11" onClick={() => { const values = recent.filter((part) => part !== term); setRecent(values); localStorage.setItem(RECENT_KEY, JSON.stringify(values)); }}>×</button></div>)}</section>
|
||||
</div> : <div>
|
||||
<div className="flex items-center justify-between px-4 py-2"><span className="text-xs text-text-faint">{results.length} posts</span><button type="button" className="min-h-11 rounded-md border border-accent px-3 text-xs text-accent" onClick={() => save(query)}>Save search</button></div>
|
||||
<SourceChips sources={[{ id: "all", label: "All", count: results.length }, ...[...localProviders].map((id) => ({ id, label: PLATFORMS[id]?.displayName ?? id, dot: id, count: results.filter((item) => item.platform === id).length }))]} value={active} onChange={setActive} />
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-2">{Object.keys(PLATFORMS).filter((id) => !localProviders.has(id)).map((id) => <span key={id} className="rounded-full border border-border px-3 py-2 text-xs text-text-faint">{PLATFORMS[id]?.displayName} –</span>)}</div>
|
||||
{visible.map((item) => <PostCard key={`${item.platform}-${item.id}`} item={item} platform={getPlatform(item.platform)} match={query} onAmplify={() => setAmplify({ item, platform: getPlatform(item.platform) })} onReply={() => router.push(`/compose?text=${encodeURIComponent(`@${item.handle.replace(/^@/, "")} `)}`)} />)}
|
||||
{visible.length === 0 && <p className="p-6 text-center text-sm text-text-faint">No matches in the sample feeds.</p>}
|
||||
</div>}
|
||||
<AmplifySheet post={amplify?.item ?? null} platform={amplify?.platform ?? null} open={amplify !== null} onClose={() => setAmplify(null)} />
|
||||
</div>;
|
||||
}
|
||||
109
apps/web/src/app/(main)/settings/page.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Avatar, Button } from "../../../components/ui/primitives";
|
||||
import { errorMessage } from "../../../lib/api-client";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
|
||||
/**
|
||||
* Deliberately small: this page states exactly what the API knows about the
|
||||
* current session and how to end it. There is no notifications/preferences
|
||||
* endpoint in the API, so none of that is faked here — no toggles that
|
||||
* pretend to persist.
|
||||
*/
|
||||
|
||||
function Field({ label, value, mono = false }: { label: string; value: string; mono?: boolean }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 px-4 py-3">
|
||||
<span className="text-xs text-text-faint">{label}</span>
|
||||
<span className={`min-w-0 break-all text-right text-xs text-text ${mono ? "font-mono" : ""}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const { session, status, offline, logout } = useAuth();
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function signOut(): Promise<void> {
|
||||
setSigningOut(true);
|
||||
setError(null);
|
||||
try {
|
||||
// The store's logout also clears the local token; a network failure must
|
||||
// still sign you out of this device, hence the swallow-then-state reset.
|
||||
await logout();
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setSigningOut(false);
|
||||
}
|
||||
}
|
||||
|
||||
const statusLine =
|
||||
status === "authenticated" ? "signed in" : status === "loading" ? "checking session…" : offline ? "offline" : status;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 pb-24 lg:p-6">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h1 className="mb-1 text-lg font-semibold">Settings</h1>
|
||||
<p className="mb-5 text-xs text-text-faint">
|
||||
What the API knows about this session. Workspace-level configuration lives in Team and Connections; there is no
|
||||
per-user preference store in the API, so none is shown here.
|
||||
</p>
|
||||
|
||||
<section className="mb-6 rounded-lg border border-border-subtle bg-surface">
|
||||
<div className="flex items-center gap-4 border-b border-border-subtle p-4">
|
||||
<Avatar handle={session?.user.displayName || session?.user.username || "AP"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{session?.user.displayName || session?.user.username || "Not signed in"}
|
||||
</p>
|
||||
<p className="text-xs text-text-faint">
|
||||
{session === null ? "No verified session on this device." : `@${session.user.username}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-accent px-2.5 py-1 text-2xs font-bold uppercase text-white">
|
||||
{session?.role ?? statusLine}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border-subtle">
|
||||
<Field label="Username" value={session?.user.username ?? "—"} />
|
||||
<Field label="Workspace" value={session?.workspace.name ?? "—"} />
|
||||
<Field label="Role" value={session?.role ?? "—"} />
|
||||
<Field label="Workspace slug" value={session?.workspace.slug ?? "—"} mono />
|
||||
<Field
|
||||
label="Permissions"
|
||||
value={session === null ? "—" : session.permissions.length > 0 ? session.permissions.join(", ") : "none"}
|
||||
mono
|
||||
/>
|
||||
<Field
|
||||
label="Session expires"
|
||||
value={
|
||||
session === null
|
||||
? "—"
|
||||
: `${new Date(session.expiresAt).toLocaleString()} (${Intl.DateTimeFormat().resolvedOptions().timeZone})`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error !== null && <p role="alert" className="mb-3 text-xs text-danger">{error}</p>}
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={signingOut || session === null}
|
||||
onClick={() => void signOut()}
|
||||
title={session === null ? "No session to end." : "Ends the workspace session on the API and clears this device's token."}
|
||||
>
|
||||
{signingOut ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
<p className="mt-2 text-2xs text-text-faint">
|
||||
Signing out calls <code className="font-mono">POST /auth/logout</code> on the API, clears the token here, and
|
||||
returns you to the landing page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
apps/web/src/app/(main)/streams/page.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { generateFeed } from "../../../lib/fixtures";
|
||||
import { MOCK_COLUMN_DEFS, type Platform } from "../../../lib/platforms";
|
||||
import { StreamColumn, type ColumnDef } from "../../../components/streams/column";
|
||||
import { Button } from "../../../components/ui/primitives";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { PostCard } from "../../../components/mobile/post-card";
|
||||
import { SourceChips } from "../../../components/mobile/source-chips";
|
||||
import { PullToRefresh } from "../../../components/mobile/pull-to-refresh";
|
||||
import { AmplifySheet } from "../../../components/mobile/amplify-sheet";
|
||||
import { readSavedSearches, saveSearches } from "../../../lib/saved-searches";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { api, errorMessage } from "../../../lib/api-client";
|
||||
import { liveColumns, streamItemKey, streamPlatform, type FeedsResponse, type StreamItem as FeedItem } from "../../../lib/streams-data";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface QuoteTarget {
|
||||
item: FeedItem;
|
||||
platform: Platform;
|
||||
}
|
||||
|
||||
const COLUMN_SEEDS = [11, 22, 33];
|
||||
|
||||
const COLUMNS: ColumnDef[] = MOCK_COLUMN_DEFS.map((def, i) => ({
|
||||
...def,
|
||||
items: generateFeed(def.platformId, [500, 420, 380][i] ?? 400, COLUMN_SEEDS[i] ?? 1),
|
||||
}));
|
||||
|
||||
const SAMPLE_RESPONSE: FeedsResponse = { items: [], nextCursor: null, meta: { configuredFeeds: 0, connections: [], excludedConnections: [] } };
|
||||
const toFeeds = (response: FeedsResponse): FeedsResponse => response;
|
||||
|
||||
export default function StreamsPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const resource = useApiResource("/feeds", toFeeds, SAMPLE_RESPONSE, { limit: 200 });
|
||||
// Mock columns only when the API is unreachable; a live workspace with zero
|
||||
// configured feeds gets an honest empty state instead of sample data.
|
||||
const mock = resource.source === "sample";
|
||||
const noFeeds = resource.source === "live" && !resource.error && resource.data.meta.configuredFeeds === 0;
|
||||
const columns = useMemo(() => mock ? COLUMNS : resource.error || noFeeds ? [] : liveColumns(resource.data), [mock, noFeeds, resource.data, resource.error]);
|
||||
const statusLabel = resource.source === "loading" ? "Loading feeds…" : mock ? "Offline — sample feed" : noFeeds ? "No configured feeds" : resource.error ?? "Live feeds";
|
||||
useEffect(() => {
|
||||
const timer = setInterval(resource.reload, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [resource.reload]);
|
||||
const [source, setSource] = useState("all");
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [amplify, setAmplify] = useState<QuoteTarget | null>(null);
|
||||
const [saved, setSaved] = useState<string[]>([]);
|
||||
const [selectedFlat, setSelectedFlat] = useState(0);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [quote, setQuote] = useState<QuoteTarget | null>(null);
|
||||
const [quoteText, setQuoteText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (): void => setSaved(readSavedSearches());
|
||||
sync();
|
||||
window.addEventListener("saved-searches-change", sync);
|
||||
return () => window.removeEventListener("saved-searches-change", sync);
|
||||
}, []);
|
||||
const feed = useMemo(() => columns.flatMap((column) => column.items).sort((a, b) => a.ageMinutes - b.ageMinutes), [columns]);
|
||||
const extraColumns: ColumnDef[] = useMemo(() => saved.filter((term) => !MOCK_COLUMN_DEFS.some((column) => column.title === `Saved: ${term}`)).map((term) => {
|
||||
const items = feed.filter((item) => item.text.toLowerCase().includes(term.toLowerCase()));
|
||||
return { key: `saved:${term}`, title: `Saved: ${term}`, platformId: items[0]?.platform ?? MOCK_COLUMN_DEFS[0]?.platformId ?? "limited", staleness: mock ? "sample" : "live", platform: items[0] ? streamPlatform(items[0]) : undefined, items };
|
||||
}), [feed, saved, mock]);
|
||||
const shown = source === "all" || source === "columns" ? feed : source.startsWith("saved:") ? feed.filter((item) => item.text.toLowerCase().includes(source.slice(6).toLowerCase())) : columns.find((column) => column.key === source)?.items ?? [];
|
||||
const allColumns = useMemo(() => source === "columns" ? [...columns, ...extraColumns] : [{ key: source, title: source === "all" ? "All feeds" : columns.find((column) => column.key === source)?.title ?? source.slice(6), platformId: shown[0]?.platform ?? MOCK_COLUMN_DEFS[0]?.platformId ?? "limited", platform: shown[0] ? streamPlatform(shown[0]) : undefined, staleness: mock ? "sample" : "live", items: shown }], [columns, extraColumns, shown, source, mock]);
|
||||
useEffect(() => { setSelectedFlat(0); }, [source]);
|
||||
const offsets = useMemo(() => allColumns.reduce<number[]>((acc, _column, index) => [...acc, (acc[index - 1] ?? 0) + (allColumns[index - 1]?.items.length ?? 0)], []), [allColumns]);
|
||||
const flatMax = (offsets[offsets.length - 1] ?? 0) + (allColumns[allColumns.length - 1]?.items.length ?? 0) - 1;
|
||||
useEffect(() => { setSelectedFlat((current) => Math.max(0, Math.min(current, flatMax))); }, [flatMax]);
|
||||
useEffect(() => {
|
||||
if (source !== "all" && source !== "columns" && !source.startsWith("saved:") && !columns.some((column) => column.key === source)) setSource("all");
|
||||
}, [columns, source]);
|
||||
const mobileItems = source === "columns" ? allColumns.flatMap((column) => column.items) : shown;
|
||||
const sources = [{ id: "all", label: "All feeds", count: feed.length }, { id: "columns", label: "Columns" }, ...columns.map((column) => ({ id: column.key, label: column.title, dot: column.platformId, count: column.items.length })), ...saved.map((term) => ({ id: `saved:${term}`, label: `Saved: ${term}`, count: feed.filter((item) => item.text.toLowerCase().includes(term.toLowerCase())).length }))];
|
||||
const refresh = async (): Promise<void> => {
|
||||
try {
|
||||
if (!mock) await api.post("/feeds/refresh");
|
||||
resource.reload();
|
||||
setRefreshKey((key) => key + 1);
|
||||
} catch (error) { pushToast(errorMessage(error)); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia("(max-width: 1023px)").matches) document.querySelector('[data-stream-selected="true"]')?.scrollIntoView({ block: "nearest" });
|
||||
}, [selectedFlat]);
|
||||
|
||||
const pushToast = useCallback((text: string) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((t) => [...t, { id, text }]);
|
||||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600);
|
||||
}, []);
|
||||
|
||||
const locate = useCallback(
|
||||
(flat: number): { column: ColumnDef; item: FeedItem; platform: Platform } | null => {
|
||||
for (let i = allColumns.length - 1; i >= 0; i -= 1) {
|
||||
const start = offsets[i] ?? 0;
|
||||
if (flat >= start) {
|
||||
const item = allColumns[i]?.items[flat - start];
|
||||
const column = allColumns[i];
|
||||
if (item === undefined || column === undefined) return null;
|
||||
return { column, item, platform: streamPlatform(item) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [allColumns, offsets]);
|
||||
|
||||
const notConnectedToast = useCallback(() => {
|
||||
pushToast(mock ? "sample — connect an account to boost" : "Not connected — connect an account to boost");
|
||||
}, [mock, pushToast]);
|
||||
|
||||
const doRepost = useCallback(
|
||||
(item: FeedItem, platform: Platform) => {
|
||||
// No connection id: there is nothing to amplify through — never fake success.
|
||||
if (item.connectionId) { setAmplify({ item, platform }); return; }
|
||||
notConnectedToast();
|
||||
},
|
||||
[notConnectedToast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (quote !== null) {
|
||||
if (e.key === "Escape") setQuote(null);
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
|
||||
if (e.key === "j" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedFlat((f) => Math.max(0, Math.min(f + 1, flatMax)));
|
||||
} else if (e.key === "k" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedFlat((f) => Math.max(f - 1, 0));
|
||||
} else if (e.key === "r") {
|
||||
const loc = locate(selectedFlat);
|
||||
if (loc !== null && (loc.platform.capabilities.amplifyRepost || loc.platform.capabilities.amplifyShare)) {
|
||||
doRepost(loc.item, loc.platform);
|
||||
} else if (loc !== null) {
|
||||
pushToast(`${loc.platform.displayName}: no repost capability — API exposes none`);
|
||||
}
|
||||
} else if (e.key === "q") {
|
||||
const loc = locate(selectedFlat);
|
||||
if (loc !== null && loc.item.connectionId === undefined) {
|
||||
notConnectedToast();
|
||||
} else if (loc !== null && (loc.platform.capabilities.amplifyQuote || loc.platform.capabilities.publishText)) {
|
||||
setQuoteText("");
|
||||
setQuote(loc);
|
||||
} else if (loc !== null) {
|
||||
pushToast(`${loc.platform.displayName}: no quote capability — API exposes none`);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [locate, doRepost, notConnectedToast, pushToast, quote, selectedFlat, flatMax]);
|
||||
|
||||
const headerNote = "j/k move · r amplify · q quote";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-2"><span role="status" className="text-xs text-text-faint">{statusLabel}{!mock && resource.data.meta.excludedConnections.length > 0 ? ` · ${resource.data.meta.excludedConnections.length} accounts do not support feeds` : ""}</span><Button size="sm" variant="ghost" onClick={() => void refresh()}>Refresh</Button></div>
|
||||
<SourceChips sources={sources} value={source} onChange={setSource} />
|
||||
<div className="flex min-h-0 flex-1 flex-col lg:hidden">
|
||||
<div className="flex items-center justify-end border-b border-border-subtle px-4 py-2"><button type="button" className="min-h-11 min-w-11 text-xl" aria-label="Search" onClick={() => router.push("/search")}>⌕</button></div>
|
||||
{noFeeds ? (
|
||||
<div className="p-6">
|
||||
<EmptyState title="No configured feeds" hint="None of your connected accounts have feeds configured yet." />
|
||||
<Link href="/connections" className="mt-3 flex min-h-11 items-center justify-center rounded-md bg-accent px-3 text-xs font-medium text-white hover:bg-accent-hover">Connect an account</Link>
|
||||
</div>
|
||||
) : (
|
||||
<PullToRefresh sourceCount={columns.length} onRefresh={refresh}>
|
||||
<div key={refreshKey}>{mobileItems.map((item, index) => <PostCard key={`${streamItemKey(item)}:${index}`} item={item} selected={selectedFlat === index} onSelect={() => setSelectedFlat(index)} platform={streamPlatform(item)} onAmplify={() => doRepost(item, streamPlatform(item))} onReply={() => router.push(`/compose?text=${encodeURIComponent(`@${item.handle.replace(/^@/, "")} `)}`)} />)}</div>
|
||||
{mobileItems.length === 0 && <EmptyState title="No posts in this source" hint="Choose another feed or saved search." />}
|
||||
</PullToRefresh>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden min-h-0 flex-1 flex-col lg:flex">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border-subtle bg-surface/60 px-4 py-1.5">
|
||||
<span className="text-2xs text-text-faint">{headerNote}</span>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => { const term = window.prompt("Save a search as a column"); if (term?.trim()) saveSearches([...readSavedSearches(), term]); }}>+ Column</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{noFeeds ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 self-center">
|
||||
<EmptyState className="w-full self-center" title="No configured feeds" hint="None of your connected accounts have feeds configured yet." />
|
||||
<Link href="/connections" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">Connect an account</Link>
|
||||
</div>
|
||||
) : allColumns.length === 0 && (
|
||||
<EmptyState
|
||||
className="w-full self-center"
|
||||
title="No columns"
|
||||
hint="Nothing on the wall yet — add a column to pull in a feed."
|
||||
/>
|
||||
)}
|
||||
{allColumns.map((column, i) => (
|
||||
<StreamColumn
|
||||
key={column.key}
|
||||
column={column}
|
||||
flatStartIndex={offsets[i] ?? 0}
|
||||
selectedFlat={selectedFlat}
|
||||
onSelectFlat={setSelectedFlat}
|
||||
onRepost={doRepost}
|
||||
onQuote={(item, platform) => {
|
||||
// No connection id: nothing to quote through — say so instead of faking it.
|
||||
if (item.connectionId === undefined) { notConnectedToast(); return; }
|
||||
setQuoteText("");
|
||||
setQuote({ item, platform });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!mock && !noFeeds && !resource.error && resource.data.nextCursor && <Button size="sm" variant="ghost" onClick={async () => {
|
||||
try {
|
||||
const next = await api.get<FeedsResponse>("/feeds", { limit: 200, cursor: resource.data.nextCursor ?? undefined });
|
||||
resource.setData((current) => ({ ...next, items: [...new Map([...current.items, ...next.items].map((item) => [`${item.connectionId}:${item.id}`, item])).values()] }));
|
||||
} catch (error) { pushToast(errorMessage(error)); }
|
||||
}}>Load older posts</Button>}
|
||||
<AmplifySheet post={amplify?.item ?? null} platform={amplify?.platform ?? null} open={amplify !== null} onClose={() => setAmplify(null)} />
|
||||
|
||||
{quote !== null && (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60" onClick={() => setQuote(null)}>
|
||||
<div className="w-[520px] max-w-[90vw] rounded-lg border border-border bg-surface p-4 shadow-2xl" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Quote {quote.item.handle}</span>
|
||||
<span className="text-2xs text-text-faint">{quote.platform.displayName}</span>
|
||||
</div>
|
||||
<blockquote className="mb-3 max-h-24 overflow-y-auto rounded-md border border-border-subtle bg-surface-2 p-2 text-xs leading-relaxed text-text-dim scrollbar-thin">
|
||||
{quote.item.text}
|
||||
</blockquote>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={quoteText}
|
||||
onChange={(e) => setQuoteText(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Add your commentary…"
|
||||
className="w-full resize-none rounded-md border border-border bg-surface-2 p-2 text-sm text-text placeholder:text-text-faint focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-2xs text-text-faint">{quoteText.length} / {quote.platform.charLimit}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setQuote(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={quoteText.trim() === "" || quote.item.connectionId === undefined}
|
||||
title={quote.item.connectionId === undefined ? "not connected" : undefined}
|
||||
onClick={() => {
|
||||
if (quote.item.connectionId) {
|
||||
const params = new URLSearchParams({ text: `${quoteText}\n\n“${quote.item.text}”\nVia ${quote.item.handle}`, targets: quote.item.connectionId });
|
||||
router.push(`/compose?${params.toString()}`);
|
||||
setQuote(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{quote.item.connectionId ? "Open compose" : "Not connected"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-xs text-text shadow-lg">
|
||||
<span className="h-3 w-0.5 shrink-0 rounded-full bg-accent" />
|
||||
{t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
439
apps/web/src/app/(main)/team/page.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { ASSETS, MEMBERS, ROLES } from "../../../lib/fixtures";
|
||||
import { ApiError, api, errorMessage } from "../../../lib/api-client";
|
||||
import type { InviteLink, InviteLinkStatus, Member, MemberMutationResult } from "../../../lib/api-types";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { useApiResource } from "../../../lib/use-api";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
|
||||
const OWNER = "Owner";
|
||||
|
||||
const SAMPLE_MEMBERS: Member[] = MEMBERS.map((m) => ({
|
||||
id: String(m.id),
|
||||
userId: String(m.id),
|
||||
username: m.email.split("@")[0] ?? "member",
|
||||
email: m.email,
|
||||
displayName: m.name,
|
||||
role: m.role,
|
||||
status: m.status,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
}));
|
||||
|
||||
const memberLabel = (m: Member): string => m.email ?? `@${m.username}`;
|
||||
|
||||
/** Invite-link lifetime choices, in hours. */
|
||||
const LINK_TTL_OPTIONS: Array<{ hours: number; label: string }> = [
|
||||
{ hours: 24, label: "24 hours" },
|
||||
{ hours: 168, label: "7 days" },
|
||||
{ hours: 720, label: "30 days" },
|
||||
];
|
||||
const DEFAULT_LINK_TTL = 168;
|
||||
const DEFAULT_LINK_ROLE = "Editor";
|
||||
|
||||
const SAMPLE_LINKS: InviteLink[] = [
|
||||
{
|
||||
id: "sample-1", role: "Publisher", createdAt: "2024-05-01T09:00:00.000Z",
|
||||
expiresAt: new Date(Date.now() + 86_400_000 * 3).toISOString(), maxUses: 50, useCount: 38,
|
||||
revokedAt: null, status: "active", createdByUserId: "sample-owner",
|
||||
},
|
||||
{
|
||||
id: "sample-2", role: "Editor", createdAt: "2024-04-01T09:00:00.000Z",
|
||||
expiresAt: new Date(Date.now() - 86_400_000).toISOString(), maxUses: null, useCount: 12,
|
||||
revokedAt: null, status: "expired", createdByUserId: "sample-owner",
|
||||
},
|
||||
{
|
||||
id: "sample-3", role: "Approver", createdAt: "2024-03-01T09:00:00.000Z",
|
||||
expiresAt: new Date(Date.now() + 86_400_000).toISOString(), maxUses: 10, useCount: 10,
|
||||
revokedAt: null, status: "exhausted", createdByUserId: "sample-owner",
|
||||
},
|
||||
{
|
||||
id: "sample-4", role: "Analyst", createdAt: "2024-02-01T09:00:00.000Z",
|
||||
expiresAt: new Date(Date.now() + 86_400_000).toISOString(), maxUses: null, useCount: 3,
|
||||
revokedAt: "2024-02-04T09:00:00.000Z", status: "revoked", createdByUserId: "sample-owner",
|
||||
},
|
||||
];
|
||||
|
||||
const linkStatusTone = (status: InviteLinkStatus): "live" | "warn" | "neutral" =>
|
||||
status === "active" ? "live" : status === "revoked" ? "neutral" : "warn";
|
||||
|
||||
const usesLabel = (link: InviteLink): string =>
|
||||
link.maxUses === null ? "unlimited" : `${link.useCount} / ${link.maxUses}`;
|
||||
|
||||
const identity = <T,>(value: T): T => value;
|
||||
|
||||
export default function TeamPage(): React.ReactElement {
|
||||
const { session, can, adoptToken, logout } = useAuth();
|
||||
const members = useApiResource<Member[], Member[]>("/team/members", identity, SAMPLE_MEMBERS);
|
||||
const roles = useApiResource<string[], string[]>("/team/roles", identity, ROLES);
|
||||
const links = useApiResource<InviteLink[], InviteLink[]>("/team/invite-links", identity, SAMPLE_LINKS);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState("Editor");
|
||||
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
|
||||
const [linkRole, setLinkRole] = useState(DEFAULT_LINK_ROLE);
|
||||
const [linkTtl, setLinkTtl] = useState(String(DEFAULT_LINK_TTL));
|
||||
const [linkMaxUses, setLinkMaxUses] = useState("");
|
||||
const [createdLink, setCreatedLink] = useState<InviteLink | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<InviteLink | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||
|
||||
const live = members.source === "live";
|
||||
// Either resource falling back to fixtures means the screen is showing sample data somewhere.
|
||||
const sampleMode = members.source === "sample" || links.source === "sample";
|
||||
const canManage = !live || can("workspace.members.manage");
|
||||
const isOwner = !live || session?.role === OWNER;
|
||||
|
||||
async function mutate(run: () => Promise<MemberMutationResult>, apply: (result: MemberMutationResult) => void | Promise<void>, ok: string): Promise<void> {
|
||||
if (!live) {
|
||||
setMessage({ tone: "error", text: "Offline — sample data is read-only." });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await run();
|
||||
// Membership changes bump the authz epoch; without the re-issued token our next call would 401.
|
||||
adoptToken(result.token);
|
||||
await apply(result);
|
||||
if (ok !== "") setMessage({ tone: "ok", text: ok });
|
||||
} catch (err) {
|
||||
setMessage({ tone: "error", text: errorMessage(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const changeRole = (member: Member, role: string): Promise<void> =>
|
||||
mutate(
|
||||
() => api.patch<MemberMutationResult>(`/team/members/${member.id}/role`, { role }),
|
||||
(result) => {
|
||||
const updated = result.member;
|
||||
if (updated !== null) members.setData((list) => list.map((m) => (m.id === updated.id ? updated : m)));
|
||||
},
|
||||
`${memberLabel(member)} is now ${role}.`,
|
||||
);
|
||||
|
||||
const invite = (): Promise<void> =>
|
||||
mutate(
|
||||
() => api.post<MemberMutationResult>("/team/members/invite", { email: inviteEmail.trim(), role: inviteRole }),
|
||||
async (result) => {
|
||||
const added = result.member;
|
||||
if (added !== null) members.setData((list) => [...list, added]);
|
||||
setInviteEmail("");
|
||||
// No mailer exists, so the invitee would never see anything. Create a
|
||||
// join link for the same role and hand it to the inviter instead.
|
||||
const link = await api.post<InviteLink>("/team/invite-links", { role: inviteRole, expiresInHours: DEFAULT_LINK_TTL });
|
||||
links.setData((list) => [link, ...list.filter((l) => l.id !== link.id)]);
|
||||
setInviteLink(link);
|
||||
setCopied(false);
|
||||
},
|
||||
"",
|
||||
);
|
||||
|
||||
const remove = (member: Member): Promise<void> =>
|
||||
mutate(
|
||||
() => api.del<MemberMutationResult>(`/team/members/${member.id}`),
|
||||
(result) => {
|
||||
members.setData((list) => list.filter((m) => m.id !== member.id));
|
||||
setConfirmRemove(null);
|
||||
// Removing yourself ends your membership: no replacement token.
|
||||
if (result.token === null && member.userId === session?.user.id) void logout();
|
||||
},
|
||||
`Removed ${memberLabel(member)}.`,
|
||||
);
|
||||
|
||||
// Invite links: no epoch bump on create, but the returned token is displayed exactly once.
|
||||
async function createLink(): Promise<void> {
|
||||
if (!live) {
|
||||
setMessage({ tone: "error", text: "Offline — sample data is read-only." });
|
||||
return;
|
||||
}
|
||||
const maxUsesRaw = linkMaxUses.trim();
|
||||
const maxUses = maxUsesRaw === "" ? undefined : Number(maxUsesRaw);
|
||||
if (maxUses !== undefined && (!Number.isInteger(maxUses) || maxUses < 1)) {
|
||||
setMessage({ tone: "error", text: "Max uses must be a whole number of 1 or more." });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const created = await api.post<InviteLink>("/team/invite-links", {
|
||||
role: linkRole,
|
||||
expiresInHours: Number(linkTtl),
|
||||
...(maxUses === undefined ? {} : { maxUses }),
|
||||
});
|
||||
links.setData((list) => [created, ...list.filter((l) => l.id !== created.id)]);
|
||||
setCreatedLink(created);
|
||||
setCopied(false);
|
||||
setLinkMaxUses("");
|
||||
setMessage({ tone: "ok", text: `Invite link created — ${created.role} access, shown once below.` });
|
||||
} catch (err) {
|
||||
setMessage({ tone: "error", text: errorMessage(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke returns the updated link (no re-issued token), so it skips `mutate`'s MemberMutationResult shape.
|
||||
async function revokeLink(link: InviteLink): Promise<void> {
|
||||
if (!live) {
|
||||
setMessage({ tone: "error", text: "Offline — sample data is read-only." });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const revoked = await api.del<InviteLink>(`/team/invite-links/${link.id}`);
|
||||
links.setData((list) => list.map((l) => (l.id === revoked.id ? revoked : l)));
|
||||
setCreatedLink((current) => (current !== null && current.id === revoked.id ? revoked : current));
|
||||
setMessage({ tone: "ok", text: `Revoked the ${link.role} invite link.` });
|
||||
} catch (err) {
|
||||
setMessage({ tone: "error", text: errorMessage(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const assignable = (current: string): string[] => roles.data.filter((r) => r !== OWNER || isOwner || current === OWNER);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 scrollbar-thin">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Team</h1>
|
||||
<OfflineBadge source={sampleMode ? "sample" : members.source} />
|
||||
</div>
|
||||
|
||||
<Card className="mb-6 p-4">
|
||||
{members.error !== null && <p className="mb-3 text-xs text-danger">{members.error}</p>}
|
||||
{/* On a live error (e.g. 403) the sample roster is stale, so the table is withheld rather than faked. */}
|
||||
{members.source !== "loading" && members.error !== null ? null : (
|
||||
<table className="mobile-stack-table w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-left text-2xs text-text-faint">
|
||||
<th className="pb-2 font-medium">Member</th>
|
||||
<th className="pb-2 font-medium">Role</th>
|
||||
<th className="pb-2 font-medium">Status</th>
|
||||
<th className="pb-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.source === "loading" && (
|
||||
<tr><td colSpan={4} className="py-3"><span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading members" /> Loading members…</span></td></tr>
|
||||
)}
|
||||
{members.source !== "loading" && members.data.map((m) => {
|
||||
const ownerLocked = m.role === OWNER && !isOwner;
|
||||
return (
|
||||
<tr key={m.id} className="border-b border-border-subtle last:border-b-0">
|
||||
<td className="py-2.5">
|
||||
<div className="font-medium">{m.displayName}</div>
|
||||
<div className="text-2xs text-text-faint">{memberLabel(m)}</div>
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<select
|
||||
value={m.role}
|
||||
disabled={!canManage || ownerLocked || busy}
|
||||
onChange={(e) => void changeRole(m, e.target.value)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs disabled:opacity-40"
|
||||
>
|
||||
{assignable(m.role).map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
{m.status === "active" ? <Badge tone="live">active</Badge> : <Badge tone="warn">invited</Badge>}
|
||||
</td>
|
||||
<td className="py-2.5 text-right">
|
||||
{confirmRemove === m.id ? (
|
||||
<span className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirmRemove(null)}>Cancel</Button>
|
||||
<Button size="sm" variant="danger" disabled={busy} onClick={() => void remove(m)}>Confirm remove</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" disabled={!canManage || ownerLocked} onClick={() => setConfirmRemove(m.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{canManage && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Input
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="teammate@studio.io"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
aria-label="Role for the invitee"
|
||||
>
|
||||
{assignable("").map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="outline" disabled={!inviteEmail.includes("@") || busy} onClick={() => void invite()}>Send invite</Button>
|
||||
</div>
|
||||
)}
|
||||
{message !== null && (
|
||||
<p className={`mt-2 text-2xs ${message.tone === "ok" ? "text-live" : "text-danger"}`}>{message.text}</p>
|
||||
)}
|
||||
<p className="mt-1.5 text-2xs text-text-faint">
|
||||
Role changes and removals bump the workspace authz epoch — live sessions re-check and fail closed.
|
||||
No invite email is sent — after inviting, share the join link shown above so the member can activate.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-6 p-4">
|
||||
<h2 className="mb-1 text-sm font-semibold">Invite links</h2>
|
||||
<p className="mb-4 text-xs text-text-faint">
|
||||
Anyone with the link joins this workspace at the chosen role — no email needed. Links expire and can be revoked.
|
||||
</p>
|
||||
{canManage && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={linkRole}
|
||||
onChange={(e) => setLinkRole(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
aria-label="Role for the invite link"
|
||||
>
|
||||
{assignable("").map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={linkTtl}
|
||||
onChange={(e) => setLinkTtl(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
aria-label="How long the invite link stays valid"
|
||||
>
|
||||
{LINK_TTL_OPTIONS.map((option) => (
|
||||
<option key={option.hours} value={String(option.hours)}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={linkMaxUses}
|
||||
onChange={(e) => setLinkMaxUses(e.target.value)}
|
||||
placeholder="Max uses (optional)"
|
||||
className="max-w-[11rem]"
|
||||
/>
|
||||
<Button variant="outline" disabled={busy || roles.data.length === 0} onClick={() => void createLink()}>Create link</Button>
|
||||
</div>
|
||||
)}
|
||||
{canManage && inviteLink !== null && (
|
||||
<p className="mb-3 rounded-md border border-accent bg-accent-dim px-3 py-2 text-xs text-accent">Member added — share this link to activate (shown below).</p>
|
||||
)}
|
||||
{(inviteLink !== null || (createdLink !== null && createdLink.token !== undefined)) && (() => {
|
||||
const link = inviteLink ?? createdLink;
|
||||
if (link === null || link.token === undefined) return null;
|
||||
return (
|
||||
<div className="mb-4 rounded-md border border-border-subtle bg-surface-2 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">
|
||||
{inviteLink !== null ? "Member added — share this link to activate" : "Share this link — shown once"}
|
||||
</span>
|
||||
<Badge tone="accent">{link.role}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md border border-border bg-bg px-2 py-1.5 font-mono text-2xs text-text">
|
||||
{`${window.location.origin}/join/${link.token}`}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(`${window.location.origin}/join/${link.token}`).then(() => {
|
||||
setCopied(true);
|
||||
}).catch(() => setCopied(false));
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-2xs text-text-faint">
|
||||
{inviteLink !== null
|
||||
? "No invite email is sent — copy this link and give it to the member. The token is shown only once."
|
||||
: "The token is never shown again — copy it now. Revoke the link below if it leaks."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{links.error !== null && <p className="mb-3 text-xs text-danger">{links.error}</p>}
|
||||
{/* On a live error (e.g. 403) the sample data is stale, so the list is withheld rather than faked. */}
|
||||
{links.error === null && (
|
||||
<table className="mobile-stack-table w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-left text-2xs text-text-faint">
|
||||
<th className="pb-2 font-medium">Role</th>
|
||||
<th className="pb-2 font-medium">Uses</th>
|
||||
<th className="pb-2 font-medium">Status</th>
|
||||
<th className="pb-2 font-medium">Expires</th>
|
||||
<th className="pb-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{links.source === "loading" && (
|
||||
<tr><td colSpan={5} className="py-3"><span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading invite links" /> Loading invite links…</span></td></tr>
|
||||
)}
|
||||
{links.source !== "loading" && links.data.length === 0 && (
|
||||
<tr><td colSpan={5} className="py-3 text-xs text-text-faint">No invite links yet — create one above.</td></tr>
|
||||
)}
|
||||
{links.source !== "loading" && links.data.map((link) => (
|
||||
<tr key={link.id} className="border-b border-border-subtle last:border-b-0">
|
||||
<td className="py-2.5 font-medium">{link.role}</td>
|
||||
<td className="py-2.5 text-2xs text-text-faint">{usesLabel(link)}</td>
|
||||
<td className="py-2.5"><Badge tone={linkStatusTone(link.status)}>{link.status}</Badge></td>
|
||||
<td className="py-2.5 text-2xs text-text-faint">{new Date(link.expiresAt).toLocaleDateString()}</td>
|
||||
<td className="py-2.5 text-right">
|
||||
{link.status === "active" ? (
|
||||
<Button size="sm" variant="ghost" disabled={!canManage || busy} onClick={() => void revokeLink(link)}>Revoke</Button>
|
||||
) : (
|
||||
<span className="text-2xs text-text-faint">{link.revokedAt !== null ? `revoked ${new Date(link.revokedAt).toLocaleDateString()}` : "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="mt-1.5 text-2xs text-text-faint">
|
||||
Tokens are returned once, at creation; the list never exposes them. Joining as <em>Owner</em> requires an Owner to have created the link.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Asset library stays on fixtures: the media library API is not part of this wiring pass. */}
|
||||
<h2 className="mb-3 text-sm font-semibold">Asset library <Badge tone="neutral">sample</Badge></h2>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
{ASSETS.map((a) => (
|
||||
<Card key={a.id} className="p-3">
|
||||
<div className={`flex h-16 items-center justify-center rounded-md border font-mono text-2xs uppercase ${
|
||||
a.locked ? "border-border-subtle bg-surface-2 text-text-faint" : "border-border-subtle text-text-dim"
|
||||
}`}>
|
||||
{a.name.split(".").pop()}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="truncate text-2xs">{a.name}</span>
|
||||
{a.locked && <Badge tone="neutral">group-locked</Badge>}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-text-faint">{a.group}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
apps/web/src/app/(main)/video/page.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "../../../components/ui/primitives";
|
||||
import { OfflineBadge } from "../../../components/ui/offline-badge";
|
||||
import { EmptyState } from "../../../components/brand/empty-state";
|
||||
import { Loading } from "../../../components/brand/loading";
|
||||
import { api, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
import { SAMPLE_VIDEO_ENTRIES } from "../../../lib/video-fixtures";
|
||||
import type { VideoLibraryEntryDto, VideoLibraryPageDto, VideoStatusDto } from "../../../lib/video-types";
|
||||
import { VideoCard, UploadingCard } from "../../../components/video/video-card";
|
||||
import { UploadDropzone } from "../../../components/video/upload-dropzone";
|
||||
import { VideoDetailDrawer } from "../../../components/video/video-detail-drawer";
|
||||
import { DeleteVideoModal } from "../../../components/video/delete-video-modal";
|
||||
|
||||
/**
|
||||
* Video library + upload. Live mode pages through GET /video by cursor (audit
|
||||
* page's load-more pattern); offline the screen renders the sample library
|
||||
* read-only per useApiResource conventions. In-flight assets auto-refresh so
|
||||
* queued → probing → ready advances without a manual reload.
|
||||
*/
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
const IN_FLIGHT: VideoLibraryEntryDto["processingState"][] = ["queued", "probing", "transcoding"];
|
||||
const REFRESH_MS = 6_000;
|
||||
|
||||
/** A pick in flight: shows as an "uploading…" card until the API answers. */
|
||||
interface UploadDraft {
|
||||
key: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export default function VideoPage(): React.ReactElement {
|
||||
const { status: authStatus, can } = useAuth();
|
||||
const [items, setItems] = useState<VideoLibraryEntryDto[] | null>(null);
|
||||
const [source, setSource] = useState<"loading" | "live" | "sample">("loading");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<VideoLibraryEntryDto | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [optimistic, setOptimistic] = useState<UploadDraft[]>([]);
|
||||
const [notice, setNotice] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||
|
||||
const live = source === "live";
|
||||
const canManage = !live || can("media.manage");
|
||||
|
||||
const loadFirstPage = useCallback((signal: AbortSignal): void => {
|
||||
api
|
||||
.get<VideoLibraryPageDto>("/video", { limit: PAGE_SIZE }, signal)
|
||||
.then((page) => {
|
||||
setItems(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
setSource("live");
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (signal.aborted) return;
|
||||
if (err instanceof ApiUnreachableError) {
|
||||
setSource("sample");
|
||||
return;
|
||||
}
|
||||
setSource("live");
|
||||
setError(errorMessage(err));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authStatus === "loading") return;
|
||||
if (authStatus !== "authenticated") {
|
||||
setSource("sample");
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
loadFirstPage(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [authStatus, loadFirstPage]);
|
||||
|
||||
const anyInFlight = useMemo(
|
||||
() => (items ?? []).some((e) => IN_FLIGHT.includes(e.processingState)),
|
||||
[items],
|
||||
);
|
||||
|
||||
// Status refresh: keeps queued/probing/transcoding rows moving without a manual reload.
|
||||
useEffect(() => {
|
||||
if (!live || !anyInFlight) return;
|
||||
const timer = setInterval(() => loadFirstPage(new AbortController().signal), REFRESH_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [live, anyInFlight, loadFirstPage]);
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (nextCursor === undefined) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const page = await api.get<VideoLibraryPageDto>("/video", { cursor: nextCursor, limit: PAGE_SIZE });
|
||||
setItems((current) => {
|
||||
const seen = new Set((current ?? []).map((e) => e.videoAsset.id));
|
||||
return [...(current ?? []), ...page.items.filter((e) => !seen.has(e.videoAsset.id))];
|
||||
});
|
||||
setNextCursor(page.nextCursor);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onUploadStarted = useCallback((draft: UploadDraft): void => {
|
||||
setOptimistic((current) => [draft, ...current]);
|
||||
}, []);
|
||||
|
||||
const onUploadSettled = useCallback((key: string): void => {
|
||||
setOptimistic((current) => current.filter((d) => d.key !== key));
|
||||
}, []);
|
||||
|
||||
const applyStatus = useCallback((status: VideoStatusDto): void => {
|
||||
const entry: VideoLibraryEntryDto = {
|
||||
videoAsset: status.videoAsset,
|
||||
processingState: status.processingJob.state,
|
||||
publications: [],
|
||||
};
|
||||
setItems((current) => [entry, ...(current ?? []).filter((e) => e.videoAsset.id !== entry.videoAsset.id)]);
|
||||
setSelectedId(entry.videoAsset.id);
|
||||
}, []);
|
||||
|
||||
async function remove(entry: VideoLibraryEntryDto): Promise<void> {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.del<void>(`/video/${entry.videoAsset.id}`);
|
||||
setItems((current) => (current ?? []).filter((e) => e.videoAsset.id !== entry.videoAsset.id));
|
||||
if (selectedId === entry.videoAsset.id) setSelectedId(null);
|
||||
setConfirmDelete(null);
|
||||
setNotice({ tone: "ok", text: "Video deleted." });
|
||||
} catch (err) {
|
||||
setNotice({ tone: "error", text: errorMessage(err) });
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const visible = live ? (items ?? []) : SAMPLE_VIDEO_ENTRIES;
|
||||
const drafts = live ? optimistic : [];
|
||||
const selected = visible.find((e) => e.videoAsset.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6 scrollbar-thin">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">Video</h1>
|
||||
<OfflineBadge source={source} />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!live}
|
||||
onClick={() => loadFirstPage(new AbortController().signal)}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error !== null && <p className="mb-3 text-xs text-danger">{error}</p>}
|
||||
|
||||
<UploadDropzone
|
||||
live={live}
|
||||
canUpload={canManage}
|
||||
onStarted={onUploadStarted}
|
||||
onSettled={onUploadSettled}
|
||||
onUploaded={applyStatus}
|
||||
/>
|
||||
|
||||
{source === "loading" && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-faint">
|
||||
<Loading size={16} label="Loading video library" /> Loading video library…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source !== "loading" && visible.length === 0 && (
|
||||
<EmptyState
|
||||
title="No videos yet"
|
||||
hint="Drop a file above to upload it to this workspace. Videos are stored encrypted and probed before they are ready to attach to posts."
|
||||
/>
|
||||
)}
|
||||
|
||||
{source !== "loading" && (drafts.length > 0 || visible.length > 0) && (
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{drafts.map((draft) => (
|
||||
<UploadingCard key={draft.key} name={draft.name} detail={draft.detail} />
|
||||
))}
|
||||
{visible.map((entry) => (
|
||||
<VideoCard
|
||||
key={entry.videoAsset.id}
|
||||
entry={entry}
|
||||
active={entry.videoAsset.id === selectedId}
|
||||
onOpen={() => setSelectedId(entry.videoAsset.id)}
|
||||
onDelete={() => {
|
||||
if (!canManage) {
|
||||
setNotice({ tone: "error", text: "Deleting videos needs the media.manage permission." });
|
||||
return;
|
||||
}
|
||||
if (!live) {
|
||||
setNotice({ tone: "error", text: "Offline — sample data is read-only." });
|
||||
return;
|
||||
}
|
||||
setConfirmDelete(entry);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{live && nextCursor !== undefined && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={loadingMore} onClick={() => void loadMore()}>
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice !== null && (
|
||||
<p className={`mt-3 text-xs ${notice.tone === "ok" ? "text-live" : "text-danger"}`}>{notice.text}</p>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-2xs leading-relaxed text-text-faint">
|
||||
Videos are stored encrypted and probed on upload. The original file is retained;
|
||||
rendering and transcoding run separately in the media worker.
|
||||
</p>
|
||||
|
||||
{selected !== null && <VideoDetailDrawer key={selected.videoAsset.id} entry={selected} onClose={() => setSelectedId(null)} />}
|
||||
{confirmDelete !== null && (
|
||||
<DeleteVideoModal
|
||||
asset={confirmDelete.videoAsset}
|
||||
busy={deleting}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
onConfirm={() => void remove(confirmDelete)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/app/(main)/wiki/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useWikiLang, useWikiPages } from "../lang-context";
|
||||
import { WikiBody } from "../body";
|
||||
|
||||
interface WikiSlugPageProps {
|
||||
params: { slug: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* One manual: title, blurb, then every section in order, with prev/next
|
||||
* navigation in reading order. Unknown slugs render a "not found" panel that
|
||||
* links back to the table of contents.
|
||||
*/
|
||||
export default function WikiSlugPage({ params }: WikiSlugPageProps): React.ReactElement | null {
|
||||
const pages = useWikiPages();
|
||||
const { t } = useWikiLang();
|
||||
const index = pages.findIndex((page) => page.slug === params.slug);
|
||||
const page = index >= 0 ? pages[index] : undefined;
|
||||
|
||||
if (page === undefined) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border-subtle bg-surface p-6">
|
||||
<h1 className="mb-2 text-lg font-semibold">{t({ en: "Page not found", es: "Página no encontrada" })}</h1>
|
||||
<p className="mb-4 text-sm text-text-dim">
|
||||
{t({
|
||||
en: "That manual does not exist. Head back to the table of contents and pick from the list.",
|
||||
es: "Ese manual no existe. Vuelve al índice y elige de la lista.",
|
||||
})}
|
||||
</p>
|
||||
<Link href="/wiki" className="text-sm font-medium text-accent hover:underline">
|
||||
{t({ en: "← All manuals", es: "← Todos los manuales" })}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const previous = index > 0 ? pages[index - 1] : undefined;
|
||||
const next = index < pages.length - 1 ? pages[index + 1] : undefined;
|
||||
|
||||
return (
|
||||
<article>
|
||||
<Link href="/wiki" className="mb-3 inline-block text-2xs text-text-faint transition-colors duration-150 hover:text-accent">
|
||||
{t({ en: "← Contents", es: "← Contenido" })}
|
||||
</Link>
|
||||
<p className="mb-1 font-mono text-2xs font-bold uppercase tracking-wider text-accent">
|
||||
{t({ en: "Manual", es: "Manual" })} {String(index + 1).padStart(2, "0")} / {String(pages.length).padStart(2, "0")}
|
||||
</p>
|
||||
<h1 className="mb-2 text-2xl font-bold">{t(page.title)}</h1>
|
||||
<p className="mb-6 border-l-2 border-accent pl-3 text-sm leading-relaxed text-text-dim">{t(page.blurb)}</p>
|
||||
<div className="flex flex-col gap-7">
|
||||
{page.sections.map((section, sectionIndex) => (
|
||||
<section key={`${page.slug}-${sectionIndex}`}>
|
||||
<h2 className="mb-2 text-base font-semibold text-text">{t(section.heading)}</h2>
|
||||
<div className="text-sm text-text-dim">
|
||||
<WikiBody text={t(section.body)} />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<nav className="mt-10 flex flex-col gap-2 border-t border-border-subtle pt-5 sm:flex-row">
|
||||
{previous !== undefined ? (
|
||||
<Link
|
||||
href={`/wiki/${previous.slug}`}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border-subtle bg-surface p-3 transition-colors duration-150 hover:border-accent"
|
||||
>
|
||||
<span className="block text-2xs text-text-faint">{t({ en: "Previous", es: "Anterior" })}</span>
|
||||
<span className="block truncate text-sm font-medium">{t(previous.title)}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="hidden min-w-0 flex-1 sm:block" />
|
||||
)}
|
||||
{next !== undefined ? (
|
||||
<Link
|
||||
href={`/wiki/${next.slug}`}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border-subtle bg-surface p-3 text-right transition-colors duration-150 hover:border-accent"
|
||||
>
|
||||
<span className="block text-2xs text-text-faint">{t({ en: "Next", es: "Siguiente" })}</span>
|
||||
<span className="block truncate text-sm font-medium">{t(next.title)}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="hidden min-w-0 flex-1 sm:block" />
|
||||
)}
|
||||
</nav>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/app/(main)/wiki/body.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Minimal markup for the wiki bodies, matching what the content file promises:
|
||||
* blank-line paragraphs, "- " bullets, numbered steps, and **bold** for
|
||||
* on-screen labels. Kept deliberately small — no HTML from content is ever
|
||||
* injected, so manuals stay safe to edit for non-technical contributors.
|
||||
*/
|
||||
|
||||
function inline(text: string, keyPrefix: string): ReactNode[] {
|
||||
return text.split(/(\*\*[^*]+\*\*)/g).filter(Boolean).map((part, index) =>
|
||||
part.startsWith("**") && part.endsWith("**") && part.length > 4 ? (
|
||||
<strong key={`${keyPrefix}-${index}`} className="font-semibold text-text">
|
||||
{part.slice(2, -2)}
|
||||
</strong>
|
||||
) : (
|
||||
<Fragment key={`${keyPrefix}-${index}`}>{part}</Fragment>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderBlock(block: string, key: string): ReactNode {
|
||||
const lines = block.split("\n");
|
||||
const isBullets = lines.every((line) => line.startsWith("- "));
|
||||
const isSteps = lines.length > 0 && lines.every((line) => /^\d+\.\s/.test(line));
|
||||
if (isBullets) {
|
||||
return (
|
||||
<ul key={key} className="my-3 list-disc space-y-1.5 pl-5 marker:text-accent">
|
||||
{lines.map((line, index) => (
|
||||
<li key={`${key}-${index}`}>{inline(line.slice(2), `${key}-${index}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
if (isSteps) {
|
||||
return (
|
||||
<ol key={key} className="my-3 list-decimal space-y-1.5 pl-5 marker:font-mono marker:text-text-faint">
|
||||
{lines.map((line, index) => (
|
||||
<li key={`${key}-${index}`}>{inline(line.replace(/^\d+\.\s/, ""), `${key}-${index}`)}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
return <p key={key}>{inline(block, key)}</p>;
|
||||
}
|
||||
|
||||
export function WikiBody({ text }: { text: string }): React.ReactElement {
|
||||
const blocks = text.split(/\n{2,}/).filter((block) => block.trim().length > 0);
|
||||
return <div className="space-y-1.5 leading-relaxed">{blocks.map((block, index) => renderBlock(block, `b${index}`))}</div>;
|
||||
}
|
||||
65
apps/web/src/app/(main)/wiki/lang-context.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { WIKI_PAGES, type LocalizedText } from "../../../lib/wiki-content";
|
||||
|
||||
export type WikiLang = "en" | "es";
|
||||
|
||||
interface WikiLangContextValue {
|
||||
lang: WikiLang;
|
||||
setLang: (lang: WikiLang) => void;
|
||||
/** Pick the string for the current language — the one helper every wiki screen uses. */
|
||||
t: (text: LocalizedText) => string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "tmbh.wiki.lang";
|
||||
const WikiLangContext = createContext<WikiLangContextValue | null>(null);
|
||||
|
||||
function readStoredLang(): WikiLang {
|
||||
if (typeof window === "undefined") return "en";
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored === "es" ? "es" : "en";
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Language toggle for the wiki only. The choice is per-browser (localStorage)
|
||||
* and never sent to the server: it is a reading preference, not an account
|
||||
* setting, and the app has no per-user preference store to put it in.
|
||||
*/
|
||||
export function WikiLangProvider({ children }: { children: ReactNode }): React.ReactElement {
|
||||
const [lang, setLangState] = useState<WikiLang>("en");
|
||||
|
||||
useEffect(() => {
|
||||
setLangState(readStoredLang());
|
||||
}, []);
|
||||
|
||||
const setLang = useCallback((next: WikiLang) => {
|
||||
setLangState(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Private-browsing modes can refuse storage; the choice still holds for
|
||||
// this screen, it just will not survive a reload.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const t = useCallback((text: LocalizedText) => text[lang], [lang]);
|
||||
|
||||
const value = useMemo<WikiLangContextValue>(() => ({ lang, setLang, t }), [lang, setLang, t]);
|
||||
return <WikiLangContext.Provider value={value}>{children}</WikiLangContext.Provider>;
|
||||
}
|
||||
|
||||
export function useWikiLang(): WikiLangContextValue {
|
||||
const context = useContext(WikiLangContext);
|
||||
if (context === null) throw new Error("useWikiLang must be used inside WikiLangProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
/** The page list in reading order — sidebar, table of contents and prev/next all share it. */
|
||||
export function useWikiPages() {
|
||||
return WIKI_PAGES;
|
||||
}
|
||||
73
apps/web/src/app/(main)/wiki/layout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useWikiLang, useWikiPages } from "./lang-context";
|
||||
|
||||
/**
|
||||
* Wiki shell: page list on the left (collapses to a top strip on mobile) and
|
||||
* the EN/ES toggle. The language lives in localStorage per browser — see
|
||||
* lang-context for why it is not an account setting.
|
||||
*/
|
||||
export default function WikiLayout({ children }: { children: ReactNode }): React.ReactElement {
|
||||
const pages = useWikiPages();
|
||||
const { lang, setLang, t } = useWikiLang();
|
||||
const pathname = usePathname();
|
||||
const activeSlug = pathname.split("/")[2] ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col lg:flex-row">
|
||||
<aside className="flex min-h-0 shrink-0 flex-col border-b border-border-subtle bg-surface lg:w-64 lg:border-b-0 lg:border-r">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Link href="/wiki" className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-bold">
|
||||
{t({ en: "Help & docs", es: "Ayuda y documentación" })}
|
||||
</span>
|
||||
<span className="block truncate text-2xs text-text-faint">
|
||||
{t({ en: "TMBH manuals", es: "Manuales de TMBH" })}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Language">
|
||||
{(["en", "es"] as const).map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => setLang(code)}
|
||||
aria-pressed={lang === code}
|
||||
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
|
||||
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-3 lg:pb-6">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{pages.map((page, index) => {
|
||||
const active = page.slug === activeSlug;
|
||||
return (
|
||||
<li key={page.slug}>
|
||||
<Link
|
||||
href={`/wiki/${page.slug}`}
|
||||
className={`flex min-h-9 items-center gap-2 rounded-md px-2.5 py-1.5 text-xs transition-colors duration-150 ${
|
||||
active ? "bg-accent-dim font-semibold text-accent" : "text-text-dim hover:bg-surface-2 hover:text-text"
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 shrink-0 text-right font-mono text-2xs text-text-faint">{index + 1}</span>
|
||||
<span className="min-w-0 truncate">{t(page.title)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl px-4 py-6 pb-24 lg:px-8 lg:py-8">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/app/(main)/wiki/page.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useWikiLang, useWikiPages } from "./lang-context";
|
||||
|
||||
/**
|
||||
* Wiki home: table of contents with every manual title and a one-line summary.
|
||||
* Numbered so a printed or shared copy reads as a course, not a link dump.
|
||||
*/
|
||||
export default function WikiHomePage(): React.ReactElement {
|
||||
const pages = useWikiPages();
|
||||
const { t } = useWikiLang();
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1 text-2xs font-bold uppercase tracking-wider text-accent">
|
||||
{t({ en: "Documentation", es: "Documentación" })}
|
||||
</p>
|
||||
<h1 className="mb-2 text-2xl font-bold">{t({ en: "TMBH manual", es: "Manual de TMBH" })}</h1>
|
||||
<p className="mb-6 text-sm leading-relaxed text-text-dim">
|
||||
{t({
|
||||
en: "Step-by-step guides for every part of the newsroom, written for people who organise — not for developers. Switch language with the EN / ES buttons above; your choice is remembered on this device.",
|
||||
es: "Guías paso a paso de cada parte de la redacción, escritas para gente que organiza, no para programadores. Cambia de idioma con los botones EN / ES de arriba; tu elección queda recordada en este dispositivo.",
|
||||
})}
|
||||
</p>
|
||||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wider text-text-faint">
|
||||
{t({ en: "Contents", es: "Contenido" })}
|
||||
</h2>
|
||||
<ol className="flex flex-col gap-2.5">
|
||||
{pages.map((page, index) => (
|
||||
<li key={page.slug}>
|
||||
<Link
|
||||
href={`/wiki/${page.slug}`}
|
||||
className="flex gap-3 rounded-lg border border-border-subtle bg-surface p-3.5 transition-colors duration-150 hover:border-accent"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-xs font-bold text-accent">{String(index + 1).padStart(2, "0")}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold">{t(page.title)}</span>
|
||||
<span className="mt-0.5 block text-xs leading-relaxed text-text-faint">{t(page.blurb)}</span>
|
||||
</span>
|
||||
<span aria-hidden="true" className="ml-auto self-center text-text-faint">›</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/app/globals.css
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg: oklch(0.11 0.008 25);
|
||||
--color-surface: oklch(0.15 0.01 25);
|
||||
--color-surface-2: oklch(0.19 0.012 25);
|
||||
--color-border: oklch(0.26 0.02 25);
|
||||
--color-border-subtle: oklch(0.22 0.015 25);
|
||||
--color-text: oklch(0.93 0.005 25);
|
||||
--color-text-dim: oklch(0.62 0.02 25);
|
||||
--color-text-faint: oklch(0.46 0.02 25);
|
||||
--color-accent: #ff1610;
|
||||
--color-accent-hover: #e3130e;
|
||||
--color-accent-dim: rgb(255 22 16 / 0.15);
|
||||
--color-live: oklch(0.72 0.19 145);
|
||||
--color-warn: oklch(0.78 0.16 85);
|
||||
--color-danger: oklch(0.55 0.24 15);
|
||||
--color-added: oklch(0.72 0.16 145);
|
||||
--color-removed: oklch(0.55 0.24 15);
|
||||
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
|
||||
--text-2xs: 0.6875rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.875rem;
|
||||
--text-lg: 1rem;
|
||||
|
||||
--mobile-tab-height: 76px;
|
||||
--mobile-fab-size: 64px;
|
||||
--mobile-sheet-radius: 28px;
|
||||
--mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-brand: var(--color-accent);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html, body { max-width: 100%; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
body { overflow-x: hidden; }
|
||||
[data-mobile-main] :is(button, a[href], input, select, textarea, [role="tab"]) {
|
||||
min-height: 44px;
|
||||
}
|
||||
[data-mobile-main] :is(button, a[href], [role="tab"]) {
|
||||
min-width: 44px;
|
||||
}
|
||||
[data-mobile-main] p a[href] { display: inline-flex; align-items: center; }
|
||||
[data-mobile-main] .mobile-stack-table,
|
||||
[data-mobile-main] .mobile-stack-table tbody,
|
||||
[data-mobile-main] .mobile-stack-table tr,
|
||||
[data-mobile-main] .mobile-stack-table td { display: block; width: 100%; min-width: 0; }
|
||||
[data-mobile-main] .mobile-stack-table thead { display: none; }
|
||||
[data-mobile-main] .mobile-stack-table tr { padding: 8px 0; }
|
||||
[data-mobile-main] .mobile-stack-table td { overflow-wrap: anywhere; }
|
||||
[data-mobile-main] .mobile-stack-table td > * { max-width: 100%; }
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-accent-dim);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
328
apps/web/src/app/join/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
|
||||
import { LoadingPanel } from "../../../components/brand/loading";
|
||||
import { apiFetch, ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
|
||||
import type { InviteInfo } from "../../../lib/api-types";
|
||||
import { useAuth } from "../../../lib/auth-store";
|
||||
|
||||
const MIN_PASSWORD = 10;
|
||||
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9_.-]{1,31}$/;
|
||||
const USERNAME_TITLE = "2-32 characters: lowercase letters, digits, dot, underscore or hyphen";
|
||||
const API_UNREACHABLE = "API unreachable — check that the API server is running.";
|
||||
|
||||
type Phase = "loading" | "preview" | "dead" | "unreachable";
|
||||
/** Why the link no longer works — drives the copy on the dead-token card. */
|
||||
type DeadReason = "unknown" | "expired" | "exhausted";
|
||||
|
||||
/** 404 (unknown/revoked) vs 410 (expired or exhausted, told apart by the message). Anything else reads as unknown. */
|
||||
function deadReasonFrom(err: unknown): DeadReason {
|
||||
if (err instanceof ApiError && err.status === 410) {
|
||||
const message = err.message.toLowerCase();
|
||||
if (message.includes("expired")) return "expired";
|
||||
if (message.includes("used up") || message.includes("no uses left")) return "exhausted";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const isAlreadyMember = (err: unknown): boolean => err instanceof ApiError && err.status === 409;
|
||||
|
||||
function usesLeftLine(info: InviteInfo): string {
|
||||
const left = info.maxUses === null ? "unlimited uses" : `${Math.max(info.maxUses - info.usedCount, 0)} use${info.maxUses - info.usedCount === 1 ? "" : "s"} left`;
|
||||
return `${left} · expires ${new Date(info.expiresAt).toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
/** Sign in with an existing account, then claim: login installs the account session, claimInvite swaps it for the workspace one. */
|
||||
function JoinSignInForm({
|
||||
token,
|
||||
onSwitch,
|
||||
onAlreadyMember,
|
||||
}: {
|
||||
token: string;
|
||||
onSwitch: () => void;
|
||||
onAlreadyMember: () => void;
|
||||
}): React.ReactElement {
|
||||
const { login, claimInvite } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(username.trim(), password);
|
||||
await claimInvite(token);
|
||||
window.location.assign("/streams");
|
||||
} catch (err) {
|
||||
// Login worked but the workspace already holds this user: that is a success state.
|
||||
if (isAlreadyMember(err)) {
|
||||
setBusy(false);
|
||||
onAlreadyMember();
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => void submit(e)}>
|
||||
<h2 className="mb-1 text-base font-semibold">Sign in</h2>
|
||||
<p className="mb-4 text-xs text-text-faint">Use the username and password you registered with.</p>
|
||||
<div className="space-y-2">
|
||||
<Input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" autoComplete="username" autoFocus />
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" autoComplete="current-password" />
|
||||
</div>
|
||||
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={onSwitch}>New here? Create an account</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || username.trim() === "" || password === ""}>
|
||||
{busy ? "Joining…" : "Sign in & join"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create-or-authenticate inline, then claim. Registering normally spins up a
|
||||
* new workspace, so the claim endpoint takes the credentials itself and hands
|
||||
* back a session issued in the invited workspace.
|
||||
*/
|
||||
function RegisterToJoinForm({
|
||||
token,
|
||||
onSwitch,
|
||||
onAlreadyMember,
|
||||
}: {
|
||||
token: string;
|
||||
onSwitch: () => void;
|
||||
onAlreadyMember: () => void;
|
||||
}): React.ReactElement {
|
||||
const { claimInviteWithCredentials } = useAuth();
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await claimInviteWithCredentials(token, {
|
||||
username: username.trim(),
|
||||
password,
|
||||
...(displayName.trim() === "" ? {} : { displayName: displayName.trim() }),
|
||||
...(email.trim() === "" ? {} : { email: email.trim() }),
|
||||
});
|
||||
window.location.assign("/streams");
|
||||
} catch (err) {
|
||||
if (isAlreadyMember(err)) {
|
||||
setBusy(false);
|
||||
onAlreadyMember();
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(err));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const ready = USERNAME_PATTERN.test(username.trim()) && password.length >= MIN_PASSWORD;
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => void submit(e)}>
|
||||
<h2 className="mb-1 text-base font-semibold">Create your account</h2>
|
||||
<p className="mb-4 text-xs text-text-faint">
|
||||
Registering accepts the invite in one step — no workspace of your own is created.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your name (optional)" autoComplete="name" />
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase())}
|
||||
placeholder="Username — this is how you sign in"
|
||||
autoComplete="username"
|
||||
pattern="[a-z0-9][a-z0-9_.-]{1,31}"
|
||||
title={USERNAME_TITLE}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={`Password (min ${MIN_PASSWORD} characters)`}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional — password recovery)" autoComplete="email" />
|
||||
</div>
|
||||
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button type="button" variant="ghost" onClick={onSwitch}>I already have an account</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || !ready}>
|
||||
{busy ? "Joining…" : "Join workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JoinWorkspacePage({ params }: { params: { token: string } }): React.ReactElement {
|
||||
const { status, session, isLoading, claimInvite } = useAuth();
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [info, setInfo] = useState<InviteInfo | null>(null);
|
||||
const [deadReason, setDeadReason] = useState<DeadReason>("unknown");
|
||||
const [mode, setMode] = useState<"register" | "signin">("register");
|
||||
const [claiming, setClaiming] = useState(false);
|
||||
const [claimError, setClaimError] = useState<string | null>(null);
|
||||
const [alreadyMember, setAlreadyMember] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
// The preview is public: fetch it immediately rather than waiting for the session to settle.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPhase("loading");
|
||||
apiFetch<InviteInfo>(`/invites/${encodeURIComponent(params.token)}/info`, { redirectOn401: false })
|
||||
.then((preview) => {
|
||||
if (cancelled) return;
|
||||
setInfo(preview);
|
||||
setPhase("preview");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
if (err instanceof ApiUnreachableError) setPhase("unreachable");
|
||||
else {
|
||||
setDeadReason(deadReasonFrom(err));
|
||||
setPhase("dead");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [params.token, attempt]);
|
||||
|
||||
const retry = (): void => setAttempt((n) => n + 1);
|
||||
|
||||
async function claim(): Promise<void> {
|
||||
setClaiming(true);
|
||||
setClaimError(null);
|
||||
try {
|
||||
await claimInvite(params.token);
|
||||
// Full navigation so the (main) shell mounts with the freshly installed session.
|
||||
window.location.assign("/streams");
|
||||
} catch (err) {
|
||||
if (isAlreadyMember(err)) {
|
||||
setAlreadyMember(true);
|
||||
setClaiming(false);
|
||||
return;
|
||||
}
|
||||
setClaimError(errorMessage(err));
|
||||
setClaiming(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === "loading" || isLoading) return <LoadingPanel label="Checking invite…" />;
|
||||
|
||||
if (phase === "unreachable") {
|
||||
return (
|
||||
<Card className="w-[520px] max-w-full p-6 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-warn/15 text-lg text-warn">!</div>
|
||||
<h1 className="text-base font-semibold">Can't reach the server</h1>
|
||||
<p className="mt-1 text-xs text-text-faint">
|
||||
{API_UNREACHABLE} Your invite link is still fine — try again.
|
||||
</p>
|
||||
<Button variant="outline" className="mt-6" onClick={retry}>Retry</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "dead") {
|
||||
return (
|
||||
<Card className="w-[520px] max-w-full p-6 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-danger/15 text-lg text-danger">✕</div>
|
||||
<h1 className="text-base font-semibold">This invite link isn't valid</h1>
|
||||
<p className="mt-1 text-xs text-text-faint">
|
||||
{deadReason === "expired"
|
||||
? "It expired."
|
||||
: deadReason === "exhausted"
|
||||
? "It has already been used up."
|
||||
: "It was revoked or never existed."}{" "}
|
||||
Ask a workspace owner for a fresh invite.
|
||||
</p>
|
||||
<Link href="/onboarding" className="mt-6 inline-block rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
|
||||
Start or sign in to your own workspace
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (info === null) return <LoadingPanel label="Checking invite…" />;
|
||||
|
||||
const memberCta = alreadyMember ? (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<span className="text-xs text-text-faint">You're already in this workspace.</span>
|
||||
<Link href="/streams" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">Go to Streams</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<span className="text-2xs text-text-faint">Signed in as @{session?.user.username}</span>
|
||||
<Button variant="primary" disabled={claiming} onClick={() => void claim()}>
|
||||
{claiming ? "Joining…" : "Join workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Signed in already: one click claims the invite with the current session.
|
||||
if (status === "authenticated" && session !== null) {
|
||||
return (
|
||||
<Card className="w-[520px] max-w-full p-6">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
|
||||
<p className="mt-1 text-xs text-text-faint">
|
||||
You've been invited to join <span className="text-text">{info.workspaceName}</span> as{" "}
|
||||
<Badge tone="accent">{info.role}</Badge>.
|
||||
</p>
|
||||
<p className="mt-2 text-2xs text-text-faint">{usesLeftLine(info)}</p>
|
||||
{claimError !== null && <p className="mt-2 text-xs text-danger">{claimError}</p>}
|
||||
{memberCta}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Anonymous (or the API dropped between mount and now): hero + register/sign-in forms.
|
||||
return (
|
||||
<Card className="w-[520px] max-w-full p-6">
|
||||
<div className="mb-5 text-center">
|
||||
<img
|
||||
src="/brand/hero.png"
|
||||
alt="Tactical Media Broadcast Hub — arrows from a circle, warm red on black"
|
||||
width={1024}
|
||||
height={1024}
|
||||
className="mx-auto mb-3 h-20 w-20 rounded-lg object-cover"
|
||||
/>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
|
||||
<p className="mt-1 text-2xs text-text-faint">
|
||||
You've been invited as <Badge tone="accent">{info.role}</Badge> · {usesLeftLine(info)}
|
||||
</p>
|
||||
</div>
|
||||
{mode === "signin" ? (
|
||||
<JoinSignInForm token={params.token} onSwitch={() => setMode("register")} onAlreadyMember={() => setAlreadyMember(true)} />
|
||||
) : (
|
||||
<RegisterToJoinForm token={params.token} onSwitch={() => setMode("signin")} onAlreadyMember={() => setAlreadyMember(true)} />
|
||||
)}
|
||||
{alreadyMember && (
|
||||
<div className="mt-5 border-t border-border-subtle pt-4 text-center">
|
||||
<p className="text-xs text-text-faint">You're already in this workspace.</p>
|
||||
<Link href="/streams" className="mt-2 inline-block rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
|
||||
Go to Streams
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/app/join/layout.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider } from "../../lib/auth-store";
|
||||
|
||||
/**
|
||||
* Anonymous-safe layout for the invite claim flow. It deliberately lives
|
||||
* outside the `(main)` group: that layout mounts the app Shell and wraps
|
||||
* children in `AuthGate`, which bounces anonymous visitors to /onboarding —
|
||||
* the opposite of what a shared invite link needs. Here we mount only
|
||||
* `AuthProvider` so the page can branch on "already signed in" vs anonymous.
|
||||
*/
|
||||
export default function JoinLayout({ children }: { children: ReactNode }): React.ReactNode {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<div className="flex h-dvh items-center justify-center overflow-y-auto bg-bg p-6">{children}</div>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tactical Media Broadcast Hub",
|
||||
description: "Tactical Media Broadcast Hub — post, schedule and amplify across every network you refuse to be owned by.",
|
||||
icons: { icon: "/brand/favicon.svg" },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
interactiveWidget: "resizes-content",
|
||||
themeColor: "#FF1610",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }): React.ReactNode {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
apps/web/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage(): never {
|
||||
redirect("/streams");
|
||||
}
|
||||
50
apps/web/src/components/auth/auth-gate.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuth } from "../../lib/auth-store";
|
||||
import { ONBOARDING_PATH } from "../../lib/api-client";
|
||||
import { LoadingPanel } from "../brand/loading";
|
||||
|
||||
/** Sends visitors without a session to onboarding. Offline (API unreachable) renders sample-data screens instead. */
|
||||
export function AuthGate({ children }: { children: ReactNode }): React.ReactElement {
|
||||
const { status } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const onOnboarding = pathname === ONBOARDING_PATH;
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "anonymous" && !onOnboarding) router.replace(ONBOARDING_PATH);
|
||||
}, [status, onOnboarding, router]);
|
||||
|
||||
if (!onOnboarding && (status === "loading" || status === "anonymous")) {
|
||||
return <LoadingPanel label="Checking session…" />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function SessionStatus(): React.ReactElement {
|
||||
const { status, session, logout } = useAuth();
|
||||
if (status === "offline") {
|
||||
return (
|
||||
<span className="flex items-center gap-2" title="API unreachable — screens show sample data">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-warn" />
|
||||
offline — sample data
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status !== "authenticated" || session === null) {
|
||||
return <span>{status === "loading" ? "connecting…" : "signed out"}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-live" />
|
||||
@{session.user.username} · {session.role}
|
||||
</span>
|
||||
<button onClick={() => void logout()} className="text-text-dim transition-colors duration-150 hover:text-accent">
|
||||
Sign out
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/components/brand/empty-state.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ReactElement } from "react";
|
||||
import { CircleA } from "./logo";
|
||||
|
||||
/**
|
||||
* Branded empty state: the circle-A outline sitting low-contrast behind the
|
||||
* message, the way a screen-printed mark sits behind text on a black tee.
|
||||
*/
|
||||
export function EmptyState({
|
||||
title,
|
||||
hint,
|
||||
size = 64,
|
||||
className = "",
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center gap-3 py-8 text-center ${className}`}>
|
||||
<CircleA size={size} className="text-accent/30" />
|
||||
<p className="text-sm font-medium text-text-dim">{title}</p>
|
||||
{hint !== undefined && (
|
||||
<p className="max-w-sm text-2xs leading-relaxed text-text-faint">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/web/src/components/brand/index.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { ReactElement } from "react";
|
||||
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
withText?: boolean;
|
||||
}
|
||||
|
||||
export function Logo({ size = 32, withText = false }: LogoProps): ReactElement {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg width={size} height={size} viewBox="0 0 1200 1200" aria-label="Tactical Media Broadcast Hub">
|
||||
<g fill="none" stroke="var(--color-accent)" strokeWidth="72" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="600" cy="610" r="330" />
|
||||
<path d="M 365 915 L 600 250 L 835 915" strokeWidth="92" />
|
||||
<path d="M 250 610 L 950 610" strokeWidth="82" />
|
||||
</g>
|
||||
<g fill="var(--color-accent)">
|
||||
<polygon points="600.0,145.0 660.0,270.0 540.0,270.0" />
|
||||
<polygon points="125.0,610.0 250.0,550.0 250.0,670.0" />
|
||||
<polygon points="1075.0,610.0 950.0,670.0 950.0,550.0" />
|
||||
<polygon points="285.0,1050.0 331.0,919.2 415.8,1004.0" />
|
||||
<polygon points="915.0,1050.0 784.2,1004.0 869.0,919.2" />
|
||||
</g>
|
||||
</svg>
|
||||
{withText && (
|
||||
<span className="text-sm font-bold tracking-tight" style={{ color: "var(--color-accent)" }}>
|
||||
Tactical Media Broadcast Hub
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ size = 24 }: { size?: number }): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1200 1200"
|
||||
className="animate-spin"
|
||||
style={{ animationDuration: "1.2s" }}
|
||||
aria-label="Loading"
|
||||
>
|
||||
<path d="M 600 280 A 330 330 0 0 1 930 610" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" />
|
||||
<path d="M 930 610 A 330 330 0 0 1 600 940" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
|
||||
<path d="M 600 940 A 330 330 0 0 1 270 610" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
|
||||
<path d="M 270 610 A 330 330 0 0 1 600 280" fill="none" stroke="var(--color-accent)" strokeWidth="90" strokeLinecap="round" opacity="0.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ message, size = 120 }: { message: string; size?: number }): ReactElement {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<svg width={size} height={size} viewBox="0 0 1200 1200" opacity="0.25">
|
||||
<g fill="none" stroke="var(--color-accent)" strokeWidth="80" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="600" cy="610" r="330" />
|
||||
<path d="M 365 915 L 600 250 L 835 915" strokeWidth="100" />
|
||||
<path d="M 250 610 L 950 610" strokeWidth="80" />
|
||||
</g>
|
||||
</svg>
|
||||
<p className="text-xs text-text-faint">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformIcon({ platform, size = 20 }: { platform: string; size?: number }): ReactElement {
|
||||
return (
|
||||
<img
|
||||
src={`/brand/icon-${platform}.svg`}
|
||||
alt={platform}
|
||||
width={size}
|
||||
height={size}
|
||||
className="rounded-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavIcon({ name, size = 18 }: { name: string; size?: number }): ReactElement {
|
||||
return (
|
||||
<img
|
||||
src={`/brand/nav-${name}.svg`}
|
||||
alt={name}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
62
apps/web/src/components/brand/loading.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { ReactElement } from "react";
|
||||
import { LogoMark } from "./logo";
|
||||
|
||||
/**
|
||||
* Branded loading state: the mark upright inside a spinning ring.
|
||||
*
|
||||
* The mark itself is radially asymmetric (the A's apex sits high in its
|
||||
* bounding box), so rotating the mark would wobble instead of spin. The ring
|
||||
* carries the rotation; the A stays plumb. `animation` rather than
|
||||
* Tailwind's animate-spin so globals.css's prefers-reduced-motion override
|
||||
* (0.01ms) still applies via the transition/animation shorthand.
|
||||
*/
|
||||
export function Loading({
|
||||
size = 28,
|
||||
label = "Loading…",
|
||||
className = "",
|
||||
}: {
|
||||
size?: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={`relative inline-flex items-center justify-center ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 text-accent"
|
||||
style={{ animation: "anp-spin 0.9s linear infinite" }}
|
||||
>
|
||||
{/* Three-quarter arc: the gap becomes the motion cue. */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="44"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="207 276"
|
||||
/>
|
||||
</svg>
|
||||
<LogoMark size={Math.round(size * 0.5)} className="text-accent" title="" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-panel variant for screens that are still fetching their first page. */
|
||||
export function LoadingPanel({ label }: { label?: string }): ReactElement {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<Loading size={32} label={label ?? "Loading…"} />
|
||||
{label !== undefined && <span className="text-2xs text-text-faint">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
apps/web/src/components/brand/logo.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { ReactElement } from "react";
|
||||
|
||||
/**
|
||||
* The Tactical Media Broadcast Hub mark, inline.
|
||||
*
|
||||
* Source of truth: `public/brand/tactical-media-broadcast-hub.svg` — the arrows-from-circle
|
||||
* mark, tilted −17.5° counterclockwise so the top arrow points upper-left
|
||||
* (nothing static; everything points outward). The strokes use `currentColor`
|
||||
* so the mark always rides the workspace accent, which in the warm palette
|
||||
* lands within a hair of the brand red (#FF1610 ≈ oklch(0.63 0.24 29) vs the
|
||||
* token's oklch(0.62 0.22 30)). Geometry is copied 1:1 from the asset on its
|
||||
* 1700×1700 grid, minus the black plate and the screen-print distress paths
|
||||
* (those only read on pure black at full size).
|
||||
*/
|
||||
|
||||
export function LogoMark({
|
||||
size = 32,
|
||||
className = "text-accent",
|
||||
title = "Tactical Media Broadcast Hub",
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1700 1700"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className={className}
|
||||
>
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="850" cy="850" r="350" strokeWidth="74" />
|
||||
<path d="M 850 405 L 464.77 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 1235.23 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 850 350" strokeWidth="88" />
|
||||
<path d="M 345 850 L 1355 850" strokeWidth="88" />
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<polygon points="850,240 925,385 775,385" />
|
||||
<polygon points="235,850 380,775 380,925" />
|
||||
<polygon points="1465,850 1320,925 1320,775" />
|
||||
<polygon points="420,1370 410.51,1207.03 547.52,1268.08" />
|
||||
<polygon points="1280,1370 1152.48,1268.08 1289.49,1207.03" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The quiet version of the mark: just the circle-A outline, no arrowheads.
|
||||
* Used wherever the brand needs to whisper — empty states, placeholders.
|
||||
*/
|
||||
export function CircleA({
|
||||
size = 64,
|
||||
className = "text-accent",
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 1700 1700"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<g transform="rotate(-17.5 850 850)">
|
||||
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="850" cy="850" r="350" strokeWidth="74" />
|
||||
<path d="M 850 405 L 464.77 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 1235.23 1269.52" strokeWidth="88" />
|
||||
<path d="M 850 405 L 850 350" strokeWidth="88" />
|
||||
<path d="M 345 850 L 1355 850" strokeWidth="88" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single arrowhead lifted from the mark — the brand's bullet point.
|
||||
* Points the same way the right-hand barb of the circle-A does.
|
||||
*/
|
||||
export function ArrowGlyph({
|
||||
size = 12,
|
||||
className = "text-accent",
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 120 120" aria-hidden="true" className={className}>
|
||||
<polygon points="120,60 30,14 30,106" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark + wordmark. Inline below ~96px (sidebar, palette); stacked above it
|
||||
* (onboarding hero) so the wordmark never gets dwarfed by the mark.
|
||||
*/
|
||||
export function Logo({ size = 32, withText = true }: { size?: number; withText?: boolean }): ReactElement {
|
||||
if (!withText) return <LogoMark size={size} />;
|
||||
const stacked = size >= 96;
|
||||
return (
|
||||
<span className={`inline-flex ${stacked ? "flex-col items-center gap-3" : "items-center gap-2.5"}`}>
|
||||
<LogoMark size={size} />
|
||||
<span
|
||||
className={`font-semibold tracking-tight text-text ${stacked ? "text-2xl" : ""}`}
|
||||
style={stacked ? undefined : { fontSize: `${Math.max(13, Math.round(size * 0.45))}px` }}
|
||||
>
|
||||
Tactical Media Broadcast Hub
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/connections/capacity-chip.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Button } from "../ui/primitives";
|
||||
import { DOCUMENTED_RATES, type ConnectionCapacity } from "../../lib/platforms";
|
||||
import { capacitySnapshot, humanizeMs } from "@tactical-media-broadcast-hub/connector-sdk/rate-policy";
|
||||
|
||||
function snapshotFor(cap: ConnectionCapacity) {
|
||||
const documented = DOCUMENTED_RATES[cap.platform] ?? {};
|
||||
const policy = {
|
||||
requestsPerSecond: documented.requestsPerSecond ?? 1,
|
||||
dailyPostCap: cap.resolvedCap,
|
||||
};
|
||||
return capacitySnapshot({ used: cap.used, cap: cap.resolvedCap, recentPublishes: cap.recentPublishes }, policy);
|
||||
}
|
||||
|
||||
export function CapacityChip({ cap }: { cap: ConnectionCapacity }): React.ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const snap = useMemo(() => snapshotFor(cap), [cap]);
|
||||
const documented = DOCUMENTED_RATES[cap.platform] ?? {};
|
||||
const overDocumented =
|
||||
cap.overrideCap !== undefined && documented.dailyPostCap !== undefined && cap.overrideCap > documented.dailyPostCap;
|
||||
|
||||
const tone = snap.remaining === 0 ? "danger" : snap.remaining !== undefined && snap.remaining <= 3 ? "warn" : "live";
|
||||
|
||||
return (
|
||||
<span className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Post capacity — click for details"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2.5 py-1 font-mono text-2xs text-text-dim transition-colors duration-150 hover:border hover:border-accent hover:text-accent"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${tone === "danger" ? "bg-danger" : tone === "warn" ? "bg-warn" : "bg-live"}`} />
|
||||
{snap.remaining !== undefined ? `${snap.remaining}/${snap.cap} posts left` : `${snap.used} today · no cap`}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-8 z-30 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold">Rolling 24h capacity</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>×</Button>
|
||||
</div>
|
||||
|
||||
{snap.remaining !== undefined ? (
|
||||
<>
|
||||
<div className="mb-2 h-2 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
tone === "danger" ? "bg-danger" : tone === "warn" ? "bg-warn" : "bg-accent"
|
||||
}`}
|
||||
style={{ width: `${Math.round((snap.used / (snap.cap ?? 1)) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 text-2xs">
|
||||
<span className="text-text-faint">used</span>
|
||||
<span className="text-right font-mono">{snap.used}</span>
|
||||
<span className="text-text-faint">cap</span>
|
||||
<span className="text-right font-mono">{snap.cap}</span>
|
||||
<span className="text-text-faint">remaining</span>
|
||||
<span className="text-right font-mono">{snap.remaining}</span>
|
||||
</div>
|
||||
<div className="mt-2 border-t border-border-subtle pt-2 text-2xs leading-relaxed">
|
||||
{snap.nextSlotInMs !== undefined ? (
|
||||
<>
|
||||
<span className="text-warn">next slot frees in {humanizeMs(snap.nextSlotInMs)}</span>
|
||||
<span className="text-text-faint"> — the oldest post exits the 24h window at that point, then continuously as others age out.</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-live">slots available now — each post holds its slot for 24h, then frees it</span>
|
||||
)}
|
||||
</div>
|
||||
{cap.tierNote !== undefined && (
|
||||
<div className="mt-2 border-t border-border-subtle pt-2">
|
||||
<Badge tone="accent">{cap.tierNote}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{overDocumented && (
|
||||
<div className="mt-1.5 text-2xs leading-relaxed text-warn">
|
||||
Cap exceeds documented standard ({documented.dailyPostCap}/24h). Your API tier must genuinely
|
||||
permit {cap.overrideCap} — the provider enforces its real limit server-side.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-2xs leading-relaxed text-text-faint">
|
||||
{snap.used} posts today. This connection has no configured cap — pacing is request-rate only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
69
apps/web/src/components/control-room/audio-monitor.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "../ui/primitives";
|
||||
import { usePreviewTap } from "./preview-tap";
|
||||
import type { Production } from "./types";
|
||||
|
||||
function AudioTap({ channelId, id, fixture, context, master, gain }: { channelId: string; id: string; fixture: boolean; context: AudioContext | null; master: GainNode | null; gain: number }): React.ReactElement {
|
||||
const audio = useRef<HTMLAudioElement>(null);
|
||||
const node = useRef<GainNode | null>(null);
|
||||
const mediaSource = useRef<MediaElementAudioSourceNode | null>(null);
|
||||
const urls = usePreviewTap(channelId, id, fixture, 40000);
|
||||
const [available, setAvailable] = useState(false);
|
||||
useEffect(() => { setAvailable(Boolean(document.createElement("audio").canPlayType("application/vnd.apple.mpegurl"))); }, []);
|
||||
useEffect(() => {
|
||||
if (!context || !master || !audio.current) return;
|
||||
const source = mediaSource.current ?? context.createMediaElementSource(audio.current);
|
||||
mediaSource.current = source;
|
||||
const volume = context.createGain(); node.current = volume; volume.gain.value = 0;
|
||||
source.connect(volume); volume.connect(master);
|
||||
const element = audio.current;
|
||||
return () => { element.pause(); source.disconnect(); volume.disconnect(); node.current = null; };
|
||||
}, [context, master]);
|
||||
useEffect(() => {
|
||||
if (node.current) node.current.gain.setTargetAtTime(gain, context?.currentTime ?? 0, 0.015);
|
||||
if (context && gain > 0 && available) void audio.current?.play().catch(() => undefined);
|
||||
else audio.current?.pause();
|
||||
}, [gain, context, urls, available]);
|
||||
return <audio ref={audio} data-audio-tap={id} src={available ? urls?.audio : undefined} preload="none" crossOrigin="anonymous" />;
|
||||
}
|
||||
export function AudioMonitor({ production, liveIds, channelId, fixture, mobile, focused, observed }: { production: Production; liveIds: string[]; channelId: string; fixture: boolean; mobile: boolean; focused: string | null; observed: Production | null }): React.ReactElement {
|
||||
const [mode, setMode] = useState("program");
|
||||
const [context, setContext] = useState<AudioContext | null>(null);
|
||||
const [master, setMaster] = useState<GainNode | null>(null);
|
||||
const graph = useRef<AudioContext | null>(null);
|
||||
const [gains, setGains] = useState<Record<string, number>>({});
|
||||
const [error, setError] = useState("");
|
||||
const [native, setNative] = useState(false);
|
||||
useEffect(() => {
|
||||
setNative(Boolean(document.createElement("audio").canPlayType("application/vnd.apple.mpegurl")));
|
||||
return () => { void graph.current?.close(); };
|
||||
}, []);
|
||||
const arm = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
let ctx = graph.current;
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext(); graph.current = ctx;
|
||||
const output = ctx.createGain(); output.gain.value = 1; output.connect(ctx.destination);
|
||||
setContext(ctx); setMaster(output);
|
||||
}
|
||||
await ctx.resume(); setError("");
|
||||
} catch { setError("Audio could not be armed on this device."); }
|
||||
}, []);
|
||||
const selection = mobile ? focused ?? "program" : mode;
|
||||
const ids = mobile ? [selection] : [...new Set(["program", ...(selection !== "custom" && selection !== "program" ? [selection] : []), ...liveIds.filter(id => id !== "program")])].slice(0, 5);
|
||||
const audible = (id: string): number => selection === "custom" ? gains[id] ?? 0 : id === selection ? 1 : 0;
|
||||
const programScene = observed?.scenes.find(scene => scene.id === observed.buses.program);
|
||||
return <section aria-label="Audio monitor" className="rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<h2 className="mb-3 text-sm font-semibold">Audio monitor</h2>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Button className="min-h-11" variant="outline" disabled={fixture || !native} onClick={() => { void arm(); }}>{context ? "Resume audio" : "Arm audio"}</Button>
|
||||
{!mobile && <label className="min-w-0 flex-1 text-xs">Monitor select<select className="cr-field mt-1" value={mode} onChange={event => setMode(event.target.value)}><option value="program">Follow program</option><option value="custom">Custom monitor mix</option>{production.sources.map(source => <option key={source.id} value={source.id}>Solo · {source.label}</option>)}</select></label>}
|
||||
</div>
|
||||
{selection === "custom" && ids.filter(id => id !== "program").map(id => <label key={id} className="mt-3 block text-xs">{production.sources.find(source => source.id === id)?.label}<input aria-label={`Monitor gain ${id}`} className="mt-1 h-11 w-full" type="range" min={0} max={1} step={0.01} value={gains[id] ?? 0} onChange={event => setGains(previous => ({ ...previous, [id]: Number(event.target.value) }))} /></label>)}
|
||||
{ids.map(id => <AudioTap key={id} channelId={channelId} id={id} fixture={fixture} context={context} master={master} gain={audible(id)} />)}
|
||||
<p className="mt-3 text-xs text-text-dim" data-program-audio>Program audio: {programScene ? `${programScene.label} · ${programScene.slots.filter(slot => slot.audioMode !== "off").length} audible slots · master ${observed?.audio.masterGainDb ?? 0} dB` : "unconfirmed"}</p>
|
||||
<p className="mt-2 text-xs text-text-faint">{mobile ? "Monitor follows the watched tile. " : "Monitor gains are private to this browser. "}Same-origin signed audio taps avoid Web Audio’s cross-origin silence constraint. {fixture ? "Fixture has no audio." : !native ? "Native HLS audio is unavailable in this browser." : ""}</p>
|
||||
{error && <p role="alert" className="mt-2 text-xs text-danger">{error}</p>}
|
||||
</section>;
|
||||
}
|
||||
32
apps/web/src/components/control-room/client.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { API_URL, ApiError, ApiUnreachableError } from "../../lib/api-client";
|
||||
import { getSessionToken } from "../../lib/session-token";
|
||||
|
||||
export function idempotencyKey(): string {
|
||||
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
||||
// getRandomValues also works on HTTP development hosts where randomUUID is unavailable.
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
|
||||
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
// Kept local: the shared API client does not support custom request headers.
|
||||
export async function controlRequest<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const token = getSessionToken();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_URL}${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", signal,
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey() }) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
} catch (error) { if (signal?.aborted) throw error; throw new ApiUnreachableError(error); }
|
||||
const text = await response.text();
|
||||
let result: unknown = null;
|
||||
if (text) { try { result = JSON.parse(text); } catch { result = text; } }
|
||||
if (!response.ok) throw new ApiError(response.status, typeof result === "object" && result && "message" in result ? String(result.message) : `HTTP ${response.status}`, result);
|
||||
return result as T;
|
||||
}
|
||||
export function signedSameOrigin(raw: string): string {
|
||||
const url = new URL(raw, window.location.origin);
|
||||
if (url.origin !== window.location.origin || !url.pathname.startsWith("/media-control/preview/")) throw new Error("Preview must use signed same-origin media URLs");
|
||||
return url.href;
|
||||
}
|
||||
152
apps/web/src/components/control-room/control-room.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
import { webcrypto } from "node:crypto";
|
||||
import { idempotencyKey, controlRequest } from "./client";
|
||||
import { fixtureChannel } from "./fixtures";
|
||||
import { initialTally, refetchTally, tallyReducer, tileTally } from "./tally";
|
||||
import { RealtimeClient, type SocketLike } from "./state-store";
|
||||
import { liveTapIds } from "./multiview-grid";
|
||||
import { slotRect } from "./pst-preview";
|
||||
import type { StateFrame, Observed } from "./types";
|
||||
|
||||
if (!globalThis.crypto) Object.defineProperty(globalThis, "crypto", { value: webcrypto });
|
||||
|
||||
function observed(channel = fixtureChannel(), version = 1): Observed {
|
||||
return { workspaceId: "workspace", channelId: channel.channelId, workerId: channel.workerId, assignmentEpoch: 1, state: "RUNNING", realizedVersion: version, updatedAt: new Date().toISOString(), reason: "" };
|
||||
}
|
||||
test("PST and CUT desired changes never move tally; exact observed version does", () => {
|
||||
const channel = fixtureChannel(); channel.observed = observed();
|
||||
let state = initialTally(channel);
|
||||
const first = channel.desired?.production?.sources[0]?.id ?? "";
|
||||
const second = channel.desired?.production?.sources[1]?.id ?? "";
|
||||
assert.equal(tileTally(state.observedProduction, first), "program");
|
||||
const desired = structuredClone(channel.desired);
|
||||
assert.ok(desired?.production);
|
||||
desired.desiredVersion = 2; desired.production.buses.preview = desired.production.scenes[1]?.id ?? null;
|
||||
state = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "desired", seq: 1, data: desired });
|
||||
assert.equal(tileTally(state.observedProduction, second), "none");
|
||||
state = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "observed", seq: 2, data: observed(channel, 2) });
|
||||
assert.equal(tileTally(state.observedProduction, second), "preview");
|
||||
const cut = structuredClone(desired); assert.ok(cut.production); cut.desiredVersion = 3; cut.production.buses.program = cut.production.buses.preview;
|
||||
state = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "desired", seq: 3, data: cut });
|
||||
assert.equal(tileTally(state.observedProduction, first), "program");
|
||||
assert.equal(tileTally(state.observedProduction, second), "preview");
|
||||
state = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "observed", seq: 4, data: observed(channel, 3) });
|
||||
assert.equal(tileTally(state.observedProduction, first), "none");
|
||||
assert.equal(tileTally(state.observedProduction, second), "program");
|
||||
});
|
||||
test("unknown version, missing observation and stale assignment cannot claim program", () => {
|
||||
const channel = fixtureChannel();
|
||||
assert.equal(initialTally(channel).observedProduction, null);
|
||||
channel.observed = observed(channel, 99); assert.equal(initialTally(channel).observedProduction, null);
|
||||
channel.observed = { ...observed(), assignmentEpoch: 2 }; assert.equal(initialTally(channel).observedProduction, null);
|
||||
channel.observed = { ...observed(), workerId: "other" }; assert.equal(initialTally(channel).observedProduction, null);
|
||||
channel.observed = { ...observed(), state: "STOPPED" }; assert.equal(initialTally(channel).observedProduction, null);
|
||||
});
|
||||
test("duplicate and wrong-channel frames are ignored", () => {
|
||||
const channel = fixtureChannel(); const state = { ...initialTally(channel), lastSeq: 4 };
|
||||
const frame: StateFrame = { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "observed", seq: 4, data: observed() };
|
||||
assert.equal(tallyReducer(state, frame), state);
|
||||
assert.equal(tallyReducer(state, { ...frame, seq: 5, channelId: "other" }), state);
|
||||
});
|
||||
test("late desired snapshot cannot fabricate a tally update", () => {
|
||||
const channel = fixtureChannel(); channel.observed = observed(channel, 2);
|
||||
const state = initialTally(channel); const desired = structuredClone(channel.desired); assert.ok(desired); desired.desiredVersion = 2;
|
||||
const next = tallyReducer(state, { type: "state", workspaceId: "workspace", channelId: channel.channelId, kind: "desired", seq: 1, data: desired });
|
||||
assert.equal(next.observedProduction, null);
|
||||
});
|
||||
test("Idempotency-Key is a distinct printable UUID for every action", async () => {
|
||||
const keys = Array.from({ length: 100 }, idempotencyKey);
|
||||
assert.equal(new Set(keys).size, keys.length);
|
||||
for (const key of keys) { assert.match(key, /^[\x20-\x7e]{1,128}$/); assert.match(key, /^[0-9a-f-]{36}$/); }
|
||||
const original = globalThis.fetch; const headers: Headers[] = [];
|
||||
globalThis.fetch = async (_url, options) => { headers.push(new Headers(options?.headers)); return Response.json({ desiredVersion: 1, commandId: "test" }); };
|
||||
try { await controlRequest("/media-control/channels/test/cut", {}); await controlRequest("/production/overlays/test", { templateId: "logo", fields: {} }); }
|
||||
finally { globalThis.fetch = original; }
|
||||
assert.ok(headers[0]?.get("Idempotency-Key")); assert.notEqual(headers[0]?.get("Idempotency-Key"), headers[1]?.get("Idempotency-Key"));
|
||||
});
|
||||
class FakeSocket implements SocketLike {
|
||||
onopen: SocketLike["onopen"] = null; onmessage: SocketLike["onmessage"] = null; onclose: SocketLike["onclose"] = null; onerror: SocketLike["onerror"] = null;
|
||||
sent: Record<string, unknown>[] = [];
|
||||
send(raw: string): void { this.sent.push(JSON.parse(raw) as Record<string, unknown>); }
|
||||
close(): void {}
|
||||
}
|
||||
test("WS auth waits for ack, resume uses last consumed frame and resync fully refetches", async () => {
|
||||
const sockets: FakeSocket[] = []; const frames: StateFrame[] = []; let refetches = 0;
|
||||
const channel = fixtureChannel();
|
||||
const client = new RealtimeClient({ url: "ws://test/realtime", workspaceId: "workspace", channelId: channel.channelId, token: () => "secret", retryMs: 1,
|
||||
socket: () => { const socket = new FakeSocket(); sockets.push(socket); return socket; },
|
||||
status: async () => { refetches += 1; return { channels: [channel] }; }, onStatus: () => undefined, onFrame: frame => frames.push(frame), onConnection: () => undefined });
|
||||
try {
|
||||
client.start(); sockets[0]?.onopen?.(new Event("open"));
|
||||
assert.deepEqual(sockets[0]?.sent, [{ type: "auth", token: "secret", workspaceId: "workspace" }]);
|
||||
await client.receive(JSON.stringify({ type: "auth_ack" }));
|
||||
assert.deepEqual(sockets[0]?.sent[1], { type: "subscribe", channelId: channel.channelId });
|
||||
await client.receive(JSON.stringify({ type: "subscribe_ack", seq: 99 })); assert.equal(client.lastSeq, undefined);
|
||||
const frame = { type: "state", workspaceId: "workspace", channelId: channel.channelId, seq: 7, kind: "observed", data: observed() };
|
||||
await client.receive(JSON.stringify(frame)); await client.receive(JSON.stringify(frame));
|
||||
assert.equal(frames.length, 1); assert.equal(client.lastSeq, 7);
|
||||
sockets[0]?.onclose?.({ code: 1006 } as CloseEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await client.receive(JSON.stringify({ type: "auth_ack" }));
|
||||
assert.deepEqual(sockets[1]?.sent[0], { type: "subscribe", channelId: channel.channelId, lastSeq: 7 });
|
||||
await client.receive(JSON.stringify({ type: "RESYNC_REQUIRED", channelId: channel.channelId }));
|
||||
assert.equal(refetches, 1); assert.equal(client.lastSeq, undefined);
|
||||
assert.deepEqual(sockets[1]?.sent.at(-1), { type: "subscribe", channelId: channel.channelId });
|
||||
await client.receive(JSON.stringify({ ...frame, seq: 1 })); assert.equal(client.lastSeq, 1);
|
||||
} finally { client.stop(); }
|
||||
});
|
||||
test("live budgets enforce large tier defaults, promotions, and mobile cap", () => {
|
||||
const sources = fixtureChannel().desired?.production?.sources ?? [];
|
||||
assert.equal(liveTapIds(sources, "2x2", null, [], false).length, 4);
|
||||
assert.equal(liveTapIds(sources, "3x3", null, [], false).length, 1);
|
||||
assert.equal(liveTapIds(sources, "4x4", sources[1]?.id ?? null, [], false).length, 2);
|
||||
assert.equal(liveTapIds(sources, "custom", sources[0]?.id ?? null, sources.map(source => source.id), false).length, 4);
|
||||
assert.deepEqual(liveTapIds(sources, "4x4", sources[0]?.id ?? null, [], true), [sources[0]?.id]);
|
||||
});
|
||||
test("PST uses scene slot geometry rather than multiview geometry", () => {
|
||||
const slot = fixtureChannel().desired?.production?.scenes[0]?.slots[0]; assert.ok(slot);
|
||||
assert.deepEqual(slotRect("2x2", { ...slot, index: 3 }), { x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
|
||||
assert.deepEqual(slotRect("custom", { ...slot, rect: { x: 1000, y: 2000, w: 4000, h: 3000 } }), { x: 0.1, y: 0.2, w: 0.4, h: 0.3 });
|
||||
});
|
||||
|
||||
test("authorization close clears retries and never reconnects a revoked socket", async () => {
|
||||
const socket = new FakeSocket(); let connections = 0; const states: string[] = [];
|
||||
const client = new RealtimeClient({ url: "ws://test/realtime", workspaceId: "workspace", channelId: fixtureChannel().channelId, token: () => "revoked", retryMs: 1,
|
||||
socket: () => { connections += 1; return socket; }, status: async () => ({ channels: [] }), onStatus: () => undefined, onFrame: () => undefined, onConnection: state => states.push(state) });
|
||||
client.start(); socket.onclose?.({ code: 1008 } as CloseEvent);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
assert.equal(connections, 1); assert.equal(states.at(-1), "denied"); client.stop();
|
||||
});
|
||||
test("status refetch keeps the realized snapshot during version lag and clears it on reassignment", () => {
|
||||
const channel = fixtureChannel(); channel.observed = observed();
|
||||
const initial = initialTally(channel); const nextChannel = structuredClone(channel);
|
||||
assert.ok(nextChannel.desired?.production);
|
||||
nextChannel.desired.desiredVersion = 2;
|
||||
nextChannel.desired.production.buses.program = nextChannel.desired.production.scenes[1]?.id ?? null;
|
||||
const resynced = refetchTally(nextChannel, initial);
|
||||
assert.equal(resynced.observedProduction?.buses.program, channel.desired?.production?.buses.program);
|
||||
assert.equal(resynced.lastSeq, 0);
|
||||
assert.equal(refetchTally({ ...nextChannel, assignmentEpoch: 2 }, initial).observedProduction, null);
|
||||
});
|
||||
test("RESYNC failure stays in labeled polling mode and successful full refetch resubscribes", async () => {
|
||||
const socket = new FakeSocket(); let unavailable = true, refetches = 0; const connections: string[] = [];
|
||||
const client = new RealtimeClient({ url: "ws://test/realtime", workspaceId: "workspace", channelId: fixtureChannel().channelId, token: () => "token",
|
||||
socket: () => socket, status: async () => { refetches += 1; if (unavailable) throw new Error("offline"); return { channels: [fixtureChannel()] }; },
|
||||
onStatus: () => undefined, onFrame: () => undefined, onConnection: state => connections.push(state) });
|
||||
try {
|
||||
client.start(); await client.receive(JSON.stringify({ type: "RESYNC_REQUIRED" }));
|
||||
assert.equal(connections.at(-1), "polling"); assert.equal(socket.sent.length, 0);
|
||||
unavailable = false; await client.receive(JSON.stringify({ type: "RESYNC_REQUIRED" }));
|
||||
assert.equal(refetches, 2); assert.deepEqual(socket.sent.at(-1), { type: "subscribe", channelId: fixtureChannel().channelId });
|
||||
} finally { client.stop(); }
|
||||
});
|
||||
test("UUID fallback supports non-secure development hosts with Web Crypto entropy", () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, "randomUUID");
|
||||
Object.defineProperty(crypto, "randomUUID", { value: undefined, configurable: true });
|
||||
try {
|
||||
const keys = Array.from({ length: 100 }, idempotencyKey);
|
||||
assert.equal(new Set(keys).size, 100);
|
||||
for (const key of keys) assert.match(key, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
} finally { if (descriptor) Object.defineProperty(crypto, "randomUUID", descriptor); else Reflect.deleteProperty(crypto, "randomUUID"); }
|
||||
});
|
||||
10
apps/web/src/components/control-room/fixtures.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { Channel, Production } from "./types";
|
||||
export const fixtureChannelId = "10000000-0000-4000-8000-000000000001";
|
||||
export const fixtureProduction: Production = {
|
||||
sources: Array.from({ length: 16 }, (_, i) => ({ id: `20000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`, label: `Sample source ${i + 1}`, kind: "hls" })),
|
||||
scenes: [0, 1].map(i => ({ id: `30000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`, label: i === 0 ? "Sample studio" : "Sample field", layout: "single", slots: [{ index: 0, sourceId: `20000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`, rect: null, fit: "contain", audioMode: "primary", gainDb: 0 }] })),
|
||||
buses: { program: "30000000-0000-4000-8000-000000000001", preview: "30000000-0000-4000-8000-000000000001" },
|
||||
overlays: ["lower_third", "chyron", "logo"].map((kind, i) => ({ id: `40000000-0000-4000-8000-${String(i + 1).padStart(12, "0")}`, sceneId: "30000000-0000-4000-8000-000000000001", kind: kind as "lower_third" | "chyron" | "logo", enabled: true, layer: i, position: { x: 0, y: 8000 }, revisionId: null, assetId: null })),
|
||||
audio: { masterGainDb: 0 },
|
||||
};
|
||||
export function fixtureChannel(): Channel { return { channelId: fixtureChannelId, workerId: "sample-worker", assignmentEpoch: 1, desired: { desiredVersion: 1, desiredStatus: "RUNNING", production: structuredClone(fixtureProduction), director: { locked: false, override: null } }, observed: null, converged: false, workerAlive: false }; }
|
||||
40
apps/web/src/components/control-room/multiview-grid.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/primitives";
|
||||
import { PreviewTap } from "./preview-tap";
|
||||
import { tileTally } from "./tally";
|
||||
import type { Production, Source } from "./types";
|
||||
export type GridTier = "2x1" | "2x2" | "3x3" | "4x4" | "custom";
|
||||
export function liveTapIds(sources: Source[], tier: GridTier, focused: string | null, upgrades: string[], mobile: boolean): string[] {
|
||||
const ids = sources.map(source => source.id);
|
||||
const valid = (id: string): boolean => id === "program" || ids.includes(id);
|
||||
const candidates = mobile ? [focused ?? "program"] : tier === "2x1" || tier === "2x2" ? ["program", ...(focused ? [focused] : []), ...ids] : ["program", ...(focused ? [focused] : []), ...upgrades];
|
||||
return [...new Set(candidates)].filter(valid).slice(0, mobile ? 1 : Math.min(4, tier === "2x1" ? 2 : 4));
|
||||
}
|
||||
export function MultiviewGrid({ production, observed, channelId, fixture, mobile, liveIds, focused, onFocus, tier, onTier, upgrades, onUpgrade }: { production: Production; observed: Production | null; channelId: string; fixture: boolean; mobile: boolean; liveIds: string[]; focused: string | null; onFocus: (id: string) => void; tier: GridTier; onTier: (tier: GridTier) => void; upgrades: string[]; onUpgrade: (id: string) => void }): React.ReactElement {
|
||||
const [columns, setColumns] = useState(3);
|
||||
const capacity = tier === "2x1" ? 2 : tier === "2x2" ? 4 : tier === "3x3" ? 9 : 16;
|
||||
const tiles = [{ id: "program", label: "PGM · encoder output", kind: "hls" as const }, ...production.sources].slice(0, capacity);
|
||||
const selectedSourceIds = production.scenes.find(scene => scene.id === production.buses.preview)?.slots.map(slot => slot.sourceId) ?? [];
|
||||
return <section aria-label="Multiview" className="min-w-0 rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div className="mb-3 flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs">Multiview layout<select className="cr-field mt-1" value={tier} onChange={event => onTier(event.target.value as GridTier)}>{["2x1", "2x2", "3x3", "4x4", "custom"].map(value => <option key={value}>{value}</option>)}</select></label>
|
||||
{tier === "custom" && <label className="text-xs">Columns<input className="cr-field mt-1" type="number" min={1} max={4} value={columns} onChange={event => setColumns(Math.max(1, Math.min(4, Number(event.target.value) || 1)))} /></label>}
|
||||
<span className="pb-3 text-xs text-text-faint">{liveIds.length}/{mobile ? 1 : 4} live slots · tap to focus</span>
|
||||
</div>
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${mobile ? (tier === "2x1" ? 1 : 2) : tier === "custom" ? columns : tier === "3x3" ? 3 : tier === "4x4" ? 4 : 2}, minmax(0, 1fr))` }}>
|
||||
{tiles.map(source => {
|
||||
const tally = source.id === "program" ? observed?.buses.program ? "program" : "none" : tileTally(observed, source.id);
|
||||
return <div key={source.id} data-source={source.id} data-tally={tally} className={`min-w-0 rounded-md border-2 p-1 ${tally === "program" ? "border-red-500" : tally === "preview" ? "border-green-500" : "border-border"}`}>
|
||||
<button className="min-h-11 w-full text-left" aria-label={`Focus ${source.label}`} aria-pressed={focused === source.id} onClick={() => onFocus(source.id)}>
|
||||
<div className="mb-1 flex min-h-7 flex-wrap items-center justify-between gap-1 px-1 text-xs"><span className="break-words">{source.label}</span><span className="font-mono">{tally === "program" ? "PGM" : tally === "preview" ? "PST" : "—"}</span></div>
|
||||
<PreviewTap channelId={channelId} tap={source.id} fixture={fixture} live={liveIds.includes(source.id)} selected={selectedSourceIds.includes(source.id)} label={source.label} />
|
||||
</button>
|
||||
<p className="px-1 py-1 text-[10px] text-text-faint">{fixture ? "SAMPLE" : "Health not reported"}</p>
|
||||
{!mobile && (tier === "3x3" || tier === "4x4" || tier === "custom") && source.id !== "program" && source.id !== focused && <Button className="min-h-11 w-full justify-center" disabled={!upgrades.includes(source.id) && liveIds.length >= 4} onClick={() => onUpgrade(source.id)}>{upgrades.includes(source.id) ? "Remove live" : "Upgrade live"}</Button>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-text-faint">Extra live tiles can strain the device. Red/green tally uses the worker’s realized version. Unknown versions have no tally.</p>
|
||||
</section>;
|
||||
}
|
||||
31
apps/web/src/components/control-room/overlay-editor.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/primitives";
|
||||
import type { Overlay } from "./types";
|
||||
export interface RenderInput { templateId: "lower_third" | "chyron" | "logo"; fields: { title?: string; subtitle?: string; style?: "dark" | "red"; svg?: string } }
|
||||
function Editor({ overlay, disabled, onRender }: { overlay: Overlay; disabled: boolean; onRender: (id: string, input: RenderInput) => Promise<void> }): React.ReactElement {
|
||||
const [title, setTitle] = useState(""); const [subtitle, setSubtitle] = useState(""); const [style, setStyle] = useState<"dark" | "red">("dark"); const [svg, setSvg] = useState("");
|
||||
const [busy, setBusy] = useState(false); const [message, setMessage] = useState("");
|
||||
if (overlay.kind === "clock") return <p className="text-xs text-text-faint">Clock overlay is server-generated.</p>;
|
||||
const templateId = overlay.kind;
|
||||
return <form className="min-w-0 space-y-3" onSubmit={event => {
|
||||
event.preventDefault(); setBusy(true); setMessage("");
|
||||
const input: RenderInput = { templateId, fields: templateId === "logo" ? { svg } : { title, subtitle, style } };
|
||||
void onRender(overlay.id, input).then(() => setMessage("Render accepted. Waiting for the current revision in status.")).catch(error => setMessage(error instanceof Error ? error.message : "Render failed")).finally(() => setBusy(false));
|
||||
}}>
|
||||
<p className="text-xs text-text-faint">Template · {templateId}</p>
|
||||
{templateId === "logo" ? <label className="block text-xs">Logo SVG<textarea className="cr-field mt-1 min-h-28" required maxLength={64000} value={svg} onChange={event => setSvg(event.target.value)} placeholder="<svg>…</svg>" /><span className="mt-1 block text-text-faint">Geometric SVG only; no scripts, external assets, or text.</span></label> : <>
|
||||
<label className="block text-xs">{templateId === "chyron" ? "Chyron text" : "Title"} · {title.length}/80<input className="cr-field mt-1" maxLength={80} value={title} onChange={event => setTitle(event.target.value)} /></label>
|
||||
<label className="block text-xs">Subtitle · {subtitle.length}/120<input className="cr-field mt-1" maxLength={120} value={subtitle} onChange={event => setSubtitle(event.target.value)} /></label>
|
||||
<label className="block text-xs">Style<select className="cr-field mt-1" value={style} onChange={event => setStyle(event.target.value as "dark" | "red")}><option value="dark">Dark</option><option value="red">Red</option></select></label>
|
||||
</>}
|
||||
<Button className="min-h-11" variant="outline" disabled={disabled || busy} type="submit">{busy ? "Rendering…" : "Render overlay"}</Button>
|
||||
<p className="break-all text-xs text-text-faint">Current revision: {overlay.revisionId ?? "None"}</p>
|
||||
{message && <p role="status" className="text-xs">{message}</p>}
|
||||
</form>;
|
||||
}
|
||||
export function OverlayEditor({ overlays, disabled, onRender }: { overlays: Overlay[]; disabled: boolean; onRender: (id: string, input: RenderInput) => Promise<void> }): React.ReactElement {
|
||||
const [selected, setSelected] = useState("");
|
||||
const overlay = overlays.find(item => item.id === selected) ?? overlays[0];
|
||||
return <section aria-label="Overlay editor" className="min-w-0 rounded-lg border border-border-subtle bg-surface p-3"><h2 className="mb-3 text-sm font-semibold">Overlay editor</h2>{overlay ? <><label className="mb-3 block text-xs">Overlay slot<select className="cr-field mt-1" value={overlay.id} onChange={event => setSelected(event.target.value)}>{overlays.map((item, index) => <option key={item.id} value={item.id}>{item.kind} · slot {index + 1}</option>)}</select></label><Editor key={overlay.id} overlay={overlay} disabled={disabled} onRender={onRender} /></> : <p className="text-xs text-text-faint">Configure an overlay slot on a production scene to render a lower third, chyron, or logo.</p>}</section>;
|
||||
}
|
||||
62
apps/web/src/components/control-room/preview-tap.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { controlRequest, signedSameOrigin } from "./client";
|
||||
import type { PreviewUrls } from "./types";
|
||||
|
||||
export function usePreviewTap(channelId: string, tap: string, fixture: boolean, cadence = 2000): PreviewUrls | null {
|
||||
const [urls, setUrls] = useState<PreviewUrls | null>(null);
|
||||
useEffect(() => {
|
||||
setUrls(null);
|
||||
if (fixture) return;
|
||||
let visible = true, busy = false;
|
||||
const abort = new AbortController();
|
||||
const update = async (): Promise<void> => {
|
||||
if (!visible || document.hidden || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const result = await controlRequest<PreviewUrls>(`/media-control/channels/${channelId}/preview/${tap}`, undefined, abort.signal);
|
||||
if (!abort.signal.aborted) setUrls({ thumbnail: `${signedSameOrigin(result.thumbnail)}&refresh=${Date.now()}`, video: signedSameOrigin(result.video), audio: signedSameOrigin(result.audio) });
|
||||
} catch { if (!abort.signal.aborted) setUrls(null); }
|
||||
finally { busy = false; }
|
||||
};
|
||||
const listener = (): void => { if (!document.hidden) void update(); };
|
||||
document.addEventListener("visibilitychange", listener);
|
||||
// Consumers pause their own polling through cadence=0 while off-screen.
|
||||
if (cadence > 0) void update(); else visible = false;
|
||||
const timer = cadence > 0 ? setInterval(() => { void update(); }, cadence) : null;
|
||||
return () => { abort.abort(); if (timer) clearInterval(timer); document.removeEventListener("visibilitychange", listener); };
|
||||
}, [channelId, tap, fixture, cadence]);
|
||||
return urls;
|
||||
}
|
||||
|
||||
export function PreviewTap({ channelId, tap, fixture, live, selected, label }: { channelId: string; tap: string; fixture: boolean; live: boolean; selected: boolean; label: string }): React.ReactElement {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [pageVisible, setPageVisible] = useState(true);
|
||||
const [native, setNative] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const active = visible && pageVisible;
|
||||
const urls = usePreviewTap(channelId, tap, fixture, active ? (selected ? 1000 : 2000) : 0);
|
||||
useEffect(() => {
|
||||
setNative(Boolean(document.createElement("video").canPlayType("application/vnd.apple.mpegurl")));
|
||||
const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false));
|
||||
const update = (): void => setPageVisible(!document.hidden);
|
||||
update(); document.addEventListener("visibilitychange", update);
|
||||
if (root.current) observer.observe(root.current);
|
||||
return () => { observer.disconnect(); document.removeEventListener("visibilitychange", update); };
|
||||
}, []);
|
||||
useEffect(() => setFailed(false), [tap, live]);
|
||||
// Refresh thumbnail signatures continuously, but keep a live manifest stable until its TTL nears expiry.
|
||||
const [videoUrl, setVideoUrl] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!urls || !live || !active) { setVideoUrl(undefined); return; }
|
||||
setVideoUrl(previous => !previous || Number(new URL(previous).searchParams.get("expires")) * 1000 < Date.now() + 10000 ? urls.video : previous);
|
||||
}, [urls, live, active]);
|
||||
const playing = live && active && native && !failed && Boolean(videoUrl);
|
||||
return <div ref={root} className="relative flex aspect-video items-center justify-center overflow-hidden rounded bg-black/60">
|
||||
{playing ? <video data-live-decode src={videoUrl} autoPlay muted playsInline preload="none" className="h-full w-full object-contain" onError={() => setFailed(true)} />
|
||||
: urls ? <img src={urls.thumbnail} alt={`${label} preview tap`} className="h-full w-full object-contain" onLoad={event => { event.currentTarget.style.visibility = "visible"; }} onError={event => { event.currentTarget.style.visibility = "hidden"; }} />
|
||||
: <span className="px-3 text-center text-xs text-text-faint">{fixture ? "Fixture · no media" : "Preview tap unavailable"}</span>}
|
||||
<span className="absolute bottom-1 left-1 rounded bg-black/80 px-2 py-1 text-[10px] text-white">{playing ? "live preview" : `thumbnail · ${selected ? "1" : "2"} s`}{live && !native && !fixture ? " · native HLS unavailable" : ""}</span>
|
||||
</div>;
|
||||
}
|
||||
64
apps/web/src/components/control-room/pst-preview.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { controlRequest, signedSameOrigin } from "./client";
|
||||
import type { Production, Slot } from "./types";
|
||||
export function slotRect(layout: string, slot: Slot): { x: number; y: number; w: number; h: number } {
|
||||
if (slot.rect) return { x: slot.rect.x / 10000, y: slot.rect.y / 10000, w: slot.rect.w / 10000, h: slot.rect.h / 10000 };
|
||||
const cols = layout === "3x3" ? 3 : layout === "4x4" ? 4 : layout === "2x1" || layout === "2x2" ? 2 : 1;
|
||||
const rows = layout === "2x1" || cols === 1 ? 1 : cols;
|
||||
return { x: (slot.index % cols) / cols, y: Math.floor(slot.index / cols) / rows, w: 1 / cols, h: 1 / rows };
|
||||
}
|
||||
export function PstPreview({ production, channelId, fixture }: { production: Production; channelId: string; fixture: boolean }): React.ReactElement {
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const scene = production.scenes.find(item => item.id === production.buses.preview);
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false));
|
||||
if (canvas.current) observer.observe(canvas.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const element = canvas.current;
|
||||
if (!element) return;
|
||||
const buffer = document.createElement("canvas"); buffer.width = element.width; buffer.height = element.height;
|
||||
const context = buffer.getContext("2d");
|
||||
if (!context) return;
|
||||
const abort = new AbortController();
|
||||
let busy = false;
|
||||
const draw = async (): Promise<void> => {
|
||||
if (document.hidden || !visible || busy) return;
|
||||
busy = true;
|
||||
context.fillStyle = "#101010"; context.fillRect(0, 0, element.width, element.height);
|
||||
try {
|
||||
for (const slot of [...(scene?.slots ?? [])].sort((a, b) => a.index - b.index)) {
|
||||
const rect = slotRect(scene?.layout ?? "single", slot);
|
||||
const x = rect.x * element.width, y = rect.y * element.height, w = rect.w * element.width, h = rect.h * element.height;
|
||||
context.save(); context.beginPath(); context.rect(x, y, w, h); context.clip();
|
||||
context.fillStyle = "#222"; context.fillRect(x, y, w, h);
|
||||
let painted = false;
|
||||
if (!fixture && slot.sourceId) {
|
||||
try {
|
||||
const urls = await controlRequest<{ thumbnail: string }>(`/media-control/channels/${channelId}/preview/${slot.sourceId}`, undefined, abort.signal);
|
||||
const response = await fetch(signedSameOrigin(urls.thumbnail), { signal: abort.signal });
|
||||
if (!response.ok) throw new Error("Thumbnail unavailable");
|
||||
const bitmap = await createImageBitmap(await response.blob());
|
||||
if (!abort.signal.aborted) {
|
||||
const scale = slot.fit === "contain" ? Math.min(w / bitmap.width, h / bitmap.height) : Math.max(w / bitmap.width, h / bitmap.height);
|
||||
context.drawImage(bitmap, x + (w - bitmap.width * scale) / 2, y + (h - bitmap.height * scale) / 2, bitmap.width * scale, bitmap.height * scale);
|
||||
painted = true;
|
||||
}
|
||||
bitmap.close();
|
||||
} catch { /* Label unavailable taps. */ }
|
||||
}
|
||||
if (!painted && !abort.signal.aborted) { context.fillStyle = "#aaa"; context.font = "16px sans-serif"; context.fillText(fixture ? "Fixture · no media" : "Tap unavailable", x + 16, y + 30, Math.max(1, w - 32)); }
|
||||
context.restore();
|
||||
}
|
||||
if (!abort.signal.aborted) element.getContext("2d")?.drawImage(buffer, 0, 0);
|
||||
} finally { busy = false; }
|
||||
};
|
||||
void draw(); const timer = setInterval(() => { void draw(); }, 1000);
|
||||
return () => { abort.abort(); clearInterval(timer); };
|
||||
}, [scene, channelId, fixture, visible]);
|
||||
const overlays = production.overlays.filter(overlay => overlay.enabled && overlay.sceneId === scene?.id);
|
||||
return <section className="rounded-lg border border-border-subtle bg-surface p-3" aria-label="PST look-ahead"><h2 className="mb-2 text-sm font-semibold">PST · {scene?.label ?? "No scene selected"}</h2><canvas ref={canvas} width={640} height={360} className="aspect-video w-full rounded bg-black" aria-label={`Look-ahead of ${scene?.label ?? "empty preview"}`} /><p className="mt-2 text-xs text-text-faint">PST is a look-ahead; PGM is the truth.</p>{overlays.length > 0 && <p className="mt-2 text-xs text-text-faint">{overlays.length} overlay slots configured. Status provides revision IDs without render pixels; overlay appearance is unavailable in this look-ahead.</p>}</section>;
|
||||
}
|
||||
90
apps/web/src/components/control-room/state-store.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { StateFrame, Status } from "./types";
|
||||
|
||||
export interface SocketLike {
|
||||
onopen: ((event: Event) => void) | null; onmessage: ((event: MessageEvent) => void) | null; onclose: ((event: CloseEvent) => void) | null; onerror: ((event: Event) => void) | null;
|
||||
send(value: string): void; close(): void;
|
||||
}
|
||||
interface Options {
|
||||
url: string; token: () => string | null; workspaceId: string; channelId: string;
|
||||
socket: (url: string) => SocketLike; status: () => Promise<Status>;
|
||||
onFrame: (frame: StateFrame) => void; onStatus: (status: Status) => void;
|
||||
onConnection: (state: "connecting" | "live" | "polling" | "denied") => void;
|
||||
retryMs?: number;
|
||||
}
|
||||
export class RealtimeClient {
|
||||
private socket: SocketLike | null = null;
|
||||
private retry: ReturnType<typeof setTimeout> | null = null;
|
||||
private poll: ReturnType<typeof setInterval> | null = null;
|
||||
private stopped = false;
|
||||
private seq: number | undefined;
|
||||
private backoff = 1000;
|
||||
private resyncing = false;
|
||||
private resyncPending = false;
|
||||
private handshake: ReturnType<typeof setTimeout> | null = null;
|
||||
private messages: Promise<void> = Promise.resolve();
|
||||
constructor(private readonly options: Options) {}
|
||||
get lastSeq(): number | undefined { return this.seq; }
|
||||
start(): void { this.stopped = false; this.connect(); }
|
||||
private connect(): void {
|
||||
if (this.stopped) return;
|
||||
this.options.onConnection("connecting");
|
||||
const socket = this.options.socket(this.options.url); this.socket = socket;
|
||||
socket.onopen = () => socket.send(JSON.stringify({ type: "auth", token: this.options.token(), workspaceId: this.options.workspaceId }));
|
||||
socket.onmessage = event => { this.messages = this.messages.then(() => this.receive(String(event.data))).catch(() => socket.close()); };
|
||||
this.handshake = setTimeout(() => socket.close(), 5000);
|
||||
socket.onerror = () => socket.close();
|
||||
socket.onclose = event => {
|
||||
if (this.stopped) return;
|
||||
if (this.handshake) clearTimeout(this.handshake);
|
||||
if (event.code === 1008) { this.options.onConnection("denied"); this.stop(); return; }
|
||||
this.options.onConnection("polling");
|
||||
if (!this.poll) this.poll = setInterval(() => { void this.refresh(); }, 2000);
|
||||
this.retry = setTimeout(() => this.connect(), this.options.retryMs ?? this.backoff);
|
||||
this.backoff = Math.min(8000, this.backoff * 2);
|
||||
};
|
||||
}
|
||||
async receive(raw: string): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
let frame: Record<string, unknown>;
|
||||
try { const value: unknown = JSON.parse(raw); if (!value || typeof value !== "object") return; frame = value as Record<string, unknown>; } catch { return; }
|
||||
if (frame.type === "auth_ack") this.socket?.send(JSON.stringify({ type: "subscribe", channelId: this.options.channelId, ...(this.seq === undefined ? {} : { lastSeq: this.seq }) }));
|
||||
if (frame.type === "subscribe_denied") { this.options.onConnection("denied"); this.stop(); }
|
||||
if (frame.type === "subscribe_ack") {
|
||||
if (this.handshake) clearTimeout(this.handshake);
|
||||
this.backoff = 1000; if (this.poll) clearInterval(this.poll); this.poll = null;
|
||||
// ACK advertises the ring head, NOT the last consumed frame. Do not advance seq.
|
||||
this.options.onConnection("live");
|
||||
}
|
||||
if (frame.type === "RESYNC_REQUIRED" && (!frame.channelId || frame.channelId === this.options.channelId)) {
|
||||
this.seq = undefined;
|
||||
this.resyncPending = true;
|
||||
this.options.onConnection("polling");
|
||||
if (!this.poll) this.poll = setInterval(() => { void this.refresh(); }, 2000);
|
||||
await this.refresh();
|
||||
}
|
||||
if (frame.type === "state" && frame.channelId === this.options.channelId && frame.workspaceId === this.options.workspaceId && (frame.kind === "desired" || frame.kind === "observed") && Number.isSafeInteger(frame.seq) && Number(frame.seq) > (this.seq ?? -1)) {
|
||||
this.seq = Number(frame.seq); this.options.onFrame(frame as unknown as StateFrame);
|
||||
this.socket?.send(JSON.stringify({ type: "ack", channelId: this.options.channelId, seq: this.seq }));
|
||||
}
|
||||
}
|
||||
private async refresh(): Promise<boolean> {
|
||||
if (this.resyncing) return false;
|
||||
this.resyncing = true;
|
||||
const startedSeq = this.seq;
|
||||
try {
|
||||
const status = await this.options.status();
|
||||
// An old REST poll must not overwrite newer WS observations after reconnect.
|
||||
if (!this.stopped && this.seq === startedSeq) {
|
||||
this.options.onStatus(status);
|
||||
if (this.resyncPending) {
|
||||
this.seq = undefined; this.resyncPending = false;
|
||||
this.socket?.send(JSON.stringify({ type: "subscribe", channelId: this.options.channelId }));
|
||||
}
|
||||
}
|
||||
return !this.stopped;
|
||||
}
|
||||
catch { return false; }
|
||||
finally { this.resyncing = false; }
|
||||
}
|
||||
stop(): void { this.stopped = true; if (this.retry) clearTimeout(this.retry); if (this.poll) clearInterval(this.poll); if (this.handshake) clearTimeout(this.handshake); this.socket?.close(); }
|
||||
}
|
||||
16
apps/web/src/components/control-room/switcher-bar.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
import { Button } from "../ui/primitives";
|
||||
import type { Desired, OverrideMode } from "./types";
|
||||
export type DirectorAction = "pst-select" | "take" | "cut" | "lock" | "override";
|
||||
export function SwitcherBar({ desired, disabled, busy, onAction }: { desired: Desired; disabled: boolean; busy: boolean; onAction: (action: DirectorAction, body: object) => void }): React.ReactElement {
|
||||
const production = desired.production;
|
||||
const locked = desired.director?.locked ?? false;
|
||||
return <section aria-label="Switcher" className="flex flex-wrap items-end gap-3 rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<label className="min-w-0 flex-1 text-xs">PST scene<select aria-label="PST scene" className="cr-field mt-1" disabled={disabled || busy} value={production?.buses.preview ?? ""} onChange={event => onAction("pst-select", { sceneId: event.target.value })}><option value="">Select a scene</option>{production?.scenes.map(scene => <option key={scene.id} value={scene.id}>{scene.label}</option>)}</select></label>
|
||||
<Button className="min-h-11 min-w-20 justify-center" variant="outline" disabled={disabled || busy || !production?.buses.preview} title="PST to PGM at the next ready frame boundary (CUT transition)" onClick={() => onAction("take", {})}>TAKE</Button>
|
||||
<Button className="min-h-11 min-w-20 justify-center" variant="primary" disabled={disabled || busy || !production?.buses.preview} title="Request an immediate CUT; tally waits for worker observation" onClick={() => onAction("cut", {})}>CUT</Button>
|
||||
<Button className="min-h-11 justify-center" variant="outline" disabled={disabled || busy} aria-pressed={locked} onClick={() => onAction("lock", { enabled: !locked })}>{locked ? "Unlock program" : "Lock program"}</Button>
|
||||
<label className="text-xs">Operator override<select className="cr-field mt-1" disabled={disabled || busy} value={desired.director?.override?.mode ?? "auto"} onChange={event => onAction("override", { mode: event.target.value === "auto" ? null : event.target.value as OverrideMode })}><option value="auto">Return to auto</option><option value="until_released">Until released</option><option value="five_minutes">Five minutes</option><option value="until_source_offline">Until source offline</option><option value="until_next_event">Until next event</option></select></label>
|
||||
<p className="w-full text-xs text-text-faint">{locked ? "LOCKED" : desired.director?.override ? "MANUAL" : "AUTO"} · TAKE uses the next ready frame boundary; CUT requests an immediate switch. Both wait for worker confirmation.</p>
|
||||
</section>;
|
||||
}
|
||||
43
apps/web/src/components/control-room/tally.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { Channel, Desired, Observed, Production, StateFrame } from "./types";
|
||||
|
||||
export interface TallyState { channel: Channel; snapshots: ReadonlyMap<number, Production>; observedProduction: Production | null; lastSeq: number }
|
||||
function boundSnapshots(snapshots: Map<number, Production>, realizedVersion: number | null | undefined): void {
|
||||
while (snapshots.size > 256) {
|
||||
const oldest = [...snapshots.keys()].find(key => key !== realizedVersion);
|
||||
if (oldest === undefined) break;
|
||||
snapshots.delete(oldest);
|
||||
}
|
||||
}
|
||||
function realized(channel: Channel, snapshots: ReadonlyMap<number, Production>): Production | null {
|
||||
const observed = channel.observed;
|
||||
return observed && observed.state !== "STOPPED" && observed.state !== "FAILED" && observed.channelId === channel.channelId && observed.assignmentEpoch === channel.assignmentEpoch && observed.workerId === channel.workerId && observed.realizedVersion !== null
|
||||
? snapshots.get(observed.realizedVersion) ?? null : null;
|
||||
}
|
||||
export function initialTally(channel: Channel): TallyState {
|
||||
const snapshots = new Map<number, Production>();
|
||||
if (channel.desired?.production) snapshots.set(channel.desired.desiredVersion, channel.desired.production);
|
||||
return { channel, snapshots, observedProduction: realized(channel, snapshots), lastSeq: 0 };
|
||||
}
|
||||
export function refetchTally(channel: Channel, previous: TallyState | null): TallyState {
|
||||
const fresh = initialTally(channel);
|
||||
if (!previous || previous.channel.channelId !== channel.channelId || previous.channel.workerId !== channel.workerId || previous.channel.assignmentEpoch !== channel.assignmentEpoch) return fresh;
|
||||
const snapshots = new Map([...previous.snapshots, ...fresh.snapshots]);
|
||||
boundSnapshots(snapshots, channel.observed?.realizedVersion);
|
||||
return { ...fresh, snapshots, observedProduction: realized(channel, snapshots) };
|
||||
}
|
||||
export function tallyReducer(state: TallyState, frame: StateFrame): TallyState {
|
||||
if (frame.channelId !== state.channel.channelId || frame.seq <= state.lastSeq) return state;
|
||||
const channel = { ...state.channel };
|
||||
const snapshots = new Map(state.snapshots);
|
||||
if (frame.kind === "desired") {
|
||||
channel.desired = frame.data as Desired | null;
|
||||
if (channel.desired?.production) snapshots.set(channel.desired.desiredVersion, channel.desired.production);
|
||||
} else channel.observed = frame.data as Observed | null;
|
||||
// Keep the realized snapshot while bounding memory during long sessions.
|
||||
boundSnapshots(snapshots, channel.observed?.realizedVersion);
|
||||
return { channel, snapshots, observedProduction: frame.kind === "observed" ? realized(channel, snapshots) : state.observedProduction, lastSeq: frame.seq };
|
||||
}
|
||||
export function tileTally(production: Production | null, sourceId: string): "program" | "preview" | "none" {
|
||||
const contains = (id: string | null | undefined): boolean => Boolean(production?.scenes.find(scene => scene.id === id)?.slots.some(slot => slot.sourceId === sourceId));
|
||||
return contains(production?.buses.program) ? "program" : contains(production?.buses.preview) ? "preview" : "none";
|
||||
}
|
||||
14
apps/web/src/components/control-room/types.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// Web-facing subset of the live media-control contracts. No remote source URLs are consumed.
|
||||
export interface Slot { index: number; sourceId: string | null; rect: { x: number; y: number; w: number; h: number } | null; fit: "contain" | "cover" | "crop"; audioMode: "off" | "on" | "primary"; gainDb: number }
|
||||
export interface Scene { id: string; label: string; layout: "single" | "pip" | "2x1" | "2x2" | "3x3" | "4x4" | "custom"; slots: Slot[] }
|
||||
export interface Source { id: string; label: string; kind: "hls" | "playlist" }
|
||||
export interface Overlay { id: string; sceneId: string; kind: "lower_third" | "chyron" | "logo" | "clock"; enabled: boolean; layer: number; position: { x: number; y: number }; revisionId: string | null; assetId: string | null }
|
||||
export interface Production { scenes: Scene[]; sources: Source[]; buses: { preview: string | null; program: string | null }; overlays: Overlay[]; audio: { masterGainDb: number } }
|
||||
export type OverrideMode = "until_released" | "five_minutes" | "until_source_offline" | "until_next_event";
|
||||
export interface Desired { desiredVersion: number; desiredStatus: string; production?: Production; director?: { locked: boolean; override: { mode: OverrideMode; expiresAt: string | null } | null } }
|
||||
export interface Observed { workspaceId: string; channelId: string; workerId: string; assignmentEpoch: number; state: string; realizedVersion: number | null; updatedAt: string; reason: string }
|
||||
export interface Channel { channelId: string; workerId: string; assignmentEpoch: number; desired: Desired | null; observed: Observed | null; converged: boolean; workerAlive: boolean }
|
||||
export interface Status { channels: Channel[] }
|
||||
export interface StateFrame { type: "state"; workspaceId: string; channelId: string; kind: "desired" | "observed"; seq: number; data: Desired | Observed | null }
|
||||
export interface PreviewUrls { thumbnail: string; video: string; audio: string }
|
||||
export interface Receipt { desiredVersion: number; commandId: string }
|
||||
85
apps/web/src/components/live-chat/chat-panel.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ChatMessage, Delivery, LiveChatCapability, ModerateChatBody, SendChatMessageBody } from "../../../../../packages/core/src/chat-contract";
|
||||
import { CHAT_CONTRACT_EXAMPLES } from "../../../../../packages/core/src/chat-contract.schema";
|
||||
|
||||
export interface ChatPanelProps {
|
||||
messages?: ChatMessage[];
|
||||
capabilities?: LiveChatCapability[];
|
||||
delivery?: Delivery[];
|
||||
readOnlyEmbedUrl?: string;
|
||||
onSend?: (body: SendChatMessageBody) => Promise<void>;
|
||||
onModerate?: (messageId: string, body: ModerateChatBody) => Promise<void>;
|
||||
}
|
||||
const fixtures: ChatMessage[] = [CHAT_CONTRACT_EXAMPLES.chatMessage];
|
||||
const fixtureCapabilities: LiveChatCapability[] = [CHAT_CONTRACT_EXAMPLES.capability];
|
||||
|
||||
/** Standalone surface. The Control Room supplies REST/WS state and callbacks. */
|
||||
export function ChatPanel({ messages = fixtures, capabilities = fixtureCapabilities, delivery = [], readOnlyEmbedUrl, onSend, onModerate }: ChatPanelProps) {
|
||||
const [platform, setPlatform] = useState("all");
|
||||
const [target, setTarget] = useState("all");
|
||||
const [text, setText] = useState("");
|
||||
const [replyTo, setReplyTo] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [demoMessages, setDemoMessages] = useState<ChatMessage[]>([]);
|
||||
const [hidden, setHidden] = useState<string[]>([]);
|
||||
const rows = [...messages, ...demoMessages].filter(message => !message.isModerated && !hidden.includes(message.id) && (platform === "all" || message.provider === platform));
|
||||
const canSend = capabilities.some(cap => cap.reply && (cap.state === "ok" || cap.state === "degraded") && (target === "all" || cap.connectionId === target));
|
||||
const reply = async () => {
|
||||
if (!text.trim() || busy || !canSend) return;
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
const body: SendChatMessageBody = { text, targets: target === "all" ? "all" : [target], ...(replyTo ? { replyTo } : {}) };
|
||||
if (onSend) await onSend(body);
|
||||
else setDemoMessages(current => [...current, { ...CHAT_CONTRACT_EXAMPLES.chatMessage, id: crypto.randomUUID(), seq: messages.length + current.length + 1, text, author: { handle: "studio", color: "hsl(200 70% 62%)" }, sentAt: new Date().toISOString(), receivedAt: new Date().toISOString(), ...(replyTo ? { replyTo } : {}) }]);
|
||||
setText(""); setReplyTo(undefined);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "Reply failed"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const moderate = async (message: ChatMessage, body: ModerateChatBody) => {
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
if (onModerate) await onModerate(message.id, body);
|
||||
else setHidden(current => [...current, message.id]);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "Moderation failed"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return <section aria-label="Live chat" className="flex min-h-80 flex-col gap-3 rounded-lg border border-white/10 bg-zinc-950 p-4 text-zinc-100">
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">Live chat</h2>
|
||||
<label>Platform <select aria-label="Filter platform" value={platform} onChange={event => setPlatform(event.target.value)} className="rounded bg-zinc-800 p-1">
|
||||
<option value="all">All platforms</option>{[...new Set(messages.map(message => message.provider))].map(provider => <option key={provider}>{provider}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
{!onSend && <p className="text-xs text-zinc-400">Fixture preview</p>}
|
||||
{readOnlyEmbedUrl && readOnlyEmbedUrl.startsWith("https://") && <iframe title="Read-only live chat" src={readOnlyEmbedUrl} className="h-80 w-full rounded" sandbox="allow-scripts allow-same-origin" referrerPolicy="no-referrer" />}
|
||||
{capabilities.filter(cap => cap.reason).map(cap => <p key={cap.connectionId} className="text-xs text-zinc-400">{cap.provider}: {cap.reason}</p>)}
|
||||
<ol aria-live="polite" className="max-h-96 flex-1 space-y-3 overflow-auto">
|
||||
{rows.map(message => <li key={message.id} className="rounded bg-zinc-900 p-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm"><span className="rounded bg-zinc-800 px-2 text-xs">{message.provider}</span>
|
||||
<strong style={{ color: message.author.color }}>{message.author.displayName ?? message.author.handle}</strong>
|
||||
{message.author.badges?.map(badge => <span key={badge} className="text-xs text-zinc-400">{badge}</span>)}
|
||||
<time className="text-xs text-zinc-500" dateTime={message.sentAt}>{new Date(message.sentAt).toLocaleTimeString()}</time>
|
||||
</div><p className="whitespace-pre-wrap break-words">{message.text}</p>
|
||||
<div className="mt-1 flex gap-3 text-xs"><button type="button" onClick={() => setReplyTo(message.id)}>Reply</button>
|
||||
{capabilities.some(cap => cap.connectionId === message.connectionId && cap.moderate && (cap.state === "ok" || cap.state === "degraded")) && <>
|
||||
<button type="button" disabled={busy} onClick={() => void moderate(message, { action: "delete" })}>Delete</button>
|
||||
<button type="button" disabled={busy} onClick={() => void moderate(message, { action: "timeout", durationSeconds: 600 })}>Timeout 10 min</button>
|
||||
</>}
|
||||
</div>
|
||||
</li>)}
|
||||
</ol>
|
||||
{rows.length === 0 && <p className="text-sm text-zinc-400">No messages yet.</p>}
|
||||
<form className="space-y-2" onSubmit={event => { event.preventDefault(); void reply(); }}>
|
||||
{replyTo && <p className="text-xs">Replying to a message <button type="button" onClick={() => setReplyTo(undefined)}>Cancel</button></p>}
|
||||
<label className="block text-sm">Send to <select aria-label="Reply targets" className="rounded bg-zinc-800 p-1" value={target} onChange={event => setTarget(event.target.value)}>
|
||||
<option value="all">All capable</option>{capabilities.map(cap => <option key={cap.connectionId} value={cap.connectionId} disabled={!cap.reply || (cap.state !== "ok" && cap.state !== "degraded")}>{cap.provider}{cap.reason ? ` — ${cap.reason}` : ""}</option>)}
|
||||
</select></label>
|
||||
<div className="flex gap-2"><input aria-label="Chat reply" value={text} onChange={event => setText(event.target.value)} maxLength={500} placeholder="Write a reply…" className="min-w-0 flex-1 rounded bg-zinc-800 p-2" />
|
||||
<button disabled={busy || !text.trim() || !canSend} className="rounded bg-indigo-600 px-4 disabled:opacity-40">Send</button></div>
|
||||
{delivery.map(row => <p key={row.connectionId} className="text-xs">{row.provider}: {row.status}{row.error ? ` — ${row.error}` : ""}</p>)}
|
||||
{error && <p role="alert" className="text-sm text-red-400">{error}</p>}
|
||||
</form>
|
||||
</section>;
|
||||
}
|
||||
49
apps/web/src/components/mobile/amplify-sheet.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FeedItem } from "../../lib/fixtures";
|
||||
import { PLATFORMS, type Platform } from "../../lib/platforms";
|
||||
import type { ConnectionSummary } from "../../lib/api-types";
|
||||
import { useApiResource } from "../../lib/use-api";
|
||||
import { Sheet } from "./sheet";
|
||||
import { nativeAmplifyActions } from "../../lib/amplify";
|
||||
|
||||
const identity = (value: ConnectionSummary[]): ConnectionSummary[] => value;
|
||||
const empty: ConnectionSummary[] = [];
|
||||
|
||||
export function AmplifySheet({ post, platform, open, onClose }: { post: FeedItem | null; platform: Platform | null; open: boolean; onClose: () => void }): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const connections = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", identity, empty);
|
||||
const [picked, setPicked] = useState<string[]>([]);
|
||||
const [notice, setNotice] = useState("");
|
||||
const native = platform === null ? [] : nativeAmplifyActions(platform);
|
||||
function compose(ids: string[]): void {
|
||||
if (post === null) return;
|
||||
const params = new URLSearchParams({ text: `${post.text}\n\nVia ${post.handle}`, targets: ids.join(",") });
|
||||
onClose();
|
||||
router.push(`/compose?${params.toString()}`);
|
||||
}
|
||||
async function share(): Promise<void> {
|
||||
if (post === null) return;
|
||||
const url = window.location.href;
|
||||
if (navigator.share) await navigator.share({ title: `Post by ${post.handle}`, text: post.text, url }).catch(() => undefined);
|
||||
else if (navigator.clipboard) { await navigator.clipboard.writeText(`${post.text}\n${url}`); setNotice("Post text copied"); }
|
||||
}
|
||||
return <Sheet open={open} onClose={onClose} title={post === null ? "Amplify" : `Amplify ${post.handle}`} footer={<div className="flex gap-2"><button type="button" className="min-h-11 min-w-11 rounded-md border border-border px-3" onClick={() => void share()}>Share</button><button type="button" className="min-h-11 flex-1 rounded-md bg-accent px-3 text-sm font-semibold text-white disabled:opacity-40" disabled={picked.length === 0} onClick={() => compose(picked)}>Crosspost to {picked.length}</button></div>}>
|
||||
{platform !== null && <><h3 className="mt-2 text-xs font-semibold uppercase text-text-faint">Native on {platform.displayName}</h3>
|
||||
{native.length === 0 ? <p className="mt-3 text-xs text-text-faint">This platform exposes no native amplify action.</p> : native.map((action) => <button key={action.kind} type="button" className="mt-2 flex min-h-12 w-full items-center rounded-md border border-border px-4 text-left text-sm" onClick={() => action.kind === "quote" ? compose([]) : setNotice(`${action.label} is available in the platform, but its connector action is not wired yet.`)}>{action.label}{action.kind === "quote" && <span className="ml-auto text-xs text-text-faint">Opens compose</span>}</button>)}
|
||||
</>}
|
||||
{notice && <p className="mt-2 text-xs text-warn" role="status">{notice}</p>}
|
||||
<h3 className="mt-6 text-xs font-semibold uppercase text-text-faint">Crosspost to your other accounts</h3>
|
||||
{connections.source !== "live" && <p className="mt-2 text-xs text-text-faint">Connect to the API to choose publishing accounts.</p>}
|
||||
{connections.data.filter((connection) => connection.provider !== platform?.id).map((connection) => {
|
||||
const target = PLATFORMS[connection.provider];
|
||||
const reason = !target?.capabilities.publishText ? "Text publishing unavailable" : target.capabilities.requiresMediaForPublish && !post?.hasMedia ? `${target.displayName} needs media` : connection.health !== "healthy" ? "Reconnect account first" : "";
|
||||
return <label key={connection.id} className={`mt-2 flex min-h-14 items-center gap-3 rounded-md border border-border px-3 ${reason ? "opacity-60" : ""}`}>
|
||||
<input type="checkbox" className="h-5 w-5 accent-accent" disabled={reason !== ""} checked={picked.includes(connection.id)} onChange={(event) => setPicked((current) => event.target.checked ? [...current, connection.id] : current.filter((id) => id !== connection.id))} />
|
||||
<span className="min-w-0 flex-1"><span className="block text-sm">{target?.displayName ?? connection.provider}</span><span className="block text-xs text-text-faint">{reason || "Creates a new attributed post in compose"}</span></span>
|
||||
</label>;
|
||||
})}
|
||||
</Sheet>;
|
||||
}
|
||||
73
apps/web/src/components/mobile/approvals-segment.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api, errorMessage } from "../../lib/api-client";
|
||||
import type { ConnectionSummary, DraftRecord, ScheduledPublicationRecord } from "../../lib/api-types";
|
||||
import { useApiResource } from "../../lib/use-api";
|
||||
import { useAuth } from "../../lib/auth-store";
|
||||
import { DOCUMENTED_RATES, MOCK_COLUMN_DEFS, PLATFORMS } from "../../lib/platforms";
|
||||
import { OfflineBadge } from "../ui/offline-badge";
|
||||
import { Sheet } from "./sheet";
|
||||
|
||||
const sample: DraftRecord[] = [{ id: "sample-review", workspaceId: "sample", authorUserId: "sample-contributor", state: "in_review", createdAt: "2026-10-07T08:00:00Z", updatedAt: "2026-10-07T08:00:00Z", post: { text: "Draft launch update. Please review the target wording before it goes out.", mediaAssetIds: [], visibility: "public", providerMetadata: { targetConnectionIds: [MOCK_COLUMN_DEFS[0]?.platformId] } } }];
|
||||
const identity = (value: DraftRecord[]): DraftRecord[] => value;
|
||||
const connectionIdentity = (value: ConnectionSummary[]): ConnectionSummary[] => value;
|
||||
const noConnections: ConnectionSummary[] = [];
|
||||
const quickReasons = ["Needs edits", "Wrong target", "Please add context"];
|
||||
|
||||
export function ApprovalsSegment(): React.ReactElement {
|
||||
const { can } = useAuth();
|
||||
const resource = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, sample, { state: "in_review", limit: 100 });
|
||||
const connections = useApiResource<ConnectionSummary[], ConnectionSummary[]>("/connections", connectionIdentity, noConnections);
|
||||
const [rejecting, setRejecting] = useState<DraftRecord | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const live = resource.source === "live";
|
||||
async function decide(draft: DraftRecord, action: "approve" | "reject"): Promise<void> {
|
||||
if (!live || !can("approval.grant")) return;
|
||||
setBusy(draft.id);
|
||||
setNotice("");
|
||||
try {
|
||||
await api.post<DraftRecord>(`/publishing/drafts/${draft.id}/${action}`, action === "reject" ? { reason } : {});
|
||||
const connectionId = draft.post.providerMetadata.connectionId;
|
||||
const queueScheduled = action === "approve" && draft.post.scheduledAt && typeof connectionId === "string" && can("publication.schedule");
|
||||
if (queueScheduled) await api.post<ScheduledPublicationRecord>(`/publishing/drafts/${draft.id}/schedule`, { connectionId, publishAt: draft.post.scheduledAt });
|
||||
resource.setData((current) => current.filter((item) => item.id !== draft.id));
|
||||
setNotice(`${action === "approve" ? queueScheduled ? "Approved and queued" : "Approved" : "Rejected"} · decision recorded in audit log.`);
|
||||
setRejecting(null);
|
||||
setReason("");
|
||||
resource.reload();
|
||||
} catch (error) { setNotice(errorMessage(error)); }
|
||||
finally { setBusy(null); }
|
||||
}
|
||||
return <section className="min-w-0 flex-1 overflow-y-auto p-4" aria-label="Approvals">
|
||||
<div className="mb-4 flex items-center justify-between"><div><h2 className="text-lg font-semibold">Approvals</h2><p className="text-xs text-text-faint">{resource.data.length} waiting for review</p></div><OfflineBadge source={resource.source} /></div>
|
||||
{resource.source !== "live" && <p className="mb-3 text-xs text-text-faint">Sample review only. Connect to the API to approve or reject.</p>}
|
||||
{resource.error && <p className="mb-3 text-xs text-danger">{resource.error}</p>}
|
||||
{notice && <div role="status" className="mb-3 flex min-h-11 items-center justify-between gap-2 rounded-md border border-border px-3 text-xs"><span>{notice}</span><Link href="/audit" className="shrink-0 text-accent">View audit</Link></div>}
|
||||
{resource.data.length === 0 && <p className="py-8 text-center text-sm text-text-faint">No drafts waiting for review.</p>}
|
||||
<div className="space-y-3">{resource.data.map((draft) => {
|
||||
const ids = Array.isArray(draft.post.providerMetadata.targetConnectionIds) ? draft.post.providerMetadata.targetConnectionIds : [draft.post.providerMetadata.connectionId].filter(Boolean);
|
||||
const targets = ids.filter((id): id is string => typeof id === "string");
|
||||
const scheduled = draft.post.scheduledAt;
|
||||
const targetConnections = targets.map((id) => connections.data.find((connection) => connection.id === id)).filter((connection): connection is ConnectionSummary => connection !== undefined);
|
||||
const tooLong = targetConnections.length > 0 ? targetConnections.some((connection) => draft.post.text.length > (PLATFORMS[connection.provider]?.charLimit ?? 500)) : draft.post.text.length > 500;
|
||||
const missingMedia = targetConnections.some((connection) => PLATFORMS[connection.provider]?.capabilities.requiresMediaForPublish && draft.post.mediaAssetIds.length === 0);
|
||||
const atCapacity = targetConnections.some((connection) => { const cap = DOCUMENTED_RATES[connection.provider]?.dailyPostCap; return cap !== undefined && connection.recentPublishes.length >= cap; });
|
||||
return <article key={draft.id} className="rounded-lg border border-border bg-surface p-4">
|
||||
<div className="flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-2"><span className="truncate text-sm font-semibold">{draft.authorUserId === "sample-contributor" ? "Sample contributor" : `Contributor ${draft.authorUserId.slice(0, 8)}`}</span>{!live && <span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-2xs text-text-faint">sample</span>}</span><span className={`rounded-full px-2 py-1 text-2xs ${scheduled ? "bg-accent-dim text-accent" : "bg-surface-2 text-text-dim"}`}>{scheduled ? new Date(scheduled).toLocaleString() : "Post now"}</span></div>
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm">{draft.post.text}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1">{targets.map((id) => { const connection = connections.data.find((item) => item.id === id); return <span key={id} className="rounded-full border border-border px-2 py-1 text-2xs text-text-dim">{PLATFORMS[connection?.provider ?? id]?.displayName ?? id.slice(0, 8)}</span>; })}</div>
|
||||
{(tooLong || missingMedia || atCapacity) && <p className="mt-3 text-xs text-warn">Preflight: {[tooLong && "over a target text limit", missingMedia && "target needs media", atCapacity && "target at capacity"].filter(Boolean).join(" · ")}. Review before approving.</p>}
|
||||
{scheduled && <p className="mt-2 text-xs text-text-faint">{can("publication.schedule") && typeof draft.post.providerMetadata.connectionId === "string" ? "Approving queues this scheduled post; it does not publish now." : "Approval changes the draft state. A publisher must queue the scheduled post."}</p>}
|
||||
<div className="mt-4 flex gap-2"><button type="button" disabled className="min-h-11 rounded-md border border-border px-3 text-xs opacity-40" title="Review comments are not available in the API">Reply</button><button type="button" disabled={!live || !can("approval.grant") || busy !== null} onClick={() => { setRejecting(draft); setReason(""); }} className="min-h-11 rounded-md border border-danger px-3 text-xs text-danger disabled:opacity-40">Reject</button><button type="button" disabled={!live || !can("approval.grant") || busy !== null} onClick={() => void decide(draft, "approve")} className="min-h-11 flex-1 rounded-md bg-accent px-3 text-xs font-semibold text-white disabled:opacity-40">Approve</button></div>
|
||||
{tooLong && <p className="mt-2 text-2xs text-text-faint">Auto-trim requires an edit operation during review and is unavailable.</p>}
|
||||
</article>;
|
||||
})}</div>
|
||||
<Sheet open={rejecting !== null} onClose={() => setRejecting(null)} title="Reject draft" footer={<button type="button" disabled={busy !== null || reason.trim() === ""} onClick={() => { if (rejecting) void decide(rejecting, "reject"); }} className="min-h-11 w-full rounded-md bg-danger px-3 text-sm font-semibold text-white disabled:opacity-40">Reject with reason</button>}>
|
||||
<p className="mb-3 text-xs text-text-faint">Choose a reason or write your own.</p><div className="flex flex-wrap gap-2">{quickReasons.map((pick) => <button key={pick} type="button" onClick={() => setReason(pick)} className="min-h-11 rounded-full border border-border px-3 text-xs">{pick}</button>)}</div><textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="Reason for rejection" className="mt-3 min-h-24 w-full rounded-md border border-border bg-bg p-3 text-sm" />
|
||||
</Sheet>
|
||||
</section>;
|
||||
}
|
||||
101
apps/web/src/components/mobile/bottom-nav.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { MouseEvent } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { CreateFab } from "./create-fab";
|
||||
import { SpeedDial } from "./speed-dial";
|
||||
import { useApiResource } from "../../lib/use-api";
|
||||
import type { DraftRecord } from "../../lib/api-types";
|
||||
import { NavIcon } from "../brand";
|
||||
|
||||
const emptyDrafts: DraftRecord[] = [];
|
||||
const identity = (drafts: DraftRecord[]): DraftRecord[] => drafts;
|
||||
|
||||
const tabs = [
|
||||
{ href: "/streams", label: "Home", icon: "home" },
|
||||
{ href: "/search", label: "Search", icon: "search" },
|
||||
{ href: "/inbox", label: "Inbox", icon: "inbox" },
|
||||
{ href: "/more", label: "More", icon: "more" },
|
||||
] as const;
|
||||
|
||||
/** Exact-match-first tab ranking: a route that is a tab itself outranks one that
|
||||
* merely lives under it, and routes like /compose match nothing at all — so
|
||||
* /compose no longer highlights Home and More no longer swallows calendar/video. */
|
||||
function activeTab(pathname: string): string {
|
||||
const candidates: Array<[string, number]> = [
|
||||
["/inbox", pathname === "/inbox" || pathname.startsWith("/inbox/") ? 5 : -1],
|
||||
["/approvals", pathname === "/approvals" ? 4 : -1],
|
||||
["/streams", pathname === "/streams" ? 3 : -1],
|
||||
["/search", pathname === "/search" ? 3 : -1],
|
||||
["/more", pathname === "/more" ? 3 : -1],
|
||||
["/team", pathname.startsWith("/team") ? 1 : -1],
|
||||
["/connections", pathname.startsWith("/connections") ? 1 : -1],
|
||||
["/audit", pathname.startsWith("/audit") ? 1 : -1],
|
||||
["/settings", pathname.startsWith("/settings") ? 1 : -1],
|
||||
["/onboarding", pathname.startsWith("/onboarding") ? 1 : -1],
|
||||
["/calendar", pathname.startsWith("/calendar") ? 1 : -1],
|
||||
["/analytics", pathname.startsWith("/analytics") ? 1 : -1],
|
||||
["/video", pathname.startsWith("/video") ? 1 : -1],
|
||||
];
|
||||
let best = "";
|
||||
let bestScore = 0;
|
||||
for (const [href, score] of candidates) {
|
||||
if (score > bestScore) { best = href; bestScore = score; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function BottomNav({ badges }: { badges: { inbox: number } }): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lastTap, setLastTap] = useState<{ href: string; time: number } | null>(null);
|
||||
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, emptyDrafts, { limit: 100 });
|
||||
// The unread badge is supplied by the shell from live approvals-in-review; keep
|
||||
// this fetch only for draft counts (Speed dial / create button).
|
||||
const actionable = badges.inbox;
|
||||
const active = activeTab(pathname);
|
||||
const activate = (href: string, event: MouseEvent<HTMLAnchorElement>): void => {
|
||||
if (active !== href || pathname !== href) return;
|
||||
event.preventDefault();
|
||||
const now = Date.now();
|
||||
const scroller = document.querySelector<HTMLElement>("[data-mobile-main]");
|
||||
scroller?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
scroller?.querySelectorAll<HTMLElement>("*").forEach((element) => {
|
||||
if (element.scrollTop > 0 && /auto|scroll/.test(getComputedStyle(element).overflowY)) element.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
if (href === "/search") document.querySelector<HTMLInputElement>("[data-search-field]")?.focus();
|
||||
if (lastTap?.href === href && now - lastTap.time < 750) router.refresh();
|
||||
setLastTap({ href, time: now });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SpeedDial open={open} onClose={() => setOpen(false)} onResumeDrafts={() => router.push("/compose#drafts")} draftCount={drafts.data.length} />
|
||||
<nav aria-label="Primary navigation" className={`relative z-50 grid h-[calc(var(--mobile-tab-height)+var(--mobile-safe-bottom))] shrink-0 grid-cols-5 border-t border-border-subtle bg-surface pb-[var(--mobile-safe-bottom)] lg:hidden ${open ? "pointer-events-none" : ""}`}>
|
||||
{tabs.slice(0, 2).map((tab) => <Tab key={tab.href} {...tab} active={active === tab.href} badge={0} onClick={(event) => activate(tab.href, event)} />)}
|
||||
<div className="relative flex items-start justify-center">
|
||||
<div className="pointer-events-auto absolute -top-6 z-10"><CreateFab draftCount={drafts.data.length} open={open} onCompose={() => router.push("/compose")} onLongPress={() => setOpen((value) => !value)} /></div>
|
||||
<button type="button" aria-label="Post" onClick={() => router.push("/compose")} className="pointer-events-auto absolute bottom-0 flex min-h-11 min-w-11 items-end justify-center pb-1 text-[11px] text-text-dim">Post</button>
|
||||
</div>
|
||||
{tabs.slice(2).map((tab) => <Tab key={tab.href} {...tab} active={active === tab.href} badge={tab.href === "/inbox" ? actionable : 0} onClick={(event) => activate(tab.href, event)} />)}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({ href, label, icon, active, badge, onClick }: {
|
||||
href: string; label: string; icon: string; active: boolean; badge: number; onClick: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Link href={href} scroll={false} onClick={onClick} aria-current={active ? "page" : undefined}
|
||||
className="relative flex min-h-11 flex-col items-center justify-center gap-0.5 text-[11px] font-semibold text-text-dim">
|
||||
<span className={`relative flex h-8 w-[60px] items-center justify-center rounded-full text-[24px] leading-none ${active ? "bg-accent-dim text-accent" : ""}`}>
|
||||
<NavIcon name={icon} size={24} />
|
||||
{badge > 0 && <span aria-label={`${badge} unread`} className="absolute -right-1 -top-1 flex min-h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 text-[10px] text-white">{badge > 99 ? "99+" : badge}</span>}
|
||||
</span>
|
||||
<span className={active ? "text-accent" : ""}>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/components/mobile/create-fab.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
export function CreateFab({ draftCount, onCompose, onLongPress, open = false }: {
|
||||
draftCount: number;
|
||||
onCompose: () => void;
|
||||
onLongPress: () => void;
|
||||
open?: boolean;
|
||||
}): React.ReactElement {
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressed = useRef(false);
|
||||
const clear = (): void => { if (timer.current !== null) clearTimeout(timer.current); timer.current = null; };
|
||||
return (
|
||||
<button type="button" aria-label={open ? "Close create menu" : `Create post${draftCount > 0 ? `, ${draftCount} drafts` : ""}`}
|
||||
aria-expanded={open}
|
||||
onPointerDown={() => { longPressed.current = false; clear(); timer.current = setTimeout(() => {
|
||||
longPressed.current = true;
|
||||
if ("vibrate" in navigator) navigator.vibrate(20);
|
||||
onLongPress();
|
||||
}, 450); }}
|
||||
onPointerUp={clear} onPointerCancel={clear} onPointerLeave={clear}
|
||||
onClick={() => { if (longPressed.current) { longPressed.current = false; return; } if (open) onLongPress(); else onCompose(); }}
|
||||
className={`relative flex h-[var(--mobile-fab-size)] w-[var(--mobile-fab-size)] items-center justify-center rounded-full border-4 border-bg text-3xl font-light transition-transform ${open ? "rotate-45 bg-white text-bg" : "bg-accent text-white"}`}>
|
||||
+
|
||||
</button>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/components/mobile/post-card.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { Avatar } from "../ui/primitives";
|
||||
import { ageLabel } from "../../lib/fixtures";
|
||||
import type { StreamItem as FeedItem } from "../../lib/streams-data";
|
||||
import type { Platform } from "../../lib/platforms";
|
||||
import { SwipeItem } from "./swipe-item";
|
||||
|
||||
export function PostCard({ item, platform, onAmplify, onReply, match = "", selected = false, onSelect }: { item: FeedItem; platform: Platform; onAmplify: () => void; onReply: () => void; match?: string; selected?: boolean; onSelect?: () => void }): React.ReactElement {
|
||||
const index = match.trim() === "" ? -1 : item.text.toLowerCase().indexOf(match.trim().toLowerCase());
|
||||
const text = index < 0 ? item.text : <>{item.text.slice(0, index)}<strong className="font-bold text-accent">{item.text.slice(index, index + match.trim().length)}</strong>{item.text.slice(index + match.trim().length)}</>;
|
||||
return <SwipeItem leftActions={[{ label: platform.amplifyLabels.repost ?? platform.amplifyLabels.share ?? "Amplify", onClick: onAmplify, tone: "accent" }, { label: "Quote", onClick: onAmplify }, { label: "Crosspost", onClick: onAmplify }]}>
|
||||
<article onMouseEnter={onSelect} data-stream-selected={selected || undefined} className={`border-b border-border-subtle px-4 py-4 ${selected ? "bg-accent-dim" : ""}`} data-post-card>
|
||||
<div className="flex gap-3"><Avatar handle={item.handle} /><div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2"><span className="truncate text-sm font-semibold">{item.author}</span><span className="truncate text-xs text-text-faint">{item.handle}</span><span className="ml-auto shrink-0 text-xs text-text-faint">{ageLabel(item.ageMinutes)}</span></div>
|
||||
<span className="mt-1 inline-flex rounded-full border border-border px-2 py-0.5 text-2xs text-text-dim"><span className="mr-1.5 h-2 w-2 self-center rounded-full bg-accent" />{item.sourceLabel ?? platform.displayName}</span>
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed">{text}</p>
|
||||
{item.mediaUrls?.map((url) => <a key={url} href={url} target="_blank" rel="noreferrer" className="mr-2 text-xs text-accent">View media ↗</a>)}
|
||||
{item.hasMedia && !item.mediaUrls && <div className="mt-3 flex min-h-28 items-center justify-center rounded-lg border border-border-subtle bg-surface-2 text-xs text-text-faint">Media preview · sample feed</div>}
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-text-faint">
|
||||
<button type="button" className="min-h-11 min-w-11 text-left" onClick={onReply} aria-label={`Reply to ${item.handle}`}>↩ {item.replies}</button>
|
||||
<button type="button" className={`min-h-11 min-w-11 ${item.connectionId === undefined ? "opacity-50" : ""}`} onClick={onAmplify} title={item.connectionId === undefined ? "not connected — connect an account to boost" : undefined} aria-label={`Amplify ${item.handle}`}>↻ {item.reposts}</button>
|
||||
<span className="flex min-h-11 min-w-11 items-center justify-end" aria-label={`${item.likes} likes`}>♡ {item.likes}</span>
|
||||
</div>
|
||||
</div></div>
|
||||
</article>
|
||||
</SwipeItem>;
|
||||
}
|
||||
28
apps/web/src/components/mobile/pull-to-refresh.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState, type ReactNode, type TouchEvent } from "react";
|
||||
import { Loading } from "../brand/loading";
|
||||
|
||||
export function PullToRefresh({ onRefresh, sourceCount, children }: { onRefresh: () => Promise<void>; sourceCount: number; children: ReactNode }): React.ReactElement {
|
||||
const start = useRef<number | null>(null);
|
||||
const [distance, setDistance] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
function touchStart(event: TouchEvent<HTMLDivElement>): void {
|
||||
if (event.currentTarget.scrollTop === 0) start.current = event.touches[0]?.clientY ?? null;
|
||||
}
|
||||
function touchMove(event: TouchEvent<HTMLDivElement>): void {
|
||||
if (start.current === null || event.currentTarget.scrollTop > 0) return;
|
||||
setDistance(Math.max(0, Math.min(90, ((event.touches[0]?.clientY ?? start.current) - start.current) * 0.55)));
|
||||
}
|
||||
function touchEnd(): void {
|
||||
start.current = null;
|
||||
if (distance >= 60 && !busy) {
|
||||
setBusy(true);
|
||||
void onRefresh().finally(() => { setBusy(false); setDistance(0); });
|
||||
} else setDistance(0);
|
||||
}
|
||||
return <div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain" onTouchStart={touchStart} onTouchMove={touchMove} onTouchEnd={touchEnd} data-pull-to-refresh>
|
||||
<div className="flex items-center justify-center gap-2 overflow-hidden text-xs text-text-faint" style={{ height: busy ? 48 : distance }} aria-live="polite">{(busy || distance > 16) && <><Loading size={18} label="Refreshing" />{busy ? "Refreshing" : "Pull to refresh"} · {sourceCount} sources</>}</div>
|
||||
{children}
|
||||
</div>;
|
||||
}
|
||||
25
apps/web/src/components/mobile/segmented.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
export interface SegmentOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export function Segmented<T extends string>({ options, value, onChange }: {
|
||||
options: SegmentOption<T>[];
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div role="tablist" className="flex min-w-0 gap-1 overflow-x-auto rounded-lg bg-surface-2 p-1 scrollbar-thin">
|
||||
{options.map((option) => (
|
||||
<button key={option.value} role="tab" aria-selected={option.value === value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`flex min-h-11 shrink-0 items-center justify-center gap-1 rounded-md px-3 text-xs font-semibold ${option.value === value ? "bg-accent-dim text-accent" : "text-text-dim"}`}>
|
||||
{option.label}{option.count !== undefined && option.count > 0 && <span>{option.count > 99 ? "99+" : option.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/components/mobile/sheet.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function Sheet({ open, onClose, title, children, footer }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}): React.ReactElement | null {
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const startY = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
panel.current?.focus();
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape") onClose();
|
||||
if (event.key !== "Tab" || panel.current === null) return;
|
||||
const focusable = Array.from(panel.current.querySelectorAll<HTMLElement>("button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex='-1'])"));
|
||||
if (focusable.length === 0) { event.preventDefault(); return; }
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => { document.removeEventListener("keydown", onKey); previous?.focus(); };
|
||||
}, [open, onClose]);
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[70] flex items-end" role="presentation">
|
||||
<button className="absolute inset-0 bg-black/70" aria-label="Close sheet" onClick={onClose} />
|
||||
<div ref={panel} tabIndex={-1} role="dialog" aria-modal="true" aria-label={typeof title === "string" ? title : "Options"}
|
||||
className="relative flex max-h-[90dvh] w-full flex-col overflow-hidden rounded-t-[var(--mobile-sheet-radius)] border-t border-border bg-surface-2 pb-[var(--mobile-safe-bottom)] outline-none">
|
||||
<div role="button" tabIndex={0} aria-label="Drag down to close" className="flex h-11 shrink-0 items-start justify-center pt-2"
|
||||
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onClose(); }}
|
||||
onTouchStart={(event) => { startY.current = event.touches[0]?.clientY ?? null; }}
|
||||
onTouchEnd={(event) => { if (startY.current !== null && (event.changedTouches[0]?.clientY ?? 0) - startY.current > 80) onClose(); startY.current = null; }}>
|
||||
<span className="h-1 w-10 rounded-full bg-border" />
|
||||
</div>
|
||||
<header className="flex min-h-14 items-center justify-between gap-3 px-4">
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
<button className="flex h-11 w-11 items-center justify-center text-xl" aria-label="Close" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{children}</div>
|
||||
{footer && <footer className="border-t border-border p-4">{footer}</footer>}
|
||||
</div>
|
||||
</div>, document.body,
|
||||
);
|
||||
}
|
||||
24
apps/web/src/components/mobile/source-chips.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, type TouchEvent } from "react";
|
||||
|
||||
export interface SourceChip { id: string; label: string; dot?: string; count?: number; }
|
||||
|
||||
export function SourceChips({ sources, value, onChange }: { sources: SourceChip[]; value: string; onChange: (value: string) => void }): React.ReactElement {
|
||||
const start = useRef<{ x: number; y: number } | null>(null);
|
||||
function end(event: TouchEvent<HTMLDivElement>): void {
|
||||
if (start.current === null) return;
|
||||
const dx = (event.changedTouches[0]?.clientX ?? start.current.x) - start.current.x;
|
||||
const dy = (event.changedTouches[0]?.clientY ?? start.current.y) - start.current.y;
|
||||
start.current = null;
|
||||
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||||
const index = sources.findIndex((source) => source.id === value);
|
||||
const next = sources[Math.max(0, Math.min(sources.length - 1, index + (dx < 0 ? 1 : -1)))];
|
||||
if (next) onChange(next.id);
|
||||
}
|
||||
return <div className="flex min-w-0 gap-2 overflow-x-auto px-4 py-2 scrollbar-thin" role="tablist" aria-label="Feed sources" onTouchStart={(event) => { start.current = { x: event.touches[0]?.clientX ?? 0, y: event.touches[0]?.clientY ?? 0 }; }} onTouchEnd={end}>
|
||||
{sources.map((source) => <button key={source.id} type="button" role="tab" aria-selected={value === source.id} onClick={() => onChange(source.id)} className={`flex min-h-11 shrink-0 items-center gap-2 rounded-full border px-4 text-xs font-semibold ${value === source.id ? "border-accent bg-accent-dim text-accent" : "border-border text-text-dim"}`}>
|
||||
{source.dot && <span className="h-2 w-2 rounded-full bg-accent" aria-hidden="true" />}{source.label}{source.count !== undefined && <span className="text-text-faint">{source.count}</span>}
|
||||
</button>)}
|
||||
</div>;
|
||||
}
|
||||