15 Commits

Author SHA1 Message Date
744b315f24 fix incorrect stream metadata url
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-08 21:36:56 +01:00
255e48ffb1 fix no clips on home page login
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-08 21:23:18 +01:00
b7b3c8e51b add stream metadata response from api
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-03 18:48:21 +01:00
9630565aee fix incorrect var name
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-02 17:18:52 +01:00
eab3f8be20 restyle the rest of the pages
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-02 16:32:00 +01:00
11809a7da8 add recent vods and clips to home page
All checks were successful
CI / Maven unit tests (push) Successful in 17s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-08-02 15:17:06 +01:00
0b1ea8e089 add live now panel with time elapsed
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-30 21:52:43 +01:00
08cfbd4cbb add start date to StreamResponse for frontend
All checks were successful
CI / Maven unit tests (push) Successful in 27s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-30 20:50:29 +01:00
e5bb0a3307 remove unused Vite dependencies
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-30 19:01:59 +01:00
bb771e630b rename Box to Card
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 22:22:41 +01:00
3c50d6aaf4 add cringe home message
All checks were successful
CI / Maven unit tests (push) Successful in 20s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 21:28:34 +01:00
9dcab44465 add titles to homepage
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 21:23:34 +01:00
6ddffd8e2b fix sidebar spacing issues
All checks were successful
CI / Maven unit tests (push) Successful in 13s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 20:53:13 +01:00
c86e5e5568 add colour and remove topbar
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 20:20:26 +01:00
2f9ddadb91 add new fonts
All checks were successful
CI / Maven unit tests (push) Successful in 14s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-07-29 19:14:20 +01:00
57 changed files with 2416 additions and 1490 deletions

View File

@@ -18,3 +18,4 @@ FRONTEND_URL=
BACKEND_URL= BACKEND_URL=
STREAM_RING_LENGTH_SECOND=28800 STREAM_RING_LENGTH_SECOND=28800
STREAM_INGEST_URL=

View File

@@ -3,8 +3,13 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/small_icon.png" /> <link rel="icon" type="image/png" href="/small_icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:wght@100;200;300;400;500;600;700&family=IBM+Plex+Sans:wght@100;200;300;400;500;600;700&family=IBM+Plex+Mono:wght@100;200;300;400;500;600;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>The VoD System</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -10,16 +10,11 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.2", "@react-oauth/google": "^0.12.2",
"axios": "^1.11.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.5.0",
"flowbite-react": "^0.11.8", "flowbite-react": "^0.11.8",
"js-cookie": "^3.0.5",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"path": "^0.12.7",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0" "react-router-dom": "^7.8.0"
}, },
"devDependencies": { "devDependencies": {
@@ -36,8 +31,7 @@
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5", "vite": "^6.3.5"
"vite-plugin-mkcert": "^1.17.8"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -2459,9 +2453,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.2", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -2704,18 +2698,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2774,24 +2756,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2812,9 +2776,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.16", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2868,19 +2832,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2979,18 +2930,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/comment-json": { "node_modules/comment-json": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz",
@@ -3034,17 +2973,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -3118,15 +3046,6 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3137,32 +3056,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.396", "version": "1.5.396",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
@@ -3184,51 +3077,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -3634,42 +3482,6 @@
"tailwindcss": "^3 || ^4" "tailwindcss": "^3 || ^4"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3685,15 +3497,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3704,43 +3507,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3767,18 +3533,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -3805,58 +3559,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3894,12 +3596,6 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
"license": "ISC"
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3947,12 +3643,6 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-cookie": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
"integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4388,15 +4078,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4419,27 +4100,6 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -4564,16 +4224,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/path": {
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
"license": "MIT",
"dependencies": {
"process": "^0.11.1",
"util": "^0.10.3"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4652,24 +4302,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4721,29 +4353,6 @@
"react": "^19.2.8" "react": "^19.2.8"
} }
}, },
"node_modules/react-range-slider-input": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/react-range-slider-input/-/react-range-slider-input-3.3.5.tgz",
"integrity": "sha512-HkGjaq+q7u42K5WzFPD67duugPyD2m7EWVCJm139EqfR9AgSdEUxSFgRj6SW+nzGB5y8/6Jw75euxbeiuTT0cQ==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"core-js": "^3.22.4"
},
"peerDependencies": {
"react": ">=17 || >=18",
"react-dom": ">=17 || >=18"
}
},
"node_modules/react-range-slider-input/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5363,15 +4972,6 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
"license": "MIT",
"dependencies": {
"inherits": "2.0.3"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
@@ -5447,23 +5047,6 @@
} }
} }
}, },
"node_modules/vite-plugin-mkcert": {
"version": "1.17.12",
"resolved": "https://registry.npmjs.org/vite-plugin-mkcert/-/vite-plugin-mkcert-1.17.12.tgz",
"integrity": "sha512-HhLyUZnvkKLuk9o+HBaMlGIa6CqPTfLbVlLFBjrkN4mpZ4saYFRfFx5R12a46UxmzWT9dO8fijmblVvCYvCD3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=v18.0.0"
},
"peerDependencies": {
"vite": ">=3"
}
},
"node_modules/vite/node_modules/fdir": { "node_modules/vite/node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",

View File

@@ -13,16 +13,11 @@
}, },
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.12.2", "@react-oauth/google": "^0.12.2",
"axios": "^1.11.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.5.0",
"flowbite-react": "^0.11.8", "flowbite-react": "^0.11.8",
"js-cookie": "^3.0.5",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"path": "^0.12.7",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-range-slider-input": "^3.2.1",
"react-router-dom": "^7.8.0" "react-router-dom": "^7.8.0"
}, },
"devDependencies": { "devDependencies": {
@@ -39,7 +34,6 @@
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5", "vite": "^6.3.5"
"vite-plugin-mkcert": "^1.17.8"
} }
} }

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,14 +0,0 @@
import clsx from "clsx";
type Props = {
children: React.ReactNode;
className?: string;
};
const Box = ({ children, className }: Props) => (
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
{children}
</div>
);
export default Box;

View File

@@ -0,0 +1,14 @@
import clsx from "clsx";
type Props = {
children: React.ReactNode;
className?: string;
};
const Card = ({ children, className }: Props) => (
<div className={clsx("bg-card shadow-card rounded-panel", className)}>
{children}
</div>
);
export default Card;

View File

@@ -9,7 +9,7 @@ type DropdownItemProps = {
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => ( const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
<li <li
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)} className={clsx("cursor-pointer hover:bg-fields px-3 py-2 font-body text-[13px] text-text-control transition-colors duration-150", className)}
onClick={() => onClick(item)} onClick={() => onClick(item)}
> >
{item} {item}
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
<div className={clsx(className, "relative inline-block text-left")} ref={ref}> <div className={clsx(className, "relative inline-block text-left")} ref={ref}>
<button <button
onClick={() => setIsOpen(v => !v)} onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150" className="inline-flex items-center gap-1 rounded-btn px-3 py-2 font-body text-[13.5px] text-text-control hover:bg-fields transition-colors duration-150"
> >
{label} {label}
</button> </button>
{isOpen && ( {isOpen && (
<ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden"> <ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-chip bg-card inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
{children} {children}
</ul> </ul>
)} )}

View File

@@ -0,0 +1,38 @@
import { type CredentialResponse, GoogleLogin } from "@react-oauth/google";
import { useAuth } from "../utils/AuthContext.tsx";
import { getUser, login } from "../utils/api/users.ts";
type Props = {
/** Google's own sizing knobs — the sidebar wants a small pill, the landing page a wide one. */
shape?: "rectangular" | "pill";
size?: "small" | "medium" | "large";
width?: string;
};
/** Google's button plus the exchange for our own session. Used by the sidebar and the landing page. */
const GoogleSignIn = ({ shape = "pill", size = "medium", width }: Props) => {
const { setUser } = useAuth();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => getUser().then(setUser))
.catch((error) => console.error("Login failed:", error));
};
return (
<GoogleLogin
shape={shape}
size={size}
width={width}
useOneTap={false}
onSuccess={handleLogin}
/>
);
};
export default GoogleSignIn;

View File

@@ -0,0 +1,13 @@
const IsLivePulse = () => {
return (
<span className={"relative flex h-2 w-2"}>
<span
className="animate-live-pulse absolute inline-flex h-full w-full rounded-full bg-live-red"
></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-live-red"
></span>
</span>
);
}
export default IsLivePulse;

View File

@@ -0,0 +1,57 @@
import clsx from "clsx";
import type { JobResponse } from "../utils/types.ts";
type TrackedJob = {
uuid: string;
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const isSettled = (job: TrackedJob) => job.state === 'SUCCEEDED' || job.state === 'FAILED';
// queued → reading source → encoding → writing file → complete
const stageOf = (job: TrackedJob): string => {
if (job.state === 'FAILED') return "failed";
if (job.state === 'SUCCEEDED') return "complete";
if (job.state === 'READY') return "queued";
if (job.progress < 0.05) return "reading source";
if (job.progress < 0.95) return "encoding";
return "writing file";
};
const JobProgress = ({ job, className }: { job: TrackedJob; className?: string }) => {
const failed = job.state === 'FAILED';
const done = job.state === 'SUCCEEDED';
const pct = done ? 100 : Math.round(Math.min(1, Math.max(0, job.progress)) * 100);
return (
<div className={clsx("flex flex-col gap-1.5", className)}>
<p className="font-heading text-[15px] leading-snug">{job.label}</p>
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<div
className={clsx(
"h-full rounded-full transition-[width] duration-500 ease-out",
failed ? "bg-live-red" : done ? "bg-olive-light" : "bg-accent-light"
)}
style={{ width: `${failed ? 100 : pct}%` }}
/>
</div>
<div className="flex items-baseline justify-between gap-3">
<p className={clsx("font-data text-[11px]", failed ? "text-live-red" : "text-muted")}>
{stageOf(job)}
</p>
<p className="font-data text-[11px] text-faint-mono">
{failed ? (job.errorOutput ?? "unknown error") : done ? "done" : `${pct}%`}
</p>
</div>
</div>
);
};
export default JobProgress;
export { isSettled };
export type { TrackedJob };

View File

@@ -0,0 +1,17 @@
import {Lock} from "lucide-react";
const NoAccessPage = () => {
return (
<div className="w-full min-h-screen flex flex-col items-center justify-center gap-3 text-center px-6">
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
<h2 className="font-heading text-2xl md:text-3xl text-faint-mono">
Log in to see this page
</h2>
<p className="text-sm text-faint-mono/70 max-w-xs">
Sign in with Google to view your clips and account details.
</p>
</div>
);
}
export default NoAccessPage;

View File

@@ -0,0 +1,24 @@
import type React from "react";
import clsx from "clsx";
import { kickerClass } from "../utils/styles.ts";
type Props = {
/** Uppercase mono eyebrow, e.g. "INGEST" or "LIBRARY". */
kicker: string;
title: string;
/** Optional trailing action, right-aligned against the title. */
children?: React.ReactNode;
className?: string;
};
const PageHeader = ({ kicker, title, children, className }: Props) => (
<header className={clsx("flex flex-row items-start justify-between gap-6 pb-8", className)}>
<div className="flex flex-col gap-1">
<p className={kickerClass}>{kicker}</p>
<h1 className="font-heading text-4xl tracking-[-0.015em] leading-tight">{title}</h1>
</div>
{children}
</header>
);
export default PageHeader;

View File

@@ -5,11 +5,11 @@ type Props = {
label: string; label: string;
}; };
// Label on the left, control on the right — the shape used for export settings.
const Selector = ({ children, label }: Props) => ( const Selector = ({ children, label }: Props) => (
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-4">
<label className="w-full text-sm text-gray-600">{label}</label> <label className="font-body text-[13px] text-text-body">{label}</label>
<div className="w-px h-5 bg-gray-200 mx-2" /> <div className="w-32 flex-shrink-0">{children}</div>
{children}
</div> </div>
); );

View File

@@ -1,50 +1,90 @@
import clsx from "clsx"; import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx"; import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react'; import { Plus, Film, Home, Video, Radio } from 'lucide-react';
import type {User} from "../utils/types.ts"; import {useAuth} from "../utils/AuthContext.tsx";
import GoogleSignIn from "./GoogleSignIn.tsx";
import {logout} from "../utils/api/users.ts";
type props = { type props = {
user: User | null;
className?: string className?: string
} }
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />; const Divider = () => <hr className="w-full my-1.5 mx-2 border-hover" />;
const Sidebar = ({ className }: props) => {
const { user, setUser } = useAuth()
const handleLogout = () => {
logout()
.then(() =>
setUser(null)
)
.catch((error) => {
console.error("Logout failed:", error);
});
}
const Sidebar = ({user, className}: props) => {
return ( return (
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}> <aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
<div className="px-4 pt-5 pb-3"> <h2 className={"font-heading text-3xl"}>The VoD System</h2>
<img
className="w-36 mx-auto" <nav className="flex flex-col flex-1 items-center">
src="../../public/logo.png" <Divider />
alt="VoD System Logo" <div className="flex flex-col gap-0.5 w-full">
/>
</div>
<nav className="flex flex-col flex-1 px-2 pb-4">
<div className="flex flex-col gap-0.5">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" /> <SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" /> <SidebarButton url="/create" logo={<Plus size={18}/>} label="Upload" />
</div> </div>
<Divider />
{user && ( {user && (
<> <div className="flex flex-col gap-0.5 w-full">
<Divider /> <div>
<div className="flex flex-col gap-0.5">
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" /> <SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" /> <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" /> <SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
</div> </div>
</> </div>
)} )}
<div className="flex-1" /> <div className="flex-1" />
{user && ( <div className={"p-2"}>
<> {user ? (
<Divider /> <div>
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" /> <Divider />
</> <div className={"flex gap-2 items-center pt-1"}>
)} <img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
alt={"Profile Picture"}
/>
<div className={"flex flex-col"}>
<p className={"font-body text-text-strong text-sm"}>
{user.name}
</p>
<p className={"text-[0.625rem] font-body text-muted wrap-break-word"}>
Signed in with Google
</p>
</div>
<button
className={"font-body text-muted text-xs hover:bg-hover p-1 pt-1.5 rounded"}
onClick={handleLogout}
>
Sign Out
</button>
</div>
</div>
) : (
<div>
<Divider />
<GoogleSignIn />
</div>
)}
</div>
</nav> </nav>
</aside> </aside>
); );

View File

@@ -1,103 +0,0 @@
import { Menu, X } from 'lucide-react';
import { login, logout } from "../utils/api/users.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
import type { User } from "../utils/types.ts";
import type { CredentialResponse } from '@react-oauth/google';
import MenuButton from "./buttons/MenuButton.tsx";
import clsx from "clsx";
import {useNavigate} from "react-router-dom";
type props = {
sidebarToggled: boolean;
setSidebarToggled: (toggled: boolean) => void;
user: User | null;
fetchUser: () => void;
isStreaming: boolean;
className?: string;
}
const Topbar = ({
sidebarToggled,
setSidebarToggled,
user,
fetchUser,
isStreaming,
className}: props) => {
const navigate = useNavigate();
const handleLogin = (response: CredentialResponse) => {
if (!response.credential) {
console.error("No credential received from Google login.");
return;
}
login(response.credential)
.then(() => {
fetchUser();
navigate(0);
})
.catch((error) => {
console.error("Login failed:", error);
});
}
const handleLogout = () => {
logout()
.then(() => {
fetchUser();
navigate("/");
})
.catch((error) => {
console.error("Logout failed:", error);
});
}
return (
<div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton>
{ user ? (
<div>
<img
className={"w-8 h-8 rounded-full inline-block"}
src={user.profilePictureUrl}
referrerPolicy="no-referrer"
/>
<Dropdown label={
<span className="flex items-center gap-1.5">
{user.name}
{isStreaming && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
)}
</span>
}>
<DropdownItem item="Profile"
onClick={() => navigate("/profile")} />
<DropdownItem item="Logout"
onClick={() => handleLogout()}
className={"text-red-500 font-medium"} />
</Dropdown>
</div>
) :
(
<GoogleLogin
shape={"pill"}
useOneTap={false}
onSuccess={(credentialResponse) => handleLogin(credentialResponse)} />
)}
</div>
)
}
export default Topbar;

View File

