Compare commits

...
120 Commits
Author SHA1 Message Date
thePR0M3TH3AN 79e5267524 Merge pull request #156 from PR0M3TH3AN/unstable
Unstable
2025-09-29 13:49:42 -04:00
thePR0M3TH3AN 344089cd39 Merge pull request #152 from PR0M3TH3AN/codex/fix-player-state-retention-issue
Ensure modal video element resets between playback sessions
2025-09-29 12:39:45 -04:00
thePR0M3TH3AN 1dbfa9b802 Stop treating emptied event as URL failure 2025-09-29 12:23:06 -04:00
thePR0M3TH3AN 7e8d5f2fef Add detailed playback teardown logging 2025-09-29 12:10:36 -04:00
thePR0M3TH3AN 6a3bc50066 Clear crossOrigin when resetting modal video 2025-09-29 11:55:35 -04:00
thePR0M3TH3AN 83a9947611 Hard reset modal player between playback sessions 2025-09-29 11:43:36 -04:00
thePR0M3TH3AN 0bf3ce8e8a Merge pull request #151 from PR0M3TH3AN/codex/fix-video-player-styling-issues
Restore video modal layout and theming
2025-09-29 11:31:20 -04:00
thePR0M3TH3AN fe39e65aa5 Match video modal styling to dark theme 2025-09-29 11:23:58 -04:00
thePR0M3TH3AN 8de0ecc47d Restore video modal styling 2025-09-29 11:02:00 -04:00
thePR0M3TH3AN b438564b64 Merge pull request #150 from PR0M3TH3AN/codex/add-tailwind-css-setup-and-configuration
Add Tailwind build pipeline and generated stylesheet
2025-09-29 10:34:45 -04:00
thePR0M3TH3AN 37a5ee3b7b Add SEO metadata and PostCSS config for Tailwind build 2025-09-29 10:27:30 -04:00
thePR0M3TH3AN 23a8eacad4 chore: add tailwind build tooling 2025-09-29 10:16:30 -04:00
thePR0M3TH3AN 1f191e2b90 Merge pull request #149 from PR0M3TH3AN/codex/make-desktop-video-player-modal-full-screen
Make desktop video modal full screen
2025-09-29 10:01:39 -04:00
thePR0M3TH3AN 2d0ddd0d3a Fix desktop video modal scrolling and lock 2025-09-29 09:57:57 -04:00
thePR0M3TH3AN db8178e573 Ensure desktop modal owns its scroll surface 2025-09-29 09:47:09 -04:00
thePR0M3TH3AN 3f293f77fa Ensure desktop player modal scrolls 2025-09-29 09:26:33 -04:00
thePR0M3TH3AN ebb289a80c Fix scrolling for desktop video modal 2025-09-29 09:03:49 -04:00
thePR0M3TH3AN d5551fd6a9 Make desktop video modal occupy the full viewport 2025-09-29 08:54:28 -04:00
thePR0M3TH3AN 622c829b46 Make desktop video modal full screen 2025-09-29 08:41:11 -04:00
thePR0M3TH3AN 5f33aeea79 Merge pull request #147 from PR0M3TH3AN/codex/adjust-desktop-layout-for-smaller-display
Adjust desktop video grid density
2025-09-28 22:42:11 -04:00
thePR0M3TH3AN 8d2f32c62b Scale desktop layout down via root font sizing 2025-09-28 22:41:09 -04:00
thePR0M3TH3AN dd60ff07e9 Tighten desktop video grid spacing 2025-09-28 22:28:03 -04:00
thePR0M3TH3AN 5f20f1eb56 update 2025-09-28 22:23:21 -04:00
thePR0M3TH3AN 4cf6294591 Merge pull request #146 from PR0M3TH3AN/codex/move-hardcoded-values-to-central-file
Centralize admin configuration defaults
2025-09-28 22:21:55 -04:00
thePR0M3TH3AN d01fa93689 Centralize instance admin settings 2025-09-28 22:16:34 -04:00
thePR0M3TH3AN e92e681860 Merge pull request #145 from PR0M3TH3AN/codex/fix-disclaimer-modal-layout-on-desktop
Improve disclaimer modal layout
2025-09-28 22:12:16 -04:00
thePR0M3TH3AN cdf0b5f08c Fix nav auth button visibility after login 2025-09-28 22:09:53 -04:00
thePR0M3TH3AN 7e3acdfd68 Fix disclaimer modal dismissal 2025-09-28 22:00:58 -04:00
thePR0M3TH3AN 9c537ba7c7 Improve disclaimer modal layout 2025-09-28 21:57:10 -04:00
thePR0M3TH3AN 6f5a931caa Merge pull request #144 from PR0M3TH3AN/codex/add-user-level-block-list-feature
Prevent personal block list self-blocking
2025-09-28 21:51:40 -04:00
thePR0M3TH3AN 62cbc2a2eb Guard user block list from self entries 2025-09-28 21:47:56 -04:00
thePR0M3TH3AN 511a016db4 Merge pull request #143 from PR0M3TH3AN/codex/add-blacklist-button-for-admins
Add blacklist controls to moderator menus
2025-09-28 21:29:32 -04:00
thePR0M3TH3AN bd345f99d2 Fix channel overflow menu layout 2025-09-28 21:28:04 -04:00
thePR0M3TH3AN 169f0c952c Prevent moderators from blacklisting themselves 2025-09-28 21:23:33 -04:00
thePR0M3TH3AN d39ed45c5a Add moderator blacklist controls to menus 2025-09-28 21:17:44 -04:00
thePR0M3TH3AN a0356967ce Merge pull request #142 from PR0M3TH3AN/codex/automate-dm-for-npub-blacklist/whitelist
Update blacklist DM with appeals link
2025-09-28 21:03:13 -04:00
thePR0M3TH3AN 57552dcfe1 Add timeout when sending admin DMs 2025-09-28 20:57:46 -04:00
thePR0M3TH3AN bc08598237 Refine admin DM guidelines copy 2025-09-28 20:50:29 -04:00
thePR0M3TH3AN c9dff9d436 Merge pull request #141 from PR0M3TH3AN/codex/implement-admin-list-state-management
Add async admin list adapters
2025-09-28 20:18:48 -04:00
thePR0M3TH3AN 84b5ef77c0 Enforce remote admin list storage 2025-09-28 19:38:56 -04:00
thePR0M3TH3AN c6c297e16b Clear legacy admin defaults and trust adapter state 2025-09-28 19:21:51 -04:00
thePR0M3TH3AN 66595edaf6 Enforce admin list invariants 2025-09-28 19:17:31 -04:00
thePR0M3TH3AN a56568e3c7 Remove whitelist mode toggle from admin panel 2025-09-28 19:12:19 -04:00
thePR0M3TH3AN 014b1369b0 feat: add async admin list adapters 2025-09-28 19:04:28 -04:00
thePR0M3TH3AN c8afe45545 Merge pull request #140 from PR0M3TH3AN/codex/add-super-admin-and-moderator-roles
Add Super Admin moderation controls and centralized access checks
2025-09-28 18:47:46 -04:00
thePR0M3TH3AN 875cee4428 Add admin governance roles and UI 2025-09-28 18:38:10 -04:00
thePR0M3TH3AN 2eb00740fd Merge pull request #139 from PR0M3TH3AN/codex/align-video-modal-buttons-to-the-right
Fix video modal action alignment on mobile
2025-09-28 18:19:26 -04:00
thePR0M3TH3AN 7e8db028eb Ensure video modal actions stay right-aligned 2025-09-28 18:17:41 -04:00
thePR0M3TH3AN a53bded07f Merge pull request #136 from PR0M3TH3AN/codex/fix-menu-not-opening-on-video-player
Fix modal overflow so player menu is accessible
2025-09-28 11:17:11 -04:00
thePR0M3TH3AN 0d5a8f4017 Fix modal action alignment 2025-09-28 11:15:35 -04:00
thePR0M3TH3AN 468444a454 Relocate modal action icons 2025-09-28 11:05:47 -04:00
thePR0M3TH3AN a677683205 Fix video modal dropdown stacking 2025-09-28 10:39:28 -04:00
thePR0M3TH3AN e5f05f362c Fix modal overflow to show options menu 2025-09-28 10:18:43 -04:00
thePR0M3TH3AN 0041e24c87 Merge pull request #135 from PR0M3TH3AN/codex/add-profile-management-area-and-menus
Add profile modal scaffolding and overflow menus
2025-09-28 00:04:29 -04:00
thePR0M3TH3AN de2d6c409f Make modal overflow menu reuse card actions 2025-09-28 00:02:57 -04:00
thePR0M3TH3AN 5365fad888 Add profile modal scaffolding and video overflow menus 2025-09-27 23:55:18 -04:00
thePR0M3TH3AN 8966b426f8 Merge pull request #134 from PR0M3TH3AN/codex/make-bitvid-logo-clickable-to-index-page
Make BitVid logo link to home page
2025-09-27 23:38:59 -04:00
thePR0M3TH3AN 92a7576fcb Make BitVid logo link back to home 2025-09-27 23:38:40 -04:00
thePR0M3TH3AN dd2f3768db Merge pull request #133 from PR0M3TH3AN/codex/fix-video-note-editing-functionality
Allow clearing fields when editing videos
2025-09-27 23:27:25 -04:00
thePR0M3TH3AN b4e11cdce2 Fix clearing optional fields when editing videos 2025-09-27 23:25:12 -04:00
thePR0M3TH3AN c39fb6ffe2 Allow clearing values when editing videos 2025-09-27 23:19:45 -04:00
thePR0M3TH3AN f3293a8c29 Merge pull request #132 from PR0M3TH3AN/codex/refresh-page-on-new-profile-login
Refresh login state and stabilize WebTorrent cleanup
2025-09-27 23:11:15 -04:00
thePR0M3TH3AN 546fb4d1d2 Refresh UI on manual login and guard torrent cleanup 2025-09-27 23:07:08 -04:00
thePR0M3TH3AN c638b273fe Merge pull request #131 from PR0M3TH3AN/codex/update-edit-video-modal-form
Add comment toggle and magnet hint editing to video forms
2025-09-27 22:57:35 -04:00
thePR0M3TH3AN 3e083336b3 Remove Cloudflare quick upload section from custom form 2025-09-27 22:50:14 -04:00
thePR0M3TH3AN c851abc989 Add comment toggle and magnet hint editing to video forms 2025-09-27 22:39:25 -04:00
thePR0M3TH3AN 1c101266a6 Merge pull request #130 from PR0M3TH3AN/codex/update-modal-scroll-bars-style
Align scrollbar styling across modals and forms
2025-09-27 22:21:27 -04:00
thePR0M3TH3AN 30c260aa82 Improve NIP-07 login reliability 2025-09-27 22:19:30 -04:00
thePR0M3TH3AN fa638e08a5 Align scrollbar styling across modals 2025-09-27 22:11:25 -04:00
thePR0M3TH3AN 3da14ee174 Merge pull request #129 from PR0M3TH3AN/codex/add-revert-modal-for-note-versions
Add revert history modal for video notes
2025-09-27 22:07:36 -04:00
thePR0M3TH3AN 875094c800 Add revert history modal for video notes 2025-09-27 22:02:31 -04:00
thePR0M3TH3AN f1e0c63e01 Merge pull request #128 from PR0M3TH3AN/codex/add-warning-for-experimental-video-upload-feature
Add experimental warning to Cloudflare upload mode
2025-09-27 21:41:15 -04:00
thePR0M3TH3AN c773a07cdc Add experimental warning to Cloudflare upload mode 2025-09-27 21:36:21 -04:00
thePR0M3TH3AN 9de3592293 Merge pull request #127 from PR0M3TH3AN/codex/migrate-edit-button-to-modal-form
Replace video edit prompts with a modal form
2025-09-27 21:34:00 -04:00
thePR0M3TH3AN 1b4bf696b7 Add modal-driven video edit flow 2025-09-27 21:28:20 -04:00
thePR0M3TH3AN b5987c5923 Merge pull request #126 from PR0M3TH3AN/codex/add-profile-link-to-channel-page
Add profile channel link and reset view state between navigations
2025-09-27 21:13:41 -04:00
thePR0M3TH3AN 538526fbf5 Add channel link to profile modal and refresh view state 2025-09-27 21:11:46 -04:00
thePR0M3TH3AN c95e911f76 Merge pull request #125 from PR0M3TH3AN/codex/add-lightning-button-visibility-check
Hide Zap buttons for profiles without lightning addresses
2025-09-27 21:02:45 -04:00
thePR0M3TH3AN e297bd7bb3 Hide Zap buttons for profiles without lightning 2025-09-27 21:00:01 -04:00
thePR0M3TH3AN cc627c1e65 update 2025-09-27 20:47:55 -04:00
thePR0M3TH3AN f6612236f0 update 2025-09-27 20:20:05 -04:00
thePR0M3TH3AN e0e1eb381c update 2025-09-27 20:17:46 -04:00
thePR0M3TH3AN 5d0f122e48 Merge pull request #124 from PR0M3TH3AN/codex/improve-webtorrent-and-cdn-health-checks
Scope health probes to visible video cards
2025-09-27 20:16:56 -04:00
thePR0M3TH3AN 0461d06834 Tune WebTorrent probe scheduling 2025-09-27 20:13:47 -04:00
thePR0M3TH3AN f07ca9966c Defer health checks until video cards are visible 2025-09-27 15:33:41 -04:00
thePR0M3TH3AN 62b770eff0 Merge pull request #123 from PR0M3TH3AN/codex/add-support-for-multiple-webtorrent-health-checks
Increase WebTorrent grid health probe concurrency
2025-09-27 15:27:28 -04:00
thePR0M3TH3AN 12aaf63d3d Increase WebTorrent health probe concurrency 2025-09-27 15:24:26 -04:00
thePR0M3TH3AN 55cae181df Merge pull request #122 from PR0M3TH3AN/codex/implement-video-visibility-logic-based-on-indicators
Hide non-owner videos when CDN and WebTorrent are offline
2025-09-27 15:14:23 -04:00
thePR0M3TH3AN 2b0f3547cb Highlight owner offline videos with critical styling 2025-09-27 15:11:02 -04:00
thePR0M3TH3AN 2dfe20907b Treat missing CDN/WebTorrent sources as offline 2025-09-27 14:59:45 -04:00
thePR0M3TH3AN 0b4523b0be Fix duplicate card variable in health badge update 2025-09-27 14:50:56 -04:00
thePR0M3TH3AN 866e4f11fb Hide videos with dead CDN and torrent peers 2025-09-27 14:45:48 -04:00
thePR0M3TH3AN f6fa9daaad Merge pull request #121 from PR0M3TH3AN/codex/adjust-video-health-check-order
Prioritize viewport-visible cards for health probes
2025-09-27 14:32:10 -04:00
thePR0M3TH3AN 0217009752 Retry CDN probes after timeouts 2025-09-27 14:23:05 -04:00
thePR0M3TH3AN 1985b16064 Improve health probe prioritization 2025-09-27 13:49:45 -04:00
thePR0M3TH3AN 854216f331 Prioritize visible videos for health checks 2025-09-27 13:33:13 -04:00
thePR0M3TH3AN cbdab9f011 Merge pull request #120 from PR0M3TH3AN/codex/update-cdn-indicator-to-check-link-status
Verify CDN badge only marks healthy for playable videos
2025-09-27 13:15:43 -04:00
thePR0M3TH3AN 801bcd2345 Reset video element before WebTorrent fallback 2025-09-27 13:05:27 -04:00
thePR0M3TH3AN c6a838ebc2 Restore CDN playback after badge verification 2025-09-27 12:56:30 -04:00
thePR0M3TH3AN 2b1d0ca26e Verify CDN playback before marking healthy 2025-09-27 12:43:47 -04:00
thePR0M3TH3AN 5ef94c03ba Merge pull request #119 from PR0M3TH3AN/codex/fix-video-deletion-error-for-legacy-events
Handle legacy video deletions without d tags
2025-09-27 12:39:43 -04:00
thePR0M3TH3AN e6b6522f6f Handle legacy video deletions without d tags 2025-09-27 12:34:21 -04:00
thePR0M3TH3AN 742dd12915 Merge pull request #118 from PR0M3TH3AN/codex/fix-cloudflare-api-token-error
Allow manual Cloudflare R2 configuration without API token
2025-09-26 23:58:22 -04:00
thePR0M3TH3AN 1684c7070b Shorten bucket slugs and fix CORS refresh 2025-09-26 23:55:40 -04:00
thePR0M3TH3AN 8a278ca0ac Shorten Cloudflare bucket preview text 2025-09-26 23:46:52 -04:00
thePR0M3TH3AN c32d2fce12 Ensure manual R2 buckets exist before applying CORS 2025-09-26 23:40:41 -04:00
thePR0M3TH3AN 46c2b39aae Ensure R2 CORS when using manual access keys 2025-09-26 22:58:13 -04:00
thePR0M3TH3AN 67f80787d0 Allow manual Cloudflare bucket fallback 2025-09-26 22:48:19 -04:00
thePR0M3TH3AN 1b720b92d3 Merge pull request #117 from PR0M3TH3AN/codex/add-cloudflare-r2-auto-setup-and-upload
Add quick Cloudflare R2 upload helper to custom modal
2025-09-26 22:29:18 -04:00
thePR0M3TH3AN 7890a94949 Add quick Cloudflare R2 upload helper to custom modal 2025-09-26 22:24:01 -04:00
thePR0M3TH3AN 3fa08631ea Merge pull request #116 from PR0M3TH3AN/codex/add-r2-multipart-upload-support
Improve R2 auto-setup and multipart upload reliability
2025-09-26 21:55:42 -04:00
thePR0M3TH3AN 9da4e40ec9 Improve R2 auto-setup and multipart upload reliability 2025-09-26 21:51:22 -04:00
thePR0M3TH3AN 34d4895799 Merge pull request #115 from PR0M3TH3AN/codex/add-auto-create-r2-bucket-and-domain-setup
Add automated Cloudflare R2 bucket provisioning
2025-09-26 21:27:22 -04:00
thePR0M3TH3AN 7f70270889 Hide advanced Cloudflare settings behind toggle 2025-09-26 21:25:20 -04:00
thePR0M3TH3AN 9b4dd2a6ff Add automated Cloudflare R2 bucket provisioning 2025-09-26 20:46:18 -04:00
thePR0M3TH3AN f121a17cd9 Merge pull request #114 from PR0M3TH3AN/codex/add-cloudflare-r2-support-to-video-uploads
Implement Cloudflare R2 upload flow in modal
2025-09-26 20:22:36 -04:00
thePR0M3TH3AN 7d73a8c594 Enable scrolling inside upload modal 2025-09-26 20:09:49 -04:00
thePR0M3TH3AN ea300b47bf Add Cloudflare R2 upload workflow 2025-09-26 20:00:26 -04:00
thePR0M3TH3AN 8d5d79e96f Merge pull request #113 from PR0M3TH3AN/codex/add-toggle-for-video-upload-paths
Add Cloudflare toggle placeholder to upload modal
2025-09-26 19:12:17 -04:00
thePR0M3TH3AN b52be2ab42 Add source toggle to upload modal 2025-09-26 18:37:34 -04:00
45 changed files with 12060 additions and 832 deletions

No files matched your search

