mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d1655d2a7 | ||
|
|
4ebc6f6f4a | ||
|
|
db408f4b06 | ||
|
|
f832695a6a | ||
|
|
213f92f496 | ||
|
|
96e8adc553 | ||
|
|
233c50d238 | ||
|
|
7937410e87 | ||
|
|
b50996b907 | ||
|
|
3f4b56ae90 | ||
|
|
edbc3786eb | ||
|
|
c7419a7f74 | ||
|
|
ee09b723af | ||
|
|
c617fcbfda | ||
|
|
622aa756cc | ||
|
|
c52199b1d0 | ||
|
|
ec83b1222f | ||
|
|
033d3b0288 | ||
|
|
5cfdd79a18 | ||
|
|
ce1f2a8f37 | ||
|
|
2b1d5488d6 | ||
|
|
e50780e4b3 | ||
|
|
c2846f9348 | ||
|
|
4266d0bd18 | ||
|
|
711df9bf07 | ||
|
|
24328c4c52 | ||
|
|
d15c574b13 | ||
|
|
198fd97a80 | ||
|
|
c285bd7e9c | ||
|
|
55774f588e | ||
|
|
79e5267524 | ||
|
|
344089cd39 | ||
|
|
1dbfa9b802 | ||
|
|
7e8d5f2fef | ||
|
|
6a3bc50066 | ||
|
|
83a9947611 | ||
|
|
0bf3ce8e8a | ||
|
|
fe39e65aa5 | ||
|
|
8de0ecc47d | ||
|
|
b438564b64 | ||
|
|
37a5ee3b7b | ||
|
|
23a8eacad4 | ||
|
|
1f191e2b90 | ||
|
|
2d0ddd0d3a | ||
|
|
db8178e573 | ||
|
|
3f293f77fa | ||
|
|
ebb289a80c | ||
|
|
d5551fd6a9 | ||
|
|
622c829b46 | ||
|
|
5f33aeea79 | ||
|
|
8d2f32c62b | ||
|
|
dd60ff07e9 | ||
|
|
5f20f1eb56 | ||
|
|
4cf6294591 | ||
|
|
d01fa93689 | ||
|
|
e92e681860 | ||
|
|
cdf0b5f08c | ||
|
|
7e3acdfd68 | ||
|
|
9c537ba7c7 | ||
|
|
6f5a931caa | ||
|
|
62cbc2a2eb | ||
|
|
511a016db4 | ||
|
|
bd345f99d2 | ||
|
|
169f0c952c | ||
|
|
d39ed45c5a | ||
|
|
a0356967ce | ||
|
|
57552dcfe1 | ||
|
|
bc08598237 | ||
|
|
c9dff9d436 | ||
|
|
84b5ef77c0 | ||
|
|
c6c297e16b | ||
|
|
66595edaf6 | ||
|
|
a56568e3c7 | ||
|
|
014b1369b0 | ||
|
|
c8afe45545 | ||
|
|
875cee4428 | ||
|
|
2eb00740fd | ||
|
|
7e8db028eb | ||
|
|
a53bded07f | ||
|
|
0d5a8f4017 | ||
|
|
468444a454 | ||
|
|
a677683205 | ||
|
|
e5f05f362c | ||
|
|
0041e24c87 | ||
|
|
de2d6c409f | ||
|
|
5365fad888 | ||
|
|
8966b426f8 | ||
|
|
92a7576fcb | ||
|
|
dd2f3768db | ||
|
|
b4e11cdce2 | ||
|
|
c39fb6ffe2 | ||
|
|
f3293a8c29 | ||
|
|
546fb4d1d2 | ||
|
|
c638b273fe | ||
|
|
3e083336b3 | ||
|
|
c851abc989 | ||
|
|
1c101266a6 | ||
|
|
30c260aa82 | ||
|
|
fa638e08a5 | ||
|
|
3da14ee174 | ||
|
|
875094c800 | ||
|
|
f1e0c63e01 | ||
|
|
c773a07cdc | ||
|
|
9de3592293 | ||
|
|
1b4bf696b7 | ||
|
|
b5987c5923 | ||
|
|
538526fbf5 | ||
|
|
c95e911f76 | ||
|
|
e297bd7bb3 | ||
|
|
cc627c1e65 | ||
|
|
f6612236f0 | ||
|
|
e0e1eb381c | ||
|
|
5d0f122e48 | ||
|
|
0461d06834 | ||
|
|
f07ca9966c | ||
|
|
62b770eff0 | ||
|
|
12aaf63d3d | ||
|
|
55cae181df | ||
|
|
2b0f3547cb | ||
|
|
2dfe20907b | ||
|
|
0b4523b0be | ||
|
|
866e4f11fb | ||
|
|
f6fa9daaad | ||
|
|
0217009752 | ||
|
|
1985b16064 | ||
|
|
854216f331 | ||
|
|
cbdab9f011 | ||
|
|
801bcd2345 | ||
|
|
c6a838ebc2 | ||
|
|
2b1d0ca26e | ||
|
|
5ef94c03ba | ||
|
|
e6b6522f6f | ||
|
|
742dd12915 | ||
|
|
1684c7070b | ||
|
|
8a278ca0ac | ||
|
|
c32d2fce12 | ||
|
|
46c2b39aae | ||
|
|
67f80787d0 | ||
|
|
1b720b92d3 | ||
|
|
7890a94949 | ||
|
|
3fa08631ea | ||
|
|
9da4e40ec9 | ||
|
|
34d4895799 | ||
|
|
7f70270889 | ||
|
|
9b4dd2a6ff | ||
|
|
f121a17cd9 | ||
|
|
7d73a8c594 | ||
|
|
ea300b47bf | ||
|
|
8d5d79e96f | ||
|
|
b52be2ab42 |
No files matched your search
+2
-1
@@ -1,3 +1,4 @@
|
||||
saved_config.yaml
|
||||
repo-context.txt
|
||||
src/webtorrent-docs/
|
||||
node_modules/
|
||||
src/webtorrent-docs/
|
||||
@@ -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`**:
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.<baseDomain></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 & 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 & publish
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+77
-23
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
+362
-28
@@ -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
@@ -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
@@ -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
@@ -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 = [];
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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}` : "",
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Generated
+1672
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./blog.html',
|
||||
'./components/**/*.html',
|
||||
'./views/**/*.html',
|
||||
'./js/**/*.js'
|
||||
],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
+7
-5
@@ -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
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user