@@ -1,14 +0,0 @@
import React from "react";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
{...props}
/>
);
};
export default BlueButton;

View File

@@ -0,0 +1,32 @@
import React from "react";
import clsx from "clsx";
type Variant = "primary" | "olive" | "dark" | "secondary" | "ghost";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
};
// Hover shifts one step darker within the same family — no transforms.
const variants: Record<Variant, string> = {
primary: "bg-accent text-on-accent shadow-terracotta hover:bg-accent-hover disabled:bg-accent-disabled disabled:shadow-none",
olive: "bg-olive text-on-accent shadow-olive hover:bg-olive-hover disabled:bg-inactive-strong disabled:shadow-none",
dark: "bg-player text-on-dark hover:bg-player-stripe disabled:bg-inactive-strong",
secondary: "bg-panel text-text-control hover:bg-chip-hover",
ghost: "text-muted hover:text-text-strong",
};
const Button = ({ variant = "primary", className, ...props }: Props) => (
<button
className={clsx(
"font-body text-[13.5px] rounded-btn px-4 py-2.5 transition-colors duration-150",
"disabled:cursor-not-allowed",
variants[variant],
className
)}
{...props}
/>
);
export default Button;
export type { Variant };

View File

@@ -1,15 +0,0 @@
import React from "react";
import clsx from "clsx";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return (
<button
className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
{...props}
/>
);
};
export default MenuButton;

View File

@@ -1,5 +1,5 @@
import MenuButton from "./MenuButton.tsx";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import React from "react";
type Props = { type Props = {
url: string; url: string;
@@ -13,9 +13,15 @@ const SidebarButton = ({url, logo, label}: Props) => {
return ( return (
<Link className="w-full" to={url}> <Link className="w-full" to={url}>
<MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}> <button className={`flex items-center gap-2.5 w-full text-sm
font-body p-2 hover:bg-hover hover:text-text-strong
rounded-md
${isActive ?
"bg-card text-text-strong font-medium shadow-sm"
:
"text-text-primary"}`}>
{logo}{label} {logo}{label}
</MenuButton> </button>
</Link> </Link>
) )
} }

View File

@@ -1,42 +0,0 @@
import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css';
import { useRef, type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type Props = {
videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
setSliderValue: Dispatch<SetStateAction<number>>;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string;
};
export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
const previousRange = useRef<[number, number]>([0, 0]);
const handleRangeInput = (val: [number, number]) => {
if (!videoRef) return;
if (previousRange.current[0] !== val[0]) {
videoRef.currentTime = val[0];
setSliderValue(val[0]);
} else if (previousRange.current[1] !== val[1]) {
videoRef.currentTime = val[1];
setSliderValue(val[1]);
}
setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
previousRange.current = val;
};
return (
<RangeSlider
min={0}
max={videoMetadata.duration}
step={0.1}
onInput={handleRangeInput}
className={className}
id="range-slider"
/>
);
}

View File

@@ -2,15 +2,49 @@ import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx"; import Selector from "../Selector.tsx";
import clsx from "clsx"; import clsx from "clsx";
import { inputClass, monoInputClass, kickerClass } from "../../utils/styles.ts";
type Props = { type Props = {
metadata: VideoMetadata;
setMetadata: Dispatch<SetStateAction<VideoMetadata>>; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
}; };
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"; // Mirrors CompressionService.buildBitrate so the number shown is the number used:
// 90% of the raw budget, audio capped at 128 kbit/s, video takes the rest.
const BUDGET_MULTIPLIER = 0.9;
const AUDIO_RATIO = 0.15;
const MAX_AUDIO_BITRATE = 128;
export default function ConfigBox({ setMetadata, className }: Props) { type Estimate = { total: number; audio: number; video: number; bpp: number | null };
const estimateBitrate = (metadata: VideoMetadata): Estimate | null => {
const { fileSize, duration, width, height, fps } = metadata;
if (!fileSize || !duration || duration <= 0) return null;
const total = ((fileSize * 8) / duration) * BUDGET_MULTIPLIER;
const audio = Math.min(total * AUDIO_RATIO, MAX_AUDIO_BITRATE);
const video = total - audio;
// Bits per pixel per frame — the usual way to judge whether a bitrate suits a resolution.
const bpp = width && height && fps ? (video * 1000) / (width * height * fps) : null;
return { total, audio, video, bpp };
};
const formatBitrate = (kbps: number): string =>
kbps >= 1000 ? `${(kbps / 1000).toFixed(1)} Mbit/s` : `${Math.round(kbps)} kbit/s`;
const verdictOf = (estimate: Estimate): { word: string; tone: string } => {
if (estimate.video <= 0) return { word: "not enough room", tone: "text-live-red" };
if (estimate.bpp === null) return { word: "—", tone: "text-muted" };
if (estimate.bpp >= 0.15) return { word: "crisp", tone: "text-olive" };
if (estimate.bpp >= 0.09) return { word: "clean", tone: "text-olive" };
if (estimate.bpp >= 0.05) return { word: "soft", tone: "text-muted" };
return { word: "very soft", tone: "text-live-red" };
};
export default function ConfigBox({ metadata, setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const [width, height] = e.target.value.split(",").map(Number); const [width, height] = e.target.value.split(",").map(Number);
setMetadata(prev => ({ ...prev, width, height })); setMetadata(prev => ({ ...prev, width, height }));
@@ -21,15 +55,19 @@ export default function ConfigBox({ setMetadata, className }: Props) {
}; };
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => { const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 })); setMetadata(prev => ({ ...prev, fileSize: Math.max(1, parseInt(e.target.value) || 1) * 1000 }));
}; };
const sizeMb = Math.round(metadata.fileSize / 1000);
const estimate = estimateBitrate(metadata);
const verdict = estimate && verdictOf(estimate);
return ( return (
<div className={clsx("flex flex-col gap-3 p-6", className)}> <div className={clsx("flex flex-col gap-4", className)}>
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2> <p className={kickerClass}>Export settings</p>
<Selector label="Resolution"> <Selector label="Resolution">
<select id="resolution" name="resolution" defaultValue="1280,720" <select id="resolution" name="resolution" value={`${metadata.width},${metadata.height}`}
onChange={updateRes} className={inputClass}> onChange={updateRes} className={inputClass}>
<option value="3840,2160">2160p</option> <option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option> <option value="2560,1440">1440p</option>
@@ -40,8 +78,8 @@ export default function ConfigBox({ setMetadata, className }: Props) {
</select> </select>
</Selector> </Selector>
<Selector label="FPS"> <Selector label="Frames per second">
<select id="fps" name="fps" defaultValue="30" <select id="fps" name="fps" value={String(metadata.fps)}
onChange={updateFps} className={inputClass}> onChange={updateFps} className={inputClass}>
<option value="60">60</option> <option value="60">60</option>
<option value="30">30</option> <option value="30">30</option>
@@ -49,11 +87,35 @@ export default function ConfigBox({ setMetadata, className }: Props) {
</select> </select>
</Selector> </Selector>
<Selector label="File Size Limit (MB)"> <Selector label="Size limit (MB)">
<input type="number" min="1" defaultValue="10" <input type="number" min="1" value={sizeMb}
onChange={updateFileSize} className={inputClass} onChange={updateFileSize} className={monoInputClass}
/> />
</Selector> </Selector>
{/* What that budget actually buys, at this length and resolution. */}
<div className="flex flex-col gap-2 bg-fields rounded-field p-3.5">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Estimated bitrate</p>
{verdict && <p className={clsx("font-body text-[12px]", verdict.tone)}>{verdict.word}</p>}
</div>
{estimate ? (
<>
<p className="font-data text-[15px] text-text-primary tabular-nums">
{formatBitrate(Math.max(0, estimate.video))}
</p>
<p className="font-body text-[12px] text-muted">
video, plus {formatBitrate(estimate.audio)} audio — {sizeMb} MB
across {metadata.duration.toFixed(1)}s
</p>
</>
) : (
<p className="font-body text-[12px] text-muted">
Pick a trim range to see what the size limit buys you.
</p>
)}
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import BlueButton from "../buttons/BlueButton.tsx";
import React from "react"; import React from "react";
import Button from "../buttons/Button.tsx";
import { kickerClass } from "../../utils/styles.ts";
type props = { type props = {
dataSend: React.MouseEventHandler<HTMLButtonElement>; dataSend: React.MouseEventHandler<HTMLButtonElement>;
@@ -9,43 +10,44 @@ type props = {
uploading: boolean; uploading: boolean;
} }
const Spinner = () => ( // queued → reading source → encoding → writing file → complete
<svg const stageOf = (progress: number, uploading: boolean, downloadable: boolean): string => {
className="animate-spin h-4 w-4" if (downloadable) return "complete";
xmlns="http://www.w3.org/2000/svg" if (!uploading) return "waiting";
fill="none" if (progress < 0.05) return "reading source";
viewBox="0 0 24 24" if (progress < 0.95) return "encoding";
> return "writing file";
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> };
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg> const ExportWidget = ({ dataSend, handleDownload, downloadable, progress, uploading }: props) => {
); const pct = downloadable ? 100 : Math.round(Math.min(1, Math.max(0, progress)) * 100);
const ExportWidget = ({dataSend, handleDownload, downloadable, progress, uploading}: props) => {
return ( return (
<div className={"flex flex-col gap-3"}> <div className="flex flex-col gap-4">
<BlueButton <p className={kickerClass}>Export</p>
onClick={dataSend}
disabled={uploading}
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
>
{uploading && <Spinner />}
{uploading ? "Uploading..." : "Export"}
</BlueButton>
<div className="h-10 flex items-center"> <Button onClick={dataSend} disabled={uploading} className="w-full py-3">
{ downloadable ? {uploading ? "Compressing…" : "Compress and save"}
(<BlueButton className="w-full" onClick={handleDownload}> </Button>
Download
</BlueButton>) <div className="flex flex-col gap-1.5">
:( <div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
<progress <div
value={progress} className={`h-full rounded-full transition-[width] duration-500 ease-out ${downloadable ? "bg-olive-light" : "bg-accent-light"}`}
className="w-full h-1.5 rounded bg-gray-200"> style={{ width: `${pct}%` }}
</progress> />
) </div>
} <div className="flex items-baseline justify-between gap-3">
<p className="font-data text-[11px] text-muted">{stageOf(progress, uploading, downloadable)}</p>
<p className="font-data text-[11px] text-faint-mono">{downloadable ? "done" : `${pct}%`}</p>
</div>
</div> </div>
{downloadable && (
<Button variant="olive" className="w-full py-3" onClick={handleDownload}>
Download the file
</Button>
)}
</div> </div>
) )
} }

View File

@@ -1,6 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { type Dispatch, type SetStateAction } from "react"; import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import { inputClass, labelClass } from "../../utils/styles.ts";
type Props = { type Props = {
setMetadata: Dispatch<SetStateAction<VideoMetadata>>; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
@@ -8,13 +9,14 @@ type Props = {
}; };
const MetadataBox = ({ setMetadata, className }: Props) => ( const MetadataBox = ({ setMetadata, className }: Props) => (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}> <div className={clsx("flex flex-col gap-2", className)}>
<p className="w-full font-semibold text-gray-800 text-sm">Title</p> <label className={labelClass} htmlFor="clip-title">Title</label>
<input <input
id="clip-title"
type="text" type="text"
placeholder="Enter title" placeholder="Cat walks across the patch bay"
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))} onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors" className={inputClass}
/> />
</div> </div>
); );

View File

@@ -1,37 +0,0 @@
import { useEffect, type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type Props = {
videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata;
sliderValue: number;
setSliderValue: Dispatch<SetStateAction<number>>;
className?: string;
};
export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef) return;
videoRef.currentTime = e.target.valueAsNumber;
setSliderValue(e.target.valueAsNumber);
};
useEffect(() => {
if (!videoRef) return;
const updateSlider = () => setSliderValue(videoRef.currentTime);
videoRef.addEventListener("timeupdate", updateSlider);
return () => videoRef.removeEventListener("timeupdate", updateSlider);
}, [videoRef]);
return (
<input
type="range"
min={0}
max={videoMetadata.duration}
value={sliderValue}
onChange={updateVideo}
step={0.1}
className={className}
/>
);
}

View File