+2 -1
View File
@@ -1,3 +1,4 @@
saved_config.yaml
repo-context.txt
src/webtorrent-docs/
node_modules/
src/webtorrent-docs/
+24 -3
View File
@@ -73,12 +73,33 @@ To run **bitvid** locally:
```
3. Open the site in your browser:
```
http://localhost:8000
```
```
http://localhost:8000
```
### CSS build pipeline
Tailwind utilities are generated from `css/tailwind.source.css` using the build
script below. Run this whenever you add or remove classes in HTML or JavaScript
so the purged output stays in sync:
```bash
npm install # first run only
npm run build:css
```
The command compiles Tailwind with `tailwind.config.cjs`, runs it through the
PostCSS pipeline defined in `postcss.config.cjs` (for autoprefixing), and emits
the purged, minified stylesheet at `css/tailwind.generated.css`. Commit the
regenerated file alongside any template changes so deployments pick up the
latest styles.
### Configuration
- **`config/instance-config.js`**:
- Central place for instance-specific values like the Super Admin npub and the
default whitelist-only mode setting. Update the documented exports here when
preparing a new deployment.
- **`config.js`**:
- Toggle `isDevMode` for development (`true`) or production (`false`).
- **`js/constants.js`**:
+11
View File
@@ -0,0 +1,11 @@
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="5" cy="12" r="2" fill="#ffffff" />
<circle cx="12" cy="12" r="2" fill="#ffffff" />
<circle cx="19" cy="12" r="2" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 263 B

+6 -1
View File
@@ -33,7 +33,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<meta
name="description"
content="Explore Bitvid release notes, roadmap updates, and community highlights across the decentralized video network."
/>
<meta name="robots" content="index,follow" />
<title>Bitvid Blog</title>
<script src="./js/tracking.js" defer></script>
<script type="module" crossorigin>
var pv = Object.defineProperty;
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Application Form</title>
<!-- Include Tailwind CSS -->
<link href="css/tailwind.min.css" rel="stylesheet" />
<link href="css/tailwind.generated.css" rel="stylesheet" />
</head>
<body class="bg-gray-900 text-gray-100">
<!-- Application Form Modal -->
+14 -17
View File
@@ -20,21 +20,18 @@
>
<!-- The .modal-content, same classes: bg-gray-900, w-full max-w-[90%], etc. -->
<div
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative"
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative flex flex-col"
>
<!-- Sticky top bar, if you want a top heading or 'X' button up here -->
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
>
class="modal-header sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
>
</div>
<!-- Main Content -->
<div class="p-6">
<div class="modal-body flex-1 overflow-y-auto">
<!-- Scrollable disclaimers area -->
<div
class="space-y-6 text-gray-300"
style="max-height: 70vh; overflow-y: auto"
>
<div class="space-y-6 text-gray-300">
<!-- Example: Insert your disclaimers here -->
<div class="flex justify-center mb-8">
@@ -114,16 +111,16 @@
</div>
</div>
</div>
</div>
<!-- Button at bottom -->
<div class="mt-6">
<button
id="acceptDisclaimer"
class="w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-black transition-colors duration-200"
>
I Understand
</button>
</div>
<!-- Button at bottom -->
<div class="modal-footer">
<button
id="acceptDisclaimer"
class="w-full bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-black transition-colors duration-200"
>
I Understand
</button>
</div>
</div>
</div>
+249
View File
@@ -0,0 +1,249 @@
<!-- components/edit-video-modal.html -->
<div
id="editVideoModal"
class="fixed inset-0 z-50 hidden"
style="background: transparent"
>
<div
id="editVideoModalOverlay"
class="absolute inset-0 z-10"
style="
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
"
></div>
<div
class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20"
>
<div
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-4xl my-0 rounded-lg overflow-y-auto relative"
style="max-height: 90vh"
>
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150 p-4 flex items-center justify-between"
>
<h2 class="text-xl font-bold text-white">Edit Video Note</h2>
<button
id="closeEditVideoModal"
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="p-6 space-y-6">
<p class="text-sm text-gray-400">
Fields that already have values are locked. Click <strong>Edit field</strong> to make changes.
</p>
<form id="editVideoForm" class="space-y-5">
<div class="space-y-1">
<label for="editVideoTitle" class="block text-sm font-medium text-gray-200">
Title
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="text"
id="editVideoTitle"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="Video title"
/>
<button
type="button"
class="edit-field-button hidden"
data-edit-target="editVideoTitle"
>
Edit field
</button>
</div>
</div>
<div class="space-y-1">
<label for="editVideoUrl" class="block text-sm font-medium text-gray-200">
Hosted video URL (https)
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="url"
id="editVideoUrl"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="https://cdn.example.com/video.mp4"
/>
<button
type="button"
class="edit-field-button hidden"
data-edit-target="editVideoUrl"
>
Edit field
</button>
</div>
</div>
<div class="space-y-1">
<label for="editVideoWs" class="block text-sm font-medium text-gray-200">
Web seed (ws=)
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="url"
id="editVideoWs"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="https://cdn.example.com/video.mp4"
/>
<button
type="button"
class="edit-field-button hidden"
data-edit-target="editVideoWs"
>
Edit field
</button>
</div>
</div>
<div class="space-y-1">
<label for="editVideoXs" class="block text-sm font-medium text-gray-200">
.torrent URL (xs=)
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="url"
id="editVideoXs"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="https://cdn.example.com/video.torrent"
/>
<button
type="button"
class="edit-field-button hidden"
data-edit-target="editVideoXs"
>
Edit field
</button>
</div>
</div>
<div class="space-y-1">
<label for="editVideoMagnet" class="block text-sm font-medium text-gray-200">
Magnet link
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="text"
id="editVideoMagnet"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="magnet:?xt=urn:btih:..."
/>
<button
type="button"
class="edit-field-button hidden"
data-edit-target="editVideoMagnet"
>
Edit field
</button>
</div>
</div>
<div class="space-y-1">
<label for="editVideoThumbnail" class="block text-sm font-medium text-gray-200">
Thumbnail URL
</label>
<div class="flex flex-col gap-2 sm:flex-row">
<input
type="url"
id="editVideoThumbnail"
class="flex-1 rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="https://cdn.example.com/preview.jpg"
/>
<button
type="button"
class="edit-field-button hidden"
data-edit-target="editVideoThumbnail"
>
Edit field
</button>
</div>
</div>
<div class="space-y-1">
<label for="editVideoDescription" class="block text-sm font-medium text-gray-200">
Description
</label>
<div class="flex flex-col gap-2">
<textarea
id="editVideoDescription"
rows="4"
class="w-full rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
placeholder="What is this video about?"
></textarea>
<button
type="button"
class="edit-field-button self-start hidden"
data-edit-target="editVideoDescription"
>
Edit field
</button>
</div>
</div>
<div class="space-y-1">
<label for="editEnableComments" class="block text-sm font-medium text-gray-200">
Enable comments
</label>
<div
class="flex flex-col gap-2 rounded-md border border-gray-800 bg-black/30 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-3">
<input
type="checkbox"
id="editEnableComments"
class="h-5 w-5 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
/>
<span class="text-sm text-gray-300">
Allow replies once comment threads are available.
</span>
</div>
<button
type="button"
class="edit-field-button hidden"
data-edit-target="editEnableComments"
>
Edit field
</button>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
<button
type="button"
id="cancelEditVideo"
class="w-full sm:w-auto px-4 py-2 rounded-md bg-gray-800 text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-gray-500"
>
Cancel
</button>
<button
type="submit"
id="submitEditVideo"
class="w-full sm:w-auto px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-blue-500"
>
Save changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@@ -112,16 +112,19 @@
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background-color: #3b82f6;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
}
/* Custom Scrollbar styling for Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #3b82f6 transparent;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
</style>
<!-- Load nostr‑tools v2.10.4 -->
@@ -112,16 +112,19 @@
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background-color: #3b82f6;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
}
/* Custom Scrollbar styling for Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #3b82f6 transparent;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
</style>
<!-- Load nostr‑tools v2.10.4 -->
@@ -105,16 +105,19 @@
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background-color: #3b82f6;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
}
/* Custom Scrollbar styling for Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #3b82f6 transparent;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
</style>
<!-- Load nostr‑tools v2.10.4 -->
@@ -119,16 +119,19 @@
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background-color: #3b82f6;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
}
/* Custom Scrollbar styling for Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #3b82f6 transparent;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
</style>
<!-- Load nostr‑tools v2.10.4 -->
@@ -112,15 +112,18 @@
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
background: rgba(255, 255, 255, 0.1);
}
::-webkit-scrollbar-thumb {
background-color: #3b82f6;
background-color: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.4);
}
* {
scrollbar-width: thin;
scrollbar-color: #3b82f6 transparent;
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
}
</style>
<!-- Load nostr‑tools v2.10.4 -->
+286 -16
View File
@@ -2,6 +2,10 @@
<div
id="profileModal"
class="fixed inset-0 z-50 hidden"
role="dialog"
aria-modal="true"
aria-labelledby="profileModalTitle"
tabindex="-1"
style="background: transparent"
>
<!-- Overlay Layer with dark background and blur -->
@@ -27,7 +31,7 @@
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
>
<h2 class="text-xl font-bold text-white">Profile</h2>
<h2 id="profileModalTitle" class="text-xl font-bold text-white">Profile</h2>
<button
id="closeProfileModal"
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
@@ -50,28 +54,294 @@
</div>
<!-- Modal Body -->
<div class="p-6 flex-1 text-gray-200 space-y-4">
<div class="flex items-center space-x-4">
<img
id="profileModalAvatar"
src="assets/svg/default-profile.svg"
alt="Profile"
class="w-16 h-16 object-cover rounded-full"
/>
<p id="profileModalName" class="text-lg font-semibold truncate">
Loading...
</p>
<div class="p-6 flex-1 overflow-hidden">
<div class="flex flex-col lg:flex-row gap-6 h-full">
<!-- Left navigation column -->
<nav
class="flex lg:flex-col gap-2 flex-shrink-0"
aria-label="Profile sections"
>
<button
id="profileNavAccount"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-white bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
aria-controls="profilePaneAccount"
aria-selected="true"
>
Account
</button>
<button
id="profileNavRelays"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
aria-controls="profilePaneRelays"
aria-selected="false"
>
Relays
</button>
<button
id="profileNavBlocked"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
aria-controls="profilePaneBlocked"
aria-selected="false"
>
Blocked
</button>
<button
id="profileNavAdmin"
type="button"
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900 hidden"
aria-controls="profilePaneAdmin"
aria-selected="false"
>
Admin (Moderation)
</button>
</nav>
<!-- Right content panes -->
<div class="flex-1 min-h-[320px] overflow-y-auto pr-1">
<section
id="profilePaneAccount"
class="profile-pane space-y-6"
role="tabpanel"
aria-labelledby="profileNavAccount"
>
<div class="flex items-center space-x-4">
<img
id="profileModalAvatar"
src="assets/svg/default-profile.svg"
alt="Profile"
class="w-16 h-16 object-cover rounded-full"
/>
<p id="profileModalName" class="text-lg font-semibold truncate">
Loading...
</p>
</div>
<a
id="profileChannelLink"
href="#"
class="inline-flex items-center text-sm text-blue-400 hover:text-blue-300 transition-colors hidden"
>
View your channel
</a>
<p class="text-sm text-gray-400">
Manage your account details and upcoming preferences here.
</p>
</section>
<section
id="profilePaneRelays"
class="profile-pane hidden space-y-6"
role="tabpanel"
aria-labelledby="profileNavRelays"
>
<div class="space-y-4">
<p class="text-sm text-gray-400">
Configure the relays Bitvid will use to discover and publish your videos.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="relayInput">
<span class="mb-2 block font-medium">Add a relay</span>
<input
id="relayInput"
type="url"
placeholder="wss://relay.example.com"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</label>
<div class="flex gap-2">
<button
id="addRelayBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Add relay
</button>
<button
id="restoreRelaysBtn"
type="button"
class="px-4 py-2 rounded-md bg-gray-800 text-sm font-medium text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Restore defaults
</button>
</div>
</div>
</div>
<ul id="relayList" class="space-y-3"></ul>
</section>
<section
id="profilePaneBlocked"
class="profile-pane hidden space-y-4"
role="tabpanel"
aria-labelledby="profileNavBlocked"
>
<p class="text-sm text-gray-400">
Creators you block are hidden from your BitVid experience. This
list is private to your account.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="blockedInput">
<span class="mb-2 block font-medium">Creator npub</span>
<input
id="blockedInput"
type="text"
inputmode="text"
autocomplete="off"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="npub1..."
/>
</label>
<button
id="addBlockedBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Block creator
</button>
</div>
<div
id="blockedEmpty"
class="rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
>
You haven’t blocked any creators yet.
</div>
<ul id="blockedList" class="space-y-3"></ul>
</section>
<section
id="profilePaneAdmin"
class="profile-pane hidden space-y-6"
role="tabpanel"
aria-labelledby="profileNavAdmin"
>
<div class="space-y-2">
<h3 class="text-lg font-semibold text-white">Platform moderation</h3>
<p class="text-sm text-gray-400">
Manage BitVid’s moderator roles and creator allow/block lists. Changes apply instantly across the app.
</p>
</div>
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 class="text-base font-semibold text-white">Moderators</h4>
<p class="text-xs uppercase tracking-wide text-gray-400">
Super Admin only
</p>
</div>
<p class="text-sm text-gray-400">
Moderators can edit the whitelist and blacklist but cannot manage other moderators or whitelist mode.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="adminModeratorInput">
<span class="mb-2 block font-medium">Moderator npub</span>
<input
id="adminModeratorInput"
type="text"
inputmode="text"
autocomplete="off"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="npub1..."
/>
</label>
<button
id="adminAddModeratorBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Add moderator
</button>
</div>
<div
id="adminModeratorsEmpty"
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
>
No moderators configured yet.
</div>
<ul id="adminModeratorList" class="space-y-3"></ul>
</div>
<div id="adminWhitelistSection" class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 class="text-base font-semibold text-white">Whitelist</h4>
</div>
<p class="text-sm text-gray-400">
Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="adminWhitelistInput">
<span class="mb-2 block font-medium">Creator npub</span>
<input
id="adminWhitelistInput"
type="text"
inputmode="text"
autocomplete="off"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="npub1..."
/>
</label>
<button
id="adminAddWhitelistBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Add to whitelist
</button>
</div>
<div
id="adminWhitelistEmpty"
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
>
No whitelisted creators yet.
</div>
<ul id="adminWhitelistList" class="space-y-3"></ul>
</div>
<div id="adminBlacklistSection" class="space-y-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 class="text-base font-semibold text-white">Blacklist</h4>
</div>
<p class="text-sm text-gray-400">
Creators on the blacklist are hidden from BitVid everywhere. Adding someone removes them from the whitelist.
</p>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1 text-sm text-gray-300" for="adminBlacklistInput">
<span class="mb-2 block font-medium">Creator npub</span>
<input
id="adminBlacklistInput"
type="text"
inputmode="text"
autocomplete="off"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="npub1..."
/>
</label>
<button
id="adminAddBlacklistBtn"
type="button"
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Add to blacklist
</button>
</div>
<div
id="adminBlacklistEmpty"
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
>
No blacklisted creators yet.
</div>
<ul id="adminBlacklistList" class="space-y-3"></ul>
</div>
</section>
</div>
</div>
<p class="text-sm text-gray-400">
Some future profile details or settings could go here.
</p>
</div>
<!-- Modal Footer -->
<div class="p-4 border-t border-gray-700 flex items-center justify-end">
<button
id="profileLogoutBtn"
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Logout
</button>
+121
View File
@@ -0,0 +1,121 @@
<!-- components/revert-video-modal.html -->
<div
id="revertVideoModal"
class="fixed inset-0 z-50 hidden"
style="background: transparent"
>
<div
id="revertVideoModalOverlay"
class="absolute inset-0 z-10"
style="
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
"
></div>
<div
class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20"
>
<div
class="modal-content bg-gray-900 w-full max-w-[95%] lg:max-w-5xl my-0 rounded-lg overflow-hidden relative"
style="max-height: 92vh"
>
<div
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150 p-4 flex items-center justify-between"
>
<div class="flex flex-col">
<h2 id="revertModalTitle" class="text-xl font-bold text-white">
Revert Video Note
</h2>
<p id="revertModalSubtitle" class="text-xs text-gray-400">
Review previous versions before restoring an older state.
</p>
</div>
<button
id="closeRevertVideoModal"
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="p-6 space-y-6 overflow-y-auto" style="max-height: calc(92vh - 4rem)">
<div class="rounded-md border border-gray-700 bg-gray-800/60 p-4 text-sm text-gray-300">
<p>
Selecting an older version will mark any newer edits as reverted.
The previous revision will become the active note on the relays.
</p>
<p class="mt-2 text-xs text-gray-400">
Versions are grouped by their <code class="px-1 py-0.5 bg-gray-900/60 rounded border border-gray-700 text-[0.7rem]">d</code>
tag or shared <code class="px-1 py-0.5 bg-gray-900/60 rounded border border-gray-700 text-[0.7rem]">videoRootId</code>. This ensures legacy
clients see consistent history when multiple edits were published.
</p>
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(240px,280px)_1fr]">
<aside class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-200">Revision history</h3>
<span
id="revertHistoryCount"
class="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-400"
></span>
</div>
<div
id="revertVersionsList"
class="space-y-2 overflow-y-auto pr-1"
style="max-height: 48vh"
></div>
</aside>
<section
id="revertVersionDetails"
class="rounded-lg border border-gray-800 bg-gray-900/70 p-4 text-sm text-gray-200"
aria-live="polite"
>
<div id="revertVersionPlaceholder" class="flex h-full items-center justify-center text-center text-gray-500">
Select an earlier revision to inspect its metadata.
</div>
</section>
</div>
<p
id="revertSelectionStatus"
class="text-xs text-gray-400"
></p>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-end">
<button
type="button"
id="cancelRevertVideo"
class="w-full sm:w-auto px-4 py-2 rounded-md bg-gray-800 text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-gray-500"
>
Cancel
</button>
<button
type="button"
id="confirmRevertVideo"
class="w-full sm:w-auto px-4 py-2 rounded-md bg-red-600 text-white hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-red-500 disabled:opacity-60 disabled:cursor-not-allowed"
disabled
>
Revert to selected version
</button>
</div>
</div>
</div>
</div>
</div>
+340 -4
View File
@@ -19,7 +19,7 @@
class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20"
>
<div
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative"
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-y-auto relative"
style="max-height: 90vh"
>
<!-- Top Bar (similar to video-modal) -->
@@ -50,8 +50,34 @@
</div>
<!-- Form Content -->
<div class="p-6 space-y-4">
<form id="uploadForm" class="space-y-4">
<div class="p-6 space-y-6">
<div class="flex justify-center">
<div
class="inline-flex items-center rounded-full bg-gray-800/80 p-1 text-sm"
role="group"
aria-label="Upload mode selector"
>
<button
type="button"
class="upload-mode-toggle flex-1 rounded-full px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
data-upload-mode="custom"
aria-pressed="true"
>
Custom
</button>
<button
type="button"
class="upload-mode-toggle flex-1 rounded-full px-4 py-2 font-medium text-gray-300 transition-colors hover:bg-gray-700/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
data-upload-mode="cloudflare"
aria-pressed="false"
>
Cloudflare
</button>
</div>
</div>
<div id="customUploadSection">
<form id="uploadForm" class="space-y-4">
<p class="text-sm text-gray-400">
Share a title plus either a hosted video URL or a magnet link. Providing both helps Bitvid fall back gracefully.
</p>
@@ -121,7 +147,6 @@
cost control.
</p>
</div>
<div>
<label
for="uploadThumbnail"
@@ -147,6 +172,23 @@
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
></textarea>
</div>
<div class="flex items-start gap-3 rounded-md border border-gray-800 bg-black/30 p-3">
<input
type="checkbox"
id="uploadEnableComments"
class="mt-1 h-5 w-5 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
checked
/>
<div>
<label for="uploadEnableComments" class="text-sm font-medium text-gray-200"
>Enable comments</label
>
<p class="mt-1 text-xs text-gray-400">
Keep this on to let viewers discuss the video once comment threads launch.
</p>
</div>
</div>
<!--
<div class="flex items-center space-x-2">
<input
@@ -166,6 +208,300 @@
Publish
</button>
</form>
</div>
<div id="cloudflareUploadSection" class="hidden">
<div class="space-y-8">
<p class="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-300">
This feature is experimental and may not be working correctly.
</p>
<section class="rounded-lg border border-gray-800 bg-gray-900/60 p-5">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-100">
Cloudflare R2 credentials
</h3>
<span class="text-xs uppercase tracking-wide text-gray-500">
stored locally
</span>
</div>
<p class="mb-4 text-sm text-gray-400">
Paste your Cloudflare R2 API credentials. We keep them in your
browser so uploads happen client-side.
</p>
<form id="cloudflareSettingsForm" class="space-y-4">
<div class="grid gap-3 md:grid-cols-2">
<div>
<label for="r2AccountId" class="block text-sm font-medium text-gray-200"
>Account ID</label
>
<input
id="r2AccountId"
type="text"
autocomplete="off"
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
required
/>
</div>
<div>
<label for="r2AccessKeyId" class="block text-sm font-medium text-gray-200"
>S3 Access Key ID</label
>
<input
id="r2AccessKeyId"
type="text"
autocomplete="off"
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="AKIA..."
required
/>
</div>
</div>
<div>
<label for="r2SecretAccessKey" class="block text-sm font-medium text-gray-200"
>S3 Secret Access Key</label
>
<input
id="r2SecretAccessKey"
type="password"
autocomplete="off"
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div class="border-t border-gray-800 pt-4">
<button
type="button"
id="cloudflareAdvancedToggle"
class="flex items-center gap-2 text-sm font-medium text-gray-300 transition-colors hover:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
aria-expanded="false"
aria-controls="cloudflareAdvancedFields"
>
<svg
id="cloudflareAdvancedToggleIcon"
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-gray-300 transition-transform duration-150 transform"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span id="cloudflareAdvancedToggleLabel">Show advanced options</span>
</button>
<div
id="cloudflareAdvancedFields"
class="mt-3 space-y-4 hidden"
>
<div>
<label for="r2ApiToken" class="block text-sm font-medium text-gray-200"
>Cloudflare API Token (optional)</label
>
<input
id="r2ApiToken"
type="password"
autocomplete="off"
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Token with Workers R2 Storage Write"
/>
<p class="mt-1 text-xs text-gray-500">
Used to create buckets, configure CORS, and attach domains automatically.
</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label for="r2ZoneId" class="block text-sm font-medium text-gray-200"
>Zone ID (optional)</label
>
<input
id="r2ZoneId"
type="text"
autocomplete="off"
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
<p class="mt-1 text-xs text-gray-500">
Required when you want us to attach a custom domain on your behalf.
</p>
</div>
<div>
<label for="r2BaseDomain" class="block text-sm font-medium text-gray-200"
>Base domain (optional)</label
>
<input
id="r2BaseDomain"
type="text"
autocomplete="off"
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="cdn.example.com"
/>
<p class="mt-1 text-xs text-gray-500">
We'll attach <code class="rounded bg-gray-800 px-1 py-0.5">npub.&lt;baseDomain&gt;</code>.
Leave blank to use the managed <code class="rounded bg-gray-800 px-1 py-0.5">*.r2.dev</code> domain.
</p>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
type="submit"
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Save settings
</button>
<button
type="button"
id="cloudflareClearSettings"
class="rounded-md border border-gray-600 px-4 py-2 text-sm font-semibold text-gray-200 transition hover:border-gray-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Clear
</button>
<span
id="cloudflareSettingsStatus"
class="text-xs text-gray-400"
role="status"
></span>
</div>
</form>
</section>
<section class="rounded-lg border border-gray-800 bg-gray-900/60 p-5">
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-100">
Upload &amp; publish
</h3>
<p class="mt-1 text-sm text-gray-400">
Choose a file, we will upload it to R2, fill the hosted URL,
and publish your note automatically.
</p>
</div>
<form id="cloudflareUploadForm" class="space-y-4">
<div>
<label for="cloudflareTitle" class="block text-sm font-medium text-gray-200"
>Title</label
>
<input
id="cloudflareTitle"
type="text"
required
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label for="cloudflareDescription" class="block text-sm font-medium text-gray-200"
>Description (optional)</label
>
<textarea
id="cloudflareDescription"
rows="3"
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label for="cloudflareThumbnail" class="block text-sm font-medium text-gray-200"
>Thumbnail URL (optional)</label
>
<input
id="cloudflareThumbnail"
type="url"
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label for="cloudflareMagnet" class="block text-sm font-medium text-gray-200"
>Magnet link (optional)</label
>
<input
id="cloudflareMagnet"
type="text"
placeholder="magnet:?xt=urn:btih:..."
class="mt-1 w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<input
id="cloudflareWs"
type="url"
placeholder="Web seed (ws=)"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
id="cloudflareXs"
type="url"
placeholder=".torrent URL (xs=)"
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-gray-100 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div class="flex items-start gap-3 rounded-md border border-gray-800 bg-black/30 p-3">
<input
type="checkbox"
id="cloudflareEnableComments"
class="mt-1 h-5 w-5 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500"
checked
/>
<div>
<label for="cloudflareEnableComments" class="text-sm font-medium text-gray-200"
>Enable comments</label
>
<p class="mt-1 text-xs text-gray-400">
Publish with comments switched on. Toggle off to quietly disable replies on this video.
</p>
</div>
</div>
<div>
<label for="cloudflareFile" class="block text-sm font-medium text-gray-200"
>Video or HLS file</label
>
<input
id="cloudflareFile"
type="file"
accept="video/*,.m3u8,.ts,.mp4,.webm,.mov,.mkv,.mpg,.mpeg"
class="mt-1 w-full rounded-md border border-dashed border-gray-600 bg-gray-900 px-3 py-4 text-sm text-gray-300 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
<p class="mt-1 text-xs text-gray-500">
We use multipart uploads with automatic bucket creation.
Large files are supported.
</p>
</div>
<div class="rounded-md border border-gray-800 bg-black/40 p-3 text-xs text-gray-400">
<p>
<strong class="text-gray-200">Bucket preview:</strong>
<span id="cloudflareBucketPreview" class="ml-1">We'll show your
bucket and public domain after setup.</span>
</p>
</div>
<div class="space-y-2">
<div
id="cloudflareProgressBar"
class="hidden h-2 w-full overflow-hidden rounded-full bg-gray-800"
>
<div
id="cloudflareProgressFill"
class="h-full w-0 bg-blue-500 transition-all duration-150"
></div>
</div>
<p
id="cloudflareUploadStatus"
class="text-xs text-gray-400"
role="status"
></p>
</div>
<button
type="submit"
id="cloudflareUploadButton"
class="w-full rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:bg-blue-900"
>
Upload to R2 &amp; publish
</button>
</form>
</section>
</div>
</div>
</div>
</div>
</div>
+77 -23
View File
@@ -1,15 +1,11 @@
<!-- components/video-modal.html -->
<div id="playerModal" class="fixed inset-0 bg-black/90 z-50 hidden">
<div
class="modal-container h-full w-full flex items-start justify-center overflow-y-auto"
>
<div
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative"
>
<div id="playerModal" class="player-modal hidden">
<div class="player-modal__container">
<div class="player-modal__content">
<!-- Navigation bar - sliding at top -->
<div
id="modalNav"
class="sticky top-0 z-60 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150"
class="modal-nav sticky top-0 z-60 transition-transform duration-150"
>
<div class="flex items-center px-6 py-4">
<button
@@ -31,19 +27,7 @@
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
</div>
</div>
<div class="video-container w-full bg-black">
<video id="modalVideo" class="w-full aspect-video" controls></video>
</div>
<div class="video-info p-6">
<!-- Title and icons row -->
<div class="flex items-center justify-between mb-2">
<h2 id="videoTitle" class="text-2xl font-bold text-white"></h2>
<div class="flex items-center space-x-2">
<!-- Copy Magnet Button (circular) -->
<div class="modal-actions">
<button id="copyMagnetBtn" class="icon-button">
<img
src="assets/svg/copy-magnet.svg"
@@ -51,7 +35,6 @@
class="icon-image"
/>
</button>
<!-- Zap Button (circular) -->
<button
id="modalZapBtn"
type="button"
@@ -64,7 +47,6 @@
class="icon-image"
/>
</button>
<!-- Share Button (circular) -->
<button id="shareBtn" class="icon-button">
<img
src="assets/svg/share-video.svg"
@@ -72,8 +54,80 @@
class="icon-image"
/>
</button>
<div
class="modal-actions__overflow"
data-more-menu-wrapper="true"
>
<button
id="modalMoreBtn"
type="button"
class="icon-button"
aria-haspopup="true"
aria-expanded="false"
aria-label="More"
data-more-dropdown="modal"
>
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5" />
</button>
<div
id="moreDropdown-modal"
class="hidden absolute right-0 top-14 w-44 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
role="menu"
data-more-menu="true"
>
<div class="py-1">
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="open-channel"
data-context="modal"
>
Open channel
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="copy-link"
data-context="modal"
>
Copy link
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="blacklist-author"
data-context="modal"
data-blacklist-menu="true"
>
Blacklist creator
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="block-author"
data-context="modal"
>
Block creator
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="report"
data-context="modal"
>
Report
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="player-modal__video modal-video-shell">
<video id="modalVideo" class="w-full aspect-video" controls></video>
</div>
<div class="player-modal__info modal-info-panel">
<!-- Title and icons row -->
<div class="flex items-center justify-between mb-2">
<h2 id="videoTitle" class="text-2xl font-bold text-white"></h2>
</div>
<!-- Video Timestamp -->
<div
+46
View File
@@ -0,0 +1,46 @@
// config/instance-config.js
// -----------------------------------------------------------------------------
// BitVid instance configuration
// -----------------------------------------------------------------------------
//
// This file consolidates the values that operators usually customize when they
// deploy their own BitVid instance. Update the exports below to match your
// environment, commit the changes, and redeploy. Leaving this file in the repo
// (instead of hidden in environment variables) makes it easy for future
// maintainers to understand how your instance is configured.
//
// Each setting includes guidance about how BitVid uses the value and what
// adjustments are safe. When in doubt, mirror the structure shown here so other
// contributors can follow along.
/**
* The primary administrator for this BitVid instance.
*
* BitVid treats this Nostr public key (npub) as the "Super Admin". This user
* cannot be removed from moderator lists, and only they can promote new
* moderators or toggle whitelist-only mode. Replace the string below with the
* npub of the account you want to act as the ultimate authority for your
* deployment.
*/
export const ADMIN_SUPER_NPUB =
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx";
/**
* Storage key used to persist whitelist-only mode in the browser.
*
* You usually do not need to change this, but the export lives here so that all
* whitelist-related knobs are grouped together. If you do change the key, make
* sure to migrate any previously stored values in localStorage.
*/
export const ADMIN_WHITELIST_MODE_STORAGE_KEY = "bitvid_admin_whitelist_mode";
/**
* Whether whitelist-only mode should be enabled the first time an operator
* loads the admin dashboard.
*
* Set this to `true` if you want BitVid to start with whitelist-only access and
* require an explicit opt-out. Set to `false` to allow all creators by default.
* Operators can still toggle the mode at runtime; this value only controls the
* default state when no preference has been stored in localStorage yet.
*/
export const DEFAULT_WHITELIST_MODE_ENABLED = true;
+292 -103
View File
@@ -3,12 +3,51 @@
--color-card: #1e293b;
--color-primary: #fe0032;
--color-secondary: #ff93a5;
--color-card-critical: #3f0b13;
--color-text: #f8fafc;
--color-muted: #94a3b8;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--shadow-sm: 0 0.0625rem 0.125rem 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 0.25rem 0.375rem -0.0625rem rgb(0 0 0 / 0.1);
--shadow-lg: 0 0.625rem 0.9375rem -0.1875rem rgb(0 0 0 / 0.1);
--modal-motion-duration: 160ms;
--scrollbar-width: 0.5rem;
--scrollbar-track: rgba(255, 255, 255, 0.1);
--scrollbar-thumb: rgba(255, 255, 255, 0.3);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.4);
}
html {
font-size: 100%;
}
@media (min-width: 1024px) {
html {
font-size: 75%;
}
}
/* Global scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
*::-webkit-scrollbar {
width: var(--scrollbar-width);
height: var(--scrollbar-width);
}
*::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
*::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 0.25rem;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Core Styles */
@@ -36,7 +75,7 @@ header img {
}
.container {
max-width: 1480px;
max-width: 92.5rem;
margin: 0 auto;
padding: 1rem;
}
@@ -44,7 +83,7 @@ header img {
/* Video Grids */
#videoList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 2rem;
padding: 1.5rem 0;
}
@@ -52,7 +91,7 @@ header img {
/* Subscriptions grid: same pattern as #videoList */
#subscriptionsVideoList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 2rem;
padding: 1.5rem 0;
}
@@ -60,7 +99,7 @@ header img {
/* Now also match for channelVideoList (channel profile) */
#channelVideoList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: 2rem;
padding: 1.5rem 0;
}
@@ -74,6 +113,19 @@ header img {
box-shadow: var(--shadow-md);
}
.video-card[data-owner-is-viewer="false"][data-url-health-state="offline"][data-stream-health-state="unhealthy"] {
display: none;
}
.video-card[data-owner-is-viewer="true"][data-url-health-state="offline"][data-stream-health-state="unhealthy"] {
background-color: var(--color-card-critical);
box-shadow: 0 0 0 0.0625rem rgba(239, 68, 68, 0.35), var(--shadow-md);
}
.video-card[data-owner-is-viewer="true"][data-url-health-state="offline"][data-stream-health-state="unhealthy"]:hover {
box-shadow: 0 0 0 0.0625rem rgba(239, 68, 68, 0.45), var(--shadow-lg);
}
.video-card--enter {
opacity: 0;
animation: video-card-fade-in 220ms ease-out forwards;
@@ -82,7 +134,7 @@ header img {
@keyframes video-card-fade-in {
from {
opacity: 0;
transform: translateY(8px);
transform: translateY(0.5rem);
}
to {
opacity: 1;
@@ -161,7 +213,7 @@ header img {
}
.video-card:hover {
transform: translateY(-4px);
transform: translateY(-0.25rem);
box-shadow: var(--shadow-lg);
}
@@ -195,21 +247,26 @@ header img {
#playerModal {
position: fixed;
inset: 0;
background-color: rgb(0 0 0 / 0.9); /* You can adjust opacity if needed */
z-index: 50;
display: none; /* Hidden by default */
z-index: 100;
display: none;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
width: 100vw;
height: 100vh;
background-color: #0f172a;
color: #e2e8f0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); /* For Safari support */
-webkit-overflow-scrolling: touch;
}
#nostrFormModal,
#profileModal,
#uploadModal {
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px); /* For Safari */
backdrop-filter: blur(0.625rem);
-webkit-backdrop-filter: blur(0.625rem); /* For Safari */
}
/* If you ever want to show it, add ".flex" class dynamically */
@@ -217,6 +274,82 @@ header img {
display: flex;
}
#playerModal .player-modal__container {
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 100vh;
padding: 0;
margin: 0;
}
#playerModal .player-modal__content {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
min-height: 100%;
max-width: none;
background-color: #0f172a;
overflow: visible;
box-shadow: none;
animation: fadeIn var(--modal-motion-duration) ease-out;
}
#playerModal .player-modal__video {
width: 100%;
position: sticky;
top: 0;
z-index: 51;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(0.75rem, 3.5vw, 2.5rem);
background-color: var(--color-bg);
border-bottom: 0.0625rem solid rgba(148, 163, 184, 0.18);
}
#playerModal .player-modal__video video {
width: 100%;
aspect-ratio: 16 / 9;
background-color: #000;
border-radius: 0.75rem;
display: block;
box-shadow: 0 1.125rem 2.75rem rgba(8, 11, 30, 0.55);
max-height: min(80vh, calc(100vw * 9 / 16));
object-fit: contain;
}
#playerModal .player-modal__info {
flex: 0 0 auto;
padding: clamp(1.5rem, 3vw, 2.5rem);
padding-bottom: clamp(2.5rem, 6vw, 4rem);
background-color: var(--color-bg);
border-top: 0.0625rem solid rgba(148, 163, 184, 0.16);
box-shadow: inset 0 1px 0 rgba(15, 23, 42, 0.35);
}
html.modal-open {
overflow: hidden;
}
body.modal-open {
overflow: hidden;
}
html.modal-open,
body.modal-open {
height: 100%;
}
body.modal-open #app {
overflow: hidden;
}
/* Modal Content Container */
.modal-content {
width: 100%;
@@ -230,7 +363,7 @@ header img {
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
transform: translateY(1.25rem);
}
to {
opacity: 1;
@@ -244,15 +377,6 @@ header img {
}
}
/* Video Container */
.video-container {
width: 100%;
background-color: black;
position: sticky;
top: 0;
z-index: 51;
}
/* Modal Video */
#modalVideo {
width: 100%;
@@ -260,33 +384,74 @@ header img {
background-color: black;
}
/* Video Info Section */
.video-info {
padding: 1rem;
flex: 1;
overflow-y: auto;
}
/* Responsive Adjustments */
@media (min-width: 768px) {
#playerModal {
padding: 2rem;
align-items: center;
justify-content: center;
}
.modal-content {
max-width: 64rem;
max-height: calc(100vh - 4rem); /* Account for padding */
border-radius: 0.5rem;
overflow: hidden;
overflow: visible;
}
.video-container {
position: relative;
#playerModal .player-modal__content {
max-width: none;
border-radius: 0;
}
}
#modalNav.modal-nav,
.modal-nav {
background-color: var(--color-bg);
border-bottom: 0.0625rem solid rgba(148, 163, 184, 0.25);
box-shadow: 0 0.75rem 2rem rgba(8, 11, 30, 0.45);
}
.modal-nav .flex {
position: relative;
gap: 0.75rem;
}
.modal-nav .icon-button {
background-color: rgba(30, 41, 59, 0.85);
border: 0.0625rem solid rgba(148, 163, 184, 0.4);
color: #f8fafc;
box-shadow: none;
}
.modal-nav .icon-button:hover {
background-color: rgba(51, 65, 85, 0.95);
border-color: rgba(148, 163, 184, 0.55);
}
.modal-nav .icon-button:focus-visible {
outline: none;
box-shadow: 0 0 0 0.1875rem rgba(148, 163, 184, 0.45);
}
#modalNav {
position: sticky;
top: 0;
z-index: 60;
}
.modal-actions {
margin-left: auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-wrap: nowrap;
}
.modal-actions__overflow {
position: relative;
overflow: visible;
}
[data-more-menu="true"] {
z-index: 70;
}
/* Mobile-specific styles */
@media (max-width: 767px) {
#playerModal {
@@ -298,24 +463,6 @@ header img {
}
}
/* Custom Scrollbar */
.video-info::-webkit-scrollbar {
width: 8px;
}
.video-info::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.video-info::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.video-info::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.4);
}
/* Progress Bars */
.progress-bar {
width: 100%;
@@ -337,7 +484,7 @@ textarea {
width: 100%;
padding: 0.75rem;
background-color: var(--color-bg);
border: 1px solid rgb(255 255 255 / 0.1);
border: 0.0625rem solid rgb(255 255 255 / 0.1);
border-radius: 0.5rem;
color: var(--color-text);
transition: border-color 0.2s;
@@ -347,7 +494,7 @@ input:focus,
textarea:focus {
outline: none;
border-color: var(--color-primary);
ring: 2px var(--color-primary);
ring: 0.125rem var(--color-primary);
}
/* -------------------------------------------
@@ -366,12 +513,12 @@ button:not(.icon-button) {
button:not(.icon-button):hover {
background-color: var(--color-secondary);
transform: translateY(-1px);
transform: translateY(-0.0625rem);
}
button:not(.icon-button):focus {
outline: none;
ring: 2px var(--color-primary);
ring: 0.125rem var(--color-primary);
}
*/
@@ -416,7 +563,7 @@ button:not(.icon-button):focus {
}
footer {
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 0.0625rem solid rgba(255, 255, 255, 0.1);
margin-top: 4rem;
padding-top: 2rem;
}
@@ -436,47 +583,39 @@ footer a:hover {
background-color: rgb(0 0 0 / 0.9);
z-index: 50;
/* remove display: none; */
display: flex;
flex-direction: column;
overflow-y: auto;
overscroll-behavior: contain;
}
#disclaimerModal.hidden {
display: none !important;
}
#disclaimerModal .modal-content {
width: 100%;
width: min(60rem, calc(100% - 2rem));
max-height: min(90vh, 51.25rem);
display: flex;
flex-direction: column;
background-color: var(--color-bg);
box-shadow: 0 1.5625rem 3.125rem -0.75rem rgb(0 0 0 / 0.45);
}
#disclaimerModal .modal-scroll {
#disclaimerModal .modal-header {
min-height: 1.5rem;
}
#disclaimerModal .modal-body {
padding: 1.5rem;
flex: 1;
overflow-y: auto;
}
/* Disclaimer Modal Scrollbar */
#disclaimerModal .modal-scroll::-webkit-scrollbar {
width: 8px;
}
#disclaimerModal .modal-scroll::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
#disclaimerModal .modal-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
#disclaimerModal .modal-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.4);
}
/* Disclaimer Modal Button Container */
#disclaimerModal .button-container {
padding: 1rem 1.5rem;
#disclaimerModal .modal-footer {
padding: 1.25rem 1.5rem 1.75rem;
background-color: #1a2234;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 0.0625rem solid rgba(255, 255, 255, 0.1);
}
/* Responsive Adjustments for Disclaimer Modal */
@@ -487,9 +626,14 @@ footer a:hover {
justify-content: center;
}
#disclaimerModal .modal-scroll {
#disclaimerModal .modal-body {
padding: 2rem 2.5rem 1.5rem;
max-height: calc(90vh - 5rem);
}
#disclaimerModal .modal-footer {
padding: 1.5rem 2.5rem 2rem;
}
}
/* Mobile-specific styles for Disclaimer Modal */
@@ -501,20 +645,34 @@ footer a:hover {
#disclaimerModal .modal-content {
min-height: 100vh;
border-radius: 0;
max-height: none;
width: 100%;
}
#disclaimerModal .modal-body {
padding: 1.5rem 1.5rem 1rem;
max-height: none;
}
#disclaimerModal .modal-footer {
padding: 1rem 1.5rem 2rem;
}
}
/* Override default button styles for back button */
.back-button {
background: rgba(0, 0, 0, 0.5) !important;
background: rgba(15, 23, 42, 0.75) !important;
padding: 0 !important;
color: rgb(209 213 219) !important;
color: rgb(226 232 240) !important;
transform: none !important;
border: 0.0625rem solid rgba(148, 163, 184, 0.4) !important;
box-shadow: none;
}
.back-button:hover {
background: rgba(0, 0, 0, 0.7) !important;
background: rgba(51, 65, 85, 0.95) !important;
color: white !important;
border-color: rgba(148, 163, 184, 0.55) !important;
}
/* Modal Container */
@@ -529,9 +687,9 @@ footer a:hover {
}
/* Video info cards */
.video-info .bg-gray-800\/50 {
.player-modal__info .bg-gray-800\/50 {
background-color: rgb(31 41 55 / 0.5);
backdrop-filter: blur(4px);
backdrop-filter: blur(0.25rem);
}
/* Circular Icon Buttons */
@@ -557,7 +715,7 @@ footer a:hover {
.icon-button:focus,
.icon-button:active {
outline: none;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.6);
box-shadow: 0 0 0 0.1875rem rgba(220, 38, 38, 0.6);
}
.icon-image {
@@ -644,14 +802,14 @@ footer a:hover {
.sidebar-dropup-panel {
background-color: #1f2937; /* gray-800 */
border: 1px solid rgba(148, 163, 184, 0.25);
border: 0.0625rem solid rgba(148, 163, 184, 0.25);
border-radius: 0.75rem;
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.45);
box-shadow: 0 1.125rem 2.1875rem rgba(15, 23, 42, 0.45);
max-height: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateY(16px);
transform: translateY(1rem);
transition: max-height 0.35s ease, opacity 0.25s ease, transform 0.35s ease;
}
@@ -660,7 +818,7 @@ footer a:hover {
}
.sidebar-dropup-panel--expanded {
max-height: 640px;
max-height: 40rem;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
@@ -669,7 +827,7 @@ footer a:hover {
.sidebar-dropup-trigger {
align-items: center;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(14, 116, 144, 0.35));
border: 1px solid rgba(148, 163, 184, 0.35);
border: 0.0625rem solid rgba(148, 163, 184, 0.35);
border-radius: 9999px;
color: #f8fafc;
display: flex;
@@ -682,12 +840,43 @@ footer a:hover {
.sidebar-dropup-trigger:hover,
.sidebar-dropup-trigger:focus {
border-color: rgba(148, 163, 184, 0.6);
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.25);
box-shadow: 0 0.75rem 1.875rem rgba(37, 99, 235, 0.25);
outline: none;
transform: translateY(-1px);
transform: translateY(-0.0625rem);
}
.sidebar-dropup-trigger__content {
align-items: center;
display: flex;
}
.edit-field-button {
align-items: center;
background-color: #1f2937;
border-radius: 0.5rem;
color: #e2e8f0;
display: inline-flex;
font-size: 0.75rem;
font-weight: 600;
justify-content: center;
padding: 0.5rem 0.85rem;
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.edit-field-button:hover,
.edit-field-button:focus {
background-color: #334155;
color: #f8fafc;
transform: translateY(-0.0625rem);
}
.locked-input {
background-color: rgba(31, 41, 55, 0.75) !important;
border-color: rgba(55, 65, 81, 0.9) !important;
color: #94a3b8 !important;
cursor: default;
}
.locked-input:focus {
outline: none;
box-shadow: none;
}
+1
View File
@@ -0,0 +1 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@mediLine truncated
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+18 -6
View File
@@ -5,6 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bitvid | Decentralized Video Sharing</title>
<meta
name="description"
content="Bitvid is a decentralized video hub that prioritizes URL-first playback with WebTorrent fallback so your audience can stream reliably."
/>
<meta name="robots" content="index,follow" />
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="BitVid - Decentralized Video Sharing" />
<meta
@@ -40,7 +46,7 @@
<!-- Styles -->
<script src="js/tracking.js" defer></script>
<link href="css/tailwind.min.css" rel="stylesheet" />
<link href="css/tailwind.generated.css" rel="stylesheet" />
<link href="css/style.css" rel="stylesheet" />
<link href="css/markdown.css" rel="stylesheet" />
</head>
@@ -87,11 +93,17 @@
</button>
<!-- Logo -->
<!-- Logo fades in once on initial load. -->
<img
src="assets/svg/bitvid-logo-light-mode.svg"
alt="BitVid Logo"
class="h-16 fade-in fade-in-delay-100"
/>
<a
href="index.html"
aria-label="Return to the bitvid home page"
class="fade-in fade-in-delay-100"
>
<img
src="assets/svg/bitvid-logo-light-mode.svg"
alt="BitVid Logo"
class="h-16"
/>
</a>
<!-- Buttons on the far right -->
+365 -192
View File
@@ -1,228 +1,401 @@
// js/accessControl.js
import { isDevMode, isWhitelistEnabled } from "./config.js";
import { initialWhitelist, initialBlacklist } from "./lists.js";
import {
ADMIN_EDITORS_NPUBS,
ADMIN_SUPER_NPUB,
getWhitelistMode,
setWhitelistMode as persistWhitelistMode,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
} from "./config.js";
import { loadAdminState, persistAdminState } from "./adminListStore.js";
function normalizeNpub(value) {
return typeof value === "string" ? value.trim() : "";
}
function dedupeNpubs(values) {
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
return Array.from(
normalized.reduce((set, npub) => {
if (npub) {
set.add(npub);
}
return set;
}, new Set())
);
}
function isValidNpub(npub) {
if (typeof npub !== "string") {
return false;
}
const trimmed = npub.trim();
if (!trimmed) {
return false;
}
try {
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
return decoded?.type === "npub";
} catch (error) {
return false;
}
}
class AccessControl {
constructor() {
// Debug logging for initialization
console.log("DEBUG: AccessControl constructor called");
const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist();
const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist();
if (storedWhitelist !== null) {
this.whitelist = new Set(storedWhitelist);
} else {
this.whitelist = new Set(initialWhitelist);
this.saveWhitelist();
if (whitelistStatus && whitelistStatus !== "missing") {
console.warn(
`Whitelist storage ${whitelistStatus}. Falling back to initial whitelist.`
);
}
}
if (storedBlacklist !== null) {
this.blacklist = new Set(storedBlacklist);
} else {
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
this.saveBlacklist();
if (blacklistStatus && blacklistStatus !== "missing") {
console.warn(
`Blacklist storage ${blacklistStatus}. Falling back to initial blacklist.`
);
}
}
// Debug the sets
console.log("DEBUG: Whitelist after initialization:", [...this.whitelist]);
console.log("DEBUG: Blacklist after initialization:", [...this.blacklist]);
this.editors = new Set();
this.whitelist = new Set();
this.blacklist = new Set();
this.whitelistEnabled = getWhitelistMode();
this.hasLoaded = false;
this.lastError = null;
this._isRefreshing = false;
this._refreshPromise = this._performRefresh();
this._refreshPromise.catch((error) => {
console.error("Failed to load admin lists:", error);
});
}
// Rest of the class remains the same...
loadWhitelist() {
async _performRefresh() {
if (this._isRefreshing) {
return this._refreshPromise;
}
this._isRefreshing = true;
try {
const stored = localStorage.getItem("bitvid_whitelist");
if (!stored) {
if (isDevMode) console.log("No stored whitelist found in localStorage.");
return { data: null, status: "missing" };
}
const state = await loadAdminState();
const editors = Array.isArray(state?.editors) ? state.editors : [];
const whitelist = Array.isArray(state?.whitelist)
? state.whitelist
: [];
const blacklist = Array.isArray(state?.blacklist)
? state.blacklist
: [];
const parsed = JSON.parse(stored);
const sanitized = this.sanitizeList(parsed, "whitelist");
return {
data: sanitized,
status: sanitized === null ? "invalid" : "ok",
};
} catch (error) {
console.error("Error loading whitelist, using defaults:", error);
return { data: null, status: "error" };
}
}
loadBlacklist() {
try {
const stored = localStorage.getItem("bitvid_blacklist");
if (!stored) {
if (isDevMode) console.log("No stored blacklist found in localStorage.");
return { data: null, status: "missing" };
}
const parsed = JSON.parse(stored);
const sanitized = this.sanitizeList(parsed, "blacklist");
return {
data: sanitized,
status: sanitized === null ? "invalid" : "ok",
};
} catch (error) {
console.error("Error loading blacklist, using defaults:", error);
return { data: null, status: "error" };
}
}
sanitizeList(list, listName) {
if (!Array.isArray(list)) {
console.warn(
`Stored ${listName} is not an array. Received:`,
list
this.editors = new Set(
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
);
return null;
}
const normalizedWhitelist = dedupeNpubs(whitelist);
this.whitelist = new Set(normalizedWhitelist);
const sanitized = [];
let invalidEntries = 0;
const blacklistDedupe = dedupeNpubs(blacklist);
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
const adminGuardSet = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...Array.from(this.editors),
]);
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (adminGuardSet.has(normalized)) {
return false;
}
return true;
});
this.blacklist = new Set(sanitizedBlacklist);
list.forEach((value) => {
if (typeof value !== "string") {
invalidEntries += 1;
return;
this.whitelistEnabled = getWhitelistMode();
this.hasLoaded = true;
this.lastError = null;
} catch (error) {
this.lastError = error;
if (!this.hasLoaded) {
this.editors.clear();
this.whitelist.clear();
this.blacklist.clear();
}
throw error;
} finally {
this._isRefreshing = false;
}
}
const trimmed = value.trim();
if (trimmed) {
sanitized.push(trimmed);
refresh() {
const promise = this._performRefresh();
this._refreshPromise = promise;
promise.catch((error) => {
console.error("Failed to refresh admin lists:", error);
});
return promise;
}
async ensureReady() {
try {
await this._refreshPromise;
} catch (error) {
if (!this.hasLoaded) {
await this.refresh();
await this._refreshPromise;
} else {
invalidEntries += 1;
throw error;
}
});
if (invalidEntries > 0) {
console.warn(
`Stored ${listName} contained ${invalidEntries} invalid entr${
invalidEntries === 1 ? "y" : "ies"
}. Sanitized list:`,
sanitized
);
}
return sanitized;
}
saveWhitelist() {
try {
localStorage.setItem(
"bitvid_whitelist",
JSON.stringify([...this.whitelist])
);
} catch (error) {
console.error("Error saving whitelist:", error);
}
}
saveBlacklist() {
try {
localStorage.setItem(
"bitvid_blacklist",
JSON.stringify([...this.blacklist])
);
} catch (error) {
console.error("Error saving blacklist:", error);
}
whitelistMode() {
return this.whitelistEnabled;
}
addToWhitelist(npub) {
if (!this.isValidNpub(npub)) {
throw new Error("Invalid npub format");
}
this.whitelist.add(npub);
this.saveWhitelist();
if (isDevMode) console.log(`Added ${npub} to whitelist`);
isSuperAdmin(npub) {
const normalized = normalizeNpub(npub);
return normalized ? normalized === ADMIN_SUPER_NPUB : false;
}
removeFromWhitelist(npub) {
this.whitelist.delete(npub);
this.saveWhitelist();
if (isDevMode) console.log(`Removed ${npub} from whitelist`);
}
addToBlacklist(npub) {
if (!this.isValidNpub(npub)) {
throw new Error("Invalid npub format");
}
this.blacklist.add(npub);
this.saveBlacklist();
if (isDevMode) console.log(`Added ${npub} to blacklist`);
}
removeFromBlacklist(npub) {
this.blacklist.delete(npub);
this.saveBlacklist();
if (isDevMode) console.log(`Removed ${npub} from blacklist`);
}
isWhitelisted(npub) {
const result = this.whitelist.has(npub);
if (isDevMode)
console.log(
`Checking if ${npub} is whitelisted:`,
result,
"Current whitelist:",
[...this.whitelist]
);
return result;
}
isBlacklisted(npub) {
return this.blacklist.has(npub);
}
canAccess(npub) {
if (this.isBlacklisted(npub)) {
isAdminEditor(npub) {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub);
if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess);
return canAccess;
}
filterVideos(videos) {
return videos.filter((video) => {
try {
const npub = window.NostrTools.nip19.npubEncode(video.pubkey);
return !this.isBlacklisted(npub);
} catch (error) {
console.error("Error filtering video:", error);
return false;
}
});
}
isValidNpub(npub) {
try {
return npub.startsWith("npub1") && npub.length === 63;
} catch (error) {
return false;
if (this.isSuperAdmin(normalized)) {
return true;
}
return this.editors.has(normalized);
}
canEditAdminLists(npub) {
return this.isAdminEditor(npub);
}
getWhitelist() {
return [...this.whitelist];
return Array.from(this.whitelist);
}
getBlacklist() {
return [...this.blacklist];
return Array.from(this.blacklist);
}
getEditors() {
return Array.from(this.editors);
}
async addModerator(requestorNpub, moderatorNpub) {
if (!this.isSuperAdmin(requestorNpub)) {
return { ok: false, error: "forbidden" };
}
if (!isValidNpub(moderatorNpub)) {
return { ok: false, error: "invalid npub" };
}
const normalized = normalizeNpub(moderatorNpub);
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
return { ok: false, error: "immutable" };
}
const nextEditors = dedupeNpubs([...this.getEditors(), normalized]);
try {
await persistAdminState(requestorNpub, { editors: nextEditors });
await this.refresh();
return { ok: true };
} catch (error) {
return { ok: false, error: error?.code || "storage-error" };
}
}
async removeModerator(requestorNpub, moderatorNpub) {
if (!this.isSuperAdmin(requestorNpub)) {
return { ok: false, error: "forbidden" };
}
const normalized = normalizeNpub(moderatorNpub);
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
return { ok: false, error: "immutable" };
}
const nextEditors = this.getEditors().filter((value) => value !== normalized);
try {
await persistAdminState(requestorNpub, { editors: nextEditors });
await this.refresh();
return { ok: true };
} catch (error) {
return { ok: false, error: error?.code || "storage-error" };
}
}
async addToWhitelist(actorNpub, targetNpub) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
if (!isValidNpub(targetNpub)) {
return { ok: false, error: "invalid npub" };
}
const normalized = normalizeNpub(targetNpub);
if (!normalized) {
return { ok: false, error: "invalid npub" };
}
const nextWhitelist = dedupeNpubs([...this.getWhitelist(), normalized]);
const nextBlacklist = this.getBlacklist().filter(
(value) => value !== normalized
);
try {
await persistAdminState(actorNpub, {
whitelist: nextWhitelist,
blacklist: nextBlacklist,
});
await this.refresh();
return { ok: true };
} catch (error) {
return { ok: false, error: error?.code || "storage-error" };
}
}
async removeFromWhitelist(actorNpub, targetNpub) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
const normalized = normalizeNpub(targetNpub);
if (!normalized) {
return { ok: false, error: "invalid npub" };
}
const nextWhitelist = this.getWhitelist().filter(
(value) => value !== normalized
);
try {
await persistAdminState(actorNpub, { whitelist: nextWhitelist });
await this.refresh();
return { ok: true };
} catch (error) {
return { ok: false, error: error?.code || "storage-error" };
}
}
async addToBlacklist(actorNpub, targetNpub) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
if (!isValidNpub(targetNpub)) {
return { ok: false, error: "invalid npub" };
}
const normalized = normalizeNpub(targetNpub);
if (!normalized) {
return { ok: false, error: "invalid npub" };
}
const normalizedActor = normalizeNpub(actorNpub);
if (normalizedActor && normalized === normalizedActor) {
return { ok: false, error: "self" };
}
if (this.isSuperAdmin(normalized) || this.isAdminEditor(normalized)) {
return { ok: false, error: "immutable" };
}
const nextBlacklist = dedupeNpubs([...this.getBlacklist(), normalized]);
const nextWhitelist = this.getWhitelist().filter(
(value) => value !== normalized
);
try {
await persistAdminState(actorNpub, {
blacklist: nextBlacklist,
whitelist: nextWhitelist,
});
await this.refresh();
return { ok: true };
} catch (error) {
return { ok: false, error: error?.code || "storage-error" };
}
}
async removeFromBlacklist(actorNpub, targetNpub) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
const normalized = normalizeNpub(targetNpub);
if (!normalized) {
return { ok: false, error: "invalid npub" };
}
const nextBlacklist = this.getBlacklist().filter(
(value) => value !== normalized
);
try {
await persistAdminState(actorNpub, { blacklist: nextBlacklist });
await this.refresh();
return { ok: true };
} catch (error) {
return { ok: false, error: error?.code || "storage-error" };
}
}
setWhitelistMode(actorNpub, enabled) {
if (!this.isSuperAdmin(actorNpub)) {
return { ok: false, error: "forbidden" };
}
persistWhitelistMode(!!enabled);
this.whitelistEnabled = !!enabled;
return { ok: true };
}
isBlacklisted(npub) {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (this.whitelist.has(normalized)) {
return false;
}
return this.blacklist.has(normalized);
}
canAccess(candidate) {
let npub = "";
if (typeof candidate === "string") {
npub = candidate;
} else if (candidate && typeof candidate === "object") {
if (typeof candidate.npub === "string") {
npub = candidate.npub;
} else if (typeof candidate.pubkey === "string") {
try {
npub = window.NostrTools.nip19.npubEncode(candidate.pubkey);
} catch (error) {
npub = candidate.pubkey;
}
}
}
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (this.isAdminEditor(normalized)) {
return true;
}
if (this.whitelist.has(normalized)) {
return true;
}
if (this.blacklist.has(normalized)) {
return false;
}
if (this.whitelistEnabled && !this.whitelist.has(normalized)) {
return false;
}
return true;
}
}
export const accessControl = new AccessControl();
export {
ADMIN_WHITELIST_MODE_STORAGE_KEY,
normalizeNpub,
isValidNpub,
};
+558
View File
@@ -0,0 +1,558 @@
// js/adminListStore.js
import {
ADMIN_LIST_MODE,
ADMIN_LIST_NAMESPACE,
ADMIN_SUPER_NPUB,
ADMIN_EDITORS_NPUBS,
isDevMode,
} from "./config.js";
import { nostrClient } from "./nostr.js";
const LIST_IDENTIFIERS = {
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
};
const LEGACY_STORAGE_KEYS = {
editors: "bitvid_admin_editors",
whitelist: "bitvid_admin_whitelist",
whitelistLegacy: "bitvid_whitelist",
blacklist: "bitvid_admin_blacklist",
blacklistLegacy: "bitvid_blacklist",
};
const PUBLISH_TIMEOUT_MS = 7000;
function createError(code, message, cause) {
const error = new Error(message);
error.code = code;
if (cause) {
error.cause = cause;
}
return error;
}
function normalizeNpub(value) {
return typeof value === "string" ? value.trim() : "";
}
function dedupeNpubs(values) {
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
return Array.from(
normalized.reduce((set, npub) => {
if (npub) {
set.add(npub);
}
return set;
}, new Set())
);
}
function canDecodeNpub() {
return !!(
typeof window !== "undefined" &&
window?.NostrTools?.nip19 &&
typeof window.NostrTools.nip19.decode === "function"
);
}
function isLikelyNpub(value) {
if (typeof value !== "string") {
return false;
}
const trimmed = value.trim();
if (!trimmed) {
return false;
}
if (!canDecodeNpub()) {
return true;
}
try {
const decoded = window.NostrTools.nip19.decode(trimmed);
return decoded?.type === "npub";
} catch (error) {
return false;
}
}
function sanitizeNpubList(values) {
return dedupeNpubs(values).filter(isLikelyNpub);
}
function sanitizeAdminState(state = {}) {
const sanitizedEditors = sanitizeNpubList(state.editors || []);
const sanitizedWhitelist = sanitizeNpubList(state.whitelist || []);
const whitelistSet = new Set(sanitizedWhitelist.map(normalizeNpub));
const adminGuardSet = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...ADMIN_EDITORS_NPUBS.map(normalizeNpub),
...sanitizedEditors.map(normalizeNpub),
]);
const sanitizedBlacklist = sanitizeNpubList(state.blacklist || []).filter(
(npub) => {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (adminGuardSet.has(normalized)) {
return false;
}
return true;
}
);
return {
editors: sanitizedEditors,
whitelist: sanitizedWhitelist,
blacklist: sanitizedBlacklist,
};
}
function hasAnyEntries(state = {}) {
return Boolean(
(Array.isArray(state.editors) && state.editors.length) ||
(Array.isArray(state.whitelist) && state.whitelist.length) ||
(Array.isArray(state.blacklist) && state.blacklist.length)
);
}
function readJsonListFromStorage(key) {
if (typeof localStorage === "undefined") {
return null;
}
try {
const raw = localStorage.getItem(key);
if (raw === null) {
return null;
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (error) {
if (isDevMode) {
console.warn(`[adminListStore] Failed to parse legacy list for ${key}:`, error);
}
return null;
}
}
function loadLegacyAdminState() {
const editors = readJsonListFromStorage(LEGACY_STORAGE_KEYS.editors) || [];
const whitelist =
readJsonListFromStorage(LEGACY_STORAGE_KEYS.whitelist) ||
readJsonListFromStorage(LEGACY_STORAGE_KEYS.whitelistLegacy) ||
[];
const blacklist =
readJsonListFromStorage(LEGACY_STORAGE_KEYS.blacklist) ||
readJsonListFromStorage(LEGACY_STORAGE_KEYS.blacklistLegacy) ||
[];
const sanitized = sanitizeAdminState({ editors, whitelist, blacklist });
return hasAnyEntries(sanitized) ? sanitized : null;
}
function clearLegacyStorageFor(listKey) {
if (typeof localStorage === "undefined") {
return;
}
const keyGroups = {
editors: [LEGACY_STORAGE_KEYS.editors],
whitelist: [
LEGACY_STORAGE_KEYS.whitelist,
LEGACY_STORAGE_KEYS.whitelistLegacy,
],
blacklist: [
LEGACY_STORAGE_KEYS.blacklist,
LEGACY_STORAGE_KEYS.blacklistLegacy,
],
};
const keys = keyGroups[listKey];
if (!Array.isArray(keys)) {
return;
}
for (const key of keys) {
try {
localStorage.removeItem(key);
} catch (error) {
if (isDevMode) {
console.warn(`[adminListStore] Failed to clear legacy storage for ${key}:`, error);
}
}
}
}
function ensureNostrReady() {
if (!nostrClient || !nostrClient.pool) {
throw createError(
"nostr-unavailable",
"Nostr relay pool is not initialized."
);
}
const relays = Array.isArray(nostrClient.relays) ? nostrClient.relays : [];
if (!relays.length) {
throw createError("nostr-unavailable", "No Nostr relays configured.");
}
return relays;
}
function decodeNpubToHex(npub) {
const trimmed = normalizeNpub(npub);
if (!trimmed) {
throw createError("invalid npub", "Empty npub provided.");
}
try {
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
if (decoded?.type !== "npub" || !decoded?.data) {
throw new Error("Invalid npub format");
}
return typeof decoded.data === "string"
? decoded.data
: decoded.data?.pubkey || "";
} catch (error) {
throw createError("invalid npub", "Unable to decode npub.", error);
}
}
function encodeHexToNpub(hex) {
try {
return window?.NostrTools?.nip19?.npubEncode(hex) || "";
} catch (error) {
if (isDevMode) {
console.warn("Failed to encode hex pubkey to npub:", error);
}
return "";
}
}
function extractNpubsFromEvent(event) {
if (!event || !Array.isArray(event.tags)) {
return [];
}
const hexKeys = event.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1])
.map((tag) => tag[1]);
const npubs = hexKeys
.map((hex) => encodeHexToNpub(hex))
.filter((npub) => typeof npub === "string" && npub);
return dedupeNpubs(npubs);
}
async function loadNostrList(identifier) {
const relays = ensureNostrReady();
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
const filter = {
kinds: [30000],
"#d": [dTagValue],
limit: 50,
};
let events = [];
try {
const combined = await nostrClient.pool.list(relays, [filter]);
if (Array.isArray(combined)) {
events = combined;
}
} catch (error) {
if (isDevMode) {
console.warn(
`[adminListStore] Combined relay fetch failed for ${identifier}:`,
error
);
}
}
if (!events.length) {
const perRelay = await Promise.all(
relays.map(async (url) => {
try {
const result = await nostrClient.pool.list([url], [filter]);
return Array.isArray(result) ? result : [];
} catch (error) {
if (isDevMode) {
console.warn(
`[adminListStore] Relay fetch failed for ${identifier} on ${url}:`,
error
);
}
return [];
}
})
);
events = perRelay.flat();
}
if (!events.length) {
return [];
}
const newest = events.reduce((latest, event) => {
if (!latest) {
return event;
}
if (event.created_at === latest.created_at) {
return event.id > latest.id ? event : latest;
}
return event.created_at > latest.created_at ? event : latest;
}, null);
return extractNpubsFromEvent(newest);
}
async function loadNostrState() {
const [editors, whitelist, blacklist] = await Promise.all([
loadNostrList(LIST_IDENTIFIERS.editors),
loadNostrList(LIST_IDENTIFIERS.whitelist),
loadNostrList(LIST_IDENTIFIERS.blacklist),
]);
return sanitizeAdminState({ editors, whitelist, blacklist });
}
function buildListEvent(listKey, npubs, actorHex) {
const identifier = LIST_IDENTIFIERS[listKey];
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
const hexPubkeys = Array.from(
new Set(npubs.map((npub) => {
try {
return decodeNpubToHex(npub);
} catch (error) {
if (isDevMode) {
console.warn(
`[adminListStore] Failed to decode npub while publishing ${listKey}:`,
error
);
}
return "";
}
}).filter((hex) => !!hex))
);
if (listKey === "whitelist" || listKey === "editors") {
try {
const superHex = decodeNpubToHex(ADMIN_SUPER_NPUB);
if (superHex && !hexPubkeys.includes(superHex)) {
hexPubkeys.push(superHex);
}
} catch (error) {
if (isDevMode) {
console.warn("Failed to ensure super admin presence:", error);
}
}
}
const tags = [["d", dTagValue]];
hexPubkeys.forEach((hex) => {
tags.push(["p", hex]);
});
return {
kind: 30000,
pubkey: actorHex,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
};
}
function publishToRelay(url, signedEvent, listKey) {
return new Promise((resolve) => {
let settled = false;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
resolve({ url, success: false, error: new Error("timeout") });
}
}, PUBLISH_TIMEOUT_MS);
const finalize = (success, error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolve({ url, success, error });
};
try {
const pub = nostrClient.pool.publish([url], signedEvent);
if (!pub) {
finalize(false, new Error("publish returned no result"));
return;
}
if (typeof pub.on === "function") {
pub.on("ok", () => finalize(true));
pub.on("seen", () => finalize(true));
pub.on("failed", (reason) => {
const error =
reason instanceof Error ? reason : new Error(String(reason || "failed"));
finalize(false, error);
});
return;
}
if (typeof pub.then === "function") {
pub.then(() => finalize(true)).catch((error) => finalize(false, error));
return;
}
} catch (error) {
finalize(false, error);
return;
}
finalize(true);
}).then((result) => {
if (!result.success && isDevMode) {
console.warn(
`[adminListStore] Publish failed for ${listKey} on ${result.url}:`,
result.error
);
}
return result;
});
}
async function persistNostrState(actorNpub, updates = {}) {
ensureNostrReady();
const extension = window?.nostr;
if (!extension || typeof extension.signEvent !== "function") {
throw createError(
"nostr-extension-missing",
"A Nostr extension with signEvent support is required."
);
}
const actorHex = decodeNpubToHex(actorNpub);
if (!actorHex) {
throw createError("invalid npub", "Unable to decode the actor npub.");
}
const sanitizedUpdates = {};
const actorNormalized = normalizeNpub(actorNpub);
if (Array.isArray(updates.editors)) {
sanitizedUpdates.editors = sanitizeNpubList(updates.editors).filter((npub) => {
const normalized = normalizeNpub(npub);
return normalized && normalized !== normalizeNpub(ADMIN_SUPER_NPUB);
});
}
if (Array.isArray(updates.whitelist)) {
sanitizedUpdates.whitelist = sanitizeNpubList(updates.whitelist);
}
if (Array.isArray(updates.blacklist)) {
const whitelistSet = new Set(
(sanitizedUpdates.whitelist || []).map(normalizeNpub)
);
const editorGuard = new Set([
normalizeNpub(ADMIN_SUPER_NPUB),
...ADMIN_EDITORS_NPUBS.map(normalizeNpub),
...((sanitizedUpdates.editors || []).map(normalizeNpub) || []),
]);
if (actorNormalized) {
editorGuard.add(actorNormalized);
}
sanitizedUpdates.blacklist = sanitizeNpubList(updates.blacklist).filter(
(npub) => {
const normalized = normalizeNpub(npub);
if (!normalized) {
return false;
}
if (whitelistSet.has(normalized)) {
return false;
}
if (editorGuard.has(normalized)) {
return false;
}
return true;
}
);
}
const entries = Object.entries(sanitizedUpdates).filter(([, value]) =>
Array.isArray(value)
);
if (!entries.length) {
return;
}
for (const [listKey, npubs] of entries) {
const event = buildListEvent(listKey, npubs, actorHex);
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
} catch (error) {
throw createError("signature-failed", "Failed to sign admin list event.", error);
}
const relays = ensureNostrReady();
const publishResults = await Promise.all(
relays.map((url) => publishToRelay(url, signedEvent, listKey))
);
if (!publishResults.some((result) => result.success)) {
throw createError(
"publish-failed",
`Failed to publish ${listKey} list to any relay.`
);
}
clearLegacyStorageFor(listKey);
}
}
export async function loadAdminState() {
if (ADMIN_LIST_MODE !== "nostr" && isDevMode) {
console.warn(
`[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Defaulting to remote Nostr lists.`
);
}
const state = await loadNostrState();
if (!hasAnyEntries(state)) {
const legacy = loadLegacyAdminState();
if (legacy && isDevMode) {
console.warn(
"[adminListStore] Ignoring legacy admin lists because remote mode is enforced. Publish them to Nostr to retain access.",
legacy
);
}
}
return state;
}
export async function persistAdminState(actorNpub, updates) {
if (ADMIN_LIST_MODE !== "nostr" && isDevMode) {
console.warn(
`[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Remote lists are enforced.`
);
}
return persistNostrState(actorNpub, updates);
}
+4825 -240
View File
File diff suppressed because it is too large. Load diff
+362 -28
View File
@@ -7,8 +7,206 @@ import {
import { app } from "./app.js";
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
import { attachHealthBadges } from "./gridHealth.js";
import { initialBlacklist, initialWhitelist } from "./lists.js";
import { isWhitelistEnabled } from "./config.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { accessControl } from "./accessControl.js";
let currentChannelHex = null;
let currentChannelNpub = null;
let cachedZapButton = null;
let cachedChannelShareButton = null;
let cachedChannelMenu = null;
function getChannelZapButton() {
if (cachedZapButton && !document.body.contains(cachedZapButton)) {
cachedZapButton = null;
}
if (!cachedZapButton) {
cachedZapButton = document.getElementById("zapButton");
}
return cachedZapButton;
}
function setChannelZapVisibility(visible) {
const zapButton = getChannelZapButton();
if (!zapButton) {
return;
}
const shouldShow = !!visible;
zapButton.classList.toggle("hidden", !shouldShow);
zapButton.disabled = !shouldShow;
zapButton.setAttribute("aria-disabled", (!shouldShow).toString());
zapButton.setAttribute("aria-hidden", (!shouldShow).toString());
if (shouldShow) {
zapButton.removeAttribute("tabindex");
} else {
zapButton.setAttribute("tabindex", "-1");
}
}
function getChannelShareButton() {
if (cachedChannelShareButton && !document.body.contains(cachedChannelShareButton)) {
cachedChannelShareButton = null;
}
if (!cachedChannelShareButton) {
cachedChannelShareButton = document.getElementById("channelShareBtn");
}
return cachedChannelShareButton;
}
function getChannelMenuElement() {
if (cachedChannelMenu && !document.body.contains(cachedChannelMenu)) {
cachedChannelMenu = null;
}
if (!cachedChannelMenu) {
cachedChannelMenu = document.getElementById("moreDropdown-channel-profile");
}
return cachedChannelMenu;
}
function buildChannelShareUrl() {
if (!currentChannelNpub) {
return "";
}
const base = typeof app.getShareUrlBase === "function" ? app.getShareUrlBase() : "";
if (base) {
return `${base}#view=channel-profile&npub=${encodeURIComponent(currentChannelNpub)}`;
}
try {
const url = new URL(window.location.href);
url.search = "";
url.hash = `#view=channel-profile&npub=${currentChannelNpub}`;
return url.toString();
} catch (error) {
console.warn("Falling back to basic channel share URL:", error);
const origin = window.location?.origin || "";
const pathname = window.location?.pathname || "";
if (!origin && !pathname) {
return "";
}
return `${origin}${pathname}#view=channel-profile&npub=${currentChannelNpub}`;
}
}
function syncChannelShareButtonState() {
const shareBtn = getChannelShareButton();
if (!shareBtn) {
return;
}
const hasNpub = typeof currentChannelNpub === "string" && currentChannelNpub;
shareBtn.disabled = !hasNpub;
shareBtn.setAttribute("aria-disabled", (!hasNpub).toString());
shareBtn.classList.toggle("opacity-50", !hasNpub);
shareBtn.classList.toggle("cursor-not-allowed", !hasNpub);
}
function setupChannelShareButton() {
const shareBtn = getChannelShareButton();
if (!shareBtn) {
return;
}
syncChannelShareButtonState();
if (shareBtn.dataset.initialized === "true") {
return;
}
shareBtn.addEventListener("click", () => {
const shareUrl = buildChannelShareUrl();
if (!shareUrl) {
app.showError("Could not generate channel link.");
return;
}
navigator.clipboard
.writeText(shareUrl)
.then(() => app.showSuccess("Channel link copied to clipboard!"))
.catch(() => app.showError("Failed to copy the link."));
});
shareBtn.dataset.initialized = "true";
}
function setupChannelMoreMenu() {
const container = document.querySelector(".channel-profile-container");
if (!container) {
return;
}
app.attachMoreMenuHandlers(container);
}
async function updateChannelMenuState() {
const menu = getChannelMenuElement();
if (!menu) {
return;
}
try {
await accessControl.ensureReady();
} catch (error) {
console.warn("Failed to refresh moderation lists for channel menu:", error);
}
const canBlacklist =
typeof app.canCurrentUserManageBlacklist === "function"
? app.canCurrentUserManageBlacklist()
: false;
const buttons = menu.querySelectorAll("button[data-action]");
buttons.forEach((button) => {
if (!(button instanceof HTMLElement)) {
return;
}
const action = button.dataset.action || "";
if (action === "copy-npub") {
if (currentChannelNpub) {
button.dataset.npub = currentChannelNpub;
button.classList.remove("hidden");
button.setAttribute("aria-hidden", "false");
} else {
delete button.dataset.npub;
button.classList.add("hidden");
button.setAttribute("aria-hidden", "true");
}
return;
}
if (action === "block-author") {
if (currentChannelHex) {
button.dataset.author = currentChannelHex;
button.classList.remove("hidden");
button.setAttribute("aria-hidden", "false");
} else {
delete button.dataset.author;
button.classList.add("hidden");
button.setAttribute("aria-hidden", "true");
}
return;
}
if (action === "blacklist-author") {
if (canBlacklist && currentChannelHex) {
button.dataset.author = currentChannelHex;
if (currentChannelNpub) {
button.dataset.npub = currentChannelNpub;
} else {
delete button.dataset.npub;
}
button.classList.remove("hidden");
button.setAttribute("aria-hidden", "false");
} else {
delete button.dataset.author;
delete button.dataset.npub;
button.classList.add("hidden");
button.setAttribute("aria-hidden", "true");
}
}
});
}
/**
* Initialize the channel profile view.
@@ -25,6 +223,9 @@ export async function initChannelProfileView() {
return;
}
currentChannelHex = null;
currentChannelNpub = null;
// 2) Decode npub => hex pubkey
let hexPub;
try {
@@ -39,6 +240,13 @@ export async function initChannelProfileView() {
return;
}
currentChannelHex = hexPub;
currentChannelNpub = npub;
setupChannelShareButton();
setupChannelMoreMenu();
await updateChannelMenuState();
// 3) If user is logged in, load subscriptions and show sub/unsub button
if (app.pubkey) {
await subscriptions.loadSubscriptions(app.pubkey);
@@ -49,20 +257,25 @@ export async function initChannelProfileView() {
}
setupZapButton();
syncChannelShareButtonState();
// 4) Load user’s profile (banner, avatar, etc.)
await loadUserProfile(hexPub);
await updateChannelMenuState();
syncChannelShareButtonState();
// 5) Load user’s videos (filtered + rendered like the home feed)
await loadUserVideos(hexPub);
}
function setupZapButton() {
const zapButton = document.getElementById("zapButton");
const zapButton = getChannelZapButton();
if (!zapButton) {
return;
}
setChannelZapVisibility(false);
if (zapButton.dataset.initialized === "true") {
return;
}
@@ -181,15 +394,27 @@ async function loadUserProfile(pubkey) {
// Lightning Address
const lnEl = document.getElementById("channelLightning");
const lightningAddress = (meta.lud16 || meta.lud06 || "").trim();
if (lnEl) {
lnEl.textContent =
meta.lud16 || meta.lud06 || "No lightning address found.";
lightningAddress || "No lightning address found.";
}
setChannelZapVisibility(!!lightningAddress);
} else {
console.warn("No metadata found for this user.");
setChannelZapVisibility(false);
const lnEl = document.getElementById("channelLightning");
if (lnEl) {
lnEl.textContent = "No lightning address found.";
}
}
} catch (err) {
console.error("Failed to fetch user profile data:", err);
setChannelZapVisibility(false);
const lnEl = document.getElementById("channelLightning");
if (lnEl) {
lnEl.textContent = "No lightning address found.";
}
}
}
@@ -199,6 +424,12 @@ async function loadUserProfile(pubkey) {
*/
async function loadUserVideos(pubkey) {
try {
try {
await accessControl.ensureReady();
} catch (error) {
console.warn("Failed to ensure admin lists were loaded before channel fetch:", error);
}
// 1) Build filter for videos from this pubkey
const filter = {
kinds: [30078],
@@ -218,29 +449,24 @@ async function loadUserVideos(pubkey) {
}
}
// 3) Convert to "video" objects
let videos = [];
for (const evt of events) {
const vid = sharedConvertEventToVideo(evt);
if (!vid.invalid && !vid.deleted) {
videos.push(vid);
}
}
// 3) Convert to "video" objects and keep everything (including tombstones)
const convertedVideos = events
.map((evt) => sharedConvertEventToVideo(evt))
.filter((vid) => !vid.invalid);
// 4) Deduplicate older overshadowed versions => newest only
videos = app?.dedupeVideosByRoot?.(videos) ?? dedupeToNewestByRoot(videos);
const newestByRoot =
app?.dedupeVideosByRoot?.(convertedVideos) ??
dedupeToNewestByRoot(convertedVideos);
// 5) Filter out blacklisted IDs / authors
// 5) Filter out tombstones, blacklisted IDs / authors
let videos = newestByRoot.filter((video) => !video.deleted);
videos = videos.filter((video) => {
// Event-level blacklisting
if (app.blacklistedEventIds.has(video.id)) return false;
// Author-level
const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey;
if (initialBlacklist.includes(authorNpub)) return false;
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
return false;
}
if (!accessControl.canAccess(video)) return false;
return true;
});
@@ -344,6 +570,48 @@ async function loadUserVideos(pubkey) {
`;
}
const moreMenu = `
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
<button
type="button"
class="inline-flex items-center justify-center w-10 h-10 p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
data-more-dropdown="${index}"
aria-haspopup="true"
aria-expanded="false"
aria-label="More options"
>
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5 object-contain" />
</button>
<div
id="moreDropdown-${index}"
class="hidden absolute right-0 bottom-full mb-2 w-40 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
role="menu"
data-more-menu="true"
>
<div class="py-1">
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="open-channel" data-author="${video.pubkey || ""}">
Open channel
</button>
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
Copy link
</button>
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="block-author" data-author="${video.pubkey || ""}">
Block creator
</button>
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="report" data-event-id="${video.id || ""}">
Report
</button>
</div>
</div>
</div>
`;
const cardControls = `
<div class="flex items-center">
${moreMenu}${gearMenu}
</div>
`;
// Fallback thumbnail
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
const safeThumb = video.thumbnail || fallbackThumb;
@@ -361,6 +629,13 @@ async function loadUserVideos(pubkey) {
"duration-300"
);
cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
if (typeof video.pubkey === "string" && video.pubkey) {
cardEl.dataset.ownerPubkey = video.pubkey;
} else if (cardEl.dataset.ownerPubkey) {
delete cardEl.dataset.ownerPubkey;
}
const rawMagnet =
typeof video.magnet === "string" ? video.magnet : "";
const trimmedMagnet = rawMagnet ? rawMagnet.trim() : "";
@@ -420,7 +695,7 @@ async function loadUserVideos(pubkey) {
${new Date(video.created_at * 1000).toLocaleString()}
</p>
</div>
${gearMenu}
${cardControls}
</div>
${connectionBadgesHtml}
</div>
@@ -434,6 +709,35 @@ async function loadUserVideos(pubkey) {
delete cardEl.dataset.torrentSupported;
}
if (trimmedUrl) {
cardEl.dataset.urlHealthState = "checking";
if (cardEl.dataset.urlHealthReason) {
delete cardEl.dataset.urlHealthReason;
}
cardEl.dataset.urlHealthEventId = video.id || "";
cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
cardEl.dataset.urlHealthState = "offline";
cardEl.dataset.urlHealthReason = "missing-source";
if (cardEl.dataset.urlHealthEventId) {
delete cardEl.dataset.urlHealthEventId;
}
if (cardEl.dataset.urlHealthUrl) {
delete cardEl.dataset.urlHealthUrl;
}
}
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
if (cardEl.dataset.streamHealthReason) {
delete cardEl.dataset.streamHealthReason;
}
} else {
cardEl.dataset.streamHealthState = "unhealthy";
cardEl.dataset.streamHealthReason = magnetProvided
? "unsupported"
: "missing-source";
}
if (magnetProvided) {
cardEl.dataset.magnet = playbackMagnet;
} else if (cardEl.dataset.magnet) {
@@ -464,14 +768,18 @@ async function loadUserVideos(pubkey) {
}
});
if (trimmedUrl) {
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
app.handleUrlHealthBadge({
video,
url: trimmedUrl,
badgeEl,
});
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
if (trimmedUrl) {
badgeEl.dataset.urlHealthEventId = video.id || "";
badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
if (badgeEl.dataset.urlHealthEventId) {
delete badgeEl.dataset.urlHealthEventId;
}
if (badgeEl.dataset.urlHealthUrl) {
delete badgeEl.dataset.urlHealthUrl;
}
}
}
@@ -481,6 +789,10 @@ async function loadUserVideos(pubkey) {
container.appendChild(fragment);
attachHealthBadges(container);
attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
const video = app.videosMap.get(eventId) || { id: eventId };
app.handleUrlHealthBadge({ video, url, badgeEl });
});
window.app.videoList = container;
window.app.attachVideoListHandler();
@@ -489,6 +801,8 @@ async function loadUserVideos(pubkey) {
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => app.mediaLoader.observe(el));
app.attachMoreMenuHandlers(container);
// Gear menu toggles
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
gearButtons.forEach((btn) => {
@@ -557,6 +871,26 @@ async function loadUserVideos(pubkey) {
}
}
window.addEventListener("bitvid:access-control-updated", () => {
const hashParams = new URLSearchParams(window.location.hash.slice(1));
if (hashParams.get("view") !== "channel-profile") {
return;
}
if (!currentChannelHex) {
return;
}
const activeNpub = hashParams.get("npub");
if (currentChannelNpub && activeNpub && activeNpub !== currentChannelNpub) {
return;
}
loadUserVideos(currentChannelHex).catch((error) => {
console.error("Failed to refresh channel videos after admin update:", error);
});
});
/**
* Minimal placeholder for private video decryption.
*/
+41 -1
View File
@@ -1,4 +1,44 @@
// js/config.js
import {
ADMIN_SUPER_NPUB,
ADMIN_WHITELIST_MODE_STORAGE_KEY,
DEFAULT_WHITELIST_MODE_ENABLED,
} from "../config/instance-config.js";
export const isDevMode = true; // Set to false for production
export const isWhitelistEnabled = true; // Set to false to allow all non-blacklisted users
// -----------------------------------------------------------------------------
// Admin governance — production defaults rely on remote Nostr lists
// -----------------------------------------------------------------------------
export const ADMIN_LIST_MODE = "nostr";
export { ADMIN_SUPER_NPUB };
export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional)
export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists
const WHITELIST_MODE_KEY = ADMIN_WHITELIST_MODE_STORAGE_KEY;
const DEFAULT_WHITELIST_ENABLED = DEFAULT_WHITELIST_MODE_ENABLED;
export function getWhitelistMode() {
try {
const raw = localStorage.getItem(WHITELIST_MODE_KEY);
if (raw === null) {
return DEFAULT_WHITELIST_ENABLED;
}
return raw === "true";
} catch (error) {
console.warn("Failed to read whitelist mode from storage:", error);
return DEFAULT_WHITELIST_ENABLED;
}
}
export function setWhitelistMode(enabled) {
try {
localStorage.setItem(WHITELIST_MODE_KEY, enabled ? "true" : "false");
} catch (error) {
console.warn("Failed to persist whitelist mode to storage:", error);
}
}
export { ADMIN_WHITELIST_MODE_STORAGE_KEY };
+266 -83
View File
@@ -110,7 +110,7 @@ function normalizeResult(result) {
};
}
function queueProbe(magnet, cacheKey) {
function queueProbe(magnet, cacheKey, priority = 0) {
if (!magnet) {
return Promise.resolve(null);
}
@@ -125,21 +125,23 @@ function queueProbe(magnet, cacheKey) {
}
const job = probeQueue
.run(() =>
torrentClient
.probePeers(magnet, {
timeoutMs: PROBE_TIMEOUT_MS,
maxWebConns: 2,
polls: PROBE_POLL_COUNT,
})
.catch((err) => ({
healthy: false,
peers: 0,
reason: "error",
error: err,
appendedTrackers: false,
hasProbeTrackers: false,
}))
.run(
() =>
torrentClient
.probePeers(magnet, {
timeoutMs: PROBE_TIMEOUT_MS,
maxWebConns: 2,
polls: PROBE_POLL_COUNT,
})
.catch((err) => ({
healthy: false,
peers: 0,
reason: "error",
error: err,
appendedTrackers: false,
hasProbeTrackers: false,
})),
priority
)
.then((result) => {
const normalized = normalizeResult(result);
@@ -163,8 +165,9 @@ function queueProbe(magnet, cacheKey) {
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
const PROBE_TIMEOUT_MS = 8000;
const PROBE_CONCURRENCY = 3;
const PROBE_CONCURRENCY = 96;
const PROBE_POLL_COUNT = 3;
const PRIORITY_BASELINE = 1_000_000;
class ProbeQueue {
constructor(max = 2) {
@@ -173,51 +176,72 @@ class ProbeQueue {
this.queue = [];
}
run(task) {
run(task, priority = 0) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
const normalizedPriority = Number.isFinite(priority) ? priority : 0;
const job = {
task,
resolve,
reject,
priority: normalizedPriority,
};
this.enqueue(job);
this.drain();
});
}
enqueue(job) {
if (!this.queue.length) {
this.queue.push(job);
return;
}
const index = this.queue.findIndex(
(existing) => existing.priority < job.priority
);
if (index === -1) {
this.queue.push(job);
} else {
this.queue.splice(index, 0, job);
}
}
drain() {
if (this.running >= this.max) {
return;
}
const job = this.queue.shift();
if (!job) {
return;
}
this.running += 1;
let finished = false;
const finalize = () => {
if (finished) {
while (this.running < this.max && this.queue.length > 0) {
const job = this.queue.shift();
if (!job) {
return;
}
finished = true;
this.running -= 1;
this.drain();
};
this.running += 1;
let finished = false;
const finalize = () => {
if (finished) {
return;
}
finished = true;
this.running -= 1;
this.drain();
};
let result;
try {
result = job.task();
} catch (err) {
job.reject(err);
finalize();
return;
}
Promise.resolve(result)
.then((value) => {
job.resolve(value);
})
.catch((err) => {
let result;
try {
result = job.task();
} catch (err) {
job.reject(err);
})
.finally(() => {
finalize();
});
continue;
}
Promise.resolve(result)
.then((value) => {
job.resolve(value);
})
.catch((err) => {
job.reject(err);
})
.finally(() => {
finalize();
});
}
}
}
@@ -233,33 +257,197 @@ function ensureState(container) {
const pendingByCard = new WeakMap();
const observedCards = new WeakSet();
state = { observer: null, pendingByCard, observedCards };
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const card = entry.target;
if (!(card instanceof HTMLElement)) {
return;
}
if (!entry.isIntersecting) {
return;
}
handleCardVisible({ card, pendingByCard });
});
processObserverEntries(entries, state);
},
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
);
state = { observer, pendingByCard, observedCards };
state.observer = observer;
containerState.set(container, state);
return state;
}
function getViewportCenter() {
if (typeof window === "undefined") {
return null;
}
const width = Number(window.innerWidth) || 0;
const height = Number(window.innerHeight) || 0;
if (width <= 0 && height <= 0) {
return null;
}
return {
x: width > 0 ? width / 2 : 0,
y: height > 0 ? height / 2 : 0,
};
}
function prioritizeEntries(entries, viewportCenter) {
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
const filtered = entries
.filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement)
.map((entry) => {
const rect = getIntersectionRect(entry);
if (!rect) {
return null;
}
const ratio =
typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0;
const centerY = rect.top + rect.height / 2;
const verticalDistance = viewportCenter
? Math.abs(centerY - viewportCenter.y)
: Number.POSITIVE_INFINITY;
return { entry, ratio, centerY, verticalDistance };
})
.filter(Boolean);
if (filtered.length === 0) {
return [];
}
if (!viewportCenter) {
return filtered
.sort((a, b) => {
if (b.ratio !== a.ratio) {
return b.ratio - a.ratio;
}
return a.centerY - b.centerY;
})
.map((item, index) => ({
entry: item.entry,
priority: PRIORITY_BASELINE - index,
}));
}
const ordered = filtered
.slice()
.sort((a, b) => a.centerY - b.centerY);
let centerIndex = 0;
let minDistance = Number.POSITIVE_INFINITY;
for (let i = 0; i < ordered.length; i += 1) {
const candidate = ordered[i];
if (candidate.verticalDistance < minDistance) {
minDistance = candidate.verticalDistance;
centerIndex = i;
}
}
const prioritized = [];
const pushCandidate = (candidate) => {
if (!candidate) {
return;
}
prioritized.push(candidate);
};
pushCandidate(ordered[centerIndex]);
let left = centerIndex - 1;
let right = centerIndex + 1;
while (left >= 0 || right < ordered.length) {
const leftCandidate = left >= 0 ? ordered[left] : null;
const rightCandidate = right < ordered.length ? ordered[right] : null;
if (leftCandidate && rightCandidate) {
const leftDistance = leftCandidate.verticalDistance;
const rightDistance = rightCandidate.verticalDistance;
const distanceDelta = Math.abs(leftDistance - rightDistance);
if (distanceDelta <= 0.5) {
if (rightCandidate.ratio > leftCandidate.ratio) {
pushCandidate(rightCandidate);
right += 1;
} else {
pushCandidate(leftCandidate);
left -= 1;
}
} else if (leftDistance < rightDistance) {
pushCandidate(leftCandidate);
left -= 1;
} else {
pushCandidate(rightCandidate);
right += 1;
}
} else if (rightCandidate) {
pushCandidate(rightCandidate);
right += 1;
} else if (leftCandidate) {
pushCandidate(leftCandidate);
left -= 1;
}
}
return prioritized.map((candidate, index) => ({
entry: candidate.entry,
priority: PRIORITY_BASELINE - index,
}));
}
function processObserverEntries(entries, state) {
if (!state || !entries || entries.length === 0) {
return;
}
const viewportCenter = getViewportCenter();
const prioritized = prioritizeEntries(entries, viewportCenter);
prioritized.forEach(({ entry, priority }) => {
const card = entry.target;
handleCardVisible({ card, pendingByCard: state.pendingByCard, priority });
});
}
function getIntersectionRect(entry) {
if (!entry) {
return null;
}
const rect = entry.intersectionRect;
if (rect && rect.width > 0 && rect.height > 0) {
return rect;
}
const fallback = entry.boundingClientRect;
if (fallback && fallback.width > 0 && fallback.height > 0) {
return fallback;
}
return rect || fallback || null;
}
function setBadge(card, state, details) {
if (!(card instanceof HTMLElement)) {
return;
}
const normalizedState =
typeof state === "string" && state ? state : "unknown";
const peersValue =
details && Number.isFinite(details.peers)
? Math.max(0, Number(details.peers))
: 0;
const hasPeerCount = details ? Number.isFinite(details.peers) : false;
const peersTextValue = hasPeerCount ? String(peersValue) : "";
card.dataset.streamHealthState = normalizedState;
if (hasPeerCount) {
card.dataset.streamHealthPeers = peersTextValue;
} else if (card.dataset.streamHealthPeers) {
delete card.dataset.streamHealthPeers;
}
if (details && typeof details.reason === "string" && details.reason) {
card.dataset.streamHealthReason = details.reason;
} else if (card.dataset.streamHealthReason) {
delete card.dataset.streamHealthReason;
}
const badge = card.querySelector(".torrent-health-badge");
if (!badge) {
return;
}
const hadMargin = badge.classList.contains("mt-3");
const baseClasses = [
@@ -307,22 +495,19 @@ function setBadge(card, state, details) {
},
};
const entry = map[state] || map.unknown;
const entry = map[normalizedState] || map.unknown;
entry.classes.forEach((cls) => badge.classList.add(cls));
const peers =
details && Number.isFinite(details.peers)
? Math.max(0, Number(details.peers))
: 0;
const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : "";
const peersText =
normalizedState === "healthy" && peersValue > 0 ? ` (${peersValue})` : "";
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
const tooltip =
state === "checking" || state === "unknown"
normalizedState === "checking" || normalizedState === "unknown"
? entry.aria
: buildTooltip({
peers,
peers: peersValue,
checkedAt: details?.checkedAt,
reason: details?.reason,
});
@@ -330,27 +515,27 @@ function setBadge(card, state, details) {
badge.setAttribute("title", tooltip);
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
badge.setAttribute("role", entry.role);
badge.dataset.streamHealthState = state;
if (Number.isFinite(peers)) {
badge.dataset.streamHealthPeers = String(peers);
badge.dataset.streamHealthState = normalizedState;
if (hasPeerCount) {
badge.dataset.streamHealthPeers = peersTextValue;
} else if (badge.dataset.streamHealthPeers) {
delete badge.dataset.streamHealthPeers;
}
}
function handleCardVisible({ card, pendingByCard }) {
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
if (!(card instanceof HTMLElement)) {
return;
}
const magnet = card.dataset.magnet || "";
if (!magnet) {
setBadge(card, "unknown");
setBadge(card, "unhealthy", { reason: "missing-source" });
return;
}
const infoHash = infoHashFromMagnet(magnet);
if (!infoHash) {
setBadge(card, "unknown");
setBadge(card, "unhealthy", { reason: "invalid" });
return;
}
@@ -371,7 +556,7 @@ function handleCardVisible({ card, pendingByCard }) {
setBadge(card, "checking");
const probePromise = queueProbe(magnet, infoHash);
const probePromise = queueProbe(magnet, infoHash, priority);
pendingByCard.set(card, probePromise);
@@ -421,9 +606,10 @@ export function attachHealthBadges(container) {
state.observedCards.add(card);
state.observer.observe(card);
if (!card.dataset.magnet) {
setBadge(card, "unknown");
setBadge(card, "unhealthy", { reason: "missing-source" });
}
});
processObserverEntries(state.observer.takeRecords(), state);
}
export function refreshHealthBadges(container) {
@@ -434,9 +620,6 @@ export function refreshHealthBadges(container) {
if (!state) {
return;
}
state.observer.takeRecords().forEach((entry) => {
if (entry.isIntersecting) {
handleCardVisible({ card: entry.target, pendingByCard: state.pendingByCard });
}
});
const records = state.observer.takeRecords();
processObserverEntries(records, state);
}
+6 -22
View File
@@ -1,26 +1,10 @@
// js/lists.js
// Whitelist of npubs that can access the video upload functions
const npubs = [
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe", // bitvid
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx", // thePR0M3TH3AN
"npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8", // Ghost Grid Network
"npub1epvnvv3kskvpnmpqgnm2atevsmdferhp7dg2s0yc7uc0hdmqmgssx09tu2", // Chain Reaction
"npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx", // ODELL
"npub1qxduthz4p8v5zsux524df569pt7lj0d36dyqadnta2val64dtnhsr50h64", // NosToons
"npub19ma2w9dmk3kat0nt0k5dwuqzvmg3va9ezwup0zkakhpwv0vcwvcsg8axkl", // vinney
"npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
"npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox
"npub196rl3tls3c4y79pc3ptrrj2430z7p5uwetfhukhtkt69hph0fvwq08l43q", // ~Bordut-Nodlex
];
console.log("DEBUG: lists.js loaded, npubs:", npubs);
// Blacklist of npubs that events will not be displayed in the bitvid official client
export const initialWhitelist = npubs;
export const initialBlacklist = [""];
// Operators must manage admin lists through the Admin panel UI or remote
// adapters. These legacy constants are intentionally empty so new installs do
// not ship with hard-coded accounts.
export const ADMIN_INITIAL_WHITELIST = [];
export const ADMIN_INITIAL_BLACKLIST = [];
// Block specific events with the nevent
export const initialEventBlacklist = [""];
export const ADMIN_INITIAL_EVENT_BLACKLIST = [];
+54
View File
@@ -168,3 +168,57 @@ export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) {
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
}
export function extractMagnetHints(rawValue) {
const hints = { ws: "", xs: "" };
if (typeof rawValue !== "string") {
return hints;
}
const trimmed = rawValue.trim();
if (!trimmed || !trimmed.toLowerCase().startsWith("magnet:")) {
return hints;
}
const queryIndex = trimmed.indexOf("?");
if (queryIndex === -1 || queryIndex === trimmed.length - 1) {
return hints;
}
const query = trimmed.slice(queryIndex + 1);
if (!query) {
return hints;
}
const parts = query.split("&");
for (const part of parts) {
if (!part) {
continue;
}
const [rawKey, rawValue = ""] = part.split("=", 2);
if (!rawKey) {
continue;
}
const lowerKey = rawKey.trim().toLowerCase();
if (lowerKey !== "ws" && lowerKey !== "xs") {
continue;
}
if (hints[lowerKey]) {
continue;
}
let decoded = rawValue;
try {
decoded = decodeURIComponent(rawValue);
} catch (err) {
// Ignore decode errors and use the raw value.
}
const clean = typeof decoded === "string" ? decoded.trim() : "";
if (!clean) {
continue;
}
hints[lowerKey] = clean;
}
return hints;
}
+450 -20
View File
@@ -5,6 +5,7 @@ import { ACCEPT_LEGACY_V1 } from "./constants.js";
import { accessControl } from "./accessControl.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js";
import { extractMagnetHints } from "./magnet.js";
/**
* The usual relays
@@ -20,6 +21,7 @@ const RELAY_URLS = [
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds
// To limit error spam
let errorLogCount = 0;
@@ -50,6 +52,94 @@ function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
function decodeNpubToHex(npub) {
if (typeof npub !== "string" || !npub.trim()) {
return "";
}
if (
!window?.NostrTools?.nip19 ||
typeof window.NostrTools.nip19.decode !== "function"
) {
return "";
}
try {
const decoded = window.NostrTools.nip19.decode(npub.trim());
if (decoded?.type === "npub" && typeof decoded.data === "string") {
return decoded.data;
}
} catch (error) {
if (isDevMode) {
console.warn(`[nostr] Failed to decode npub: ${npub}`, error);
}
}
return "";
}
const DM_PUBLISH_TIMEOUT_MS = 10_000;
function publishEventToRelay(pool, url, event) {
return new Promise((resolve) => {
let settled = false;
const finalize = (success, error = null) => {
if (settled) {
return;
}
settled = true;
resolve({ url, success, error });
};
const timeoutId = setTimeout(() => {
finalize(false, new Error("publish timeout"));
}, DM_PUBLISH_TIMEOUT_MS);
try {
const pub = pool.publish([url], event);
if (pub && typeof pub.on === "function") {
pub.on("ok", () => {
clearTimeout(timeoutId);
finalize(true);
});
pub.on("seen", () => {
clearTimeout(timeoutId);
finalize(true);
});
pub.on("failed", (reason) => {
clearTimeout(timeoutId);
const err =
reason instanceof Error
? reason
: new Error(String(reason || "publish failed"));
finalize(false, err);
});
return;
}
if (pub && typeof pub.then === "function") {
pub
.then(() => {
clearTimeout(timeoutId);
finalize(true);
})
.catch((error) => {
clearTimeout(timeoutId);
finalize(false, error);
});
return;
}
} catch (error) {
clearTimeout(timeoutId);
finalize(false, error);
return;
}
clearTimeout(timeoutId);
finalize(true);
});
}
const EXTENSION_MIME_MAP = {
mp4: "video/mp4",
m4v: "video/x-m4v",
@@ -195,6 +285,10 @@ function convertEventToVideo(event = {}) {
const deleted = parsedContent.deleted === true;
const isPrivate = parsedContent.isPrivate === true;
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
const wsField = safeTrim(parsedContent.ws);
const xsField = safeTrim(parsedContent.xs);
const enableComments =
parsedContent.enableComments === false ? false : true;
let infoHash = "";
const pushInfoHash = (candidate) => {
@@ -281,6 +375,12 @@ function convertEventToVideo(event = {}) {
};
}
const magnetHints = magnet
? extractMagnetHints(magnet)
: { ws: "", xs: "" };
const ws = wsField || magnetHints.ws || "";
const xs = xsField || magnetHints.xs || "";
return {
id: event.id,
videoRootId,
@@ -295,6 +395,9 @@ function convertEventToVideo(event = {}) {
description,
mode,
deleted,
ws,
xs,
enableComments,
pubkey: event.pubkey,
created_at: event.created_at,
tags,
@@ -492,13 +595,57 @@ class NostrClient {
*/
async login() {
try {
if (!window.nostr) {
const extension = window.nostr;
if (!extension) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (Alby, nos2x, etc.)."
);
}
const pubkey = await window.nostr.getPublicKey();
if (typeof extension.getPublicKey !== "function") {
throw new Error(
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
);
}
if (typeof extension.enable === "function") {
if (isDevMode) {
console.log("Requesting permissions from NIP-07 extension...");
}
try {
await extension.enable();
} catch (enableErr) {
throw new Error(
enableErr && enableErr.message
? enableErr.message
: "The NIP-07 extension denied the permission request."
);
}
}
let timeoutId;
const pubkey = await Promise.race([
extension.getPublicKey(),
new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
"Timed out waiting for the NIP-07 extension. Check the extension prompt and try again."
)
);
}, NIP07_LOGIN_TIMEOUT_MS);
}),
]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
if (!pubkey || typeof pubkey !== "string") {
throw new Error(
"The NIP-07 extension did not return a public key. Please try again."
);
}
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
@@ -531,6 +678,108 @@ class NostrClient {
if (isDevMode) console.log("User logged out.");
}
async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) {
const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : "";
const trimmedMessage = typeof message === "string" ? message.trim() : "";
if (!trimmedTarget) {
return { ok: false, error: "invalid-target" };
}
if (!trimmedMessage) {
return { ok: false, error: "empty-message" };
}
if (!this.pool) {
return { ok: false, error: "nostr-uninitialized" };
}
const extension = window?.nostr;
if (!extension) {
return { ok: false, error: "nostr-extension-missing" };
}
const nip04 = extension.nip04;
if (!nip04 || typeof nip04.encrypt !== "function") {
return { ok: false, error: "nip04-unavailable" };
}
if (typeof extension.signEvent !== "function") {
return { ok: false, error: "sign-event-unavailable" };
}
let actorHex =
typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim()
? actorPubkeyOverride.trim()
: "";
if (!actorHex && typeof this.pubkey === "string") {
actorHex = this.pubkey.trim();
}
if (!actorHex && typeof extension.getPublicKey === "function") {
try {
actorHex = await extension.getPublicKey();
} catch (error) {
if (isDevMode) {
console.warn(
"[nostr] Failed to fetch actor pubkey from extension:",
error
);
}
}
}
if (!actorHex) {
return { ok: false, error: "missing-actor-pubkey" };
}
const targetHex = decodeNpubToHex(trimmedTarget);
if (!targetHex) {
return { ok: false, error: "invalid-target" };
}
let ciphertext = "";
try {
ciphertext = await nip04.encrypt(targetHex, trimmedMessage);
} catch (error) {
return { ok: false, error: "encryption-failed", details: error };
}
const event = {
kind: 4,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", targetHex]],
content: ciphertext,
pubkey: actorHex,
};
let signedEvent;
try {
signedEvent = await extension.signEvent(event);
} catch (error) {
return { ok: false, error: "signature-failed", details: error };
}
const relays =
Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS;
const publishResults = await Promise.all(
relays.map((url) => publishEventToRelay(this.pool, url, signedEvent))
);
const success = publishResults.some((result) => result.success);
if (!success) {
return {
ok: false,
error: "publish-failed",
details: publishResults.filter((result) => !result.success),
};
}
return { ok: true };
}
/**
* Publish a new video using the v3 content schema.
*/
@@ -567,6 +816,13 @@ class NostrClient {
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const finalEnableComments =
videoData.enableComments === false ? false : true;
const finalWs =
typeof videoData.ws === "string" ? videoData.ws.trim() : "";
const finalXs =
typeof videoData.xs === "string" ? videoData.xs.trim() : "";
const contentObject = {
version: 3,
title: finalTitle,
@@ -578,8 +834,17 @@ class NostrClient {
videoRootId,
deleted: false,
isPrivate: videoData.isPrivate ?? false,
enableComments: finalEnableComments,
};
if (finalWs) {
contentObject.ws = finalWs;
}
if (finalXs) {
contentObject.xs = finalXs;
}
const event = {
kind: 30078,
pubkey,
@@ -742,17 +1007,38 @@ class NostrClient {
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
const magnetEdited = updatedData.magnetEdited === true;
const newMagnetValue =
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
let finalPlainMagnet = newMagnetValue || oldPlainMagnet;
let finalPlainMagnet = magnetEdited ? newMagnetValue : oldPlainMagnet;
let finalMagnet =
wantPrivate && finalPlainMagnet
? fakeEncrypt(finalPlainMagnet)
: finalPlainMagnet;
const urlEdited = updatedData.urlEdited === true;
const newUrlValue =
typeof updatedData.url === "string" ? updatedData.url.trim() : "";
const finalUrl = newUrlValue || oldUrl;
const finalUrl = urlEdited ? newUrlValue : oldUrl;
const wsEdited = updatedData.wsEdited === true;
const xsEdited = updatedData.xsEdited === true;
const newWsValue =
typeof updatedData.ws === "string" ? updatedData.ws.trim() : "";
const newXsValue =
typeof updatedData.xs === "string" ? updatedData.xs.trim() : "";
const baseWs =
typeof baseEvent.ws === "string" ? baseEvent.ws.trim() : "";
const baseXs =
typeof baseEvent.xs === "string" ? baseEvent.xs.trim() : "";
const finalWs = wsEdited ? newWsValue : baseWs;
const finalXs = xsEdited ? newXsValue : baseXs;
const finalEnableComments =
typeof updatedData.enableComments === "boolean"
? updatedData.enableComments
: baseEvent.enableComments === false
? false
: true;
// Use the existing videoRootId (or fall back to the base event's ID)
const oldRootId = baseEvent.videoRootId || baseEvent.id;
@@ -772,8 +1058,17 @@ class NostrClient {
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
description: updatedData.description ?? baseEvent.description,
mode: updatedData.mode ?? baseEvent.mode ?? "live",
enableComments: finalEnableComments,
};
if (finalWs) {
contentObject.ws = finalWs;
}
if (finalXs) {
contentObject.xs = finalXs;
}
const event = {
kind: 30078,
// Use the provided userPubkey (or you can also force it to lowercase here if desired)
@@ -854,21 +1149,26 @@ class NostrClient {
};
}
const dTag = baseEvent.tags.find((t) => t[0] === "d");
if (!dTag) {
throw new Error(
'No "d" tag => cannot revert addressable kind=30078 event.'
);
}
const existingD = dTag[1];
const safeTags = Array.isArray(baseEvent.tags) ? baseEvent.tags : [];
const dTag = safeTags.find((t) => t[0] === "d");
const existingD = dTag ? dTag[1] : null;
const oldContent = JSON.parse(baseEvent.content || "{}");
let oldContent = {};
try {
oldContent = JSON.parse(baseEvent.content || "{}");
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to parse baseEvent.content while reverting:", err);
}
oldContent = {};
}
const oldVersion = oldContent.version ?? 1;
let finalRootId = oldContent.videoRootId || null;
if (!finalRootId) {
finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`;
}
const finalRootId =
oldContent.videoRootId ||
(existingD
? `LEGACY:${baseEvent.pubkey}:${existingD}`
: baseEvent.id);
const contentObject = {
videoRootId: finalRootId,
@@ -883,14 +1183,16 @@ class NostrClient {
mode: oldContent.mode || "live",
};
const tags = [["t", "video"]];
if (existingD) {
tags.push(["d", existingD]);
}
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", existingD],
],
tags,
content: JSON.stringify(contentObject),
};
@@ -1201,6 +1503,134 @@ class NostrClient {
return null;
}
/**
* Ensure we have every historical revision for a given video in memory and
* return the complete set sorted newest-first. We primarily group revisions
* by their shared `videoRootId`, but fall back to the NIP-33 `d` tag when
* working with legacy notes. The explicit `d` tag fetch is important because
* relays cannot be queried by values that only exist inside the JSON
* content. Without this pass, the UI would occasionally miss mid-history
* edits that were published from other devices.
*/
async hydrateVideoHistory(video) {
if (!video || typeof video !== "object") {
return [];
}
const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : "";
const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : "";
const findDTagValue = (tags = []) => {
if (!Array.isArray(tags)) {
return "";
}
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
if (tag[0] === "d" && typeof tag[1] === "string") {
return tag[1];
}
}
return "";
};
const targetDTag = findDTagValue(video.tags);
const collectLocalMatches = () => {
const seen = new Set();
const matches = [];
for (const candidate of this.allEvents.values()) {
if (!candidate || typeof candidate !== "object") {
continue;
}
if (targetPubkey) {
const candidatePubkey = typeof candidate.pubkey === "string"
? candidate.pubkey.toLowerCase()
: "";
if (candidatePubkey !== targetPubkey) {
continue;
}
}
const candidateRoot =
typeof candidate.videoRootId === "string" ? candidate.videoRootId : "";
const candidateDTag = findDTagValue(candidate.tags);
const sameRoot = targetRoot && candidateRoot === targetRoot;
const sameD = targetDTag && candidateDTag === targetDTag;
// Legacy fallbacks: some old posts reused only the "d" tag without a
// canonical videoRootId. If neither identifier exists we at least keep
// the active event so the caller can surface an informative message.
const sameLegacyRoot =
!targetRoot && candidateRoot && candidateRoot === video.id;
if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) {
if (!seen.has(candidate.id)) {
seen.add(candidate.id);
matches.push(candidate);
}
}
}
return matches;
};
let localMatches = collectLocalMatches();
const shouldFetchFromRelays =
localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag;
if (shouldFetchFromRelays && this.pool) {
const filter = {
kinds: [30078],
"#t": ["video"],
"#d": [targetDTag],
limit: 200,
};
if (targetPubkey) {
filter.authors = [video.pubkey];
}
try {
const perRelay = await Promise.all(
this.relays.map(async (url) => {
try {
const events = await this.pool.list([url], [filter]);
return events || [];
} catch (err) {
if (isDevMode) {
console.warn(`[nostr] History fetch failed on ${url}:`, err);
}
return [];
}
})
);
const merged = perRelay.flat();
for (const evt of merged) {
try {
const parsed = convertEventToVideo(evt);
if (!parsed.invalid) {
this.allEvents.set(evt.id, parsed);
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] Failed to convert historical event:", err);
}
}
}
} catch (err) {
if (isDevMode) {
console.warn("[nostr] hydrateVideoHistory relay fetch error:", err);
}
}
localMatches = collectLocalMatches();
}
return localMatches.sort((a, b) => b.created_at - a.created_at);
}
getActiveVideos() {
return Array.from(this.activeMap.values()).sort(
(a, b) => b.created_at - a.created_at
+411
View File
@@ -0,0 +1,411 @@
import { buildR2Key, buildPublicUrl } from "./r2.js";
import {
sanitizeBucketName,
ensureBucket,
putCors,
attachCustomDomainAndWait,
setManagedDomain,
} from "./storage/r2-mgmt.js";
import { makeR2Client, multipartUpload } from "./storage/r2-s3.js";
const STORAGE_KEY = "bitvid:quickR2Settings";
const STATUS_CLASSNAMES = [
"text-gray-400",
"text-green-400",
"text-red-400",
"text-yellow-400",
];
const STATUS_TONES = {
info: "text-gray-400",
success: "text-green-400",
error: "text-red-400",
warning: "text-yellow-400",
};
function sanitizeDomain(value) {
if (!value) {
return "";
}
return String(value)
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/\/.*$/, "");
}
function loadSavedSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed;
} catch (err) {
console.warn("Failed to read quick R2 settings:", err);
return null;
}
}
function persistSettings(settings) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (err) {
console.warn("Failed to store quick R2 settings:", err);
}
}
function clearSavedSettings() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (err) {
console.warn("Failed to clear quick R2 settings:", err);
}
}
function setStatus(el, message, tone = "info") {
if (!el) {
return;
}
STATUS_CLASSNAMES.forEach((cls) => el.classList.remove(cls));
const className = STATUS_TONES[tone] || STATUS_TONES.info;
if (className) {
el.classList.add(className);
}
el.textContent = message || "";
}
function setUploadingState(elements, uploading) {
const disabled = Boolean(uploading);
elements.forEach((el) => {
if (el) {
el.disabled = disabled;
}
});
}
function getCorsOrigins() {
const origins = new Set();
if (typeof window !== "undefined" && window.location) {
const origin = window.location.origin;
if (origin && origin !== "null") {
origins.add(origin);
}
if (origin && origin.startsWith("http://")) {
origins.add(origin.replace("http://", "https://"));
}
}
origins.add("http://localhost:8000");
origins.add("http://127.0.0.1:8000");
return Array.from(origins);
}
function buildQuickKey(title, file, npub) {
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const baseName = (title || file?.name || "video")
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, "-")
.replace(/^-+|-+$/g, "");
const safeBase = baseName || "video";
const originalName = typeof file?.name === "string" ? file.name : "";
const extMatch = originalName.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : "mp4";
const sanitizedNpub = String(npub || "user").replace(/[^a-z0-9]/gi, "");
return `videos/${datePart}/${sanitizedNpub || "user"}-${safeBase}.${ext}`;
}
export function initQuickR2Upload(app) {
const section = document.getElementById("quickR2Section");
if (!section) {
return;
}
const elements = {
accountId: document.getElementById("quickR2AccountId"),
accessKeyId: document.getElementById("quickR2AccessKeyId"),
secretAccessKey: document.getElementById("quickR2SecretAccessKey"),
apiToken: document.getElementById("quickR2ApiToken"),
customDomain: document.getElementById("quickR2CustomDomain"),
zoneId: document.getElementById("quickR2ZoneId"),
remember: document.getElementById("quickR2Remember"),
allowManaged: document.getElementById("quickR2AllowManaged"),
file: document.getElementById("quickR2File"),
uploadButton: document.getElementById("quickR2UploadButton"),
status: document.getElementById("quickR2Status"),
hostedUrl: document.getElementById("uploadUrl"),
titleInput: document.getElementById("uploadTitle"),
form: document.getElementById("uploadForm"),
};
const saved = loadSavedSettings();
if (saved && saved.remember) {
if (elements.accountId) elements.accountId.value = saved.accountId || "";
if (elements.accessKeyId)
elements.accessKeyId.value = saved.accessKeyId || "";
if (elements.secretAccessKey)
elements.secretAccessKey.value = saved.secretAccessKey || "";
if (elements.apiToken) elements.apiToken.value = saved.apiToken || "";
if (elements.customDomain)
elements.customDomain.value = saved.customDomain || "";
if (elements.zoneId) elements.zoneId.value = saved.zoneId || "";
if (elements.allowManaged)
elements.allowManaged.checked = Boolean(saved.allowManaged ?? true);
if (elements.remember) elements.remember.checked = true;
}
function maybePersist() {
if (!elements.remember) {
return;
}
if (elements.remember.checked) {
persistSettings({
remember: true,
accountId: elements.accountId?.value?.trim() || "",
accessKeyId: elements.accessKeyId?.value?.trim() || "",
secretAccessKey: elements.secretAccessKey?.value?.trim() || "",
apiToken: elements.apiToken?.value?.trim() || "",
customDomain: elements.customDomain?.value?.trim() || "",
zoneId: elements.zoneId?.value?.trim() || "",
allowManaged: Boolean(elements.allowManaged?.checked ?? true),
});
} else {
clearSavedSettings();
}
}
elements.remember?.addEventListener("change", () => {
if (!elements.remember.checked) {
clearSavedSettings();
}
});
async function handleUpload() {
const file = elements.file?.files?.[0] || null;
if (!file) {
setStatus(elements.status, "Choose a file to upload.", "error");
return;
}
const accountId = elements.accountId?.value?.trim();
const accessKeyId = elements.accessKeyId?.value?.trim();
const secretAccessKey = elements.secretAccessKey?.value?.trim();
const apiToken = elements.apiToken?.value?.trim();
const customDomain = sanitizeDomain(
elements.customDomain?.value || ""
);
const zoneId = elements.zoneId?.value?.trim();
const allowManaged = Boolean(elements.allowManaged?.checked ?? true);
if (!accountId || !accessKeyId || !secretAccessKey) {
setStatus(
elements.status,
"Account ID and S3 keys are required for Cloudflare R2.",
"error"
);
return;
}
const npub =
typeof app?.safeEncodeNpub === "function" ? app.safeEncodeNpub(app.pubkey) : null;
if (!npub) {
setStatus(
elements.status,
"Unable to derive your npub. Connect a Nostr key first.",
"error"
);
return;
}
maybePersist();
const bucket = sanitizeBucketName(npub);
const inputsToDisable = [
elements.accountId,
elements.accessKeyId,
elements.secretAccessKey,
elements.apiToken,
elements.customDomain,
elements.zoneId,
elements.remember,
elements.allowManaged,
elements.file,
elements.uploadButton,
];
setUploadingState(inputsToDisable, true);
setStatus(elements.status, `Preparing bucket ${bucket}…`, "info");
try {
if (apiToken) {
try {
await ensureBucket({ accountId, bucket, token: apiToken });
} catch (err) {
throw new Error(
err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed."
);
}
try {
await putCors({
accountId,
bucket,
token: apiToken,
origins: getCorsOrigins(),
});
} catch (corsErr) {
console.warn("Quick R2 CORS update failed:", corsErr);
}
}
let publicBaseUrl = "";
let usedManagedFallback = false;
if (customDomain) {
if (apiToken && zoneId) {
try {
setStatus(
elements.status,
`Attaching ${customDomain}…`,
"info"
);
const custom = await attachCustomDomainAndWait({
accountId,
bucket,
token: apiToken,
zoneId,
domain: customDomain,
pollInterval: 1500,
timeoutMs: 120000,
});
if (custom?.active && custom?.url) {
publicBaseUrl = custom.url;
try {
await setManagedDomain({
accountId,
bucket,
token: apiToken,
enabled: false,
});
} catch (disableErr) {
console.warn("Quick R2 managed domain disable failed:", disableErr);
}
} else {
setStatus(
elements.status,
`Custom domain pending (${custom?.status || "unknown"}).`,
"warning"
);
}
} catch (err) {
console.warn("Quick R2 custom domain error:", err);
setStatus(
elements.status,
err?.message
? `Custom domain setup failed: ${err.message}`
: "Custom domain setup failed.",
"warning"
);
}
}
if (!publicBaseUrl) {
publicBaseUrl = `https://${customDomain}`;
}
}
if (!publicBaseUrl && apiToken && allowManaged) {
try {
setStatus(elements.status, "Enabling managed r2.dev domain…", "info");
const managed = await setManagedDomain({
accountId,
bucket,
token: apiToken,
enabled: true,
});
if (managed?.url) {
publicBaseUrl = managed.url;
usedManagedFallback = true;
}
} catch (err) {
console.warn("Quick R2 managed domain enable failed:", err);
setStatus(
elements.status,
err?.message
? `Managed domain failed: ${err.message}`
: "Managed domain failed.",
"warning"
);
}
}
if (!publicBaseUrl) {
throw new Error(
"No public domain configured. Provide a custom domain or allow the managed r2.dev fallback."
);
}
const s3 = makeR2Client({ accountId, accessKeyId, secretAccessKey });
const title = elements.titleInput?.value?.trim() || "";
const key = buildQuickKey(title, file, npub) || buildR2Key(npub, file);
const hostedUrl = buildPublicUrl(publicBaseUrl, key);
let lastPercent = -1;
setStatus(elements.status, `Uploading… 0%`, usedManagedFallback ? "warning" : "info");
await multipartUpload({
s3,
bucket,
key,
file,
contentType: file.type,
onProgress: (fraction) => {
if (typeof fraction !== "number" || !isFinite(fraction)) {
return;
}
const percent = Math.max(0, Math.min(100, Math.round(fraction * 100)));
if (percent !== lastPercent) {
lastPercent = percent;
setStatus(
elements.status,
`Uploading… ${percent}%`,
usedManagedFallback ? "warning" : "info"
);
}
},
});
if (elements.hostedUrl) {
elements.hostedUrl.value = hostedUrl;
}
setStatus(
elements.status,
`Upload complete. Hosted URL set to ${hostedUrl}.`,
"success"
);
if (elements.form && typeof elements.form.requestSubmit === "function") {
elements.form.requestSubmit();
}
} catch (err) {
console.error("Quick R2 upload failed:", err);
setStatus(
elements.status,
err?.message ? `Upload failed: ${err.message}` : "Upload failed.",
"error"
);
} finally {
setUploadingState(inputsToDisable, false);
}
}
elements.uploadButton?.addEventListener("click", () => {
handleUpload();
});
}
export default initQuickR2Upload;
+258
View File
@@ -0,0 +1,258 @@
const DB_NAME = "bitvidSettings";
const DB_VERSION = 1;
const STORE_NAME = "kv";
const SETTINGS_KEY = "r2Settings";
const LOCALSTORAGE_FALLBACK_KEY = "bitvid:r2Settings";
function sanitizeBaseDomain(domain) {
if (!domain) {
return "";
}
let value = String(domain).trim().toLowerCase();
value = value.replace(/^https?:\/\//, "");
value = value.replace(/\/.*$/, "");
return value;
}
function normalizeBucketEntry(entry) {
if (!entry || typeof entry !== "object") {
return null;
}
const bucket = String(entry.bucket || "").toLowerCase();
if (!bucket) {
return null;
}
const publicBaseUrl = String(entry.publicBaseUrl || "");
const domainType = entry.domainType === "custom" ? "custom" : "managed";
const lastUpdated = Number.isFinite(entry.lastUpdated)
? entry.lastUpdated
: Date.now();
return { bucket, publicBaseUrl, domainType, lastUpdated };
}
function normalizeSettings(raw) {
const base = raw && typeof raw === "object" ? raw : {};
const buckets = {};
if (base.buckets && typeof base.buckets === "object") {
for (const [npub, value] of Object.entries(base.buckets)) {
const normalizedEntry = normalizeBucketEntry(value);
if (normalizedEntry) {
buckets[String(npub)] = normalizedEntry;
}
}
}
return {
accountId: String(base.accountId || ""),
accessKeyId: String(base.accessKeyId || ""),
secretAccessKey: String(base.secretAccessKey || ""),
apiToken: String(base.apiToken || ""),
zoneId: String(base.zoneId || ""),
baseDomain: sanitizeBaseDomain(base.baseDomain || ""),
buckets,
};
}
function isIndexedDbAvailable() {
try {
return typeof indexedDB !== "undefined";
} catch (err) {
return false;
}
}
function openSettingsDb() {
return new Promise((resolve, reject) => {
if (!isIndexedDbAvailable()) {
resolve(null);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error || new Error("Failed to open settings DB"));
};
});
}
export async function loadR2Settings() {
try {
const db = await openSettingsDb();
if (db) {
return await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const req = store.get(SETTINGS_KEY);
req.onsuccess = () => {
resolve(normalizeSettings(req.result));
};
req.onerror = () => {
reject(req.error || new Error("Failed to load settings"));
};
});
}
} catch (err) {
console.warn("Failed to open IndexedDB for settings, falling back:", err);
}
try {
if (typeof localStorage !== "undefined") {
const raw = localStorage.getItem(LOCALSTORAGE_FALLBACK_KEY);
if (raw) {
return normalizeSettings(JSON.parse(raw));
}
}
} catch (err) {
console.warn("Failed to read fallback settings:", err);
}
return normalizeSettings(null);
}
export async function saveR2Settings(settings) {
const normalized = normalizeSettings(settings);
let db = null;
try {
db = await openSettingsDb();
} catch (err) {
console.warn("Unable to open IndexedDB, continuing with fallback:", err);
}
if (db) {
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const req = store.put(normalized, SETTINGS_KEY);
req.onsuccess = () => resolve();
req.onerror = () =>
reject(req.error || new Error("Failed to save R2 settings"));
});
} else if (typeof localStorage !== "undefined") {
localStorage.setItem(
LOCALSTORAGE_FALLBACK_KEY,
JSON.stringify(normalized)
);
}
return normalized;
}
export async function clearR2Settings() {
let cleared = false;
try {
const db = await openSettingsDb();
if (db) {
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const req = store.delete(SETTINGS_KEY);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error || new Error("Failed to clear"));
});
cleared = true;
}
} catch (err) {
console.warn("Failed to clear IndexedDB settings:", err);
}
try {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(LOCALSTORAGE_FALLBACK_KEY);
cleared = true;
}
} catch (err) {
console.warn("Failed to clear fallback settings:", err);
}
return cleared;
}
export function mergeBucketEntry(settings, npub, entry) {
if (!settings || typeof settings !== "object") {
return settings;
}
const normalizedEntry = normalizeBucketEntry(entry);
if (!normalizedEntry) {
return settings;
}
return {
...settings,
buckets: {
...(settings.buckets || {}),
[npub]: normalizedEntry,
},
};
}
function guessExtension(file) {
if (!file) {
return "mp4";
}
const name = typeof file.name === "string" ? file.name : "";
const dotIndex = name.lastIndexOf(".");
if (dotIndex > -1 && dotIndex < name.length - 1) {
return name.slice(dotIndex + 1).toLowerCase();
}
const type = typeof file.type === "string" ? file.type : "";
switch (type) {
case "video/webm":
return "webm";
case "application/vnd.apple.mpegurl":
return "m3u8";
case "video/mp2t":
return "ts";
case "video/quicktime":
return "mov";
case "video/x-matroska":
return "mkv";
default:
return "mp4";
}
}
export function buildR2Key(npub, file) {
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
const safeNpub = String(npub || "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
const baseName = typeof file?.name === "string" ? file.name : "video";
const withoutExt = baseName.replace(/\.[^/.]+$/, "");
const slug = withoutExt
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const safeSlug = slug || "video";
const ext = guessExtension(file);
return `u/${safeNpub}/${year}/${month}/${safeSlug}.${ext}`;
}
export function buildPublicUrl(baseUrl, key) {
if (!baseUrl) {
return "";
}
const sanitizedBase = String(baseUrl).replace(/\/$/, "");
const encodedKey = key
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
return `${sanitizedBase}/${encodedKey}`;
}
export { sanitizeBaseDomain };
+293
View File
@@ -0,0 +1,293 @@
// js/storage/r2-mgmt.js
function computeSlugHash(input) {
const text = String(input || "");
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = Math.imul(31, hash) + text.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
export function sanitizeBucketName(npub) {
const raw = String(npub || "");
let value = raw
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|[-]+$/g, "");
if (!value) {
value = "bitvid-user";
}
if (value.length > 32) {
const prefix = value.slice(0, 20).replace(/-+$/g, "");
const hash = computeSlugHash(raw).slice(0, 8);
value = [prefix, hash].filter(Boolean).join("-");
}
value = value.replace(/-+/g, "-").replace(/^-+|[-]+$/g, "");
if (!/^[a-z0-9]/.test(value)) {
value = `bv-${value}`;
}
if (!/[a-z0-9]$/.test(value)) {
value = `${value}0`;
}
if (value.length > 63) {
value = value.slice(0, 63).replace(/-+$/g, "");
}
while (value.length < 3) {
value += "0";
}
return value;
}
export function deriveShortSubdomain(npub) {
const bucketSlug = sanitizeBucketName(npub);
const hash = computeSlugHash(npub).slice(0, 6);
const prefix = bucketSlug
.replace(/^bv-/, "")
.slice(0, 18)
.replace(/-+$/g, "");
let candidate = [prefix, hash].filter(Boolean).join("-");
candidate = candidate
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|[-]+$/g, "");
if (!candidate) {
candidate = "user";
}
if (candidate.length > 32) {
candidate = candidate.slice(0, 32).replace(/-+$/g, "");
}
if (!/^[a-z0-9]/.test(candidate)) {
candidate = `u${candidate}`;
}
if (!/[a-z0-9]$/.test(candidate)) {
candidate = `${candidate}0`;
}
while (candidate.length < 3) {
candidate += "0";
}
return candidate;
}
function buildError(error, fallbackMessage) {
if (error instanceof Error) {
return error;
}
const err = new Error(fallbackMessage || "Cloudflare request failed");
err.original = error;
return err;
}
// --- Cloudflare API helpers (Bearer token) ---
async function cfFetch(path, { token, method = "GET", body, headers = {} }) {
if (!token) {
throw new Error("Cloudflare API token is required");
}
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (parseErr) {
if (!res.ok) {
const err = new Error(text || `Cloudflare API error ${res.status}`);
err.status = res.status;
throw err;
}
throw parseErr;
}
}
if (!res.ok || data?.success === false) {
const errorMessage =
data?.errors?.[0]?.message ||
data?.message ||
text ||
`Cloudflare API error ${res.status}`;
const err = new Error(errorMessage);
err.status = res.status;
err.response = data;
throw err;
}
return data;
}
// 1) Create bucket (idempotent-ish: treat 409 as success)
export async function ensureBucket({ accountId, bucket, token }) {
try {
await cfFetch(`/accounts/${accountId}/r2/buckets`, {
token,
method: "POST",
body: { name: bucket },
});
} catch (error) {
const err = buildError(error);
if (err.status === 409 || /already exists/i.test(err.message || "")) {
return;
}
throw err;
}
}
// 2) Set CORS so browser PUT/GETs work from your app origins
export async function putCors({ accountId, bucket, token, origins }) {
const filteredOrigins = (origins || []).filter(Boolean);
if (filteredOrigins.length === 0) {
return;
}
const rules = [
{
id: "bitvid-default",
allowed: {
origins: filteredOrigins,
methods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"],
headers: ["*"],
},
expose_headers: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
max_age_seconds: 3600,
},
];
await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/cors`, {
token,
method: "PUT",
body: { rules },
});
}
function extractStatus(payload) {
return (
payload?.result?.status ||
payload?.result?.result?.status ||
payload?.status ||
payload?.result?.statusText ||
null
);
}
async function pollCustomDomain({
accountId,
bucket,
token,
domain,
pollInterval = 2500,
timeoutMs = 120000,
}) {
const path = `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom/${encodeURIComponent(
domain
)}`;
const deadline = Date.now() + timeoutMs;
let lastPayload = null;
while (Date.now() < deadline) {
lastPayload = await cfFetch(path, { token, method: "GET" }).catch((err) => {
if (err?.status === 404) {
return null;
}
throw err;
});
if (!lastPayload) {
break;
}
const status = extractStatus(lastPayload);
if (status === "active") {
return { status, payload: lastPayload };
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
return {
status: extractStatus(lastPayload) || "unknown",
payload: lastPayload,
};
}
// 3) Attach custom domain to bucket (auto-provisions on Cloudflare)
export async function attachCustomDomainAndWait({
accountId,
bucket,
token,
zoneId,
domain,
pollInterval,
timeoutMs,
}) {
let shouldPoll = true;
try {
await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`, {
token,
method: "POST",
body: { domain, zoneId, enabled: true },
});
} catch (error) {
const err = buildError(error);
if (err.status === 409 || /already exists/i.test(err.message || "")) {
shouldPoll = true;
} else {
throw err;
}
}
let status = "unknown";
if (shouldPoll) {
const pollResult = await pollCustomDomain({
accountId,
bucket,
token,
domain,
pollInterval,
timeoutMs,
});
status = pollResult.status;
}
const url = `https://${domain}`;
return {
url,
status,
active: status === "active",
};
}
// 4) Toggle managed r2.dev domain for the bucket
export async function setManagedDomain({ accountId, bucket, token, enabled }) {
const { result } = await cfFetch(
`/accounts/${accountId}/r2/buckets/${bucket}/domains/managed`,
{ token, method: "PUT", body: { enabled: Boolean(enabled) } }
);
const domain = result?.domain || result?.result?.domain || "";
return {
enabled: Boolean(result?.enabled ?? enabled),
url: domain ? `https://${domain}` : "",
};
}
+215
View File
@@ -0,0 +1,215 @@
// js/storage/r2-s3.js
import {
S3Client,
CreateBucketCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
HeadBucketCommand,
PutBucketCorsCommand,
} from "https://esm.sh/@aws-sdk/[email protected]?target=es2022&bundle";
function computeCacheControl(key) {
if (!key) {
return "public, max-age=3600";
}
const lower = String(key).toLowerCase();
if (/(?:\.)(m3u8|mpd)$/.test(lower)) {
return "public, max-age=60, must-revalidate";
}
if (
/\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|vtt|srt|mpg|mpeg)$/.test(
lower
)
) {
return "public, max-age=31536000, immutable";
}
return "public, max-age=3600";
}
export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) {
if (!accountId || !accessKeyId || !secretAccessKey) {
throw new Error("Missing Cloudflare R2 credentials");
}
return new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
forcePathStyle: true,
});
}
async function ensureBucketExists({ s3, bucket }) {
try {
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
return;
} catch (error) {
const status = error?.$metadata?.httpStatusCode || 0;
const code = error?.name || error?.Code || "";
if (status !== 404 && code !== "NotFound" && code !== "NoSuchBucket") {
throw error;
}
}
try {
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
} catch (error) {
const status = error?.$metadata?.httpStatusCode || 0;
const code = error?.name || error?.Code || "";
if (
status === 409 ||
code === "BucketAlreadyOwnedByYou" ||
code === "BucketAlreadyExists"
) {
return;
}
throw error;
}
}
export async function ensureBucketCors({ s3, bucket, origins }) {
if (!s3) {
throw new Error("S3 client is required to configure CORS");
}
if (!bucket) {
throw new Error("Bucket name is required to configure CORS");
}
const allowedOrigins = (origins || []).filter(Boolean);
if (allowedOrigins.length === 0) {
return;
}
await ensureBucketExists({ s3, bucket });
const command = new PutBucketCorsCommand({
Bucket: bucket,
CORSConfiguration: {
CORSRules: [
{
AllowedHeaders: ["*"],
AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"],
AllowedOrigins: allowedOrigins,
ExposeHeaders: [
"ETag",
"Content-Length",
"Content-Range",
"Accept-Ranges",
],
MaxAgeSeconds: 3600,
},
],
},
});
await s3.send(command);
}
export async function multipartUpload({
s3,
bucket,
key,
file,
contentType,
onProgress,
}) {
if (!s3) {
throw new Error("S3 client is required");
}
if (!bucket) {
throw new Error("Bucket is required");
}
if (!key) {
throw new Error("Object key is required");
}
if (!file) {
throw new Error("File is required");
}
const createCommand = new CreateMultipartUploadCommand({
Bucket: bucket,
Key: key,
ContentType: contentType || file.type || "application/octet-stream",
CacheControl: computeCacheControl(key),
});
createCommand.middlewareStack.add(
(next) => async (args) => {
if (args?.request?.headers) {
args.request.headers["cf-create-bucket-if-missing"] = "true";
}
return next(args);
},
{ step: "build" }
);
const { UploadId } = await s3.send(createCommand);
if (!UploadId) {
throw new Error("Failed to start multipart upload");
}
const MIN_PART = 5 * 1024 * 1024;
const maxParts = 10000;
const calculated = Math.ceil(file.size / maxParts);
const partSize = Math.max(MIN_PART, calculated);
const total = file.size;
const parts = [];
let uploadedBytes = 0;
let partNumber = 1;
if (typeof onProgress === "function") {
onProgress(0);
}
try {
while (uploadedBytes < total) {
const start = uploadedBytes;
const end = Math.min(start + partSize, total);
const chunk = file.slice(start, end);
const { ETag } = await s3.send(
new UploadPartCommand({
Bucket: bucket,
Key: key,
UploadId,
PartNumber: partNumber,
Body: chunk,
})
);
parts.push({ ETag, PartNumber: partNumber });
uploadedBytes = end;
partNumber += 1;
if (typeof onProgress === "function") {
onProgress(uploadedBytes / total);
}
}
await s3.send(
new CompleteMultipartUploadCommand({
Bucket: bucket,
Key: key,
UploadId,
MultipartUpload: { Parts: parts },
})
);
} catch (error) {
await s3
.send(
new AbortMultipartUploadCommand({
Bucket: bucket,
Key: key,
UploadId,
})
)
.catch(() => {});
throw error;
}
}
+120 -12
View File
@@ -4,6 +4,7 @@ import {
convertEventToVideo as sharedConvertEventToVideo,
} from "./nostr.js";
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
function getAbsoluteShareUrl(nevent) {
if (!nevent) {
@@ -278,7 +279,22 @@ class SubscriptionsManager {
window.app?.dedupeVideosByRoot?.(safeVideos) ??
this.dedupeToNewestByRoot(safeVideos);
if (!dedupedVideos.length) {
const filteredVideos = dedupedVideos.filter((video) => {
if (!video || typeof video !== "object") {
return false;
}
if (
window.app?.isAuthorBlocked &&
window.app.isAuthorBlocked(video.pubkey)
) {
return false;
}
return true;
});
if (!filteredVideos.length) {
container.innerHTML = `
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
No videos available yet.
@@ -287,14 +303,14 @@ class SubscriptionsManager {
}
// Sort newest first
dedupedVideos.sort((a, b) => b.created_at - a.created_at);
filteredVideos.sort((a, b) => b.created_at - a.created_at);
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
const fragment = document.createDocumentFragment();
// Only declare localAuthorSet once
const localAuthorSet = new Set();
dedupedVideos.forEach((video, index) => {
filteredVideos.forEach((video, index) => {
if (!video.id || !video.title) {
console.error("Missing ID or title:", video);
return;
@@ -376,6 +392,48 @@ class SubscriptionsManager {
`
: "";
const moreMenu = `
<div class="relative inline-block ml-1 overflow-visible" data-more-menu-wrapper="true">
<button
type="button"
class="inline-flex items-center justify-center w-10 h-10 p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
data-more-dropdown="${index}"
aria-haspopup="true"
aria-expanded="false"
aria-label="More options"
>
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5 object-contain" />
</button>
<div
id="moreDropdown-${index}"
class="hidden absolute right-0 bottom-full mb-2 w-40 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
role="menu"
data-more-menu="true"
>
<div class="py-1">
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="open-channel" data-author="${video.pubkey || ""}">
Open channel
</button>
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="copy-link" data-event-id="${video.id || ""}">
Copy link
</button>
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white" data-action="block-author" data-author="${video.pubkey || ""}">
Block creator
</button>
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700" data-action="report" data-event-id="${video.id || ""}">
Report
</button>
</div>
</div>
</div>
`;
const cardControls = `
<div class="flex items-center">
${moreMenu}${gearMenu}
</div>
`;
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
const playbackUrl =
@@ -456,7 +514,7 @@ class SubscriptionsManager {
</div>
</div>
</div>
${gearMenu}
${cardControls}
</div>
${connectionBadgesHtml}
</div>
@@ -467,6 +525,42 @@ class SubscriptionsManager {
t.innerHTML = cardHtml.trim();
const cardEl = t.content.firstElementChild;
if (cardEl) {
cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
if (typeof video.pubkey === "string" && video.pubkey) {
cardEl.dataset.ownerPubkey = video.pubkey;
} else if (cardEl.dataset.ownerPubkey) {
delete cardEl.dataset.ownerPubkey;
}
if (trimmedUrl) {
cardEl.dataset.urlHealthState = "checking";
if (cardEl.dataset.urlHealthReason) {
delete cardEl.dataset.urlHealthReason;
}
cardEl.dataset.urlHealthEventId = video.id || "";
cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
cardEl.dataset.urlHealthState = "offline";
cardEl.dataset.urlHealthReason = "missing-source";
if (cardEl.dataset.urlHealthEventId) {
delete cardEl.dataset.urlHealthEventId;
}
if (cardEl.dataset.urlHealthUrl) {
delete cardEl.dataset.urlHealthUrl;
}
}
if (magnetProvided && magnetSupported) {
cardEl.dataset.streamHealthState = "checking";
if (cardEl.dataset.streamHealthReason) {
delete cardEl.dataset.streamHealthReason;
}
} else {
cardEl.dataset.streamHealthState = "unhealthy";
cardEl.dataset.streamHealthReason = magnetProvided
? "unsupported"
: "missing-source";
}
// Leave the data-play-* attributes empty in the literal markup so we can
// assign the raw URL/magnet strings post-parsing without HTML entity
// escaping, mirroring the approach in app.js. The URL is encoded so that
@@ -491,14 +585,18 @@ class SubscriptionsManager {
delete cardEl.dataset.magnet;
}
if (trimmedUrl && window.app?.handleUrlHealthBadge) {
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
window.app.handleUrlHealthBadge({
video,
url: trimmedUrl,
badgeEl,
});
const badgeEl = cardEl.querySelector("[data-url-health-state]");
if (badgeEl) {
if (trimmedUrl) {
badgeEl.dataset.urlHealthEventId = video.id || "";
badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
} else {
if (badgeEl.dataset.urlHealthEventId) {
delete badgeEl.dataset.urlHealthEventId;
}
if (badgeEl.dataset.urlHealthUrl) {
delete badgeEl.dataset.urlHealthUrl;
}
}
}
}
@@ -507,6 +605,14 @@ class SubscriptionsManager {
container.appendChild(fragment);
attachHealthBadges(container);
attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => {
if (!window.app?.handleUrlHealthBadge) {
return;
}
const video =
window.app.videosMap?.get?.(eventId) || { id: eventId };
window.app.handleUrlHealthBadge({ video, url, badgeEl });
});
if (window.app) {
window.app.videoList = container;
@@ -517,6 +623,8 @@ class SubscriptionsManager {
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
window.app?.attachMoreMenuHandlers?.(container);
// Gear menus
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
gearButtons.forEach((btn) => {
+124
View File
@@ -0,0 +1,124 @@
const containerState = new WeakMap();
const ROOT_MARGIN = "0px";
const THRESHOLD = 0.25;
function decodeUrl(value) {
if (typeof value !== "string" || !value) {
return "";
}
try {
return decodeURIComponent(value);
} catch (err) {
return value;
}
}
function ensureState(container) {
let state = containerState.get(container);
if (state) {
return state;
}
const observedCards = new WeakSet();
const observer = new IntersectionObserver(
(entries) => {
processEntries(entries, state);
},
{ root: null, rootMargin: ROOT_MARGIN, threshold: THRESHOLD }
);
state = {
observer,
observedCards,
onCheck: null,
};
containerState.set(container, state);
return state;
}
function processEntries(entries, state) {
if (!state?.onCheck || !Array.isArray(entries) || !entries.length) {
return;
}
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
return;
}
const card = entry.target;
if (!(card instanceof HTMLElement)) {
return;
}
const badgeEl = card.querySelector("[data-url-health-state]");
if (!(badgeEl instanceof HTMLElement)) {
return;
}
const encodedUrl =
card.dataset.urlHealthUrl || badgeEl.dataset.urlHealthUrl || "";
const eventId =
card.dataset.urlHealthEventId || badgeEl.dataset.urlHealthEventId || "";
if (!encodedUrl || !eventId) {
return;
}
const currentState =
card.dataset.urlHealthState || badgeEl.dataset.urlHealthState;
if (currentState && currentState !== "checking") {
return;
}
const url = decodeUrl(encodedUrl);
if (!url) {
return;
}
try {
state.onCheck({ card, badgeEl, url, eventId });
} catch (err) {
console.warn("[urlHealthObserver] onCheck handler failed", err);
}
});
}
export function attachUrlHealthBadges(container, onCheck) {
if (!(container instanceof HTMLElement)) {
return;
}
const state = ensureState(container);
if (typeof onCheck === "function") {
state.onCheck = onCheck;
}
const cards = container.querySelectorAll(".video-card");
cards.forEach((card) => {
if (!(card instanceof HTMLElement)) {
return;
}
if (state.observedCards.has(card)) {
return;
}
state.observedCards.add(card);
state.observer.observe(card);
});
const records = state.observer.takeRecords();
if (records.length) {
processEntries(records, state);
}
}
export function refreshUrlHealthBadges(container) {
const state = containerState.get(container);
if (!state) {
return;
}
const records = state.observer.takeRecords();
if (records.length) {
processEntries(records, state);
}
}
+282
View File
@@ -0,0 +1,282 @@
// js/userBlocks.js
import { nostrClient } from "./nostr.js";
const BLOCK_LIST_IDENTIFIER = "user-blocks";
function normalizeHex(pubkey) {
if (typeof pubkey !== "string") {
return null;
}
const trimmed = pubkey.trim();
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
return null;
}
class UserBlockListManager {
constructor() {
this.blockedPubkeys = new Set();
this.blockEventId = null;
this.loaded = false;
}
reset() {
this.blockedPubkeys.clear();
this.blockEventId = null;
this.loaded = false;
}
getBlockedPubkeys() {
return Array.from(this.blockedPubkeys);
}
isBlocked(pubkey) {
const normalized = normalizeHex(pubkey);
if (!normalized) {
return false;
}
return this.blockedPubkeys.has(normalized);
}
async ensureLoaded(userPubkey) {
if (this.loaded) {
return;
}
const normalized = normalizeHex(userPubkey);
if (!normalized) {
return;
}
await this.loadBlocks(normalized);
}
async loadBlocks(userPubkey) {
const normalized = normalizeHex(userPubkey);
if (!normalized) {
this.reset();
this.loaded = true;
return;
}
if (!window?.nostr?.nip04?.decrypt) {
console.warn(
"[UserBlockList] nip04.decrypt is unavailable; treating block list as empty."
);
this.reset();
this.loaded = true;
return;
}
try {
const filter = {
kinds: [30002],
authors: [normalized],
"#d": [BLOCK_LIST_IDENTIFIER],
limit: 1,
};
const events = [];
for (const relay of nostrClient.relays) {
try {
const res = await nostrClient.pool.list([relay], [filter]);
if (Array.isArray(res) && res.length) {
events.push(...res);
}
} catch (err) {
console.error(`[UserBlockList] Relay error at ${relay}:`, err);
}
}
if (!events.length) {
this.blockedPubkeys.clear();
this.blockEventId = null;
this.loaded = true;
return;
}
events.sort((a, b) => b.created_at - a.created_at);
const newest = events[0];
this.blockEventId = newest.id;
let decrypted = "";
try {
decrypted = await window.nostr.nip04.decrypt(
normalized,
newest.content
);
} catch (err) {
console.error("[UserBlockList] Failed to decrypt block list:", err);
this.blockedPubkeys.clear();
this.loaded = true;
return;
}
try {
const parsed = JSON.parse(decrypted);
const list = Array.isArray(parsed?.blockedPubkeys)
? parsed.blockedPubkeys
: [];
const sanitized = list
.map((entry) => normalizeHex(entry))
.filter((candidate) => {
if (!candidate) {
return false;
}
if (candidate === normalized) {
return false;
}
return true;
});
this.blockedPubkeys = new Set(sanitized);
} catch (err) {
console.error("[UserBlockList] Failed to parse block list:", err);
this.blockedPubkeys.clear();
}
} catch (error) {
console.error("[UserBlockList] loadBlocks failed:", error);
this.blockedPubkeys.clear();
} finally {
this.loaded = true;
}
}
async addBlock(targetPubkey, userPubkey) {
const actorHex = normalizeHex(userPubkey);
if (!actorHex) {
throw new Error("Invalid user pubkey.");
}
const targetHex = normalizeHex(targetPubkey);
if (!targetHex) {
const err = new Error("Invalid target pubkey.");
err.code = "invalid";
throw err;
}
if (actorHex === targetHex) {
const err = new Error("Cannot block yourself.");
err.code = "self";
throw err;
}
await this.ensureLoaded(actorHex);
if (this.blockedPubkeys.has(targetHex)) {
return { ok: true, already: true };
}
const snapshot = new Set(this.blockedPubkeys);
this.blockedPubkeys.add(targetHex);
try {
await this.publishBlockList(actorHex);
return { ok: true };
} catch (err) {
this.blockedPubkeys = snapshot;
throw err;
}
}
async removeBlock(targetPubkey, userPubkey) {
const actorHex = normalizeHex(userPubkey);
if (!actorHex) {
throw new Error("Invalid user pubkey.");
}
const targetHex = normalizeHex(targetPubkey);
if (!targetHex) {
return { ok: true, already: true };
}
await this.ensureLoaded(actorHex);
if (!this.blockedPubkeys.has(targetHex)) {
return { ok: true, already: true };
}
const snapshot = new Set(this.blockedPubkeys);
this.blockedPubkeys.delete(targetHex);
try {
await this.publishBlockList(actorHex);
return { ok: true };
} catch (err) {
this.blockedPubkeys = snapshot;
throw err;
}
}
async publishBlockList(userPubkey) {
if (!window?.nostr?.nip04?.encrypt) {
const err = new Error(
"NIP-04 encryption is required to update the block list."
);
err.code = "nip04-missing";
throw err;
}
if (typeof window.nostr.signEvent !== "function") {
const err = new Error("Nostr extension missing signEvent support.");
err.code = "nip04-missing";
throw err;
}
const normalized = normalizeHex(userPubkey);
if (!normalized) {
throw new Error("Invalid user pubkey.");
}
const payload = {
blockedPubkeys: Array.from(this.blockedPubkeys).filter(
(candidate) => candidate && candidate !== normalized
),
};
const plaintext = JSON.stringify(payload);
let cipherText = "";
try {
cipherText = await window.nostr.nip04.encrypt(normalized, plaintext);
} catch (error) {
const err = new Error("Failed to encrypt block list.");
err.code = "nip04-missing";
throw err;
}
const event = {
kind: 30002,
pubkey: normalized,
created_at: Math.floor(Date.now() / 1000),
tags: [["d", BLOCK_LIST_IDENTIFIER]],
content: cipherText,
};
const signedEvent = await window.nostr.signEvent(event);
await Promise.all(
nostrClient.relays.map(async (relay) => {
try {
await nostrClient.pool.publish([relay], signedEvent);
} catch (err) {
console.error(`[UserBlockList] Failed to publish to ${relay}:`, err);
}
})
);
this.blockEventId = signedEvent.id;
return signedEvent;
}
}
export const userBlocks = new UserBlockListManager();
if (typeof window !== "undefined") {
window.userBlocks = userBlocks;
}
+4
View File
@@ -9,6 +9,10 @@ const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
*/
export async function loadView(viewUrl) {
try {
if (window.app && typeof window.app.prepareForViewLoad === "function") {
window.app.prepareForViewLoad();
}
const res = await fetch(viewUrl);
if (!res.ok) {
throw new Error(`Failed to load view: ${res.status}`);
+95 -8
View File
@@ -713,6 +713,74 @@ export class TorrentClient {
});
}
async destroyWithTimeout(
target,
{ label = "resource", args = [], timeoutMs = 8000 } = {}
) {
if (!target || typeof target.destroy !== "function") {
return;
}
const safeTimeout = Math.max(
1000,
Math.min(
Number.isFinite(timeoutMs) ? timeoutMs : 8000,
this.TIMEOUT_DURATION
)
);
await new Promise((resolve) => {
let settled = false;
const finalize = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
resolve();
};
const logError = (error) => {
if (error) {
this.log(`[cleanup] ${label} destroy error:`, error);
}
};
const timeoutId = setTimeout(() => {
this.log(
`[cleanup] ${label} destroy timed out after ${safeTimeout}ms; forcing resolution.`
);
finalize();
}, safeTimeout);
const callback = (error) => {
logError(error);
finalize();
};
let result;
try {
result = target.destroy(...args, callback);
} catch (error) {
logError(error);
finalize();
return;
}
if (result && typeof result.then === "function") {
result
.then(() => {
finalize();
})
.catch((error) => {
logError(error);
finalize();
});
}
});
}
/**
* Initiates streaming of a torrent magnet to a <video> element.
* Ensures the service worker is set up only once and the client is reused.
@@ -778,19 +846,38 @@ export class TorrentClient {
async cleanup() {
try {
if (this.currentTorrent) {
this.currentTorrent.destroy();
try {
await this.destroyWithTimeout(this.currentTorrent, {
label: "current torrent",
args: [{ destroyStore: true }],
});
} finally {
this.currentTorrent = null;
}
}
// Destroy client entirely and set to null so a future streamVideo call starts fresh
if (this.client) {
await this.client.destroy();
this.client = null;
try {
await this.destroyWithTimeout(this.client, {
label: "WebTorrent client",
});
} finally {
this.client = null;
this.serverCreated = false;
}
} else {
this.serverCreated = false;
}
if (this.probeClient) {
await this.probeClient.destroy();
this.probeClient = null;
try {
await this.destroyWithTimeout(this.probeClient, {
label: "probe client",
});
} finally {
this.probeClient = null;
}
}
this.currentTorrent = null;
this.serverCreated = false;
} catch (error) {
this.log("Cleanup error:", error);
}
+1672
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
{
"name": "bitvid",
"private": true,
"version": "1.0.0",
"scripts": {
"build:css": "npx tailwindcss -c tailwind.config.cjs -i css/tailwind.source.css -o css/tailwind.generated.css --minify"
},
"devDependencies": {
"autoprefixer": "^10.4.19",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.13"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+13
View File
@@ -0,0 +1,13 @@
module.exports = {
content: [
'./index.html',
'./blog.html',
'./components/**/*.html',
'./views/**/*.html',
'./js/**/*.js'
],
theme: {
extend: {}
},
plugins: []
};
+7 -5
View File
@@ -44,11 +44,13 @@
<header class="header-torrent">
<div class="container">
<div class="logo-container">
<img
src="../assets/svg/bitvid-logo-light-mode.svg"
alt="bitvid Logo"
class="bitvid-logo"
/>
<a href="../index.html" aria-label="Return to the bitvid home page">
<img
src="../assets/svg/bitvid-logo-light-mode.svg"
alt="bitvid Logo"
class="bitvid-logo"
/>
</a>
</div>
</div>
</header>
+99 -31
View File
@@ -1,29 +1,111 @@
<section class="channel-profile-container">
<!-- Modified banner wrapper for taller, rounded style -->
<div
class="profile-banner relative mb-6 rounded-lg overflow-hidden shadow-lg"
>
<div class="profile-banner relative mb-6 rounded-lg shadow-lg">
<!-- Increase the default height classes -->
<img
id="channelBanner"
class="w-full h-48 md:h-64 object-cover"
class="w-full h-48 md:h-64 object-cover rounded-lg"
src=""
alt="Banner"
/>
<div class="absolute inset-0 bg-black bg-opacity-50"></div>
<div class="absolute inset-0 bg-black bg-opacity-50 rounded-lg"></div>
<div class="absolute bottom-4 left-4 flex items-center">
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
<img
id="channelAvatar"
src="assets/svg/default-profile.svg"
alt="Avatar"
class="w-full h-full object-cover"
/>
<div
class="absolute bottom-4 left-4 right-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
>
<div class="flex items-center">
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
<img
id="channelAvatar"
src="assets/svg/default-profile.svg"
alt="Avatar"
class="w-full h-full object-cover"
/>
</div>
<div class="ml-4 text-white">
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
</div>
</div>
<div class="ml-4 text-white">
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
<div class="flex items-center gap-2 self-start sm:self-end">
<button
id="zapButton"
type="button"
class="icon-button"
aria-label="Send a zap"
>
<img
src="assets/svg/lightning-bolt.svg"
alt="Zap"
class="icon-image"
/>
</button>
<button
id="channelShareBtn"
type="button"
class="icon-button"
aria-label="Share channel"
>
<img
src="assets/svg/share-video.svg"
alt="Share channel"
class="icon-image"
/>
</button>
<div
class="relative inline-block overflow-visible"
data-more-menu-wrapper="true"
>
<button
id="channelMoreBtn"
type="button"
class="icon-button"
aria-haspopup="true"
aria-expanded="false"
aria-label="Channel options"
data-more-dropdown="channel-profile"
>
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5" />
</button>
<div
id="moreDropdown-channel-profile"
class="hidden absolute right-0 top-12 w-48 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
role="menu"
data-more-menu="true"
>
<div class="py-1">
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="copy-npub"
data-context="channel-profile"
>
Copy npub
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="blacklist-author"
data-context="channel-profile"
data-blacklist-menu="true"
>
Blacklist channel
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
data-action="block-author"
data-context="channel-profile"
>
Block channel
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
data-action="report"
data-context="channel-profile"
>
Report
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -43,21 +125,7 @@
<!-- Area for Subscribe/Unsubscribe button -->
<div class="px-4 my-4">
<div class="flex items-center gap-3">
<button
id="zapButton"
type="button"
class="icon-button"
aria-label="Send a zap"
>
<img
src="assets/svg/lightning-bolt.svg"
alt="Zap"
class="icon-image"
/>
</button>
<div id="subscribeBtnArea" class="hidden"></div>
</div>
<div id="subscribeBtnArea" class="hidden"></div>
</div>
<hr class="my-6" />