mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 02:42:28 +00:00
Merge pull request #1565 from PR0M3TH3AN/codex/implement-kids-view-and-routing
Add Kids feed view and routing
This commit is contained in:
6 files changed
+309
No files matched your search
@@ -54,6 +54,15 @@
|
||||
/>
|
||||
<span class="sidebar-text">For You</span>
|
||||
</a>
|
||||
<a href="#view=kids" class="sidebar-nav-link" title="For Kids">
|
||||
<img
|
||||
src="assets/svg/feed-icon.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="sidebar-icon"
|
||||
/>
|
||||
<span class="sidebar-text">For Kids</span>
|
||||
</a>
|
||||
<a href="#view=most-recent-videos" class="sidebar-nav-link" title="Recently Added">
|
||||
<img
|
||||
src="assets/svg/globe-icon.svg"
|
||||
|
||||
@@ -6086,6 +6086,23 @@ class Application {
|
||||
};
|
||||
}
|
||||
|
||||
buildKidsFeedRuntime() {
|
||||
const blacklist =
|
||||
this.blacklistedEventIds instanceof Set
|
||||
? new Set(this.blacklistedEventIds)
|
||||
: new Set();
|
||||
|
||||
const moderationThresholds = this.getActiveModerationThresholds();
|
||||
|
||||
return {
|
||||
blacklistedEventIds: blacklist,
|
||||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||||
moderationThresholds: moderationThresholds
|
||||
? { ...moderationThresholds }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
refreshForYouFeed({ reason, fallbackVideos } = {}) {
|
||||
const runtime = this.buildForYouFeedRuntime();
|
||||
const normalizedReason = typeof reason === "string" ? reason : undefined;
|
||||
@@ -6148,6 +6165,63 @@ class Application {
|
||||
});
|
||||
}
|
||||
|
||||
refreshKidsFeed({ reason, fallbackVideos } = {}) {
|
||||
const runtime = this.buildKidsFeedRuntime();
|
||||
const normalizedReason = typeof reason === "string" ? reason : undefined;
|
||||
const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : [];
|
||||
|
||||
if (!this.feedEngine || typeof this.feedEngine.run !== "function") {
|
||||
const metadata = {
|
||||
reason: normalizedReason,
|
||||
engine: "unavailable",
|
||||
};
|
||||
this.latestFeedMetadata = metadata;
|
||||
this.videosMap = this.nostrService.getVideosMap();
|
||||
if (this.videoListView) {
|
||||
this.videoListView.state.videosMap = this.videosMap;
|
||||
}
|
||||
this.renderVideoList({ videos: fallback, metadata });
|
||||
return Promise.resolve({ videos: fallback, metadata });
|
||||
}
|
||||
|
||||
return this.feedEngine
|
||||
.run("kids", { runtime })
|
||||
.then((result) => {
|
||||
const videos = Array.isArray(result?.videos) ? result.videos : [];
|
||||
const metadata = {
|
||||
...(result?.metadata || {}),
|
||||
};
|
||||
if (normalizedReason) {
|
||||
metadata.reason = normalizedReason;
|
||||
}
|
||||
|
||||
this.latestFeedMetadata = metadata;
|
||||
this.videosMap = this.nostrService.getVideosMap();
|
||||
if (this.videoListView) {
|
||||
this.videoListView.state.videosMap = this.videosMap;
|
||||
}
|
||||
|
||||
const payload = { videos, metadata };
|
||||
this.renderVideoList(payload);
|
||||
return payload;
|
||||
})
|
||||
.catch((error) => {
|
||||
devLogger.error("[Application] Failed to run kids feed:", error);
|
||||
const metadata = {
|
||||
reason: normalizedReason || "error:kids-feed",
|
||||
error: true,
|
||||
};
|
||||
this.latestFeedMetadata = metadata;
|
||||
this.videosMap = this.nostrService.getVideosMap();
|
||||
if (this.videoListView) {
|
||||
this.videoListView.state.videosMap = this.videosMap;
|
||||
}
|
||||
const payload = { videos: fallback, metadata };
|
||||
this.renderVideoList(payload);
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
|
||||
refreshExploreFeed({ reason, fallbackVideos } = {}) {
|
||||
const runtime = this.buildExploreFeedRuntime();
|
||||
const normalizedReason = typeof reason === "string" ? reason : undefined;
|
||||
@@ -6367,6 +6441,54 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
async loadKidsVideos(forceFetch = false) {
|
||||
devLogger.log("Starting loadKidsVideos... (forceFetch =", forceFetch, ")");
|
||||
this.setFeedTelemetryContext("kids");
|
||||
|
||||
const container = this.mountVideoListView({ includeTags: false });
|
||||
const hasCachedVideos =
|
||||
this.nostrService &&
|
||||
Array.isArray(this.nostrService.getFilteredActiveVideos()) &&
|
||||
this.nostrService.getFilteredActiveVideos().length > 0;
|
||||
|
||||
if (!hasCachedVideos) {
|
||||
if (this.videoListView && container) {
|
||||
this.videoListView.showLoading("Fetching kids videos…");
|
||||
} else if (container) {
|
||||
container.innerHTML = getSidebarLoadingMarkup("Fetching kids videos…");
|
||||
}
|
||||
}
|
||||
|
||||
let initialRefreshPromise = null;
|
||||
|
||||
const videos = await this.nostrService.loadVideos({
|
||||
forceFetch,
|
||||
blacklistedEventIds: this.blacklistedEventIds,
|
||||
isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey),
|
||||
onVideos: (payload, detail = {}) => {
|
||||
const promise = this.refreshKidsFeed({
|
||||
reason: detail?.reason,
|
||||
fallbackVideos: payload,
|
||||
});
|
||||
if (!initialRefreshPromise) {
|
||||
initialRefreshPromise = promise;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (initialRefreshPromise) {
|
||||
await initialRefreshPromise;
|
||||
} else if (!Array.isArray(videos) || videos.length === 0) {
|
||||
await this.refreshKidsFeed({ reason: "initial", fallbackVideos: [] });
|
||||
}
|
||||
|
||||
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
|
||||
this.videosMap = this.nostrService.getVideosMap();
|
||||
if (this.videoListView) {
|
||||
this.videoListView.state.videosMap = this.videosMap;
|
||||
}
|
||||
}
|
||||
|
||||
async loadExploreVideos(forceFetch = false) {
|
||||
devLogger.log("Starting loadExploreVideos... (forceFetch =", forceFetch, ")");
|
||||
this.setFeedTelemetryContext("explore");
|
||||
|
||||
@@ -71,6 +71,10 @@ export function createHashChangeHandler({
|
||||
typeof window !== "undefined" && window.location && window.location.hash
|
||||
? window.location.hash
|
||||
: "";
|
||||
if (hash === "#kids") {
|
||||
window.location.hash = "#view=kids";
|
||||
return;
|
||||
}
|
||||
const match = hash.match(/^#view=([^&]+)/);
|
||||
|
||||
try {
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js";
|
||||
|
||||
const VIDEO_CARD_SELECTOR = "[data-component=\"video-card\"]";
|
||||
const EMPTY_STATE_SELECTOR = "[data-kids-empty-state]";
|
||||
const LOADING_SELECTOR = ".sidebar-loading-wrapper";
|
||||
|
||||
class KidsView {
|
||||
constructor({ document: doc = document } = {}) {
|
||||
this.document = doc;
|
||||
this.container = null;
|
||||
this.observer = null;
|
||||
this.handleMutation = this.handleMutation.bind(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.container = this.document?.getElementById("videoList") || null;
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.observeContainer();
|
||||
this.updateEmptyState();
|
||||
|
||||
const infoTrigger = this.document.getElementById("kidsInfoTrigger");
|
||||
if (infoTrigger) {
|
||||
attachFeedInfoPopover(
|
||||
infoTrigger,
|
||||
"Kid-friendly videos filtered for younger audiences."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
observeContainer() {
|
||||
if (!this.container || typeof MutationObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
this.observer = new MutationObserver(this.handleMutation);
|
||||
this.observer.observe(this.container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
handleMutation() {
|
||||
this.updateEmptyState();
|
||||
}
|
||||
|
||||
updateEmptyState() {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasVideos = Boolean(this.container.querySelector(VIDEO_CARD_SELECTOR));
|
||||
const isLoading = Boolean(this.container.querySelector(LOADING_SELECTOR));
|
||||
const existingEmptyState = this.container.querySelector(EMPTY_STATE_SELECTOR);
|
||||
|
||||
if (isLoading) {
|
||||
if (existingEmptyState) {
|
||||
existingEmptyState.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasVideos) {
|
||||
if (existingEmptyState) {
|
||||
existingEmptyState.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingEmptyState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyState = this.buildEmptyState();
|
||||
if (emptyState) {
|
||||
this.container.innerHTML = "";
|
||||
this.container.appendChild(emptyState);
|
||||
}
|
||||
}
|
||||
|
||||
buildEmptyState() {
|
||||
if (!this.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrapper = this.document.createElement("div");
|
||||
wrapper.className = "col-span-full";
|
||||
wrapper.dataset.kidsEmptyState = "true";
|
||||
|
||||
const card = this.document.createElement("div");
|
||||
card.className = "card p-lg text-center";
|
||||
|
||||
const title = this.document.createElement("h3");
|
||||
title.className = "text-lg font-semibold text-text";
|
||||
title.textContent = "No kids videos available yet.";
|
||||
|
||||
const copy = this.document.createElement("p");
|
||||
copy.className = "mt-2 text-sm text-muted";
|
||||
copy.textContent = "Check back soon for new kid-friendly videos.";
|
||||
|
||||
card.appendChild(title);
|
||||
card.appendChild(copy);
|
||||
wrapper.appendChild(card);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
}
|
||||
|
||||
export function initKidsView() {
|
||||
const view = new KidsView();
|
||||
view.init();
|
||||
return view;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// js/viewManager.js
|
||||
import { initChannelProfileView } from "./channelProfile.js";
|
||||
import { initForYouView } from "./forYouView.js";
|
||||
import { initKidsView } from "./kidsView.js";
|
||||
import { initExploreView } from "./exploreView.js";
|
||||
import { subscriptions } from "./subscriptions.js";
|
||||
import { getApplication } from "./applicationContext.js";
|
||||
@@ -118,6 +119,20 @@ export const viewInitRegistry = {
|
||||
refreshApp.forceRefreshAllProfiles();
|
||||
}
|
||||
},
|
||||
kids: () => {
|
||||
initKidsView();
|
||||
const app = getApplication();
|
||||
if (app && typeof app.loadKidsVideos === "function") {
|
||||
if (typeof app.mountVideoListView === "function") {
|
||||
app.mountVideoListView();
|
||||
}
|
||||
app.loadKidsVideos();
|
||||
}
|
||||
const refreshApp = getApplication();
|
||||
if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") {
|
||||
refreshApp.forceRefreshAllProfiles();
|
||||
}
|
||||
},
|
||||
explore: () => {
|
||||
initExploreView();
|
||||
const app = getApplication();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!-- views/kids.html -->
|
||||
<section data-ds="new">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<h2 class="text-xl font-semibold text-text">For Kids</h2>
|
||||
<button
|
||||
type="button"
|
||||
id="kidsInfoTrigger"
|
||||
class="text-muted hover:text-text focus:outline-none transition-colors"
|
||||
aria-label="How is this feed sorted?"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="videoList" class="bv-grid-video">
|
||||
<div class="sidebar-loading-wrapper" role="status" aria-live="polite">
|
||||
<div class="sidebar-loading-indicator">
|
||||
<span
|
||||
class="status-spinner status-spinner--inline"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="sidebar-loading-text">Loading kids videos…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
Reference in new issue
Block a user