@@ -48,25 +48,25 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
}, [video]); }, [video]);
return ( return (
<div className={clsx("flex justify-between items-center mb-2", className)}> <div className={clsx("flex justify-between items-center mb-3", className)}>
<div className="flex gap-1"> <div className="flex items-center gap-2 text-text-control">
<Icon size={24} /> <Icon size={20} />
<input <input
type="range" type="range"
min={0} min={0}
max={100} max={100}
value={volume} value={volume}
onChange={updateVolume} onChange={updateVolume}
className="w-20" className="w-20 accent-accent"
/> />
</div> </div>
<button <button
onClick={togglePlay} onClick={togglePlay}
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700" className="rounded-full p-2 text-text-primary hover:bg-fields transition-colors duration-150"
> >
{isPlaying ? <Pause size={24} /> : <Play size={24} />} {isPlaying ? <Pause size={22} /> : <Play size={22} />}
</button> </button>
<span> <span className="font-data text-[13px] text-text-secondary tabular-nums">
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)} {formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</span> </span>
</div> </div>

View File

@@ -0,0 +1,283 @@
import { useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { formatTime } from "../../utils/utils.ts";
type Range = [number, number];
type Props = {
/** Full length of the source video, in seconds. */
duration: number;
/** The trim window, in seconds. */
range: Range;
onRangeChange: (range: Range) => void;
/** Playhead position, in seconds. */
currentTime: number;
onSeek: (seconds: number) => void;
className?: string;
};
/** Never zoom in past this, or the handles have nothing left to grab. */
const MIN_VIEW_SECONDS = 0.5;
/** The shortest trim the handles will allow between them. */
const MIN_GAP = 0.1;
const ZOOM_STEP = 1.6;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
/** A readable tick interval that yields roughly 6 labels across the view. */
const tickInterval = (span: number): number => {
const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
return steps.find((step) => span / step <= 6) ?? 3600;
};
const TrimTimeline = ({ duration, range, onRangeChange, currentTime, onSeek, className }: Props) => {
const [view, setView] = useState<Range>([0, duration]);
// Which thing the pointer is currently dragging, if any.
const [dragging, setDragging] = useState<"in" | "out" | "playhead" | "window" | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const overviewRef = useRef<HTMLDivElement>(null);
const dragOffset = useRef(0);
// A new source resets the view to the whole thing.
useEffect(() => setView([0, duration]), [duration]);
const [viewStart, viewEnd] = view;
const viewSpan = Math.max(viewEnd - viewStart, MIN_VIEW_SECONDS);
const zoomFactor = duration / viewSpan;
/** Seconds → percentage across the zoomed track. */
const toPct = (seconds: number) => ((seconds - viewStart) / viewSpan) * 100;
/** Pointer x → seconds, within the zoomed track. */
const toSeconds = (clientX: number, element: HTMLElement | null, span: Range): number => {
const rect = element?.getBoundingClientRect();
if (!rect || rect.width === 0) return span[0];
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
return span[0] + ratio * (span[1] - span[0]);
};
/** Re-centre the view on `focus`, keeping it inside the source. */
const applyZoom = useCallback((nextSpan: number, focus: number) => {
const span = clamp(nextSpan, MIN_VIEW_SECONDS, duration);
const ratio = duration === 0 ? 0 : clamp((focus - view[0]) / (view[1] - view[0] || 1), 0, 1);
let start = focus - span * ratio;
start = clamp(start, 0, Math.max(0, duration - span));
setView([start, start + span]);
}, [duration, view]);
const zoomBy = (factor: number) => {
const focus = clamp(currentTime, viewStart, viewEnd);
applyZoom(viewSpan / factor, focus);
};
/** Zoom the view down to the trim range, with a little air on each side. */
const zoomToRange = () => {
const span = Math.max(range[1] - range[0], MIN_VIEW_SECONDS);
const padded = Math.min(duration, span * 1.25);
const centre = (range[0] + range[1]) / 2;
const start = clamp(centre - padded / 2, 0, Math.max(0, duration - padded));
setView([start, start + padded]);
};
// Wheel over the track zooms around the pointer rather than scrolling the page.
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const onWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
event.preventDefault();
const focus = toSeconds(event.clientX, track, view);
applyZoom(event.deltaY > 0 ? viewSpan * ZOOM_STEP : viewSpan / ZOOM_STEP, focus);
};
track.addEventListener("wheel", onWheel, { passive: false });
return () => track.removeEventListener("wheel", onWheel);
}, [applyZoom, view, viewSpan]);
// The move handler reads through refs, so it always sees the current view and
// range without having to be torn down and rebuilt on every frame of a drag.
const latest = useRef({ view, range, onRangeChange, onSeek, duration });
latest.current = { view, range, onRangeChange, onSeek, duration };
// Listeners are attached on mousedown rather than in an effect: a fast drag
// would otherwise lose its first frames waiting for a re-render.
const beginDrag = (kind: "in" | "out" | "playhead" | "window", event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setDragging(kind);
const onMove = (move: MouseEvent) => {
const state = latest.current;
const span = state.view[1] - state.view[0];
if (kind === "window") {
const at = toSeconds(move.clientX, overviewRef.current, [0, state.duration]);
const start = clamp(at - dragOffset.current, 0, Math.max(0, state.duration - span));
setView([start, start + span]);
return;
}
const at = toSeconds(move.clientX, trackRef.current, state.view);
if (kind === "playhead") {
state.onSeek(at);
} else if (kind === "in") {
state.onRangeChange([Math.min(at, state.range[1] - MIN_GAP), state.range[1]]);
state.onSeek(at);
} else {
state.onRangeChange([state.range[0], Math.max(at, state.range[0] + MIN_GAP)]);
state.onSeek(at);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setDragging(null);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
if (duration <= 0) return null;
const interval = tickInterval(viewSpan);
const firstTick = Math.ceil(viewStart / interval) * interval;
const ticks: number[] = [];
for (let at = firstTick; at <= viewEnd; at += interval) ticks.push(at);
const windowLeft = (viewStart / duration) * 100;
const windowWidth = (viewSpan / duration) * 100;
return (
<div className={clsx("flex flex-col gap-3 select-none", className)}>
{/* Overview — the whole source, with the zoom window drawn on top. */}
<div
ref={overviewRef}
onMouseDown={(event) => {
const at = toSeconds(event.clientX, overviewRef.current, [0, duration]);
// Grabbing inside the window drags it; clicking outside jumps it here.
dragOffset.current = at >= viewStart && at <= viewEnd ? at - viewStart : viewSpan / 2;
const start = clamp(at - dragOffset.current, 0, Math.max(0, duration - viewSpan));
setView([start, start + viewSpan]);
beginDrag("window", event);
}}
className="relative h-8 rounded-chip bg-fields cursor-grab active:cursor-grabbing overflow-hidden"
>
<div
className="absolute inset-y-0 bg-olive-light/30"
style={{ left: `${(range[0] / duration) * 100}%`, width: `${((range[1] - range[0]) / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 w-px bg-accent"
style={{ left: `${(currentTime / duration) * 100}%` }}
/>
<div
className="absolute inset-y-0 rounded-[4px] shadow-[inset_0_0_0_1.5px_#8E8577]"
style={{ left: `${windowLeft}%`, width: `${windowWidth}%` }}
/>
</div>
{/* Zoom controls */}
<div className="flex items-center gap-2">
<button
onClick={() => zoomBy(1 / ZOOM_STEP)}
disabled={viewSpan >= duration}
title="Zoom out"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
−
</button>
<button
onClick={() => zoomBy(ZOOM_STEP)}
disabled={viewSpan <= MIN_VIEW_SECONDS}
title="Zoom in"
className="bg-chip hover:bg-chip-hover disabled:opacity-40 disabled:cursor-not-allowed text-text-control rounded-chip w-7 h-7 font-data text-[13px] transition-colors duration-150"
>
+
</button>
<span className="font-data text-[11px] text-faint-mono tabular-nums">
{zoomFactor < 1.02 ? "whole clip" : `${zoomFactor.toFixed(1)}×`}
{" · "}
{formatTime(viewStart)} – {formatTime(viewEnd)}
</span>
<div className="flex-1" />
<button
onClick={zoomToRange}
className="font-body text-[12px] text-muted hover:text-text-strong whitespace-nowrap transition-colors duration-150"
>
Fit to trim
</button>
<button
onClick={() => setView([0, duration])}
disabled={viewSpan >= duration}
className="font-body text-[12px] text-muted hover:text-text-strong disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors duration-150"
>
Whole clip
</button>
</div>
{/* Zoomed track */}
<div
ref={trackRef}
onMouseDown={(event) => {
onSeek(toSeconds(event.clientX, trackRef.current, view));
beginDrag("playhead", event);
}}
className="relative h-14 rounded-chip bg-fields cursor-pointer"
>
{ticks.map((at) => (
<div key={at} className="absolute inset-y-0 flex flex-col justify-end" style={{ left: `${toPct(at)}%` }}>
<div className="absolute inset-y-0 w-px bg-inactive/70" />
<span className="relative pl-1 pb-1 font-data text-[10px] text-faint-mono whitespace-nowrap">
{formatTime(at)}
</span>
</div>
))}
{/* Trim band */}
<div
className="absolute inset-y-0 bg-olive-light/35"
style={{
left: `${clamp(toPct(range[0]), 0, 100)}%`,
width: `${clamp(toPct(range[1]), 0, 100) - clamp(toPct(range[0]), 0, 100)}%`,
}}
/>
{/* Playhead */}
<div
className="absolute inset-y-0 w-[2px] bg-accent pointer-events-none"
style={{ left: `${clamp(toPct(currentTime), 0, 100)}%` }}
/>
{/* In / out handles — only grabbable while inside the view */}
{([["in", range[0]], ["out", range[1]]] as const).map(([edge, at]) => {
const pct = toPct(at);
if (pct < -2 || pct > 102) return null;
return (
<div
key={edge}
onMouseDown={(event) => beginDrag(edge, event)}
title={`${edge === "in" ? "In" : "Out"} — ${formatTime(at)}`}
className={clsx(
"absolute top-0 bottom-0 w-3 -ml-1.5 flex items-center justify-center cursor-ew-resize",
dragging === edge && "z-10"
)}
style={{ left: `${pct}%` }}
>
<div className="h-full w-[3px] bg-olive rounded-full" />
<div className="absolute h-4 w-4 rounded-full bg-olive shadow-olive" />
</div>
);
})}
</div>
</div>
);
};
export default TrimTimeline;
export type { Range };

View File

@@ -1,7 +1,7 @@
import clsx from "clsx"; import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { formatTime, formatFileSize, formatLocalDate, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/api/client.ts"; import { isThumbnailAvailable } from "../../utils/api/client.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react"; import { MoreVertical, Pencil, Trash2 } from "lucide-react";
@@ -10,6 +10,11 @@ type VideoCardProps = {
title: string, title: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
onEdit?: () => void, onEdit?: () => void,
onDelete?: () => void, onDelete?: () => void,
className?: string, className?: string,
@@ -18,23 +23,36 @@ type VideoCardProps = {
itemLabel?: string, itemLabel?: string,
} }
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => { // Stand-in for a missing thumbnail: warm 135° stripes rather than a broken image.
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const placeholders: Record<string, React.CSSProperties> = {
vod: { backgroundImage: "repeating-linear-gradient(135deg, #E3DACB 0 9px, #DAD0BF 9px 18px)" },
clip: { backgroundImage: "repeating-linear-gradient(135deg, #DFE0D2 0 9px, #D6D8C7 9px 18px)" },
};
const VideoCard = ({ id, title, duration, createdAt, width, height, fps, fileSize, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => {
const [thumbnailAvailable, setThumbnailAvailable] = useState(true); const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`; const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
useEffect(() => { // VoDs read as 16/9 tiles with a serif title; clips are smaller 4/3 tiles.
const interval = setInterval(() => { const isVod = itemLabel === 'vod';
setTimeAgo(dateToTimeAgo(stringToDate(createdAt))); const slug = `${itemLabel}_${String(id).padStart(4, '0')}`;
}, 1000);
return () => clearInterval(interval); // "27 Jul · 3.4 GB" — the two facts you actually scan a library by.
}, [createdAt]); const meta = [
formatLocalDate(createdAt, { day: 'numeric', month: 'short' }),
fileSize ? formatFileSize(fileSize) : null,
].filter(Boolean).join(" · ");
// The technical detail, in mono, for when it matters.
const facts = [
width && height ? (isVod ? `${width}×${height}` : `${height}p`) : null,
isVod && fps ? `${Math.round(fps)} fps` : null,
].filter(Boolean);
useEffect(() => { useEffect(() => {
isThumbnailAvailable(thumbnailUrl) isThumbnailAvailable(thumbnailUrl)
@@ -62,15 +80,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
return ( return (
<Link to={`${playerPath}/${id}`}> <Link to={`${playerPath}/${id}`}>
<div className={clsx("flex flex-col group cursor-pointer", className)}> <div className={clsx(
<div className="relative overflow-hidden rounded-lg"> "group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200",
<img className
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail} )}>
alt="Video Thumbnail" <div
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200" className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")}
/> style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']}
>
{thumbnailAvailable && (
<img
src={thumbnailUrl}
alt=""
className="w-full h-full object-cover"
onError={() => setThumbnailAvailable(false)}
/>
)}
<span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none"> <span className="absolute top-2.5 left-2.5 bg-card/85 text-text-secondary px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{slug}
</span>
<span className="absolute bottom-2.5 right-2.5 bg-player/85 text-on-dark px-2 py-1 rounded-md font-data text-[11px] pointer-events-none">
{formatTime(duration)} {formatTime(duration)}
</span> </span>
@@ -82,18 +113,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
> >
<button <button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })} onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors" className="bg-player/40 hover:bg-player/85 text-on-dark rounded-md p-1 transition-colors duration-150"
> >
<MoreVertical size={14} /> <MoreVertical size={14} />
</button> </button>
{menuOpen && ( {menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden"> <div className="absolute right-0 top-8 w-36 bg-card rounded-chip inset-shadow-hairline shadow-card z-50 py-1 overflow-hidden">
{!confirmDelete && (<> {!confirmDelete && (<>
{onEdit && ( {onEdit && (
<button <button
onClick={(e) => stopAndRun(e, onEdit)} onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-text-control hover:bg-fields transition-colors duration-150"
> >
<Pencil size={13} /> Edit <Pencil size={13} /> Edit
</button> </button>
@@ -101,7 +132,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
{onDelete && ( {onDelete && (
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))} onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50" className="flex items-center gap-2 w-full px-3 py-2 font-body text-[13px] text-live-red hover:bg-armed transition-colors duration-150"
> >
<Trash2 size={13} /> Delete <Trash2 size={13} /> Delete
</button> </button>
@@ -109,17 +140,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
</>)} </>)}
{confirmDelete && ( {confirmDelete && (
<div className="px-3 py-2"> <div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this {itemLabel}?</p> <p className="font-body text-[12px] text-text-body mb-2">Delete this {itemLabel}?</p>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
onClick={(e) => stopAndRun(e, onDelete!)} onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors" className="flex-1 font-body text-[12px] bg-live-red text-on-accent rounded-md px-2 py-1 hover:bg-accent-hover transition-colors duration-150"
> >
Delete Delete
</button> </button>
<button <button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))} onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors" className="flex-1 font-body text-[12px] bg-chip text-text-control rounded-md px-2 py-1 hover:bg-chip-hover transition-colors duration-150"
> >
Cancel Cancel
</button> </button>
@@ -132,11 +163,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
)} )}
</div> </div>
<div className="flex flex-col p-2"> <div className={clsx("flex flex-col gap-1", isVod ? "px-4 pt-3.5 pb-4" : "px-3 pt-3 pb-3.5")}>
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2"> <p className={clsx(
{title === "" ? "(No Title)" : title} "line-clamp-2",
isVod
? "font-heading text-[17.5px] leading-[1.3]"
: "font-body text-[13.5px] text-text-strong leading-snug"
)}>
{title === "" ? "(No title)" : title}
</p> </p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
<p
className="font-body text-[12.5px] text-muted"
title={`${dateToTimeAgo(stringToDate(createdAt))} — ${formatLocalDate(createdAt)}`}
>
{meta}
</p>
{facts.length > 0 && (
<p className="font-data text-[10.5px] text-faint-mono truncate">
{facts.join(" · ")}
</p>
)}
</div> </div>
</div> </div>
</Link> </Link>

View File

@@ -1,37 +1,94 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "flowbite-react/plugin/tailwindcss"; @plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json";
@theme { @theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; /* ── Fonts ───────────────────────────────────────────────────────────── */
--font-body: "IBM Plex Sans", system-ui, sans-serif;
--font-data: "IBM Plex Mono", monospace;
/* Breakpoints */ /* ── Surfaces ────────────────────────────────────────────────────────── */
--breakpoint-3xl: 1920px; --color-background: #F7F4EE;
--color-sidebar: #F2EEE6;
--color-card: #FDFBF7;
--color-fields: #F4F0E8;
--color-panel: #F1ECE3;
--color-dropzone: #F9F6EF;
--color-armed: #F6EFE6;
--color-live-card: #F0EBE1;
--color-player: #2B2620;
--color-player-stripe: #3A342C;
--color-primary: oklch(0.55 0.21 254); /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-primary-pressed: oklch(0.45 0.21 254); --color-text-primary: #2B2620;
--color-secondary: oklch(0.94 0.01 250); --color-text-strong: #35302A;
--color-accent: oklch(0.68 0.20 288); --color-text-body: #554D44;
--color-text: oklch(0.17 0.01 270); --color-text-control: #4A423A;
--color-background: oklch(0.97 0.005 250); --color-text-secondary: #6E6559;
--color-surface: oklch(1 0 0); --color-text-table: #7A7166;
--color-border: oklch(0.91 0.008 250); --color-muted: #9A9084;
--color-faint-mono: #A79C8E;
--color-on-dark: #FBF8F2;
--color-on-accent: #FDFBF7;
/* Easing */ /* ── Accents ─────────────────────────────────────────────────────────── */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1); --color-accent: #A85A38;
--ease-snappy: cubic-bezier(0.2, 0, 0, 1); --color-accent-hover: #94492A;
} --color-accent-light: #C87A52;
--color-accent-disabled: #C9AE9C;
--color-olive: #6E7A50;
--color-olive-hover: #5D6843;
--color-olive-light: #8A9668;
--color-live-red: #B5432E;
#range-slider .range-slider__range { /* ── Lines, chips, placeholders ──────────────────────────────────────── */
background: var(--color-primary); --color-hairline: #F0EBE2;
} --color-hover: #E7E0D2;
--color-chip: #EAE3D7;
--color-chip-hover: #DAD1C0;
--color-ring-dashed: #E2DACB;
--color-inactive: #DED7CA;
--color-inactive-strong: #C4BAA9;
#range-slider .range-slider__thumb { /* ── Elevation — soft shadows instead of borders ─────────────────────── */
background: var(--color-primary); --shadow-card: 0 1px 2px rgba(60, 48, 32, .05), 0 10px 26px -18px rgba(60, 48, 32, .35);
--shadow-card-hover: 0 2px 4px rgba(60, 48, 32, .07), 0 18px 34px -18px rgba(60, 48, 32, .45);
--shadow-panel: 0 1px 2px rgba(60, 48, 32, .05), 0 16px 36px -22px rgba(60, 48, 32, .3);
--shadow-player: 0 1px 2px rgba(60, 48, 32, .08), 0 22px 46px -26px rgba(60, 48, 32, .5);
--shadow-terracotta: 0 1px 2px rgba(90, 40, 20, .2), 0 12px 22px -14px rgba(90, 40, 20, .55);
--shadow-olive: 0 1px 2px rgba(50, 60, 30, .18), 0 10px 20px -14px rgba(50, 60, 30, .55);
--inset-shadow-hairline: inset 0 0 0 1px rgba(60, 48, 32, .08);
--inset-shadow-armed: inset 0 0 0 1.5px #A85A38;
--inset-shadow-focus: inset 0 0 0 1.5px #C87A52;
--inset-shadow-dashed: inset 0 0 0 1.5px #E2DACB;
/* ── Shape ───────────────────────────────────────────────────────────── */
--radius-hero: 16px;
--radius-panel: 15px;
--radius-tile: 13px;
--radius-thumb: 12px;
--radius-field: 10px;
--radius-btn: 9px;
--radius-chip: 8px;
/* ── Motion — the live dot, plus the player's one-shot action badge ──── */
--animate-live-pulse: livePulse 2.2s ease-in-out infinite;
--animate-flash-badge: flashBadge 500ms ease-out forwards;
@keyframes livePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .45; transform: scale(1.35); }
}
@keyframes flashBadge {
0% { opacity: 0; transform: scale(.75); }
25% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.4); }
}
} }
:root { :root {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-synthesis: none; font-synthesis: none;
@@ -42,4 +99,12 @@
body { body {
background-color: var(--color-background); background-color: var(--color-background);
font-family: "IBM Plex Sans", system-ui, sans-serif;
color: var(--color-text-body);
}
@utility font-heading {
font-family: "Newsreader", serif;
font-weight: 400;
color: #2B2620;
} }

View File

@@ -1,51 +1,16 @@
// layout/MainLayout.jsx // layout/MainLayout.jsx
import Sidebar from '../components/Sidebar' import Sidebar from '../components/Sidebar'
import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react"; import { useState } from "react";
import type {User} from "../utils/types";
import { getUser } from "../utils/api/users";
import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => { const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false); const [sidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null);
const [isStreaming, setIsStreaming] = useState(false);
const fetchUser = async () => {
try {
const userData = await getUser();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
}
};
useEffect(() => {
fetchUser();
}, []);
useEffect(() => {
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
return ( return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}> <div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[252px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar <Sidebar
user={user}
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/> className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
<Topbar <div className="row-span-2 overflow-y-auto">
className={"transition-all duration-300"}
sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled}
user={user}
fetchUser={fetchUser}
isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet /> <Outlet />
</div> </div>
</div> </div>

View File

@@ -1,10 +1,13 @@
import './index.css'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx' import App from './App.tsx'
import {AuthProvider} from "./utils/AuthContext.tsx";
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <AuthProvider>
<App />
</AuthProvider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,15 +1,17 @@
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar"; import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider"; import TrimTimeline, { type Range } from "../components/video/TrimTimeline.tsx";
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx"; import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx";
import { compress } from "../utils/api/media" import { compress } from "../utils/api/media"
import { getJob } from "../utils/api/jobs" import { getJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts"; import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx";
import PageHeader from "../components/PageHeader.tsx";
import { formatTime, formatTimecode } from "../utils/utils.ts";
import { kickerClass } from "../utils/styles.ts";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
@@ -23,6 +25,7 @@ const ClipEdit = () => {
const [metadata, setMetadata] = useState<VideoMetadata | null>(null); const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0); const [playbackValue, setPlaybackValue] = useState(0);
const [range, setRange] = useState<Range>([0, 5]);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({ const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
title: "", title: "",
description: "", description: "",
@@ -58,8 +61,30 @@ const ClipEdit = () => {
fps: 30, fps: 30,
fileSize: localFile?.size ?? 0, fileSize: localFile?.size ?? 0,
}); });
// Start with the whole thing trimmed in — narrowing is the common move.
setRange([0, video.duration]);
setOutputMetadata(prev => ({ ...prev, startPoint: 0, duration: video.duration }));
}; };
// Keep the playhead in step with the video while it plays.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTime = () => setPlaybackValue(video.currentTime);
video.addEventListener("timeupdate", onTime);
return () => video.removeEventListener("timeupdate", onTime);
}, [videoUrl]);
const handleRangeChange = useCallback((next: Range) => {
setRange(next);
setOutputMetadata(prev => ({ ...prev, startPoint: next[0], duration: next[1] - next[0] }));
}, []);
const handleSeek = useCallback((seconds: number) => {
setPlaybackValue(seconds);
if (videoRef.current) videoRef.current.currentTime = seconds;
}, []);
const sendData = async () => { const sendData = async () => {
if (!localFile || uploading) return; if (!localFile || uploading) return;
@@ -79,7 +104,7 @@ const ClipEdit = () => {
const interval = setInterval(async () => await pollProgress(jobId, interval), 500); const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
}; };
const pollProgress = async (jobId: string, intervalId: number) => { const pollProgress = async (jobId: string, intervalId: ReturnType<typeof setInterval>) => {
getJob(jobId) getJob(jobId)
.then((job) => { .then((job) => {
setProgress(job.progress); setProgress(job.progress);
@@ -124,60 +149,78 @@ const ClipEdit = () => {
}; };
return ( return (
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}> <div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<video <PageHeader kicker="Upload" title="Trim and compress" />
ref={videoRef}
src={videoUrl}
className={"w-full rounded-lg shadow-sm bg-black"}
onLoadedMetadata={handleVideoMetadataLoaded}
/>
<Box className={"w-4/5 h-full m-auto"}> <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
<MetadataBox setMetadata={setOutputMetadata} /> <div className="flex flex-col gap-[26px]">
<ConfigBox setMetadata={setOutputMetadata} /> {/* Player — the video and its transport read as one object. */}
</Box> <Card className="overflow-hidden">
<video
ref={videoRef}
src={videoUrl}
className="w-full aspect-video bg-player object-contain"
onLoadedMetadata={handleVideoMetadataLoaded}
/>
{metadata &&
<Playbar
video={videoRef.current}
videoMetadata={metadata}
className="w-full px-5 pt-3.5"
/>
}
</Card>
{metadata && {metadata &&
<Box className={"mt-4 p-5"}> <Card className="p-6">
<Playbar <div className="flex items-baseline justify-between gap-4 pb-4">
video={videoRef.current} <p className={kickerClass}>Trim</p>
videoMetadata={metadata} <p className="font-data text-[12px] text-faint-mono tabular-nums">
className={"w-full accent-primary"} in {formatTimecode(range[0])} · out {formatTimecode(range[1])} ·{" "}
/> {formatTime(range[1] - range[0])} long
</p>
</div>
<PlaybackSlider <TrimTimeline
videoRef={videoRef.current} duration={metadata.duration}
videoMetadata={metadata} range={range}
sliderValue={playbackValue} onRangeChange={handleRangeChange}
setSliderValue={setPlaybackValue} currentTime={playbackValue}
className={"w-full accent-primary"} onSeek={handleSeek}
/> />
<ClipRangeSlider <p className="pt-3 font-body text-[12px] text-muted">
videoRef={videoRef.current} Scroll over the timeline to zoom, drag the outlined window above to pan.
videoMetadata={metadata} </p>
setSliderValue={setPlaybackValue}
setMetadata={setOutputMetadata}
className={"w-full mt-2 bg-primary"}
/>
{error && ( {error && (
<div className={"text-red-600 text-center mt-2"}> <p className="font-body text-[13px] text-live-red pt-3">{error}</p>
{error} )}
</div> </Card>
)} }
</Box> </div>
}
<Box className={"flex flex-col gap-2 w-4/5 m-auto mt-4 p-5"}> <aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
<ExportWidget <Card className="p-6 flex flex-col gap-4">
dataSend={sendData} <p className={kickerClass}>Details</p>
handleDownload={handleDownload} <MetadataBox setMetadata={setOutputMetadata} />
downloadable={downloadable} </Card>
progress={progress}
uploading={uploading} <Card className="p-6">
/> <ConfigBox metadata={outputMetadata} setMetadata={setOutputMetadata} />
</Box> </Card>
<div className="bg-panel rounded-panel shadow-panel p-6">
<ExportWidget
dataSend={sendData}
handleDownload={handleDownload}
downloadable={downloadable}
progress={progress}
uploading={uploading}
/>
</div>
</aside>
</div>
</div> </div>
); );
}; };

View File

@@ -1,40 +1,89 @@
import {useState} from "react"; import { useRef, useState } from "react";
import {useNavigate} from "react-router-dom"; import { useNavigate } from "react-router-dom";
import BlueButton from "../components/buttons/BlueButton.tsx"; import clsx from "clsx";
import Box from "../components/Box.tsx"; import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
import { kickerClass } from "../utils/styles.ts";
const formatSize = (bytes: number): string => {
const mb = bytes / 1_000_000;
return mb >= 1000 ? `${(mb / 1000).toFixed(2)} GB` : `${mb.toFixed(1)} MB`;
};
const ClipUpload = () => { const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate(); const [dragging, setDragging] = useState(false);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const choose = (selected: File | null) => {
setFile(selected);
setError(null);
};
const press = () => { const press = () => {
if (!file) { if (!file) {
setError("Please choose a file"); setError("Pick a file first.");
return; return;
} }
navigate('/create/new', { state: { file } }); navigate('/create/new', { state: { file } });
}; };
return ( return (
<div className="flex items-start justify-center p-8 min-h-full"> <div className="mx-auto max-w-180 px-13 pt-10 pb-18">
<Box className="flex flex-col gap-4 p-6 w-full max-w-md"> <PageHeader kicker="Upload" title="Bring in a file" />
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
<label
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
choose(e.dataTransfer.files?.[0] ?? null);
}}
className={clsx(
"flex aspect-16/7 cursor-pointer flex-col items-center justify-center gap-2 rounded-hero px-8 text-center transition-colors duration-150",
dragging ? "bg-armed inset-shadow-armed" : "bg-dropzone inset-shadow-dashed hover:bg-fields"
)}
>
<input <input
ref={inputRef}
type="file" type="file"
onChange={(e) => { accept="video/*"
const selected = e.target.files?.[0] ?? null; className="hidden"
setFile(selected); onChange={(e) => choose(e.target.files?.[0] ?? null)}
setError(null);
}}
className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
/> />
<BlueButton onClick={press}>Upload</BlueButton> {file ? (
<>
<p className="font-heading text-[25px]">{file.name}</p>
<p className="font-data text-[12px] text-faint-mono">{formatSize(file.size)}</p>
<p className="font-body text-[13px] text-muted pt-1">Click to pick a different one.</p>
</>
) : (
<>
<p className={kickerClass}>Video file</p>
<p className="font-heading text-[25px]">Drop a file here</p>
<p className="font-body text-[14.5px] text-text-body max-w-[42ch] text-pretty">
Or click anywhere in this box to browse. You'll trim and compress it on the next screen.
</p>
</>
)}
</label>
{error && <p className="text-sm text-red-500 text-center">{error}</p>} <div className="flex items-center gap-4 pt-6">
</Box> <Button onClick={press} disabled={!file} className="px-6 py-3">
{file ? "Trim and compress" : "Waiting for a file"}
</Button>
{file && (
<Button variant="ghost" onClick={() => { choose(null); if (inputRef.current) inputRef.current.value = ""; }}>
Clear
</Button>
)}
</div>
{error && <p className="font-body text-[13px] text-live-red pt-3">{error}</p>}
</div> </div>
) )
}; };

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import clsx from "clsx";
import { getClipById, patchClip } from "../utils/api/clips.ts"; import { getClipById, patchClip } from "../utils/api/clips.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx"; import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"; import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
const EditClip = () => { const EditClip = () => {
const { id } = useParams(); const { id } = useParams();
@@ -47,59 +48,51 @@ const EditClip = () => {
}; };
return ( return (
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<button <button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
onClick={() => navigate(-1)} <ArrowLeft size={15} />
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back Back
</button> </button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1> <PageHeader kicker="Clip" title="Edit details" />
{loading ? ( {loading ? (
<div className="text-gray-400 text-sm">Loading...</div> <p className="font-data text-[13px] text-faint-mono">loading…</p>
) : ( ) : (
<Box className="p-6 flex flex-col gap-4"> <Card className="p-7 flex flex-col gap-5">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Title</label> <label className={labelClass} htmlFor="clip-title">Title</label>
<input <input
id="clip-title"
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title" placeholder="Cat walks across the patch bay"
className={inputClass} className={inputClass}
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Description</label> <label className={labelClass} htmlFor="clip-description">Description</label>
<textarea <textarea
id="clip-description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description" placeholder="What's worth remembering about it"
rows={4} rows={4}
className={inputClass + " resize-none"} className={clsx(inputClass, "resize-none leading-[1.65]")}
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="font-body text-[13px] text-live-red">{error}</p>}
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-4">
<BlueButton onClick={handleSave} disabled={saving} className="w-28"> <Button onClick={handleSave} disabled={saving} className="px-6">
{saving ? "Saving…" : saved {saving ? "Saving…" : saved ? "Saved" : "Save changes"}
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span> </Button>
: "Save"} <Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>
</div> </div>
</Box> </Card>
)} )}
</div> </div>
); );

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import clsx from "clsx";
import { getVodById, patchVod } from "../utils/api/vods.ts"; import { getVodById, patchVod } from "../utils/api/vods.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx"; import Button from "../components/buttons/Button.tsx";
import PageHeader from "../components/PageHeader.tsx";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"; import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
const EditVod = () => { const EditVod = () => {
const { id } = useParams(); const { id } = useParams();
@@ -47,59 +48,51 @@ const EditVod = () => {
}; };
return ( return (
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<button <button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
onClick={() => navigate(-1)} <ArrowLeft size={15} />
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back Back
</button> </button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1> <PageHeader kicker="VoD" title="Edit details" />
{loading ? ( {loading ? (
<div className="text-gray-400 text-sm">Loading...</div> <p className="font-data text-[13px] text-faint-mono">loading…</p>
) : ( ) : (
<Box className="p-6 flex flex-col gap-4"> <Card className="p-7 flex flex-col gap-5">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Title</label> <label className={labelClass} htmlFor="vod-title">Title</label>
<input <input
id="vod-title"
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title" placeholder="Tuesday night, patch bay walkthrough"
className={inputClass} className={inputClass}
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">Description</label> <label className={labelClass} htmlFor="vod-description">Description</label>
<textarea <textarea
id="vod-description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description" placeholder="What's worth remembering about it"
rows={4} rows={4}
className={inputClass + " resize-none"} className={clsx(inputClass, "resize-none leading-[1.65]")}
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="font-body text-[13px] text-live-red">{error}</p>}
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-4">
<BlueButton onClick={handleSave} disabled={saving} className="w-28"> <Button onClick={handleSave} disabled={saving} className="px-6">
{saving ? "Saving…" : saved {saving ? "Saving…" : saved ? "Saved" : "Save changes"}
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span> </Button>
: "Save"} <Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>
</div> </div>
</Box> </Card>
)} )}
</div> </div>
); );

View File

@@ -1,78 +1,311 @@
import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react"; import {useAuth} from "../utils/AuthContext.tsx";
import Landing from "./Landing.tsx";
import {Link, useNavigate} from "react-router-dom";
import Card from "../components/Card.tsx";
import {useEffect, useState} from "react";
import IsLivePulse from "../components/IsLivePulse.tsx";
import type {Clip, Marker, StreamStatus, Vod} from "../utils/types.ts";
import {getCurrentStream} from "../utils/api/stream.ts";
import VideoCard from "../components/video/VideoCard.tsx";
import {createMarker, getMarkers} from "../utils/api/markers.ts";
import clsx from "clsx";
import {getVods} from "../utils/api/vods.ts";
import {getClips} from "../utils/api/clips.ts";
import Button from "../components/buttons/Button.tsx";
const features = [ type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
{
icon: Upload, function getTimeOfDay(date: Date = new Date()): TimeOfDay {
title: "Upload & compress", const hour = date.getHours();
description: "Drop in a video file, set your clip range and export settings, then compress.",
}, if (hour >= 5 && hour < 12) return "morning";
{ if (hour >= 12 && hour < 17) return "afternoon";
icon: Scissors, if (hour >= 17 && hour < 22) return "evening";
title: "Trim", return "night";
description: "Set start and end points with a range slider. Preview as you go.", }
},
{ function getGreetingSubtext(date: Date = new Date()): string {
icon: SlidersHorizontal, const timeOfDay = getTimeOfDay(date);
title: "Export settings",
description: "Choose resolution, frame rate, and an optional file size cap.", const messages: Record<TimeOfDay, string[]> = {
}, morning: [
{ "Good morning.",
icon: Radio, "Coffee first, then clips.",
title: "RTMP streaming", "Here's what's waiting for you.",
description: "Point OBS at your stream key and go live. Find the key in your profile.", ],
}, afternoon: [
{ "Good afternoon.",
icon: LayoutGrid, "A quiet moment for your clips.",
title: "Clip library", "Catching up?",
description: "Browse all your clips. Watch, rename, edit the description, or delete.", ],
}, evening: [
{ "Good evening.",
icon: Play, "Settle in.",
title: "Built-in player", "Time to unwind.",
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.", ],
}, night: [
]; "Still up?",
"Quiet night for it.",
"Good night, when you're ready.",
]
}
const options = messages[timeOfDay];
return options[Math.floor(Math.random() * options.length)];
}
function getTimeFromISOString(date: string | null): string {
if (date == null) {
return "--:--";
}
const d = new Date(date);
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(d);
}
type SectionHeaderProps = { title: string; to: string; count: number };
const SectionHeader = ({ title, to, count }: SectionHeaderProps) => (
<div className={"flex flex-row items-baseline justify-between gap-4 pb-4"}>
<h2 className={"font-heading text-2xl"}>{title}</h2>
{count > 0 && (
<Link
to={to}
className={"font-body text-[13.5px] text-accent hover:text-accent-hover transition-colors duration-150"}
>
All {count} <span aria-hidden={true}>→</span>
</Link>
)}
</div>
);
const Home = () => { const Home = () => {
const now = new Date();
const formatted: string = new Intl.DateTimeFormat('en-IE', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(now);
const { user } = useAuth();
const [ streamStatus, setStreamStatus ] = useState<StreamStatus | null>(null)
const [ elapsed, setElapsed ] = useState('00:00:00');
const [ markers, setMarkers ] = useState<Marker[]>([])
const [ clips, setClips ] = useState<Clip[]>([]);
const [ vods, setVods ] = useState<Vod[]>([]);
const [ counts, setCounts ] = useState({ vods: 0, clips: 0 });
const [ markerBusy, setMarkerBusy ] = useState(false);
const [ markerNote, setMarkerNote ] = useState<string | null>(null);
const isStreaming = streamStatus?.isStreaming ?? false;
const [ greeting ] = useState<string>(getGreetingSubtext(new Date()));
const navigate = useNavigate();
// Fetch Markers & Stream information
useEffect(() => {
getCurrentStream()
.then(data => {
setStreamStatus(data);
});
getMarkers()
.then(data => {
setMarkers(data);
})
// Get Top 4 Clips
getClips()
.then(data => {
const topSix = [...data]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 4);
setClips(topSix);
setCounts(prev => ({ ...prev, clips: data.length }));
})
// Get top 3 vods
getVods()
.then(data => {
const topSix = [...data]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 3);
setVods(topSix);
setCounts(prev => ({ ...prev, vods: data.length }));
})
}, [user]);
const handleDropMarker = async () => {
if (markerBusy) return;
setMarkerBusy(true);
try {
await createMarker(`Marker at ${elapsed}`);
setMarkers(await getMarkers());
setMarkerNote(`dropped at ${elapsed}`);
} catch (err) {
setMarkerNote(err instanceof Error ? err.message : "Couldn't drop that marker");
} finally {
setMarkerBusy(false);
}
};
useEffect(() => {
if (streamStatus?.startTime == null) {
setElapsed('00:00:00');
return;
}
const startMs = new Date(streamStatus?.startTime).getTime();
const tick = () => {
const diffSec = Math.floor((Date.now() - startMs) / 1000);
const hh = String(Math.floor(diffSec / 3600)).padStart(2, '0');
const mm = String(Math.floor((diffSec % 3600) / 60)).padStart(2, '0');
const ss = String(diffSec % 60).padStart(2, '0');
setElapsed(`${hh}:${mm}:${ss}`);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [streamStatus?.startTime]);
if (user == null) {
return (<Landing />)
}
return ( return (
<div className="flex flex-col items-center px-8 py-12 min-h-full"> <div className={"w-10/12 m-auto pt-10"}>
{/* Hero */} <div className={"flex flex-row justify-between items-start pb-8"}>
<div className="flex flex-col items-center text-center max-w-2xl mb-14"> <div>
<img <p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
src="/logo.png" <h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1>
alt="VoD System Logo" </div>
className="h-24 mb-6 object-contain"
/> <Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}>
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight"> Open Stream Panel
A home for your clips. </Link>
</h1>
<p className="text-base text-gray-500 leading-relaxed">
Upload, trim, and export video clips. Stream live if you need to.
</p>
</div> </div>
{/* Feature grid */} <Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10"> <div className={"flex flex-row items-center gap-2"}>
{features.map(({ icon: Icon, title, description }) => ( {isStreaming
<div ? <IsLivePulse />
key={title} : <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm" <p className={clsx(
> "text-xs font-data tracking-[0.12em]",
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0"> isStreaming ? "text-live-red" : "text-faint-mono"
<Icon size={18} className="text-primary" /> )}>
{isStreaming ? "LIVE NOW" : "OFF AIR"}
</p>
</div>
<div className={"p-1"}></div>
{isStreaming ? (
<>
<h2 className={"font-heading text-3xl"}>
Session {String(streamStatus?.id).padStart(5, '0')} {' '}
recording since {getTimeFromISOString(streamStatus?.startTime ?? null)}.</h2>
<div className={"flex flex-row items-center gap-10 pt-4"}>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{elapsed}</h2>
<p className={"text-xs text-muted"}>elapsed</p>
</div>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{markers.length}</h2>
<p className={"text-xs text-muted"}>markers dropped</p>
</div>
</div> </div>
<div>
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p> <div className={"mt-4 flex items-center gap-4"}>
<p className="text-sm text-gray-500 leading-relaxed">{description}</p> <Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
</div> </div>
</div> </>
))} ) : (
<>
<h2 className={"font-heading text-3xl"}>Nothing is streaming right now.</h2>
<p className={"pt-2 font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty"}>
Point OBS at the ingest URL with your stream key and this block turns over to
elapsed time and markers.
</p>
<div className={"flex flex-row items-center gap-10 pt-4"}>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{counts.vods}</h2>
<p className={"text-xs text-muted"}>vods saved</p>
</div>
<div className={"flex flex-col"}>
<h2 className={"font-data text-xl"}>{counts.clips}</h2>
<p className={"text-xs text-muted"}>clips cut</p>
</div>
</div>
<div className={"mt-4"}>
<Link to={"/stream"}>
<Button variant={"secondary"}>Open the stream panel</Button>
</Link>
</div>
</>
)}
</Card>
<div className={"flex flex-col"}>
<SectionHeader title={"Recent VoDs"} to={"/my-vods"} count={counts.vods} />
<div className={"grid grid-cols-3 gap-5.5"}>
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
title={vod.title}
duration={vod.duration}
createdAt={vod.createdAt}
width={vod.width}
height={vod.height}
fps={vod.fps}
fileSize={vod.fileSize}
mediaApiPath="/api/v1/vods"
playerPath="/video"
itemLabel="vod"
onEdit={() => navigate(`/video/${vod.id}/edit`)}
/>
))}
</div>
</div>
<div className={"flex flex-col mt-9"}>
<SectionHeader title={"Recent Clips"} to={"/my-clips"} count={counts.clips} />
<div className={"grid grid-cols-4 gap-4.5"}>
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
title={clip.title}
duration={clip.duration}
createdAt={clip.createdAt}
width={clip.width}
height={clip.height}
fps={clip.fps}
fileSize={clip.fileSize}
mediaApiPath="/api/v1/clips"
playerPath="/video"
itemLabel="clip"
onEdit={() => navigate(`/video/${clip.id}/edit`)}
/>
))}
</div>
</div> </div>
{/* Format note */}
<p className="text-xs text-gray-400">
Supports MP4, MOV, WebM, and OGG.
</p>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,108 @@
import Card from "../components/Card.tsx";
import GoogleSignIn from "../components/GoogleSignIn.tsx";
import { kickerClass } from "../utils/styles.ts";
const steps = [
{
number: "01",
title: "Point OBS at it",
body: "One ingest URL and one stream key. Everything you send lands on disk as it arrives — no upload step, no waiting at the end.",
},
{
number: "02",
title: "Mark it as it happens",
body: "Drop a marker the moment something is worth keeping. Each one remembers its timecode so you don't have to scrub for it later.",
},
{
number: "03",
title: "Cut it up afterwards",
body: "Tick two markers to pull the stretch between them out as a VoD, or take the last minute as a clip. Cutting runs in the background while you keep streaming.",
},
];
/** Stand-in for the marker list on the stream panel — the shape of the thing, in miniature. */
const previewMarkers = [
{ at: "00:10:12", message: "Patch bay walkthrough begins", selected: false },
{ at: "00:49:56", message: "Filter finally tracks", selected: true },
{ at: "00:50:37", message: "…and it stops tracking", selected: true },
{ at: "01:05:01", message: "Cat on the desk", selected: false },
];
const Landing = () => (
<div className="mx-auto max-w-[1180px] px-13 pt-16 pb-18">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_380px] gap-12 items-start pb-16">
<div className="flex flex-col gap-5">
<p className={kickerClass}>Personal stream archive</p>
<h1 className="font-heading text-5xl tracking-[-0.015em] leading-[1.1] max-w-[16ch]">
Keep the good bits of everything you stream.
</h1>
<p className="font-body text-[16px] leading-[1.65] text-text-body max-w-[58ch] text-pretty">
The VoD System sits between your encoder and your hard drive. It records
every session in full, lets you mark the moments worth keeping while
they're happening, and turns those marks into VoDs and clips afterwards.
</p>
<div className="flex flex-col gap-3 pt-2">
<GoogleSignIn shape="rectangular" size="large" width="260" />
<p className="font-body text-[12.5px] text-muted">
Sign in with Google to reach your library.
</p>
</div>
</div>
{/* A quiet look at what's behind the login. */}
<Card className="p-6 flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Live markers</p>
<p className="font-data text-[12px] text-faint-mono">t = 01:33:35</p>
</div>
<div className="flex flex-col gap-2">
{previewMarkers.map((marker) => (
<div
key={marker.at}
className={`flex items-center gap-3 rounded-tile px-3 py-2.5 ${
marker.selected ? "bg-armed inset-shadow-armed" : "bg-fields"
}`}
>
<span className={`h-[14px] w-[14px] flex-shrink-0 rounded-[4px] ${
marker.selected ? "bg-accent" : "bg-inactive"
}`} />
<span className="font-data text-[12px] text-accent flex-shrink-0">{marker.at}</span>
<span className="font-body text-[12.5px] text-text-strong truncate">{marker.message}</span>
</div>
))}
</div>
<p className="font-body text-[12.5px] text-muted pt-1">
Two marks selected — that's a 41-second VoD, one click away.
</p>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-[22px]">
{steps.map((step) => (
<Card key={step.number} className="p-6 flex flex-col gap-2.5">
<p className="font-data text-[13px] text-accent">{step.number}</p>
<h2 className="font-heading text-[22px] leading-snug">{step.title}</h2>
<p className="font-body text-[14px] leading-[1.65] text-text-body text-pretty">
{step.body}
</p>
</Card>
))}
</div>
<div className="bg-panel rounded-panel p-6 mt-[26px] flex flex-wrap items-baseline justify-between gap-x-8 gap-y-2">
<p className="font-body text-[14px] text-text-body">
Built for one person and their own machine. No tiers, no transcoding queue you share with strangers.
</p>
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">
rtmp in · mp4 out
</p>
</div>
</div>
);
export default Landing;

