WIP: feat(frontend): overhaul styling with warm and editorial look #12
@@ -18,3 +18,4 @@ FRONTEND_URL=
|
||||
BACKEND_URL=
|
||||
|
||||
STREAM_RING_LENGTH_SECOND=28800
|
||||
STREAM_INGEST_URL=
|
||||
@@ -3,8 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>The VoD System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
431
frontend/package-lock.json
generated
431
frontend/package-lock.json
generated
@@ -10,16 +10,11 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@react-oauth/google": "^0.12.2",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"flowbite-react": "^0.11.8",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.511.0",
|
||||
"path": "^0.12.7",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-range-slider-input": "^3.2.1",
|
||||
"react-router-dom": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36,8 +31,7 @@
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-mkcert": "^1.17.8"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -2459,9 +2453,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -2704,18 +2698,6 @@
|
||||
"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": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -2774,24 +2756,6 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -2812,9 +2776,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2868,19 +2832,6 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -2979,18 +2930,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz",
|
||||
@@ -3034,17 +2973,6 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -3118,15 +3046,6 @@
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -3137,32 +3056,6 @@
|
||||
"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": {
|
||||
"version": "1.5.396",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
|
||||
@@ -3184,51 +3077,6 @@
|
||||
"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": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -3634,42 +3482,6 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"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_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": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -3704,43 +3507,6 @@
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -3767,18 +3533,6 @@
|
||||
"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": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -3805,58 +3559,6 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3894,12 +3596,6 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3947,12 +3643,6 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -4388,15 +4078,6 @@
|
||||
"@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": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -4419,27 +4100,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": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -4564,16 +4224,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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4652,24 +4302,6 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4721,29 +4353,6 @@
|
||||
"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": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -5363,15 +4972,6 @@
|
||||
"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": {
|
||||
"version": "6.4.3",
|
||||
"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": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
||||
@@ -13,16 +13,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-oauth/google": "^0.12.2",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"flowbite-react": "^0.11.8",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.511.0",
|
||||
"path": "^0.12.7",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-range-slider-input": "^3.2.1",
|
||||
"react-router-dom": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,7 +34,6 @@
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-mkcert": "^1.17.8"
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
14
frontend/src/components/Card.tsx
Normal file
14
frontend/src/components/Card.tsx
Normal 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;
|
||||
@@ -9,7 +9,7 @@ type DropdownItemProps = {
|
||||
|
||||
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
|
||||
<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)}
|
||||
>
|
||||
{item}
|
||||
@@ -40,12 +40,12 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
|
||||
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
||||
<button
|
||||
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}
|
||||
</button>
|
||||
{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}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
38
frontend/src/components/GoogleSignIn.tsx
Normal file
38
frontend/src/components/GoogleSignIn.tsx
Normal 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;
|
||||
13
frontend/src/components/IsLivePulse.tsx
Normal file
13
frontend/src/components/IsLivePulse.tsx
Normal 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;
|
||||
57
frontend/src/components/JobProgress.tsx
Normal file
57
frontend/src/components/JobProgress.tsx
Normal 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 };
|
||||
17
frontend/src/components/NoAccessPage.tsx
Normal file
17
frontend/src/components/NoAccessPage.tsx
Normal 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;
|
||||
24
frontend/src/components/PageHeader.tsx
Normal file
24
frontend/src/components/PageHeader.tsx
Normal 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;
|
||||
@@ -5,11 +5,11 @@ type Props = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
// Label on the left, control on the right — the shape used for export settings.
|
||||
const Selector = ({ children, label }: Props) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-full text-sm text-gray-600">{label}</label>
|
||||
<div className="w-px h-5 bg-gray-200 mx-2" />
|
||||
{children}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="font-body text-[13px] text-text-body">{label}</label>
|
||||
<div className="w-32 flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,50 +1,90 @@
|
||||
import clsx from "clsx";
|
||||
import SidebarButton from "./buttons/SidebarButton.tsx";
|
||||
import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
|
||||
import type {User} from "../utils/types.ts";
|
||||
import { Plus, Film, Home, Video, Radio } from 'lucide-react';
|
||||
import {useAuth} from "../utils/AuthContext.tsx";
|
||||
import GoogleSignIn from "./GoogleSignIn.tsx";
|
||||
import {logout} from "../utils/api/users.ts";
|
||||
|
||||
type props = {
|
||||
user: User | null;
|
||||
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 (
|
||||
<aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
|
||||
<div className="px-4 pt-5 pb-3">
|
||||
<img
|
||||
className="w-36 mx-auto"
|
||||
src="../../public/logo.png"
|
||||
alt="VoD System Logo"
|
||||
/>
|
||||
</div>
|
||||
<nav className="flex flex-col flex-1 px-2 pb-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<aside className={clsx("h-screen flex flex-col bg-sidebar border-r border-hairline p-2 items-center gap-1", className)}>
|
||||
<h2 className={"font-heading text-3xl"}>The VoD System</h2>
|
||||
|
||||
<nav className="flex flex-col flex-1 items-center">
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<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>
|
||||
<Divider />
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div>
|
||||
<SidebarButton url="/stream" logo={<Radio size={18}/>} label="Stream" />
|
||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||
<SidebarButton url="/my-vods" logo={<Video size={18}/>} label="VoDs" />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<div className={"p-2"}>
|
||||
{user ? (
|
||||
<div>
|
||||
<Divider />
|
||||
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
|
||||
</>
|
||||
<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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
32
frontend/src/components/buttons/Button.tsx
Normal file
32
frontend/src/components/buttons/Button.tsx
Normal 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 };
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
import MenuButton from "./MenuButton.tsx";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
@@ -13,9 +13,15 @@ const SidebarButton = ({url, logo, label}: Props) => {
|
||||
|
||||
return (
|
||||
<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}
|
||||
</MenuButton>
|
||||
</button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,49 @@ import { type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
import Selector from "../Selector.tsx";
|
||||
import clsx from "clsx";
|
||||
import { inputClass, monoInputClass, kickerClass } from "../../utils/styles.ts";
|
||||
|
||||
type Props = {
|
||||
metadata: VideoMetadata;
|
||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||
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 [width, height] = e.target.value.split(",").map(Number);
|
||||
setMetadata(prev => ({ ...prev, width, height }));
|
||||
@@ -21,15 +55,19 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
|
||||
<div className={clsx("flex flex-col gap-4", className)}>
|
||||
<p className={kickerClass}>Export settings</p>
|
||||
|
||||
<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}>
|
||||
<option value="3840,2160">2160p</option>
|
||||
<option value="2560,1440">1440p</option>
|
||||
@@ -40,8 +78,8 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
||||
</select>
|
||||
</Selector>
|
||||
|
||||
<Selector label="FPS">
|
||||
<select id="fps" name="fps" defaultValue="30"
|
||||
<Selector label="Frames per second">
|
||||
<select id="fps" name="fps" value={String(metadata.fps)}
|
||||
onChange={updateFps} className={inputClass}>
|
||||
<option value="60">60</option>
|
||||
<option value="30">30</option>
|
||||
@@ -49,11 +87,35 @@ export default function ConfigBox({ setMetadata, className }: Props) {
|
||||
</select>
|
||||
</Selector>
|
||||
|
||||
<Selector label="File Size Limit (MB)">
|
||||
<input type="number" min="1" defaultValue="10"
|
||||
onChange={updateFileSize} className={inputClass}
|
||||
<Selector label="Size limit (MB)">
|
||||
<input type="number" min="1" value={sizeMb}
|
||||
onChange={updateFileSize} className={monoInputClass}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BlueButton from "../buttons/BlueButton.tsx";
|
||||
import React from "react";
|
||||
import Button from "../buttons/Button.tsx";
|
||||
import { kickerClass } from "../../utils/styles.ts";
|
||||
|
||||
type props = {
|
||||
dataSend: React.MouseEventHandler<HTMLButtonElement>;
|
||||
@@ -9,43 +10,44 @@ type props = {
|
||||
uploading: boolean;
|
||||
}
|
||||
|
||||
const Spinner = () => (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
// queued → reading source → encoding → writing file → complete
|
||||
const stageOf = (progress: number, uploading: boolean, downloadable: boolean): string => {
|
||||
if (downloadable) return "complete";
|
||||
if (!uploading) return "waiting";
|
||||
if (progress < 0.05) return "reading source";
|
||||
if (progress < 0.95) return "encoding";
|
||||
return "writing file";
|
||||
};
|
||||
|
||||
const ExportWidget = ({ dataSend, handleDownload, downloadable, progress, uploading }: props) => {
|
||||
return (
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
<BlueButton
|
||||
onClick={dataSend}
|
||||
disabled={uploading}
|
||||
className={uploading ? "opacity-60 cursor-not-allowed flex items-center justify-center gap-2" : ""}
|
||||
>
|
||||
{uploading && <Spinner />}
|
||||
{uploading ? "Uploading..." : "Export"}
|
||||
</BlueButton>
|
||||
const pct = downloadable ? 100 : Math.round(Math.min(1, Math.max(0, progress)) * 100);
|
||||
|
||||
<div className="h-10 flex items-center">
|
||||
{ downloadable ?
|
||||
(<BlueButton className="w-full" onClick={handleDownload}>
|
||||
Download
|
||||
</BlueButton>)
|
||||
:(
|
||||
<progress
|
||||
value={progress}
|
||||
className="w-full h-1.5 rounded bg-gray-200">
|
||||
</progress>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className={kickerClass}>Export</p>
|
||||
|
||||
<Button onClick={dataSend} disabled={uploading} className="w-full py-3">
|
||||
{uploading ? "Compressing…" : "Compress and save"}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-[3px] w-full rounded-full bg-chip overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ease-out ${downloadable ? "bg-olive-light" : "bg-accent-light"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{downloadable && (
|
||||
<Button variant="olive" className="w-full py-3" onClick={handleDownload}>
|
||||
Download the file
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
import { inputClass, labelClass } from "../../utils/styles.ts";
|
||||
|
||||
type Props = {
|
||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||
@@ -8,13 +9,14 @@ type Props = {
|
||||
};
|
||||
|
||||
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
||||
<div className={clsx("flex flex-col gap-2", className)}>
|
||||
<label className={labelClass} htmlFor="clip-title">Title</label>
|
||||
<input
|
||||
id="clip-title"
|
||||
type="text"
|
||||
placeholder="Enter title"
|
||||
placeholder="Cat walks across the patch bay"
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -48,25 +48,25 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
||||
}, [video]);
|
||||
|
||||
return (
|
||||
<div className={clsx("flex justify-between items-center mb-2", className)}>
|
||||
<div className="flex gap-1">
|
||||
<Icon size={24} />
|
||||
<div className={clsx("flex justify-between items-center mb-3", className)}>
|
||||
<div className="flex items-center gap-2 text-text-control">
|
||||
<Icon size={20} />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
onChange={updateVolume}
|
||||
className="w-20"
|
||||
className="w-20 accent-accent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
<span>
|
||||
<span className="font-data text-[13px] text-text-secondary tabular-nums">
|
||||
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
283
frontend/src/components/video/TrimTimeline.tsx
Normal file
283
frontend/src/components/video/TrimTimeline.tsx
Normal 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 };
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { isThumbnailAvailable } from "../../utils/api/client.ts";
|
||||
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
@@ -10,6 +10,11 @@ type VideoCardProps = {
|
||||
title: string,
|
||||
duration: number,
|
||||
createdAt: string,
|
||||
width?: number | null,
|
||||
height?: number | null,
|
||||
fps?: number | null,
|
||||
/** Size on disk, in bytes. */
|
||||
fileSize?: number | null,
|
||||
onEdit?: () => void,
|
||||
onDelete?: () => void,
|
||||
className?: string,
|
||||
@@ -18,23 +23,36 @@ type VideoCardProps = {
|
||||
itemLabel?: string,
|
||||
}
|
||||
|
||||
const fallbackThumbnail = "../../../public/default_thumbnail.png";
|
||||
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) => {
|
||||
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
|
||||
// Stand-in for a missing thumbnail: warm 135° stripes rather than a broken image.
|
||||
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 [menuOpen, setMenuOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [createdAt]);
|
||||
// VoDs read as 16/9 tiles with a serif title; clips are smaller 4/3 tiles.
|
||||
const isVod = itemLabel === 'vod';
|
||||
const slug = `${itemLabel}_${String(id).padStart(4, '0')}`;
|
||||
|
||||
// "27 Jul · 3.4 GB" — the two facts you actually scan a library by.
|
||||
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(() => {
|
||||
isThumbnailAvailable(thumbnailUrl)
|
||||
@@ -62,15 +80,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
|
||||
return (
|
||||
<Link to={`${playerPath}/${id}`}>
|
||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<div className={clsx(
|
||||
"group flex flex-col bg-card rounded-tile overflow-hidden shadow-card hover:shadow-card-hover transition-shadow duration-200",
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
className={clsx("relative", isVod ? "aspect-video" : "aspect-4/3")}
|
||||
style={thumbnailAvailable ? undefined : placeholders[isVod ? 'vod' : 'clip']}
|
||||
>
|
||||
{thumbnailAvailable && (
|
||||
<img
|
||||
src={thumbnailAvailable ? thumbnailUrl : fallbackThumbnail}
|
||||
alt="Video Thumbnail"
|
||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
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)}
|
||||
</span>
|
||||
|
||||
@@ -82,18 +113,18 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
>
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
|
||||
{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 && (<>
|
||||
{onEdit && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -101,7 +132,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
{onDelete && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -109,17 +140,17 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
</>)}
|
||||
{confirmDelete && (
|
||||
<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">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -132,11 +163,28 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col p-2">
|
||||
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
|
||||
{title === "" ? "(No Title)" : title}
|
||||
<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={clsx(
|
||||
"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 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>
|
||||
</Link>
|
||||
|
||||
@@ -1,37 +1,94 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "flowbite-react/plugin/tailwindcss";
|
||||
@source "../.flowbite-react/class-list.json";
|
||||
|
||||
@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 */
|
||||
--breakpoint-3xl: 1920px;
|
||||
/* ── Surfaces ────────────────────────────────────────────────────────── */
|
||||
--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);
|
||||
--color-primary-pressed: oklch(0.45 0.21 254);
|
||||
--color-secondary: oklch(0.94 0.01 250);
|
||||
--color-accent: oklch(0.68 0.20 288);
|
||||
--color-text: oklch(0.17 0.01 270);
|
||||
--color-background: oklch(0.97 0.005 250);
|
||||
--color-surface: oklch(1 0 0);
|
||||
--color-border: oklch(0.91 0.008 250);
|
||||
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
||||
--color-text-primary: #2B2620;
|
||||
--color-text-strong: #35302A;
|
||||
--color-text-body: #554D44;
|
||||
--color-text-control: #4A423A;
|
||||
--color-text-secondary: #6E6559;
|
||||
--color-text-table: #7A7166;
|
||||
--color-muted: #9A9084;
|
||||
--color-faint-mono: #A79C8E;
|
||||
--color-on-dark: #FBF8F2;
|
||||
--color-on-accent: #FDFBF7;
|
||||
|
||||
/* Easing */
|
||||
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
|
||||
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
|
||||
/* ── Accents ─────────────────────────────────────────────────────────── */
|
||||
--color-accent: #A85A38;
|
||||
--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;
|
||||
|
||||
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
||||
--color-hairline: #F0EBE2;
|
||||
--color-hover: #E7E0D2;
|
||||
--color-chip: #EAE3D7;
|
||||
--color-chip-hover: #DAD1C0;
|
||||
--color-ring-dashed: #E2DACB;
|
||||
--color-inactive: #DED7CA;
|
||||
--color-inactive-strong: #C4BAA9;
|
||||
|
||||
/* ── Elevation — soft shadows instead of borders ─────────────────────── */
|
||||
--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); }
|
||||
}
|
||||
|
||||
#range-slider .range-slider__range {
|
||||
background: var(--color-primary);
|
||||
@keyframes flashBadge {
|
||||
0% { opacity: 0; transform: scale(.75); }
|
||||
25% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
#range-slider .range-slider__thumb {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
: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;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
@@ -42,4 +99,12 @@
|
||||
|
||||
body {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,51 +1,16 @@
|
||||
// layout/MainLayout.jsx
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import Topbar from '../components/Topbar'
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import {useEffect, useState} from "react";
|
||||
import type {User} from "../utils/types";
|
||||
import { getUser } from "../utils/api/users";
|
||||
import { getStreamStatus } from "../utils/api/stream";
|
||||
import { useState } from "react";
|
||||
|
||||
const MainLayout = () => {
|
||||
const [sidebarToggled, setSidebarToggled] = 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]);
|
||||
const [sidebarToggled] = useState(false);
|
||||
|
||||
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
|
||||
user={user}
|
||||
className={`row-span-2 transition-all duration-300 overflow-hidden whitespace-nowrap ${sidebarToggled ? "-translate-x-full" : "translate-x-0"}`}/>
|
||||
<Topbar
|
||||
className={"transition-all duration-300"}
|
||||
sidebarToggled={sidebarToggled}
|
||||
setSidebarToggled={setSidebarToggled}
|
||||
user={user}
|
||||
fetchUser={fetchUser}
|
||||
isStreaming={isStreaming}/>
|
||||
<div className="overflow-auto bg-background">
|
||||
<div className="row-span-2 overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import './index.css'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import {AuthProvider} from "./utils/AuthContext.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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 PlaybackSlider from "./../components/video/PlaybackSlider";
|
||||
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
|
||||
import TrimTimeline, { type Range } from "../components/video/TrimTimeline.tsx";
|
||||
import ConfigBox from "../components/video/ConfigBox.tsx";
|
||||
import ExportWidget from "../components/video/ExportWidget.tsx";
|
||||
import { compress } from "../utils/api/media"
|
||||
import { getJob } from "../utils/api/jobs"
|
||||
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 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;
|
||||
|
||||
@@ -23,6 +25,7 @@ const ClipEdit = () => {
|
||||
|
||||
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
||||
const [playbackValue, setPlaybackValue] = useState(0);
|
||||
const [range, setRange] = useState<Range>([0, 5]);
|
||||
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
|
||||
title: "",
|
||||
description: "",
|
||||
@@ -58,8 +61,30 @@ const ClipEdit = () => {
|
||||
fps: 30,
|
||||
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 () => {
|
||||
if (!localFile || uploading) return;
|
||||
|
||||
@@ -79,7 +104,7 @@ const ClipEdit = () => {
|
||||
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)
|
||||
.then((job) => {
|
||||
setProgress(job.progress);
|
||||
@@ -124,52 +149,68 @@ const ClipEdit = () => {
|
||||
};
|
||||
|
||||
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">
|
||||
<PageHeader kicker="Upload" title="Trim and compress" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
|
||||
<div className="flex flex-col gap-[26px]">
|
||||
{/* Player — the video and its transport read as one object. */}
|
||||
<Card className="overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className={"w-full rounded-lg shadow-sm bg-black"}
|
||||
className="w-full aspect-video bg-player object-contain"
|
||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||
/>
|
||||
|
||||
<Box className={"w-4/5 h-full m-auto"}>
|
||||
<MetadataBox setMetadata={setOutputMetadata} />
|
||||
<ConfigBox setMetadata={setOutputMetadata} />
|
||||
</Box>
|
||||
|
||||
{metadata &&
|
||||
<Box className={"mt-4 p-5"}>
|
||||
<Playbar
|
||||
video={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
className={"w-full accent-primary"}
|
||||
className="w-full px-5 pt-3.5"
|
||||
/>
|
||||
}
|
||||
</Card>
|
||||
|
||||
{metadata &&
|
||||
<Card className="p-6">
|
||||
<div className="flex items-baseline justify-between gap-4 pb-4">
|
||||
<p className={kickerClass}>Trim</p>
|
||||
<p className="font-data text-[12px] text-faint-mono tabular-nums">
|
||||
in {formatTimecode(range[0])} · out {formatTimecode(range[1])} ·{" "}
|
||||
{formatTime(range[1] - range[0])} long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TrimTimeline
|
||||
duration={metadata.duration}
|
||||
range={range}
|
||||
onRangeChange={handleRangeChange}
|
||||
currentTime={playbackValue}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
|
||||
<PlaybackSlider
|
||||
videoRef={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
sliderValue={playbackValue}
|
||||
setSliderValue={setPlaybackValue}
|
||||
className={"w-full accent-primary"}
|
||||
/>
|
||||
|
||||
<ClipRangeSlider
|
||||
videoRef={videoRef.current}
|
||||
videoMetadata={metadata}
|
||||
setSliderValue={setPlaybackValue}
|
||||
setMetadata={setOutputMetadata}
|
||||
className={"w-full mt-2 bg-primary"}
|
||||
/>
|
||||
<p className="pt-3 font-body text-[12px] text-muted">
|
||||
Scroll over the timeline to zoom, drag the outlined window above to pan.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className={"text-red-600 text-center mt-2"}>
|
||||
{error}
|
||||
</div>
|
||||
<p className="font-body text-[13px] text-live-red pt-3">{error}</p>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
}
|
||||
</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">
|
||||
<Card className="p-6 flex flex-col gap-4">
|
||||
<p className={kickerClass}>Details</p>
|
||||
<MetadataBox setMetadata={setOutputMetadata} />
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<ConfigBox metadata={outputMetadata} setMetadata={setOutputMetadata} />
|
||||
</Card>
|
||||
|
||||
<div className="bg-panel rounded-panel shadow-panel p-6">
|
||||
<ExportWidget
|
||||
dataSend={sendData}
|
||||
handleDownload={handleDownload}
|
||||
@@ -177,7 +218,9 @@ const ClipEdit = () => {
|
||||
progress={progress}
|
||||
uploading={uploading}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,40 +1,89 @@
|
||||
import {useState} from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||
import Box from "../components/Box.tsx";
|
||||
import clsx from "clsx";
|
||||
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 [file, setFile] = useState<File | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
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 = () => {
|
||||
if (!file) {
|
||||
setError("Please choose a file");
|
||||
setError("Pick a file first.");
|
||||
return;
|
||||
}
|
||||
navigate('/create/new', { state: { file } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-center p-8 min-h-full">
|
||||
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
|
||||
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
|
||||
<div className="mx-auto max-w-180 px-13 pt-10 pb-18">
|
||||
<PageHeader kicker="Upload" title="Bring in a file" />
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const selected = e.target.files?.[0] ?? null;
|
||||
setFile(selected);
|
||||
setError(null);
|
||||
<label
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
choose(e.dataTransfer.files?.[0] ?? 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"
|
||||
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
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => choose(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
<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>}
|
||||
</Box>
|
||||
<div className="flex items-center gap-4 pt-6">
|
||||
<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>
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 Box from "../components/Box.tsx";
|
||||
import BlueButton from "../components/buttons/BlueButton.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 Card from "../components/Card.tsx";
|
||||
import Button from "../components/buttons/Button.tsx";
|
||||
import PageHeader from "../components/PageHeader.tsx";
|
||||
import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
|
||||
|
||||
const EditClip = () => {
|
||||
const { id } = useParams();
|
||||
@@ -47,59 +48,51 @@ const EditClip = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
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} />
|
||||
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
|
||||
<ArrowLeft size={15} />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
|
||||
<PageHeader kicker="Clip" title="Edit details" />
|
||||
|
||||
{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">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||
<Card className="p-7 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={labelClass} htmlFor="clip-title">Title</label>
|
||||
<input
|
||||
id="clip-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter title"
|
||||
placeholder="Cat walks across the patch bay"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={labelClass} htmlFor="clip-description">Description</label>
|
||||
<textarea
|
||||
id="clip-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter description"
|
||||
placeholder="What's worth remembering about it"
|
||||
rows={4}
|
||||
className={inputClass + " resize-none"}
|
||||
className={clsx(inputClass, "resize-none leading-[1.65]")}
|
||||
/>
|
||||
</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">
|
||||
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||
{saving ? "Saving…" : saved
|
||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||
: "Save"}
|
||||
</BlueButton>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={handleSave} disabled={saving} className="px-6">
|
||||
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 Box from "../components/Box.tsx";
|
||||
import BlueButton from "../components/buttons/BlueButton.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 Card from "../components/Card.tsx";
|
||||
import Button from "../components/buttons/Button.tsx";
|
||||
import PageHeader from "../components/PageHeader.tsx";
|
||||
import { inputClass, labelClass, quietLinkClass } from "../utils/styles.ts";
|
||||
|
||||
const EditVod = () => {
|
||||
const { id } = useParams();
|
||||
@@ -47,59 +48,51 @@ const EditVod = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
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} />
|
||||
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||
<button onClick={() => navigate(-1)} className={clsx(quietLinkClass, "flex items-center gap-1.5 pb-5")}>
|
||||
<ArrowLeft size={15} />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit VoD</h1>
|
||||
<PageHeader kicker="VoD" title="Edit details" />
|
||||
|
||||
{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">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Title</label>
|
||||
<Card className="p-7 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={labelClass} htmlFor="vod-title">Title</label>
|
||||
<input
|
||||
id="vod-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter title"
|
||||
placeholder="Tuesday night, patch bay walkthrough"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-gray-700">Description</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className={labelClass} htmlFor="vod-description">Description</label>
|
||||
<textarea
|
||||
id="vod-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter description"
|
||||
placeholder="What's worth remembering about it"
|
||||
rows={4}
|
||||
className={inputClass + " resize-none"}
|
||||
className={clsx(inputClass, "resize-none leading-[1.65]")}
|
||||
/>
|
||||
</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">
|
||||
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
|
||||
{saving ? "Saving…" : saved
|
||||
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
|
||||
: "Save"}
|
||||
</BlueButton>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={handleSave} disabled={saving} className="px-6">
|
||||
{saving ? "Saving…" : saved ? "Saved" : "Save changes"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>Cancel</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
icon: Upload,
|
||||
title: "Upload & compress",
|
||||
description: "Drop in a video file, set your clip range and export settings, then compress.",
|
||||
},
|
||||
{
|
||||
icon: Scissors,
|
||||
title: "Trim",
|
||||
description: "Set start and end points with a range slider. Preview as you go.",
|
||||
},
|
||||
{
|
||||
icon: SlidersHorizontal,
|
||||
title: "Export settings",
|
||||
description: "Choose resolution, frame rate, and an optional file size cap.",
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
title: "RTMP streaming",
|
||||
description: "Point OBS at your stream key and go live. Find the key in your profile.",
|
||||
},
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
title: "Clip library",
|
||||
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
|
||||
},
|
||||
{
|
||||
icon: Play,
|
||||
title: "Built-in player",
|
||||
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
|
||||
},
|
||||
];
|
||||
type TimeOfDay = "morning" | "afternoon" | "evening" | "night";
|
||||
|
||||
function getTimeOfDay(date: Date = new Date()): TimeOfDay {
|
||||
const hour = date.getHours();
|
||||
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 17) return "afternoon";
|
||||
if (hour >= 17 && hour < 22) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
function getGreetingSubtext(date: Date = new Date()): string {
|
||||
const timeOfDay = getTimeOfDay(date);
|
||||
|
||||
const messages: Record<TimeOfDay, string[]> = {
|
||||
morning: [
|
||||
"Good morning.",
|
||||
"Coffee first, then clips.",
|
||||
"Here's what's waiting for you.",
|
||||
],
|
||||
afternoon: [
|
||||
"Good afternoon.",
|
||||
"A quiet moment for your clips.",
|
||||
"Catching up?",
|
||||
],
|
||||
evening: [
|
||||
"Good evening.",
|
||||
"Settle in.",
|
||||
"Time to unwind.",
|
||||
],
|
||||
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 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 (
|
||||
<div className="flex flex-col items-center px-8 py-12 min-h-full">
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="VoD System Logo"
|
||||
className="h-24 mb-6 object-contain"
|
||||
/>
|
||||
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
|
||||
A home for your clips.
|
||||
</h1>
|
||||
<p className="text-base text-gray-500 leading-relaxed">
|
||||
Upload, trim, and export video clips. Stream live if you need to.
|
||||
<div className={"w-10/12 m-auto pt-10"}>
|
||||
<div className={"flex flex-row justify-between items-start pb-8"}>
|
||||
<div>
|
||||
<p className={"font-data text-faint-mono text-sm tracking-widest"}>{formatted.toLocaleUpperCase()}</p>
|
||||
<h1 className={"font-heading font text-5xl text-text-strong tracking-[-0.015em]"}>{greeting}</h1>
|
||||
</div>
|
||||
|
||||
<Link to={"/stream"} className={"bg-accent rounded-md text-hover text-sm p-2 hover:bg-accent-hover shadow-md"}>
|
||||
Open Stream Panel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className={clsx("p-8 mb-8", isStreaming ? "bg-live-card" : "bg-card")}>
|
||||
<div className={"flex flex-row items-center gap-2"}>
|
||||
{isStreaming
|
||||
? <IsLivePulse />
|
||||
: <span className={"h-2 w-2 rounded-full bg-inactive-strong"} />}
|
||||
<p className={clsx(
|
||||
"text-xs font-data tracking-[0.12em]",
|
||||
isStreaming ? "text-live-red" : "text-faint-mono"
|
||||
)}>
|
||||
{isStreaming ? "LIVE NOW" : "OFF AIR"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
|
||||
{features.map(({ icon: Icon, title, description }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Icon size={18} className="text-primary" />
|
||||
<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>
|
||||
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
|
||||
|
||||
<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 className={"mt-4 flex items-center gap-4"}>
|
||||
<Button onClick={handleDropMarker} disabled={markerBusy}>+ Drop marker</Button>
|
||||
{markerNote && <p className={"font-data text-[12px] text-muted"}>{markerNote}</p>}
|
||||
</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>
|
||||
|
||||
{/* Format note */}
|
||||
<p className="text-xs text-gray-400">
|
||||
Supports MP4, MOV, WebM, and OGG.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
108
frontend/src/pages/Landing.tsx
Normal file
108
frontend/src/pages/Landing.tsx
Normal 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;
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 Box from "../components/Box.tsx";
|
||||
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
|
||||
import Card from "../components/Card.tsx";
|
||||
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.
|
||||
type MediaItem = {
|
||||
title: string,
|
||||
createdAt: string,
|
||||
width?: number | null,
|
||||
height?: number | null,
|
||||
fps?: number | null,
|
||||
/** Size on disk, in bytes. */
|
||||
fileSize?: number | null,
|
||||
};
|
||||
|
||||
type MediaPlayerProps = {
|
||||
@@ -53,12 +60,15 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
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 ────────────────────────────────────────────────────────
|
||||
// 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(() => {
|
||||
if (!id) { setError(`${noun} ID is required.`); return; }
|
||||
|
||||
@@ -68,6 +78,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
if (e instanceof AuthError) setNotAuthenticated(true);
|
||||
else setError(`Failed to load ${noun.toLowerCase()} details.`);
|
||||
});
|
||||
|
||||
}, [id, noun, fetchDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +100,6 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
scheduleHide();
|
||||
}, [scheduleHide]);
|
||||
|
||||
// ── Fullscreen ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
@@ -119,11 +129,13 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.max(0, video.currentTime - 5);
|
||||
showFlash('back');
|
||||
showControls();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||
showFlash('forward');
|
||||
showControls();
|
||||
break;
|
||||
case 'KeyM':
|
||||
@@ -137,7 +149,15 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
};
|
||||
document.addEventListener('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 ────────────────────────────────────────────────
|
||||
const seekTo = useCallback((clientX: number) => {
|
||||
@@ -191,29 +211,40 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
showFlash(video.paused ? 'play' : 'pause');
|
||||
video.paused ? video.play() : video.pause();
|
||||
showControls();
|
||||
};
|
||||
|
||||
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const hoverPct = hoverTime !== null && duration > 0 ? (hoverTime / duration) * 100 : 0;
|
||||
const effectiveVolume = isMuted ? 0 : volume;
|
||||
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) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
|
||||
<div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Lock size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">Not authenticated</p>
|
||||
<p className="text-sm text-gray-500 mt-1">You don't have access to this {noun.toLowerCase()}.</p>
|
||||
</div>
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-center p-8">
|
||||
<Lock className="w-8 h-8 text-faint-mono/60 mb-1" strokeWidth={1.5} />
|
||||
<h2 className="font-heading text-[27px] text-faint-mono">Not yours to watch</h2>
|
||||
<p className="font-body text-sm text-faint-mono/70 max-w-xs text-pretty">
|
||||
You don't have access to this {noun.toLowerCase()}.
|
||||
</p>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@@ -221,19 +252,19 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
</button>
|
||||
|
||||
{/* Player */}
|
||||
<div
|
||||
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}
|
||||
onMouseLeave={() => isPlaying && setControlsVisible(false)}
|
||||
onClick={togglePlay}
|
||||
@@ -269,10 +300,33 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
</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 */}
|
||||
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{/* Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
|
||||
{/* Gradients — top one carries the title, bottom one the controls */}
|
||||
<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 */}
|
||||
<div
|
||||
@@ -284,13 +338,35 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
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"
|
||||
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-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
|
||||
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)` }}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Buttons row */}
|
||||
@@ -317,7 +393,7 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
</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)}
|
||||
</span>
|
||||
|
||||
@@ -331,18 +407,26 @@ const MediaPlayer = ({ noun, getMediaUrl, fetchDetails }: MediaPlayerProps) => {
|
||||
</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 && (
|
||||
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
|
||||
Loading video...
|
||||
</div>
|
||||
<p className="font-data text-[12px] text-faint-mono">loading video…</p>
|
||||
)}
|
||||
|
||||
<Box className="p-4 flex-shrink-0">
|
||||
<p className="text-xl font-semibold text-gray-900">{item?.title || "(No Title)"}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
|
||||
</Box>
|
||||
<Card className="p-5 shrink-0 flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
|
||||
<div className="min-w-0">
|
||||
<p className={kickerClass}>{noun}</p>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import VideoCard from "../components/video/VideoCard";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import NoAccessPage from "../components/NoAccessPage";
|
||||
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 { useAuth } from "../utils/AuthContext";
|
||||
import type { Clip } from "../utils/types";
|
||||
|
||||
const MyClips = () => {
|
||||
const { user, loading } = useAuth();
|
||||
const [clips, setClips] = useState<Clip[]>([]);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getClips()
|
||||
.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) => {
|
||||
try {
|
||||
@@ -24,11 +29,16 @@ const MyClips = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
|
||||
if (loading) return null;
|
||||
if (!user) return <NoAccessPage />;
|
||||
|
||||
<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) => (
|
||||
<VideoCard
|
||||
key={clip.id}
|
||||
@@ -36,6 +46,10 @@ const MyClips = () => {
|
||||
title={clip.title}
|
||||
duration={clip.duration}
|
||||
createdAt={clip.createdAt}
|
||||
width={clip.width}
|
||||
height={clip.height}
|
||||
fps={clip.fps}
|
||||
fileSize={clip.fileSize}
|
||||
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
||||
onDelete={() => handleDelete(clip.id)}
|
||||
/>
|
||||
@@ -43,13 +57,17 @@ const MyClips = () => {
|
||||
</div>
|
||||
|
||||
{clips.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<p className="text-base">No clips yet.</p>
|
||||
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
|
||||
<h2 className="font-heading text-[25px]">No clips yet.</h2>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import VideoCard from "../components/video/VideoCard";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import NoAccessPage from "../components/NoAccessPage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getVods, deleteVod } from "../utils/api/vods";
|
||||
import { useAuth } from "../utils/AuthContext";
|
||||
import type { Vod } from "../utils/types";
|
||||
|
||||
const MyVods = () => {
|
||||
const { user, loading } = useAuth();
|
||||
const [vods, setVods] = useState<Vod[]>([]);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getVods()
|
||||
.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"));
|
||||
}, []);
|
||||
}, [user]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
@@ -24,11 +29,16 @@ const MyVods = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My VoDs</h1>
|
||||
if (loading) return null;
|
||||
if (!user) return <NoAccessPage />;
|
||||
|
||||
<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) => (
|
||||
<VideoCard
|
||||
key={vod.id}
|
||||
@@ -36,6 +46,10 @@ const MyVods = () => {
|
||||
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="/vod"
|
||||
itemLabel="vod"
|
||||
@@ -46,13 +60,15 @@ const MyVods = () => {
|
||||
</div>
|
||||
|
||||
{vods.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<p className="text-base">No VoDs yet.</p>
|
||||
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
||||
<div className="flex flex-col items-center justify-center gap-1.5 py-20 text-center">
|
||||
<h2 className="font-heading text-[25px]">Nothing saved yet.</h2>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,84 +1,68 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Eye, EyeOff, Copy, Check } from "lucide-react";
|
||||
import Box from "../components/Box.tsx";
|
||||
import { getUser } from "../utils/api/users.ts";
|
||||
import type { User } from "../utils/types.ts";
|
||||
import { useState } from "react";
|
||||
import Card from "../components/Card.tsx";
|
||||
import PageHeader from "../components/PageHeader.tsx";
|
||||
import NoAccessPage from "../components/NoAccessPage.tsx";
|
||||
import { useAuth } from "../utils/AuthContext.tsx";
|
||||
import { formatLocalDate } from "../utils/utils.ts";
|
||||
import { labelClass, chipButtonClass } from "../utils/styles.ts";
|
||||
|
||||
const Profile = () => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const { user, loading } = useAuth();
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getUser().then(setUser).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!user?.streamKey) return;
|
||||
navigator.clipboard.writeText(user.streamKey);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-full text-gray-400 text-base">
|
||||
Please log in to view your profile.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return null;
|
||||
if (!user) return <NoAccessPage />;
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10 max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
|
||||
<div className="mx-auto max-w-[720px] px-13 pt-10 pb-18">
|
||||
<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
|
||||
src={user.profilePictureUrl}
|
||||
referrerPolicy="no-referrer"
|
||||
alt="Profile picture"
|
||||
className="w-20 h-20 rounded-full shadow-sm"
|
||||
alt=""
|
||||
className="w-[72px] h-[72px] rounded-full shadow-card"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">{user.name}</p>
|
||||
<p className="text-sm text-gray-500">@{user.username}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Member since {formatLocalDate(user.createdAt)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="font-heading text-[25px] leading-tight">{user.name}</p>
|
||||
<p className="font-body text-[13px] text-text-secondary">@{user.username} · {user.email}</p>
|
||||
<p className="font-data text-[11px] text-faint-mono pt-1">
|
||||
member since {formatLocalDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Box className="p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Use this key in OBS or any RTMP-compatible software to start streaming.
|
||||
Keep it private — anyone with this key can stream to your account.
|
||||
<Card className="p-7">
|
||||
<h2 className="font-heading text-[22px] pb-1.5">Stream key</h2>
|
||||
<p className="font-body text-[14.5px] leading-[1.65] text-text-body max-w-[62ch] text-pretty pb-5">
|
||||
Point OBS — or anything else that speaks RTMP — at the ingest URL with this key.
|
||||
Keep it private: anyone holding it can stream to your account.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
|
||||
<p className={labelClass}>Key</p>
|
||||
<div className="flex items-center gap-2.5 pt-2">
|
||||
<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>
|
||||
|
||||
<button
|
||||
onClick={() => setKeyVisible(v => !v)}
|
||||
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 className={chipButtonClass} onClick={() => setKeyVisible(visible => !visible)}>
|
||||
{keyVisible ? "Hide" : "Reveal"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
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 className={chipButtonClass} onClick={handleCopy}>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,94 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Box from "../components/Box.tsx";
|
||||
import BlueButton from "../components/buttons/BlueButton.tsx";
|
||||
import { getUser } from "../utils/api/users.ts";
|
||||
import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
|
||||
|
||||
import Card from "../components/Card.tsx";
|
||||
import Button from "../components/buttons/Button.tsx";
|
||||
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 { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
|
||||
import { saveSection, clipSection } from "../utils/api/media.ts";
|
||||
import { getJob } from "../utils/api/jobs.ts";
|
||||
import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
|
||||
import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
|
||||
import type { Marker, StreamStatus } from "../utils/types.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";
|
||||
const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
|
||||
/** Ten-minute gridlines behind the marker ticks. */
|
||||
const GRID_INTERVAL_SECONDS = 600;
|
||||
|
||||
type TrackedJob = {
|
||||
uuid: string;
|
||||
label: string;
|
||||
progress: number;
|
||||
state: JobResponse['state'];
|
||||
errorOutput: string | null;
|
||||
const useCopy = () => {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const copy = (key: string, text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied((current) => (current === key ? null : current)), 1600);
|
||||
};
|
||||
|
||||
const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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")}>
|
||||
<Icon size={16} className={accent === "primary" ? "text-primary" : "text-accent"} />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-gray-800">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
const JobStatus = ({ job }: { job: TrackedJob }) => {
|
||||
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>
|
||||
);
|
||||
return { copied, copy };
|
||||
};
|
||||
|
||||
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 [streamDetails, setStreamDetails] = useState<StreamHistoryItem | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||
const [markerMessage, setMarkerMessage] = useState("");
|
||||
const [markerBusy, setMarkerBusy] = useState(false);
|
||||
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 [clipTitle, setClipTitle] = useState("");
|
||||
const [clipDescription, setClipDescription] = useState("");
|
||||
const [clipName, setClipName] = useState("");
|
||||
const [clipError, setClipError] = useState<string | null>(null);
|
||||
|
||||
const [startMarkerId, setStartMarkerId] = useState<string>("");
|
||||
const [endMarkerId, setEndMarkerId] = useState<string>("");
|
||||
const [markerSaveTitle, setMarkerSaveTitle] = useState("");
|
||||
const [markerSaveDescription, setMarkerSaveDescription] = useState("");
|
||||
const [markerSaveError, setMarkerSaveError] = useState<string | null>(null);
|
||||
|
||||
const [keyVisible, setKeyVisible] = useState(false);
|
||||
const [ingestUrl, setIngestUrl] = useState<string | null>(null);
|
||||
const [jobs, setJobs] = useState<TrackedJob[]>([]);
|
||||
|
||||
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(() => {
|
||||
getUser().then(setUser).catch(() => setUser(null));
|
||||
}, []);
|
||||
|
||||
// ── Polling ─────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
|
||||
@@ -98,24 +76,18 @@ const StreamControl = () => {
|
||||
}, [user]);
|
||||
|
||||
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);
|
||||
return () => clearInterval(interval);
|
||||
}, [streamDetails?.startDate]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getIngestUrl().then(setIngestUrl).catch(() => setIngestUrl(null));
|
||||
}, [user]);
|
||||
|
||||
const refreshMarkers = () => {
|
||||
getMarkers()
|
||||
.then((data) => setMarkers(data
|
||||
.then((data) => setMarkers([...data]
|
||||
.sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
|
||||
.catch(() => {});
|
||||
};
|
||||
@@ -123,6 +95,7 @@ const StreamControl = () => {
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
setMarkers([]);
|
||||
setSelected([]);
|
||||
return;
|
||||
}
|
||||
refreshMarkers();
|
||||
@@ -130,268 +103,495 @@ const StreamControl = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, [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(() => {
|
||||
const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
|
||||
if (pending.length === 0) return;
|
||||
if (!pendingKey) return;
|
||||
const uuids = pendingKey.split(",");
|
||||
|
||||
const interval = setInterval(() => {
|
||||
pending.forEach((job) => {
|
||||
getJob(job.uuid)
|
||||
.then((result) => {
|
||||
setJobs((prev) => prev.map((j) => j.uuid === job.uuid
|
||||
? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
|
||||
: j));
|
||||
})
|
||||
uuids.forEach((uuid) => {
|
||||
getJob(uuid)
|
||||
.then((result) => setJobs((prev) => prev.map((job) => job.uuid === uuid
|
||||
? { ...job, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
|
||||
: job)))
|
||||
.catch(() => {});
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [jobs]);
|
||||
}, [pendingKey]);
|
||||
|
||||
const addJob = (uuid: string, label: string) => {
|
||||
setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
|
||||
// ── Marker helpers ──────────────────────────────────────────────────────
|
||||
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 () => {
|
||||
if (!markerMessage.trim() || markerBusy) return;
|
||||
if (markerBusy) return;
|
||||
setMarkerBusy(true);
|
||||
setMarkerError(null);
|
||||
try {
|
||||
await createMarker(markerMessage.trim());
|
||||
await createMarker(markerMessage.trim() || `Marker at ${elapsed}`);
|
||||
setMarkerMessage("");
|
||||
refreshMarkers();
|
||||
} catch (err) {
|
||||
setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
|
||||
setMarkerError(err instanceof Error ? err.message : "Failed to drop the marker");
|
||||
} finally {
|
||||
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) => {
|
||||
try {
|
||||
await deleteMarker(id);
|
||||
if (startMarkerId === String(id)) setStartMarkerId("");
|
||||
if (endMarkerId === String(id)) setEndMarkerId("");
|
||||
setSelected((prev) => prev.filter((current) => current !== id));
|
||||
refreshMarkers();
|
||||
} 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 idStr = String(id);
|
||||
setMarkerSaveError(null);
|
||||
if (startMarkerId === idStr) { setStartMarkerId(""); return; }
|
||||
if (endMarkerId === idStr) { setEndMarkerId(""); return; }
|
||||
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)
|
||||
const inSeconds = parseTimecode(inValue);
|
||||
const outSeconds = parseTimecode(outValue);
|
||||
// In/Out are timecodes (hh:mm:ss); the length between them is a duration (h:mm:ss).
|
||||
const rangeLength = inSeconds !== null && outSeconds !== null && outSeconds > inSeconds
|
||||
? formatTime(outSeconds - inSeconds)
|
||||
: null;
|
||||
|
||||
const startMarker = markers.find((m) => String(m.id) === startMarkerId);
|
||||
const endMarker = markers.find((m) => String(m.id) === endMarkerId);
|
||||
const handleSaveRange = async () => {
|
||||
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 (
|
||||
<div className="px-8 py-10 max-w-5xl mx-auto flex flex-col gap-5">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Stream Control</h1>
|
||||
<div className="mx-auto max-w-[1180px] px-13 pt-10 pb-18">
|
||||
<PageHeader kicker="Ingest" title="Stream panel" />
|
||||
|
||||
{/* Hero status bar */}
|
||||
<Box className={clsx(
|
||||
"p-6 flex items-center justify-between gap-6 border-l-4",
|
||||
isStreaming ? "border-l-red-500" : "border-l-gray-200"
|
||||
)}>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<span className="relative flex h-3.5 w-3.5">
|
||||
{isStreaming && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />}
|
||||
<span className={clsx("relative inline-flex rounded-full h-3.5 w-3.5", isStreaming ? "bg-red-500" : "bg-gray-300")} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-lg leading-tight">{isStreaming ? "Live" : "Offline"}</p>
|
||||
{isStreaming && streamDetails?.startDate ? (
|
||||
<p className="text-sm text-gray-500">Started {formatLocalDate(streamDetails.startDate)}</p>
|
||||
) : !isStreaming ? (
|
||||
<p className="text-sm text-gray-500">Point OBS at your stream key (see Profile) to go live.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isStreaming && elapsed ? (
|
||||
<div className="text-right">
|
||||
<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 className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] gap-[26px] items-start">
|
||||
{/* ── Main column ───────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-[26px]">
|
||||
<Card className="p-7">
|
||||
<div className="flex items-baseline justify-between gap-4 pb-5">
|
||||
<h2 className="font-heading text-[22px]">Server credentials</h2>
|
||||
{isStreaming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<IsLivePulse />
|
||||
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-live-red">Live now</p>
|
||||
</div>
|
||||
) : (
|
||||
<Radio size={28} className="text-gray-200" />
|
||||
<p className="font-data text-[11px] uppercase tracking-[0.12em] text-faint-mono">Off air</p>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
<p className={labelClass}>Ingest URL</p>
|
||||
<div className="flex items-center gap-2.5 pt-2">
|
||||
<input
|
||||
type="text"
|
||||
value={markerMessage}
|
||||
onChange={(e) => setMarkerMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
|
||||
placeholder="What just happened?"
|
||||
className={inputClass}
|
||||
readOnly
|
||||
value={ingestUrl ?? ""}
|
||||
placeholder="asking the server…"
|
||||
className={clsx(monoInputClass, "flex-1")}
|
||||
/>
|
||||
<BlueButton onClick={handleAddMarker} disabled={markerBusy || !markerMessage.trim()} className="whitespace-nowrap">
|
||||
Add Marker
|
||||
</BlueButton>
|
||||
</div>
|
||||
{markerError && <p className="text-sm text-red-500">{markerError}</p>}
|
||||
|
||||
{markers.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-0.5 -mx-1 px-1 pt-1.5">
|
||||
{markers.map((m) => {
|
||||
const isStart = startMarkerId === String(m.id);
|
||||
const isEnd = endMarkerId === String(m.id);
|
||||
return (
|
||||
<div key={m.id} className="group relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleChipClick(m.id)}
|
||||
className={clsx(chipButtonClass, !ingestUrl && "opacity-40 cursor-not-allowed")}
|
||||
disabled={!ingestUrl}
|
||||
onClick={() => ingestUrl && copy("url", ingestUrl)}
|
||||
>
|
||||
{copied === "url" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className={clsx(labelClass, "pt-5 block")}>Stream key</p>
|
||||
<div className="flex items-center gap-2.5 pt-2">
|
||||
<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>
|
||||
<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 (
|
||||
<span
|
||||
key={marker.id}
|
||||
title={marker.message}
|
||||
className={clsx(
|
||||
"flex flex-col items-start gap-0.5 rounded-lg border px-3 py-1.5 text-left transition-colors duration-150",
|
||||
isStart && "border-primary bg-primary/5 ring-1 ring-primary",
|
||||
isEnd && "border-accent bg-accent/5 ring-1 ring-accent",
|
||||
!isStart && !isEnd && "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
"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
|
||||
onClick={() => toggleMarker(marker.id)}
|
||||
className={clsx(
|
||||
"flex flex-1 items-center gap-4 rounded-tile px-4 py-3 text-left transition-colors duration-150 min-w-0",
|
||||
isSelected
|
||||
? "bg-armed inset-shadow-armed"
|
||||
: "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="text-[11px] text-gray-400">
|
||||
{formatLocalDate(m.timestamp, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||
{isStart && <span className="text-primary font-medium"> · start</span>}
|
||||
{isEnd && <span className="text-accent font-medium"> · end</span>}
|
||||
<span className={clsx(
|
||||
"flex h-[18px] w-[18px] items-center justify-center rounded-[5px] flex-shrink-0",
|
||||
isSelected ? "bg-accent text-on-accent" : "bg-inactive"
|
||||
)}>
|
||||
{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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteMarker(m.id); }}
|
||||
title="Delete 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"
|
||||
onClick={() => handleDeleteMarker(marker.id)}
|
||||
title="Remove marker"
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* Forefront actions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-stretch">
|
||||
<Box className="p-6 flex flex-col gap-3">
|
||||
<CardHeader icon={Scissors} title="Clip the last few seconds" />
|
||||
{/* ── Side panel ────────────────────────────────────────── */}
|
||||
<aside className="flex flex-col gap-[26px] lg:sticky lg:top-10">
|
||||
{isStreaming && (
|
||||
<>
|
||||
<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]">Make something from a range</h2>
|
||||
<p className="font-body text-[13.5px] leading-[1.6] text-text-body">
|
||||
{selected.length === 2 && rangeLength
|
||||
? `Two markers selected — ${inValue} to ${outValue}.`
|
||||
: "Tick two markers, or type a timecode by hand."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelClass}>Duration (seconds)</label>
|
||||
<label className={labelClass} htmlFor="range-in">In</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={clipDuration}
|
||||
onChange={(e) => setClipDuration(Number(e.target.value))}
|
||||
className={inputClass}
|
||||
id="range-in"
|
||||
value={inValue}
|
||||
onChange={(event) => setInValue(event.target.value)}
|
||||
placeholder="00:00:00"
|
||||
className={clsx(monoInputClass, "bg-card")}
|
||||
/>
|
||||
</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">
|
||||
<CardHeader icon={Film} title="Save between two markers" accent="accent" />
|
||||
{markers.length < 2 ? (
|
||||
<p className="text-sm text-gray-400">Add at least two markers above to save a section between them.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={clsx(
|
||||
"text-xs font-medium px-2.5 py-1 rounded-full border",
|
||||
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 className="flex flex-col gap-1.5">
|
||||
<label className={labelClass} htmlFor="range-out">Out</label>
|
||||
<input
|
||||
id="range-out"
|
||||
value={outValue}
|
||||
onChange={(event) => setOutValue(event.target.value)}
|
||||
placeholder="00:00:00"
|
||||
className={clsx(monoInputClass, "bg-card")}
|
||||
/>
|
||||
</div>
|
||||
<input type="text" placeholder="Title" value={markerSaveTitle} onChange={(e) => setMarkerSaveTitle(e.target.value)} className={inputClass} />
|
||||
<textarea placeholder="Description" value={markerSaveDescription} onChange={(e) => setMarkerSaveDescription(e.target.value)} rows={2} className={inputClass + " resize-none"} />
|
||||
{markerSaveError && <p className="text-sm text-red-500">{markerSaveError}</p>}
|
||||
</>
|
||||
)}
|
||||
<BlueButton onClick={handleSaveByMarkers} disabled={markers.length < 2} className="self-start mt-auto">Save</BlueButton>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Box className="p-5 flex flex-col gap-3">
|
||||
<CardHeader icon={Activity} title="Recent actions" />
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Clip and save actions will show up here.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{jobs.map((job) => <JobStatus key={job.uuid} job={job} />)}
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-body text-[13.5px] text-text-body">Range length</span>
|
||||
<span className="font-data text-[15px] text-text-primary tabular-nums">
|
||||
{rangeLength ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
42
frontend/src/utils/AuthContext.tsx
Normal file
42
frontend/src/utils/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -18,6 +18,18 @@ const getCurrentStream = async (): Promise<StreamStatus> => {
|
||||
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 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}`);
|
||||
@@ -28,4 +40,4 @@ const getStreamHistory = async (userId: number): Promise<StreamHistoryItem[]> =>
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export { getStreamStatus, getCurrentStream, getStreamHistory };
|
||||
export { getStreamStatus, getCurrentStream, getIngestUrl, getStreamHistory };
|
||||
|
||||
27
frontend/src/utils/styles.ts
Normal file
27
frontend/src/utils/styles.ts
Normal 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 };
|
||||
@@ -32,6 +32,11 @@ type Clip = {
|
||||
description: string,
|
||||
duration: number,
|
||||
createdAt: string,
|
||||
width: number | null,
|
||||
height: number | null,
|
||||
fps: number | null,
|
||||
// Bytes, as stored on disk.
|
||||
fileSize: number | null,
|
||||
}
|
||||
|
||||
type Vod = {
|
||||
@@ -41,6 +46,11 @@ type Vod = {
|
||||
description: string,
|
||||
duration: number,
|
||||
createdAt: string,
|
||||
width: number | null,
|
||||
height: number | null,
|
||||
fps: number | null,
|
||||
// Bytes, as stored on disk.
|
||||
fileSize: number | null,
|
||||
}
|
||||
|
||||
type JobResponse = {
|
||||
@@ -55,6 +65,7 @@ type JobResponse = {
|
||||
type StreamStatus = {
|
||||
isStreaming: boolean,
|
||||
id: number | null,
|
||||
startTime: string | null
|
||||
}
|
||||
|
||||
type StreamHistoryItem = {
|
||||
@@ -72,6 +83,11 @@ type Marker = {
|
||||
timestamp: string,
|
||||
}
|
||||
|
||||
type StreamMetadata = {
|
||||
url: string,
|
||||
length: number
|
||||
}
|
||||
|
||||
export type {
|
||||
APIResponse,
|
||||
VideoMetadata,
|
||||
@@ -81,5 +97,6 @@ export type {
|
||||
JobResponse,
|
||||
StreamStatus,
|
||||
StreamHistoryItem,
|
||||
Marker
|
||||
Marker,
|
||||
StreamMetadata
|
||||
}
|
||||
@@ -7,6 +7,45 @@ function formatTime(seconds: number): string {
|
||||
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
|
||||
// the browser always interprets the value as UTC rather than local time.
|
||||
function stringToDate(dateString: string): Date {
|
||||
@@ -40,4 +79,4 @@ function dateToTimeAgo(date: Date): string {
|
||||
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
|
||||
export { formatTime, formatTimecode, formatFileSize, parseTimecode, toRelativeOffset, stringToDate, dateToTimeAgo, formatLocalDate };
|
||||
|
||||
@@ -116,7 +116,11 @@ public class ClipController {
|
||||
clip.getTitle(),
|
||||
clip.getDescription(),
|
||||
clip.getDuration(),
|
||||
clip.getCreatedAt()
|
||||
clip.getCreatedAt(),
|
||||
clip.getWidth(),
|
||||
clip.getHeight(),
|
||||
clip.getFps(),
|
||||
clip.getFileSize()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package com.ddf.vodsystem.controllers;
|
||||
|
||||
import com.ddf.vodsystem.controllers.dto.StreamMetadataResponse;
|
||||
import com.ddf.vodsystem.controllers.dto.StreamResponse;
|
||||
import com.ddf.vodsystem.dto.APIResponse;
|
||||
import com.ddf.vodsystem.entities.Stream;
|
||||
import com.ddf.vodsystem.services.StreamService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -18,8 +21,21 @@ public class StreamController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
|
||||
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;
|
||||
|
||||
@Value("${stream.ingest.url:}")
|
||||
private String configuredIngestUrl;
|
||||
|
||||
@Value("${frontend.url:}")
|
||||
private String frontendUrl;
|
||||
|
||||
@Value("${stream.max-length}")
|
||||
private Float streamLength;
|
||||
|
||||
public StreamController(StreamService streamService) {
|
||||
this.streamService = streamService;
|
||||
}
|
||||
@@ -53,13 +69,56 @@ public class StreamController {
|
||||
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")
|
||||
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
|
||||
Optional<Stream> stream = streamService.getActiveStream();
|
||||
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
|
||||
"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",
|
||||
new StreamResponse(false, null))));
|
||||
new StreamResponse(false, null, null))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,11 @@ public class VodController {
|
||||
vod.getTitle(),
|
||||
vod.getDescription(),
|
||||
vod.getDuration(),
|
||||
vod.getCreatedAt()
|
||||
vod.getCreatedAt(),
|
||||
vod.getWidth(),
|
||||
vod.getHeight(),
|
||||
vod.getFps(),
|
||||
vod.getFileSize()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,9 @@ public record ClipResponse(
|
||||
String title,
|
||||
String description,
|
||||
Float duration,
|
||||
Instant createdAt
|
||||
Instant createdAt,
|
||||
Integer width,
|
||||
Integer height,
|
||||
Float fps,
|
||||
Long fileSize
|
||||
) {}
|
||||
@@ -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) {}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.ddf.vodsystem.controllers.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record StreamResponse(
|
||||
boolean isStreaming,
|
||||
Long id
|
||||
Long id,
|
||||
Instant startTime
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -8,5 +8,9 @@ public record VodResponse(
|
||||
String title,
|
||||
String description,
|
||||
Float duration,
|
||||
Instant createdAt
|
||||
Instant createdAt,
|
||||
Integer width,
|
||||
Integer height,
|
||||
Float fps,
|
||||
Long fileSize
|
||||
) {}
|
||||
|
||||
@@ -4,6 +4,7 @@ spring.application.name=vodSystem
|
||||
spring.servlet.multipart.max-file-size=2GB
|
||||
spring.servlet.multipart.max-request-size=2GB
|
||||
stream.max-length=${STREAM_RING_LENGTH_SECOND}
|
||||
stream.ingest.url=${STREAM_INGEST_URL:}
|
||||
|
||||
# Database
|
||||
spring.datasource.url=${POSTGRES_URL}
|
||||
|
||||
Reference in New Issue
Block a user