openvibers/openvibe.live Commits

Make channel sections reactive and browseable (#730)

OP OpenVibers committed 4 days ago · 01c72b6d90 · 59 files, +1233 −162

* Unify channel header actions and metadata

* Make channel sections reactive and browseable

* Footer test: the live state's claim sits a few more lines down (wider window)

* Merge remote-tracking branch 'origin/main' into live-channel-sections-1109

Changes

ddiff --git a/docs/screenshots/channel-header-1109/following-1280-dark.png b/docs/screenshots/channel-header-1109/following-1280-dark.png
nnew file mode 100644
iindex 00000000..e865cd81
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/following-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/following-1280-light.png b/docs/screenshots/channel-header-1109/following-1280-light.png
nnew file mode 100644
iindex 00000000..78d54cb8
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/following-1280-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/following-390-dark.png b/docs/screenshots/channel-header-1109/following-390-dark.png
nnew file mode 100644
iindex 00000000..f86ad870
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/following-390-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/following-390-light.png b/docs/screenshots/channel-header-1109/following-390-light.png
nnew file mode 100644
iindex 00000000..5f89a7a7
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/following-390-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/guest-1280-dark.png b/docs/screenshots/channel-header-1109/guest-1280-dark.png
nnew file mode 100644
iindex 00000000..a0b135f7
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/guest-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/guest-1280-light.png b/docs/screenshots/channel-header-1109/guest-1280-light.png
nnew file mode 100644
iindex 00000000..5e112d25
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/guest-1280-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/guest-390-dark.png b/docs/screenshots/channel-header-1109/guest-390-dark.png
nnew file mode 100644
iindex 00000000..fb82f2e3
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/guest-390-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/guest-390-light.png b/docs/screenshots/channel-header-1109/guest-390-light.png
nnew file mode 100644
iindex 00000000..a2511f5d
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/guest-390-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/live-1280-dark.png b/docs/screenshots/channel-header-1109/live-1280-dark.png
nnew file mode 100644
iindex 00000000..cb272905
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/live-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/live-1280-light.png b/docs/screenshots/channel-header-1109/live-1280-light.png
nnew file mode 100644
iindex 00000000..fde3abda
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/live-1280-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/live-390-dark.png b/docs/screenshots/channel-header-1109/live-390-dark.png
nnew file mode 100644
iindex 00000000..e4d4f82f
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/live-390-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/live-390-light.png b/docs/screenshots/channel-header-1109/live-390-light.png
nnew file mode 100644
iindex 00000000..511d60b2
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/live-390-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/signed-in-1280-dark.png b/docs/screenshots/channel-header-1109/signed-in-1280-dark.png
nnew file mode 100644
iindex 00000000..03b837c3
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/signed-in-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/signed-in-1280-light.png b/docs/screenshots/channel-header-1109/signed-in-1280-light.png
nnew file mode 100644
iindex 00000000..ee9d177c
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/signed-in-1280-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/signed-in-390-dark.png b/docs/screenshots/channel-header-1109/signed-in-390-dark.png
nnew file mode 100644
iindex 00000000..85ce3e60
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/signed-in-390-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/signed-in-390-light.png b/docs/screenshots/channel-header-1109/signed-in-390-light.png
nnew file mode 100644
iindex 00000000..e6057410
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/signed-in-390-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/staff-1280-dark.png b/docs/screenshots/channel-header-1109/staff-1280-dark.png
nnew file mode 100644
iindex 00000000..ab2487c0
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/staff-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/staff-1280-light.png b/docs/screenshots/channel-header-1109/staff-1280-light.png
nnew file mode 100644
iindex 00000000..da4bb325
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/staff-1280-light.png differ
ddiff --git a/docs/screenshots/channel-header-1109/staff-390-dark.png b/docs/screenshots/channel-header-1109/staff-390-dark.png
nnew file mode 100644
iindex 00000000..f004ce7b
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/staff-390-dark.png differ
ddiff --git a/docs/screenshots/channel-header-1109/staff-390-light.png b/docs/screenshots/channel-header-1109/staff-390-light.png
nnew file mode 100644
iindex 00000000..d83b9f99
BBinary files /dev/null and b/docs/screenshots/channel-header-1109/staff-390-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/following-1280-dark.png b/docs/screenshots/channel-sections-1109/following-1280-dark.png
nnew file mode 100644
iindex 00000000..d0bc6d9a
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/following-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/following-1280-light.png b/docs/screenshots/channel-sections-1109/following-1280-light.png
nnew file mode 100644
iindex 00000000..1428ab05
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/following-1280-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/following-390-dark.png b/docs/screenshots/channel-sections-1109/following-390-dark.png
nnew file mode 100644
iindex 00000000..fd22879f
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/following-390-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/following-390-light.png b/docs/screenshots/channel-sections-1109/following-390-light.png
nnew file mode 100644
iindex 00000000..bd970338
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/following-390-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/guest-1280-dark.png b/docs/screenshots/channel-sections-1109/guest-1280-dark.png
nnew file mode 100644
iindex 00000000..b0ed0fc9
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/guest-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/guest-1280-light.png b/docs/screenshots/channel-sections-1109/guest-1280-light.png
nnew file mode 100644
iindex 00000000..6695901d
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/guest-1280-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/guest-320-dark.png b/docs/screenshots/channel-sections-1109/guest-320-dark.png
nnew file mode 100644
iindex 00000000..230aa697
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/guest-320-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/guest-390-dark.png b/docs/screenshots/channel-sections-1109/guest-390-dark.png
nnew file mode 100644
iindex 00000000..e4817a4d
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/guest-390-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/guest-390-light.png b/docs/screenshots/channel-sections-1109/guest-390-light.png
nnew file mode 100644
iindex 00000000..6f8e8021
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/guest-390-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/owner-1280-dark.png b/docs/screenshots/channel-sections-1109/owner-1280-dark.png
nnew file mode 100644
iindex 00000000..e2d246fb
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/owner-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/owner-1280-light.png b/docs/screenshots/channel-sections-1109/owner-1280-light.png
nnew file mode 100644
iindex 00000000..327095a7
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/owner-1280-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/owner-390-dark.png b/docs/screenshots/channel-sections-1109/owner-390-dark.png
nnew file mode 100644
iindex 00000000..481e75f1
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/owner-390-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/owner-390-light.png b/docs/screenshots/channel-sections-1109/owner-390-light.png
nnew file mode 100644
iindex 00000000..b98b2570
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/owner-390-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/staff-1280-dark.png b/docs/screenshots/channel-sections-1109/staff-1280-dark.png
nnew file mode 100644
iindex 00000000..34c0ed1d
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/staff-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/staff-1280-light.png b/docs/screenshots/channel-sections-1109/staff-1280-light.png
nnew file mode 100644
iindex 00000000..d1f1b7e8
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/staff-1280-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/staff-390-dark.png b/docs/screenshots/channel-sections-1109/staff-390-dark.png
nnew file mode 100644
iindex 00000000..a8fc4103
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/staff-390-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/staff-390-light.png b/docs/screenshots/channel-sections-1109/staff-390-light.png
nnew file mode 100644
iindex 00000000..df095fc2
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/staff-390-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/tabs-1280-dark.png b/docs/screenshots/channel-sections-1109/tabs-1280-dark.png
nnew file mode 100644
iindex 00000000..79ec88c6
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/tabs-1280-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/tabs-1280-light.png b/docs/screenshots/channel-sections-1109/tabs-1280-light.png
nnew file mode 100644
iindex 00000000..5187aef9
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/tabs-1280-light.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/tabs-320-dark.png b/docs/screenshots/channel-sections-1109/tabs-320-dark.png
nnew file mode 100644
iindex 00000000..3e2fdd93
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/tabs-320-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/tabs-390-dark.png b/docs/screenshots/channel-sections-1109/tabs-390-dark.png
nnew file mode 100644
iindex 00000000..cb83c41c
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/tabs-390-dark.png differ
ddiff --git a/docs/screenshots/channel-sections-1109/tabs-390-light.png b/docs/screenshots/channel-sections-1109/tabs-390-light.png
nnew file mode 100644
iindex 00000000..df27cf04
BBinary files /dev/null and b/docs/screenshots/channel-sections-1109/tabs-390-light.png differ
ddiff --git a/package-lock.json b/package-lock.json
iindex ba4bf4a3..121ffdaf 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,6 +14,7 @@
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
++ "esbuild": "^0.28.2",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"geoip-lite": "^2.0.0",
@@@ -65,6 +66,422 @@
"tslib": "^2.4.0"
}
},
++ "node_modules/@esbuild/aix-ppc64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
++ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
++ "cpu": [
++ "ppc64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "aix"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/android-arm": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
++ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
++ "cpu": [
++ "arm"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "android"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/android-arm64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
++ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "android"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/android-x64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
++ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "android"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/darwin-arm64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
++ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/darwin-x64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
++ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/freebsd-arm64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
++ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "freebsd"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/freebsd-x64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
++ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "freebsd"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-arm": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
++ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
++ "cpu": [
++ "arm"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-arm64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
++ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-ia32": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
++ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
++ "cpu": [
++ "ia32"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-loong64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
++ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
++ "cpu": [
++ "loong64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-mips64el": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
++ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
++ "cpu": [
++ "mips64el"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-ppc64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
++ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
++ "cpu": [
++ "ppc64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-riscv64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
++ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
++ "cpu": [
++ "riscv64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-s390x": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
++ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
++ "cpu": [
++ "s390x"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/linux-x64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
++ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/netbsd-arm64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
++ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "netbsd"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/netbsd-x64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
++ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "netbsd"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/openbsd-arm64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
++ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "openbsd"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/openbsd-x64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
++ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "openbsd"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/openharmony-arm64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
++ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "openharmony"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/sunos-x64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
++ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "sunos"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/win32-arm64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
++ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/win32-ia32": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
++ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
++ "cpu": [
++ "ia32"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
++ "node_modules/@esbuild/win32-x64": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
++ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MIT",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": ">=18"
++ }
++ },
"node_modules/@fidm/asn1": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz",
@@@ -1543,6 +1960,47 @@
"node": ">= 0.4"
}
},
++ "node_modules/esbuild": {
++ "version": "0.28.2",
++ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
++ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "bin": {
++ "esbuild": "bin/esbuild"
++ },
++ "engines": {
++ "node": ">=18"
++ },
++ "optionalDependencies": {
++ "@esbuild/aix-ppc64": "0.28.2",
++ "@esbuild/android-arm": "0.28.2",
++ "@esbuild/android-arm64": "0.28.2",
++ "@esbuild/android-x64": "0.28.2",
++ "@esbuild/darwin-arm64": "0.28.2",
++ "@esbuild/darwin-x64": "0.28.2",
++ "@esbuild/freebsd-arm64": "0.28.2",
++ "@esbuild/freebsd-x64": "0.28.2",
++ "@esbuild/linux-arm": "0.28.2",
++ "@esbuild/linux-arm64": "0.28.2",
++ "@esbuild/linux-ia32": "0.28.2",
++ "@esbuild/linux-loong64": "0.28.2",
++ "@esbuild/linux-mips64el": "0.28.2",
++ "@esbuild/linux-ppc64": "0.28.2",
++ "@esbuild/linux-riscv64": "0.28.2",
++ "@esbuild/linux-s390x": "0.28.2",
++ "@esbuild/linux-x64": "0.28.2",
++ "@esbuild/netbsd-arm64": "0.28.2",
++ "@esbuild/netbsd-x64": "0.28.2",
++ "@esbuild/openbsd-arm64": "0.28.2",
++ "@esbuild/openbsd-x64": "0.28.2",
++ "@esbuild/openharmony-arm64": "0.28.2",
++ "@esbuild/sunos-x64": "0.28.2",
++ "@esbuild/win32-arm64": "0.28.2",
++ "@esbuild/win32-ia32": "0.28.2",
++ "@esbuild/win32-x64": "0.28.2"
++ }
++ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
ddiff --git a/package.json b/package.json
iindex 0315be0b..d0d5a3fc 100644
---- a/package.json
++++ b/package.json
@@@ -33,6 +33,7 @@
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
++ "esbuild": "^0.28.2",
"express": "^4.21.0",
"express-rate-limit": "^7.4.0",
"geoip-lite": "^2.0.0",
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 29ae91b9..1b2ec181 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1748,13 +1748,13 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
counts (it follows the section in view); long lists ride sideways on a phone; AI timeline and Analytics are counted
folds. Scoped to the channel: the dashboard's settings tabs keep their tabs. */
#page-channel .channel-below-fold > .container { display: flex; flex-direction: column; }
--#page-channel #ch-tabs { order: 0; position: sticky; top: var(--ovnav-h, 56px); z-index: 6; margin: 0 0 16px; background: var(--bg-primary); }
++#page-channel .ch-tabs-wrap { order: 0; position: sticky; top: var(--ovnav-h, 56px); z-index: 6; margin: 0 0 16px; background: var(--bg-primary); min-width: 0; }
/* The sticky section bar (owner 11-08: "the empty space gap above these when they are fixed like this is ugly"):
it follows the navbar. While the bar is there it sits under it; when a scroll down slides the navbar away it rises to
the top of the screen with the navbar's own easing, so no strip of page shows above it. It runs edge to edge (no
page showing down the sides), is frosted, and once it is stuck (app-channel.js sets .is-stuck) it lifts with a soft
shadow and an accent hairline. */
--#page-channel #ch-tabs {
++#page-channel .ch-tabs-wrap {
top: var(--ovnav-h, 56px);
transition: top .45s cubic-bezier(.2, .7, .2, 1), box-shadow .25s ease, border-color .25s ease;
margin-inline: calc(-1 * clamp(16px, 2vw, 28px));
@@@ -1763,23 +1763,65 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
-webkit-backdrop-filter: saturate(1.4) blur(14px);
backdrop-filter: saturate(1.4) blur(14px);
}
--html:has(.openvibe-navbar.ovnav-hide) #page-channel #ch-tabs { top: 0; padding-top: env(safe-area-inset-top, 0px); }
--#page-channel #ch-tabs.is-stuck {
++html:has(.openvibe-navbar.ovnav-hide) #page-channel .ch-tabs-wrap { top: 0; padding-top: env(safe-area-inset-top, 0px); }
++#page-channel .ch-tabs-wrap:has(#ch-tabs.is-stuck) {
border-bottom-color: color-mix(in srgb, var(--accent) 28%, var(--border));
box-shadow: 0 12px 20px -16px rgba(0, 0, 0, .7);
}
@media (max-width: 768px) {
/* The phone's gutter is the container's 16 px plus the fold's own 8 px. */
-- #page-channel #ch-tabs { margin-inline: -24px; padding-inline: 24px; }
++ #page-channel .ch-tabs-wrap { margin-inline: -24px; padding-inline: 24px; }
++}
++@media (prefers-reduced-motion: reduce) { #page-channel .ch-tabs-wrap { transition: none; } }
++html[data-ov-motion="reduced"] #page-channel .ch-tabs-wrap { transition: none; }
++#page-channel .ch-tabs-wrap #ch-tabs { margin: 0; scroll-padding-inline: 14px; }
++#page-channel .ch-tabs-arrow { display: none; position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); color: var(--text-primary); cursor: pointer; }
++#page-channel .ch-tabs-arrow[data-dir="prev"] { left: 0; }
++#page-channel .ch-tabs-arrow[data-dir="next"] { right: 0; }
++#page-channel .ch-tabs-arrow:disabled { opacity: .35; cursor: default; }
++@media (min-width: 761px) {
++ #page-channel .ch-tabs-wrap.has-overflow { padding-inline: 40px; }
++ #page-channel .ch-tabs-wrap.has-overflow .ch-tabs-arrow { display: grid; place-items: center; }
}
--@media (prefers-reduced-motion: reduce) { #page-channel #ch-tabs { transition: none; } }
--html[data-ov-motion="reduced"] #page-channel #ch-tabs { transition: none; }
#page-channel .ch-tab-panel { display: block; order: 50; margin: 0 0 30px; animation: none; scroll-margin-top: calc(var(--ovnav-h, 56px) + 56px); }
#page-channel .ch-tab-panel[hidden] { display: none; }
#page-channel #ch-panel-about { order: 1; } #page-channel #ch-panel-videos { order: 2; } #page-channel #ch-panel-clips { order: 3; }
#page-channel #ch-panel-agents { order: 4; } #page-channel #ch-panel-clips-taken { order: 5; } #page-channel #ch-panel-pastes { order: 6; }
#page-channel #ch-panel-media { order: 7; } #page-channel #ch-panel-ai-timeline { order: 8; } #page-channel #ch-panel-analytics { order: 9; }
#page-channel .ch-sec-h { margin: 0 0 10px; font-size: 1.12rem; font-weight: 800; letter-spacing: -.01em; }
++#page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: 12px; row-gap: 8px; }
++#page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-sec-h { grid-column: 1; grid-row: 1; align-self: center; margin: 0; }
++#page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar { grid-column: 2; grid-row: 1; display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 6px; min-width: 0; }
++#page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > :not(.ch-sec-h, .ch-panel-bar) { grid-column: 1 / -1; }
++#page-channel .ch-view-all { display: inline-flex; align-items: center; min-height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-card); color: var(--text-primary); font-size: .78rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
++#page-channel .ch-view-all:hover { background: var(--bg-hover); }
++#page-channel .ch-rail-arrow { display: inline-grid; place-items: center; width: 36px; height: 36px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-card); color: var(--text-primary); cursor: pointer; }
++#page-channel .ch-rail-arrow[hidden] { display: none; }
++#page-channel .ch-rail-arrow:disabled { opacity: .35; cursor: default; }
++@media (max-width: 760px) { #page-channel .ch-rail-arrow { display: none; } }
++@media (max-width: 600px) {
++ #page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) { grid-template-columns: minmax(0, 1fr); }
++ #page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar { grid-column: 1; grid-row: 2; justify-content: flex-start; }
++ #page-channel .ch-view-all { min-height: 44px; }
++}
++#page-channel .ch-tab-panel :is(.stream-grid, .pastes-grid) { display: grid; grid-auto-flow: column; grid-template-columns: none; grid-auto-columns: minmax(220px, 25%); gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x proximity; padding-bottom: 8px; scrollbar-width: thin; min-width: 0; }
++#page-channel .ch-tab-panel :is(.stream-grid, .pastes-grid) > * { scroll-snap-align: start; min-width: 0; }
++#page-channel .ch-tab-panel .pastes-grid { grid-auto-columns: minmax(220px, 28%); }
++@media (max-width: 760px) { #page-channel .ch-tab-panel :is(.stream-grid, .pastes-grid) { grid-auto-columns: minmax(220px, 78%); } }
++#page-channel .ch-rail-skeleton { min-height: 150px; border-radius: 12px; background: linear-gradient(110deg, var(--bg-card) 25%, var(--bg-hover) 50%, var(--bg-card) 75%); background-size: 200% 100%; animation: ch-shimmer 1.5s linear infinite; }
++@keyframes ch-shimmer { to { background-position-x: -200%; } }
++@media (prefers-reduced-motion: reduce) { #page-channel .ch-rail-skeleton { animation: none; } }
++html[data-ov-motion="reduced"] #page-channel .ch-rail-skeleton { animation: none; }
++.ch-list-page { background: var(--bg-primary); color: var(--text-primary); }
++.ch-list-content { max-width: 960px; margin: 0 auto; padding: clamp(20px, 4vw, 48px); }
++.ch-list-content > p:first-child a { color: var(--accent); text-decoration: none; }
++.ch-list-content h1 { margin: 12px 0 24px; }
++.ch-list-content ol { display: grid; gap: 10px; padding: 0; list-style: none; }
++.ch-list-content li { padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); }
++.ch-list-content li a { display: block; color: var(--text-primary); font-weight: 700; }
++.ch-list-content li time { display: block; margin-top: 5px; color: var(--text-muted); font-size: .82rem; }
++.ch-list-content .pagination { display: flex; align-items: center; gap: 14px; margin-top: 24px; }
++.ch-list-content .pagination a { color: var(--accent); }
#page-channel .ch-tab-panel > .ch-panel-hint:first-of-type { margin-top: -4px; }
#page-channel .ch-fold > summary { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 6px 14px; cursor: pointer; list-style: none;
border: 1px solid var(--border); border-radius: 12px; background: color-mix(in srgb, var(--text-primary) 3%, transparent); }
@@@ -1800,42 +1842,108 @@ html[data-ov-motion="reduced"] #page-channel #ch-tabs { transition: none; }
}
@media (prefers-reduced-motion: reduce) { #page-channel .ch-tab-panel { scroll-margin-top: calc(var(--ovnav-h, 56px) + 56px); } }
--/* The stream's actions are one family (owner 11-08, phone screenshot: "ugly and cut off … make it consistent and nice
-- looking"): Share, Ask your agent, Follow, Still and Ban are pills of one height, one type size and one icon size.
-- Follow is the solid one; Following is quiet (it read as a disabled grey button); Ban is a red-tinted one. */
--#page-channel .ch-info-bar-right { gap: 8px; }
--#page-channel .ch-info-bar-right :is(#ch-share-slot, #ch-ask-slot) { display: contents; }
--#page-channel .ch-info-bar-right :is(#ch-btn-share, .ovk-ask, #ch-btn-follow, #ch-btn-ban, #btn-cam) {
++/* Shared channel identity, chips, actions, and More menu. */
++#page-channel :is(.ch-info-bar-text, .ch-offline-header-info) { min-width: 0; }
++#page-channel .ch-offline-header > .ch-avatar { align-self: flex-start; margin-top: 4px; }
++#page-channel :is(.ch-info-bar, .ch-offline-header) .ch-username { display: block; margin: 2px 0 4px; color: var(--text-muted); font-size: .78rem; }
++#page-channel :is(.ch-info-bar, .ch-offline-header) .ch-meta { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin: 3px 0; }
++#page-channel :is(.ch-info-bar, .ch-offline-header) .ch-meta > :is(.ch-follower-count, .ch-alert-chip, .ch-category-badge, .ch-lang-chip, .ch-game-badge) {
++ display: inline-flex; align-items: center; min-height: 36px; box-sizing: border-box; padding: 4px 10px;
++ border: 1px solid var(--border); border-radius: 9px; background: var(--bg-secondary);
++ color: var(--text-primary); font-size: .75rem; font-weight: 600; line-height: 1.2; white-space: nowrap;
++}
++#page-channel .ch-meta > .ch-alert-chip { gap: 6px; text-decoration: none; }
++#page-channel .ch-meta > .ch-alert-chip:hover { background: var(--bg-hover); }
++#page-channel .ch-meta > .ch-category-badge { order: 1; }
++#page-channel .ch-meta > .ch-game-badge { order: 2; }
++#page-channel :is(.ch-info-bar, .ch-offline-header) .ch-meta > [hidden] { display: none; }
++#page-channel .ch-meta .ch-category-badge.is-inferred .cat-inferred { color: var(--text-muted); }
++#page-channel :is(.ch-info-bar, .ch-offline-header) .ch-social-pill { min-height: 36px; box-sizing: border-box; border-radius: 9px; background: var(--bg-secondary); border-color: var(--border); color: var(--text-primary); font-weight: 600; }
++#page-channel :is(.ch-info-bar, .ch-offline-header) .ch-social-pill:hover { background: var(--bg-hover); color: var(--text-primary); transform: none; }
++#page-channel .ch-social-more { min-height: 36px; border: 1px solid var(--border); border-radius: 9px; padding: 0 10px; background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; }
++#page-channel .ch-social-pill[hidden] { display: none; }
++#page-channel .ch-social-sheet { width: min(380px, calc(100vw - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); color: var(--text-primary); }
++#page-channel .ch-social-sheet::backdrop { background: rgba(0,0,0,.65); }
++#page-channel .ch-social-sheet-links { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
++#page-channel .ch-presence { margin: 3px 0 7px; color: var(--text-secondary); font-size: .82rem; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++#page-channel .ch-presence.is-live { color: var(--danger-text, #eb5963); font-weight: 700; }
++#page-channel .ch-presence-dot { color: var(--text-muted); font-size: .75em; }
++#page-channel .ch-presence a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
++#page-channel .ch-usual-schedule { display: block; margin: 3px 0 0 10px; color: var(--text-muted); }
++#page-channel .ch-presence[hidden] { display: none; }
++#page-channel .ch-alert-chip[hidden] { display: none; }
++#page-channel .ch-social-pills[hidden] { display: none; }
++@media (max-width: 700px) {
++ #page-channel .ch-offline-header { display: grid; grid-template-columns: 64px minmax(0, 1fr); align-items: start; column-gap: 14px; row-gap: 5px; }
++ #page-channel .ch-offline-header-info { display: contents; }
++ #page-channel .ch-offline-header > .ch-avatar { grid-column: 1; grid-row: 1 / span 3; }
++ #page-channel .ch-offline-header-info > h1 { grid-column: 2; grid-row: 1; margin: 0; }
++ #page-channel .ch-offline-header-info > .ch-username { grid-column: 2; grid-row: 2; }
++ #page-channel .ch-offline-header-info > .ch-presence { grid-column: 2; grid-row: 3; }
++ #page-channel .ch-offline-header-info > .ch-meta { grid-column: 1 / -1; grid-row: 4; }
++ #page-channel .ch-offline-header-info > .ch-social-pills { grid-column: 1 / -1; grid-row: 5; }
++ #page-channel .ch-offline-header-info > .ch-description { grid-column: 1 / -1; grid-row: 6; }
++ #page-channel .ch-offline-header-info > .ch-next { grid-column: 1 / -1; grid-row: 7; }
++ #page-channel .ch-offline-header-info > .ch-weather-widget { grid-column: 1 / -1; grid-row: 8; }
++ #page-channel .ch-offline-header > .ch-actions { grid-column: 1 / -1; grid-row: 9; width: 100%; margin-top: 8px; }
++ #page-channel .ch-meta > .ch-alert-chip { width: 36px; justify-content: center; padding: 0; }
++ #page-channel .ch-meta > .ch-alert-chip span { display: none; }
++}
++#page-channel :is(.ch-info-bar, .ch-offline-header) .staff-badge { display: inline-grid; place-items: center; width: 24px; height: 24px; padding: 0; border-radius: 7px; font-size: 12px; }
++#page-channel :is(.ch-info-bar-right, .ch-actions) { display: flex; align-items: center; gap: 6px; min-width: 0; }
++#page-channel :is(#ch-share-slot, #ch-ask-slot) { display: contents; }
++#page-channel #ch-ask-slot[hidden] { display: none; }
++#page-channel :is(.ch-info-bar-right, .ch-actions) :is(.ch-action, .ovk-ask) {
box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; flex: none;
-- height: 36px; min-height: 36px; margin: 0; padding: 0 14px; border: 0; border-radius: 999px; box-shadow: none;
-- font: inherit; font-size: 14px; font-weight: 650; line-height: 1; white-space: nowrap; text-decoration: none; cursor: pointer;
-- transition: background-color .15s ease, color .15s ease;
--}
--#page-channel .ch-info-bar-right :is(#ch-btn-share, .ovk-ask, #ch-btn-follow, #ch-btn-ban, #btn-cam) :is(svg, i) { flex: none; width: 16px; height: 16px; font-size: 14px; line-height: 16px; text-align: center; }
--#page-channel .ch-info-bar-right :is(#ch-btn-share, .ovk-ask, #btn-cam) { background: color-mix(in srgb, var(--text-primary) 9%, transparent); color: var(--text-primary); }
--#page-channel .ch-info-bar-right :is(#ch-btn-share, .ovk-ask, #btn-cam):hover { background: color-mix(in srgb, var(--text-primary) 15%, transparent); }
--#page-channel .ch-info-bar-right #ch-btn-follow { background: var(--live-red, #ef4444); color: #fff; }
--#page-channel .ch-info-bar-right #ch-btn-follow:hover { filter: brightness(1.08); }
--#page-channel .ch-info-bar-right #ch-btn-follow.following { background: color-mix(in srgb, var(--text-primary) 9%, transparent); color: var(--text-primary); filter: none; }
--#page-channel .ch-info-bar-right #ch-btn-follow.following:hover { background: color-mix(in srgb, var(--danger, #ef4444) 20%, transparent); color: var(--danger, #ef4444); }
--#page-channel .ch-info-bar-right #ch-btn-ban { background: color-mix(in srgb, var(--danger, #ef4444) 15%, transparent); color: var(--danger, #f87171); }
--#page-channel .ch-info-bar-right #ch-btn-ban:hover { background: color-mix(in srgb, var(--danger, #ef4444) 26%, transparent); }
--#page-channel .ch-info-bar-right :is(#ch-btn-share, .ovk-ask, #ch-btn-follow, #ch-btn-ban, #btn-cam):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++ height: 40px; min-height: 40px; margin: 0; padding: 0 13px; border: 1px solid var(--border); border-radius: 10px;
++ background: var(--bg-card); color: var(--text-primary); box-shadow: none; font: inherit; font-size: 13px; font-weight: 700;
++ line-height: 1; white-space: nowrap; text-decoration: none; cursor: pointer;
++}
++#page-channel :is(.ch-info-bar-right, .ch-actions) :is(.ch-action, .ovk-ask) :is(svg, i) { flex: none; width: 16px; height: 16px; font-size: 15px; line-height: 16px; text-align: center; }
++#page-channel :is(.ch-info-bar-right, .ch-actions) :is(.ch-action, .ovk-ask):hover { background: var(--bg-hover); color: var(--text-primary); }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--follow:not(.following) { border-color: var(--accent-strong, var(--accent)); background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff); }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--primary { border-color: var(--accent-strong, var(--accent)); background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff); }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--follow:not(.following):hover { filter: brightness(1.1); }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--follow.following { background: var(--bg-card); color: var(--text-primary); border-color: var(--border); opacity: 1; }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--follow.following:hover { background: var(--bg-hover); }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--follow.following:is(:hover, .show-unfollow) span { display: none; }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--follow.following:is(:hover, .show-unfollow)::after { content: 'Unfollow'; }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--follow.following:active { color: var(--danger-text, var(--danger)); }
++#page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--more { width: 40px; padding: 0; list-style: none; }
++#page-channel .ch-action--more::-webkit-details-marker { display: none; }
++#page-channel :is(.ch-info-bar-right, .ch-actions) :is(.ch-action, .ovk-ask):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++#page-channel .ch-more { position: relative; flex: none; }
++#page-channel .ch-more-menu { position: absolute; z-index: 20; right: 0; top: calc(100% + 6px); width: 210px; padding: 6px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); box-shadow: 0 12px 28px rgba(0,0,0,.25); }
++#page-channel .ch-more-item { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 40px; box-sizing: border-box; padding: 7px 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text-primary); text-align: left; text-decoration: none; font: inherit; font-size: 13px; cursor: pointer; }
++#page-channel .ch-more-item:hover, #page-channel .ch-more-item:focus-visible { background: var(--bg-hover); }
++#page-channel .ch-more-item i { width: 17px; color: var(--text-secondary); }
++#page-channel .ch-more-item[hidden], #page-channel .ch-more-mod[hidden] { display: none; }
++#page-channel .ch-more-mod { border-top: 1px solid var(--border); margin-top: 5px; padding-top: 5px; }
++#page-channel .ch-more-heading { display: block; padding: 5px 9px; color: var(--text-muted); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .05em; }
++.ch-action-dialog { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; padding: 22px; background: var(--bg-card); color: var(--text-primary); }
++.ch-action-dialog::backdrop { background: rgba(0,0,0,.65); }
++.ch-action-dialog h3 { margin: 0 0 12px; }
++.ch-action-dialog p, .ch-action-dialog time { color: var(--text-secondary); }
++.ch-action-dialog label { display: grid; gap: 6px; margin: 18px 0; }
++.ch-action-dialog input { width: 100%; box-sizing: border-box; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary); color: var(--text-primary); }
++.ch-action-dialog-buttons { display: flex; gap: 8px; justify-content: flex-end; }
++.ch-action-dialog .ch-action--danger { background: var(--danger); color: #fff; }
@media (max-width: 768px) and (orientation: portrait) {
-- /* A phone watching live: who is streaming on one line of its own (never under the buttons), the buttons on the
-- next, Follow first, sliding sideways when a moderator has more of them than fit. */
#page-channel.ch-is-live .ch-info-bar { flex-wrap: wrap; align-items: center; row-gap: 10px; padding: 10px 12px; }
#page-channel.ch-is-live .ch-info-bar-left { flex: 1 1 100%; align-items: center; gap: 10px; }
#page-channel.ch-is-live .ch-info-bar .ch-avatar { width: 40px; height: 40px; }
#page-channel.ch-is-live .ch-info-bar-top h2 { font-size: 15px; font-weight: 750; }
#page-channel.ch-is-live .ch-stream-title { font-size: 13px; color: var(--text-secondary); }
-- #page-channel.ch-is-live .ch-info-bar-right { flex: 1 1 100%; width: 100%; justify-content: flex-start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; padding-bottom: 1px; }
-- #page-channel.ch-is-live .ch-info-bar-right::-webkit-scrollbar { display: none; }
-- #page-channel.ch-is-live .ch-info-bar-right #ch-btn-follow { order: -1; }
-- /* The category is a quiet chip beside the name, not a second red button competing with Follow. */
-- #page-channel.ch-is-live .ch-info-bar-top .ch-category-badge { flex: none; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 650;
-- background: color-mix(in srgb, var(--text-primary) 9%, transparent); color: var(--text-secondary); border: 0; }
++ #page-channel :is(.ch-info-bar-right, .ch-actions) { flex: 1 1 100%; width: 100%; flex-wrap: nowrap; overflow: visible; }
++ #page-channel.ch-is-live .ch-info-bar-right { flex: 1 1 100%; width: 100%; justify-content: flex-start; overflow: visible; }
++ #page-channel .ch-offline-header .ch-actions { margin-left: 0; }
++ #page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--follow { flex: 1 1 0; min-width: 0; }
++ #page-channel :is(.ch-info-bar-right, .ch-actions) :is(.ch-action, .ovk-ask) { height: 44px; min-height: 44px; }
++ #page-channel :is(.ch-info-bar-right, .ch-actions) .ch-action--more { width: 44px; }
++ #page-channel :is(.ch-info-bar-right, .ch-actions) .ovk-ask { width: 44px; padding: 0; }
++ #page-channel :is(.ch-info-bar-right, .ch-actions) .ovk-ask span { display: none; }
++ #page-channel :is(.ch-info-bar-right, .ch-actions) #ch-btn-share { padding: 0 10px; }
}
/* A phone watching live (owner 11-08: "the chat should be collapsible … togglable like it used to be; scrolling past
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 4c5a1855..02f9a008 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -117,10 +117,12 @@
<div class="ch-info-bar-top">
<h2 id="ch-display-name">Channel</h2>
<span class="ch-staff-badge" id="ch-staff-badge"></span>
-- <span class="ch-username" id="ch-username">username</span>
-- <span class="ch-category-badge badge" id="ch-category-badge">Live</span>
<span id="ch-protocol-badge"></span>
</div>
++ <span class="ch-username" id="ch-username">@username</span>
++ <p class="ch-presence" id="ch-presence-live"></p>
++ <div class="ch-meta"><span class="ch-follower-count" id="ch-follower-count">0 followers</span><a class="ch-alert-chip" id="ch-watch-chip-live" href="https://openvibe.watch/" target="_blank" rel="noopener" hidden><i class="fa-regular fa-bell" aria-hidden="true"></i><span>Get an alert</span></a><span class="ch-category-badge" id="ch-category-badge">Live</span><a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a></div>
++ <div class="ch-social-pills" id="ch-socials-live" hidden></div>
<p class="ch-stream-title" id="ch-stream-title">Stream Title</p>
<div id="stream-labels" class="stream-labels"></div>
<p class="ch-stream-description muted" id="ch-stream-description" style="display:none"></p>
@@@ -129,22 +131,29 @@
</div>
</div>
<div class="ch-info-bar-right">
++ <button class="ovk-btn ch-action ch-action--follow" id="ch-btn-follow" type="button"><i class="fa-solid fa-heart" aria-hidden="true"></i><span>Follow</span></button>
<!-- The channel's Share button (the one share sheet every OpenVibe site opens). It sits in the
stream's own action row while the channel is live and in the header's actions when it is
not, so the page always has one Share button and never two. -->
-- <span id="ch-share-slot"><button class="ovk-btn ovk-btn--quiet" id="ch-btn-share" type="button" data-ov-share-sheet><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button></span>
++ <span id="ch-share-slot"><button class="ovk-btn ovk-btn--quiet ch-action" id="ch-btn-share" type="button" data-ov-share-sheet><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button></span>
<!-- Ask your agent (openvibe-shared 4.135.0): the SEO renderer fills this with the channel's
own chip, live or offline; app-channel.js keeps it beside Share in the same row. -->
<span id="ch-ask-slot"></span>
-- <span class="ch-follower-count" id="ch-follower-count">0 followers</span>
-- <a class="ch-game-badge" id="ch-game-badge" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
-- <button class="btn btn-outline" id="btn-cam" title="Take a still" style="display:none"><i class="fa-solid fa-camera"></i> Still</button>
-- <button class="btn btn-follow" id="ch-btn-follow">
-- <i class="fa-solid fa-heart"></i> Follow
-- </button>
-- <button class="btn btn-danger" id="ch-btn-ban" style="display:none" title="Ban from site">
-- <i class="fa-solid fa-ban"></i> Ban
-- </button>
++ <details class="ch-more" id="ch-more">
++ <summary class="ovk-btn ovk-btn--quiet ch-action ch-action--more" aria-label="More actions" title="More actions"><i class="fa-solid fa-ellipsis" aria-hidden="true"></i></summary>
++ <div class="ch-more-menu">
++ <a class="ch-more-item ch-watch-alert" id="ch-watch-offline" href="https://openvibe.watch/" target="_blank" rel="noopener" hidden><i class="fa-solid fa-bell" aria-hidden="true"></i>Get an alert</a>
++ <button class="ch-more-item" type="button" data-ch-action="copy"><i class="fa-solid fa-link" aria-hidden="true"></i>Copy channel link</button>
++ <a class="ch-more-item" id="ch-report" href="mailto:[email protected]"><i class="fa-solid fa-flag" aria-hidden="true"></i>Report</a>
++ <button class="ch-more-item" id="btn-cam" type="button" style="display:none"><i class="fa-solid fa-camera" aria-hidden="true"></i>Take a still</button>
++ <div class="ch-more-mod" id="ch-more-mod" hidden>
++ <span class="ch-more-heading">Moderation</span>
++ <button class="ch-more-item" id="ch-menu-ban" type="button" data-ch-action="ban" hidden><i class="fa-solid fa-ban" aria-hidden="true"></i>Ban from site</button>
++ <button class="ch-more-item" type="button" data-ch-action="timeout"><i class="fa-solid fa-clock" aria-hidden="true"></i>Timeout</button>
++ <button class="ch-more-item" type="button" data-ch-action="log"><i class="fa-solid fa-list" aria-hidden="true"></i>Mod log</button>
++ </div>
++ </div>
++ </details>
</div>
</div>
<!-- Weather spans the full width under the info bar (left-aligned) -->
@@@ -255,24 +264,20 @@
<div class="ch-offline-header-info">
<h1 id="ch-display-name-offline">Channel</h1>
<p class="ch-username" id="ch-username-offline">username</p>
-- <p class="ch-description" id="ch-description-offline"></p>
-- <div class="ch-social-pills" id="ch-socials-offline" hidden></div>
-- <div class="ch-next" id="ch-next-offline" hidden></div>
++ <p class="ch-presence" id="ch-presence-offline"></p>
<div class="ch-meta">
<span class="ch-follower-count" id="ch-follower-count-offline">0 followers</span>
-- <a class="ch-watch-alert" id="ch-watch-offline" href="https://openvibe.watch/" target="_blank" rel="noopener" hidden><i class="fa-solid fa-bell" aria-hidden="true"></i> Get an alert</a>
++ <a class="ch-alert-chip" id="ch-watch-chip-offline" href="https://openvibe.watch/" target="_blank" rel="noopener" hidden><i class="fa-regular fa-bell" aria-hidden="true"></i><span>Get an alert</span></a>
<a class="ch-game-badge" id="ch-game-badge-offline" href="https://openvibe.games/" target="_blank" rel="noopener" hidden></a>
-- <span class="ch-category-badge badge" id="ch-category-badge-offline">Offline</span>
++ <span class="ch-category-badge" id="ch-category-badge-offline">Offline</span>
</div>
++ <div class="ch-social-pills" id="ch-socials-offline" hidden></div>
++ <p class="ch-description" id="ch-description-offline"></p>
++ <div class="ch-next" id="ch-next-offline" hidden></div>
<div class="ch-weather-widget" id="ch-weather-widget-offline" style="display:none"></div>
</div>
<div class="ch-actions">
-- <button class="btn btn-follow" id="ch-btn-follow-offline">
-- <i class="fa-solid fa-heart"></i> Follow
-- </button>
-- <button class="btn btn-danger" id="ch-btn-ban-offline" style="display:none" title="Ban from site">
-- <i class="fa-solid fa-ban"></i> Ban
-- </button>
++ <button class="ovk-btn ch-action ch-action--follow" id="ch-btn-follow-offline" type="button"><i class="fa-solid fa-heart" aria-hidden="true"></i><span>Follow</span></button>
</div>
</div>
<!-- Customizable offline screen (image / video / custom HTML) — filled by JS -->
@@@ -393,6 +398,7 @@
<section class="ch-tab-panel" id="ch-panel-videos" aria-labelledby="ch-h-videos">
<h2 class="ch-sec-h" id="ch-h-videos">Videos</h2>
<div class="ch-panel-bar">
++ <a class="ch-view-all" id="ch-vods-all" href="#">View all videos</a>
<div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div>
<a id="ch-vods-video-link" class="btn btn-small btn-outline" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists"><i class="fa-solid fa-film"></i> On OpenVibe.Video</a>
<button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-vods-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
@@@ -404,6 +410,7 @@
<!-- Clips (clips OF this streamer's streams) -->
<section class="ch-tab-panel" id="ch-panel-clips" aria-labelledby="ch-h-clips">
<h2 class="ch-sec-h" id="ch-h-clips">Clips</h2>
++ <div class="ch-panel-bar"><a class="ch-view-all" id="ch-clips-of-all" href="#">View all clips</a></div>
<p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">Loading clips…</p></div>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
@@@ -420,6 +427,7 @@
<h2 class="ch-sec-h" id="ch-h-clips-taken">Clips taken</h2>
<p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p>
<div class="ch-panel-bar">
++ <a class="ch-view-all" id="ch-clips-taken-all" href="#">View all clips taken</a>
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
<button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
</div>
@@@ -431,6 +439,8 @@
<section class="ch-tab-panel" id="ch-panel-pastes" aria-labelledby="ch-h-pastes">
<h2 class="ch-sec-h" id="ch-h-pastes">Pastes</h2>
<div class="ch-panel-bar" id="ch-pastes-header">
++ <a class="ch-view-all" id="ch-pastes-all" href="#">View all pastes</a>
++ <span id="ch-pastes-sort"></span>
<button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
</div>
<div id="ch-pastes-grid" class="pastes-grid"><p class="muted">Loading pastes…</p></div>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 9cef7a0f..38c15429 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -87,24 +87,117 @@ let _activeChannelIsOwnerRank = false;
function _isInferredCategory(row) { return !!(row && row.ai_category && row.ai_category === row.category); }
function _setCategoryBadge(el, value, inferred) {
if (!el) return;
-- el.textContent = _capTag(value);
++ const device = { desktop: 'Streams from a computer', mobile: 'Streams from a phone', browser: 'Streams from a browser' }[String(value || '').toLowerCase()];
++ el.textContent = device || _capTag(value);
el.classList.toggle('is-inferred', !!inferred);
-- if (inferred) {
++ if (inferred && !device) {
const mark = document.createElement('span');
mark.className = 'cat-inferred';
-- mark.textContent = ' · inferred';
++ mark.textContent = ' · auto';
el.append(mark);
el.title = "Category inferred by OpenVibe's AI from the stream";
} else {
el.removeAttribute('title');
}
}
++function _setChannelPresence(data, liveStreams) {
++ const live = document.getElementById('ch-presence-live');
++ const offline = document.getElementById('ch-presence-offline');
++ if (live && liveStreams.length) {
++ const count = liveStreams.reduce((n, s) => n + (Number(s.viewer_count) || 0), 0);
++ live.textContent = `● Live now · ${count} watching`;
++ live.classList.add('is-live');
++ }
++ if (!offline) return;
++ const last = data.last_session;
++ if (!last || !last.ended_at) { offline.hidden = true; return; }
++ offline.hidden = false;
++ offline.textContent = '';
++ const dot = document.createElement('span');
++ dot.className = 'ch-presence-dot';
++ dot.textContent = '●';
++ const elapsedDays = Math.floor((Date.now() - new Date(last.ended_at).getTime()) / 86400000);
++ const when = elapsedDays > 1 ? `${elapsedDays} days ago` : elapsedDays === 1 ? 'yesterday' : timeAgo(last.ended_at);
++ const duration = Math.max(0, Number(last.duration_seconds) || 0);
++ const hours = Math.floor(duration / 3600);
++ const minutes = Math.floor(duration % 3600 / 60);
++ const length = hours ? `${hours} h ${minutes} m` : `${Math.max(1, minutes)} m`;
++ offline.append(dot, ` Last live ${when} · ${length}`);
++ if (last.title) {
++ offline.append(' · ');
++ if (last.vod_id) {
++ const link = document.createElement('a');
++ link.href = videoHref('vod', last.vod_id);
++ link.textContent = last.title;
++ offline.append(link);
++ } else offline.append(last.title);
++ }
++ const starts = (data.recent_starts || []).map((at) => new Date(at)).filter((date) => !Number.isNaN(date.getTime()) && Date.now() - date.getTime() < 70 * 86400000);
++ if (starts.length >= 8) {
++ const counts = Array.from({ length: 7 }, () => 0);
++ for (const date of starts) counts[date.getDay()]++;
++ const days = counts.map((count, day) => ({ count, day })).filter(({ count }) => count >= 3)
++ .sort((a, b) => b.count - a.count).slice(0, 2).sort((a, b) => a.day - b.day);
++ if (days.length) {
++ const label = days.map(({ day }) => new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(new Date(2026, 10, 8 + day))).join(' and ');
++ const hours = starts.filter((date) => days.some(({ day }) => day === date.getDay())).map((date) => date.getHours());
++ const evening = hours.length >= 6 && hours.filter((hour) => hour >= 17 && hour <= 22).length / hours.length >= .75;
++ const schedule = document.createElement('span');
++ schedule.className = 'ch-usual-schedule';
++ schedule.textContent = `Usually live ${label}${evening ? ' evenings' : ''}`;
++ offline.append(schedule);
++ }
++ }
++}
const CHANNEL_VODS_PAGE_SIZE = 12;
const CHANNEL_CLIPS_PAGE_SIZE = 12;
const channelVodsPageByUser = Object.create(null);
const channelClipsPageByUser = Object.create(null);
const channelClipsOfPageByUser = Object.create(null);
const channelAiClipsPageByUser = Object.create(null);
++function _channelRail(grid, offset, count, total, loadNext, old = []) {
++ if (!grid) return;
++ if (old.length) grid.prepend(...old);
++ grid.querySelectorAll('img').forEach((img) => { img.width = 320; img.height = 180; img.loading = 'lazy'; });
++ grid._railObserver?.disconnect();
++ const panel = grid.closest('.ch-tab-panel');
++ const bar = panel?.querySelector(':scope > .ch-panel-bar');
++ if (bar && !bar.querySelector(`[data-rail-for="${grid.id}"]`)) {
++ for (const [dir, icon] of [['prev', 'fa-chevron-left'], ['next', 'fa-chevron-right']]) {
++ const arrow = document.createElement('button');
++ arrow.type = 'button';
++ arrow.className = 'ch-rail-arrow';
++ arrow.dataset.railFor = grid.id;
++ arrow.dataset.dir = dir;
++ arrow.setAttribute('aria-label', `${dir === 'prev' ? 'Previous' : 'Next'} ${panel.querySelector('h2')?.textContent || 'items'}`);
++ arrow.innerHTML = `<i class="fa-solid ${icon}" aria-hidden="true"></i>`;
++ arrow.addEventListener('click', () => grid.scrollBy({ left: (dir === 'prev' ? -1 : 1) * grid.clientWidth * .75, behavior: _channelReduceMotion() ? 'auto' : 'smooth' }));
++ bar.append(arrow);
++ }
++ const update = () => {
++ const overflow = grid.scrollWidth > grid.clientWidth + 2;
++ bar.querySelectorAll(`[data-rail-for="${grid.id}"]`).forEach((arrow) => {
++ arrow.hidden = !overflow;
++ arrow.disabled = arrow.dataset.dir === 'prev' ? grid.scrollLeft < 2 : grid.scrollLeft + grid.clientWidth >= grid.scrollWidth - 2;
++ });
++ };
++ grid.addEventListener('scroll', update, { passive: true });
++ window.addEventListener('resize', update, { passive: true });
++ requestAnimationFrame(update);
++ }
++ if (!loadNext || offset + count >= total || !('IntersectionObserver' in window)) return;
++ const sentinel = document.createElement('div');
++ sentinel.className = 'ch-rail-skeleton';
++ sentinel.setAttribute('aria-label', 'Loading more items');
++ grid.append(sentinel);
++ const observer = new IntersectionObserver((entries) => {
++ if (!entries.some((entry) => entry.isIntersecting)) return;
++ observer.disconnect();
++ loadNext();
++ }, { root: grid, rootMargin: '0px 260px 0px 0px' });
++ grid._railObserver = observer;
++ observer.observe(sentinel);
++}
// Channel VOD filter/sort state
let currentChannelVodFilter = null; // numeric managed stream id or null = all
let currentChannelVodOrder = 'newest'; // newest|oldest|views|peak_viewers
@@@ -113,6 +206,7 @@ let currentChannelManagedStreams = [];
async function renderChannelVodsSection(username, liveStreams, vods, meta = {}) {
const vodsGrid = document.getElementById('ch-vods-grid');
if (!vodsGrid) return;
++ const oldCards = meta.offset > 0 ? [...vodsGrid.children].filter((el) => !el.classList.contains('ch-rail-skeleton')) : [];
// Render the VOD filter bar if we have managed streams
const filterBar = document.getElementById('ch-vods-filter-bar');
@@@ -204,7 +298,10 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
vodsGrid.innerHTML = '<p class="muted">No VODs yet</p>';
}
-- renderVodsPagination('ch-vods-pagination', page, total, pageSize, 'setChannelVodsPage', 'videos');
++ _channelRail(vodsGrid, offset, vods.length, total, () => setChannelVodsPage(page + 1), oldCards);
++ const all = document.getElementById('ch-vods-all');
++ if (all) { all.href = `/@${encodeURIComponent(username)}?section=videos`; all.textContent = `View all (${total})`; }
++ document.getElementById('ch-vods-pagination').style.display = 'none';
_selSyncAllBtns();
}
@@@ -229,8 +326,8 @@ function _fillChannelAvatar(el, ch, path) {
}
function _staffBadge(role, isOwner) {
-- if (role === 'admin' || isOwner) return '<span class="staff-badge staff-badge-admin" data-tip="Staff - Admin"><i class="fa-solid fa-shield-halved"></i></span>';
-- if (role === 'global_mod') return '<span class="staff-badge staff-badge-mod" data-tip="Staff - Mod"><i class="fa-solid fa-shield"></i></span>';
++ if (role === 'admin' || isOwner) return '<span class="staff-badge staff-badge-admin" data-tip="Staff - Admin" role="img" aria-label="Staff - Admin"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i></span>';
++ if (role === 'global_mod') return '<span class="staff-badge staff-badge-mod" data-tip="Staff - Mod" role="img" aria-label="Staff - Mod"><i class="fa-solid fa-shield" aria-hidden="true"></i></span>';
return '';
}
@@@ -254,6 +351,7 @@ function _reloadChannelContent(username) {
function renderChannelClipsSection(username, clips, meta = {}) {
const clipsGrid = document.getElementById('ch-clips-grid');
if (!clipsGrid) return;
++ const oldCards = meta.offset > 0 ? [...clipsGrid.children].filter((el) => !el.classList.contains('ch-rail-skeleton')) : [];
const pageSize = meta.limit || CHANNEL_CLIPS_PAGE_SIZE;
const offset = meta.offset || 0;
@@@ -282,7 +380,8 @@ function renderChannelClipsSection(username, clips, meta = {}) {
clipsGrid.innerHTML = '<p class="muted">No clips yet</p>';
}
-- renderVodsPagination('ch-clips-pagination', page, total, pageSize, 'setChannelClipsPage', 'clips');
++ _channelRail(clipsGrid, offset, clips.length, total, () => setChannelClipsPage(page + 1), oldCards);
++ document.getElementById('ch-clips-pagination').style.display = 'none';
_selSyncAllBtns();
}
@@@ -301,7 +400,7 @@ async function loadClipsTaken(username = currentChannelUsername, { reset = false
const params = new URLSearchParams({ sort: st.sort, limit: String(CLIPS_TAKEN_PAGE_SIZE), offset: String(offset) });
if (st.of) params.set('of', String(st.of));
if (st.includeSelf) params.set('includeSelf', '1');
-- if (grid) grid.innerHTML = '<p class="muted">Loading…</p>';
++ if (grid && st.page === 1) grid.innerHTML = '<div class="ch-rail-skeleton"></div>';
let data;
try { data = await api(`/streams/channel/${encodeURIComponent(username)}/clips-taken?${params.toString()}`); }
catch { if (grid) grid.innerHTML = '<p class="muted">Failed to load clips</p>'; return; }
@@@ -337,11 +436,12 @@ function _renderClipsTakenGrid(username, data) {
const grid = document.getElementById('ch-clips-grid');
if (!grid) return;
const clips = data.clips || [];
++ const oldCards = data.offset > 0 ? [...grid.children].filter((el) => !el.classList.contains('ch-rail-skeleton')) : [];
const canManage = _channelCanManage(username);
_selSetContext(canManage, () => loadClipsTaken(username));
if (!clips.length) {
grid.innerHTML = '<p class="muted">No clips found for this filter.</p>';
-- renderVodsPagination('ch-clips-pagination', 1, 0, CLIPS_TAKEN_PAGE_SIZE, 'setClipsTakenPage', 'clips');
++ document.getElementById('ch-clips-pagination').style.display = 'none';
return;
}
grid.innerHTML = clips.map(cl => _selWrap('clip', cl.id, `
@@@ -360,7 +460,10 @@ function _renderClipsTakenGrid(username, data) {
</a>
`, _activeChannelIsOwnerRank || !!(cl.owner_is_owner || cl.streamer_is_owner))).join('');
const page = Math.floor((data.offset || 0) / CLIPS_TAKEN_PAGE_SIZE) + 1;
-- renderVodsPagination('ch-clips-pagination', page, data.total || 0, CLIPS_TAKEN_PAGE_SIZE, 'setClipsTakenPage', 'clips');
++ _channelRail(grid, data.offset || 0, clips.length, data.total || 0, () => setClipsTakenPage(page + 1), oldCards);
++ const all = document.getElementById('ch-clips-taken-all');
++ if (all) { all.href = `/@${encodeURIComponent(username)}?section=clips-taken`; all.textContent = `View all (${data.total || 0})`; }
++ document.getElementById('ch-clips-pagination').style.display = 'none';
_selSyncAllBtns();
}
@@@ -377,6 +480,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
const grid = document.getElementById('ch-clips-of-grid');
const header = document.getElementById('ch-clips-of-header');
if (!grid) return;
++ const oldCards = meta.offset > 0 ? [...grid.children].filter((el) => !el.classList.contains('ch-rail-skeleton')) : [];
const pageSize = meta.limit || CHANNEL_CLIPS_PAGE_SIZE;
const offset = meta.offset || 0;
@@@ -404,7 +508,10 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
grid.innerHTML = '<p class="muted">No clips yet</p>';
}
-- renderVodsPagination('ch-clips-of-pagination', page, total, pageSize, 'setChannelClipsOfPage', 'clips of streams');
++ _channelRail(grid, offset, clips.length, total, () => setChannelClipsOfPage(page + 1), oldCards);
++ const all = document.getElementById('ch-clips-of-all');
++ if (all) { all.href = `/@${encodeURIComponent(username)}?section=clips`; all.textContent = `View all (${total})`; }
++ document.getElementById('ch-clips-of-pagination').style.display = 'none';
}
// ── AI Moments on the Clips tab: auto-clips of this channel's streams, after people's clips ──
@@@ -508,21 +615,28 @@ function _startChannelPastesAutoRefresh(username) {
if (_channelPastesTimer) clearInterval(_channelPastesTimer);
_channelPastesTimer = setInterval(() => {
if (currentChannelUsername !== username) { clearInterval(_channelPastesTimer); _channelPastesTimer = null; return; }
++ const rail = document.getElementById('ch-pastes-grid');
++ if (rail && (rail.scrollLeft > 0 || rail.querySelectorAll('.ch-paste-card').length > 12)) return;
loadChannelPastes(username);
}, 60000);
}
--async function loadChannelPastes(username = currentChannelUsername) {
++async function loadChannelPastes(username = currentChannelUsername, { append = false } = {}) {
if (!username) return;
const grid = document.getElementById('ch-pastes-grid');
const header = document.getElementById('ch-pastes-header');
const pager = document.getElementById('ch-pastes-pagination');
++ const sortHost = document.getElementById('ch-pastes-sort');
if (!grid) return;
const sort = channelPastesSortByUser[username] || 'newest';
++ if (sortHost) sortHost.innerHTML = (typeof sortToggleHTML === 'function') ? sortToggleHTML(sort, 'setChannelPastesSort') : '';
try {
-- const data = await api(`/pastes/by-user/${encodeURIComponent(username)}?limit=30&sort=${sort}`);
++ const oldCards = append ? [...grid.children].filter((el) => !el.classList.contains('ch-rail-skeleton')) : [];
++ const offset = append ? oldCards.length : 0;
++ const data = await api(`/pastes/by-user/${encodeURIComponent(username)}?limit=12&offset=${offset}&sort=${sort}`);
const pastes = data.pastes || [];
if (!pastes.length) {
++ if (append) { grid.querySelector('.ch-rail-skeleton')?.remove(); return; }
// Tab context: keep the panel readable with an empty state.
grid.style.display = ''; grid.innerHTML = '<p class="muted">No pastes yet</p>';
if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }
@@@ -533,10 +647,11 @@ async function loadChannelPastes(username = currentChannelUsername) {
if (header) header.style.display = '';
grid.style.display = '';
grid.innerHTML = pastes.map(p => _selWrap('paste', p.slug, _channelPasteCardHTML(p, canManage), !!(p.owner_is_owner || data.owner_is_owner))).join('');
-- if (pager) {
-- pager.style.display = '';
-- pager.innerHTML = (typeof sortToggleHTML === 'function') ? sortToggleHTML(sort, 'setChannelPastesSort') : '';
-- }
++ const total = Number(data.total) || pastes.length;
++ _channelRail(grid, offset, pastes.length, total, () => loadChannelPastes(username, { append: true }), oldCards);
++ const all = document.getElementById('ch-pastes-all');
++ if (all) { all.href = `/@${encodeURIComponent(username)}?section=pastes`; all.textContent = `View all (${total})`; }
++ if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }
_selSyncAllBtns();
} catch { /* silent */ }
}
@@@ -585,13 +700,7 @@ async function setChannelVodsPage(page) {
if (safePage === (channelVodsPageByUser[currentChannelUsername] || 1)) return;
channelVodsPageByUser[currentChannelUsername] = safePage;
-- const grid = document.getElementById('ch-vods-grid');
-- if (grid) {
-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-spinner fa-spin fa-2x"></i><p>Loading videos...</p></div>';
-- }
await refreshChannelVodsPage(currentChannelUsername);
-- const top = document.getElementById('ch-vods-grid');
-- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function setChannelClipsPage(page) {
@@@ -600,10 +709,6 @@ async function setChannelClipsPage(page) {
if (safePage === (channelClipsPageByUser[currentChannelUsername] || 1)) return;
channelClipsPageByUser[currentChannelUsername] = safePage;
-- const grid = document.getElementById('ch-clips-grid');
-- if (grid) {
-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-spinner fa-spin fa-2x"></i><p>Loading clips...</p></div>';
-- }
const vodPage = channelVodsPageByUser[currentChannelUsername] || 1;
const vodOffset = (vodPage - 1) * CHANNEL_VODS_PAGE_SIZE;
@@@ -615,8 +720,6 @@ async function setChannelClipsPage(page) {
offset: data.clipOffset || clipOffset,
});
-- const top = document.getElementById('ch-clips-grid');
-- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function setChannelClipsOfPage(page) {
@@@ -625,10 +728,6 @@ async function setChannelClipsOfPage(page) {
if (safePage === (channelClipsOfPageByUser[currentChannelUsername] || 1)) return;
channelClipsOfPageByUser[currentChannelUsername] = safePage;
-- const grid = document.getElementById('ch-clips-of-grid');
-- if (grid) {
-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-spinner fa-spin fa-2x"></i><p>Loading clips...</p></div>';
-- }
const offset = (safePage - 1) * CHANNEL_CLIPS_PAGE_SIZE;
const data = await api(`/streams/channel/${currentChannelUsername}?clipsOfLimit=${CHANNEL_CLIPS_PAGE_SIZE}&clipsOfOffset=${offset}`);
@@@ -638,8 +737,6 @@ async function setChannelClipsOfPage(page) {
offset,
});
-- const top = document.getElementById('ch-clips-of-grid');
-- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/** Hide or show the channel page's chat column on desktop (the stream or offline page then takes the full width). Remembered per browser. */
@@@ -846,22 +943,27 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
btn.style.display = '';
btn.classList.toggle('following', ch.is_following);
btn.innerHTML = ch.is_following
-- ? '<i class="fa-solid fa-check"></i> Following'
-- : '<i class="fa-solid fa-heart"></i> Follow';
++ ? '<i class="fa-solid fa-check" aria-hidden="true"></i><span>Following</span>'
++ : '<i class="fa-solid fa-heart" aria-hidden="true"></i><span>Follow</span>';
btn.title = ch.is_following ? 'Unfollow' : 'Follow';
btn.onclick = () => toggleChannelFollow(username);
++ btn.onpointerdown = event => {
++ if (event.pointerType === 'touch' && btn.classList.contains('following')) {
++ clearTimeout(btn._unfollowTimer);
++ btn._unfollowTimer = setTimeout(() => btn.classList.add('show-unfollow'), 550);
++ }
++ };
++ btn.onpointerup = btn.onpointercancel = btn.onpointerleave = () => {
++ clearTimeout(btn._unfollowTimer);
++ setTimeout(() => btn.classList.remove('show-unfollow'), 700);
++ };
}
};
-- // Ban button helper (admin / global_mod only)
-- const setupBanBtn = (btn) => {
-- if (!btn) return;
-- const canBan = currentUser?.capabilities?.manage_site_bans;
-- const isSelf = currentUser && currentUser.username === username;
-- if (!canBan || isSelf) { btn.style.display = 'none'; return; }
-- btn.style.display = '';
-- btn.onclick = () => banChannelUser(ch.user_id, ch.username || username);
-- };
++ _setupChannelMore(ch);
++ _setWatchAlert(document.getElementById('ch-watch-chip-live'), ch.username);
++ _setWatchAlert(document.getElementById('ch-watch-chip-offline'), ch.username);
++ _setChannelPresence(data, liveStreams);
if (liveStreams.length > 0) {
// ── LIVE STATE ──
@@@ -869,12 +971,13 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-offline-area').style.display = 'none';
_setChannelLive(true);
_placeShareButton('live');
++ _setChannelOwnerActions(ch.username);
updateShareButton(null);
_channelFooter(true);
_placeBelowFold();
// Populate streamer info bar (below video). Avatar image (letter fallback)
-- // + display name both link to the channel; the @handle is dropped.
++ // + display name both link to the channel.
const _chPath = channelPath(ch.username);
_fillChannelAvatar(document.getElementById('ch-avatar'), ch, _chPath);
const _chName = document.getElementById('ch-display-name');
@@@ -887,14 +990,14 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
if (_chStaff) _chStaff.innerHTML = _staffBadge(ch.role, ch.is_owner);
_activeChannelIsOwnerRank = !!ch.is_owner;
const _chUser = document.getElementById('ch-username');
-- if (_chUser) _chUser.style.display = 'none';
++ if (_chUser) _chUser.textContent = '@' + ch.username;
++ if (window.ChannelSocials) ChannelSocials.pills(document.getElementById('ch-socials-live'), ch.social_links || []);
{ const _ls0 = liveStreams[0], _liveCat = _ls0 && _ls0.category;
_setCategoryBadge(document.getElementById('ch-category-badge'), _liveCat || ch.ai_category || ch.category || 'Live',
_liveCat ? _isInferredCategory(_ls0) : !!ch.ai_category); }
document.getElementById('ch-follower-count').textContent = `${ch.follower_count || 0} followers`;
_loadGameBadge(ch.username);
setupFollowBtn(document.getElementById('ch-btn-follow'));
-- setupBanBtn(document.getElementById('ch-btn-ban'));
// Pick the preferred stream:
// 0. URL /@username/:managedStreamRef (managed stream deep link)
@@@ -954,6 +1057,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_channelFooter(false);
_setChannelLive(false);
_placeShareButton('offline');
++ _setChannelOwnerActions(ch.username);
updateShareButton(null);
_wireCamButton();
_updateCamButton(null);
@@@ -972,7 +1076,6 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_loadGameBadge(ch.username);
_setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
-- setupBanBtn(document.getElementById('ch-btn-ban-offline'));
// Customizable offline screen (image / video / custom HTML)
offlineBlocks = _renderOfflineScreen(ch);
@@@ -1269,14 +1372,43 @@ function _placeShareButton(where) {
if (!host) return;
if (b.parentElement !== host) {
if (live) host.appendChild(b);
-- else host.insertBefore(b, host.firstChild);
++ else host.insertBefore(b, document.getElementById('ch-btn-follow-offline').nextSibling);
}
// The Ask your agent chip (openvibe-shared 4.135.0) sits beside Share, in the same row, live or offline.
const ask = document.getElementById('ch-ask-slot');
if (ask && ask.parentElement !== host) host.insertBefore(ask, b.nextSibling);
-- b.className = 'ovk-btn ovk-btn--quiet';
++ ask?.querySelector('.ovk-ask')?.setAttribute('aria-label', 'Ask your agent');
++ const more = document.getElementById('ch-more');
++ const moreHost = live ? document.querySelector('#ch-info-bar .ch-info-bar-right') : host;
++ if (more && more.parentElement !== moreHost) moreHost.appendChild(more);
++ b.className = 'ovk-btn ovk-btn--quiet ch-action';
b.title = live ? 'Share this stream' : 'Share this channel';
}
++function _setChannelOwnerActions(username) {
++ const own = !!(currentUser && currentUser.username === username);
++ const live = document.getElementById('ch-live-area').style.display !== 'none';
++ const row = document.querySelector(live ? '#ch-info-bar .ch-info-bar-right' : '#ch-offline-area .ch-actions');
++ for (const id of ['ch-owner-edit', 'ch-owner-live']) document.getElementById(id)?.remove();
++ document.getElementById('ch-btn-follow').hidden = own;
++ document.getElementById('ch-btn-follow-offline').hidden = own;
++ const ask = document.getElementById('ch-ask-slot');
++ const more = document.getElementById('ch-more');
++ if (ask) ask.hidden = own;
++ if (more) more.hidden = own;
++ if (!own || !row) return;
++ for (const [id, label, icon, path, primary] of [
++ ['ch-owner-edit', 'Edit channel', 'fa-pen', '/dashboard', false],
++ ['ch-owner-live', 'Go live', 'fa-video', '/broadcast', true],
++ ]) {
++ const button = document.createElement('button');
++ button.id = id;
++ button.type = 'button';
++ button.className = `ovk-btn ch-action${primary ? ' ch-action--primary' : ''}`;
++ button.innerHTML = `<i class="fa-solid ${icon}" aria-hidden="true"></i><span>${label}</span>`;
++ button.addEventListener('click', () => navigate(path));
++ row.append(button);
++ }
++}
/** What the channel's Share button shares: the address, the name, a line about it, and the picture. */
function updateShareButton(stream) {
@@@ -2048,22 +2180,70 @@ function loadMoreChannelAgents(button) {
// the screen, or when it is jumped to. Inline handlers keep this name (features.json stubs).
function switchChannelTab(tab, btn, jump) {
_channelTab = tab;
-- _markChannelJump(tab);
++ _markChannelJump(tab, !!jump);
_ensureChannelSection(tab);
if (!jump) return;
const panel = document.getElementById('ch-panel-' + tab);
if (!panel || panel.hidden) return;
const fold = panel.querySelector('details.ch-fold');
if (fold && !fold.open) fold.open = true;
-- const reduce = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
++ const reduce = _channelReduceMotion();
++ _chJumpUntil = Date.now() + (reduce ? 180 : 1400);
panel.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
++ setTimeout(_queueChannelSectionSpy, reduce ? 200 : 1450);
}
--function _markChannelJump(tab) {
++function _channelReduceMotion() {
++ return document.documentElement.dataset.ovMotion === 'reduced' || !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
++}
++let _chJumpUntil = 0;
++function _markChannelJump(tab, fromTap = false) {
++ const active = document.querySelector(`#ch-tabs .ch-tab[data-tab="${tab}"]`);
++ if (!active || active.style.display === 'none' || active.closest('[hidden]')) return;
document.querySelectorAll('#ch-tabs .ch-tab').forEach(b => {
const on = b.dataset.tab === tab;
b.classList.toggle('active', on);
if (on) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
});
++ const strip = document.getElementById('ch-tabs');
++ if (strip) {
++ const inset = 14;
++ const left = active.offsetLeft - strip.offsetLeft;
++ const right = left + active.offsetWidth;
++ const target = left < strip.scrollLeft + inset ? left - inset : right > strip.scrollLeft + strip.clientWidth - inset ? right - strip.clientWidth + inset + 24 : strip.scrollLeft;
++ if (Math.abs(target - strip.scrollLeft) > 2) strip.scrollTo({ left: target, behavior: _channelReduceMotion() ? 'auto' : 'smooth' });
++ }
++ _updateChannelTabArrows();
++ if (fromTap || location.hash !== `#${tab}`) history.replaceState(history.state, '', `${location.pathname}${location.search}#${tab}`);
++}
++function _updateChannelTabArrows() {
++ const strip = document.getElementById('ch-tabs');
++ const wrap = strip && strip.parentElement;
++ if (!wrap || !wrap.classList.contains('ch-tabs-wrap')) return;
++ wrap.classList.toggle('has-overflow', strip.scrollWidth > strip.clientWidth + 2);
++ wrap.querySelectorAll('.ch-tabs-arrow').forEach((arrow) => {
++ arrow.disabled = arrow.dataset.dir === 'prev' ? strip.scrollLeft < 2 : strip.scrollLeft + strip.clientWidth >= strip.scrollWidth - 2;
++ });
++}
++function _initChannelTabArrows() {
++ const strip = document.getElementById('ch-tabs');
++ if (!strip || strip.parentElement.classList.contains('ch-tabs-wrap')) return;
++ const wrap = document.createElement('div');
++ wrap.className = 'ch-tabs-wrap';
++ strip.parentNode.insertBefore(wrap, strip);
++ wrap.append(strip);
++ for (const [dir, icon] of [['prev', 'fa-chevron-left'], ['next', 'fa-chevron-right']]) {
++ const arrow = document.createElement('button');
++ arrow.type = 'button';
++ arrow.className = 'ch-tabs-arrow';
++ arrow.dataset.dir = dir;
++ arrow.setAttribute('aria-label', `${dir === 'prev' ? 'Previous' : 'Next'} sections`);
++ arrow.innerHTML = `<i class="fa-solid ${icon}" aria-hidden="true"></i>`;
++ arrow.addEventListener('click', () => strip.scrollBy({ left: (dir === 'prev' ? -1 : 1) * strip.clientWidth * .7, behavior: _channelReduceMotion() ? 'auto' : 'smooth' }));
++ wrap.append(arrow);
++ }
++ strip.addEventListener('scroll', _updateChannelTabArrows, { passive: true });
++ window.addEventListener('resize', _updateChannelTabArrows, { passive: true });
++ _updateChannelTabArrows();
}
// A section's data: once, the first time it is near the screen or jumped to. The two folds (AI timeline, Analytics)
// load when opened, not on the way past; the media queue reloads when it comes back into view (it changes constantly).
@@@ -2103,11 +2283,35 @@ function _syncChannelSections() {
const panel = document.getElementById('ch-panel-' + b.dataset.tab);
if (panel) panel.hidden = b.style.display === 'none';
});
++ _updateChannelTabArrows();
}
let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;
++let _chSpyFrame = 0;
++function _channelSectionInView() {
++ _chSpyFrame = 0;
++ if (Date.now() < _chJumpUntil) return;
++ const panels = Array.from(document.querySelectorAll('#page-channel .ch-tab-panel'))
++ .filter(p => !p.hidden).sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
++ if (!panels.length) return;
++ const bar = document.getElementById('ch-tabs');
++ const margin = parseFloat(getComputedStyle(panels[0]).scrollMarginTop) || 0;
++ const line = bar ? Math.max(bar.getBoundingClientRect().bottom + 24, margin + 8) : 100;
++ const bottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 4;
++ let chosen = panels[0];
++ if (bottom) chosen = panels[panels.length - 1];
++ else for (const panel of panels) {
++ if (panel.getBoundingClientRect().top <= line) chosen = panel;
++ else break;
++ }
++ _markChannelJump(chosen.id.replace('ch-panel-', ''));
++}
++function _queueChannelSectionSpy() {
++ if (!_chSpyFrame) _chSpyFrame = requestAnimationFrame(_channelSectionInView);
++}
function _watchChannelSections() {
_syncChannelSections();
const tabs = document.getElementById('ch-tabs');
++ _initChannelTabArrows();
if (!_chTabsObs && tabs && window.MutationObserver) {
_chTabsObs = new MutationObserver(_syncChannelSections);
_chTabsObs.observe(tabs, { subtree: true, attributes: true, attributeFilter: ['style'] });
@@@ -2115,8 +2319,14 @@ function _watchChannelSections() {
const panels = document.querySelectorAll('#page-channel .ch-tab-panel');
if (!('IntersectionObserver' in window)) { panels.forEach(p => _ensureChannelSection(p.id.replace('ch-panel-', ''), true)); return; }
if (!_chSecObs) _chSecObs = new IntersectionObserver((es) => es.forEach(e => { if (e.isIntersecting) _ensureChannelSection(e.target.id.replace('ch-panel-', ''), true); }), { rootMargin: '600px 0px' });
-- if (!_chSpyObs) _chSpyObs = new IntersectionObserver((es) => es.forEach(e => { if (e.isIntersecting) _markChannelJump(e.target.id.replace('ch-panel-', '')); }), { rootMargin: '-35% 0px -60% 0px' });
++ if (!_chSpyObs) _chSpyObs = new IntersectionObserver(_queueChannelSectionSpy, { rootMargin: '-100px 0px -65% 0px' });
panels.forEach(p => { _chSecObs.observe(p); _chSpyObs.observe(p); });
++ if (!tabs._chSpyBound) {
++ tabs._chSpyBound = true;
++ window.addEventListener('scroll', _queueChannelSectionSpy, { passive: true });
++ window.addEventListener('resize', _queueChannelSectionSpy, { passive: true });
++ }
++ _queueChannelSectionSpy();
document.querySelectorAll('#page-channel details.ch-fold').forEach(f => {
if (f._ovBound) return;
f._ovBound = true;
@@@ -2892,7 +3102,7 @@ function _applyChannelLanguage(language, ch, isLive) {
_chLang = language || null;
// One chip in the live info bar, one in the offline header — whichever is showing.
const targets = [
-- ['ch-lang-chip', document.querySelector('#ch-info-bar .ch-info-bar-top')],
++ ['ch-lang-chip', document.querySelector('#ch-info-bar .ch-meta')],
['ch-lang-chip-offline', document.querySelector('#ch-offline-header .ch-meta')],
];
const off = !_chLang || !_chLang.code || _chLang.code === 'en';
@@@ -3405,7 +3615,7 @@ function _wireAboutDrag() {
}
/**
-- * "Get an alert" beside the follower count while a channel is offline: OpenVibe.Watch's form, filled in for this
++ * "Get an alert" in the channel's More menu: OpenVibe.Watch's form, filled in for this
* channel going live (the live.stream.started event, nothing polled). It reaches people where Live's own follow cannot:
* an email, a webhook, or one of their agents woken up. Not on one's own channel.
*/
@@@ -3416,6 +3626,7 @@ function _setWatchAlert(a, username) {
const q = new URLSearchParams({ template: 'event', event: 'live.stream.started', event_filter: username, name: `@${username} goes live` });
a.href = `https://openvibe.watch/watches/new?${q}`;
a.title = `An alert when @${username} goes live: by email, a webhook or your agent (OpenVibe.Watch)`;
++ a.setAttribute('aria-label', `Get an alert when @${username} goes live`);
a.hidden = false;
}
@@@ -3766,9 +3977,10 @@ async function toggleChannelFollow(username) {
if (!btn) return;
btn.classList.toggle('following', data.following);
btn.innerHTML = data.following
-- ? '<i class="fa-solid fa-check"></i> Following'
-- : '<i class="fa-solid fa-heart"></i> Follow';
++ ? '<i class="fa-solid fa-check" aria-hidden="true"></i><span>Following</span>'
++ : '<i class="fa-solid fa-heart" aria-hidden="true"></i><span>Follow</span>';
btn.title = data.following ? 'Unfollow' : 'Follow';
++ if (!data.following) btn.classList.remove('show-unfollow');
});
['ch-follower-count', 'ch-follower-count-offline'].forEach(id => {
const el = document.getElementById(id);
@@@ -3842,21 +4054,85 @@ async function offerLiveAlerts(name) {
box.appendChild(card);
}
--async function banChannelUser(userId, username) {
-- if (!userId || !username) return;
-- const reason = prompt(`⚠️ GLOBAL BAN: Ban ${username} from the entire site?\n\nEnter reason:`);
-- if (reason === null) return;
++function _setupChannelMore(ch) {
++ const menu = document.getElementById('ch-more');
++ if (!menu) return;
++ menu.open = false;
++ const username = ch.username;
++ _setWatchAlert(document.getElementById('ch-watch-offline'), username);
++ const report = document.getElementById('ch-report');
++ report.href = `mailto:[email protected]?subject=${encodeURIComponent(`Report @${username} on OpenVibe.Live`)}&body=${encodeURIComponent(`${location.origin}${channelPath(username)}\n\nReason: `)}`;
++ const staff = !!currentUser?.capabilities?.manage_site_bans && currentUser.username !== username;
++ const group = document.getElementById('ch-more-mod');
++ const ban = document.getElementById('ch-menu-ban');
++ group.hidden = !staff;
++ ban.hidden = !staff;
++ if (currentUser && !staff) {
++ api('/chat/channels/moderation/mine').then(data => {
++ if (menu.dataset.channel !== username) return;
++ group.hidden = !(data.channels || []).some(c => String(c.user_id) === String(ch.user_id) || c.username === username);
++ }).catch(() => {});
++ }
++ menu.dataset.channel = username;
++ menu.querySelector('.ch-more-menu').onclick = async (event) => {
++ const action = event.target.closest('[data-ch-action]')?.dataset.chAction;
++ if (!action) return;
++ menu.open = false;
++ if (action === 'copy') {
++ try { await navigator.clipboard.writeText(`${location.origin}${channelPath(username)}`); toast('Channel link copied', 'success'); }
++ catch { toast('Could not copy channel link', 'error'); }
++ } else if (action === 'ban' && staff) {
++ banChannelUser(ch.user_id, username);
++ } else if (action === 'timeout' && !group.hidden) {
++ const input = document.querySelector('#page-channel.active .chat-input-area textarea, #page-channel.active .chat-input-area input');
++ if (input) { input.value = `/timeout ${username} 600`; input.focus(); toast('Review the timeout command, then send it in chat', 'info'); }
++ else toast('Open chat to send a timeout command', 'info');
++ } else if (action === 'log' && !group.hidden) {
++ _showChannelModLog(ch.id);
++ }
++ };
++ if (!menu.dataset.outsideBound) {
++ document.addEventListener('click', event => { if (!menu.contains(event.target)) menu.open = false; });
++ menu.dataset.outsideBound = '1';
++ }
++}
++
++async function _showChannelModLog(channelId) {
++ const dialog = document.createElement('dialog');
++ dialog.className = 'ch-action-dialog';
++ dialog.innerHTML = '<h3>Mod log</h3><div class="ch-action-dialog-body">Loading…</div><button class="ovk-btn ovk-btn--quiet" type="button">Close</button>';
++ dialog.querySelector('button').onclick = () => dialog.close();
++ dialog.addEventListener('close', () => dialog.remove());
++ document.body.appendChild(dialog);
++ dialog.showModal();
try {
-- if (typeof staffBanUser === 'function') {
-- await staffBanUser(userId, username, reason || 'Banned from channel page', 0);
-- } else {
-- await api('/mod/global-ban', {
-- method: 'POST',
-- body: { user_id: userId, reason: reason || 'Banned from channel page' },
-- });
++ const data = await api(`/chat/channels/${channelId}/moderation/logs?limit=12`);
++ if (!dialog.isConnected) return;
++ dialog.querySelector('.ch-action-dialog-body').innerHTML = (data.actions || []).length
++ ? data.actions.map(a => `<p><strong>${esc(a.action_type || 'Action')}</strong> <time>${esc(a.created_at || '')}</time></p>`).join('')
++ : '<p>No channel moderation actions yet.</p>';
++ } catch (e) { if (dialog.isConnected) dialog.querySelector('.ch-action-dialog-body').textContent = e.message || 'Could not load the mod log.'; }
++}
++
++function banChannelUser(userId, username) {
++ if (!userId || !username || !currentUser?.capabilities?.manage_site_bans) return;
++ const dialog = document.createElement('dialog');
++ dialog.className = 'ch-action-dialog';
++ dialog.innerHTML = `<h3>Ban @${esc(username)} from the site?</h3><p>This blocks the account across OpenVibe.Live.</p><form><label>Reason<input name="reason" maxlength="200" required></label><div class="ch-action-dialog-buttons"><button class="ovk-btn ovk-btn--quiet" type="button">Cancel</button><button class="ovk-btn ch-action--danger" type="submit">Ban from site</button></div></form>`;
++ dialog.querySelector('[type=button]').onclick = () => dialog.close();
++ dialog.addEventListener('close', () => dialog.remove());
++ dialog.querySelector('form').onsubmit = async event => {
++ event.preventDefault();
++ const submit = dialog.querySelector('[type=submit]');
++ submit.disabled = true;
++ try {
++ await api('/mod/global-ban', { method: 'POST', body: { user_id: userId, reason: dialog.querySelector('[name=reason]').value } });
toast(`${username} banned from site`, 'success');
-- }
-- } catch (e) { toast(e.message || 'Ban failed', 'error'); }
++ dialog.close();
++ } catch (e) { submit.disabled = false; toast(e.message || 'Ban failed', 'error'); }
++ };
++ document.body.appendChild(dialog);
++ dialog.showModal();
}
/* ── Stream Viewer (legacy /stream/:id) ──────────────────────── */
ddiff --git a/public/js/channel-socials.js b/public/js/channel-socials.js
iindex ccfb80ab..d522e79e 100644
---- a/public/js/channel-socials.js
++++ b/public/js/channel-socials.js
@@@ -18,11 +18,50 @@
/** Pills: one line of links (the offline screen). */
function pills(host, links) {
if (!host) return;
-- // The OpenVibe profile pill says what it is: its @handle is the channel's own, already written just above it
-- // (the header read "JapaneseOldGuy · @JapaneseOldGuy · @JapaneseOldGuy", 2026-11-02).
-- const text = (l) => (l.kind === 'openvibe' && !l.label ? 'OpenVibe profile' : label(l));
-- host.innerHTML = (links || []).map((l) => `<a class="ch-social-pill" style="--sc:${escH(l.color)}" href="${escH(l.url)}" target="_blank" rel="noopener me" title="${escH(l.name)}${l.auto ? ' (connected)' : ''}${l.kind === 'openvibe' ? ` ${escH(label(l))}` : ''}"><i class="${escH(l.icon)}" aria-hidden="true"></i><span>${escH(text(l))}</span></a>`).join('');
++ host._sheet?.remove();
++ // This link opens the person's OpenVibe.Network profile.
++ const text = (l) => (l.kind === 'openvibe' ? 'Network profile' : l.auto ? l.name : label(l));
++ const ordered = [...(links || [])].sort((a, b) => (a.kind === 'openvibe' ? -1 : 0) - (b.kind === 'openvibe' ? -1 : 0));
++ host.innerHTML = ordered.map((l) => `<a class="ch-social-pill" style="--sc:${escH(l.color)}" href="${escH(l.url)}" target="_blank" rel="noopener me" title="${escH(l.name)}${l.auto ? ' (connected)' : ''}${l.kind === 'openvibe' ? ` ${escH(label(l))}` : ''}"><i class="${escH(l.icon)}" aria-hidden="true"></i><span>${escH(text(l))}</span></a>`).join('');
host.hidden = !(links || []).length;
++ const fold = () => {
++ if (host.hidden || !host.getClientRects().length) return;
++ host.querySelector('.ch-social-more')?.remove();
++ const chips = [...host.querySelectorAll('.ch-social-pill')];
++ chips.forEach((chip) => { chip.hidden = false; });
++ if (!chips.length) return;
++ const firstTop = chips[0].offsetTop;
++ const secondTop = chips.find((chip) => chip.offsetTop > firstTop + 2)?.offsetTop;
++ if (secondTop == null) return;
++ let hidden = chips.filter((chip) => chip.offsetTop > secondTop + 2);
++ if (!hidden.length) return;
++ const more = document.createElement('button');
++ more.type = 'button';
++ more.className = 'ch-social-more';
++ host.append(more);
++ do {
++ hidden.forEach((chip) => { chip.hidden = true; });
++ more.textContent = `+${hidden.length}`;
++ more.setAttribute('aria-label', `Show ${hidden.length} more profile links`);
++ if (more.offsetTop <= secondTop + 2) break;
++ const last = chips.filter((chip) => !chip.hidden).pop();
++ if (!last) break;
++ hidden.unshift(last);
++ } while (hidden.length < chips.length);
++ const sheet = host._sheet || document.createElement('dialog');
++ sheet.className = 'ch-social-sheet';
++ sheet.innerHTML = `<strong>Profile links</strong><div class="ch-social-sheet-links"></div><button type="button" class="ovk-btn">Close</button>`;
++ sheet.querySelector('.ch-social-sheet-links').append(...chips.map((chip) => { const copy = chip.cloneNode(true); copy.hidden = false; return copy; }));
++ sheet.querySelector('button').onclick = () => sheet.close();
++ if (!sheet.isConnected) document.body.append(sheet);
++ host._sheet = sheet;
++ more.onclick = () => sheet.showModal();
++ };
++ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fold);
++ if (!host._resizeObserver && window.ResizeObserver) {
++ host._resizeObserver = new ResizeObserver(fold);
++ host._resizeObserver.observe(host);
++ }
}
/** Rich cards (About): the card is a link at once; its preview fills in when it scrolls into view. */
ddiff --git a/server/media-proxy/lookups.js b/server/media-proxy/lookups.js
iindex fe80b824..5e3b4db9 100644
---- a/server/media-proxy/lookups.js
++++ b/server/media-proxy/lookups.js
@@@ -274,11 +274,11 @@ async function topContentRanges(user) {
// ── Pastes (OpenVibe.Community) ─────────────────────────────────────────────────────────────
/** Throws when the upstream fails (so a count cache never keeps a failure as zero). */
--async function _listUserPastes(user, { limit = 30, type, hidden = false }) {
++async function _listUserPastes(user, { limit = 30, offset = 0, type, hidden = false }) {
// Community lists one person's pastes by username. Hidden ones only for the owner (act as them)
// or staff (X-OV-Staff): Live says which, after its own check of the caller.
const act = hidden === 'owner' ? { liveUserId: user.id } : (hidden === 'staff' ? { staff: true } : {});
-- const out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit }, act, timeoutMs: TIMEOUT_MS });
++ const out = await pastesClient.request('GET', '', { query: { username: user.username, include_unlisted: hidden ? 1 : undefined, type, limit, offset }, act, timeoutMs: TIMEOUT_MS });
let pastes = rowsOf(out, 'pastes');
if (!hidden) pastes = pastes.filter((p) => p && p.visibility === 'public');
return { pastes, total: totalOf(out, pastes) };
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex da5fd1c6..18385376 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -213,7 +213,11 @@ async function _pageMeta(routePath, { page = 1, query = null } = {}) {
let m;
// /vod/<id> and /clip/<id> 301 to OpenVibe.Video before this runs (server/web/video-handover.js).
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return await _pasteMeta(m[1]);
-- if ((m = p.match(CHANNEL_PATH_RE))) return await _channelMeta(m[1], page);
++ if ((m = p.match(CHANNEL_PATH_RE))) {
++ const section = query && typeof query.section === 'string' ? query.section : '';
++ return ['videos', 'clips', 'clips-taken', 'pastes'].includes(section)
++ ? await _channelSectionMeta(m[1], section, page) : await _channelMeta(m[1], page);
++ }
if ((m = p.match(/^\/recap\/(\d+)$/))) return await _recapMeta(parseInt(m[1], 10));
if (p === '/rundown' || p.startsWith('/rundown/')) return await _rundownMeta(p, query, page);
// Search results (public/js/app-search.js) are never indexed; the page itself is findable.
@@@ -233,6 +237,54 @@ async function _pageMeta(routePath, { page = 1, query = null } = {}) {
// server/web/page-status.js): anything else is never a channel.
const CHANNEL_PATH_RE = /^\/@([A-Za-z0-9_]{3,24})$/;
const CHANNEL_PAGE_SIZE = 12;
++function _channelRailHtml(rows, kind) {
++ if (!rows.length) return '<p class="muted">Nothing here yet.</p>';
++ return rows.map((row) => {
++ const url = kind === 'paste' ? pasteHref(row.slug) : videoUrl(kind, row.id, null, row.watch_id);
++ const title = clean(row.title || (kind === 'paste' ? 'Untitled paste' : 'Untitled video'), 120);
++ const image = kind === 'paste' ? row.screenshot_url : row.thumbnail_url;
++ const when = isoDate(row.created_at);
++ const thumb = image ? `<img src="${esc(abs(image))}" alt="" loading="lazy" width="320" height="180">` : '';
++ if (kind === 'paste') return `<a class="ch-paste-card" href="${esc(url)}"><div class="ch-paste-thumb">${thumb}</div><div class="ch-paste-info"><div class="ch-paste-title">${esc(title)}</div><time class="ch-paste-meta" datetime="${esc(when || '')}">${esc(_day(row.created_at) || '')}</time></div></a>`;
++ return `<a class="stream-card" href="${esc(url)}"><div class="stream-card-thumb">${thumb}</div><div class="stream-card-info"><div class="stream-card-title">${esc(title)}</div><time class="stream-card-streamer muted" datetime="${esc(when || '')}">${esc(_day(row.created_at) || '')}</time></div></a>`;
++ }).join('');
++}
++async function _channelSectionMeta(username, section, page) {
++ const acct = await _channelAccount(username);
++ if (!acct || acct.banned) return null;
++ page = Math.min(CHANNEL_MAX_PAGE, Math.max(1, Math.floor(Number(page) || 1)));
++ const offset = (page - 1) * CHANNEL_PAGE_SIZE;
++ const name = acct.displayName;
++ const title = { videos: 'Videos', clips: 'Clips', 'clips-taken': 'Clips taken', pastes: 'Pastes' }[section];
++ let result = null;
++ try {
++ if (section === 'videos') result = await media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000, whole: true });
++ else if (section === 'clips') result = await media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000 });
++ else if (section === 'clips-taken') result = await media.listClips({ user_id: acct.userId, auto_generated: 0, limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000 });
++ else result = await require('../media-proxy/lookups').userPastes({ id: acct.userId, username: acct.username }, { limit: CHANNEL_PAGE_SIZE, offset });
++ } catch { result = null; }
++ const rows = ((result && (result.vods || result.clips || result.pastes)) || []).filter(_pub);
++ const total = result && Number.isFinite(Number(result.total)) ? Number(result.total) : null;
++ const pages = total == null ? null : Math.max(1, Math.ceil(total / CHANNEL_PAGE_SIZE));
++ const basePath = `/@${acct.username}?section=${section}`;
++ const canonicalPath = page > 1 ? `${basePath}&page=${page}` : basePath;
++ const items = rows.map((row) => ({
++ url: section === 'pastes' ? pasteHref(row.slug) : videoUrl(section === 'videos' ? 'vod' : 'clip', row.id, null, row.watch_id),
++ name: row.title || (section === 'pastes' ? 'Untitled paste' : 'Untitled video'),
++ meta: [_day(row.created_at), section === 'pastes' ? null : _fmtDur(row.duration_seconds ?? row.duration)].filter(Boolean).join(' · '),
++ }));
++ const ld = [{ '@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s ${title.toLowerCase()}`, itemListElement: items.map((item, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: abs(item.url), name: clean(item.name, 110) })) }];
++ const href = (n) => esc(abs(n > 1 ? `${basePath}&page=${n}` : basePath));
++ const hasNext = pages ? page < pages : rows.length === CHANNEL_PAGE_SIZE;
++ const pager = `<nav class="pagination" aria-label="Pages">${page > 1 ? `<a rel="prev" href="${href(page - 1)}">Previous</a>` : ''}<span>Page ${page}${pages ? ` of ${pages}` : ''}</span>${hasNext ? `<a rel="next" href="${href(page + 1)}">Next</a>` : ''}</nav>`;
++ const list = items.length ? `<ol>${items.map((item) => `<li><a href="${esc(abs(item.url))}">${esc(clean(item.name, 120))}</a><time>${esc(item.meta)}</time></li>`).join('')}</ol>` : '<p>No items on this page.</p>';
++ const snapshot = `<p><a href="${esc(abs(`/@${acct.username}`))}">← ${esc(name)}'s channel</a></p><h1>${esc(name)} · ${esc(title)}</h1>${list}${pager}`;
++ return {
++ title: `${name} — ${title}, page ${page} | ${SITE_NAME}`, description: `Browse ${name}'s ${title.toLowerCase()} on ${SITE_NAME}.`,
++ canonicalPath, robots: page > 1 && !rows.length ? 'noindex,follow' : 'index,follow',
++ jsonLd: ld, snapshot, standalone: true, cacheTtlMs: CHANNEL_CACHE_MS,
++ };
++}
const CHANNEL_MAX_PAGE = 500;
const CHANNEL_CACHE_MS = 60_000; // the page says who is live, so it is kept a minute, not five
@@@ -278,16 +330,20 @@ async function _channelMeta(username, page = 1) {
// streams, then what the AI cut from them, labelled (roadmap 33.6: made, then derived).
const track = { partial: false };
const offset = (page - 1) * CHANNEL_PAGE_SIZE;
-- const [vr, cr, ar] = acct.banned ? [null, null, null] : await Promise.all([
++ const [vr, cr, ar, tr, pr] = acct.banned ? [null, null, null, null, null] : await Promise.all([
_listWithin(`chv:${acct.userId}:${offset}`, async () => await media.listVods({ user_id: acct.userId, order: 'newest', limit: CHANNEL_PAGE_SIZE, offset }, { timeoutMs: 5000, whole: true }), track),
-- page === 1 ? _listWithin(`chc:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: 8 }, { timeoutMs: 5000 }), track) : null,
++ page === 1 ? _listWithin(`chc:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 0, limit: CHANNEL_PAGE_SIZE }, { timeoutMs: 5000 }), track) : null,
page === 1 ? _listWithin(`cha:${acct.userId}`, async () => await media.listClips({ channel_user_id: acct.userId, auto_generated: 1, limit: 6 }, { timeoutMs: 5000 }), track) : null,
++ page === 1 ? _listWithin(`cht:${acct.userId}`, async () => await media.listClips({ user_id: acct.userId, auto_generated: 0, limit: CHANNEL_PAGE_SIZE }, { timeoutMs: 5000 }), track) : null,
++ page === 1 ? _listWithin(`chp:${acct.userId}`, async () => await require('../media-proxy/lookups').userPastes({ id: acct.userId, username: acct.username }, { limit: CHANNEL_PAGE_SIZE }), track) : null,
]);
const vods = ((vr && vr.vods) || []).filter((v) => _pub(v) && !v.is_recording && v.status !== 'failed');
const vodTotal = vr && Number.isFinite(Number(vr.total)) ? Number(vr.total) : null;
const pages = vodTotal != null ? Math.max(1, Math.ceil(vodTotal / CHANNEL_PAGE_SIZE)) : null;
const clips = ((cr && cr.clips) || []).filter((c) => _pub(c) && !isAiClip(c));
const aiClips = ((ar && ar.clips) || []).filter((c) => _pub(c) && isAiClip(c));
++ const taken = ((tr && tr.clips) || []).filter((c) => _pub(c) && !isAiClip(c));
++ const pastes = ((pr && pr.pastes) || []).filter(_pub);
// A page past the end has nothing on it: not a page to index, and its canonical is page 1.
const pastEnd = page > 1 && !track.partial && (!vods.length || (pages != null && page > pages));
const canonicalPath = page > 1 && !pastEnd ? `${basePath}?page=${page}` : basePath;
@@@ -325,6 +381,11 @@ async function _channelMeta(username, page = 1) {
itemListElement: vods.map((v, i) => ({ '@type': 'ListItem', position: offset + i + 1, url: videoUrl('vod', v.id, null, v.watch_id), name: clean(v.title || 'Stream recording', 110) })),
});
}
++ for (const [heading, rows, kind] of [['Clips', clips, 'clip'], ['Clips taken', taken, 'clip'], ['Pastes', pastes, 'paste']]) {
++ if (!rows.length) continue;
++ ld.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: `${name}'s ${heading.toLowerCase()}`,
++ itemListElement: rows.map((row, i) => ({ '@type': 'ListItem', position: i + 1, url: abs(kind === 'paste' ? pasteHref(row.slug) : videoUrl('clip', row.id, null, row.watch_id)), name: clean(row.title || heading, 110) })) });
++ }
ld.push(_breadcrumb([{ name: 'Home', url: '/' }, { name, url: basePath }, ...(page > 1 ? [{ name: `Videos, page ${page}`, url: canonicalPath }] : [])]));
// The page body, for crawlers and for anyone reading without JavaScript.
@@@ -346,6 +407,8 @@ async function _channelMeta(username, page = 1) {
})), vods.length || page > 1 ? null : (track.partial ? null : 'No videos yet.'));
if (vods.length || page > 1) body += _pager(basePath, page, pages, vods.length === CHANNEL_PAGE_SIZE, 'videos');
if (clips.length) body += _itemSection('Clips', (await Promise.all(clips.map(async (c) => ({ url: videoUrl('clip', c.id, null, c.watch_id), name: c.title || 'Clip', meta: [await _clipperOf(c) ? `clipped by ${await _clipperOf(c)}` : null, _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })))));
++ if (taken.length) body += _itemSection('Clips taken', taken.map((c) => ({ url: videoUrl('clip', c.id, null, c.watch_id), name: c.title || 'Clip', meta: [_day(c.created_at), _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })));
++ if (pastes.length) body += _itemSection('Pastes', pastes.map((p) => ({ url: pasteHref(p.slug), name: p.title || 'Paste', meta: _day(p.created_at) })));
if (aiClips.length) {
body += _itemSection('AI Moments', aiClips.map((c) => ({ url: videoUrl('clip', c.id, null, c.watch_id), name: c.title || 'AI clip', meta: ['AI clip', _fmtDur(c.duration_seconds ?? c.duration)].filter(Boolean).join(' · ') })),
null, `<p class="ai-disclosure">Clips OpenVibe's AI cut from ${esc(name)}'s streams when chat reacted. No one clipped these.</p>`);
@@@ -367,6 +430,19 @@ async function _channelMeta(username, page = 1) {
canonicalPath, image, ogType: 'profile',
robots: acct.banned || pastEnd ? 'noindex,follow' : 'index,follow',
jsonLd: ld, snapshot: body, askText,
++ rails: page === 1 ? {
++ 'ch-vods-grid': _channelRailHtml(vods, 'vod'),
++ 'ch-clips-of-grid': _channelRailHtml(clips, 'clip'),
++ 'ch-clips-grid': _channelRailHtml(taken, 'clip'),
++ 'ch-pastes-grid': _channelRailHtml(pastes, 'paste'),
++ } : null,
++ railCounts: page === 1 ? {
++ videos: vodTotal ?? vods.length,
++ clips: Number(cr?.total) || clips.length,
++ 'clips-taken': Number(tr?.total) || taken.length,
++ pastes: Number(pr?.total) || pastes.length,
++ } : null,
++ channelUsername: acct.username,
cacheTtlMs: track.partial ? SHORT_CACHE_MS : CHANNEL_CACHE_MS,
};
}
@@@ -890,6 +966,18 @@ function render(meta, urlPath) {
// The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the channel fragment leaves a slot,
// filled here where the channel and its stream are known. kit.askAgent escapes the text; other routes have no slot.
if (meta.askText) html = html.replace('<span id="ch-ask-slot"></span>', `<span id="ch-ask-slot">${kit.askAgent({ text: meta.askText })}</span>`);
++ if (meta.rails) {
++ for (const [id, cards] of Object.entries(meta.rails)) {
++ const open = new RegExp(`<div[^>]*\\bid="${id}"[^>]*>`).exec(html);
++ if (!open) continue;
++ const contentStart = open.index + open[0].length;
++ const contentEnd = html.indexOf('</div>', contentStart);
++ if (contentEnd !== -1) html = html.slice(0, contentStart) + cards + html.slice(contentEnd);
++ }
++ for (const [section, id] of [['videos', 'ch-vods-all'], ['clips', 'ch-clips-of-all'], ['clips-taken', 'ch-clips-taken-all'], ['pastes', 'ch-pastes-all']]) {
++ html = html.replace(`id="${id}" href="#">View all`, `id="${id}" href="/@${esc(meta.channelUsername)}?section=${section}">View all (${Number(meta.railCounts[section]) || 0})`);
++ }
++ }
// Only the <head> is rewritten, so only the <head> is scanned.
//
@@@ -918,6 +1006,10 @@ function render(meta, urlPath) {
html = headEnd === -1
? head.replace(/<\/head>/i, _headBlock(meta) + '</head>')
: head + _headBlock(meta) + rest;
++ if (meta.standalone) {
++ const page = `<body class="ch-list-page"><main class="ch-list-content">${meta.snapshot}</main></body></html>`;
++ return html.replace(/<body[^>]*>[\s\S]*$/i, page);
++ }
// Server-rendered crawlable content, right after <body>. It's REAL content (so AI text
// extractors read it — unlike <noscript>, which many strip), but visually-hidden so users
// never see a flash, and the SPA removes #seo-prerender on boot (see app.js). Not cloaking:
@@@ -1006,7 +1098,8 @@ async function middleware(req, res, next) {
// these paths for data: the API is under /api.
const page = _pageParam(p, req.query);
const combo = p.startsWith('/rundown') ? _rundownKey(req.query) : '';
-- const key = combo ? `${p}?${combo}` : (page > 1 ? `${p}?page=${page}` : p);
++ const section = CHANNEL_PATH_RE.test(p) && ['videos', 'clips', 'clips-taken', 'pastes'].includes(req.query.section) ? req.query.section : '';
++ const key = combo ? `${p}?${combo}` : (section ? `${p}?section=${section}${page > 1 ? `&page=${page}` : ''}` : (page > 1 ? `${p}?page=${page}` : p));
// This renderer and the SPA fallback's status check share one wait on upstreams (page-status.js).
req.ovLookupDeadlineAt = Date.now() + pageStatus.LOOKUP_DEADLINE_MS;
const send = (html, ttl) => {
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 6826fc20..7ed0163c 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -390,6 +390,9 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
const isFollowing = req.user ? await networkFollows.isFollowing(req.user.id, channel.user_id) : false;
// Managed streams for this channel
const managedStreams = await db.getManagedStreamsByUserId(channel.user_id) || [];
++ const history = pollOnly ? [] : await db.getStreamsByUserId(channel.user_id, 16).catch(() => []);
++ const lastSession = history.find((s) => !s.is_live && s.ended_at);
++ const lastVod = lastSession && vods.find((v) => Number(v.stream_id) === Number(lastSession.id));
// Include RS restream status + per-slot external viewer counts for each live stream.
// Everything is keyed by the stream's own managed_stream_id (slot) so a channel
@@@ -594,6 +597,13 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
clipsTakenTotal,
aiEventTotal,
channel: publicChannel,
++ last_session: lastSession ? {
++ title: lastSession.title || null,
++ ended_at: lastSession.ended_at,
++ duration_seconds: lastSession.duration_seconds || 0,
++ vod_id: lastVod ? lastVod.id : null,
++ } : null,
++ recent_starts: history.filter((s) => s.started_at && !s.is_live).map((s) => s.started_at),
// The language this channel lives in (explicit setting or detected from the bio) —
// drives the chat auto-translation notice, the live-captions panel and bio translation.
language: await i18n.channelMeta(channel.user_id),
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex 44fde809..402fca30 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -22,6 +22,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const cache = require('openvibe-shared/cache-policy');
++const { transformSync } = require('esbuild');
// Where static files are read from on every request. By default the public/ next to this file. In the
// release layout (deploy/scripts/deploy.sh) the unit sets OV_APP_ROOT=/opt/openvibe.live/current: the
@@@ -51,6 +52,22 @@ let sharedDir = null;
let generation = 0; // bumps whenever any tracked file's hash changes
const files = new Map(); // urlPath -> { file, mtimeMs, size, hash, checkedAt }
const history = new Map(); // `${root}\0${urlPath}` -> hash (releases are immutable)
++const MINIFIED = new Set(['/js/app-channel.js', '/js/channel-socials.js', '/css/features/channel.css']);
++const minified = new Map();
++
++/** Channel assets are transformed on first request and cached by the file's real version. */
++function bytesFor(file, urlPath) {
++ if (!MINIFIED.has(urlPath)) return fs.readFileSync(file);
++ const st = fs.statSync(file);
++ const key = `${fs.realpathSync(file)}:${st.mtimeMs}:${st.size}`;
++ const hit = minified.get(key);
++ if (hit) return hit;
++ const source = fs.readFileSync(file, 'utf8');
++ const bytes = Buffer.from(transformSync(source, { loader: urlPath.endsWith('.css') ? 'css' : 'js', minify: true, target: 'es2020', legalComments: 'inline' }).code);
++ minified.set(key, bytes);
++ if (minified.size > 24) minified.delete(minified.keys().next().value);
++ return bytes;
++}
function setSharedDir(dir) { sharedDir = dir || null; }
@@@ -88,7 +105,7 @@ function hashOf(urlPath, { fresh = false } = {}) {
return entry.hash;
}
let hash;
-- try { hash = sha(fs.readFileSync(file)); } catch { return entry ? entry.hash : null; }
++ try { hash = sha(bytesFor(file, urlPath)); } catch { return entry ? entry.hash : null; }
if (!entry || entry.hash !== hash) generation++;
files.set(urlPath, { file, mtimeMs: st.mtimeMs, size: st.size, hash, checkedAt: now });
return hash;
@@@ -140,7 +157,7 @@ function findPrevious(urlPath, hash) {
const key = `${root}\0${urlPath}`;
let h = history.get(key);
if (h === undefined) {
-- try { h = sha(fs.readFileSync(file)); } catch { h = null; }
++ try { h = sha(bytesFor(file, urlPath)); } catch { h = null; }
history.set(key, h);
if (history.size > 4000) history.delete(history.keys().next().value);
}
@@@ -436,14 +453,30 @@ function versionedStatic(mount) {
const v = typeof req.query.v === 'string' ? req.query.v : '';
const urlPath = mount + decodeURIComponent(req.path);
res.locals.ovVersioned = false;
-- if (!v) return next();
++ if (!v && !MINIFIED.has(urlPath)) return next();
const current = hashOf(urlPath, { fresh: true });
-- if (current && v === current) { res.locals.ovVersioned = true; return next(); }
-- const previous = current !== v ? findPrevious(urlPath, v) : null;
++ if (current && v === current) {
++ res.locals.ovVersioned = true;
++ if (!MINIFIED.has(urlPath)) return next();
++ try {
++ setImmutable(res);
++ res.type(urlPath.endsWith('.css') ? 'css' : 'js');
++ return res.send(bytesFor(resolveIn(PUBLIC_DIR, urlPath), urlPath));
++ } catch { return next(); }
++ }
++ const previous = v && current !== v ? findPrevious(urlPath, v) : null;
if (previous) {
setImmutable(res);
++ if (MINIFIED.has(urlPath)) {
++ try { res.type(urlPath.endsWith('.css') ? 'css' : 'js'); res.set('X-OV-Asset', 'previous-release'); return res.send(bytesFor(previous, urlPath)); }
++ catch { return next(); }
++ }
return res.sendFile(previous, { headers: { 'X-OV-Asset': 'previous-release' } }, (err) => { if (err && !res.headersSent) next(); });
}
++ if (MINIFIED.has(urlPath) && current) {
++ try { setNoCache(res); res.type(urlPath.endsWith('.css') ? 'css' : 'js'); return res.send(bytesFor(resolveIn(PUBLIC_DIR, urlPath), urlPath)); }
++ catch { return next(); }
++ }
return next();
};
}
ddiff --git a/test/ai-derivation-optout.test.js b/test/ai-derivation-optout.test.js
iindex db875e40..d34abdc6 100644
---- a/test/ai-derivation-optout.test.js
++++ b/test/ai-derivation-optout.test.js
@@@ -6,7 +6,7 @@
* - the AI moments job never picks the channel's VODs (so no moment pastes, no clips from them);
* - the stream-memory job posts no "caught live" pastes;
* - the after-show report is the stats-only one (no model call, not an AI Moment).
-- * Channels that did not opt out are unaffected. The channel page's category pill says "inferred"
++ * Channels that did not opt out are unaffected. The channel page's category chip says "auto"
* when the AI chose it (public/js/app-channel.js, run against a small stand-in DOM).
*
* node test/ai-derivation-optout.test.js
@@@ -188,7 +188,7 @@ async function check(name, fn) {
assert.strictEqual(modelCalls.length, 1);
});
-- await check('the channel page marks an AI-inferred category "inferred", and a chosen one plain', () => {
++ await check('the channel page marks an AI-inferred category "auto", and a chosen one plain', () => {
const src = fs.readFileSync(path.join(__dirname, '../public/js/app-channel.js'), 'utf8');
const start = src.indexOf('function _isInferredCategory(');
const end = src.indexOf('\n}\n', src.indexOf('function _setCategoryBadge(')) + 3;
@@@ -205,7 +205,7 @@ async function check(name, fn) {
vm.runInContext(`${src.slice(start, end)}\nthis.set = _setCategoryBadge; this.inferred = _isInferredCategory;`, ctx);
const el = new El();
ctx.set(el, 'gaming', ctx.inferred({ category: 'gaming', ai_category: 'gaming' }));
-- assert.strictEqual(el.textContent, 'Gaming · inferred');
++ assert.strictEqual(el.textContent, 'Gaming · auto');
assert.ok(el.cls.has('is-inferred') && /inferred by OpenVibe/.test(el.attrs.title));
const chosen = new El();
ctx.set(chosen, 'irl', ctx.inferred({ category: 'irl', ai_category: null }));
ddiff --git a/test/asset-versioning.test.js b/test/asset-versioning.test.js
iindex 36fdfcdc..8784a99e 100644
---- a/test/asset-versioning.test.js
++++ b/test/asset-versioning.test.js
@@@ -23,10 +23,14 @@ const rel = (name) => path.join(base, 'releases', name, 'public');
for (const name of ['old', 'new']) {
fs.mkdirSync(path.join(rel(name), 'js'), { recursive: true });
fs.mkdirSync(path.join(rel(name), 'css'), { recursive: true });
++ fs.mkdirSync(path.join(rel(name), 'css', 'features'), { recursive: true });
}
fs.writeFileSync(path.join(rel('old'), 'js', 'app.js'), 'console.log("old");');
fs.writeFileSync(path.join(rel('new'), 'js', 'app.js'), 'console.log("new");');
fs.writeFileSync(path.join(rel('new'), 'css', 'style.css'), 'body{color:red}');
++fs.writeFileSync(path.join(rel('old'), 'js', 'app-channel.js'), 'function channelProof() { return 1 + 2; }');
++fs.writeFileSync(path.join(rel('new'), 'js', 'app-channel.js'), 'function channelProof() { return 3 + 4; }');
++fs.writeFileSync(path.join(rel('new'), 'css', 'features', 'channel.css'), '.channel-proof { color: red; }');
fs.writeFileSync(path.join(rel('new'), 'index.html'),
'<link rel="stylesheet" href="/css/style.css?v=216"><script src="/js/app.js" defer></script>' +
'<script>load(\'/js/app.js?v=9\')</script><img src="/js/missing.js"><!--ov:asset-manifest-->');
@@@ -94,6 +98,7 @@ async function check(name, fn) {
const express = require('express');
const app = express();
app.use('/js', assets.versionedStatic('/js'), express.static(path.join(rel('new'), 'js'), { setHeaders: assets.staticHeaders }));
++ app.use('/css', assets.versionedStatic('/css'), express.static(path.join(rel('new'), 'css'), { setHeaders: assets.staticHeaders }));
const server = http.createServer(app).listen(0);
if (!server.listening) await new Promise((r) => server.once('listening', r));
const get = (p) => new Promise((resolve, reject) => {
@@@ -127,6 +132,21 @@ async function check(name, fn) {
}
});
++ await check('channel JS and CSS are minified before hashing, including prior release bytes', async () => {
++ const jsHash = assets.hashOf('/js/app-channel.js');
++ const current = await get(`/js/app-channel.js?v=${jsHash}`);
++ assert.ok(current.body.length < fs.readFileSync(path.join(rel('new'), 'js', 'app-channel.js')).length);
++ assert.strictEqual(sha(current.body), jsHash, 'the URL hashes the served bytes');
++ assert.match(current.headers['cache-control'], /immutable/);
++ const cssHash = assets.hashOf('/css/features/channel.css');
++ const css = await get(`/css/features/channel.css?v=${cssHash}`);
++ assert.strictEqual(sha(css.body), cssHash);
++ const oldHash = sha(require('esbuild').transformSync(fs.readFileSync(path.join(rel('old'), 'js', 'app-channel.js'), 'utf8'), { loader: 'js', minify: true, target: 'es2020', legalComments: 'inline' }).code);
++ const old = await get(`/js/app-channel.js?v=${oldHash}`);
++ assert.strictEqual(sha(old.body), oldHash);
++ assert.strictEqual(old.headers['x-ov-asset'], 'previous-release');
++ });
++
await check('the public static mount never serves index.html for "/" (it would skip rendering)', () => {
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
const mount = src.match(/app\.use\(express\.static\(assets\.PUBLIC_DIR,[^\n]*/);
ddiff --git a/test/channel-socials-pills.test.js b/test/channel-socials-pills.test.js
iindex fa499321..bf84e48d 100644
---- a/test/channel-socials-pills.test.js
++++ b/test/channel-socials-pills.test.js
@@@ -1,9 +1,6 @@
'use strict';
/**
-- * The offline screen's social pills (public/js/channel-socials.js pills): the OpenVibe profile pill says "OpenVibe
-- * profile", since its @handle is the channel's own and is already written just above it (the header read
-- * "JapaneseOldGuy · @JapaneseOldGuy · @JapaneseOldGuy" on 2026-11-02); other platforms keep their @handle, and a label
-- * the streamer typed wins.
++ * Channel social pills name the Network destination; other platforms keep their own handle.
*
* node test/channel-socials-pills.test.js
*/
@@@ -24,7 +21,7 @@ pills(host, [
{ kind: 'openvibe', url: 'https://openvibe.network/@other', handle: 'other', label: 'My other channel', name: 'OpenVibe', icon: 'fa-solid fa-id-card', color: '#3b82f6' },
]);
const spans = [...host.innerHTML.matchAll(/<span>([^<]*)<\/span>/g)].map((m) => m[1]);
--assert.deepStrictEqual(spans, ['OpenVibe profile', '@jog', 'My other channel']);
++assert.deepStrictEqual(spans, ['Network profile', 'Network profile', '@jog']);
assert.match(host.innerHTML, /href="https:\/\/openvibe\.network\/@JapaneseOldGuy"/, 'the link is unchanged');
assert.match(host.innerHTML, /title="OpenVibe \(connected\) @JapaneseOldGuy"/, 'the handle stays in its title');
--console.log('channel social pills: the OpenVibe profile pill says what it is; other handles and typed labels stay');
++console.log('channel social pills: Network links name their destination; other handles stay');
ddiff --git a/test/channel-watch-alert.test.js b/test/channel-watch-alert.test.js
iindex 3d838819..daad98e0 100644
---- a/test/channel-watch-alert.test.js
++++ b/test/channel-watch-alert.test.js
@@@ -1,6 +1,5 @@
'use strict';
--// "Get an alert" on an offline channel (plan §4, Agents and devices: the gap was an entry point on Live's channel
--// page): OpenVibe.Watch's form, filled in for this channel going live (live.stream.started, nothing polled), so an
++// "Get an alert" in the channel's More menu opens OpenVibe.Watch's form for live.stream.started, so an
// alert can reach an email, a webhook or an agent. Hidden on one's own channel and until the channel is known.
const assert = require('assert');
const fs = require('fs');
@@@ -9,15 +8,15 @@ const vm = require('vm');
const ROOT = path.join(__dirname, '..');
const fragment = fs.readFileSync(path.join(ROOT, 'public/fragments/channel.html'), 'utf8');
--assert.match(fragment, /<a class="ch-watch-alert" id="ch-watch-offline" href="https:\/\/openvibe\.watch\/" target="_blank" rel="noopener" hidden>/, 'the link is in the offline header, hidden until filled');
++assert.match(fragment, /<a class="ch-more-item ch-watch-alert" id="ch-watch-offline" href="https:\/\/openvibe\.watch\/" target="_blank" rel="noopener" hidden>/, 'the link is in More, hidden until filled');
const src = fs.readFileSync(path.join(ROOT, 'public/js/app-channel.js'), 'utf8');
const fn = /function _setWatchAlert\(a, username\) \{[\s\S]*?\n\}/.exec(src);
assert.ok(fn, '_setWatchAlert exists');
--assert.match(src, /_setWatchAlert\(document\.getElementById\('ch-watch-offline'\), ch\.username\);/, 'the offline header fills it');
++assert.match(src, /_setWatchAlert\(document\.getElementById\('ch-watch-offline'\), username\);/, 'More fills it for either header');
const run = (viewer, username) => {
-- const a = { hidden: true, href: 'https://openvibe.watch/', title: '' };
++ const a = { hidden: true, href: 'https://openvibe.watch/', title: '', setAttribute(name, value) { this[name] = value; } };
const ctx = { currentUser: viewer, URLSearchParams };
vm.createContext(ctx);
vm.runInContext(`${fn[0]}\n_setWatchAlert(a, ${JSON.stringify(username)});`, Object.assign(ctx, { a }));
@@@ -30,6 +29,7 @@ const u = new URL(a.href);
assert.strictEqual(u.origin + u.pathname, 'https://openvibe.watch/watches/new');
assert.deepStrictEqual(Object.fromEntries(u.searchParams), { template: 'event', event: 'live.stream.started', event_filter: 'goosely', name: '@goosely goes live' });
assert.match(a.title, /@goosely goes live: by email, a webhook or your agent/);
++assert.strictEqual(a['aria-label'], 'Get an alert when @goosely goes live');
assert.strictEqual(run(null, 'goosely').hidden, false, 'signed out too: Watch asks them to sign in');
assert.strictEqual(run({ username: 'goosely' }, 'goosely').hidden, true, 'not on one\'s own channel');
assert.strictEqual(run(null, '').hidden, true, 'not before the channel is known');
ddiff --git a/test/footer-channel-claim.test.js b/test/footer-channel-claim.test.js
iindex 8dbcb411..9799cb24 100644
---- a/test/footer-channel-claim.test.js
++++ b/test/footer-channel-claim.test.js
@@@ -35,7 +35,7 @@ disposers.forEach((d) => d());
assert.deepStrictEqual(calls.at(-1), ['release', 'channel-live'], 'released when the page is left');
// Both states draw it; the channel's own variant is full only when the footer can be claimed.
--assert.ok(/\/\/ ── LIVE STATE ──[\s\S]{0,300}_channelFooter\(true\);/.test(app), 'the live state claims');
++assert.ok(/\/\/ ── LIVE STATE ──[\s\S]{0,800}_channelFooter\(true\);/.test(app), 'the live state claims');
assert.ok(/getElementById\('ch-offline-area'\)\.style\.display = '';\n\s*_channelFooter\(false\);/.test(app), 'the offline state releases');
assert.ok(read('public/js/footer-live.js').includes("if (/^\\/@/.test(p)) return window.OpenVibeFooter && typeof OpenVibeFooter.claim === 'function' ? 'full' : 'compact';"));
ddiff --git a/test/seo-ssr.test.js b/test/seo-ssr.test.js
iindex b1a3d72d..db4d66ca 100644
---- a/test/seo-ssr.test.js
++++ b/test/seo-ssr.test.js
@@@ -63,6 +63,7 @@ media.listVods = async (q = {}) => {
media.listClips = async (q = {}) => {
if (slow) await new Promise((r) => setTimeout(r, slow));
const rows = CLIPS.filter((c) => c.is_public
++ && (q.user_id == null || String(c.user_id) === String(q.user_id))
&& (q.channel_user_id == null || String(c.channel_user_id) === String(q.channel_user_id))
&& (q.vod_id == null || String(c.vod_id) === String(q.vod_id))
&& (q.auto_generated == null || Number(c.auto_generated) === Number(q.auto_generated)));
@@@ -74,6 +75,7 @@ media.request = async () => { throw new media.MediaApiError('stubbed', 0, null);
const pastesClient = require('../server/pastes-client');
pastesClient.listPastes = async () => ({ pastes: [] });
pastesClient.getPaste = async () => { throw missing(); };
++require('../server/media-proxy/lookups').userPastes = async () => ({ pastes: [{ slug: 'alice-note', title: 'Alice notes', visibility: 'public', created_at: day(28) }], total: 1 });
// ── The app, mounted as server/index.js mounts it ──
const express = require('express');
@@@ -174,6 +176,30 @@ const server = http.createServer(app).listen(0, '127.0.0.1', async () => {
assert.strictEqual(list.itemListElement.length, 12);
});
++ await check('channel rails contain twelve server-rendered links and ItemLists for each content type', async () => {
++ const html = (await get('/@alice')).body;
++ const rail = (id) => { const at = html.indexOf(`id="${id}"`); return html.slice(at, at + 2500); };
++ assert.ok(rail('ch-vods-grid').includes('Build night 100'));
++ assert.ok(rail('ch-clips-of-grid').includes('Bob clipped the saw'));
++ assert.ok(rail('ch-pastes-grid').includes('Alice notes'), rail('ch-pastes-grid').slice(0, 320));
++ assert.ok(html.includes('href="/@alice?section=pastes"'));
++ assert.ok(ldOf(html).filter((node) => node['@type'] === 'ItemList').length >= 3);
++ });
++
++ await check('full lists are server-rendered, paginated and self-canonical', async () => {
++ const first = (await get('/@alice?section=videos')).body;
++ assert.deepStrictEqual(canonicalsOf(first), ['https://openvibe.live/@alice?section=videos']);
++ assert.ok(first.includes('<main class="ch-list-content">'));
++ assert.ok(first.includes('Build night 100'));
++ assert.ok(first.includes('rel="next"'));
++ assert.ok(!first.includes('id="app"'));
++ const second = (await get('/@alice?section=videos&page=2')).body;
++ assert.deepStrictEqual(canonicalsOf(second), ['https://openvibe.live/@alice?section=videos&amp;page=2']);
++ assert.ok(second.includes('rel="prev"'));
++ assert.ok(second.includes('Build night 112'));
++ assert.ok((await get('/@alice?section=pastes')).body.includes('Alice notes'));
++ });
++
await check('/@alice?page=2 and ?page=3: self-canonical pages with prev/next; past the end is noindex', async () => {
const p2 = (await get('/@alice?page=2')).body;
assert.deepStrictEqual(canonicalsOf(p2), ['https://openvibe.live/@alice?page=2']);
ddiff --git a/test/share-sheet.test.js b/test/share-sheet.test.js
iindex 75a789ef..81fcfd9b 100644
---- a/test/share-sheet.test.js
++++ b/test/share-sheet.test.js
@@@ -30,7 +30,7 @@ const buttons = channel.match(/data-ov-share-sheet/g) || [];
assert.strictEqual(buttons.length, 1, 'the channel page has one Share button');
assert.ok(channel.includes('id="ch-btn-share"'), "it is the channel's Share button");
assert.ok(channel.includes('id="ch-share-slot"'), "and it sits in the stream's own action row");
--assert.ok(channel.includes('class="ovk-btn ovk-btn--quiet"'), 'the kit\'s button');
++assert.ok(channel.includes('class="ovk-btn ovk-btn--quiet ch-action"'), 'the kit\'s button uses the channel action family');
assert.ok(!appChannel.includes('shareStreamUrl'), 'the old copy-the-link button is gone');
assert.ok(!read('public/css/features/channel.css').includes('ch-share-stream'), 'and so is its CSS');