View File

@@ -1,14 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react"; import { ArrowLeft, FastForward, Lock, Maximize, Minimize, Pause, Play, Rewind, Volume1, Volume2, VolumeX } from "lucide-react";
import clsx from "clsx";
import { AuthError } from "../utils/api/client.ts"; import { AuthError } from "../utils/api/client.ts";
import Box from "../components/Box.tsx"; import Card from "../components/Card.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts"; import { dateToTimeAgo, formatFileSize, formatTime, stringToDate } from "../utils/utils.ts";
import { kickerClass, quietLinkClass } from "../utils/styles.ts";
// Both VoDs and clips share the same on-screen shape. // Both VoDs and clips share the same on-screen shape.
type MediaItem = { type MediaItem = {
title: string, title: string,
createdAt: string, createdAt: string,
width?: number | null,
height?: number | null,
fps?: number | null,
/** Size on disk, in bytes. */
fileSize?: number | null,
}; };
type MediaPlayerProps = { type MediaPlayerProps = {
@@ -53,12 +60,15 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true); const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [hoverTime, setHoverTime] = useState<number | null>(null);
/** Transient badge that flashes in the middle of the frame on play/pause/seek. */
const [flash, setFlash] = useState<{ id: number; kind: "play" | "pause" | "back" | "forward" } | null>(null);
const showFlash = useCallback((kind: "play" | "pause" | "back" | "forward") => {
setFlash({ id: Date.now(), kind });
}, []);
// ── Data loading ──────────────────────────────────────────────────────── // ── Data loading ────────────────────────────────────────────────────────
// The video stream itself is loaded by the <video> element from `mediaUrl`,
// which lets the browser fetch it incrementally via Range requests. We only
// need to fetch the metadata here; it shares the media endpoint's auth, so a
// 401/403 here also stands in for the stream being inaccessible.
useEffect(() => { useEffect(() => {
if (!id) { setError(`${noun} ID is required.`); return; } if (!id) { setError(`${noun} ID is required.`); return; }
@@ -68,6 +78,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
if (e instanceof AuthError) setNotAuthenticated(true); if (e instanceof AuthError) setNotAuthenticated(true);
else setError(`Failed to load ${noun.toLowerCase()} details.`); else setError(`Failed to load ${noun.toLowerCase()} details.`);
}); });
}, [id, noun, fetchDetails]); }, [id, noun, fetchDetails]);
useEffect(() => { useEffect(() => {
@@ -89,7 +100,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
scheduleHide(); scheduleHide();
}, [scheduleHide]); }, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement); const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange); document.addEventListener('fullscreenchange', onChange);
@@ -119,11 +129,13 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5); video.currentTime = Math.max(0, video.currentTime - 5);
showFlash('back');
showControls(); showControls();
break; break;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5); video.currentTime = Math.min(video.duration, video.currentTime + 5);
showFlash('forward');
showControls(); showControls();
break; break;
case 'KeyM': case 'KeyM':
@@ -137,7 +149,15 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen]); }, [showControls, showFlash, toggleFullscreen]);
// Each flash clears itself; keying the node on `id` restarts the animation
// when the same action fires twice in a row.
useEffect(() => {
if (!flash) return;
const timer = setTimeout(() => setFlash(null), 500);
return () => clearTimeout(timer);
}, [flash]);
// ── Progress bar seeking ──────────────────────────────────────────────── // ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => { const seekTo = useCallback((clientX: number) => {
@@ -191,29 +211,40 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
const togglePlay = () => { const togglePlay = () => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
showFlash(video.paused ? 'play' : 'pause');
video.paused ? video.play() : video.pause(); video.paused ? video.play() : video.pause();
showControls(); showControls();
}; };
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0; const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const hoverPct = hoverTime !== null && duration > 0 ? (hoverTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume; const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2; const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
// Facts worth showing under the player, skipping anything the API didn't send.
const facts = [
item?.width && item?.height ? `${item.width}×${item.height}` : null,
item?.fps ? `${Math.round(item.fps)} fps` : null,
duration > 0 ? formatTime(duration) : null,
item?.fileSize ? formatFileSize(item.fileSize) : null,
].filter(Boolean);
const flashIcons = { play: Play, pause: Pause, back: Rewind, forward: FastForward } as const;
const FlashIcon = flash ? flashIcons[flash.kind] : null;
if (notAuthenticated) { if (notAuthenticated) {
return ( return (
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8"> <div className="h-full flex flex-col items-center justify-center gap-3 text-center p-8">
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center"> <Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
<Lock size={24} className="text-gray-400" /> <h2 className="font-heading text-[27px] text-faint-mono">Not yours to watch</h2>
</div> <p className="font-body text-sm text-faint-mono/70 max-w-xs text-pretty">
<div> You don't have access to this {noun.toLowerCase()}.
<p className="text-lg font-semibold text-gray-900">Not authenticated</p> </p>
<p className="text-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p>
</div>
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150" className={clsx(quietLinkClass, "flex items-center gap-1.5 pt-1")}
> >
<ArrowLeft size={16} /> <ArrowLeft size={15} />
Go back Go back
</button> </button>
</div> </div>
@@ -221,19 +252,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
} }
return ( return (
<div className="h-full flex flex-col p-4 gap-3"> <div className="h-full flex flex-col px-13 py-8 gap-4">
<button <button
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150" className={clsx(quietLinkClass, "flex items-center gap-1.5 shrink-0")}
> >
<ArrowLeft size={16} /> <ArrowLeft size={15} />
Back Back
</button> </button>
{/* Player */} {/* Player */}
<div <div
ref={containerRef} ref={containerRef}
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none" className="relative bg-player rounded-thumb shadow-player overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls} onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)} onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay} onClick={togglePlay}
@@ -269,10 +300,33 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
)} )}
{/* Action flash — a badge that swells and fades on play, pause and seek. */}
{flash && FlashIcon && (
<div key={flash.id} className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="animate-flash-badge flex h-18.5 w-18.5 items-center justify-center rounded-full bg-player/55 backdrop-blur-[2px] text-on-dark">
<FlashIcon size={30} fill="currentColor" strokeWidth={1} />
</div>
</div>
)}
{/* Controls overlay — pointer-events-none so video clicks pass through */} {/* Controls overlay — pointer-events-none so video clicks pass through */}
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}> <div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
{/* Gradient */} {/* Gradients — top one carries the title, bottom one the controls */}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" /> <div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-black/55 to-transparent" />
<div className="absolute inset-0 bg-linear-to-t from-black/75 via-black/10 to-transparent" />
{/* Title plate */}
<div className={clsx(
"absolute inset-x-0 top-0 px-5 pt-4 flex flex-col gap-0.5 transition-transform duration-300",
controlsVisible ? "translate-y-0" : "-translate-y-2"
)}>
<p className="font-data text-[10.5px] uppercase tracking-[0.14em] text-on-dark/55">
{noun}
</p>
<p className="font-heading text-[22px] leading-tight text-on-dark truncate">
{item?.title || "(No title)"}
</p>
</div>
{/* Controls bar — pointer-events-auto only here */} {/* Controls bar — pointer-events-auto only here */}
<div <div
@@ -284,13 +338,35 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
ref={progressRef} ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150" className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }} onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
setHoverTime(ratio * duration);
}}
onMouseLeave={() => setHoverTime(null)}
> >
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} /> <div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
{/* Ghost fill up to the cursor, so the scrub reads before you commit */}
{hoverTime !== null && (
<div className="absolute inset-y-0 left-0 bg-white/25 rounded-full" style={{ width: `${hoverPct}%` }} />
)}
<div className="absolute inset-y-0 left-0 bg-accent-light rounded-full" style={{ width: `${progressPct}%` }} />
<div <div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity" className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }} style={{ left: `calc(${progressPct}% - 6px)` }}
/> />
{/* Time under the cursor */}
{hoverTime !== null && (
<div
className="absolute bottom-4 -translate-x-1/2 rounded-md bg-player/85 px-1.5 py-0.5 font-data text-[11px] text-on-dark tabular-nums pointer-events-none"
style={{ left: `${hoverPct}%` }}
>
{formatTime(hoverTime)}
</div>
)}
</div> </div>
{/* Buttons row */} {/* Buttons row */}
@@ -317,7 +393,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
</div> </div>
<span className="text-xs font-mono tabular-nums text-white/80"> <span className="font-data text-xs tabular-nums text-on-dark/80">
{formatTime(currentTime)} / {formatTime(duration)} {formatTime(currentTime)} / {formatTime(duration)}
</span> </span>
@@ -331,18 +407,26 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
</div> </div>
</div> </div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>} {error && <p className="font-body text-[13px] text-live-red">{error}</p>}
{!mediaLoaded && !error && ( {!mediaLoaded && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2"> <p className="font-data text-[12px] text-faint-mono">loading video…</p>
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video...
</div>
)} )}
<Box className="p-4 flex-shrink-0"> <Card className="p-5 shrink-0 flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
<p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p> <div className="min-w-0">
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p> <p className={kickerClass}>{noun}</p>
</Box> <p className="font-heading text-[25px] tracking-[-0.015em] leading-tight pt-1">
{item?.title || "(No title)"}
</p>
<p className="font-body text-[13px] text-muted pt-1">{timeAgo}</p>
</div>
{facts.length > 0 && (
<p className="font-data text-[12px] text-faint-mono tabular-nums">
{facts.join(" · ")}
</p>
)}
</Card>
</div> </div>
); );
}; };

