Compare commits

...
150 Commits
Author SHA1 Message Date
thePR0M3TH3AN 1d1655d2a7 Merge pull request #166 from PR0M3TH3AN/unstable
Unstable
2025-09-30 09:10:39 -04:00
thePR0M3TH3AN 4ebc6f6f4a Merge pull request #165 from PR0M3TH3AN/codex/ensure-notification-box-visibility-on-modals
Ensure notifications appear over modal overlays
2025-09-30 09:06:45 -04:00
thePR0M3TH3AN db408f4b06 Raise notification portal above modals 2025-09-30 09:05:03 -04:00
thePR0M3TH3AN f832695a6a Ensure notifications appear above modals 2025-09-30 08:59:18 -04:00
thePR0M3TH3AN 213f92f496 Merge pull request #164 from PR0M3TH3AN/codex/update-moderation-list-loading-indication
Replace moderation relay warning with loading status
2025-09-30 00:01:10 -04:00
thePR0M3TH3AN 96e8adc553 Ensure moderation loading banner clears after sync 2025-09-29 23:59:52 -04:00
thePR0M3TH3AN 233c50d238 Show moderation filter loading status 2025-09-29 23:51:20 -04:00
thePR0M3TH3AN 7937410e87 Merge pull request #163 from PR0M3TH3AN/codex/update-rendersavedprofiles-for-metadata-fetching
Auto-fetch metadata for saved profiles lacking hints
2025-09-29 23:46:53 -04:00
thePR0M3TH3AN b50996b907 Auto-fetch saved profile metadata 2025-09-29 23:38:22 -04:00
thePR0M3TH3AN 3f4b56ae90 Merge pull request #162 from PR0M3TH3AN/codex/extend-profile-switching-functionality
Add profile switching flow to saved profile cards
2025-09-29 23:19:20 -04:00
thePR0M3TH3AN edbc3786eb Enhance profile switching interactions 2025-09-29 23:18:37 -04:00
thePR0M3TH3AN c7419a7f74 Merge pull request #161 from PR0M3TH3AN/codex/enhance-nostrclient.login-with-options-object
Add NIP-07 profile selection flow to profile switcher
2025-09-29 23:15:18 -04:00
thePR0M3TH3AN ee09b723af Add profile switching support for NIP-07 accounts 2025-09-29 23:14:52 -04:00
thePR0M3TH3AN c617fcbfda Merge pull request #160 from PR0M3TH3AN/codex/implement-profile-modal-rendering-logic
Refine saved profile rendering
2025-09-29 23:08:25 -04:00
thePR0M3TH3AN 622aa756cc Refine saved profile rendering 2025-09-29 23:07:45 -04:00
thePR0M3TH3AN c52199b1d0 Merge pull request #159 from PR0M3TH3AN/codex/refactor-login-function-and-update-logout-logic
Refine login flow and profile storage
2025-09-29 22:57:33 -04:00
thePR0M3TH3AN ec83b1222f Refine login flow and profile storage 2025-09-29 22:55:40 -04:00
thePR0M3TH3AN 033d3b0288 Merge pull request #158 from PR0M3TH3AN/codex/add-saved-profile-management-in-app.js
Persist saved Nostr profiles in localStorage
2025-09-29 22:40:53 -04:00
thePR0M3TH3AN 5cfdd79a18 Add saved Nostr profile storage helpers 2025-09-29 17:24:25 -04:00
thePR0M3TH3AN ce1f2a8f37 Merge pull request #157 from PR0M3TH3AN/codex/add-ui-for-new-nostr-profiles
Add UI affordance for managing multiple Nostr profiles
2025-09-29 16:01:10 -04:00
thePR0M3TH3AN 2b1d5488d6 Restore hidden profile panes 2025-09-29 15:58:12 -04:00
thePR0M3TH3AN e50780e4b3 Consolidate profile modal account styling 2025-09-29 15:10:53 -04:00
thePR0M3TH3AN c2846f9348 Polish profile switcher card styling 2025-09-29 14:51:05 -04:00
thePR0M3TH3AN 4266d0bd18 Refine account cards in profile modal 2025-09-29 14:39:23 -04:00
thePR0M3TH3AN 711df9bf07 Ensure full npub wraps in profile modal 2025-09-29 14:27:45 -04:00
thePR0M3TH3AN 24328c4c52 Refine active profile layout in modal 2025-09-29 14:23:18 -04:00
thePR0M3TH3AN d15c574b13 Restyle profile switcher active row 2025-09-29 14:15:10 -04:00
thePR0M3TH3AN 198fd97a80 Adjust profile switcher layout 2025-09-29 14:09:48 -04:00
thePR0M3TH3AN c285bd7e9c Stack profile switcher list vertically 2025-09-29 13:59:07 -04:00
thePR0M3TH3AN 55774f588e Add UI affordance for managing multiple Nostr profiles 2025-09-29 13:52:34 -04:00
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
47 changed files with 13542 additions and 880 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 -->
+349 -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,357 @@
</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"
>
<!--
The account pane now relies on bespoke CSS (see css/style.css under
"Profile modal account pane") so we are not dependent on utility
classes that may be purged or overridden by other builds.
-->
<div class="profile-switcher" id="profileSwitcherTray">
<div class="profile-switcher__header">
<div class="profile-switcher__intro">
<h3 class="profile-switcher__title">Switch profiles</h3>
<p class="profile-switcher__subtitle">
Save multiple Nostr profiles so you can swap identities without leaving BitVid.
</p>
</div>
<span class="profile-switcher__badge">Preview</span>
</div>
<div
class="profile-card profile-card--active"
aria-label="Current Nostr profile"
aria-pressed="true"
role="button"
tabindex="0"
>
<span class="profile-card__avatar">
<img
id="profileModalAvatar"
src="assets/svg/default-profile.svg"
alt="Active profile avatar"
/>
</span>
<span class="profile-card__meta">
<span class="profile-card__topline">
<span class="profile-card__label">Active profile</span>
<a
id="profileChannelLink"
href="#"
class="profile-card__action hidden"
>
View channel
</a>
</span>
<span id="profileModalName" class="profile-card__name">Loading...</span>
<span id="profileModalNpub" class="profile-card__npub">npub1...</span>
</span>
</div>
<div
id="profileSwitcherList"
class="profile-switcher__list"
aria-live="polite"
aria-label="Additional Nostr profiles"
></div>
<button
type="button"
id="profileAddAccountBtn"
class="profile-switcher__add"
aria-label="Add a new Nostr profile"
>
<span class="profile-switcher__addAvatar">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</span>
<span class="profile-switcher__addCopy">
<span class="profile-switcher__addTitle">Add profile</span>
<span class="profile-switcher__addHint">Link another npub</span>
</span>
</button>
<p class="profile-switcher__helper">
Profiles you add here will stay on this device so you can quickly switch before posting or moderating.
</p>
</div>
</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;
+623 -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,310 @@ 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 */
}
/* --------------------------------------------------------------------------
Profile modal account pane
--------------------------------------------------------------------------
The modal previously mixed Tailwind-style utility classes with our global
stylesheet. Those utilities are not always present in the production build,
so important layout rules (borders, gaps, flex behaviour) were getting
dropped. This block consolidates the account tab styling in one predictable
place so future tweaks only require CSS updates here instead of scattered
inline class lists.
--------------------------------------------------------------------------- */
#profileModal .profile-pane {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/*
Ensure the global `.hidden` helper keeps working for the tab panes. Our
profile-pane declaration above sets a `display` value with higher
specificity than `.hidden`, so we explicitly restore the hidden state when
both classes are present.
*/
#profileModal .profile-pane.hidden {
display: none;
}
#profileModal .profile-switcher {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: clamp(1.25rem, 2vw, 1.5rem);
border-radius: 1.25rem;
border: 0.0625rem solid rgba(148, 163, 184, 0.28);
background: linear-gradient(145deg, rgba(15, 23, 42, 0.94), rgba(15, 23, 42, 0.72));
box-shadow: 0 1.75rem 3.5rem rgba(8, 11, 30, 0.45);
}
#profileModal .profile-switcher__header {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
#profileModal .profile-switcher__intro {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
#profileModal .profile-switcher__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
#profileModal .profile-switcher__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-muted);
}
#profileModal .profile-switcher__badge {
align-self: flex-start;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem 0.85rem;
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
border-radius: 9999px;
background: rgba(59, 130, 246, 0.18);
color: rgba(191, 219, 254, 0.95);
border: 0.0625rem solid rgba(96, 165, 250, 0.25);
}
#profileModal .profile-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1rem;
border-radius: 1rem;
border: 0.0625rem solid rgba(148, 163, 184, 0.24);
background: rgba(15, 23, 42, 0.72);
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
color: var(--color-text);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
outline: none;
}
#profileModal .profile-card:hover {
border-color: rgba(148, 163, 184, 0.45);
}
#profileModal .profile-card:focus-visible {
border-color: rgba(96, 165, 250, 0.6);
box-shadow: 0 0 0 0.1875rem rgba(37, 99, 235, 0.25);
}
#profileModal .profile-card--active {
border-color: rgba(59, 130, 246, 0.55);
background: linear-gradient(160deg, rgba(37, 99, 235, 0.22), rgba(15, 23, 42, 0.78));
box-shadow: 0 1.35rem 2.8rem rgba(37, 99, 235, 0.22);
}
#profileModal .profile-card__avatar {
flex: 0 0 auto;
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 9999px;
overflow: hidden;
border: 0.1875rem solid rgba(148, 163, 184, 0.3);
background: rgba(15, 23, 42, 0.95);
}
#profileModal .profile-card__avatar img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
}
#profileModal .profile-card--active .profile-card__avatar {
border-color: rgba(96, 165, 250, 0.7);
box-shadow: 0 0 0 0.1875rem rgba(59, 130, 246, 0.18);
}
#profileModal .profile-card__meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#profileModal .profile-card__topline {
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: flex-start;
}
#profileModal .profile-card__label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(191, 219, 254, 0.9);
}
#profileModal .profile-card__action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem 0.8rem;
border-radius: 9999px;
border: 0.0625rem solid rgba(59, 130, 246, 0.4);
background: rgba(59, 130, 246, 0.14);
color: rgba(191, 219, 254, 0.95);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
#profileModal .profile-card__action:hover,
#profileModal .profile-card__action:focus-visible {
background: rgba(59, 130, 246, 0.24);
border-color: rgba(59, 130, 246, 0.6);
color: #ffffff;
}
#profileModal .profile-card__name {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#profileModal .profile-card__npub {
font-size: 0.75rem;
font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: rgba(148, 163, 184, 0.85);
word-break: break-all;
}
#profileModal .profile-switcher__list {
display: flex;
flex-direction: column;
gap: 1rem;
}
#profileModal .profile-switcher__add {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-radius: 1rem;
border: 0.125rem dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.55);
color: rgba(226, 232, 240, 0.85);
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
}
#profileModal .profile-switcher__add:hover,
#profileModal .profile-switcher__add:focus-visible {
border-color: rgba(96, 165, 250, 0.65);
background: rgba(59, 130, 246, 0.18);
color: #ffffff;
outline: none;
}
#profileModal .profile-switcher__addAvatar {
flex: 0 0 auto;
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 9999px;
border: 0.125rem dashed currentColor;
color: inherit;
background: rgba(15, 23, 42, 0.75);
}
#profileModal .profile-switcher__addAvatar svg {
width: 1.25rem;
height: 1.25rem;
}
#profileModal .profile-switcher__addCopy {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
#profileModal .profile-switcher__addTitle {
font-size: 0.9rem;
font-weight: 600;
color: inherit;
}
#profileModal .profile-switcher__addHint {
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.85);
}
#profileModal .profile-switcher__helper {
margin: 0;
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.7);
}
@media (min-width: 640px) {
#profileModal .profile-switcher__header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
#profileModal .profile-card {
flex-direction: row;
align-items: center;
}
#profileModal .profile-card__topline {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
}
/* If you ever want to show it, add ".flex" class dynamically */
@@ -217,6 +558,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 +647,7 @@ header img {
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
transform: translateY(1.25rem);
}
to {
opacity: 1;
@@ -244,15 +661,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 +668,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 +747,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 +768,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 +778,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 +797,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);
}
*/
@@ -388,6 +819,27 @@ button:not(.icon-button):focus {
}
/* Notifications */
#notificationPortal {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 1.5rem);
left: 50%;
transform: translateX(-50%);
z-index: 400; /* Keep notifications above the highest modal overlays */
width: min(90vw, 32rem);
display: flex;
flex-direction: column;
gap: 0.75rem;
pointer-events: none;
}
#notificationPortal > * {
width: 100%;
margin-bottom: 0;
pointer-events: auto;
border-radius: 0.75rem;
box-shadow: 0 1.25rem 3rem rgba(15, 23, 42, 0.3);
}
/* Base styling without a forced display */
#errorContainer,
#successContainer {
@@ -403,6 +855,32 @@ button:not(.icon-button):focus {
display: flex;
}
.status-banner {
align-items: center;
background-color: rgba(31, 41, 55, 0.9);
border-radius: 0.5rem;
color: #e5e7eb;
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 1rem;
}
.status-banner .status-spinner {
animation: spin 1s linear infinite;
border: 0.125rem solid rgba(229, 231, 235, 0.35);
border-radius: 9999px;
border-top-color: #f9fafb;
height: 1.25rem;
width: 1.25rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive Design */
@media (max-width: 640px) {
#videoList {
@@ -416,7 +894,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 +914,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 +957,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 +976,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 +1018,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 +1046,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 +1133,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 +1149,7 @@ footer a:hover {
}
.sidebar-dropup-panel--expanded {
max-height: 640px;
max-height: 40rem;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
@@ -669,7 +1158,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 +1171,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;
+73
View File
@@ -0,0 +1,73 @@
# Nostr authentication storage
Bitvid keeps lightweight state in `localStorage` so the UI can remember which
accounts signed in recently. This document explains the storage schema so future
updates can evolve it without breaking existing users.
## Storage keys
- `bitvid:profileCache:v1` – short-lived metadata cache for profile names and
avatars. See `js/app.js` for TTL and eviction rules.
- `bitvid:savedProfiles:v1` – persistent list of accounts the user authenticated
with in this browser.
Both keys live in the main origin scope. Clearing one should not affect the
other.
## Saved profile schema
`bitvid:savedProfiles:v1` is a JSON object with the shape:
```json
{
"version": 1,
"entries": [
{
"pubkey": "<hex-encoded pubkey>",
"npub": "<bech32 npub, optional>",
"name": "<cached display name>",
"picture": "<cached avatar URL>",
"authType": "<auth strategy>"
}
],
"activePubkey": "<hex pubkey or null>"
}
```
Notes:
- `pubkey` always stores a lowercase 64-character hex string and is the primary
dedupe key.
- `npub` is optional. When omitted, the UI regenerates it on load using
`NostrTools.nip19.npubEncode`.
- `name` and `picture` are cached hints that keep the profile switcher snappy.
They may be empty strings and should be treated as hints, not the source of
truth.
- `activePubkey` tracks the last account that successfully authenticated in this
browser. It may be `null` when the user logs out but keeps saved entries.
### `authType` enum
`authType` describes how the profile authenticated:
- `"nip07"` – Browser extension flow (current default).
- `"nsec"` – Reserved for future direct key import or signer integrations. New
auth strategies should pick a distinct string and document how migration works
alongside any recovery tooling.
When the app reads stored entries it normalises unknown values back to
`"nip07"` but keeps recognised alternatives intact.
## Migration notes
Earlier builds only persisted a single `userPubKey` string. During startup the
app now:
1. Attempts to read `bitvid:savedProfiles:v1` and validate the payload.
2. If the key is missing (or malformed) but a legacy `userPubKey` entry exists,
it seeds `savedProfiles` with that value and writes the new structure.
3. Once the JSON payload is written successfully, the legacy `userPubKey` entry
is removed.
Future migrations should follow the same pattern: validate, normalise, write the
new format, then clean up legacy keys to avoid data loss.
+3
View File
@@ -17,5 +17,8 @@ Run this checklist before publishing releases or merging changes that touch uplo
5. **Cross-Browser Verification**
- Spot-check Chromium and Firefox (desktop) for console warnings about CORS, Range requests, or tracker connectivity.
- Optionally validate on mobile Safari/Chrome if the change targets mobile UX.
6. **Saved Profile Metadata Refresh**
- In the browser devtools console/storage panel, delete `bitvid:profileCache:v1` but keep `bitvid:savedProfiles:v1` populated with Nostr pubkeys that lack stored names/avatars.
- Reload the page and open the profile switcher; confirm avatars and display names populate automatically without manual refresh.
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
+46 -15
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 -->
@@ -137,17 +149,36 @@
</div>
</header>
<!-- Error Container -->
<!-- Notification Portal -->
<div
id="errorContainer"
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
></div>
id="notificationPortal"
role="region"
aria-live="polite"
aria-label="System notifications"
>
<!-- Error Container -->
<div
id="errorContainer"
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
></div>
<!-- Success Container -->
<div
id="successContainer"
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
></div>
<!-- Status Container -->
<div
id="statusContainer"
class="hidden status-banner"
role="status"
aria-live="polite"
>
<span class="status-spinner" aria-hidden="true"></span>
<span class="status-message" data-status-message></span>
</div>
<!-- Success Container -->
<div
id="successContainer"
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
></div>
</div>
<!-- Dynamic views (like most-recent-videos.html, etc.) -->
<main id="viewContainer" class="flex-grow mb-8"></main>
+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);
}
+5768 -278
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;
}
+491 -21
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,
@@ -490,15 +593,99 @@ class NostrClient {
/**
* Attempt login with a Nostr extension
*/
async login() {
async login(options = {}) {
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();
const { allowAccountSelection = false, expectPubkey } =
typeof options === "object" && options !== null ? options : {};
const normalizedExpectedPubkey =
typeof expectPubkey === "string" && expectPubkey.trim()
? expectPubkey.trim().toLowerCase()
: null;
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."
);
}
}
if (allowAccountSelection && typeof extension.selectAccounts === "function") {
try {
const selection = await extension.selectAccounts(
expectPubkey ? [expectPubkey] : undefined
);
const didCancelSelection =
selection === undefined ||
selection === null ||
selection === false ||
(Array.isArray(selection) && selection.length === 0);
if (didCancelSelection) {
throw new Error("Account selection was cancelled.");
}
} catch (selectionErr) {
const message =
selectionErr && typeof selectionErr.message === "string"
? selectionErr.message
: "Account selection was cancelled.";
throw new Error(message);
}
}
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."
);
}
if (
normalizedExpectedPubkey &&
pubkey.toLowerCase() !== normalizedExpectedPubkey
) {
throw new Error(
"The selected account doesn't match the expected profile. Please try again."
);
}
const npub = window.NostrTools.nip19.npubEncode(pubkey);
if (isDevMode) {
@@ -531,6 +718,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 +856,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 +874,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 +1047,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 +1098,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 +1189,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 +1223,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 +1543,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" />