1157 lines
34 KiB
HTML
1157 lines
34 KiB
HTML
<!DOCTYPE html>
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<html lang="en" dir="ltr">
|
||
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>SLIDES</title>
|
||
|
||
<link rel="stylesheet" href="./css/client.css" />
|
||
|
||
<script>
|
||
(function () {
|
||
const savedTheme = localStorage.getItem('theme');
|
||
if (savedTheme === 'dark') {
|
||
document.documentElement.classList.add('dark-mode');
|
||
if (document.body) document.body.classList.add('dark-mode');
|
||
}
|
||
})();
|
||
</script>
|
||
|
||
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
||
<script src="./js/vendor/svg.min.js"></script>
|
||
|
||
<style>
|
||
#divBody {
|
||
min-height: calc(100vh - max(var(--header-height), var(--header-min-height)) - max(var(--footer-height), var(--footer-min-height)));
|
||
height: auto !important;
|
||
display: block;
|
||
overflow-y: auto;
|
||
padding: 12px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
#divSlideStatus {
|
||
margin: 0 0 12px 0;
|
||
color: var(--primary-color);
|
||
opacity: 0.9;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
#divSlideGrid {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.slideColumn {
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
display: block;
|
||
}
|
||
|
||
.slideTile {
|
||
display: block;
|
||
width: 100%;
|
||
margin: 0 0 12px 0;
|
||
padding: 0;
|
||
appearance: none;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--border-radius);
|
||
background: var(--secondary-color);
|
||
cursor: pointer;
|
||
filter: grayscale(var(--image-grayscale));
|
||
transition: filter 0.18s ease, transform 0.18s ease;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.slideTile:hover {
|
||
filter: grayscale(var(--image-grayscale-hover));
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.slideTile img {
|
||
display: block;
|
||
width: 100%;
|
||
height: auto;
|
||
object-fit: cover;
|
||
background: var(--secondary-color);
|
||
}
|
||
|
||
#divSlideEmpty {
|
||
display: none;
|
||
padding: 24px 12px;
|
||
text-align: center;
|
||
color: var(--primary-color);
|
||
opacity: 0.8;
|
||
}
|
||
|
||
#divLightbox {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 25;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.92);
|
||
}
|
||
|
||
#divLightbox.isOpen {
|
||
display: flex;
|
||
}
|
||
|
||
#imgLightboxMain {
|
||
max-width: 92vw;
|
||
max-height: 86vh;
|
||
width: auto;
|
||
height: auto;
|
||
border-radius: 8px;
|
||
box-shadow: 0 8px 34px rgba(0, 0, 0, 0.35);
|
||
background: #111;
|
||
}
|
||
|
||
.lightboxBtn {
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 46px;
|
||
height: 46px;
|
||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
color: #fff;
|
||
font-size: 26px;
|
||
line-height: 40px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
#btnLightboxPrev {
|
||
left: 18px;
|
||
}
|
||
|
||
#btnLightboxNext {
|
||
right: 18px;
|
||
}
|
||
|
||
#btnLightboxClose {
|
||
position: absolute;
|
||
top: 16px;
|
||
right: 16px;
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
color: #fff;
|
||
font-size: 22px;
|
||
line-height: 34px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
#divLightboxMeta {
|
||
position: absolute;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
bottom: 14px;
|
||
color: #fff;
|
||
opacity: 0.85;
|
||
font-size: 0.9rem;
|
||
letter-spacing: 0.01em;
|
||
}
|
||
|
||
#divSlideControlsFooter {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
width: 100%;
|
||
white-space: nowrap;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
|
||
.slideControlWord {
|
||
color: var(--primary-color);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
font-size: 80%;
|
||
line-height: 1;
|
||
transition: color 0.2s, opacity 0.2s;
|
||
}
|
||
|
||
.slideControlWord:hover {
|
||
color: var(--button-hover-color);
|
||
}
|
||
|
||
.slideControlWord.is-active {
|
||
color: var(--button-hover-color);
|
||
}
|
||
|
||
#divSlideMoreWrap {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 8px 0 14px;
|
||
}
|
||
|
||
#btnSlideMore {
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--border-radius);
|
||
background: var(--secondary-color);
|
||
color: var(--primary-color);
|
||
padding: 6px 16px;
|
||
font-size: 0.9rem;
|
||
cursor: pointer;
|
||
filter: grayscale(var(--image-grayscale));
|
||
}
|
||
|
||
#btnSlideMore:hover {
|
||
color: var(--button-hover-color);
|
||
filter: grayscale(var(--image-grayscale-hover));
|
||
}
|
||
|
||
|
||
@media (max-width: 640px) {
|
||
#divSlideGrid {
|
||
gap: 8px;
|
||
}
|
||
|
||
.slideTile {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.lightboxBtn {
|
||
width: 40px;
|
||
height: 40px;
|
||
font-size: 22px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div id="divSvgHam" class="divHeaderButtons"></div>
|
||
|
||
<div id="divHeader">
|
||
<div id="divHeaderFlexLeft"></div>
|
||
<div id="divHeaderFlexCenter">
|
||
|
||
</div>
|
||
<div id="divHeaderFlexRight"></div>
|
||
</div>
|
||
|
||
<div id="divBody">
|
||
<div id="divSlideGrid"></div>
|
||
<div id="divSlideEmpty">No kind 1 posts with direct image URLs were found.</div>
|
||
<div id="divSlideMoreWrap">
|
||
<button id="btnSlideMore" type="button">more</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="divFooter">
|
||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||
<div id="divFooterCenter" class="divFooterBox">
|
||
<div id="divSlideControlsFooter">
|
||
<span id="slideCtlSlideshow" class="slideControlWord" data-action="slideshow">slideshow</span>
|
||
<span id="slideCtlSlideshowFull" class="slideControlWord" data-action="slideshow-full">slideshow-full</span>
|
||
<span id="slideCtlFull" class="slideControlWord" data-action="full">full</span>
|
||
</div>
|
||
</div>
|
||
<div id="divFooterRight" class="divFooterBox"></div>
|
||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||
</div>
|
||
|
||
<div id="divSideNav">
|
||
<div id="divSideNavHeader"></div>
|
||
|
||
<div id="divSideNavBody">
|
||
<table>
|
||
<tbody>
|
||
<tr>
|
||
<td><label for="inputSlideSeconds">slideshow seconds</label></td>
|
||
<td><input id="inputSlideSeconds" type="number" min="1" max="30" step="1" value="2" /></td>
|
||
</tr>
|
||
<tr>
|
||
<td><label for="selectSlideMode">mode</label></td>
|
||
<td>
|
||
<select id="selectSlideMode">
|
||
<option value="in-order">In order</option>
|
||
<option value="random">Random</option>
|
||
<option value="latest">Last published</option>
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div id="divFiles"></div>
|
||
</div>
|
||
|
||
<div id="divAiSection" class="sidenavSection">
|
||
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
||
<div id="divAiList" class="sidenavSectionList">
|
||
<div id="divAiProvidersList">No saved providers yet.</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<div id="divRelaySection">
|
||
<div id="divRelaySectionTitle">リレー</div>
|
||
<div id="divRelayList">Loading relays...</div>
|
||
</div>
|
||
|
||
<div id="divBlossomSection">
|
||
<div id="divBlossomSectionTitle">ブロッサム</div>
|
||
<div id="divBlossomList">Loading blossom servers...</div>
|
||
</div>
|
||
|
||
<div id="divVersionBar">
|
||
<span id="versionDisplay">v0.0.1</span>
|
||
<div id="divVersionBarButtons">
|
||
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
||
<div id="themeToggleHamburgerContainer"></div>
|
||
</button>
|
||
<button id="logoutButton" title="Logout">
|
||
<div id="logoutHamburgerContainer"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="divLightbox" aria-hidden="true">
|
||
<button id="btnLightboxClose" aria-label="Close">×</button>
|
||
<button id="btnLightboxPrev" class="lightboxBtn" aria-label="Previous">‹</button>
|
||
<img id="imgLightboxMain" alt="Slide image" />
|
||
<button id="btnLightboxNext" class="lightboxBtn" aria-label="Next">›</button>
|
||
<div id="divLightboxMeta"></div>
|
||
</div>
|
||
|
||
<script src="./nostr.bundle.js"></script>
|
||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||
|
||
<script type="module">
|
||
import {
|
||
initNDKPage,
|
||
getPubkey,
|
||
injectHeaderAvatar,
|
||
subscribe,
|
||
disconnect,
|
||
getVersion,
|
||
updateVersionDisplay
|
||
} from './js/init-ndk.mjs';
|
||
|
||
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||
import {
|
||
initFooterRelayStatus,
|
||
updateFooterRelayStatus,
|
||
initSidenavRelaySection,
|
||
updateSidenavRelaySection,
|
||
setRelayActivityState
|
||
} from './js/relay-ui.mjs';
|
||
|
||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||
|
||
|
||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||
const versionInfo = await getVersion();
|
||
const VERSION = versionInfo.VERSION;
|
||
console.log(`[slide-show.html ${VERSION}] Loading...`);
|
||
|
||
const DIRECT_IMAGE_URL_REGEX = /\bhttps?:\/\/[^\s<>"')]+\.(?:png|jpe?g|gif|webp|avif|bmp|svg)(?:\?[^\s<>"')]*)?/ig;
|
||
|
||
let updateIntervalId = null;
|
||
let currentPubkey = null;
|
||
let targetPubkey = null;
|
||
|
||
// Auth behavior modes for this page:
|
||
// - required: no target in URL => authenticate immediately.
|
||
// - none: target in URL (?npub / ?pubkey) => do not prompt auth on load.
|
||
let isAuthenticated = false;
|
||
let authMode = 'required';
|
||
|
||
let hamburgerInstance = null;
|
||
let isNavOpen = false;
|
||
let logoutHamburger = null;
|
||
let themeToggleHamburger = null;
|
||
let isDarkMode = false;
|
||
|
||
let notesSubscription = null;
|
||
let initialLoadSettled = false;
|
||
let initialLoadTimer = null;
|
||
|
||
const noteMap = new Map();
|
||
let imagePosts = [];
|
||
let currentLightboxIndex = -1;
|
||
let publicPool = null;
|
||
let publicSub = null;
|
||
let slideshowIntervalId = null;
|
||
let slideshowIntervalMs = 2200;
|
||
|
||
const SLIDESHOW_SECONDS_STORAGE_KEY = 'slideShowSeconds';
|
||
const SLIDESHOW_MODE_STORAGE_KEY = 'slideShowMode';
|
||
const INITIAL_VISIBLE_IMAGE_COUNT = 200;
|
||
const MORE_IMAGES_CHUNK = 200;
|
||
let visibleImageCount = INITIAL_VISIBLE_IMAGE_COUNT;
|
||
|
||
const PUBLIC_READ_RELAYS = [
|
||
'wss://relay.damus.io',
|
||
'wss://relay.primal.net',
|
||
'wss://nos.lol',
|
||
'wss://relay.nostr.band',
|
||
'wss://purplepag.es'
|
||
];
|
||
|
||
const divBody = document.getElementById('divBody');
|
||
const divSideNav = document.getElementById('divSideNav');
|
||
const divFooterCenter = document.getElementById('divFooterCenter');
|
||
const divFooterRight = document.getElementById('divFooterRight');
|
||
const divSlideGrid = document.getElementById('divSlideGrid');
|
||
const divSlideEmpty = document.getElementById('divSlideEmpty');
|
||
|
||
const divLightbox = document.getElementById('divLightbox');
|
||
const imgLightboxMain = document.getElementById('imgLightboxMain');
|
||
const divLightboxMeta = document.getElementById('divLightboxMeta');
|
||
const btnLightboxClose = document.getElementById('btnLightboxClose');
|
||
const btnLightboxPrev = document.getElementById('btnLightboxPrev');
|
||
const btnLightboxNext = document.getElementById('btnLightboxNext');
|
||
const divSlideControlsFooter = document.getElementById('divSlideControlsFooter');
|
||
const slideCtlSlideshow = document.getElementById('slideCtlSlideshow');
|
||
const slideCtlSlideshowFull = document.getElementById('slideCtlSlideshowFull');
|
||
const slideCtlFull = document.getElementById('slideCtlFull');
|
||
const btnSlideMore = document.getElementById('btnSlideMore');
|
||
const inputSlideSeconds = document.getElementById('inputSlideSeconds');
|
||
const selectSlideMode = document.getElementById('selectSlideMode');
|
||
|
||
let slideshowMode = 'in-order';
|
||
|
||
function initHamburgerMenu() {
|
||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||
foreground: 'var(--primary-color)',
|
||
background: 'var(--secondary-color)',
|
||
hover: 'var(--accent-color)'
|
||
});
|
||
hamburgerInstance.animateTo('burger');
|
||
}
|
||
|
||
function openNav() {
|
||
divSideNav.style.zIndex = 3;
|
||
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
|
||
isNavOpen = true;
|
||
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
|
||
|
||
if (!logoutHamburger) {
|
||
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
||
size: 24,
|
||
foreground: 'var(--primary-color)',
|
||
background: 'var(--secondary-color)',
|
||
hover: 'var(--accent-color)'
|
||
});
|
||
logoutHamburger.animateTo('x');
|
||
}
|
||
|
||
if (!themeToggleHamburger) {
|
||
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
||
size: 24,
|
||
foreground: 'var(--primary-color)',
|
||
background: 'var(--secondary-color)',
|
||
hover: 'var(--accent-color)'
|
||
});
|
||
const savedTheme = localStorage.getItem('theme');
|
||
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
||
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
||
}
|
||
}
|
||
|
||
function closeNav() {
|
||
divSideNav.style.width = '0vw';
|
||
divSideNav.style.zIndex = -1;
|
||
isNavOpen = false;
|
||
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
|
||
}
|
||
|
||
function toggleNav() {
|
||
isNavOpen ? closeNav() : openNav();
|
||
}
|
||
|
||
const UpdateFooter = async () => {
|
||
try {
|
||
if (isAuthenticated) {
|
||
await updateFooterRelayStatus();
|
||
await updateSidenavRelaySection();
|
||
await updateBlossomSection();
|
||
}
|
||
divFooterRight.textContent = `${imagePosts.length} photos`;
|
||
} catch (error) {
|
||
console.error('[slide-show.html] Error updating footer:', error);
|
||
}
|
||
};
|
||
|
||
const Logout = async () => {
|
||
if (updateIntervalId) {
|
||
clearInterval(updateIntervalId);
|
||
updateIntervalId = null;
|
||
}
|
||
disconnect();
|
||
if (window.NOSTR_LOGIN_LITE?.logout) {
|
||
await window.NOSTR_LOGIN_LITE.logout();
|
||
}
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
location.reload(true);
|
||
};
|
||
|
||
function isHexPubkey(value) {
|
||
return /^[a-fA-F0-9]{64}$/.test(String(value || '').trim());
|
||
}
|
||
|
||
function parseTargetPubkeyFromUrl(fallbackPubkey) {
|
||
const params = new URLSearchParams(window.location.search || '');
|
||
const npubParam = String(params.get('npub') || '').trim();
|
||
const hexParam = String(params.get('pubkey') || '').trim();
|
||
|
||
if (isHexPubkey(hexParam)) {
|
||
return hexParam.toLowerCase();
|
||
}
|
||
|
||
if (npubParam) {
|
||
if (isHexPubkey(npubParam)) {
|
||
return npubParam.toLowerCase();
|
||
}
|
||
|
||
try {
|
||
const decoded = window?.NostrTools?.nip19?.decode(npubParam);
|
||
if (decoded?.type === 'npub' && isHexPubkey(decoded?.data)) {
|
||
return String(decoded.data).toLowerCase();
|
||
}
|
||
} catch (_error) {
|
||
// fall through to fallback
|
||
}
|
||
}
|
||
|
||
return fallbackPubkey || null;
|
||
}
|
||
|
||
function resolveAuthModeFromUrl() {
|
||
const params = new URLSearchParams(window.location.search || '');
|
||
const hasTarget = Boolean(String(params.get('npub') || '').trim() || String(params.get('pubkey') || '').trim());
|
||
return hasTarget ? 'none' : 'required';
|
||
}
|
||
|
||
function encodeNpub(pubkeyHex) {
|
||
try {
|
||
if (!isHexPubkey(pubkeyHex)) return null;
|
||
return window?.NostrTools?.nip19?.npubEncode
|
||
? window.NostrTools.nip19.npubEncode(pubkeyHex)
|
||
: null;
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function populateOwnNpubInUrlIfNeeded() {
|
||
if (!isHexPubkey(currentPubkey) || targetPubkey !== currentPubkey) return;
|
||
|
||
const params = new URLSearchParams(window.location.search || '');
|
||
const hasTargetParam = Boolean(String(params.get('npub') || '').trim() || String(params.get('pubkey') || '').trim());
|
||
if (hasTargetParam) return;
|
||
|
||
const npub = encodeNpub(currentPubkey);
|
||
if (!npub) return;
|
||
|
||
params.set('npub', npub);
|
||
const query = params.toString();
|
||
const nextUrl = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash || ''}`;
|
||
window.history.replaceState(null, '', nextUrl);
|
||
}
|
||
|
||
async function initializeAuthentication(mode) {
|
||
if (mode === 'required') {
|
||
await initNDKPage();
|
||
currentPubkey = await getPubkey();
|
||
isAuthenticated = true;
|
||
return;
|
||
}
|
||
|
||
// mode === 'none': do not request login on page load.
|
||
isAuthenticated = false;
|
||
currentPubkey = null;
|
||
}
|
||
|
||
async function promptLoginIfNeeded() {
|
||
if (isAuthenticated) return true;
|
||
await initNDKPage();
|
||
currentPubkey = await getPubkey();
|
||
isAuthenticated = true;
|
||
return true;
|
||
}
|
||
|
||
function extractFirstDirectImageUrl(content) {
|
||
const text = String(content || '');
|
||
if (!text) return null;
|
||
DIRECT_IMAGE_URL_REGEX.lastIndex = 0;
|
||
const match = DIRECT_IMAGE_URL_REGEX.exec(text);
|
||
return match ? match[0] : null;
|
||
}
|
||
|
||
function rebuildImagePosts() {
|
||
imagePosts = Array.from(noteMap.values())
|
||
.filter((evt) => evt && evt.imageUrl)
|
||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||
}
|
||
|
||
function setLoadingState(_isLoading) {}
|
||
|
||
function getVisibleImagePosts() {
|
||
const visibleCount = Math.max(0, Math.min(visibleImageCount, imagePosts.length));
|
||
return imagePosts.slice(0, visibleCount);
|
||
}
|
||
|
||
function updateMoreButton() {
|
||
if (!btnSlideMore) return;
|
||
const visibleCount = Math.min(visibleImageCount, imagePosts.length);
|
||
const remaining = Math.max(0, imagePosts.length - visibleCount);
|
||
if (remaining <= 0) {
|
||
btnSlideMore.style.display = 'none';
|
||
return;
|
||
}
|
||
btnSlideMore.style.display = 'inline-block';
|
||
btnSlideMore.textContent = `more (${remaining})`;
|
||
}
|
||
|
||
function sanitizeSlideMode(value) {
|
||
const mode = String(value || '').trim().toLowerCase();
|
||
if (mode === 'random' || mode === 'latest' || mode === 'in-order') return mode;
|
||
return 'in-order';
|
||
}
|
||
|
||
function applySlideMode(mode) {
|
||
slideshowMode = sanitizeSlideMode(mode);
|
||
if (selectSlideMode) selectSlideMode.value = slideshowMode;
|
||
localStorage.setItem(SLIDESHOW_MODE_STORAGE_KEY, slideshowMode);
|
||
if (slideshowMode === 'latest') {
|
||
stopSlideshowAutoplay();
|
||
}
|
||
}
|
||
|
||
function initializeSlideModeSetting() {
|
||
const storedMode = localStorage.getItem(SLIDESHOW_MODE_STORAGE_KEY);
|
||
applySlideMode(storedMode || 'in-order');
|
||
}
|
||
|
||
function sanitizeSlideshowSeconds(value) {
|
||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
||
if (!Number.isFinite(parsed)) return 2;
|
||
return Math.max(1, Math.min(30, parsed));
|
||
}
|
||
|
||
function applySlideshowSeconds(seconds) {
|
||
const safeSeconds = sanitizeSlideshowSeconds(seconds);
|
||
slideshowIntervalMs = safeSeconds * 1000;
|
||
if (inputSlideSeconds) inputSlideSeconds.value = String(safeSeconds);
|
||
localStorage.setItem(SLIDESHOW_SECONDS_STORAGE_KEY, String(safeSeconds));
|
||
if (slideshowIntervalId) startSlideshowAutoplay();
|
||
}
|
||
|
||
function initializeSlideshowSecondsSetting() {
|
||
const stored = localStorage.getItem(SLIDESHOW_SECONDS_STORAGE_KEY);
|
||
const initialSeconds = sanitizeSlideshowSeconds(stored ?? 2);
|
||
applySlideshowSeconds(initialSeconds);
|
||
}
|
||
|
||
function getSlideColumnCount() {
|
||
const width = divSlideGrid?.clientWidth || window.innerWidth || 1024;
|
||
const minTileWidth = window.innerWidth <= 640 ? 160 : 240;
|
||
return Math.max(1, Math.floor(width / minTileWidth));
|
||
}
|
||
|
||
function renderGrid() {
|
||
divSlideGrid.innerHTML = '';
|
||
if (imagePosts.length === 0) {
|
||
divSlideEmpty.style.display = 'block';
|
||
updateMoreButton();
|
||
return;
|
||
}
|
||
|
||
divSlideEmpty.style.display = 'none';
|
||
|
||
const visiblePosts = getVisibleImagePosts();
|
||
const visibleTotal = visiblePosts.length;
|
||
const columnCount = getSlideColumnCount();
|
||
const columns = [];
|
||
for (let i = 0; i < columnCount; i += 1) {
|
||
const col = document.createElement('div');
|
||
col.className = 'slideColumn';
|
||
columns.push(col);
|
||
divSlideGrid.appendChild(col);
|
||
}
|
||
|
||
// Row-major visual ordering:
|
||
// 0->col0, 1->col1, ... n->col(n%columnCount)
|
||
visiblePosts.forEach((post, index) => {
|
||
const tile = document.createElement('button');
|
||
tile.className = 'slideTile';
|
||
tile.type = 'button';
|
||
tile.dataset.index = String(index);
|
||
tile.title = `Open image ${index + 1} of ${visibleTotal}`;
|
||
|
||
const img = document.createElement('img');
|
||
img.src = post.imageUrl;
|
||
img.alt = 'Post image';
|
||
img.loading = 'lazy';
|
||
|
||
tile.appendChild(img);
|
||
tile.addEventListener('click', () => openLightbox(index));
|
||
|
||
const targetColumn = columns[index % columnCount];
|
||
targetColumn.appendChild(tile);
|
||
});
|
||
|
||
updateMoreButton();
|
||
}
|
||
|
||
function updateLightboxMeta() {
|
||
const visibleTotal = getVisibleImagePosts().length;
|
||
if (currentLightboxIndex < 0 || currentLightboxIndex >= visibleTotal) {
|
||
divLightboxMeta.textContent = '';
|
||
return;
|
||
}
|
||
divLightboxMeta.textContent = `${currentLightboxIndex + 1} / ${visibleTotal}`;
|
||
}
|
||
|
||
function renderLightboxImage() {
|
||
const visiblePosts = getVisibleImagePosts();
|
||
if (currentLightboxIndex < 0 || currentLightboxIndex >= visiblePosts.length) return;
|
||
const post = visiblePosts[currentLightboxIndex];
|
||
imgLightboxMain.src = post.imageUrl;
|
||
updateLightboxMeta();
|
||
}
|
||
|
||
function openLightbox(index) {
|
||
const visibleTotal = getVisibleImagePosts().length;
|
||
if (visibleTotal === 0) return;
|
||
currentLightboxIndex = Math.max(0, Math.min(index, visibleTotal - 1));
|
||
renderLightboxImage();
|
||
divLightbox.classList.add('isOpen');
|
||
divLightbox.setAttribute('aria-hidden', 'false');
|
||
}
|
||
|
||
function closeLightbox() {
|
||
stopSlideshowAutoplay();
|
||
currentLightboxIndex = -1;
|
||
divLightbox.classList.remove('isOpen');
|
||
divLightbox.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
function showPrevLightbox() {
|
||
const visibleTotal = getVisibleImagePosts().length;
|
||
if (visibleTotal === 0) return;
|
||
if (currentLightboxIndex < 0) return;
|
||
currentLightboxIndex = (currentLightboxIndex - 1 + visibleTotal) % visibleTotal;
|
||
renderLightboxImage();
|
||
}
|
||
|
||
function showNextLightbox() {
|
||
const visibleTotal = getVisibleImagePosts().length;
|
||
if (visibleTotal === 0) return;
|
||
if (currentLightboxIndex < 0) return;
|
||
|
||
if (slideshowMode === 'random') {
|
||
currentLightboxIndex = getRandomNextIndex(currentLightboxIndex, visibleTotal);
|
||
} else {
|
||
currentLightboxIndex = (currentLightboxIndex + 1) % visibleTotal;
|
||
}
|
||
|
||
renderLightboxImage();
|
||
}
|
||
|
||
function syncSlideControlsState() {
|
||
if (slideCtlSlideshow) {
|
||
slideCtlSlideshow.classList.toggle('is-active', Boolean(slideshowIntervalId));
|
||
}
|
||
if (slideCtlSlideshowFull) {
|
||
slideCtlSlideshowFull.classList.toggle('is-active', Boolean(slideshowIntervalId));
|
||
}
|
||
}
|
||
|
||
function stopSlideshowAutoplay() {
|
||
if (slideshowIntervalId) {
|
||
clearInterval(slideshowIntervalId);
|
||
slideshowIntervalId = null;
|
||
}
|
||
syncSlideControlsState();
|
||
}
|
||
|
||
function getRandomNextIndex(currentIndex, total) {
|
||
if (total <= 1) return 0;
|
||
let next = currentIndex;
|
||
while (next === currentIndex) {
|
||
next = Math.floor(Math.random() * total);
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function showLatestPublishedImageIfEnabled() {
|
||
if (slideshowMode !== 'latest') return;
|
||
const visibleTotal = getVisibleImagePosts().length;
|
||
if (visibleTotal === 0) return;
|
||
openLightbox(0);
|
||
}
|
||
|
||
function startSlideshowAutoplay() {
|
||
if (slideshowMode === 'latest') return;
|
||
if (getVisibleImagePosts().length === 0) return;
|
||
if (!divLightbox.classList.contains('isOpen')) {
|
||
openLightbox(Math.max(0, currentLightboxIndex));
|
||
}
|
||
stopSlideshowAutoplay();
|
||
slideshowIntervalId = setInterval(() => {
|
||
showNextLightbox();
|
||
}, slideshowIntervalMs);
|
||
syncSlideControlsState();
|
||
}
|
||
|
||
function toggleSlideshowAutoplay() {
|
||
if (slideshowIntervalId) {
|
||
stopSlideshowAutoplay();
|
||
} else {
|
||
startSlideshowAutoplay();
|
||
}
|
||
}
|
||
|
||
async function ensurePageFullscreen() {
|
||
try {
|
||
if (!document.fullscreenElement) {
|
||
await divBody.requestFullscreen();
|
||
}
|
||
} catch (error) {
|
||
console.warn('[slide-show.html] Fullscreen enable failed:', error?.message || error);
|
||
}
|
||
}
|
||
|
||
async function ensureSlideshowFullscreen() {
|
||
try {
|
||
if (document.fullscreenElement === divLightbox) return;
|
||
if (document.fullscreenElement && document.fullscreenElement !== divLightbox) {
|
||
await document.exitFullscreen();
|
||
}
|
||
if (!document.fullscreenElement) {
|
||
await divLightbox.requestFullscreen();
|
||
}
|
||
} catch (error) {
|
||
console.warn('[slide-show.html] Slideshow fullscreen enable failed:', error?.message || error);
|
||
}
|
||
}
|
||
|
||
async function startSlideshowFullscreen() {
|
||
if (!divLightbox.classList.contains('isOpen')) {
|
||
openLightbox(Math.max(0, currentLightboxIndex));
|
||
}
|
||
await ensureSlideshowFullscreen();
|
||
startSlideshowAutoplay();
|
||
}
|
||
|
||
async function togglePageFullscreen() {
|
||
try {
|
||
if (document.fullscreenElement === divBody) {
|
||
await document.exitFullscreen();
|
||
} else if (!document.fullscreenElement) {
|
||
await divBody.requestFullscreen();
|
||
}
|
||
} catch (error) {
|
||
console.warn('[slide-show.html] Fullscreen toggle failed:', error?.message || error);
|
||
}
|
||
}
|
||
|
||
function onWindowKeydown(event) {
|
||
if (divLightbox.classList.contains('isOpen')) {
|
||
if (event.key === 'Escape') {
|
||
closeLightbox();
|
||
} else if (event.key === 'ArrowLeft') {
|
||
showPrevLightbox();
|
||
} else if (event.key === 'ArrowRight') {
|
||
showNextLightbox();
|
||
}
|
||
}
|
||
}
|
||
|
||
function upsertNoteIfImage(evt) {
|
||
if (!evt?.id || evt.kind !== 1 || evt.pubkey !== targetPubkey) return false;
|
||
const imageUrl = extractFirstDirectImageUrl(evt.content);
|
||
if (!imageUrl) return false;
|
||
|
||
const prev = noteMap.get(evt.id);
|
||
if (prev && (prev.created_at || 0) >= (evt.created_at || 0)) return false;
|
||
|
||
noteMap.set(evt.id, {
|
||
id: evt.id,
|
||
pubkey: evt.pubkey,
|
||
created_at: evt.created_at || 0,
|
||
content: evt.content || '',
|
||
imageUrl
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
function settleInitialLoad() {
|
||
if (initialLoadSettled) return;
|
||
initialLoadSettled = true;
|
||
if (initialLoadTimer) {
|
||
clearTimeout(initialLoadTimer);
|
||
initialLoadTimer = null;
|
||
}
|
||
setLoadingState(false);
|
||
renderGrid();
|
||
}
|
||
|
||
function handleNdkEvent(event) {
|
||
const evt = event?.detail;
|
||
if (!evt) return;
|
||
|
||
if (evt.kind === 1) {
|
||
const changed = upsertNoteIfImage(evt);
|
||
if (changed) {
|
||
rebuildImagePosts();
|
||
renderGrid();
|
||
setLoadingState(false);
|
||
if (initialLoadSettled) showLatestPublishedImageIfEnabled();
|
||
}
|
||
}
|
||
}
|
||
|
||
function startKind1Subscription() {
|
||
notesSubscription = subscribe(
|
||
{
|
||
kinds: [1],
|
||
authors: [targetPubkey],
|
||
limit: 2000
|
||
},
|
||
{
|
||
closeOnEose: false,
|
||
cacheUsage: 'CACHE_FIRST'
|
||
}
|
||
);
|
||
|
||
initialLoadTimer = setTimeout(() => {
|
||
settleInitialLoad();
|
||
}, 1800);
|
||
}
|
||
|
||
function startPublicKind1Subscription() {
|
||
if (!targetPubkey) return;
|
||
if (!window?.NostrTools?.SimplePool) {
|
||
console.warn('[slide-show.html] SimplePool unavailable for public mode');
|
||
settleInitialLoad();
|
||
return;
|
||
}
|
||
|
||
publicPool = new window.NostrTools.SimplePool();
|
||
publicSub = publicPool.subscribeMany(
|
||
PUBLIC_READ_RELAYS,
|
||
[{ kinds: [1], authors: [targetPubkey], limit: 2000 }],
|
||
{
|
||
onevent: (evt) => {
|
||
const changed = upsertNoteIfImage(evt);
|
||
if (changed) {
|
||
rebuildImagePosts();
|
||
renderGrid();
|
||
if (initialLoadSettled) showLatestPublishedImageIfEnabled();
|
||
}
|
||
},
|
||
oneose: () => {
|
||
settleInitialLoad();
|
||
}
|
||
}
|
||
);
|
||
|
||
initialLoadTimer = setTimeout(() => {
|
||
settleInitialLoad();
|
||
}, 2200);
|
||
}
|
||
|
||
function cleanup() {
|
||
stopSlideshowAutoplay();
|
||
|
||
if (notesSubscription?.unsubscribe) {
|
||
notesSubscription.unsubscribe();
|
||
notesSubscription = null;
|
||
}
|
||
|
||
if (publicSub?.close) {
|
||
publicSub.close();
|
||
publicSub = null;
|
||
}
|
||
|
||
if (publicPool?.close) {
|
||
publicPool.close(PUBLIC_READ_RELAYS);
|
||
publicPool = null;
|
||
}
|
||
|
||
window.removeEventListener('keydown', onWindowKeydown);
|
||
window.removeEventListener('ndkEvent', handleNdkEvent);
|
||
window.removeEventListener('resize', renderGrid);
|
||
|
||
if (updateIntervalId) {
|
||
clearInterval(updateIntervalId);
|
||
updateIntervalId = null;
|
||
}
|
||
|
||
if (initialLoadTimer) {
|
||
clearTimeout(initialLoadTimer);
|
||
initialLoadTimer = null;
|
||
}
|
||
}
|
||
|
||
(async function main() {
|
||
try {
|
||
initHamburgerMenu();
|
||
|
||
const divSvgHam = document.getElementById('divSvgHam');
|
||
if (divSvgHam) divSvgHam.addEventListener('click', toggleNav);
|
||
|
||
const themeToggleButton = document.getElementById('themeToggleButton');
|
||
const logoutButton = document.getElementById('logoutButton');
|
||
|
||
if (themeToggleButton) {
|
||
themeToggleButton.addEventListener('click', () => {
|
||
isDarkMode = !isDarkMode;
|
||
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
||
document.documentElement.classList.toggle('dark-mode', isDarkMode);
|
||
document.body.classList.toggle('dark-mode', isDarkMode);
|
||
if (themeToggleHamburger) {
|
||
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
||
}
|
||
});
|
||
}
|
||
|
||
if (logoutButton) {
|
||
logoutButton.addEventListener('click', async () => {
|
||
if (!isAuthenticated) {
|
||
await promptLoginIfNeeded();
|
||
window.location.reload();
|
||
return;
|
||
}
|
||
await Logout();
|
||
});
|
||
}
|
||
|
||
btnLightboxClose.addEventListener('click', closeLightbox);
|
||
btnLightboxPrev.addEventListener('click', showPrevLightbox);
|
||
btnLightboxNext.addEventListener('click', showNextLightbox);
|
||
divLightbox.addEventListener('click', (event) => {
|
||
if (event.target === divLightbox) closeLightbox();
|
||
});
|
||
|
||
window.addEventListener('keydown', onWindowKeydown);
|
||
window.addEventListener('ndkEvent', handleNdkEvent);
|
||
window.addEventListener('resize', renderGrid);
|
||
window.addEventListener('beforeunload', cleanup, { once: true });
|
||
|
||
if (divSlideControlsFooter) {
|
||
divSlideControlsFooter.addEventListener('click', async (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLElement)) return;
|
||
const action = target.getAttribute('data-action');
|
||
if (!action) return;
|
||
|
||
if (action === 'slideshow') {
|
||
toggleSlideshowAutoplay();
|
||
return;
|
||
}
|
||
|
||
if (action === 'slideshow-full') {
|
||
await startSlideshowFullscreen();
|
||
return;
|
||
}
|
||
|
||
if (action === 'full') {
|
||
await togglePageFullscreen();
|
||
}
|
||
});
|
||
syncSlideControlsState();
|
||
}
|
||
|
||
if (btnSlideMore) {
|
||
btnSlideMore.addEventListener('click', () => {
|
||
visibleImageCount += MORE_IMAGES_CHUNK;
|
||
renderGrid();
|
||
});
|
||
}
|
||
|
||
if (inputSlideSeconds) {
|
||
inputSlideSeconds.addEventListener('change', (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLInputElement)) return;
|
||
applySlideshowSeconds(target.value);
|
||
});
|
||
}
|
||
|
||
if (selectSlideMode) {
|
||
selectSlideMode.addEventListener('change', (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLSelectElement)) return;
|
||
applySlideMode(target.value);
|
||
});
|
||
}
|
||
|
||
initializeSlideModeSetting();
|
||
initializeSlideshowSecondsSetting();
|
||
|
||
authMode = resolveAuthModeFromUrl();
|
||
await initializeAuthentication(authMode);
|
||
|
||
targetPubkey = parseTargetPubkeyFromUrl(currentPubkey);
|
||
if (!targetPubkey) {
|
||
throw new Error('Missing target user. Provide ?npub=<npub1...> or ?pubkey=<hex>.');
|
||
}
|
||
|
||
if (isAuthenticated) {
|
||
populateOwnNpubInUrlIfNeeded();
|
||
await injectHeaderAvatar(targetPubkey);
|
||
initFooterRelayStatus();
|
||
initSidenavRelaySection();
|
||
await initBlossomSection();
|
||
initAiSectionWithLocalConfig();
|
||
startKind1Subscription();
|
||
|
||
window.addEventListener('ndkRelayActivity', (event) => {
|
||
const { relayUrl, activity } = event.detail || {};
|
||
if (relayUrl && activity) setRelayActivityState(relayUrl, activity);
|
||
});
|
||
|
||
window.addEventListener('message', (event) => {
|
||
if (event.data && event.data.type === 'relayActivity') {
|
||
const { relayUrl, activity } = event.data;
|
||
setRelayActivityState(relayUrl, activity);
|
||
}
|
||
});
|
||
} else {
|
||
// Public mode: no login prompt when URL already specifies target npub/pubkey.
|
||
startPublicKind1Subscription();
|
||
}
|
||
|
||
await UpdateFooter();
|
||
updateIntervalId = setInterval(UpdateFooter, 1000);
|
||
|
||
|
||
await updateVersionDisplay();
|
||
document.title = 'SLIDES';
|
||
const headerText = document.querySelector('.divHeaderText');
|
||
if (headerText) headerText.textContent = '';
|
||
} catch (error) {
|
||
console.error('[slide-show.html] Initialization failed:', error);
|
||
divBody.innerHTML = `<div style="text-align:center;padding:50px;">
|
||
<div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div>
|
||
<div style="font-size:16px;">${error.message}</div>
|
||
</div>`;
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|