View File

@@ -1,19 +1,24 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import PageHeader from "../components/PageHeader";
import NoAccessPage from "../components/NoAccessPage";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips"; import { getClips, deleteClip } from "../utils/api/clips";
import { useAuth } from "../utils/AuthContext";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const { user, loading } = useAuth();
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (!user) return;
getClips() getClips()
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()))) .then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err)); .catch((err) => setError(err instanceof Error ? err.message : "Failed to load clips"));
}, []); }, [user]);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
@@ -24,11 +29,16 @@ const MyClips = () => {
} }
}; };
return ( if (loading) return null;
<div className="p-6"> if (!user) return <NoAccessPage />;
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Library" title="Clips">
<p className="font-data text-[13px] text-faint-mono pt-6">{clips.length} cut</p>
</PageHeader>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-[18px]">
{clips.map((clip) => ( {clips.map((clip) => (
<VideoCard <VideoCard
key={clip.id} key={clip.id}
@@ -36,6 +46,10 @@ const MyClips = () => {
title={clip.title} title={clip.title}
duration={clip.duration} duration={clip.duration}
createdAt={clip.createdAt} createdAt={clip.createdAt}
width={clip.width}
height={clip.height}
fps={clip.fps}
fileSize={clip.fileSize}
onEdit={() => navigate(`/clips/${clip.id}/edit`)} onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)} onDelete={() => handleDelete(clip.id)}
/> />
@@ -43,13 +57,17 @@ const MyClips = () => {
</div> </div>
{clips.length === 0 && !error && ( {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400"> <div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
<p className="text-base">No clips yet.</p> <h2 className="font-heading text-[25px]">No clips yet.</h2>
<p className="text-sm mt-1">Upload your first clip to get started.</p> <p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
Cut one from the stream panel, or{" "}
<Link to="/create" className="text-accent hover:text-accent-hover">upload a file</Link>{" "}
to trim it down here.
</p>
</div> </div>
)} )}
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>} {error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
</div> </div>
); );
} }

View File

@@ -1,19 +1,24 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import PageHeader from "../components/PageHeader";
import NoAccessPage from "../components/NoAccessPage";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods"; import { getVods, deleteVod } from "../utils/api/vods";
import { useAuth } from "../utils/AuthContext";
import type { Vod } from "../utils/types"; import type { Vod } from "../utils/types";
const MyVods = () => { const MyVods = () => {
const { user, loading } = useAuth();
const [vods, setVods] = useState<Vod[]>([]); const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (!user) return;
getVods() getVods()
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()))) .then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods")); .catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
}, []); }, [user]);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
@@ -24,11 +29,16 @@ const MyVods = () => {
} }
}; };
return ( if (loading) return null;
<div className="p-6"> if (!user) return <NoAccessPage />;
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> return (
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<PageHeader kicker="Library" title="VoDs">
<p className="font-data text-[13px] text-faint-mono pt-6">{vods.length} saved</p>
</PageHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-[22px]">
{vods.map((vod) => ( {vods.map((vod) => (
<VideoCard <VideoCard
key={vod.id} key={vod.id}
@@ -36,6 +46,10 @@ const MyVods = () => {
title={vod.title} title={vod.title}
duration={vod.duration} duration={vod.duration}
createdAt={vod.createdAt} createdAt={vod.createdAt}
width={vod.width}
height={vod.height}
fps={vod.fps}
fileSize={vod.fileSize}
mediaApiPath="/api/v1/vods" mediaApiPath="/api/v1/vods"
playerPath="/vod" playerPath="/vod"
itemLabel="vod" itemLabel="vod"
@@ -46,13 +60,15 @@ const MyVods = () => {
</div> </div>
{vods.length === 0 && !error && ( {vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400"> <div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
<p className="text-base">No VoDs yet.</p> <h2 className="font-heading text-[25px]">Nothing saved yet.</h2>
<p className="text-sm mt-1">Your stream recordings will appear here.</p> <p className="font-body text-[14.5px] text-muted max-w-[42ch] text-pretty">
Ranges you save from the stream panel land here, full length and untouched.
</p>
</div> </div>
)} )}
{error && <p className="text-sm text-red-500 mt-4">{error}</p>} {error && <p className="font-body text-[13px] text-live-red mt-6">{error}</p>}
</div> </div>
); );
}; };

View File

@@ -1,84 +1,68 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react"; import Card from "../components/Card.tsx";
import Box from "../components/Box.tsx"; import PageHeader from "../components/PageHeader.tsx";
import { getUser } from "../utils/api/users.ts"; import NoAccessPage from "../components/NoAccessPage.tsx";
import type { User } from "../utils/types.ts"; import { useAuth } from "../utils/AuthContext.tsx";
import { formatLocalDate } from "../utils/utils.ts"; import { formatLocalDate } from "../utils/utils.ts";
import { labelClass, chipButtonClass } from "../utils/styles.ts";
const Profile = () => { const Profile = () => {
const [user, setUser] = useState<User | null>(null); const { user, loading } = useAuth();
const [keyVisible, setKeyVisible] = useState(false); const [keyVisible, setKeyVisible] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
useEffect(() => {
getUser().then(setUser).catch(console.error);
}, []);
const handleCopy = () => { const handleCopy = () => {
if (!user?.streamKey) return; if (!user?.streamKey) return;
navigator.clipboard.writeText(user.streamKey); navigator.clipboard.writeText(user.streamKey);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 1600);
}; };
if (!user) { if (loading) return null;
return ( if (!user) return <NoAccessPage />;
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to view your profile.
</div>
);
}
return ( return (
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1> <PageHeader kicker="Account" title="Profile" />
<Box className="p-6 mb-4 flex items-center gap-5"> <Card className="p-7 mb-[26px] flex items-center gap-5">
<img <img
src={user.profilePictureUrl} src={user.profilePictureUrl}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
alt="Profile picture" alt=""
className="w-20 h-20 rounded-full shadow-sm" className="w-[72px] h-[72px] rounded-full shadow-card"
/> />
<div> <div className="flex flex-col gap-0.5">
<p className="text-lg font-semibold text-gray-900">{user.name}</p> <p className="font-heading text-[25px] leading-tight">{user.name}</p>
<p className="text-sm text-gray-500">@{user.username}</p> <p className="font-body text-[13px] text-text-secondary">@{user.username} · {user.email}</p>
<p className="text-sm text-gray-500">{user.email}</p> <p className="font-data text-[11px] text-faint-mono pt-1">
<p className="text-xs text-gray-400 mt-1"> member since {formatLocalDate(user.createdAt)}
Member since {formatLocalDate(user.createdAt)}
</p> </p>
</div> </div>
</Box> </Card>
<Box className="p-6"> <Card className="p-7">
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2> <h2 className="font-heading text-[22px] pb-1.5">Stream key</h2>
<p className="text-sm text-gray-500 mb-4"> <p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty pb-5">
Use this key in OBS or any RTMP-compatible software to start streaming. Point OBS — or anything else that speaks RTMP — at the ingest URL with this key.
Keep it private — anyone with this key can stream to your account. Keep it private: anyone holding it can stream to your account.
</p> </p>
<div className="flex items-center gap-2"> <p className={labelClass}>Key</p>
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden"> <div className="flex items-center gap-2.5 pt-2">
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)} <div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
{keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
</div> </div>
<button <button className={chipButtonClass} onClick={() => setKeyVisible(visible => !visible)}>
onClick={() => setKeyVisible(v => !v)} {keyVisible ? "Hide" : "Reveal"}
title={keyVisible ? "Hide key" : "Show key"}
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
>
{keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>
<button <button className={chipButtonClass} onClick={handleCopy}>
onClick={handleCopy} {copied ? "Copied" : "Copy"}
title="Copy key"
className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
>
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
</button> </button>
</div> </div>
</Box> </Card>
</div> </div>
); );
}; };

View File

@@ -1,94 +1,72 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react"; import { Check, X } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import Box from "../components/Box.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx"; import Card from "../components/Card.tsx";
import { getUser } from "../utils/api/users.ts"; import Button from "../components/buttons/Button.tsx";
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts"; import PageHeader from "../components/PageHeader.tsx";
import IsLivePulse from "../components/IsLivePulse.tsx";
import NoAccessPage from "../components/NoAccessPage.tsx";
import JobProgress, { isSettled, type TrackedJob } from "../components/JobProgress.tsx";
import { useAuth } from "../utils/AuthContext.tsx";
import { getCurrentStream, getIngestUrl } from "../utils/api/stream.ts";
import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts"; import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts"; import { saveSection, clipSection } from "../utils/api/media.ts";
import { getJob } from "../utils/api/jobs.ts"; import { getJob } from "../utils/api/jobs.ts";
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts"; import type { Marker, StreamStatus } from "../utils/types.ts";
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts"; import { formatTime, formatTimecode, parseTimecode, toRelativeOffset, stringToDate } from "../utils/utils.ts";
import { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass } from "../utils/styles.ts";
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"; /** Ten-minute gridlines behind the marker ticks. */
const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide"; const GRID_INTERVAL_SECONDS = 600;
type TrackedJob = { const useCopy = () => {
uuid: string; const [copied, setCopied] = useState<string | null>(null);
label: string;
progress: number;
state: JobResponse['state'];
errorOutput: string | null;
};
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => ( const copy = (key: string, text: string) => {
<div className="flex items-center gap-2.5"> navigator.clipboard.writeText(text);
<div className={clsx("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", accent === "primary" ? "bg-primary/10" : "bg-accent/10")}> setCopied(key);
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} /> setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
</div> };
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
</div>
);
const JobStatus = ({ job }: { job: TrackedJob }) => { return { copied, copy };
if (job.state === 'FAILED') {
return (
<div className="flex items-start gap-2 text-sm">
<X size={15} className="text-red-500 mt-0.5 flex-shrink-0" />
<p className="text-red-500">{job.label} failed: {job.errorOutput ?? "Unknown error"}</p>
</div>
);
}
if (job.state === 'SUCCEEDED') {
return (
<div className="flex items-center gap-2 text-sm">
<Check size={15} className="text-green-600 flex-shrink-0" />
<p className="text-gray-700">{job.label} saved</p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Loader2 size={15} className="text-primary animate-spin flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-gray-600 mb-1">{job.label}…</p>
<progress value={job.progress} className="w-full h-1.5 rounded bg-gray-200" />
</div>
</div>
);
}; };
const StreamControl = () => { const StreamControl = () => {
const [user, setUser] = useState<User | null>(null); const { user, loading } = useAuth();
const { copied, copy } = useCopy();
const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null); const [streamStatus, setStreamStatus] = useState<StreamStatus | null>(null);
const [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [markers, setMarkers] = useState<Marker[]>([]); const [markers, setMarkers] = useState<Marker[]>([]);
const [markerMessage, setMarkerMessage] = useState(""); const [markerMessage, setMarkerMessage] = useState("");
const [markerBusy, setMarkerBusy] = useState(false); const [markerBusy, setMarkerBusy] = useState(false);
const [markerError, setMarkerError] = useState<string | null>(null); const [markerError, setMarkerError] = useState<string | null>(null);
const markerInputRef = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState<number[]>([]);
const [inValue, setInValue] = useState("");
const [outValue, setOutValue] = useState("");
const [rangeName, setRangeName] = useState("");
const [rangeError, setRangeError] = useState<string | null>(null);
const [rangeBusy, setRangeBusy] = useState(false);
const [clipDuration, setClipDuration] = useState(30); const [clipDuration, setClipDuration] = useState(30);
const [clipTitle, setClipTitle] = useState(""); const [clipName, setClipName] = useState("");
const [clipDescription, setClipDescription] = useState("");
const [clipError, setClipError] = useState<string | null>(null); const [clipError, setClipError] = useState<string | null>(null);
const [startMarkerId, setStartMarkerId] = useState<string>(""); const [keyVisible, setKeyVisible] = useState(false);
const [endMarkerId, setEndMarkerId] = useState<string>(""); const [ingestUrl, setIngestUrl] = useState<string | null>(null);
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
const [markerSaveError, setMarkerSaveError] = useState<string | null>(null);
const [jobs, setJobs] = useState<TrackedJob[]>([]); const [jobs, setJobs] = useState<TrackedJob[]>([]);
const isStreaming = streamStatus?.isStreaming ?? false; const isStreaming = streamStatus?.isStreaming ?? false;
const startMs = streamStatus?.startTime ? stringToDate(streamStatus.startTime).getTime() : null;
const elapsedSeconds = startMs === null ? 0 : Math.max(0, (now - startMs) / 1000);
const elapsed = formatTimecode(elapsedSeconds);
useEffect(() => { // ── Polling ─────────────────────────────────────────────────────────────
getUser().then(setUser).catch(() => setUser(null));
}, []);
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {}); const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
@@ -98,24 +76,18 @@ const StreamControl = () => {
}, [user]); }, [user]);
useEffect(() => { useEffect(() => {
if (!user || !streamStatus?.id) {
setStreamDetails(null);
return;
}
getStreamHistory(user.id)
.then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
.catch(() => {});
}, [user, streamStatus?.id]);
useEffect(() => {
if (!streamDetails?.startDate) return;
const interval = setInterval(() => setNow(Date.now()), 1000); const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [streamDetails?.startDate]); }, []);
useEffect(() => {
if (!user) return;
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
}, [user]);
const refreshMarkers = () => { const refreshMarkers = () => {
getMarkers() getMarkers()
.then((data) => setMarkers(data .then((data) => setMarkers([...data]
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime()))) .sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
.catch(() => {}); .catch(() => {});
}; };
@@ -123,6 +95,7 @@ const StreamControl = () => {
useEffect(() => { useEffect(() => {
if (!isStreaming) { if (!isStreaming) {
setMarkers([]); setMarkers([]);
setSelected([]);
return; return;
} }
refreshMarkers(); refreshMarkers();
@@ -130,268 +103,495 @@ const StreamControl = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isStreaming, streamStatus?.id]); }, [isStreaming, streamStatus?.id]);
// One interval for the whole pending set, rebuilt only when that set changes.
const pendingKey = jobs.filter((job) => !isSettled(job)).map((job) => job.uuid).join(",");
useEffect(() => { useEffect(() => {
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED'); if (!pendingKey) return;
if (pending.length === 0) return; const uuids = pendingKey.split(",");
const interval = setInterval(() => { const interval = setInterval(() => {
pending.forEach((job) => { uuids.forEach((uuid) => {
getJob(job.uuid) getJob(uuid)
.then((result) => { .then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid
setJobs((prev) => prev.map((j) => j.uuid === job.uuid ? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput } : job)))
: j));
})
.catch(() => {}); .catch(() => {});
}); });
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [jobs]); }, [pendingKey]);
const addJob = (uuid: string, label: string) => { // ── Marker helpers ──────────────────────────────────────────────────────
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]); const offsetOf = (marker: Marker) =>
startMs === null ? 0 : (stringToDate(marker.timestamp).getTime() - startMs) / 1000;
const toggleMarker = (id: number) => {
const next = selected.includes(id)
? selected.filter((current) => current !== id)
: selected.length < 2 ? [...selected, id] : [selected[1], id];
setSelected(next);
setRangeError(null);
if (next.length !== 2 || startMs === null) return;
const offsets = next
.map((markerId) => markers.find((marker) => marker.id === markerId))
.filter((marker): marker is Marker => marker !== undefined)
.map(offsetOf)
.sort((a, b) => a - b);
if (offsets.length !== 2) return;
setInValue(formatTimecode(offsets[0]));
setOutValue(formatTimecode(offsets[1]));
}; };
// 'M' anywhere on the page jumps to the marker field.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key !== "m" && event.key !== "M") return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
event.preventDefault();
markerInputRef.current?.focus();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
// ── Actions ─────────────────────────────────────────────────────────────
const addJob = (uuid: string, label: string) =>
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
const handleAddMarker = async () => { const handleAddMarker = async () => {
if (!markerMessage.trim() || markerBusy) return; if (markerBusy) return;
setMarkerBusy(true); setMarkerBusy(true);
setMarkerError(null); setMarkerError(null);
try { try {
await createMarker(markerMessage.trim()); await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
setMarkerMessage(""); setMarkerMessage("");
refreshMarkers(); refreshMarkers();
} catch (err) { } catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to add marker"); setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
} finally { } finally {
setMarkerBusy(false); setMarkerBusy(false);
} }
}; };
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
addJob(uuid, `Clip (last ${clipDuration}s)`);
setClipTitle("");
setClipDescription("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to clip section");
}
};
const handleSaveByMarkers = async () => {
setMarkerSaveError(null);
if (!startMarkerId || !endMarkerId) {
setMarkerSaveError("Tap two markers above to pick a start and end");
return;
}
try {
const uuid = await saveSectionByMarkers(
Number(startMarkerId),
Number(endMarkerId),
markerSaveTitle || undefined,
markerSaveDescription || undefined
);
addJob(uuid, "Save (by markers)");
setStartMarkerId("");
setEndMarkerId("");
setMarkerSaveTitle("");
setMarkerSaveDescription("");
} catch (err) {
setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section");
}
};
const handleDeleteMarker = async (id: number) => { const handleDeleteMarker = async (id: number) => {
try { try {
await deleteMarker(id); await deleteMarker(id);
if (startMarkerId === String(id)) setStartMarkerId(""); setSelected((prev) => prev.filter((current) => current !== id));
if (endMarkerId === String(id)) setEndMarkerId("");
refreshMarkers(); refreshMarkers();
} catch (err) { } catch (err) {
setMarkerError(err instanceof Error ? err.message : "Failed to delete marker"); setMarkerError(err instanceof Error ? err.message : "Failed to remove the marker");
} }
}; };
const handleChipClick = (id: number) => { const inSeconds = parseTimecode(inValue);
const idStr = String(id); const outSeconds = parseTimecode(outValue);
setMarkerSaveError(null); // In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss).
if (startMarkerId === idStr) { setStartMarkerId(""); return; } const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds
if (endMarkerId === idStr) { setEndMarkerId(""); return; } ? formatTime(outSeconds - inSeconds)
if (!startMarkerId) { setStartMarkerId(idStr); return; }
if (!endMarkerId) { setEndMarkerId(idStr); return; }
setEndMarkerId(idStr);
};
if (!user) {
return (
<div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to control your stream.
</div>
);
}
const elapsed = streamDetails?.startDate
? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
: null; : null;
const startMarker = markers.find((m) => String(m.id) === startMarkerId); const handleSaveRange = async () => {
const endMarker = markers.find((m) => String(m.id) === endMarkerId); setRangeError(null);
if (startMs === null) {
setRangeError("No stream is running to take a range from.");
return;
}
if (inSeconds === null || outSeconds === null) {
setRangeError("Timecodes read as hh:mm:ss.");
return;
}
if (outSeconds <= inSeconds) {
setRangeError("The out point has to come after the in point.");
return;
}
if (outSeconds > elapsedSeconds) {
setRangeError("That out point hasn't been streamed yet.");
return;
}
setRangeBusy(true);
try {
const uuid = await saveSection(
new Date(startMs + inSeconds * 1000).toISOString(),
new Date(startMs + outSeconds * 1000).toISOString(),
rangeName || undefined
);
addJob(uuid, rangeName || `Saving ${inValue} – ${outValue}`);
clearSelection();
} catch (err) {
setRangeError(err instanceof Error ? err.message : "Failed to save the range");
} finally {
setRangeBusy(false);
}
};
const clearSelection = () => {
setSelected([]);
setInValue("");
setOutValue("");
setRangeName("");
setRangeError(null);
};
const handleClip = async () => {
setClipError(null);
try {
const uuid = await clipSection(clipDuration, clipName || undefined);
addJob(uuid, clipName || `Cutting the last ${clipDuration}s`);
setClipName("");
} catch (err) {
setClipError(err instanceof Error ? err.message : "Failed to cut the clip");
}
};
// ── Timeline ────────────────────────────────────────────────────────────
const gridlines = useMemo(() => {
if (elapsedSeconds < GRID_INTERVAL_SECONDS) return [];
return Array.from(
{ length: Math.floor(elapsedSeconds / GRID_INTERVAL_SECONDS) },
(_, index) => ((index + 1) * GRID_INTERVAL_SECONDS) / elapsedSeconds * 100
);
}, [elapsedSeconds]);
const runningJobs = jobs.filter((job) => !isSettled(job)).length;
if (loading) return null;
if (!user) return <NoAccessPage />;
return ( return (
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5"> <div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1> <PageHeader kicker="Ingest" title="Stream panel" />
{/* Hero status bar */} <div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
<Box className={clsx( {/* ── Main column ───────────────────────────────────────── */}
"p-6 flex items-center justify-between gap-6 border-l-4", <div className="flex flex-col gap-[26px]">
isStreaming ? "border-l-red-500" : "border-l-gray-200" <Card className="p-7">
)}> <div className="flex items-baseline justify-between gap-4 pb-5">
<div className="flex items-center gap-3.5"> <h2 className="font-heading text-[22px]">Server credentials</h2>
<span className="relative flex h-3.5 w-3.5"> {isStreaming ? (
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />} <div className="flex items-center gap-2">
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} /> <IsLivePulse />
</span> <p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
<div> </div>
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p> ) : (
{isStreaming && streamDetails?.startDate ? ( <p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p> )}
) : !isStreaming ? ( </div>
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
) : null}
</div>
</div>
{isStreaming && elapsed ? ( <p className={labelClass}>Ingest URL</p>
<div className="text-right"> <div className="flex items-center gap-2.5 pt-2">
<p className="text-2xl font-mono font-semibold text-gray-900 tabular-nums">{elapsed}</p>
<p className="text-xs text-gray-400 uppercase tracking-wide">elapsed</p>
</div>
) : (
<Radio size={28} className="text-gray-200" />
)}
</Box>
{isStreaming && (
<>
{/* Quick mark toolbar */}
<Box className="p-4 flex flex-col gap-3">
<div className="flex items-center gap-3">
<Bookmark size={18} className="text-accent flex-shrink-0" />
<input <input
type="text" type="text"
value={markerMessage} readOnly
onChange={(e) => setMarkerMessage(e.target.value)} value={ingestUrl ?? ""}
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()} placeholder="asking the server…"
placeholder="What just happened?" className={clsx(monoInputClass, "flex-1")}
className={inputClass}
/> />
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap"> <button
Add Marker className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
</BlueButton> disabled={!ingestUrl}
onClick={() => ingestUrl && copy("url", ingestUrl)}
>
{copied === "url" ? "Copied" : "Copy"}
</button>
</div> </div>
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
{markers.length > 0 && ( <p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5"> <div className="flex items-center gap-2.5 pt-2">
{markers.map((m) => { <div className="flex-1 bg-fields rounded-field px-3 py-2.5 font-data text-sm text-text-strong truncate">
const isStart = startMarkerId === String(m.id); {keyVisible ? user.streamKey : "•".repeat(Math.min(user.streamKey.length, 28))}
const isEnd = endMarkerId === String(m.id); </div>
<button className={chipButtonClass} onClick={() => setKeyVisible((visible) => !visible)}>
{keyVisible ? "Hide" : "Reveal"}
</button>
<button className={chipButtonClass} onClick={() => copy("key", user.streamKey)}>
{copied === "key" ? "Copied" : "Copy"}
</button>
</div>
<p className="pt-3 font-body text-[13px] text-muted">
Keep this private — anyone holding the key can stream to your account.
</p>
</Card>
{isStreaming ? (
<Card className="p-7">
<div className="flex items-baseline justify-between gap-4 pb-5">
<h2 className="font-heading text-[22px]">Live markers</h2>
<p className="font-data text-[13px] text-faint-mono tabular-nums">t = {elapsed}</p>
</div>
<div className="flex items-center gap-3">
<input
ref={markerInputRef}
type="text"
value={markerMessage}
onChange={(event) => setMarkerMessage(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && handleAddMarker()}
placeholder="What just happened? (optional)"
className={clsx(inputClass, "flex-1")}
/>
<Button
variant="dark"
onClick={handleAddMarker}
disabled={markerBusy}
className="whitespace-nowrap px-5 py-3"
>
+ Drop marker at {elapsed}
</Button>
</div>
<p className="pt-2.5 font-body text-[13px] text-muted">
or press{" "}
<kbd className="bg-chip rounded-[5px] px-1.5 py-0.5 font-data text-[11px] text-text-control">M</kbd>
{" "}to jump here
</p>
{markerError && <p className="pt-2 font-body text-[13px] text-live-red">{markerError}</p>}
{/* Timeline overview */}
<div className="relative mt-5 h-[74px] rounded-chip bg-fields overflow-hidden">
{gridlines.map((left, index) => (
<span
key={index}
className="absolute inset-y-0 w-px bg-inactive/60"
style={{ left: `${left}%` }}
/>
))}
{markers.map((marker) => {
const left = elapsedSeconds > 0
? Math.min(100, Math.max(0, (offsetOf(marker) / elapsedSeconds) * 100))
: 0;
return ( return (
<div key={m.id} className="group relative flex-shrink-0"> <span
key={marker.id}
title={marker.message}
className={clsx(
"absolute inset-y-0 w-[2px]",
selected.includes(marker.id) ? "bg-accent" : "bg-olive-light"
)}
style={{ left: `${left}%` }}
/>
);
})}
<span className="absolute bottom-2 left-3 font-data text-[10px] text-faint-mono">00:00:00</span>
<span className="absolute bottom-2 right-3 font-data text-[10px] text-faint-mono tabular-nums">{elapsed}</span>
</div>
{/* Marker list */}
<div className="flex flex-col gap-2 pt-5">
{markers.length === 0 && (
<p className="font-body text-[13px] text-muted py-2">
No markers yet. Drop one the moment something happens — you can cut around it later.
</p>
)}
{markers.map((marker) => {
const isSelected = selected.includes(marker.id);
const offset = offsetOf(marker);
return (
<div key={marker.id} className="group flex items-center gap-1">
<button <button
onClick={() => handleChipClick(m.id)} onClick={() => toggleMarker(marker.id)}
className={clsx( className={clsx(
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150", "flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
isStart && "border-primary bg-primary/5 ring-1 ring-primary", isSelected
isEnd && "border-accent bg-accent/5 ring-1 ring-accent", ? "bg-armed inset-shadow-armed"
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50" : "bg-card inset-shadow-hairline hover:bg-fields"
)} )}
> >
<span className="text-xs font-medium text-gray-800 max-w-40 truncate">{m.message}</span> <span className={clsx(
<span className="text-[11px] text-gray-400"> "flex h-[18px] w-[18px] items-center justify-center rounded-[5px] flex-shrink-0",
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })} isSelected ? "bg-accent text-on-accent" : "bg-inactive"
{isStart && <span className="text-primary font-medium"> · start</span>} )}>
{isEnd && <span className="text-accent font-medium"> · end</span>} {isSelected && <Check size={12} strokeWidth={3} />}
</span>
<span className="font-data text-[13px] text-accent tabular-nums flex-shrink-0">
{formatTimecode(offset)}
</span>
<span className="flex-1 font-body text-sm text-text-strong truncate">
{marker.message}
</span>
<span className="font-data text-[12px] text-faint-mono flex-shrink-0">
{toRelativeOffset(elapsedSeconds - offset)}
</span> </span>
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }} onClick={() => handleDeleteMarker(marker.id)}
title="Delete marker" title="Remove marker"
className="hidden group-hover:flex items-center justify-center absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white transition-colors duration-150" className="flex h-7 w-7 items-center justify-center rounded-chip text-faint-mono opacity-0 group-hover:opacity-100 hover:bg-chip hover:text-live-red transition-all duration-150"
> >
<X size={10} /> <X size={14} />
</button> </button>
</div> </div>
); );
})} })}
</div> </div>
)} </Card>
</Box> ) : (
<Card className="p-8 flex flex-col gap-2">
<p className={kickerClass}>Off air</p>
<h2 className="font-heading text-[27px] leading-[1.25]">Nothing is coming in right now.</h2>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty">
Point OBS at the ingest URL above with your stream key, and this panel fills
in — elapsed time, markers, and the range cutter.
</p>
</Card>
)}
</div>
{/* Forefront actions */} {/* ── Side panel ────────────────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch"> <aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
<Box className="p-6 flex flex-col gap-3"> {isStreaming && (
<CardHeader icon={Scissors} title="Clip the last few seconds" /> <>
<div className="flex flex-col gap-1.5"> <div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<label className={labelClass}>Duration (seconds)</label> <div className="flex flex-col gap-2">
<input <h2 className="font-heading text-[22px]">Make something from a range</h2>
type="number" <p className="font-body text-[13.5px] leading-[1.6] text-text-body">
min={1} {selected.length === 2 && rangeLength
value={clipDuration} ? `Two markers selected — ${inValue} to ${outValue}.`
onChange={(e) => setClipDuration(Number(e.target.value))} : "Tick two markers, or type a timecode by hand."}
className={inputClass} </p>
/> </div>
</div>
<input type="text" placeholder="Title" value={clipTitle} onChange={(e) => setClipTitle(e.target.value)} className={inputClass} />
<textarea placeholder="Description" value={clipDescription} onChange={(e) => setClipDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
{clipError && <p className="text-sm text-red-500">{clipError}</p>}
<BlueButton onClick={handleClip} className="self-start mt-auto">Clip</BlueButton>
</Box>
<Box className="p-6 flex flex-col gap-3"> <div className="grid grid-cols-2 gap-3">
<CardHeader icon={Film} title="Save between two markers" accent="accent" /> <div className="flex flex-col gap-1.5">
{markers.length < 2 ? ( <label className={labelClass} htmlFor="range-in">In</label>
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p> <input
) : ( id="range-in"
<> value={inValue}
<div className="flex items-center gap-2 flex-wrap"> onChange={(event) => setInValue(event.target.value)}
<span className={clsx( placeholder="00:00:00"
"text-xs font-medium px-2.5 py-1 rounded-full border", className={clsx(monoInputClass, "bg-card")}
startMarker ? "border-primary text-primary bg-primary/5" : "border-dashed border-gray-300 text-gray-400" />
)}>
{startMarker ? startMarker.message : "Tap a marker to set start"}
</span>
<span className="text-gray-300">→</span>
<span className={clsx(
"text-xs font-medium px-2.5 py-1 rounded-full border",
endMarker ? "border-accent text-accent bg-accent/5" : "border-dashed border-gray-300 text-gray-400"
)}>
{endMarker ? endMarker.message : "Tap a marker to set end"}
</span>
</div> </div>
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} /> <div className="flex flex-col gap-1.5">
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} /> <label className={labelClass} htmlFor="range-out">Out</label>
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>} <input
</> id="range-out"
)} value={outValue}
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton> onChange={(event) => setOutValue(event.target.value)}
</Box> placeholder="00:00:00"
</div> className={clsx(monoInputClass, "bg-card")}
/>
</div>
</div>
<Box className="p-5 flex flex-col gap-3"> <div className="flex items-baseline justify-between gap-3">
<CardHeader icon={Activity} title="Recent actions" /> <span className="font-body text-[13.5px] text-text-body">Range length</span>
{jobs.length === 0 ? ( <span className="font-data text-[15px] text-text-primary tabular-nums">
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p> {rangeLength ?? "—"}
) : ( </span>
<div className="flex flex-col gap-3"> </div>
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="range-name">Name</label>
<input
id="range-name"
value={rangeName}
onChange={(event) => setRangeName(event.target.value)}
placeholder="Give it a name"
className={clsx(inputClass, "bg-card")}
/>
</div>
{rangeError && <p className="font-body text-[13px] text-live-red">{rangeError}</p>}
<div className="flex flex-col gap-2.5">
<Button
onClick={handleSaveRange}
disabled={rangeBusy || rangeLength === null}
className="w-full py-3"
>
{rangeBusy ? "Queueing…" : "Save range as VOD"}
</Button>
<Button variant="ghost" onClick={clearSelection} className="w-full">
Clear selection
</Button>
</div>
</div> </div>
)}
</Box> <div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
</> <div className="flex flex-col gap-2">
)} <h2 className="font-heading text-[22px]">Cut a clip from just now</h2>
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
Takes the last stretch of stream, counted back from this moment.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="clip-duration">Seconds</label>
<input
id="clip-duration"
type="number"
min={1}
max={180}
value={clipDuration}
onChange={(event) => setClipDuration(Number(event.target.value))}
className={clsx(monoInputClass, "bg-card")}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelClass} htmlFor="clip-name">Name</label>
<input
id="clip-name"
value={clipName}
onChange={(event) => setClipName(event.target.value)}
placeholder="Optional"
className={clsx(inputClass, "bg-card")}
/>
</div>
</div>
{clipError && <p className="font-body text-[13px] text-live-red">{clipError}</p>}
<Button
variant="olive"
onClick={handleClip}
disabled={clipDuration < 1 || clipDuration > 180}
className="w-full py-3"
>
Cut the last {clipDuration}s as a clip
</Button>
</div>
</>
)}
{jobs.length > 0 && (
<div className="bg-panel rounded-panel shadow-panel p-6 flex flex-col gap-4">
<div className="flex items-baseline justify-between gap-3">
<p className={kickerClass}>Background jobs</p>
<p className="font-data text-[11px] text-faint-mono">{runningJobs} running</p>
</div>
{jobs.map((job) => <JobProgress key={job.uuid} job={job} />)}
</div>
)}
<div className="bg-panel rounded-panel p-6 flex flex-col gap-3">
<p className={kickerClass}>How it works</p>
<p className="font-body text-[14.5px] leading-[1.65] text-text-body text-pretty">
Tick two markers to fill the range automatically, or type a timecode by hand.
Saving queues a background job — you can keep streaming while it runs.
</p>
</div>
</aside>
</div>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,42 @@
import React, {createContext, useContext, useState, useEffect, type ReactNode} from 'react';
import { getUser } from "./api/users.ts";
import type {User} from "./types.ts";
// for what the Auth object will look like
interface AuthContextValue {
user: User | null;
setUser: React.Dispatch<React.SetStateAction<User | null>>;
loading: boolean;
}
interface AuthProviderProps {
children: ReactNode;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({children}: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
getUser()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
return (
<AuthContext.Provider value={{ user, setUser, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -18,6 +18,18 @@ const getCurrentStream = async (): Promise<StreamStatus> => {
return result.data; return result.data;
}; };
// The RTMP endpoint to point OBS at. Comes from the server so it stays right
// across environments rather than being baked into the bundle.
const getIngestUrl = async (): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/stream/metadata', { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch ingest URL: ${response.status}`);
const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch ingest URL: ${result.message}`);
return result.data.url;
};
const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => { const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> => {
const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' }); const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`); if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
@@ -28,4 +40,4 @@ const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> =>
return result.data; return result.data;
}; };
export { getStreamStatus, getCurrentStream, getStreamHistory }; export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory };

View File

@@ -0,0 +1,27 @@
// Shared class strings for the recurring bits of chrome. Anything used on more
// than one page lives here so the pages stay readable and stay in step.
/** Field well — no border, a soft terracotta ring on focus. */
const inputClass =
"bg-fields rounded-field w-full px-3 py-2.5 text-sm font-body text-text-strong " +
"placeholder:text-faint-mono focus:outline-none focus:inset-shadow-focus transition-shadow duration-150";
/** Same well, for timecodes and other machine facts. */
const monoInputClass = inputClass.replace("font-body", "font-data");
/** Field label — sits 6–10px above its field. */
const labelClass = "text-[11.5px] font-body text-muted";
/** Kicker / eyebrow above a page title or inside a muted panel. */
const kickerClass = "font-data text-[11px] uppercase tracking-[0.12em] text-muted";
/** Small chip button: copy, reveal, toggles. */
const chipButtonClass =
"bg-chip hover:bg-chip-hover text-text-control font-body text-[13px] rounded-chip " +
"px-3.5 py-2.5 transition-colors duration-150 whitespace-nowrap";
/** Quiet text link, used for back links and cancels. */
const quietLinkClass =
"font-body text-sm text-muted hover:text-text-strong transition-colors duration-150";
export { inputClass, monoInputClass, labelClass, kickerClass, chipButtonClass, quietLinkClass };

View File

@@ -32,6 +32,11 @@ type Clip = {
description: string, description: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
} }
type Vod = { type Vod = {
@@ -41,6 +46,11 @@ type Vod = {
description: string, description: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
width: number | null,
height: number | null,
fps: number | null,
// Bytes, as stored on disk.
fileSize: number | null,
} }
type JobResponse = { type JobResponse = {
@@ -55,6 +65,7 @@ type JobResponse = {
type StreamStatus = { type StreamStatus = {
isStreaming: boolean, isStreaming: boolean,
id: number | null, id: number | null,
startTime: string | null
} }
type StreamHistoryItem = { type StreamHistoryItem = {
@@ -72,6 +83,11 @@ type Marker = {
timestamp: string, timestamp: string,
} }
type StreamMetadata = {
url: string,
length: number
}
export type { export type {
APIResponse, APIResponse,
VideoMetadata, VideoMetadata,
@@ -81,5 +97,6 @@ export type {
JobResponse, JobResponse,
StreamStatus, StreamStatus,
StreamHistoryItem, StreamHistoryItem,
Marker Marker,
StreamMetadata
} }

View File

@@ -7,6 +7,45 @@ function formatTime(seconds: number): string {
return `${m}:${pad(s)}`; return `${m}:${pad(s)}`;
} }
// Sizes in MB/GB, as the design calls for. Input is bytes.
function formatFileSize(bytes: number): string {
const mb = bytes / 1_000_000;
if (mb >= 1000) return `${(mb / 1000).toFixed(1)} GB`;
if (mb >= 10) return `${Math.round(mb)} MB`;
return `${mb.toFixed(1)} MB`;
}
// Always hh:mm:ss, zero-padded — the shape used for stream timecodes.
function formatTimecode(seconds: number): string {
const safe = Math.max(0, Math.floor(seconds));
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(Math.floor(safe / 3600))}:${pad(Math.floor((safe % 3600) / 60))}:${pad(safe % 60)}`;
}
// Reads a typed timecode back into seconds. Accepts hh:mm:ss, mm:ss or ss, so a
// half-typed field doesn't reject the user mid-keystroke.
function parseTimecode(value: string): number | null {
const trimmed = value.trim();
if (!/^\d{1,2}(:\d{1,2}){0,2}$/.test(trimmed)) return null;
const parts = trimmed.split(':').map(Number);
if (parts.some(isNaN)) return null;
// Pad on the left so a bare "45" reads as seconds and "5:00" as minutes.
const [h, m, s] = [0, 0, 0].concat(parts).slice(-3);
if (m > 59 || s > 59) return null;
return h * 3600 + m * 60 + s;
}
// "83m ago" as the compact "−83m" used beside markers. Stays in minutes for the
// first few hours — that's the scale you actually think in mid-stream.
function toRelativeOffset(secondsAgo: number): string {
const m = Math.round(Math.max(0, secondsAgo) / 60);
if (m < 180) return `−${m}m`;
return `−${Math.floor(m / 60)}h${(m % 60).toString().padStart(2, '0')}`;
}
// Parses a UTC date string, appending Z if no timezone offset is present so // Parses a UTC date string, appending Z if no timezone offset is present so
// the browser always interprets the value as UTC rather than local time. // the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date { function stringToDate(dateString: string): Date {
@@ -40,4 +79,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`; return `${s} second${s !== 1 ? 's' : ''} ago`;
} }
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate }; export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate };

View File

@@ -116,7 +116,11 @@ public class ClipController {
clip.getTitle(), clip.getTitle(),
clip.getDescription(), clip.getDescription(),
clip.getDuration(), clip.getDuration(),
clip.getCreatedAt() clip.getCreatedAt(),
clip.getWidth(),
clip.getHeight(),
clip.getFps(),
clip.getFileSize()
); );
} }
} }

