mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 19:02:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
saved_config.yaml
|
||||||
repo-context.txt
|
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:
|
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
|
### 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`**:
|
- **`config.js`**:
|
||||||
- Toggle `isDevMode` for development (`true`) or production (`false`).
|
- Toggle `isDevMode` for development (`true`) or production (`false`).
|
||||||
- **`js/constants.js`**:
|
- **`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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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 src="./js/tracking.js" defer></script>
|
||||||
<script type="module" crossorigin>
|
<script type="module" crossorigin>
|
||||||
var pv = Object.defineProperty;
|
var pv = Object.defineProperty;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Application Form</title>
|
<title>Application Form</title>
|
||||||
<!-- Include Tailwind CSS -->
|
<!-- Include Tailwind CSS -->
|
||||||
<link href="css/tailwind.min.css" rel="stylesheet" />
|
<link href="css/tailwind.generated.css" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-900 text-gray-100">
|
<body class="bg-gray-900 text-gray-100">
|
||||||
<!-- Application Form Modal -->
|
<!-- Application Form Modal -->
|
||||||
|
|||||||
+14
-17
@@ -20,21 +20,18 @@
|
|||||||
>
|
>
|
||||||
<!-- The .modal-content, same classes: bg-gray-900, w-full max-w-[90%], etc. -->
|
<!-- The .modal-content, same classes: bg-gray-900, w-full max-w-[90%], etc. -->
|
||||||
<div
|
<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 -->
|
<!-- Sticky top bar, if you want a top heading or 'X' button up here -->
|
||||||
<div
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="p-6">
|
<div class="modal-body flex-1 overflow-y-auto">
|
||||||
<!-- Scrollable disclaimers area -->
|
<!-- Scrollable disclaimers area -->
|
||||||
<div
|
<div class="space-y-6 text-gray-300">
|
||||||
class="space-y-6 text-gray-300"
|
|
||||||
style="max-height: 70vh; overflow-y: auto"
|
|
||||||
>
|
|
||||||
<!-- Example: Insert your disclaimers here -->
|
<!-- Example: Insert your disclaimers here -->
|
||||||
|
|
||||||
<div class="flex justify-center mb-8">
|
<div class="flex justify-center mb-8">
|
||||||
@@ -114,16 +111,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Button at bottom -->
|
<!-- Button at bottom -->
|
||||||
<div class="mt-6">
|
<div class="modal-footer">
|
||||||
<button
|
<button
|
||||||
id="acceptDisclaimer"
|
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"
|
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
|
I Understand
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #3b82f6;
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
/* Custom Scrollbar styling for Firefox */
|
/* Custom Scrollbar styling for Firefox */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #3b82f6 transparent;
|
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- Load nostr‑tools v2.10.4 -->
|
<!-- Load nostr‑tools v2.10.4 -->
|
||||||
|
|||||||
@@ -112,16 +112,19 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #3b82f6;
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
/* Custom Scrollbar styling for Firefox */
|
/* Custom Scrollbar styling for Firefox */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #3b82f6 transparent;
|
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- Load nostr‑tools v2.10.4 -->
|
<!-- Load nostr‑tools v2.10.4 -->
|
||||||
|
|||||||
@@ -105,16 +105,19 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #3b82f6;
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
/* Custom Scrollbar styling for Firefox */
|
/* Custom Scrollbar styling for Firefox */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #3b82f6 transparent;
|
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- Load nostr‑tools v2.10.4 -->
|
<!-- Load nostr‑tools v2.10.4 -->
|
||||||
|
|||||||
@@ -119,16 +119,19 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #3b82f6;
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
/* Custom Scrollbar styling for Firefox */
|
/* Custom Scrollbar styling for Firefox */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #3b82f6 transparent;
|
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- Load nostr‑tools v2.10.4 -->
|
<!-- Load nostr‑tools v2.10.4 -->
|
||||||
|
|||||||
@@ -112,15 +112,18 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: #3b82f6;
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #3b82f6 transparent;
|
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- Load nostr‑tools v2.10.4 -->
|
<!-- Load nostr‑tools v2.10.4 -->
|
||||||
|
|||||||
+286
-16
@@ -2,6 +2,10 @@
|
|||||||
<div
|
<div
|
||||||
id="profileModal"
|
id="profileModal"
|
||||||
class="fixed inset-0 z-50 hidden"
|
class="fixed inset-0 z-50 hidden"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="profileModalTitle"
|
||||||
|
tabindex="-1"
|
||||||
style="background: transparent"
|
style="background: transparent"
|
||||||
>
|
>
|
||||||
<!-- Overlay Layer with dark background and blur -->
|
<!-- Overlay Layer with dark background and blur -->
|
||||||
@@ -27,7 +31,7 @@
|
|||||||
<div
|
<div
|
||||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
|
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
|
<button
|
||||||
id="closeProfileModal"
|
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"
|
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
|
||||||
@@ -50,28 +54,294 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Body -->
|
<!-- Modal Body -->
|
||||||
<div class="p-6 flex-1 text-gray-200 space-y-4">
|
<div class="p-6 flex-1 overflow-hidden">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex flex-col lg:flex-row gap-6 h-full">
|
||||||
<img
|
<!-- Left navigation column -->
|
||||||
id="profileModalAvatar"
|
<nav
|
||||||
src="assets/svg/default-profile.svg"
|
class="flex lg:flex-col gap-2 flex-shrink-0"
|
||||||
alt="Profile"
|
aria-label="Profile sections"
|
||||||
class="w-16 h-16 object-cover rounded-full"
|
>
|
||||||
/>
|
<button
|
||||||
<p id="profileModalName" class="text-lg font-semibold truncate">
|
id="profileNavAccount"
|
||||||
Loading...
|
type="button"
|
||||||
</p>
|
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-white bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
aria-controls="profilePaneAccount"
|
||||||
|
aria-selected="true"
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="profileNavRelays"
|
||||||
|
type="button"
|
||||||
|
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
aria-controls="profilePaneRelays"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Relays
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="profileNavBlocked"
|
||||||
|
type="button"
|
||||||
|
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
aria-controls="profilePaneBlocked"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Blocked
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="profileNavAdmin"
|
||||||
|
type="button"
|
||||||
|
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900 hidden"
|
||||||
|
aria-controls="profilePaneAdmin"
|
||||||
|
aria-selected="false"
|
||||||
|
>
|
||||||
|
Admin (Moderation)
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Right content panes -->
|
||||||
|
<div class="flex-1 min-h-[320px] overflow-y-auto pr-1">
|
||||||
|
<section
|
||||||
|
id="profilePaneAccount"
|
||||||
|
class="profile-pane space-y-6"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="profileNavAccount"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<img
|
||||||
|
id="profileModalAvatar"
|
||||||
|
src="assets/svg/default-profile.svg"
|
||||||
|
alt="Profile"
|
||||||
|
class="w-16 h-16 object-cover rounded-full"
|
||||||
|
/>
|
||||||
|
<p id="profileModalName" class="text-lg font-semibold truncate">
|
||||||
|
Loading...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
id="profileChannelLink"
|
||||||
|
href="#"
|
||||||
|
class="inline-flex items-center text-sm text-blue-400 hover:text-blue-300 transition-colors hidden"
|
||||||
|
>
|
||||||
|
View your channel
|
||||||
|
</a>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Manage your account details and upcoming preferences here.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="profilePaneRelays"
|
||||||
|
class="profile-pane hidden space-y-6"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="profileNavRelays"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Configure the relays Bitvid will use to discover and publish your videos.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label class="flex-1 text-sm text-gray-300" for="relayInput">
|
||||||
|
<span class="mb-2 block font-medium">Add a relay</span>
|
||||||
|
<input
|
||||||
|
id="relayInput"
|
||||||
|
type="url"
|
||||||
|
placeholder="wss://relay.example.com"
|
||||||
|
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
id="addRelayBtn"
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
>
|
||||||
|
Add relay
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="restoreRelaysBtn"
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 rounded-md bg-gray-800 text-sm font-medium text-gray-200 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
>
|
||||||
|
Restore defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul id="relayList" class="space-y-3"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="profilePaneBlocked"
|
||||||
|
class="profile-pane hidden space-y-4"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="profileNavBlocked"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Creators you block are hidden from your BitVid experience. This
|
||||||
|
list is private to your account.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label class="flex-1 text-sm text-gray-300" for="blockedInput">
|
||||||
|
<span class="mb-2 block font-medium">Creator npub</span>
|
||||||
|
<input
|
||||||
|
id="blockedInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="npub1..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="addBlockedBtn"
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
>
|
||||||
|
Block creator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="blockedEmpty"
|
||||||
|
class="rounded-lg border border-dashed border-gray-700 p-6 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
You haven’t blocked any creators yet.
|
||||||
|
</div>
|
||||||
|
<ul id="blockedList" class="space-y-3"></ul>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="profilePaneAdmin"
|
||||||
|
class="profile-pane hidden space-y-6"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="profileNavAdmin"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">Platform moderation</h3>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Manage BitVid’s moderator roles and creator allow/block lists. Changes apply instantly across the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h4 class="text-base font-semibold text-white">Moderators</h4>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-gray-400">
|
||||||
|
Super Admin only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Moderators can edit the whitelist and blacklist but cannot manage other moderators or whitelist mode.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label class="flex-1 text-sm text-gray-300" for="adminModeratorInput">
|
||||||
|
<span class="mb-2 block font-medium">Moderator npub</span>
|
||||||
|
<input
|
||||||
|
id="adminModeratorInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="npub1..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="adminAddModeratorBtn"
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
>
|
||||||
|
Add moderator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="adminModeratorsEmpty"
|
||||||
|
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
No moderators configured yet.
|
||||||
|
</div>
|
||||||
|
<ul id="adminModeratorList" class="space-y-3"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="adminWhitelistSection" class="space-y-4">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h4 class="text-base font-semibold text-white">Whitelist</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label class="flex-1 text-sm text-gray-300" for="adminWhitelistInput">
|
||||||
|
<span class="mb-2 block font-medium">Creator npub</span>
|
||||||
|
<input
|
||||||
|
id="adminWhitelistInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="npub1..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="adminAddWhitelistBtn"
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
>
|
||||||
|
Add to whitelist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="adminWhitelistEmpty"
|
||||||
|
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
No whitelisted creators yet.
|
||||||
|
</div>
|
||||||
|
<ul id="adminWhitelistList" class="space-y-3"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="adminBlacklistSection" class="space-y-4">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h4 class="text-base font-semibold text-white">Blacklist</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Creators on the blacklist are hidden from BitVid everywhere. Adding someone removes them from the whitelist.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<label class="flex-1 text-sm text-gray-300" for="adminBlacklistInput">
|
||||||
|
<span class="mb-2 block font-medium">Creator npub</span>
|
||||||
|
<input
|
||||||
|
id="adminBlacklistInput"
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="npub1..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="adminAddBlacklistBtn"
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||||
|
>
|
||||||
|
Add to blacklist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="adminBlacklistEmpty"
|
||||||
|
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
No blacklisted creators yet.
|
||||||
|
</div>
|
||||||
|
<ul id="adminBlacklistList" class="space-y-3"></ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-400">
|
|
||||||
Some future profile details or settings could go here.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Footer -->
|
<!-- Modal Footer -->
|
||||||
<div class="p-4 border-t border-gray-700 flex items-center justify-end">
|
<div class="p-4 border-t border-gray-700 flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
id="profileLogoutBtn"
|
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
|
Logout
|
||||||
</button>
|
</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"
|
class="relative modal-container h-full w-full flex items-start md:items-center justify-center overflow-y-auto z-20"
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
style="max-height: 90vh"
|
||||||
>
|
>
|
||||||
<!-- Top Bar (similar to video-modal) -->
|
<!-- Top Bar (similar to video-modal) -->
|
||||||
@@ -50,8 +50,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form Content -->
|
<!-- Form Content -->
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-6">
|
||||||
<form id="uploadForm" class="space-y-4">
|
<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">
|
<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.
|
Share a title plus either a hosted video URL or a magnet link. Providing both helps Bitvid fall back gracefully.
|
||||||
</p>
|
</p>
|
||||||
@@ -121,7 +147,6 @@
|
|||||||
cost control.
|
cost control.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="uploadThumbnail"
|
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"
|
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>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
@@ -166,6 +208,300 @@
|
|||||||
Publish
|
Publish
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+77
-23
@@ -1,15 +1,11 @@
|
|||||||
<!-- components/video-modal.html -->
|
<!-- components/video-modal.html -->
|
||||||
<div id="playerModal" class="fixed inset-0 bg-black/90 z-50 hidden">
|
<div id="playerModal" class="player-modal hidden">
|
||||||
<div
|
<div class="player-modal__container">
|
||||||
class="modal-container h-full w-full flex items-start justify-center overflow-y-auto"
|
<div class="player-modal__content">
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="modal-content bg-gray-900 w-full max-w-[90%] lg:max-w-6xl my-0 rounded-lg overflow-hidden relative"
|
|
||||||
>
|
|
||||||
<!-- Navigation bar - sliding at top -->
|
<!-- Navigation bar - sliding at top -->
|
||||||
<div
|
<div
|
||||||
id="modalNav"
|
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">
|
<div class="flex items-center px-6 py-4">
|
||||||
<button
|
<button
|
||||||
@@ -31,19 +27,7 @@
|
|||||||
<path d="M15 18l-6-6 6-6" />
|
<path d="M15 18l-6-6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<div class="modal-actions">
|
||||||
</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) -->
|
|
||||||
<button id="copyMagnetBtn" class="icon-button">
|
<button id="copyMagnetBtn" class="icon-button">
|
||||||
<img
|
<img
|
||||||
src="assets/svg/copy-magnet.svg"
|
src="assets/svg/copy-magnet.svg"
|
||||||
@@ -51,7 +35,6 @@
|
|||||||
class="icon-image"
|
class="icon-image"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<!-- Zap Button (circular) -->
|
|
||||||
<button
|
<button
|
||||||
id="modalZapBtn"
|
id="modalZapBtn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -64,7 +47,6 @@
|
|||||||
class="icon-image"
|
class="icon-image"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<!-- Share Button (circular) -->
|
|
||||||
<button id="shareBtn" class="icon-button">
|
<button id="shareBtn" class="icon-button">
|
||||||
<img
|
<img
|
||||||
src="assets/svg/share-video.svg"
|
src="assets/svg/share-video.svg"
|
||||||
@@ -72,8 +54,80 @@
|
|||||||
class="icon-image"
|
class="icon-image"
|
||||||
/>
|
/>
|
||||||
</button>
|
</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>
|
||||||
|
</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 -->
|
<!-- Video Timestamp -->
|
||||||
<div
|
<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;
|
||||||
+292
-103
@@ -3,12 +3,51 @@
|
|||||||
--color-card: #1e293b;
|
--color-card: #1e293b;
|
||||||
--color-primary: #fe0032;
|
--color-primary: #fe0032;
|
||||||
--color-secondary: #ff93a5;
|
--color-secondary: #ff93a5;
|
||||||
|
--color-card-critical: #3f0b13;
|
||||||
--color-text: #f8fafc;
|
--color-text: #f8fafc;
|
||||||
--color-muted: #94a3b8;
|
--color-muted: #94a3b8;
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--shadow-sm: 0 0.0625rem 0.125rem 0 rgb(0 0 0 / 0.05);
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
--shadow-md: 0 0.25rem 0.375rem -0.0625rem rgb(0 0 0 / 0.1);
|
||||||
--shadow-lg: 0 10px 15px -3px 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;
|
--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 */
|
/* Core Styles */
|
||||||
@@ -36,7 +75,7 @@ header img {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1480px;
|
max-width: 92.5rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
@@ -44,7 +83,7 @@ header img {
|
|||||||
/* Video Grids */
|
/* Video Grids */
|
||||||
#videoList {
|
#videoList {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
@@ -52,7 +91,7 @@ header img {
|
|||||||
/* Subscriptions grid: same pattern as #videoList */
|
/* Subscriptions grid: same pattern as #videoList */
|
||||||
#subscriptionsVideoList {
|
#subscriptionsVideoList {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
@@ -60,7 +99,7 @@ header img {
|
|||||||
/* Now also match for channelVideoList (channel profile) */
|
/* Now also match for channelVideoList (channel profile) */
|
||||||
#channelVideoList {
|
#channelVideoList {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
padding: 1.5rem 0;
|
padding: 1.5rem 0;
|
||||||
}
|
}
|
||||||
@@ -74,6 +113,19 @@ header img {
|
|||||||
box-shadow: var(--shadow-md);
|
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 {
|
.video-card--enter {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: video-card-fade-in 220ms ease-out forwards;
|
animation: video-card-fade-in 220ms ease-out forwards;
|
||||||
@@ -82,7 +134,7 @@ header img {
|
|||||||
@keyframes video-card-fade-in {
|
@keyframes video-card-fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(0.5rem);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -161,7 +213,7 @@ header img {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-card:hover {
|
.video-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-0.25rem);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,21 +247,26 @@ header img {
|
|||||||
#playerModal {
|
#playerModal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-color: rgb(0 0 0 / 0.9); /* You can adjust opacity if needed */
|
z-index: 100;
|
||||||
z-index: 50;
|
display: none;
|
||||||
display: none; /* Hidden by default */
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
backdrop-filter: blur(10px);
|
-webkit-overflow-scrolling: touch;
|
||||||
-webkit-backdrop-filter: blur(10px); /* For Safari support */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#nostrFormModal,
|
#nostrFormModal,
|
||||||
#profileModal,
|
#profileModal,
|
||||||
#uploadModal {
|
#uploadModal {
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(0.625rem);
|
||||||
-webkit-backdrop-filter: blur(10px); /* For Safari */
|
-webkit-backdrop-filter: blur(0.625rem); /* For Safari */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* If you ever want to show it, add ".flex" class dynamically */
|
/* If you ever want to show it, add ".flex" class dynamically */
|
||||||
@@ -217,6 +274,82 @@ header img {
|
|||||||
display: flex;
|
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 Container */
|
||||||
.modal-content {
|
.modal-content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -230,7 +363,7 @@ header img {
|
|||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(1.25rem);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -244,15 +377,6 @@ header img {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Video Container */
|
|
||||||
.video-container {
|
|
||||||
width: 100%;
|
|
||||||
background-color: black;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 51;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Video */
|
/* Modal Video */
|
||||||
#modalVideo {
|
#modalVideo {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -260,33 +384,74 @@ header img {
|
|||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Video Info Section */
|
|
||||||
.video-info {
|
|
||||||
padding: 1rem;
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Adjustments */
|
/* Responsive Adjustments */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
#playerModal {
|
|
||||||
padding: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
max-width: 64rem;
|
max-width: 64rem;
|
||||||
max-height: calc(100vh - 4rem); /* Account for padding */
|
max-height: calc(100vh - 4rem); /* Account for padding */
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
#playerModal .player-modal__content {
|
||||||
position: relative;
|
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 */
|
/* Mobile-specific styles */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
#playerModal {
|
#playerModal {
|
||||||
@@ -298,24 +463,6 @@ header img {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
.video-info::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-info::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-info::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-info::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress Bars */
|
/* Progress Bars */
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -337,7 +484,7 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background-color: var(--color-bg);
|
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;
|
border-radius: 0.5rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
@@ -347,7 +494,7 @@ input:focus,
|
|||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
ring: 2px var(--color-primary);
|
ring: 0.125rem var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------
|
/* -------------------------------------------
|
||||||
@@ -366,12 +513,12 @@ button:not(.icon-button) {
|
|||||||
|
|
||||||
button:not(.icon-button):hover {
|
button:not(.icon-button):hover {
|
||||||
background-color: var(--color-secondary);
|
background-color: var(--color-secondary);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-0.0625rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:not(.icon-button):focus {
|
button:not(.icon-button):focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
ring: 2px var(--color-primary);
|
ring: 0.125rem var(--color-primary);
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -416,7 +563,7 @@ button:not(.icon-button):focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
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;
|
margin-top: 4rem;
|
||||||
padding-top: 2rem;
|
padding-top: 2rem;
|
||||||
}
|
}
|
||||||
@@ -436,47 +583,39 @@ footer a:hover {
|
|||||||
background-color: rgb(0 0 0 / 0.9);
|
background-color: rgb(0 0 0 / 0.9);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
/* remove display: none; */
|
/* remove display: none; */
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#disclaimerModal.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
#disclaimerModal .modal-content {
|
#disclaimerModal .modal-content {
|
||||||
width: 100%;
|
width: min(60rem, calc(100% - 2rem));
|
||||||
|
max-height: min(90vh, 51.25rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: var(--color-bg);
|
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;
|
padding: 1.5rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disclaimer Modal Scrollbar */
|
#disclaimerModal .modal-footer {
|
||||||
#disclaimerModal .modal-scroll::-webkit-scrollbar {
|
padding: 1.25rem 1.5rem 1.75rem;
|
||||||
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;
|
|
||||||
background-color: #1a2234;
|
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 */
|
/* Responsive Adjustments for Disclaimer Modal */
|
||||||
@@ -487,9 +626,14 @@ footer a:hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#disclaimerModal .modal-scroll {
|
#disclaimerModal .modal-body {
|
||||||
|
padding: 2rem 2.5rem 1.5rem;
|
||||||
max-height: calc(90vh - 5rem);
|
max-height: calc(90vh - 5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#disclaimerModal .modal-footer {
|
||||||
|
padding: 1.5rem 2.5rem 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile-specific styles for Disclaimer Modal */
|
/* Mobile-specific styles for Disclaimer Modal */
|
||||||
@@ -501,20 +645,34 @@ footer a:hover {
|
|||||||
#disclaimerModal .modal-content {
|
#disclaimerModal .modal-content {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
border-radius: 0;
|
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 */
|
/* Override default button styles for back button */
|
||||||
.back-button {
|
.back-button {
|
||||||
background: rgba(0, 0, 0, 0.5) !important;
|
background: rgba(15, 23, 42, 0.75) !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
color: rgb(209 213 219) !important;
|
color: rgb(226 232 240) !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
|
border: 0.0625rem solid rgba(148, 163, 184, 0.4) !important;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button:hover {
|
.back-button:hover {
|
||||||
background: rgba(0, 0, 0, 0.7) !important;
|
background: rgba(51, 65, 85, 0.95) !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
|
border-color: rgba(148, 163, 184, 0.55) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal Container */
|
/* Modal Container */
|
||||||
@@ -529,9 +687,9 @@ footer a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Video info cards */
|
/* Video info cards */
|
||||||
.video-info .bg-gray-800\/50 {
|
.player-modal__info .bg-gray-800\/50 {
|
||||||
background-color: rgb(31 41 55 / 0.5);
|
background-color: rgb(31 41 55 / 0.5);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(0.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Circular Icon Buttons */
|
/* Circular Icon Buttons */
|
||||||
@@ -557,7 +715,7 @@ footer a:hover {
|
|||||||
.icon-button:focus,
|
.icon-button:focus,
|
||||||
.icon-button:active {
|
.icon-button:active {
|
||||||
outline: none;
|
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 {
|
.icon-image {
|
||||||
@@ -644,14 +802,14 @@ footer a:hover {
|
|||||||
|
|
||||||
.sidebar-dropup-panel {
|
.sidebar-dropup-panel {
|
||||||
background-color: #1f2937; /* gray-800 */
|
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;
|
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;
|
max-height: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateY(16px);
|
transform: translateY(1rem);
|
||||||
transition: max-height 0.35s ease, opacity 0.25s ease, transform 0.35s ease;
|
transition: max-height 0.35s ease, opacity 0.25s ease, transform 0.35s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,7 +818,7 @@ footer a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-dropup-panel--expanded {
|
.sidebar-dropup-panel--expanded {
|
||||||
max-height: 640px;
|
max-height: 40rem;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -669,7 +827,7 @@ footer a:hover {
|
|||||||
.sidebar-dropup-trigger {
|
.sidebar-dropup-trigger {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(14, 116, 144, 0.35));
|
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;
|
border-radius: 9999px;
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -682,12 +840,43 @@ footer a:hover {
|
|||||||
.sidebar-dropup-trigger:hover,
|
.sidebar-dropup-trigger:hover,
|
||||||
.sidebar-dropup-trigger:focus {
|
.sidebar-dropup-trigger:focus {
|
||||||
border-color: rgba(148, 163, 184, 0.6);
|
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;
|
outline: none;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-0.0625rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-dropup-trigger__content {
|
.sidebar-dropup-trigger__content {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
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;
|
||||||
+18
-6
@@ -5,6 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>bitvid | Decentralized Video Sharing</title>
|
<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 -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="BitVid - Decentralized Video Sharing" />
|
<meta property="og:title" content="BitVid - Decentralized Video Sharing" />
|
||||||
<meta
|
<meta
|
||||||
@@ -40,7 +46,7 @@
|
|||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<script src="js/tracking.js" defer></script>
|
<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/style.css" rel="stylesheet" />
|
||||||
<link href="css/markdown.css" rel="stylesheet" />
|
<link href="css/markdown.css" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
@@ -87,11 +93,17 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<!-- Logo fades in once on initial load. -->
|
<!-- Logo fades in once on initial load. -->
|
||||||
<img
|
<a
|
||||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
href="index.html"
|
||||||
alt="BitVid Logo"
|
aria-label="Return to the bitvid home page"
|
||||||
class="h-16 fade-in fade-in-delay-100"
|
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 -->
|
<!-- Buttons on the far right -->
|
||||||
|
|
||||||
|
|||||||
+365
-192
@@ -1,228 +1,401 @@
|
|||||||
// js/accessControl.js
|
// js/accessControl.js
|
||||||
|
|
||||||
import { isDevMode, isWhitelistEnabled } from "./config.js";
|
import {
|
||||||
import { initialWhitelist, initialBlacklist } from "./lists.js";
|
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 {
|
class AccessControl {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Debug logging for initialization
|
this.editors = new Set();
|
||||||
console.log("DEBUG: AccessControl constructor called");
|
this.whitelist = new Set();
|
||||||
|
this.blacklist = new Set();
|
||||||
const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist();
|
this.whitelistEnabled = getWhitelistMode();
|
||||||
const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist();
|
this.hasLoaded = false;
|
||||||
|
this.lastError = null;
|
||||||
if (storedWhitelist !== null) {
|
this._isRefreshing = false;
|
||||||
this.whitelist = new Set(storedWhitelist);
|
this._refreshPromise = this._performRefresh();
|
||||||
} else {
|
this._refreshPromise.catch((error) => {
|
||||||
this.whitelist = new Set(initialWhitelist);
|
console.error("Failed to load admin lists:", error);
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rest of the class remains the same...
|
async _performRefresh() {
|
||||||
loadWhitelist() {
|
if (this._isRefreshing) {
|
||||||
|
return this._refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isRefreshing = true;
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("bitvid_whitelist");
|
const state = await loadAdminState();
|
||||||
if (!stored) {
|
const editors = Array.isArray(state?.editors) ? state.editors : [];
|
||||||
if (isDevMode) console.log("No stored whitelist found in localStorage.");
|
const whitelist = Array.isArray(state?.whitelist)
|
||||||
return { data: null, status: "missing" };
|
? state.whitelist
|
||||||
}
|
: [];
|
||||||
|
const blacklist = Array.isArray(state?.blacklist)
|
||||||
|
? state.blacklist
|
||||||
|
: [];
|
||||||
|
|
||||||
const parsed = JSON.parse(stored);
|
this.editors = new Set(
|
||||||
const sanitized = this.sanitizeList(parsed, "whitelist");
|
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
|
||||||
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
|
|
||||||
);
|
);
|
||||||
return null;
|
const normalizedWhitelist = dedupeNpubs(whitelist);
|
||||||
}
|
this.whitelist = new Set(normalizedWhitelist);
|
||||||
|
|
||||||
const sanitized = [];
|
const blacklistDedupe = dedupeNpubs(blacklist);
|
||||||
let invalidEntries = 0;
|
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) => {
|
this.whitelistEnabled = getWhitelistMode();
|
||||||
if (typeof value !== "string") {
|
this.hasLoaded = true;
|
||||||
invalidEntries += 1;
|
this.lastError = null;
|
||||||
return;
|
} 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();
|
refresh() {
|
||||||
if (trimmed) {
|
const promise = this._performRefresh();
|
||||||
sanitized.push(trimmed);
|
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 {
|
} 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() {
|
whitelistMode() {
|
||||||
try {
|
return this.whitelistEnabled;
|
||||||
localStorage.setItem(
|
|
||||||
"bitvid_blacklist",
|
|
||||||
JSON.stringify([...this.blacklist])
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving blacklist:", error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addToWhitelist(npub) {
|
isSuperAdmin(npub) {
|
||||||
if (!this.isValidNpub(npub)) {
|
const normalized = normalizeNpub(npub);
|
||||||
throw new Error("Invalid npub format");
|
return normalized ? normalized === ADMIN_SUPER_NPUB : false;
|
||||||
}
|
|
||||||
this.whitelist.add(npub);
|
|
||||||
this.saveWhitelist();
|
|
||||||
if (isDevMode) console.log(`Added ${npub} to whitelist`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFromWhitelist(npub) {
|
isAdminEditor(npub) {
|
||||||
this.whitelist.delete(npub);
|
const normalized = normalizeNpub(npub);
|
||||||
this.saveWhitelist();
|
if (!normalized) {
|
||||||
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)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub);
|
if (this.isSuperAdmin(normalized)) {
|
||||||
if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess);
|
return true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return this.editors.has(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
canEditAdminLists(npub) {
|
||||||
|
return this.isAdminEditor(npub);
|
||||||
}
|
}
|
||||||
|
|
||||||
getWhitelist() {
|
getWhitelist() {
|
||||||
return [...this.whitelist];
|
return Array.from(this.whitelist);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBlacklist() {
|
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 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 { app } from "./app.js";
|
||||||
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
||||||
import { attachHealthBadges } from "./gridHealth.js";
|
import { attachHealthBadges } from "./gridHealth.js";
|
||||||
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||||||
import { isWhitelistEnabled } from "./config.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.
|
* Initialize the channel profile view.
|
||||||
@@ -25,6 +223,9 @@ export async function initChannelProfileView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentChannelHex = null;
|
||||||
|
currentChannelNpub = null;
|
||||||
|
|
||||||
// 2) Decode npub => hex pubkey
|
// 2) Decode npub => hex pubkey
|
||||||
let hexPub;
|
let hexPub;
|
||||||
try {
|
try {
|
||||||
@@ -39,6 +240,13 @@ export async function initChannelProfileView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentChannelHex = hexPub;
|
||||||
|
currentChannelNpub = npub;
|
||||||
|
|
||||||
|
setupChannelShareButton();
|
||||||
|
setupChannelMoreMenu();
|
||||||
|
await updateChannelMenuState();
|
||||||
|
|
||||||
// 3) If user is logged in, load subscriptions and show sub/unsub button
|
// 3) If user is logged in, load subscriptions and show sub/unsub button
|
||||||
if (app.pubkey) {
|
if (app.pubkey) {
|
||||||
await subscriptions.loadSubscriptions(app.pubkey);
|
await subscriptions.loadSubscriptions(app.pubkey);
|
||||||
@@ -49,20 +257,25 @@ export async function initChannelProfileView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupZapButton();
|
setupZapButton();
|
||||||
|
syncChannelShareButtonState();
|
||||||
|
|
||||||
// 4) Load user’s profile (banner, avatar, etc.)
|
// 4) Load user’s profile (banner, avatar, etc.)
|
||||||
await loadUserProfile(hexPub);
|
await loadUserProfile(hexPub);
|
||||||
|
await updateChannelMenuState();
|
||||||
|
syncChannelShareButtonState();
|
||||||
|
|
||||||
// 5) Load user’s videos (filtered + rendered like the home feed)
|
// 5) Load user’s videos (filtered + rendered like the home feed)
|
||||||
await loadUserVideos(hexPub);
|
await loadUserVideos(hexPub);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupZapButton() {
|
function setupZapButton() {
|
||||||
const zapButton = document.getElementById("zapButton");
|
const zapButton = getChannelZapButton();
|
||||||
if (!zapButton) {
|
if (!zapButton) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setChannelZapVisibility(false);
|
||||||
|
|
||||||
if (zapButton.dataset.initialized === "true") {
|
if (zapButton.dataset.initialized === "true") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -181,15 +394,27 @@ async function loadUserProfile(pubkey) {
|
|||||||
|
|
||||||
// Lightning Address
|
// Lightning Address
|
||||||
const lnEl = document.getElementById("channelLightning");
|
const lnEl = document.getElementById("channelLightning");
|
||||||
|
const lightningAddress = (meta.lud16 || meta.lud06 || "").trim();
|
||||||
if (lnEl) {
|
if (lnEl) {
|
||||||
lnEl.textContent =
|
lnEl.textContent =
|
||||||
meta.lud16 || meta.lud06 || "No lightning address found.";
|
lightningAddress || "No lightning address found.";
|
||||||
}
|
}
|
||||||
|
setChannelZapVisibility(!!lightningAddress);
|
||||||
} else {
|
} else {
|
||||||
console.warn("No metadata found for this user.");
|
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) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch user profile data:", 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) {
|
async function loadUserVideos(pubkey) {
|
||||||
try {
|
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
|
// 1) Build filter for videos from this pubkey
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [30078],
|
kinds: [30078],
|
||||||
@@ -218,29 +449,24 @@ async function loadUserVideos(pubkey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Convert to "video" objects
|
// 3) Convert to "video" objects and keep everything (including tombstones)
|
||||||
let videos = [];
|
const convertedVideos = events
|
||||||
for (const evt of events) {
|
.map((evt) => sharedConvertEventToVideo(evt))
|
||||||
const vid = sharedConvertEventToVideo(evt);
|
.filter((vid) => !vid.invalid);
|
||||||
if (!vid.invalid && !vid.deleted) {
|
|
||||||
videos.push(vid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) Deduplicate older overshadowed versions => newest only
|
// 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) => {
|
videos = videos.filter((video) => {
|
||||||
// Event-level blacklisting
|
// Event-level blacklisting
|
||||||
if (app.blacklistedEventIds.has(video.id)) return false;
|
if (app.blacklistedEventIds.has(video.id)) return false;
|
||||||
|
|
||||||
// Author-level
|
// Author-level
|
||||||
const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey;
|
if (!accessControl.canAccess(video)) return false;
|
||||||
if (initialBlacklist.includes(authorNpub)) return false;
|
|
||||||
if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
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
|
// Fallback thumbnail
|
||||||
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
|
const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
|
||||||
const safeThumb = video.thumbnail || fallbackThumb;
|
const safeThumb = video.thumbnail || fallbackThumb;
|
||||||
@@ -361,6 +629,13 @@ async function loadUserVideos(pubkey) {
|
|||||||
"duration-300"
|
"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 =
|
const rawMagnet =
|
||||||
typeof video.magnet === "string" ? video.magnet : "";
|
typeof video.magnet === "string" ? video.magnet : "";
|
||||||
const trimmedMagnet = rawMagnet ? rawMagnet.trim() : "";
|
const trimmedMagnet = rawMagnet ? rawMagnet.trim() : "";
|
||||||
@@ -420,7 +695,7 @@ async function loadUserVideos(pubkey) {
|
|||||||
${new Date(video.created_at * 1000).toLocaleString()}
|
${new Date(video.created_at * 1000).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
${gearMenu}
|
${cardControls}
|
||||||
</div>
|
</div>
|
||||||
${connectionBadgesHtml}
|
${connectionBadgesHtml}
|
||||||
</div>
|
</div>
|
||||||
@@ -434,6 +709,35 @@ async function loadUserVideos(pubkey) {
|
|||||||
delete cardEl.dataset.torrentSupported;
|
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) {
|
if (magnetProvided) {
|
||||||
cardEl.dataset.magnet = playbackMagnet;
|
cardEl.dataset.magnet = playbackMagnet;
|
||||||
} else if (cardEl.dataset.magnet) {
|
} else if (cardEl.dataset.magnet) {
|
||||||
@@ -464,14 +768,18 @@ async function loadUserVideos(pubkey) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (trimmedUrl) {
|
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
||||||
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
if (badgeEl) {
|
||||||
if (badgeEl) {
|
if (trimmedUrl) {
|
||||||
app.handleUrlHealthBadge({
|
badgeEl.dataset.urlHealthEventId = video.id || "";
|
||||||
video,
|
badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
|
||||||
url: trimmedUrl,
|
} else {
|
||||||
badgeEl,
|
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);
|
container.appendChild(fragment);
|
||||||
|
|
||||||
attachHealthBadges(container);
|
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.videoList = container;
|
||||||
window.app.attachVideoListHandler();
|
window.app.attachVideoListHandler();
|
||||||
@@ -489,6 +801,8 @@ async function loadUserVideos(pubkey) {
|
|||||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||||
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
||||||
|
|
||||||
|
app.attachMoreMenuHandlers(container);
|
||||||
|
|
||||||
// Gear menu toggles
|
// Gear menu toggles
|
||||||
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
||||||
gearButtons.forEach((btn) => {
|
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.
|
* Minimal placeholder for private video decryption.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+41
-1
@@ -1,4 +1,44 @@
|
|||||||
// js/config.js
|
// 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 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) {
|
if (!magnet) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
@@ -125,21 +125,23 @@ function queueProbe(magnet, cacheKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const job = probeQueue
|
const job = probeQueue
|
||||||
.run(() =>
|
.run(
|
||||||
torrentClient
|
() =>
|
||||||
.probePeers(magnet, {
|
torrentClient
|
||||||
timeoutMs: PROBE_TIMEOUT_MS,
|
.probePeers(magnet, {
|
||||||
maxWebConns: 2,
|
timeoutMs: PROBE_TIMEOUT_MS,
|
||||||
polls: PROBE_POLL_COUNT,
|
maxWebConns: 2,
|
||||||
})
|
polls: PROBE_POLL_COUNT,
|
||||||
.catch((err) => ({
|
})
|
||||||
healthy: false,
|
.catch((err) => ({
|
||||||
peers: 0,
|
healthy: false,
|
||||||
reason: "error",
|
peers: 0,
|
||||||
error: err,
|
reason: "error",
|
||||||
appendedTrackers: false,
|
error: err,
|
||||||
hasProbeTrackers: false,
|
appendedTrackers: false,
|
||||||
}))
|
hasProbeTrackers: false,
|
||||||
|
})),
|
||||||
|
priority
|
||||||
)
|
)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
const normalized = normalizeResult(result);
|
const normalized = normalizeResult(result);
|
||||||
@@ -163,8 +165,9 @@ function queueProbe(magnet, cacheKey) {
|
|||||||
|
|
||||||
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
|
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
const PROBE_TIMEOUT_MS = 8000;
|
const PROBE_TIMEOUT_MS = 8000;
|
||||||
const PROBE_CONCURRENCY = 3;
|
const PROBE_CONCURRENCY = 96;
|
||||||
const PROBE_POLL_COUNT = 3;
|
const PROBE_POLL_COUNT = 3;
|
||||||
|
const PRIORITY_BASELINE = 1_000_000;
|
||||||
|
|
||||||
class ProbeQueue {
|
class ProbeQueue {
|
||||||
constructor(max = 2) {
|
constructor(max = 2) {
|
||||||
@@ -173,51 +176,72 @@ class ProbeQueue {
|
|||||||
this.queue = [];
|
this.queue = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
run(task) {
|
run(task, priority = 0) {
|
||||||
return new Promise((resolve, reject) => {
|
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();
|
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() {
|
drain() {
|
||||||
if (this.running >= this.max) {
|
while (this.running < this.max && this.queue.length > 0) {
|
||||||
return;
|
const job = this.queue.shift();
|
||||||
}
|
if (!job) {
|
||||||
const job = this.queue.shift();
|
|
||||||
if (!job) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.running += 1;
|
|
||||||
let finished = false;
|
|
||||||
const finalize = () => {
|
|
||||||
if (finished) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finished = true;
|
this.running += 1;
|
||||||
this.running -= 1;
|
let finished = false;
|
||||||
this.drain();
|
const finalize = () => {
|
||||||
};
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finished = true;
|
||||||
|
this.running -= 1;
|
||||||
|
this.drain();
|
||||||
|
};
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = job.task();
|
result = job.task();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
job.reject(err);
|
|
||||||
finalize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.resolve(result)
|
|
||||||
.then((value) => {
|
|
||||||
job.resolve(value);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
job.reject(err);
|
job.reject(err);
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
finalize();
|
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 pendingByCard = new WeakMap();
|
||||||
const observedCards = new WeakSet();
|
const observedCards = new WeakSet();
|
||||||
|
state = { observer: null, pendingByCard, observedCards };
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
processObserverEntries(entries, state);
|
||||||
const card = entry.target;
|
|
||||||
if (!(card instanceof HTMLElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!entry.isIntersecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleCardVisible({ card, pendingByCard });
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
|
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
|
||||||
);
|
);
|
||||||
|
|
||||||
state = { observer, pendingByCard, observedCards };
|
state.observer = observer;
|
||||||
containerState.set(container, state);
|
containerState.set(container, state);
|
||||||
return 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) {
|
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");
|
const badge = card.querySelector(".torrent-health-badge");
|
||||||
if (!badge) {
|
if (!badge) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hadMargin = badge.classList.contains("mt-3");
|
const hadMargin = badge.classList.contains("mt-3");
|
||||||
|
|
||||||
const baseClasses = [
|
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));
|
entry.classes.forEach((cls) => badge.classList.add(cls));
|
||||||
|
|
||||||
const peers =
|
const peersText =
|
||||||
details && Number.isFinite(details.peers)
|
normalizedState === "healthy" && peersValue > 0 ? ` (${peersValue})` : "";
|
||||||
? Math.max(0, Number(details.peers))
|
|
||||||
: 0;
|
|
||||||
const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : "";
|
|
||||||
|
|
||||||
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
|
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
|
||||||
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
|
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
|
||||||
const tooltip =
|
const tooltip =
|
||||||
state === "checking" || state === "unknown"
|
normalizedState === "checking" || normalizedState === "unknown"
|
||||||
? entry.aria
|
? entry.aria
|
||||||
: buildTooltip({
|
: buildTooltip({
|
||||||
peers,
|
peers: peersValue,
|
||||||
checkedAt: details?.checkedAt,
|
checkedAt: details?.checkedAt,
|
||||||
reason: details?.reason,
|
reason: details?.reason,
|
||||||
});
|
});
|
||||||
@@ -330,27 +515,27 @@ function setBadge(card, state, details) {
|
|||||||
badge.setAttribute("title", tooltip);
|
badge.setAttribute("title", tooltip);
|
||||||
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
|
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
|
||||||
badge.setAttribute("role", entry.role);
|
badge.setAttribute("role", entry.role);
|
||||||
badge.dataset.streamHealthState = state;
|
badge.dataset.streamHealthState = normalizedState;
|
||||||
if (Number.isFinite(peers)) {
|
if (hasPeerCount) {
|
||||||
badge.dataset.streamHealthPeers = String(peers);
|
badge.dataset.streamHealthPeers = peersTextValue;
|
||||||
} else if (badge.dataset.streamHealthPeers) {
|
} else if (badge.dataset.streamHealthPeers) {
|
||||||
delete badge.dataset.streamHealthPeers;
|
delete badge.dataset.streamHealthPeers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCardVisible({ card, pendingByCard }) {
|
function handleCardVisible({ card, pendingByCard, priority = 0 }) {
|
||||||
if (!(card instanceof HTMLElement)) {
|
if (!(card instanceof HTMLElement)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const magnet = card.dataset.magnet || "";
|
const magnet = card.dataset.magnet || "";
|
||||||
if (!magnet) {
|
if (!magnet) {
|
||||||
setBadge(card, "unknown");
|
setBadge(card, "unhealthy", { reason: "missing-source" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const infoHash = infoHashFromMagnet(magnet);
|
const infoHash = infoHashFromMagnet(magnet);
|
||||||
if (!infoHash) {
|
if (!infoHash) {
|
||||||
setBadge(card, "unknown");
|
setBadge(card, "unhealthy", { reason: "invalid" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +556,7 @@ function handleCardVisible({ card, pendingByCard }) {
|
|||||||
|
|
||||||
setBadge(card, "checking");
|
setBadge(card, "checking");
|
||||||
|
|
||||||
const probePromise = queueProbe(magnet, infoHash);
|
const probePromise = queueProbe(magnet, infoHash, priority);
|
||||||
|
|
||||||
pendingByCard.set(card, probePromise);
|
pendingByCard.set(card, probePromise);
|
||||||
|
|
||||||
@@ -421,9 +606,10 @@ export function attachHealthBadges(container) {
|
|||||||
state.observedCards.add(card);
|
state.observedCards.add(card);
|
||||||
state.observer.observe(card);
|
state.observer.observe(card);
|
||||||
if (!card.dataset.magnet) {
|
if (!card.dataset.magnet) {
|
||||||
setBadge(card, "unknown");
|
setBadge(card, "unhealthy", { reason: "missing-source" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
processObserverEntries(state.observer.takeRecords(), state);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshHealthBadges(container) {
|
export function refreshHealthBadges(container) {
|
||||||
@@ -434,9 +620,6 @@ export function refreshHealthBadges(container) {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.observer.takeRecords().forEach((entry) => {
|
const records = state.observer.takeRecords();
|
||||||
if (entry.isIntersecting) {
|
processObserverEntries(records, state);
|
||||||
handleCardVisible({ card: entry.target, pendingByCard: state.pendingByCard });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
+6
-22
@@ -1,26 +1,10 @@
|
|||||||
// js/lists.js
|
// js/lists.js
|
||||||
|
|
||||||
// Whitelist of npubs that can access the video upload functions
|
// Operators must manage admin lists through the Admin panel UI or remote
|
||||||
const npubs = [
|
// adapters. These legacy constants are intentionally empty so new installs do
|
||||||
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe", // bitvid
|
// not ship with hard-coded accounts.
|
||||||
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx", // thePR0M3TH3AN
|
export const ADMIN_INITIAL_WHITELIST = [];
|
||||||
"npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8", // Ghost Grid Network
|
export const ADMIN_INITIAL_BLACKLIST = [];
|
||||||
"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 = [""];
|
|
||||||
|
|
||||||
// Block specific events with the nevent
|
// 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}`;
|
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractMagnetHints(rawValue) {
|
||||||
|
const hints = { ws: "", xs: "" };
|
||||||
|
if (typeof rawValue !== "string") {
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (!trimmed || !trimmed.toLowerCase().startsWith("magnet:")) {
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryIndex = trimmed.indexOf("?");
|
||||||
|
if (queryIndex === -1 || queryIndex === trimmed.length - 1) {
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = trimmed.slice(queryIndex + 1);
|
||||||
|
if (!query) {
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = query.split("&");
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [rawKey, rawValue = ""] = part.split("=", 2);
|
||||||
|
if (!rawKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const lowerKey = rawKey.trim().toLowerCase();
|
||||||
|
if (lowerKey !== "ws" && lowerKey !== "xs") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hints[lowerKey]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = rawValue;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(rawValue);
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore decode errors and use the raw value.
|
||||||
|
}
|
||||||
|
const clean = typeof decoded === "string" ? decoded.trim() : "";
|
||||||
|
if (!clean) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hints[lowerKey] = clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
+450
-20
@@ -5,6 +5,7 @@ import { ACCEPT_LEGACY_V1 } from "./constants.js";
|
|||||||
import { accessControl } from "./accessControl.js";
|
import { accessControl } from "./accessControl.js";
|
||||||
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
|
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
|
||||||
import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js";
|
import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js";
|
||||||
|
import { extractMagnetHints } from "./magnet.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The usual relays
|
* The usual relays
|
||||||
@@ -20,6 +21,7 @@ const RELAY_URLS = [
|
|||||||
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
|
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
|
||||||
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
|
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
|
||||||
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
const NIP07_LOGIN_TIMEOUT_MS = 15_000; // 15 seconds
|
||||||
|
|
||||||
// To limit error spam
|
// To limit error spam
|
||||||
let errorLogCount = 0;
|
let errorLogCount = 0;
|
||||||
@@ -50,6 +52,94 @@ function fakeDecrypt(encrypted) {
|
|||||||
return encrypted.split("").reverse().join("");
|
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 = {
|
const EXTENSION_MIME_MAP = {
|
||||||
mp4: "video/mp4",
|
mp4: "video/mp4",
|
||||||
m4v: "video/x-m4v",
|
m4v: "video/x-m4v",
|
||||||
@@ -195,6 +285,10 @@ function convertEventToVideo(event = {}) {
|
|||||||
const deleted = parsedContent.deleted === true;
|
const deleted = parsedContent.deleted === true;
|
||||||
const isPrivate = parsedContent.isPrivate === true;
|
const isPrivate = parsedContent.isPrivate === true;
|
||||||
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
|
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 = "";
|
let infoHash = "";
|
||||||
const pushInfoHash = (candidate) => {
|
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 {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
videoRootId,
|
videoRootId,
|
||||||
@@ -295,6 +395,9 @@ function convertEventToVideo(event = {}) {
|
|||||||
description,
|
description,
|
||||||
mode,
|
mode,
|
||||||
deleted,
|
deleted,
|
||||||
|
ws,
|
||||||
|
xs,
|
||||||
|
enableComments,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
created_at: event.created_at,
|
created_at: event.created_at,
|
||||||
tags,
|
tags,
|
||||||
@@ -492,13 +595,57 @@ class NostrClient {
|
|||||||
*/
|
*/
|
||||||
async login() {
|
async login() {
|
||||||
try {
|
try {
|
||||||
if (!window.nostr) {
|
const extension = window.nostr;
|
||||||
|
if (!extension) {
|
||||||
console.log("No Nostr extension found");
|
console.log("No Nostr extension found");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Please install a Nostr extension (Alby, nos2x, etc.)."
|
"Please install a Nostr extension (Alby, nos2x, etc.)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
|
||||||
|
if (typeof extension.getPublicKey !== "function") {
|
||||||
|
throw new Error(
|
||||||
|
"This NIP-07 extension is missing getPublicKey support. Please update the extension."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof extension.enable === "function") {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log("Requesting permissions from NIP-07 extension...");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await extension.enable();
|
||||||
|
} catch (enableErr) {
|
||||||
|
throw new Error(
|
||||||
|
enableErr && enableErr.message
|
||||||
|
? enableErr.message
|
||||||
|
: "The NIP-07 extension denied the permission request."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutId;
|
||||||
|
const pubkey = await Promise.race([
|
||||||
|
extension.getPublicKey(),
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
"Timed out waiting for the NIP-07 extension. Check the extension prompt and try again."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, NIP07_LOGIN_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
]).finally(() => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!pubkey || typeof pubkey !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
"The NIP-07 extension did not return a public key. Please try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
@@ -531,6 +678,108 @@ class NostrClient {
|
|||||||
if (isDevMode) console.log("User logged out.");
|
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.
|
* Publish a new video using the v3 content schema.
|
||||||
*/
|
*/
|
||||||
@@ -567,6 +816,13 @@ class NostrClient {
|
|||||||
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const dTagValue = `${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 = {
|
const contentObject = {
|
||||||
version: 3,
|
version: 3,
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
@@ -578,8 +834,17 @@ class NostrClient {
|
|||||||
videoRootId,
|
videoRootId,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
isPrivate: videoData.isPrivate ?? false,
|
isPrivate: videoData.isPrivate ?? false,
|
||||||
|
enableComments: finalEnableComments,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (finalWs) {
|
||||||
|
contentObject.ws = finalWs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalXs) {
|
||||||
|
contentObject.xs = finalXs;
|
||||||
|
}
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
pubkey,
|
pubkey,
|
||||||
@@ -742,17 +1007,38 @@ class NostrClient {
|
|||||||
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
|
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
|
||||||
|
|
||||||
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
|
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
|
||||||
|
const magnetEdited = updatedData.magnetEdited === true;
|
||||||
const newMagnetValue =
|
const newMagnetValue =
|
||||||
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
|
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
|
||||||
let finalPlainMagnet = newMagnetValue || oldPlainMagnet;
|
let finalPlainMagnet = magnetEdited ? newMagnetValue : oldPlainMagnet;
|
||||||
let finalMagnet =
|
let finalMagnet =
|
||||||
wantPrivate && finalPlainMagnet
|
wantPrivate && finalPlainMagnet
|
||||||
? fakeEncrypt(finalPlainMagnet)
|
? fakeEncrypt(finalPlainMagnet)
|
||||||
: finalPlainMagnet;
|
: finalPlainMagnet;
|
||||||
|
|
||||||
|
const urlEdited = updatedData.urlEdited === true;
|
||||||
const newUrlValue =
|
const newUrlValue =
|
||||||
typeof updatedData.url === "string" ? updatedData.url.trim() : "";
|
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)
|
// Use the existing videoRootId (or fall back to the base event's ID)
|
||||||
const oldRootId = baseEvent.videoRootId || baseEvent.id;
|
const oldRootId = baseEvent.videoRootId || baseEvent.id;
|
||||||
@@ -772,8 +1058,17 @@ class NostrClient {
|
|||||||
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
|
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
|
||||||
description: updatedData.description ?? baseEvent.description,
|
description: updatedData.description ?? baseEvent.description,
|
||||||
mode: updatedData.mode ?? baseEvent.mode ?? "live",
|
mode: updatedData.mode ?? baseEvent.mode ?? "live",
|
||||||
|
enableComments: finalEnableComments,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (finalWs) {
|
||||||
|
contentObject.ws = finalWs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalXs) {
|
||||||
|
contentObject.xs = finalXs;
|
||||||
|
}
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
// Use the provided userPubkey (or you can also force it to lowercase here if desired)
|
// Use the provided userPubkey (or you can also force it to lowercase here if desired)
|
||||||
@@ -854,21 +1149,26 @@ class NostrClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const dTag = baseEvent.tags.find((t) => t[0] === "d");
|
const safeTags = Array.isArray(baseEvent.tags) ? baseEvent.tags : [];
|
||||||
if (!dTag) {
|
const dTag = safeTags.find((t) => t[0] === "d");
|
||||||
throw new Error(
|
const existingD = dTag ? dTag[1] : null;
|
||||||
'No "d" tag => cannot revert addressable kind=30078 event.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const existingD = dTag[1];
|
|
||||||
|
|
||||||
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;
|
const oldVersion = oldContent.version ?? 1;
|
||||||
|
|
||||||
let finalRootId = oldContent.videoRootId || null;
|
const finalRootId =
|
||||||
if (!finalRootId) {
|
oldContent.videoRootId ||
|
||||||
finalRootId = `LEGACY:${baseEvent.pubkey}:${existingD}`;
|
(existingD
|
||||||
}
|
? `LEGACY:${baseEvent.pubkey}:${existingD}`
|
||||||
|
: baseEvent.id);
|
||||||
|
|
||||||
const contentObject = {
|
const contentObject = {
|
||||||
videoRootId: finalRootId,
|
videoRootId: finalRootId,
|
||||||
@@ -883,14 +1183,16 @@ class NostrClient {
|
|||||||
mode: oldContent.mode || "live",
|
mode: oldContent.mode || "live",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const tags = [["t", "video"]];
|
||||||
|
if (existingD) {
|
||||||
|
tags.push(["d", existingD]);
|
||||||
|
}
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
pubkey,
|
pubkey,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [
|
tags,
|
||||||
["t", "video"],
|
|
||||||
["d", existingD],
|
|
||||||
],
|
|
||||||
content: JSON.stringify(contentObject),
|
content: JSON.stringify(contentObject),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1201,6 +1503,134 @@ class NostrClient {
|
|||||||
return null;
|
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() {
|
getActiveVideos() {
|
||||||
return Array.from(this.activeMap.values()).sort(
|
return Array.from(this.activeMap.values()).sort(
|
||||||
(a, b) => b.created_at - a.created_at
|
(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,
|
convertEventToVideo as sharedConvertEventToVideo,
|
||||||
} from "./nostr.js";
|
} from "./nostr.js";
|
||||||
import { attachHealthBadges } from "./gridHealth.js";
|
import { attachHealthBadges } from "./gridHealth.js";
|
||||||
|
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
|
||||||
|
|
||||||
function getAbsoluteShareUrl(nevent) {
|
function getAbsoluteShareUrl(nevent) {
|
||||||
if (!nevent) {
|
if (!nevent) {
|
||||||
@@ -278,7 +279,22 @@ class SubscriptionsManager {
|
|||||||
window.app?.dedupeVideosByRoot?.(safeVideos) ??
|
window.app?.dedupeVideosByRoot?.(safeVideos) ??
|
||||||
this.dedupeToNewestByRoot(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 = `
|
container.innerHTML = `
|
||||||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||||||
No videos available yet.
|
No videos available yet.
|
||||||
@@ -287,14 +303,14 @@ class SubscriptionsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort newest first
|
// 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 fullAllEventsArray = Array.from(nostrClient.allEvents.values());
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
// Only declare localAuthorSet once
|
// Only declare localAuthorSet once
|
||||||
const localAuthorSet = new Set();
|
const localAuthorSet = new Set();
|
||||||
|
|
||||||
dedupedVideos.forEach((video, index) => {
|
filteredVideos.forEach((video, index) => {
|
||||||
if (!video.id || !video.title) {
|
if (!video.id || !video.title) {
|
||||||
console.error("Missing ID or title:", video);
|
console.error("Missing ID or title:", video);
|
||||||
return;
|
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 safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
|
||||||
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
|
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
|
||||||
const playbackUrl =
|
const playbackUrl =
|
||||||
@@ -456,7 +514,7 @@ class SubscriptionsManager {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${gearMenu}
|
${cardControls}
|
||||||
</div>
|
</div>
|
||||||
${connectionBadgesHtml}
|
${connectionBadgesHtml}
|
||||||
</div>
|
</div>
|
||||||
@@ -467,6 +525,42 @@ class SubscriptionsManager {
|
|||||||
t.innerHTML = cardHtml.trim();
|
t.innerHTML = cardHtml.trim();
|
||||||
const cardEl = t.content.firstElementChild;
|
const cardEl = t.content.firstElementChild;
|
||||||
if (cardEl) {
|
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
|
// Leave the data-play-* attributes empty in the literal markup so we can
|
||||||
// assign the raw URL/magnet strings post-parsing without HTML entity
|
// assign the raw URL/magnet strings post-parsing without HTML entity
|
||||||
// escaping, mirroring the approach in app.js. The URL is encoded so that
|
// escaping, mirroring the approach in app.js. The URL is encoded so that
|
||||||
@@ -491,14 +585,18 @@ class SubscriptionsManager {
|
|||||||
delete cardEl.dataset.magnet;
|
delete cardEl.dataset.magnet;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmedUrl && window.app?.handleUrlHealthBadge) {
|
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
||||||
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
if (badgeEl) {
|
||||||
if (badgeEl) {
|
if (trimmedUrl) {
|
||||||
window.app.handleUrlHealthBadge({
|
badgeEl.dataset.urlHealthEventId = video.id || "";
|
||||||
video,
|
badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
|
||||||
url: trimmedUrl,
|
} else {
|
||||||
badgeEl,
|
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);
|
container.appendChild(fragment);
|
||||||
attachHealthBadges(container);
|
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) {
|
if (window.app) {
|
||||||
window.app.videoList = container;
|
window.app.videoList = container;
|
||||||
@@ -517,6 +623,8 @@ class SubscriptionsManager {
|
|||||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||||
lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
|
lazyEls.forEach((el) => window.app?.mediaLoader.observe(el));
|
||||||
|
|
||||||
|
window.app?.attachMoreMenuHandlers?.(container);
|
||||||
|
|
||||||
// Gear menus
|
// Gear menus
|
||||||
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
|
||||||
gearButtons.forEach((btn) => {
|
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) {
|
export async function loadView(viewUrl) {
|
||||||
try {
|
try {
|
||||||
|
if (window.app && typeof window.app.prepareForViewLoad === "function") {
|
||||||
|
window.app.prepareForViewLoad();
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(viewUrl);
|
const res = await fetch(viewUrl);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load view: ${res.status}`);
|
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.
|
* Initiates streaming of a torrent magnet to a <video> element.
|
||||||
* Ensures the service worker is set up only once and the client is reused.
|
* Ensures the service worker is set up only once and the client is reused.
|
||||||
@@ -778,19 +846,38 @@ export class TorrentClient {
|
|||||||
async cleanup() {
|
async cleanup() {
|
||||||
try {
|
try {
|
||||||
if (this.currentTorrent) {
|
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) {
|
if (this.client) {
|
||||||
await this.client.destroy();
|
try {
|
||||||
this.client = null;
|
await this.destroyWithTimeout(this.client, {
|
||||||
|
label: "WebTorrent client",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.client = null;
|
||||||
|
this.serverCreated = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.serverCreated = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.probeClient) {
|
if (this.probeClient) {
|
||||||
await this.probeClient.destroy();
|
try {
|
||||||
this.probeClient = null;
|
await this.destroyWithTimeout(this.probeClient, {
|
||||||
|
label: "probe client",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.probeClient = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.currentTorrent = null;
|
|
||||||
this.serverCreated = false;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log("Cleanup error:", 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">
|
<header class="header-torrent">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<img
|
<a href="../index.html" aria-label="Return to the bitvid home page">
|
||||||
src="../assets/svg/bitvid-logo-light-mode.svg"
|
<img
|
||||||
alt="bitvid Logo"
|
src="../assets/svg/bitvid-logo-light-mode.svg"
|
||||||
class="bitvid-logo"
|
alt="bitvid Logo"
|
||||||
/>
|
class="bitvid-logo"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+99
-31
@@ -1,29 +1,111 @@
|
|||||||
<section class="channel-profile-container">
|
<section class="channel-profile-container">
|
||||||
<!-- Modified banner wrapper for taller, rounded style -->
|
<!-- Modified banner wrapper for taller, rounded style -->
|
||||||
<div
|
<div class="profile-banner relative mb-6 rounded-lg shadow-lg">
|
||||||
class="profile-banner relative mb-6 rounded-lg overflow-hidden shadow-lg"
|
|
||||||
>
|
|
||||||
<!-- Increase the default height classes -->
|
<!-- Increase the default height classes -->
|
||||||
<img
|
<img
|
||||||
id="channelBanner"
|
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=""
|
src=""
|
||||||
alt="Banner"
|
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
|
||||||
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
|
class="absolute bottom-4 left-4 right-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||||
<img
|
>
|
||||||
id="channelAvatar"
|
<div class="flex items-center">
|
||||||
src="assets/svg/default-profile.svg"
|
<div class="w-16 h-16 rounded-full overflow-hidden border-2 border-white">
|
||||||
alt="Avatar"
|
<img
|
||||||
class="w-full h-full object-cover"
|
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>
|
||||||
<div class="ml-4 text-white">
|
<div class="flex items-center gap-2 self-start sm:self-end">
|
||||||
<h2 id="channelName" class="text-xl font-bold">User Name</h2>
|
<button
|
||||||
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,21 +125,7 @@
|
|||||||
|
|
||||||
<!-- Area for Subscribe/Unsubscribe button -->
|
<!-- Area for Subscribe/Unsubscribe button -->
|
||||||
<div class="px-4 my-4">
|
<div class="px-4 my-4">
|
||||||
<div class="flex items-center gap-3">
|
<div id="subscribeBtnArea" class="hidden"></div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<hr class="my-6" />
|
<hr class="my-6" />
|
||||||
|
|||||||
Reference in new issue
Block a user