improve homepage hero embeds
This commit is contained in:
parent
782b93b3c1
commit
454480f9a7
47
TODO.md
47
TODO.md
@ -42,6 +42,51 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
|
||||
- Keep community OKF, corrections OKF, feedback/review data, and source metadata preserved across updates.
|
||||
- Ensure generated OKF is reproducible and not accidentally overwritten by admin edits.
|
||||
|
||||
## Homepage Hero Embed Requirements
|
||||
|
||||
- Continue improving service-specific "why unavailable" messages where external providers expose enough signal.
|
||||
|
||||
### Discord Server Widget Hero
|
||||
|
||||
- Add optional Discord invite-to-server-ID lookup support if Discord server IDs are not known.
|
||||
|
||||
### Twitch Stream Hero
|
||||
|
||||
- Support additional configured parent domains for alternate hostnames.
|
||||
- Support stream-only and stream-with-chat layouts.
|
||||
- Respect Twitch minimum player size requirements.
|
||||
- If live-only mode is enabled, check live status or gracefully hide/fallback when offline.
|
||||
- Show a clear admin warning when the Twitch embed fails due to missing or incorrect parent domain.
|
||||
- Add admin helper text explaining that Twitch embeds require the site domain to be allowed as a parent domain.
|
||||
|
||||
### YouTube Live Hero
|
||||
|
||||
- Support YouTube live video URLs, raw video IDs, and optional channel-based live lookup.
|
||||
- For static live heroes, extract and use the provided live video ID.
|
||||
- For automatic channel live detection, require YouTube API configuration.
|
||||
- Cache live lookup results to avoid excessive API calls.
|
||||
- Support optional live-only mode.
|
||||
- Support optional YouTube live chat only when a valid live video ID exists.
|
||||
- Support autoplay and muted settings.
|
||||
- If live-only mode is enabled and no active stream is found, skip the hero or show fallback content.
|
||||
- Show fallback/error states for private, removed, age-restricted, embedding-disabled, unavailable, or not-yet-live videos.
|
||||
- Add admin helper text explaining that YouTube Live requires either a live video link or API-based channel lookup.
|
||||
|
||||
### YouTube Video Hero
|
||||
|
||||
- Show fallback/error states for private, removed, age-restricted, unavailable, or embedding-disabled videos.
|
||||
- Detect embedding-disabled videos before save when a YouTube API key or another reliable server-side signal is available.
|
||||
|
||||
### External Embedded Content Hero
|
||||
|
||||
- Support generic external iframe embeds only for sites that explicitly allow embedding.
|
||||
- Detect or gracefully handle sites that block embedding through `X-Frame-Options` or CSP `frame-ancestors`.
|
||||
- Show an admin warning when an external site refuses to be embedded.
|
||||
|
||||
### Hero Embed Admin UX
|
||||
|
||||
- Show “why unavailable” information in the admin UI when a hero cannot render.
|
||||
|
||||
## Update Page UX Improvements
|
||||
|
||||
- Add async in-place checks for the older `/admin` and `/admin/settings` update buttons, matching `/admin/updates`.
|
||||
@ -80,6 +125,8 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
|
||||
|
||||
## Done
|
||||
|
||||
- 2026-06-17: Bumped core package version to v0.1.3.
|
||||
- 2026-06-17: Completed homepage hero embed pass for Discord widgets, YouTube video playback options, external embed fallback/sandbox controls, admin validation, platform-specific fields, and Test preview behavior.
|
||||
- 2026-06-17: Fixed core update snapshots for large ZIP-origin installs by replacing the full-install ZIP backup with a filesystem snapshot directory, avoiding the 2 GiB ZIP limit for large preserved files such as local AI models.
|
||||
- 2026-06-17: Completed `/admin/updates` UX pass: viewport-fixed dismissible notifications, auto-dismiss for non-critical results, async core/plugin check actions without page refresh or scroll jumps, in-place update-card data refresh, loading states, and collapsed advanced Manual ZIP fallback below repo update containers.
|
||||
- 2026-06-17: Completed homepage hero reliability pass: server-side hero validation before save, admin-visible validation errors, home-page fallback message for broken legacy heroes, automatic YouTube/Twitch/Discord embed derivation, correct Twitch parent host at render time, and image/embed conflict handling.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lumi-bot",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.12",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lumi-bot",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@ -109,6 +109,17 @@
|
||||
image_url: "",
|
||||
embed_url: "",
|
||||
video_id: "",
|
||||
fallback_image_url: "",
|
||||
fallback_text: "",
|
||||
fallback_url: "",
|
||||
start_seconds: 0,
|
||||
show_controls: true,
|
||||
loop: false,
|
||||
embed_height: 0,
|
||||
aspect_ratio: "",
|
||||
allowed_domains: "",
|
||||
iframe_allow: "",
|
||||
allow_unsafe_permissions: false,
|
||||
availability_mode: "always",
|
||||
autoplay_mode: "off",
|
||||
duration_seconds: 0,
|
||||
@ -128,6 +139,8 @@
|
||||
};
|
||||
|
||||
const youtubeVideoId = (value) => {
|
||||
const raw = String(value || "").trim();
|
||||
if (/^[A-Za-z0-9_-]{11}$/.test(raw)) return raw;
|
||||
const url = parseUrl(value);
|
||||
if (!url) return "";
|
||||
if (url.hostname.includes("youtu.be")) return url.pathname.split("/").filter(Boolean)[0] || "";
|
||||
@ -155,20 +168,64 @@
|
||||
return url?.searchParams.get("id") || "";
|
||||
};
|
||||
|
||||
const deriveEmbedUrl = (type, sourceUrl, platformId = "") => {
|
||||
const youtubeStartSeconds = (value) => {
|
||||
const raw = String(value || "").trim();
|
||||
if (/^\d+$/.test(raw)) return Number(raw);
|
||||
const url = parseUrl(raw);
|
||||
const token = url?.searchParams.get("start") || url?.searchParams.get("t") || "";
|
||||
if (/^\d+$/.test(token)) return Number(token);
|
||||
const match = token.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/i);
|
||||
if (!match) return 0;
|
||||
return ((Number(match[1]) || 0) * 3600) + ((Number(match[2]) || 0) * 60) + (Number(match[3]) || 0);
|
||||
};
|
||||
|
||||
const youtubeEmbedUrl = (id, options = {}) => {
|
||||
if (!id) return "";
|
||||
const url = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`);
|
||||
if (options.autoplay && options.autoplay !== "off") {
|
||||
url.searchParams.set("autoplay", "1");
|
||||
if (options.autoplay !== "sound") url.searchParams.set("mute", "1");
|
||||
}
|
||||
if (options.showControls === false) url.searchParams.set("controls", "0");
|
||||
if (options.loop) {
|
||||
url.searchParams.set("loop", "1");
|
||||
url.searchParams.set("playlist", id);
|
||||
}
|
||||
if (options.startSeconds > 0) url.searchParams.set("start", String(options.startSeconds));
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const safeHttpUrl = (value) => {
|
||||
const url = parseUrl(value);
|
||||
return url && ["http:", "https:"].includes(url.protocol) ? url.toString() : "";
|
||||
};
|
||||
|
||||
const isLocalHost = () => ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
|
||||
|
||||
const isDiscordUrl = (value) => {
|
||||
const url = parseUrl(value);
|
||||
const host = url?.hostname.replace(/^www\./, "").toLowerCase();
|
||||
return host === "discord.gg" || host === "discord.com" || host?.endsWith(".discord.com");
|
||||
};
|
||||
|
||||
const deriveEmbedUrl = (type, sourceUrl, platformId = "", options = {}) => {
|
||||
const host = window.location.hostname || "localhost";
|
||||
if (type === "custom_embed") {
|
||||
const youtube = youtubeVideoId(sourceUrl);
|
||||
if (youtube) return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(youtube)}`;
|
||||
const twitch = twitchChannelName(sourceUrl);
|
||||
if (twitch) return `https://player.twitch.tv/?channel=${encodeURIComponent(twitch)}&parent=${encodeURIComponent(host)}&muted=true`;
|
||||
const discord = discordServerId(sourceUrl);
|
||||
if (discord) return `https://discord.com/widget?id=${encodeURIComponent(discord)}&theme=dark`;
|
||||
return "";
|
||||
if (isDiscordUrl(sourceUrl)) return "";
|
||||
return safeHttpUrl(sourceUrl);
|
||||
}
|
||||
if (type === "youtube_video") {
|
||||
const id = platformId || youtubeVideoId(sourceUrl);
|
||||
return id ? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}` : "";
|
||||
return youtubeEmbedUrl(id, {
|
||||
autoplay: options.autoplay,
|
||||
showControls: options.showControls,
|
||||
loop: options.loop,
|
||||
startSeconds: options.startSeconds || youtubeStartSeconds(sourceUrl)
|
||||
});
|
||||
}
|
||||
if (type === "youtube_channel") {
|
||||
const id = platformId || youtubeChannelId(sourceUrl);
|
||||
@ -180,7 +237,7 @@
|
||||
}
|
||||
if (type === "discord_server_overview") {
|
||||
const id = platformId || discordServerId(sourceUrl);
|
||||
return id ? `https://discord.com/widget?id=${encodeURIComponent(id)}&theme=dark` : "";
|
||||
return id ? `discord-widget:${id}` : "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@ -190,7 +247,19 @@
|
||||
if (item.type === "none") return { ok: true, message: "Fallback message is ready." };
|
||||
if (item.type === "static_image") return item.image_url ? { ok: true, message: "Image banner is ready." } : { ok: false, message: "Add an image URL before this banner can be shown." };
|
||||
if (item.type === "custom_link") return item.source_url ? { ok: true, message: "Featured link is ready." } : { ok: false, message: "Add a link before this hero can be shown." };
|
||||
if (item.type === "custom_embed") {
|
||||
if (!item.title) return { ok: false, message: "Add a title for this external embed." };
|
||||
const embed = item.embed_url || deriveEmbedUrl(item.type, item.source_url, item.video_id);
|
||||
if (isDiscordUrl(embed || item.source_url)) return { ok: false, message: "Use the Discord server widget hero for Discord. Invite links cannot be embedded." };
|
||||
const embedUrl = parseUrl(embed);
|
||||
if (embedUrl?.protocol === "http:" && !isLocalHost()) return { ok: false, message: "Use HTTPS embed URLs unless Lumi is running locally." };
|
||||
return embed ? { ok: true, message: "External embed is ready. The target site must allow iframe embedding." } : { ok: false, message: "Add a valid HTTPS embed URL. Normal website pages often block iframes." };
|
||||
}
|
||||
if (item.type === "discord_server_overview") {
|
||||
const id = item.video_id || discordServerId(item.source_url);
|
||||
return id ? { ok: true, message: "Discord widget card is ready. The Discord server widget must be enabled." } : { ok: false, message: "Add the numeric Discord server ID. Invite links cannot be embedded directly." };
|
||||
}
|
||||
const embed = item.embed_url || deriveEmbedUrl(item.type, item.source_url, item.video_id, item);
|
||||
return embed ? { ok: true, message: "Embed preview is ready." } : { ok: false, message: "Add a supported URL or platform ID so Lumi can build an embed." };
|
||||
};
|
||||
|
||||
@ -226,8 +295,12 @@
|
||||
const sourceUrl = row.querySelector("[data-field='source_url']").value.trim();
|
||||
const platformId = row.querySelector("[data-field='video_id']").value.trim();
|
||||
const embedInput = row.querySelector("[data-field='embed_url']");
|
||||
const derivedEmbed = deriveEmbedUrl(type, sourceUrl, platformId);
|
||||
if (!embedInput.value.trim() && derivedEmbed) embedInput.value = derivedEmbed;
|
||||
const startSeconds = Number(row.querySelector("[data-field='start_seconds']")?.value) || 0;
|
||||
const showControls = row.querySelector("[data-field='show_controls']")?.checked !== false;
|
||||
const loop = row.querySelector("[data-field='loop']")?.checked === true;
|
||||
const autoplay = row.querySelector("[data-field='autoplay_mode']").value;
|
||||
const derivedEmbed = deriveEmbedUrl(type, sourceUrl, platformId, { autoplay, showControls, loop, startSeconds });
|
||||
if (!embedInput.value.trim() && derivedEmbed && !derivedEmbed.startsWith("discord-widget:")) embedInput.value = derivedEmbed;
|
||||
return {
|
||||
enabled: row.querySelector("[data-field='enabled']").checked,
|
||||
type,
|
||||
@ -239,6 +312,17 @@
|
||||
image_url: row.querySelector("[data-field='image_url']").value.trim(),
|
||||
embed_url: embedInput.value.trim(),
|
||||
video_id: platformId,
|
||||
fallback_image_url: row.querySelector("[data-field='fallback_image_url']")?.value.trim() || "",
|
||||
fallback_text: row.querySelector("[data-field='fallback_text']")?.value.trim() || "",
|
||||
fallback_url: row.querySelector("[data-field='fallback_url']")?.value.trim() || "",
|
||||
start_seconds: startSeconds,
|
||||
show_controls: showControls,
|
||||
loop,
|
||||
embed_height: Number(row.querySelector("[data-field='embed_height']")?.value) || 0,
|
||||
aspect_ratio: row.querySelector("[data-field='aspect_ratio']")?.value.trim() || "",
|
||||
allowed_domains: row.querySelector("[data-field='allowed_domains']")?.value.trim() || "",
|
||||
iframe_allow: row.querySelector("[data-field='iframe_allow']")?.value.trim() || "",
|
||||
allow_unsafe_permissions: row.querySelector("[data-field='allow_unsafe_permissions']")?.checked === true,
|
||||
availability_mode: row.querySelector("[data-field='availability_mode']").value,
|
||||
autoplay_mode: row.querySelector("[data-field='autoplay_mode']").value,
|
||||
duration_seconds: Number(row.querySelector("[data-field='duration_seconds']").value) || 0,
|
||||
@ -262,18 +346,28 @@
|
||||
if (kind !== "heroes") return;
|
||||
const type = row.querySelector("[data-field='type']")?.value || "static_image";
|
||||
const videoLike = ["youtube_video", "youtube_channel", "twitch_stream"].includes(type);
|
||||
const embedded = ["custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
|
||||
const platformId = ["youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
|
||||
const embedded = ["custom_embed", "youtube_video", "youtube_channel", "twitch_stream"].includes(type);
|
||||
const source = ["custom_link", "static_image", "custom_embed", "youtube_video", "youtube_channel", "twitch_stream", "discord_server_overview"].includes(type);
|
||||
const image = type === "static_image";
|
||||
const fallback = type === "none";
|
||||
const fallbackConfig = type !== "none";
|
||||
const youtubeVideo = type === "youtube_video";
|
||||
const customEmbed = type === "custom_embed";
|
||||
const discord = type === "discord_server_overview";
|
||||
row.querySelectorAll("[data-relevance]").forEach((item) => {
|
||||
const relevance = item.dataset.relevance;
|
||||
const visible =
|
||||
relevance === "video" ? videoLike :
|
||||
relevance === "platform-id" ? platformId :
|
||||
relevance === "embed" ? embedded :
|
||||
relevance === "source" ? source :
|
||||
relevance === "image" ? image :
|
||||
relevance === "fallback" ? fallback :
|
||||
relevance === "fallback-config" ? fallbackConfig :
|
||||
relevance === "youtube-video" ? youtubeVideo :
|
||||
relevance === "custom-embed" ? customEmbed :
|
||||
relevance === "discord" ? discord :
|
||||
true;
|
||||
item.hidden = !visible;
|
||||
});
|
||||
@ -365,14 +459,26 @@
|
||||
addField(row, "Short description", textInput(item.description, "What's happening now"), "description");
|
||||
addField(row, "Display priority", numberInput(item.priority ?? index, 0), "priority", { help: "Lower numbers are checked first." });
|
||||
addField(row, "Who can see it", selectInput(item.permission || "public", permissions), "permission");
|
||||
addField(row, "Normal URL or platform link", textInput(item.source_url, "Paste a YouTube, Twitch, Discord, website, or image URL"), "source_url", { relevance: "source", help: "Paste the normal page URL. Lumi will build an embed URL when possible." });
|
||||
addField(row, "Normal URL or platform link", textInput(item.source_url, "Paste a YouTube, Twitch, Discord, website, or image URL"), "source_url", { relevance: "source", help: "Paste a normal URL when possible. For Discord, use this for the optional invite/fallback link." });
|
||||
addField(row, "Image URL", textInput(item.image_url, "https://example.com/banner.png"), "image_url", { relevance: "image", help: "Direct image URL for image banner heroes." });
|
||||
addField(row, "Embed URL", textInput(item.embed_url, "Filled automatically for supported services"), "embed_url", { relevance: "embed", help: "Optional advanced field. Leave blank to auto-convert common service URLs." });
|
||||
addField(row, "Platform ID", textInput(item.video_id, "Optional video, channel, or server ID"), "video_id", { relevance: "video", help: "Use only if the normal URL cannot be detected." });
|
||||
addField(row, "Embed URL", textInput(item.embed_url, "https://example.com/embed"), "embed_url", { relevance: "embed", help: "Use an actual embed/player URL. Discord invite links are not iframes." });
|
||||
addField(row, "Platform ID", textInput(item.video_id, "Video, channel, Twitch name, or Discord server ID"), "video_id", { relevance: "platform-id", help: "Discord requires the numeric server ID and the server widget must be enabled." });
|
||||
addField(row, "Availability", selectInput(item.availability_mode || "always", availabilityModes), "availability_mode", { relevance: "video", help: "Live-only heroes hide unless live state says they are available." });
|
||||
addField(row, "Autoplay", selectInput(item.autoplay_mode || "off", autoplayModes), "autoplay_mode", { relevance: "video" });
|
||||
addField(row, "YouTube start time", numberInput(item.start_seconds, 0), "start_seconds", { relevance: "youtube-video", help: "Optional start time in seconds. Lumi also reads t= or start= from pasted YouTube URLs." });
|
||||
addField(row, "Show YouTube controls", checkbox(item.show_controls !== false), "show_controls", { relevance: "youtube-video" });
|
||||
addField(row, "Loop YouTube video", checkbox(item.loop === true), "loop", { relevance: "youtube-video" });
|
||||
addField(row, "Trusted embed domains", textInput(item.allowed_domains, "example.com, player.example.com"), "allowed_domains", { relevance: "custom-embed", help: "Optional comma-separated allowlist. Leave blank to allow any HTTPS embed URL." });
|
||||
addField(row, "Embed aspect ratio", textInput(item.aspect_ratio, "16/9"), "aspect_ratio", { relevance: "custom-embed", help: "Optional ratio such as 16/9 or 4/3." });
|
||||
addField(row, "Embed minimum height", numberInput(item.embed_height, 0), "embed_height", { relevance: "custom-embed", help: "Optional minimum height in pixels." });
|
||||
addField(row, "Iframe permissions", textInput(item.iframe_allow, "fullscreen; autoplay"), "iframe_allow", { relevance: "custom-embed", help: "Advanced. Camera, microphone, and clipboard are blocked unless explicitly allowed below." });
|
||||
addField(row, "Allow sensitive iframe permissions", checkbox(item.allow_unsafe_permissions === true), "allow_unsafe_permissions", { relevance: "custom-embed", help: "Only use for trusted embeds that truly need camera, microphone, or clipboard access." });
|
||||
addField(row, "Discord setup note", textInput("Enable Server Settings > Widget in Discord, then paste the numeric server ID.", ""), "discord_note", { relevance: "discord", help: "Discord invite links cannot be embedded directly. Lumi renders the public widget data as a card." }).disabled = true;
|
||||
addField(row, "Fallback image URL", textInput(item.fallback_image_url, "https://example.com/fallback.png"), "fallback_image_url", { relevance: "fallback-config", help: "Optional image shown if the embed/widget is unavailable." });
|
||||
addField(row, "Fallback text", textInput(item.fallback_text, "This content is unavailable right now."), "fallback_text", { relevance: "fallback-config", help: "Optional message shown when the hero cannot render." });
|
||||
addField(row, "Fallback link", textInput(item.fallback_url, "https://example.com"), "fallback_url", { relevance: "fallback-config", help: "Optional link shown when the hero cannot render." });
|
||||
addField(row, "Display timer seconds", numberInput(item.duration_seconds, 0), "duration_seconds", { relevance: "video", help: "Optional duration hint for rotating heroes." });
|
||||
addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback" });
|
||||
addField(row, "Fallback behavior", selectInput(item.fallback_behavior || "message", [["message", "Show message"], ["link", "Show fallback link/card"], ["hide", "Hide hero"]]), "fallback_behavior", { relevance: "fallback-config" });
|
||||
typeSelect.addEventListener("change", () => updateHeroRelevance(row));
|
||||
}
|
||||
|
||||
@ -473,10 +579,15 @@
|
||||
const item = {
|
||||
enabled: row.querySelector("[data-field='enabled']")?.checked,
|
||||
type: row.querySelector("[data-field='type']")?.value,
|
||||
title: row.querySelector("[data-field='title']")?.value.trim(),
|
||||
source_url: row.querySelector("[data-field='source_url']")?.value.trim(),
|
||||
image_url: row.querySelector("[data-field='image_url']")?.value.trim(),
|
||||
embed_url: row.querySelector("[data-field='embed_url']")?.value.trim(),
|
||||
video_id: row.querySelector("[data-field='video_id']")?.value.trim()
|
||||
video_id: row.querySelector("[data-field='video_id']")?.value.trim(),
|
||||
autoplay: row.querySelector("[data-field='autoplay_mode']")?.value,
|
||||
showControls: row.querySelector("[data-field='show_controls']")?.checked !== false,
|
||||
loop: row.querySelector("[data-field='loop']")?.checked === true,
|
||||
startSeconds: Number(row.querySelector("[data-field='start_seconds']")?.value) || 0
|
||||
};
|
||||
const status = heroValidation(item);
|
||||
target.classList.toggle("danger", !status.ok);
|
||||
@ -492,14 +603,27 @@
|
||||
const image = row.querySelector("[data-field='image_url']")?.value.trim() || "";
|
||||
const embedInput = row.querySelector("[data-field='embed_url']");
|
||||
const platformId = row.querySelector("[data-field='video_id']")?.value.trim() || "";
|
||||
const embed = embedInput?.value.trim() || deriveEmbedUrl(type, source, platformId);
|
||||
if (embedInput && !embedInput.value.trim() && embed) embedInput.value = embed;
|
||||
const options = {
|
||||
autoplay: row.querySelector("[data-field='autoplay_mode']")?.value,
|
||||
showControls: row.querySelector("[data-field='show_controls']")?.checked !== false,
|
||||
loop: row.querySelector("[data-field='loop']")?.checked === true,
|
||||
startSeconds: Number(row.querySelector("[data-field='start_seconds']")?.value) || 0
|
||||
};
|
||||
const embed = embedInput?.value.trim() || deriveEmbedUrl(type, source, platformId, options);
|
||||
if (embedInput && !embedInput.value.trim() && embed && !embed.startsWith("discord-widget:")) embedInput.value = embed;
|
||||
const preview = row.querySelector("[data-homepage-preview]");
|
||||
if (!preview) return;
|
||||
if (type === "static_image" && image) {
|
||||
preview.innerHTML = `<img src="${escapeHtml(image)}" alt="" class="homepage-builder-preview-media"><small>Image preview</small>`;
|
||||
return;
|
||||
}
|
||||
if (type === "discord_server_overview") {
|
||||
const id = platformId || discordServerId(source);
|
||||
preview.innerHTML = id
|
||||
? `<strong>Discord widget card preview</strong><small>Lumi will fetch public widget data for server ${escapeHtml(id)} after the page is saved. Discord Server Widget must be enabled.</small>`
|
||||
: `<strong>Discord widget unavailable</strong><small>Add the numeric Discord server ID. Invite links can be used only as fallback links.</small>`;
|
||||
return;
|
||||
}
|
||||
if (embed) {
|
||||
preview.innerHTML = `<iframe src="${escapeHtml(embed)}" title="Homepage hero preview" class="homepage-builder-preview-media" loading="lazy" allowfullscreen></iframe><small>Embed preview. Some services block previews until saved on the public host.</small>`;
|
||||
return;
|
||||
|
||||
@ -107,6 +107,70 @@ pre {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.homepage-discord-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--lumi-space-4);
|
||||
padding: var(--lumi-space-4);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.homepage-discord-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-3);
|
||||
}
|
||||
|
||||
.homepage-discord-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--lumi-radius-md);
|
||||
background: var(--lumi-accent);
|
||||
color: var(--lumi-accent-contrast);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.homepage-discord-card small,
|
||||
.homepage-discord-label {
|
||||
color: var(--lumi-text-muted);
|
||||
}
|
||||
|
||||
.homepage-discord-label {
|
||||
display: block;
|
||||
margin-bottom: var(--lumi-space-2);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.homepage-discord-pills,
|
||||
.homepage-discord-members {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--lumi-space-2);
|
||||
}
|
||||
|
||||
.homepage-discord-pills span,
|
||||
.homepage-discord-members span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--lumi-space-2);
|
||||
padding: var(--lumi-space-2);
|
||||
border: 1px solid var(--lumi-border);
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
background: color-mix(in srgb, var(--lumi-surface) 80%, transparent);
|
||||
}
|
||||
|
||||
.homepage-discord-members img {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.homepage-link-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -786,6 +850,13 @@ input[type="color"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.homepage-hero-error img {
|
||||
max-width: 100%;
|
||||
max-height: 12rem;
|
||||
border-radius: var(--lumi-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.update-recovery-banner {
|
||||
border-left: 4px solid var(--lumi-danger);
|
||||
}
|
||||
|
||||
@ -1688,6 +1688,9 @@ function parseJsonSetting(key, fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const DISCORD_WIDGET_CACHE_MS = 5 * 60 * 1000;
|
||||
const discordWidgetCache = new Map();
|
||||
|
||||
function safeExternalUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ""));
|
||||
@ -1705,6 +1708,52 @@ function safeHomepageLinkUrl(value) {
|
||||
return safeExternalUrl(raw);
|
||||
}
|
||||
|
||||
function isLocalHostname(hostname) {
|
||||
return ["localhost", "127.0.0.1", "::1"].includes(String(hostname || "").toLowerCase());
|
||||
}
|
||||
|
||||
function isDevRequest(req = null) {
|
||||
return process.env.NODE_ENV !== "production" || isLocalHostname(homepageEmbedParent(req));
|
||||
}
|
||||
|
||||
function safeEmbedUrl(value, options = {}) {
|
||||
try {
|
||||
const url = new URL(String(value || "").trim());
|
||||
if (url.protocol === "https:") return url.toString();
|
||||
if (url.protocol === "http:" && options.allowInsecure) return url.toString();
|
||||
return "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseDomainList(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase().replace(/^\*\./, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function embedDomainAllowed(embedUrl, item) {
|
||||
const domains = parseDomainList(item.allowed_domains);
|
||||
if (!domains.length) return true;
|
||||
try {
|
||||
const host = new URL(embedUrl).hostname.toLowerCase();
|
||||
return domains.some((domain) => host === domain || host.endsWith(`.${domain}`));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDiscordUrl(value) {
|
||||
try {
|
||||
const host = new URL(value).hostname.toLowerCase().replace(/^www\./, "");
|
||||
return host === "discord.gg" || host === "discord.com" || host.endsWith(".discord.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function permissionAllows(user, permission = "public") {
|
||||
const role = ["public", "user", "mod", "admin"].includes(permission) ? permission : "public";
|
||||
return role === "public" ? true : hasAccess(user, role);
|
||||
@ -1748,13 +1797,19 @@ function homepageLinksForUser(user) {
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}
|
||||
|
||||
function homepageHeroForUser(user, req = null) {
|
||||
async function homepageHeroForUser(user, req = null) {
|
||||
const entries = parseJsonSetting("homepage_hero_entries", [])
|
||||
.filter((item) => item && item.enabled !== false)
|
||||
.filter((item) => permissionAllows(user, item.permission))
|
||||
.sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0));
|
||||
for (const item of entries) {
|
||||
const hero = normalizeHomepageHero(item, { parentHost: homepageEmbedParent(req) });
|
||||
let hero = normalizeHomepageHero(item, {
|
||||
parentHost: homepageEmbedParent(req),
|
||||
allowInsecureEmbeds: isDevRequest(req)
|
||||
});
|
||||
if (hero?.available && hero.type === "discord_server_overview") {
|
||||
hero = await enrichDiscordHero(hero, item);
|
||||
}
|
||||
if (hero?.available) return hero;
|
||||
if (hero?.render_error && hasAccess(user, "admin")) return hero;
|
||||
}
|
||||
@ -1769,18 +1824,37 @@ function normalizeHomepageHero(item, options = {}) {
|
||||
: null;
|
||||
}
|
||||
const sourceUrl = safeExternalUrl(item.source_url);
|
||||
const embedUrl = safeExternalUrl(item.embed_url) || deriveHomepageEmbedUrl(type, item, options);
|
||||
const embedUrl = safeEmbedUrl(item.embed_url, { allowInsecure: options.allowInsecureEmbeds }) || deriveHomepageEmbedUrl(type, item, options);
|
||||
const imageUrl = safeExternalUrl(item.image_url);
|
||||
const fallback = homepageHeroFallback(item);
|
||||
const title = String(item.title || "Featured content").slice(0, 120);
|
||||
const description = String(item.description || "").slice(0, 500);
|
||||
if (type === "static_image" && imageUrl) return { type, available: true, title, description, image_url: imageUrl, source_url: sourceUrl };
|
||||
if (type === "custom_link" && sourceUrl) return { type, available: true, title, description, source_url: sourceUrl };
|
||||
if (type === "custom_embed" && embedUrl) return { type, available: true, title, description, embed_url: embedUrl };
|
||||
if (type === "youtube_video") {
|
||||
const videoId = item.video_id || youtubeVideoId(sourceUrl);
|
||||
if (videoId) return { type, available: true, title, description, embed_url: `https://www.youtube-nocookie.com/embed/${videoId}`, source_url: sourceUrl };
|
||||
if (type === "custom_embed" && embedUrl && !isDiscordUrl(embedUrl) && embedDomainAllowed(embedUrl, item)) {
|
||||
return {
|
||||
type,
|
||||
available: true,
|
||||
title,
|
||||
description,
|
||||
embed_url: embedUrl,
|
||||
source_url: sourceUrl,
|
||||
iframe_sandbox: "allow-scripts allow-same-origin allow-presentation allow-popups",
|
||||
iframe_allow: customEmbedAllow(item),
|
||||
aspect_ratio: safeAspectRatio(item.aspect_ratio),
|
||||
embed_height: safeEmbedHeight(item.embed_height),
|
||||
...fallback
|
||||
};
|
||||
}
|
||||
if (["youtube_channel", "twitch_stream", "discord_server_overview"].includes(type) && (embedUrl || sourceUrl)) {
|
||||
if (type === "youtube_video") {
|
||||
const videoId = item.video_id || youtubeVideoId(item.source_url) || youtubeVideoId(sourceUrl);
|
||||
if (videoId) return { type, available: true, title, description, embed_url: youtubeVideoEmbedUrl(videoId, item, sourceUrl), source_url: sourceUrl, iframe_allow: "autoplay; encrypted-media; picture-in-picture; web-share" };
|
||||
}
|
||||
if (type === "discord_server_overview") {
|
||||
const guildId = item.video_id || discordServerId(item.source_url) || discordServerId(sourceUrl);
|
||||
if (guildId) return { type, available: true, title, description, source_url: sourceUrl, discord_guild_id: guildId, ...fallback };
|
||||
}
|
||||
if (["youtube_channel", "twitch_stream"].includes(type) && (embedUrl || sourceUrl)) {
|
||||
if (type === "twitch_stream" && item.availability_mode === "live_only" && item.live_now !== true) return null;
|
||||
return { type, available: Boolean(embedUrl), title, description, embed_url: embedUrl, source_url: sourceUrl, render_error: embedUrl ? "" : heroRenderError(type) };
|
||||
}
|
||||
@ -1790,14 +1864,17 @@ function normalizeHomepageHero(item, options = {}) {
|
||||
title,
|
||||
description,
|
||||
source_url: sourceUrl,
|
||||
...fallback,
|
||||
render_error: heroRenderError(type)
|
||||
};
|
||||
}
|
||||
|
||||
function youtubeVideoId(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (/^[A-Za-z0-9_-]{11}$/.test(raw)) return raw;
|
||||
try {
|
||||
const url = new URL(value || "");
|
||||
if (url.hostname.includes("youtu.be")) return url.pathname.slice(1);
|
||||
const url = new URL(raw);
|
||||
if (url.hostname.includes("youtu.be")) return url.pathname.split("/").filter(Boolean)[0] || "";
|
||||
const pathMatch = url.pathname.match(/\/(?:embed|shorts|live)\/([^/?#]+)/);
|
||||
return pathMatch?.[1] || url.searchParams.get("v") || "";
|
||||
} catch {
|
||||
@ -1805,6 +1882,38 @@ function youtubeVideoId(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function youtubeStartSeconds(value, fallbackSource = "") {
|
||||
const explicit = Number(value);
|
||||
if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
|
||||
try {
|
||||
const url = new URL(fallbackSource || "");
|
||||
const raw = url.searchParams.get("start") || url.searchParams.get("t") || "";
|
||||
if (/^\d+$/.test(raw)) return Number(raw);
|
||||
const match = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/i);
|
||||
if (!match) return 0;
|
||||
return ((Number(match[1]) || 0) * 3600) + ((Number(match[2]) || 0) * 60) + (Number(match[3]) || 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function youtubeVideoEmbedUrl(videoId, item = {}, sourceUrl = "") {
|
||||
const url = new URL(`https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}`);
|
||||
const autoplay = item.autoplay_mode && item.autoplay_mode !== "off";
|
||||
if (autoplay) {
|
||||
url.searchParams.set("autoplay", "1");
|
||||
if (item.autoplay_mode !== "sound") url.searchParams.set("mute", "1");
|
||||
}
|
||||
if (item.show_controls === false) url.searchParams.set("controls", "0");
|
||||
if (item.loop === true) {
|
||||
url.searchParams.set("loop", "1");
|
||||
url.searchParams.set("playlist", videoId);
|
||||
}
|
||||
const start = youtubeStartSeconds(item.start_seconds, sourceUrl || item.source_url);
|
||||
if (start > 0) url.searchParams.set("start", String(start));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function youtubeChannelId(value) {
|
||||
try {
|
||||
const url = new URL(value || "");
|
||||
@ -1841,19 +1950,21 @@ function discordServerId(value) {
|
||||
function deriveHomepageEmbedUrl(type, item, options = {}) {
|
||||
const sourceUrl = safeExternalUrl(item.source_url);
|
||||
const parentHost = options.parentHost || "localhost";
|
||||
const allowInsecure = Boolean(options.allowInsecureEmbeds);
|
||||
if (type === "custom_embed") {
|
||||
if (!sourceUrl) return "";
|
||||
const manual = safeEmbedUrl(item.embed_url, { allowInsecure });
|
||||
if (manual && !isDiscordUrl(manual)) return manual;
|
||||
const direct = safeEmbedUrl(item.source_url, { allowInsecure });
|
||||
if (!direct || isDiscordUrl(direct)) return "";
|
||||
const youtube = youtubeVideoId(sourceUrl);
|
||||
if (youtube) return `https://www.youtube-nocookie.com/embed/${youtube}`;
|
||||
const twitch = twitchChannelName(sourceUrl);
|
||||
if (twitch) return twitchEmbedUrl(twitch, parentHost);
|
||||
const discord = discordServerId(sourceUrl);
|
||||
if (discord) return `https://discord.com/widget?id=${encodeURIComponent(discord)}&theme=dark`;
|
||||
return "";
|
||||
return direct;
|
||||
}
|
||||
if (type === "youtube_video") {
|
||||
const video = item.video_id || youtubeVideoId(sourceUrl);
|
||||
return video ? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(video)}` : "";
|
||||
const video = item.video_id || youtubeVideoId(item.source_url) || youtubeVideoId(sourceUrl);
|
||||
return video ? youtubeVideoEmbedUrl(video, item, sourceUrl) : "";
|
||||
}
|
||||
if (type === "youtube_channel") {
|
||||
const channel = item.video_id || youtubeChannelId(sourceUrl);
|
||||
@ -1863,13 +1974,116 @@ function deriveHomepageEmbedUrl(type, item, options = {}) {
|
||||
const channel = item.video_id || twitchChannelName(sourceUrl);
|
||||
return channel ? twitchEmbedUrl(channel, parentHost) : "";
|
||||
}
|
||||
if (type === "discord_server_overview") {
|
||||
const server = item.video_id || discordServerId(item.source_url) || discordServerId(sourceUrl);
|
||||
return server ? `https://discord.com/widget?id=${encodeURIComponent(server)}&theme=dark` : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function homepageHeroFallback(item) {
|
||||
return {
|
||||
fallback_image_url: safeExternalUrl(item.fallback_image_url),
|
||||
fallback_url: safeExternalUrl(item.fallback_url) || safeExternalUrl(item.source_url),
|
||||
fallback_text: String(item.fallback_text || "").slice(0, 500),
|
||||
fallback_behavior: ["hide", "message", "link"].includes(item.fallback_behavior) ? item.fallback_behavior : "message"
|
||||
};
|
||||
}
|
||||
|
||||
function safeAspectRatio(value) {
|
||||
const raw = String(value || "").trim();
|
||||
return /^\d+(\.\d+)?\s*\/\s*\d+(\.\d+)?$/.test(raw) ? raw.replace(/\s+/g, "") : "";
|
||||
}
|
||||
|
||||
function safeEmbedHeight(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number) || number <= 0) return 0;
|
||||
return Math.min(1200, Math.max(180, Math.floor(number)));
|
||||
}
|
||||
|
||||
function customEmbedAllow(item) {
|
||||
const base = new Set(["fullscreen", "autoplay"]);
|
||||
String(item.iframe_allow || "")
|
||||
.split(";")
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.forEach((part) => {
|
||||
if (["fullscreen", "autoplay", "encrypted-media", "picture-in-picture"].includes(part)) {
|
||||
base.add(part);
|
||||
}
|
||||
if (item.allow_unsafe_permissions === true && ["camera", "microphone", "clipboard-write"].includes(part)) {
|
||||
base.add(part);
|
||||
}
|
||||
});
|
||||
return Array.from(base).join("; ");
|
||||
}
|
||||
|
||||
async function enrichDiscordHero(hero, item) {
|
||||
try {
|
||||
const widget = await fetchDiscordWidget(hero.discord_guild_id);
|
||||
return {
|
||||
...hero,
|
||||
discord_widget: {
|
||||
name: String(widget.name || hero.title || "Discord server").slice(0, 120),
|
||||
invite_url: safeExternalUrl(widget.instant_invite) || hero.fallback_url,
|
||||
presence_count: Number(widget.presence_count) || 0,
|
||||
channels: Array.isArray(widget.channels)
|
||||
? widget.channels.slice(0, 8).map((channel) => String(channel.name || "").slice(0, 80)).filter(Boolean)
|
||||
: [],
|
||||
members: Array.isArray(widget.members)
|
||||
? widget.members.slice(0, 8).map((member) => ({
|
||||
username: String(member.username || "").slice(0, 80),
|
||||
status: String(member.status || "").slice(0, 30),
|
||||
avatar_url: safeExternalUrl(member.avatar_url)
|
||||
})).filter((member) => member.username)
|
||||
: []
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
if (hero.fallback_behavior === "hide") return { ...hero, available: false };
|
||||
return {
|
||||
...hero,
|
||||
available: Boolean(hero.fallback_url || hero.fallback_text || hero.fallback_image_url),
|
||||
render_error: error?.message || "Discord Server Widget is unavailable. Enable the server widget in Discord or add a fallback link."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDiscordWidget(guildId) {
|
||||
const id = String(guildId || "").trim();
|
||||
if (!/^\d{10,30}$/.test(id)) {
|
||||
throw new Error("Add a valid Discord server ID.");
|
||||
}
|
||||
const now = Date.now();
|
||||
const cached = discordWidgetCache.get(id);
|
||||
if (cached && cached.expires > now) {
|
||||
if (cached.error) throw new Error(cached.error);
|
||||
return cached.data;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const response = await fetch(`https://discord.com/api/guilds/${encodeURIComponent(id)}/widget.json`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
const message = response.status === 403 || response.status === 404
|
||||
? "Discord Server Widget is disabled or unavailable for this server."
|
||||
: `Discord widget request failed with HTTP ${response.status}.`;
|
||||
discordWidgetCache.set(id, { expires: now + DISCORD_WIDGET_CACHE_MS, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await response.json();
|
||||
discordWidgetCache.set(id, { expires: now + DISCORD_WIDGET_CACHE_MS, data });
|
||||
return data;
|
||||
} catch (error) {
|
||||
const message = error?.name === "AbortError"
|
||||
? "Discord widget request timed out."
|
||||
: error?.message || "Discord widget could not be loaded.";
|
||||
discordWidgetCache.set(id, { expires: now + Math.min(DISCORD_WIDGET_CACHE_MS, 60 * 1000), error: message });
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function homepageEmbedParent(req) {
|
||||
const host = String(req?.hostname || req?.get?.("host") || "localhost")
|
||||
.split(":")[0]
|
||||
@ -1898,6 +2112,10 @@ function validateHomepageHeroEntries(entries) {
|
||||
const errors = [];
|
||||
entries.forEach((item, index) => {
|
||||
if (!item || item.enabled === false) return;
|
||||
if (item.type === "custom_embed" && !String(item.title || "").trim()) {
|
||||
errors.push(`hero ${index + 1}: Add a title for this external embed.`);
|
||||
return;
|
||||
}
|
||||
const hero = normalizeHomepageHero({ ...item, availability_mode: "always" }, { parentHost: "localhost" });
|
||||
if (hero?.available) return;
|
||||
if (item.type === "none" && item.fallback_behavior !== "hide") return;
|
||||
@ -2465,12 +2683,16 @@ function createWebServer({ loadPlugins, discordClient }) {
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
app.get("/", async (req, res, next) => {
|
||||
try {
|
||||
res.render("home", {
|
||||
title: "Home",
|
||||
homepageLinks: homepageLinksForUser(req.session.user),
|
||||
homepageHero: homepageHeroForUser(req.session.user, req)
|
||||
homepageHero: await homepageHeroForUser(req.session.user, req)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/assistant-panels", async (req, res) => {
|
||||
|
||||
@ -17,8 +17,51 @@
|
||||
</div>
|
||||
<% if (homepageHero.image_url) { %>
|
||||
<img class="homepage-hero-media" src="<%= homepageHero.image_url %>" alt="" loading="lazy" />
|
||||
<% } else if (homepageHero.discord_widget) { %>
|
||||
<div class="homepage-hero-media homepage-discord-card">
|
||||
<div class="homepage-discord-card-header">
|
||||
<span class="homepage-discord-icon" aria-hidden="true"><%= homepageHero.discord_widget.name.slice(0, 1).toUpperCase() %></span>
|
||||
<span>
|
||||
<strong><%= homepageHero.discord_widget.name %></strong>
|
||||
<small><%= homepageHero.discord_widget.presence_count %> online now</small>
|
||||
</span>
|
||||
</div>
|
||||
<% if ((homepageHero.discord_widget.channels || []).length) { %>
|
||||
<div>
|
||||
<span class="homepage-discord-label">Visible channels</span>
|
||||
<div class="homepage-discord-pills">
|
||||
<% homepageHero.discord_widget.channels.forEach((channel) => { %><span># <%= channel %></span><% }) %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if ((homepageHero.discord_widget.members || []).length) { %>
|
||||
<div>
|
||||
<span class="homepage-discord-label">Visible members</span>
|
||||
<div class="homepage-discord-members">
|
||||
<% homepageHero.discord_widget.members.forEach((member) => { %>
|
||||
<span>
|
||||
<% if (member.avatar_url) { %><img src="<%= member.avatar_url %>" alt="" loading="lazy" /><% } %>
|
||||
<strong><%= member.username %></strong>
|
||||
<% if (member.status) { %><small><%= member.status %></small><% } %>
|
||||
</span>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (homepageHero.discord_widget.invite_url) { %>
|
||||
<a class="button" href="<%= homepageHero.discord_widget.invite_url %>" target="_blank" rel="noopener noreferrer">Join Discord</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } else if (homepageHero.embed_url) { %>
|
||||
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
<% const heroFrameStyle = `${homepageHero.aspect_ratio ? `aspect-ratio:${homepageHero.aspect_ratio};` : ""}${homepageHero.embed_height ? `min-height:${homepageHero.embed_height}px;` : ""}`; %>
|
||||
<iframe class="homepage-hero-media" src="<%= homepageHero.embed_url %>" title="<%= homepageHero.title %>" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="<%= homepageHero.iframe_allow || "accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share" %>" <%- homepageHero.iframe_sandbox ? `sandbox="${homepageHero.iframe_sandbox}"` : "" %> <%- heroFrameStyle ? `style="${heroFrameStyle}"` : "" %> allowfullscreen></iframe>
|
||||
<% } else if (homepageHero.fallback_image_url || homepageHero.fallback_text || homepageHero.fallback_url) { %>
|
||||
<div class="homepage-hero-media homepage-hero-error" role="status">
|
||||
<% if (homepageHero.fallback_image_url) { %><img src="<%= homepageHero.fallback_image_url %>" alt="" loading="lazy" /><% } %>
|
||||
<strong>Featured content unavailable</strong>
|
||||
<span><%= homepageHero.fallback_text || homepageHero.render_error || "Open the fallback link instead." %></span>
|
||||
<% if (homepageHero.fallback_url) { %><a class="button subtle" href="<%= homepageHero.fallback_url %>" target="_blank" rel="noopener noreferrer">Open fallback</a><% } %>
|
||||
</div>
|
||||
<% } else if (homepageHero.render_error) { %>
|
||||
<div class="homepage-hero-media homepage-hero-error" role="status">
|
||||
<strong>Hero unavailable</strong>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user