View File

@@ -1,14 +1,17 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.StreamMetadataResponse;
import com.ddf.vodsystem.controllers.dto.StreamResponse; import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService; import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -18,8 +21,21 @@ public class StreamController {
private static final Logger logger = LoggerFactory.getLogger(StreamController.class); private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
private static final String SUCCESS = "success"; private static final String SUCCESS = "success";
/** Matches the port and application name in nginx/default.conf.template. */
private static final int RTMP_PORT = 1935;
private static final String RTMP_APPLICATION = "live";
private final StreamService streamService; private final StreamService streamService;
@Value("${stream.ingest.url:}")
private String configuredIngestUrl;
@Value("${frontend.url:}")
private String frontendUrl;
@Value("${stream.max-length}")
private Float streamLength;
public StreamController(StreamService streamService) { public StreamController(StreamService streamService) {
this.streamService = streamService; this.streamService = streamService;
} }
@@ -53,13 +69,56 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
} }
@GetMapping("/metadata")
public ResponseEntity<APIResponse<StreamMetadataResponse>> getIngestEndpoint() {
return ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Stream information endpoint retrieved",
new StreamMetadataResponse(resolveIngestUrl(), streamLength)));
}
/**
* The RTMP endpoint to publish to. Set {@code STREAM_INGEST_URL} to state it outright —
* needed when the RTMP port is reached through a different host or a non-default port.
* Otherwise it is derived from the frontend's host, which is where docker-compose
* publishes the nginx-rtmp container.
*/
private String resolveIngestUrl() {
if (configuredIngestUrl != null && !configuredIngestUrl.isBlank()) {
return configuredIngestUrl.trim();
}
String host = hostOf(frontendUrl);
return "rtmp://" + (host == null ? "localhost" : host) + ":" + RTMP_PORT + "/" + RTMP_APPLICATION;
}
private static String hostOf(String url) {
if (url == null || url.isBlank()) {
return null;
}
String trimmed = url.trim();
try {
String host = URI.create(trimmed).getHost();
if (host != null && !host.isBlank()) {
return host;
}
} catch (IllegalArgumentException e) {
logger.warn("Could not parse frontend.url ({})", trimmed);
}
// A bare "example.net" or "example.net:8080" parses as a path, not a host.
String bare = trimmed.split("/", 2)[0].split(":", 2)[0];
return bare.isBlank() ? null : bare;
}
@GetMapping("/current") @GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() { public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream(); Optional<Stream> stream = streamService.getActiveStream();
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS, return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved", "Streaming status retrieved",
new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS, new StreamResponse(true, value.getId(), value.getStartDate())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved", "Streaming status retrieved",
new StreamResponse(false, null)))); new StreamResponse(false, null, null))));
} }
} }

View File

@@ -93,7 +93,11 @@ public class VodController {
vod.getTitle(), vod.getTitle(),
vod.getDescription(), vod.getDescription(),
vod.getDuration(), vod.getDuration(),
vod.getCreatedAt() vod.getCreatedAt(),
vod.getWidth(),
vod.getHeight(),
vod.getFps(),
vod.getFileSize()
); );
} }
} }

View File

@@ -8,5 +8,9 @@ public record ClipResponse(
String title, String title,
String description, String description,
Float duration, Float duration,
Instant createdAt Instant createdAt,
Integer width,
Integer height,
Float fps,
Long fileSize
) {} ) {}

View File

@@ -0,0 +1,4 @@
package com.ddf.vodsystem.controllers.dto;
/** Where OBS (or anything else speaking RTMP) should publish to. */
public record StreamMetadataResponse(String url, Float streamMaxLength) {}

View File

@@ -1,7 +1,10 @@
package com.ddf.vodsystem.controllers.dto; package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record StreamResponse( public record StreamResponse(
boolean isStreaming, boolean isStreaming,
Long id Long id,
Instant startTime
) { ) {
} }

View File

@@ -8,5 +8,9 @@ public record VodResponse(
String title, String title,
String description, String description,
Float duration, Float duration,
Instant createdAt Instant createdAt,
Integer width,
Integer height,
Float fps,
Long fileSize
) {} ) {}

View File

@@ -4,6 +4,7 @@ spring.application.name=vodSystem
spring.servlet.multipart.max-file-size=2GB spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB spring.servlet.multipart.max-request-size=2GB
stream.max-length=${STREAM_RING_LENGTH_SECOND} stream.max-length=${STREAM_RING_LENGTH_SECOND}
stream.ingest.url=${STREAM_INGEST_URL:}
# Database # Database
spring.datasource.url=${POSTGRES_URL} spring.datasource.url=${POSTGRES_URL}