1520 lignes
54 KiB
JavaScript
1520 lignes
54 KiB
JavaScript
(function () {
|
|
var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6';
|
|
var copyLabel = 'Copy Stream URL';
|
|
var actionLabel = 'ShareLink';
|
|
var clientVersion = '1.0.2-ui-3';
|
|
var allowedItemStorageKey = 'sharelinks.allowedItemId';
|
|
var guestClassName = 'sharelinks-guest';
|
|
var hiddenAttr = 'data-sharelinks-hidden';
|
|
var injectedAttr = 'data-sharelinks-injected';
|
|
var configPromise = null;
|
|
var userPromise = null;
|
|
var userPromiseUserId = null;
|
|
var guestStatePromise = null;
|
|
var guestStatePromiseUserId = null;
|
|
var booted = false;
|
|
var scanQueued = false;
|
|
var bootRetry = null;
|
|
var observer = null;
|
|
var menuContext = null;
|
|
var shareableItemTypes = ['movie', 'series', 'season', 'episode'];
|
|
var menuTriggerSelector = '[data-action="menu"], .btnMoreCommands, .btnCardOptions, .cardOverlayButton';
|
|
var itemTypeCache = {};
|
|
var itemTypeInFlight = {};
|
|
var shareTagPattern = /^sharelinks-[0-9a-f]{32}$/i;
|
|
var tagHiddenAttr = 'data-sharelinks-tag-hidden';
|
|
var historyPatched = false;
|
|
var itemMenuActionIds = [
|
|
'resume', 'playallfromhere', 'queue', 'queuenext', 'shuffle', 'instantmix',
|
|
'multiselect', 'addtocollection', 'addtoplaylist', 'editplaylist',
|
|
'removefromcollection', 'removefromplaylist', 'movetotop', 'movetobottom',
|
|
'download', 'downloadall', 'copy-stream', 'delete', 'edit', 'editimages',
|
|
'editsubtitles', 'editlyrics', 'identify', 'moremediainfo', 'refresh',
|
|
'record', 'canceltimer', 'cancelseriestimer', 'share', 'album', 'artist',
|
|
'lyrics',
|
|
// Older Jellyfin web builds used these ids for the same commands.
|
|
'moreinfo', 'mediainfo', 'editmetadata', 'playlist', 'copystream'
|
|
];
|
|
var durationOptions = [
|
|
{ label: '1 hour', hours: 1 },
|
|
{ label: '2 hours', hours: 2 },
|
|
{ label: '4 hours', hours: 4 },
|
|
{ label: '6 hours', hours: 6 },
|
|
{ label: '12 hours', hours: 12 },
|
|
{ label: '1 day', hours: 24 },
|
|
{ label: '2 days', hours: 48 },
|
|
{ label: '7 days', hours: 168 }
|
|
];
|
|
|
|
function isFrench() {
|
|
var lang = (document.documentElement.getAttribute('lang')
|
|
|| (navigator && (navigator.language || navigator.userLanguage))
|
|
|| 'en');
|
|
return /^fr/i.test(lang);
|
|
}
|
|
|
|
var STRINGS = {
|
|
en: {
|
|
modalTitle: 'Create guest link',
|
|
modalBody: 'Choose how long this link should stay valid.',
|
|
dateLabel: 'Or pick an exact expiry date and time:',
|
|
create: 'Create',
|
|
cancel: 'Cancel',
|
|
copy: 'Copy',
|
|
done: 'Done',
|
|
pickDateFirst: 'Pick a date and time first.',
|
|
dateInvalid: 'That date is not valid.',
|
|
pickFuture: 'Pick a time in the future.',
|
|
cannotDetermineItem: 'Could not determine which item to share. Open the item page and retry.',
|
|
adminOnly: 'ShareLinks is available to administrators only.',
|
|
disabled: 'ShareLinks is disabled.',
|
|
noShareUrl: 'The server did not return a share URL.',
|
|
couldNotCreate: 'Could not create a guest link.',
|
|
copiedNote: 'The link was copied to your clipboard.',
|
|
notCopiedNote: 'The link was created, but the browser blocked automatic clipboard access.',
|
|
resultCopiedTitle: 'Share link copied',
|
|
resultCreatedTitle: 'Share link created',
|
|
toastCopied: 'Share link copied.',
|
|
toastManual: 'Select and copy the link manually.',
|
|
hour: 'hour', hours: 'hours', day: 'day', days: 'days'
|
|
},
|
|
fr: {
|
|
modalTitle: 'Créer un lien invité',
|
|
modalBody: 'Choisissez la durée de validité de ce lien.',
|
|
dateLabel: 'Ou choisissez une date et une heure d\'expiration précises :',
|
|
create: 'Créer',
|
|
cancel: 'Annuler',
|
|
copy: 'Copier',
|
|
done: 'Terminé',
|
|
pickDateFirst: 'Choisissez d\'abord une date et une heure.',
|
|
dateInvalid: 'Cette date n\'est pas valide.',
|
|
pickFuture: 'Choisissez une date dans le futur.',
|
|
cannotDetermineItem: 'Impossible de déterminer l\'élément à partager. Ouvrez la page du média et réessayez.',
|
|
adminOnly: 'ShareLinks est réservé aux administrateurs.',
|
|
disabled: 'ShareLinks est désactivé.',
|
|
noShareUrl: 'Le serveur n\'a pas renvoyé de lien de partage.',
|
|
couldNotCreate: 'Impossible de créer le lien invité.',
|
|
copiedNote: 'Le lien a été copié dans le presse-papiers.',
|
|
notCopiedNote: 'Le lien a été créé, mais le navigateur a bloqué l\'accès automatique au presse-papiers.',
|
|
resultCopiedTitle: 'Lien de partage copié',
|
|
resultCreatedTitle: 'Lien de partage créé',
|
|
toastCopied: 'Lien de partage copié.',
|
|
toastManual: 'Sélectionnez et copiez le lien manuellement.',
|
|
hour: 'heure', hours: 'heures', day: 'jour', days: 'jours'
|
|
}
|
|
};
|
|
|
|
function t(key) {
|
|
var lang = isFrench() ? 'fr' : 'en';
|
|
return (STRINGS[lang] && STRINGS[lang][key]) || STRINGS.en[key] || key;
|
|
}
|
|
|
|
function durationLabel(hours) {
|
|
if (hours < 24) {
|
|
return hours + ' ' + t(hours === 1 ? 'hour' : 'hours');
|
|
}
|
|
var d = hours / 24;
|
|
return d + ' ' + t(d === 1 ? 'day' : 'days');
|
|
}
|
|
|
|
window.ShareLinksClientVersion = clientVersion;
|
|
|
|
function ready() {
|
|
return !!window.ApiClient && !!window.document && !!document.body;
|
|
}
|
|
|
|
function start() {
|
|
if (booted) {
|
|
return;
|
|
}
|
|
|
|
if (!ready()) {
|
|
if (!bootRetry) {
|
|
bootRetry = window.setTimeout(function () {
|
|
bootRetry = null;
|
|
start();
|
|
}, 250);
|
|
}
|
|
return;
|
|
}
|
|
|
|
booted = true;
|
|
installHooks();
|
|
scheduleWork();
|
|
}
|
|
|
|
function installHooks() {
|
|
if (!historyPatched) {
|
|
historyPatched = true;
|
|
patchHistory();
|
|
}
|
|
|
|
window.addEventListener('hashchange', scheduleWork, true);
|
|
window.addEventListener('popstate', scheduleWork, true);
|
|
|
|
document.addEventListener('pointerdown', function (event) {
|
|
rememberMenuContext(event.target);
|
|
}, true);
|
|
|
|
observer = new MutationObserver(scheduleWork);
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
|
|
window.setInterval(scheduleWork, 3000);
|
|
}
|
|
|
|
function patchHistory() {
|
|
var pushState = history.pushState;
|
|
var replaceState = history.replaceState;
|
|
|
|
history.pushState = function () {
|
|
var result = pushState.apply(this, arguments);
|
|
scheduleWork();
|
|
return result;
|
|
};
|
|
|
|
history.replaceState = function () {
|
|
var result = replaceState.apply(this, arguments);
|
|
scheduleWork();
|
|
return result;
|
|
};
|
|
}
|
|
|
|
function scheduleWork() {
|
|
if (scanQueued || !ready()) {
|
|
return;
|
|
}
|
|
|
|
scanQueued = true;
|
|
window.requestAnimationFrame(function () {
|
|
scanQueued = false;
|
|
refresh().catch(function () {
|
|
// Best effort only. The menu hook should never block the web UI.
|
|
});
|
|
});
|
|
}
|
|
|
|
async function refresh() {
|
|
rememberAllowedItemFromRoute();
|
|
await applyGuestLockdown();
|
|
await hideShareTagsFromNonAdmins();
|
|
await scanForMoreMenuActions();
|
|
}
|
|
|
|
function apiGet(path) {
|
|
return ApiClient.ajax({
|
|
type: 'GET',
|
|
url: ApiClient.getUrl(path),
|
|
dataType: 'json'
|
|
});
|
|
}
|
|
|
|
function apiPost(path, body) {
|
|
return ApiClient.ajax({
|
|
type: 'POST',
|
|
url: ApiClient.getUrl(path),
|
|
dataType: 'json',
|
|
contentType: 'application/json',
|
|
data: JSON.stringify(body || {})
|
|
});
|
|
}
|
|
|
|
function getConfig() {
|
|
if (!configPromise) {
|
|
configPromise = ApiClient.getPluginConfiguration(pluginId).catch(function () {
|
|
return {};
|
|
});
|
|
}
|
|
return configPromise;
|
|
}
|
|
|
|
/**
|
|
* The web client is a single page app: signing out or switching accounts does
|
|
* not reload the document, so anything cached for "the current user" has to be
|
|
* keyed to the session it came from. Caching it for the lifetime of the page
|
|
* let an admin's verdict survive into the next user's session.
|
|
*/
|
|
function getCurrentUser() {
|
|
var userId = currentApiUserId();
|
|
if (!userPromise || userPromiseUserId !== userId) {
|
|
userPromiseUserId = userId;
|
|
userPromise = apiGet('Users/Me').catch(function () {
|
|
return null;
|
|
});
|
|
}
|
|
|
|
return userPromise;
|
|
}
|
|
|
|
function currentApiUserId() {
|
|
try {
|
|
return (window.ApiClient && ApiClient.getCurrentUserId && ApiClient.getCurrentUserId()) || '';
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function getGuestState() {
|
|
var userId = currentApiUserId();
|
|
if (!guestStatePromise || guestStatePromiseUserId !== userId) {
|
|
guestStatePromiseUserId = userId;
|
|
guestStatePromise = apiGet('ShareLinks/GuestState').catch(function () {
|
|
return null;
|
|
});
|
|
}
|
|
|
|
return guestStatePromise;
|
|
}
|
|
|
|
function hideBlockedGuestMenuItems() {
|
|
var nodes = document.querySelectorAll('.actionSheetMenuItem');
|
|
Array.prototype.forEach.call(nodes, function (node) {
|
|
if (!node || node.getAttribute('data-sharelinks-blocked') === '1') {
|
|
return;
|
|
}
|
|
|
|
var dataId = String(node.getAttribute('data-id') || '').toLowerCase();
|
|
var label = getVisibleLabel(node).toLowerCase();
|
|
var icon = node.querySelector('.material-icons');
|
|
var iconClass = icon ? String(icon.className || '') : '';
|
|
|
|
var blocked = dataId === 'playlist'
|
|
|| dataId === 'addtoplaylist'
|
|
|| dataId === 'addtocollection'
|
|
|| label.indexOf('liste de lecture') >= 0
|
|
|| label.indexOf('playlist') >= 0
|
|
|| label.indexOf('add to collection') >= 0
|
|
|| label.indexOf('ajouter à la collection') >= 0
|
|
|| (iconClass.indexOf('playlist_add') >= 0 && dataId !== 'queue' && dataId !== 'queuenext');
|
|
|
|
if (blocked) {
|
|
node.setAttribute('data-sharelinks-blocked', '1');
|
|
node.style.setProperty('display', 'none', 'important');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function applyGuestLockdown() {
|
|
var context = await getGuestContext();
|
|
if (!context.locked) {
|
|
return;
|
|
}
|
|
|
|
ensureGuestStyle();
|
|
ensurePluginHideStyle(context.hiddenSelectors);
|
|
hideGuestControls();
|
|
hideBlockedGuestMenuItems();
|
|
|
|
// UX-only lockdown: the guest user's real access boundary is still the
|
|
// server-side policy and item tags. This just keeps the web client out
|
|
// of the user's way, while still letting the guest browse into the
|
|
// shared tree (series -> season -> episode) when the server says the
|
|
// item is visible to them.
|
|
if (!context.allowedItemId) {
|
|
return;
|
|
}
|
|
|
|
var verdict = await checkAllowedLocation(context.allowedItemId);
|
|
if (verdict === false) {
|
|
navigateToItem(context.allowedItemId);
|
|
}
|
|
// verdict === true: allowed, do nothing.
|
|
// verdict === null: check still in flight or route is not item-scoped
|
|
// in a way we can verify yet; do nothing to avoid flicker.
|
|
}
|
|
|
|
async function getGuestContext() {
|
|
var config = await getConfig();
|
|
var user = await getCurrentUser();
|
|
var state = await getGuestState();
|
|
var prefix = config && config.GuestUsernamePrefix ? String(config.GuestUsernamePrefix) : 'share-';
|
|
var username = user && user.Name ? String(user.Name) : '';
|
|
var lockdownEnabled = config && config.GuestModeLockdownEnabled !== false;
|
|
if (state && state.lockdownEnabled === false) {
|
|
lockdownEnabled = false;
|
|
}
|
|
|
|
var locked = lockdownEnabled && (
|
|
(username && username.indexOf(prefix) === 0)
|
|
|| !!(state && (state.IsGuest === true || state.isGuest === true || state.GuestUserId || state.guestUserId))
|
|
);
|
|
return {
|
|
locked: locked,
|
|
allowedItemId: extractAllowedItemId(state) || sessionStorage.getItem(allowedItemStorageKey) || null,
|
|
username: username,
|
|
prefix: prefix,
|
|
lockdownEnabled: lockdownEnabled,
|
|
hiddenSelectors: (state && (state.HiddenSelectors || state.hiddenSelectors)) || ''
|
|
};
|
|
}
|
|
|
|
function extractAllowedItemId(state) {
|
|
if (!state) {
|
|
return null;
|
|
}
|
|
|
|
return state.AllowedItemId || state.allowedItemId || state.ItemId || state.itemId || state.ShareItemId || state.shareItemId || null;
|
|
}
|
|
|
|
function ensureGuestStyle() {
|
|
if (document.body.classList.contains(guestClassName)) {
|
|
return;
|
|
}
|
|
|
|
document.body.classList.add(guestClassName);
|
|
if (document.getElementById('ShareLinksGuestStyle')) {
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement('style');
|
|
style.id = 'ShareLinksGuestStyle';
|
|
style.textContent = 'body.' + guestClassName + ' [' + hiddenAttr + '="1"],'
|
|
+ ' body.' + guestClassName + ' .headerHomeButton,'
|
|
+ ' body.' + guestClassName + ' .mainDrawerButton,'
|
|
+ ' body.' + guestClassName + ' .headerSearchButton,'
|
|
+ ' body.' + guestClassName + ' [data-action="addtoplaylist"],'
|
|
+ ' body.' + guestClassName + ' [data-action="addtocollection"],'
|
|
+ ' body.' + guestClassName + ' [data-id="playlist"],'
|
|
+ ' body.' + guestClassName + ' [data-id="addtocollection"] { display: none !important; }'
|
|
// Cards stay clickable so guests can navigate season/episode cards on a shared series;
|
|
// checkAllowedLocation() verifies the destination server-side and redirects if disallowed.
|
|
+ ' body.' + guestClassName + ' #castCollapsible a,'
|
|
+ ' body.' + guestClassName + ' .detailsGroupItem a,'
|
|
+ ' body.' + guestClassName + ' .genresGroup a,'
|
|
+ ' body.' + guestClassName + ' .studiosGroup a,'
|
|
+ ' body.' + guestClassName + ' .itemTags a,'
|
|
+ ' body.' + guestClassName + ' .mediaInfoItem a { pointer-events: none !important; cursor: default !important; }';
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function ensurePluginHideStyle(selectors) {
|
|
var value = String(selectors || '').split(',').map(function (part) {
|
|
return part.trim();
|
|
}).filter(Boolean);
|
|
var existing = document.getElementById('ShareLinksPluginHideStyle');
|
|
if (value.length === 0) {
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
var css = value.join(', ') + ' { display: none !important; }';
|
|
if (existing) {
|
|
if (existing.textContent !== css) {
|
|
existing.textContent = css;
|
|
}
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement('style');
|
|
style.id = 'ShareLinksPluginHideStyle';
|
|
style.textContent = css;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function hideGuestControls() {
|
|
var roots = [
|
|
document.querySelector('.skinHeader'),
|
|
document.querySelector('.mainDrawer'),
|
|
document.querySelector('.mainDrawerPanel'),
|
|
document.querySelector('.pageContainer'),
|
|
document.body
|
|
].filter(Boolean);
|
|
var keywords = ['home', 'search', 'library', 'settings', 'download', 'share'];
|
|
|
|
roots.forEach(function (root) {
|
|
Array.from(root.querySelectorAll('button, a, [role="button"], [role="menuitem"]')).forEach(function (node) {
|
|
if (shouldHideNode(node, keywords)) {
|
|
node.setAttribute(hiddenAttr, '1');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function shouldHideNode(node, keywords) {
|
|
if (!node || node.getAttribute(hiddenAttr) === '1') {
|
|
return false;
|
|
}
|
|
|
|
var label = getVisibleLabel(node).toLowerCase();
|
|
if (!label) {
|
|
return false;
|
|
}
|
|
|
|
return keywords.some(function (word) {
|
|
return label.indexOf(word) >= 0;
|
|
});
|
|
}
|
|
|
|
function getVisibleLabel(node) {
|
|
return normalizeText(node.getAttribute('aria-label') || node.getAttribute('title') || node.textContent || '');
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function isItemGuid(value) {
|
|
return /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(String(value || '').trim());
|
|
}
|
|
|
|
async function scanForMoreMenuActions() {
|
|
var user = await getCurrentUser();
|
|
if (!isAdministrator(user)) {
|
|
// Take back anything injected for a previous session rather than only
|
|
// skipping: a switch inside the SPA leaves the old DOM in place.
|
|
removeInjectedActions();
|
|
return;
|
|
}
|
|
|
|
var container = findOpenActionContainer();
|
|
if (!container || container.querySelector('[' + injectedAttr + '="1"]')) {
|
|
return;
|
|
}
|
|
|
|
var itemId = resolveMenuItemId();
|
|
if (!itemId || isShareableItem(itemId) !== true) {
|
|
// Either this is not an item menu we can share from (a person, a
|
|
// studio, a library...) or the type lookup is still running.
|
|
// fetchItemType() re-runs this scan once the verdict is known.
|
|
return;
|
|
}
|
|
|
|
appendActionSection(container, itemId);
|
|
}
|
|
|
|
function removeInjectedActions() {
|
|
Array.prototype.forEach.call(document.querySelectorAll('[' + injectedAttr + '="1"]'), function (node) {
|
|
node.remove();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Records which item's "more" menu is about to open. The action sheet is a
|
|
* detached, body-level element with no link back to the card or row it was
|
|
* opened from, so the trigger that was just pressed is the only reliable
|
|
* signal. Any other pointerdown clears the context, so a stale item can never
|
|
* be picked up by a later menu.
|
|
*/
|
|
function rememberMenuContext(target) {
|
|
var node = target;
|
|
while (node && node !== document) {
|
|
if (isMenuTrigger(node)) {
|
|
menuContext = {
|
|
itemId: findItemIdInAncestors(node),
|
|
isPageMenu: !!(node.classList && node.classList.contains('btnMoreCommands')),
|
|
ts: Date.now()
|
|
};
|
|
return;
|
|
}
|
|
node = node.parentElement;
|
|
}
|
|
|
|
menuContext = null;
|
|
}
|
|
|
|
function isMenuTrigger(node) {
|
|
if (!node || !node.matches) {
|
|
return false;
|
|
}
|
|
|
|
if (node.matches(menuTriggerSelector)) {
|
|
return true;
|
|
}
|
|
|
|
return node.tagName === 'BUTTON' && !!node.querySelector('.material-icons.more_vert');
|
|
}
|
|
|
|
function findItemIdInAncestors(node) {
|
|
var current = node;
|
|
while (current && current !== document) {
|
|
var id = readItemIdFromNode(current);
|
|
if (id) {
|
|
return id;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** The item whose menu is open right now, or null when we cannot tell. */
|
|
function resolveMenuItemId() {
|
|
if (!menuContext || (Date.now() - menuContext.ts) > 15000) {
|
|
return null;
|
|
}
|
|
|
|
if (menuContext.itemId) {
|
|
return menuContext.itemId;
|
|
}
|
|
|
|
// The detail page's own "..." button sits in the page header, outside any
|
|
// element carrying the item id, so the route is the item in that case.
|
|
return menuContext.isPageMenu ? (parseItemIdFromUrl() || findItemIdInDocument()) : null;
|
|
}
|
|
|
|
/**
|
|
* Returns true when the item is a movie, series, season or episode, false when
|
|
* it is anything else (a person, a studio, a library, a track), and null while
|
|
* the lookup is still in flight.
|
|
*/
|
|
function isShareableItem(itemId) {
|
|
var id = String(itemId || '').toLowerCase();
|
|
if (!isItemGuid(id)) {
|
|
return false;
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(itemTypeCache, id)) {
|
|
return shareableItemTypes.indexOf(itemTypeCache[id]) >= 0;
|
|
}
|
|
|
|
if (!itemTypeInFlight[id]) {
|
|
itemTypeInFlight[id] = fetchItemType(id);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function fetchItemType(id) {
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
return ApiClient.getItem(ApiClient.getCurrentUserId(), id);
|
|
})
|
|
.then(function (item) {
|
|
itemTypeCache[id] = String((item && item.Type) || '').toLowerCase();
|
|
})
|
|
.catch(function () {
|
|
itemTypeCache[id] = '';
|
|
})
|
|
.finally(function () {
|
|
delete itemTypeInFlight[id];
|
|
scheduleWork();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The share tag has to sit in the item's real Tags for Jellyfin's own tag policy
|
|
* to confine the guest, which means every user would otherwise see a
|
|
* "sharelinks-..." chip on the title and an entry in the library's tag filter.
|
|
* Administrators keep seeing them; for everyone else they are taken back out of
|
|
* the DOM. This is the web UI only - the tag is still in the API payload.
|
|
*/
|
|
async function hideShareTagsFromNonAdmins() {
|
|
// Deliberately only skipped for a confirmed administrator. If the lookup
|
|
// failed we do not know who this is, and the safe answer is to hide.
|
|
var user = await getCurrentUser();
|
|
if (isAdministrator(user)) {
|
|
return;
|
|
}
|
|
|
|
stripShareTagChips();
|
|
hideShareTagFilters();
|
|
}
|
|
|
|
function stripShareTagChips() {
|
|
Array.prototype.forEach.call(document.querySelectorAll('.itemTags'), function (container) {
|
|
var removed = 0;
|
|
Array.prototype.forEach.call(container.querySelectorAll('a'), function (chip) {
|
|
if (!shareTagPattern.test(normalizeText(chip.textContent))) {
|
|
return;
|
|
}
|
|
|
|
removeChipSeparator(chip);
|
|
chip.remove();
|
|
removed += 1;
|
|
});
|
|
|
|
if (removed > 0 && !container.querySelector('a')) {
|
|
// Nothing but the "Tags:" prefix would be left over.
|
|
container.textContent = '';
|
|
container.classList.add('hide');
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Drops the ", " text node Jellyfin puts between two tag chips. */
|
|
function removeChipSeparator(chip) {
|
|
var separator = /^\s*,\s*$/;
|
|
var next = chip.nextSibling;
|
|
if (next && next.nodeType === 3 && separator.test(next.nodeValue)) {
|
|
next.parentNode.removeChild(next);
|
|
return;
|
|
}
|
|
|
|
var previous = chip.previousSibling;
|
|
if (previous && previous.nodeType === 3 && separator.test(previous.nodeValue)) {
|
|
previous.parentNode.removeChild(previous);
|
|
}
|
|
}
|
|
|
|
function hideShareTagFilters() {
|
|
Array.prototype.forEach.call(document.querySelectorAll('.chkTagFilter'), function (input) {
|
|
if (input.getAttribute(tagHiddenAttr) === '1') {
|
|
return;
|
|
}
|
|
|
|
if (!shareTagPattern.test(String(input.getAttribute('data-filter') || ''))) {
|
|
return;
|
|
}
|
|
|
|
input.setAttribute(tagHiddenAttr, '1');
|
|
var row = input.closest('label') || input.parentElement;
|
|
if (row) {
|
|
row.style.setProperty('display', 'none', 'important');
|
|
}
|
|
});
|
|
}
|
|
|
|
function isCopyStreamUrlLabel(label) {
|
|
var value = normalizeText(label).toLowerCase();
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
|
|
return value === copyLabel.toLowerCase()
|
|
|| (value.indexOf('copy') >= 0 && value.indexOf('stream') >= 0 && value.indexOf('url') >= 0)
|
|
|| (value.indexOf('copier') >= 0 && value.indexOf('url') >= 0 && (value.indexOf('flux') >= 0 || value.indexOf('stream') >= 0));
|
|
}
|
|
|
|
function isItemMenuAction(node) {
|
|
if (!node || !node.getAttribute) {
|
|
return false;
|
|
}
|
|
|
|
var id = String(node.getAttribute('data-id') || '').toLowerCase();
|
|
if (id && itemMenuActionIds.indexOf(id) >= 0) {
|
|
return true;
|
|
}
|
|
|
|
return isCopyStreamUrlLabel(getVisibleLabel(node));
|
|
}
|
|
|
|
function findOpenActionContainer() {
|
|
var selectors = [
|
|
'.actionSheet',
|
|
'.actionsheet',
|
|
'[role="menu"]'
|
|
];
|
|
|
|
for (var i = 0; i < selectors.length; i += 1) {
|
|
var nodes = document.querySelectorAll(selectors[i]);
|
|
for (var j = 0; j < nodes.length; j += 1) {
|
|
var node = nodes[j];
|
|
if (isVisible(node) && looksLikeActionContainer(node)) {
|
|
return node;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function looksLikeActionContainer(node) {
|
|
if (node.querySelector('select, input:not([type="checkbox"]):not([type="radio"]), textarea')) {
|
|
return false;
|
|
}
|
|
|
|
var items = Array.from(node.querySelectorAll('.actionSheetMenuItem, .actionsheetMenuItem'))
|
|
.filter(isVisible);
|
|
if (items.length < 2 || items.length > 40) {
|
|
return false;
|
|
}
|
|
|
|
return items.some(function (item) {
|
|
return isItemMenuAction(item);
|
|
});
|
|
}
|
|
|
|
function findBestActionTemplate(container) {
|
|
var actions = Array.from(container.querySelectorAll('.actionSheetMenuItem, .actionsheetMenuItem'))
|
|
.filter(isVisible)
|
|
.filter(function (node) {
|
|
return node.getAttribute(injectedAttr) !== '1';
|
|
});
|
|
|
|
return actions.length ? actions[0] : null;
|
|
}
|
|
|
|
function isVisible(node) {
|
|
return !!(node && (node.offsetWidth || node.offsetHeight || node.getClientRects().length));
|
|
}
|
|
|
|
/**
|
|
* Appends the ShareLink action as its own section at the very bottom of the
|
|
* menu, separated from the native commands by the same divider Jellyfin
|
|
* renders between its own groups.
|
|
*/
|
|
function appendActionSection(container, itemId) {
|
|
var template = findBestActionTemplate(container);
|
|
if (!template) {
|
|
return;
|
|
}
|
|
|
|
var scroller = container.querySelector('.actionSheetScroller') || template.parentElement;
|
|
if (!scroller) {
|
|
return;
|
|
}
|
|
|
|
var divider = document.createElement('div');
|
|
divider.className = 'actionsheetDivider';
|
|
divider.setAttribute(injectedAttr, '1');
|
|
|
|
var action = buildShareAction(template, itemId, container);
|
|
|
|
// Jellyfin puts its Cancel button in a trailing .buttons block inside the
|
|
// scroller; our section belongs after the last command but above that.
|
|
var cancelBlock = scroller.querySelector('.buttons');
|
|
if (cancelBlock) {
|
|
cancelBlock.insertAdjacentElement('beforebegin', divider);
|
|
divider.insertAdjacentElement('afterend', action);
|
|
return;
|
|
}
|
|
|
|
scroller.appendChild(divider);
|
|
scroller.appendChild(action);
|
|
}
|
|
|
|
function buildShareAction(template, itemId, container) {
|
|
var action = template.cloneNode(true);
|
|
action.setAttribute(injectedAttr, '1');
|
|
action.setAttribute('type', 'button');
|
|
action.removeAttribute('id');
|
|
action.removeAttribute('href');
|
|
action.removeAttribute('onclick');
|
|
action.removeAttribute('target');
|
|
action.removeAttribute('download');
|
|
action.removeAttribute('autoFocus');
|
|
// The clone inherits the template's data-id (e.g. 'edit'), which would
|
|
// duplicate an existing menu item's id and let the action sheet run that
|
|
// command instead; strip it.
|
|
action.removeAttribute('data-id');
|
|
action.setAttribute('aria-label', actionLabel);
|
|
action.setAttribute('title', actionLabel);
|
|
action.dataset.sharelinksItemId = itemId;
|
|
|
|
setActionLabel(action);
|
|
setActionIcon(action);
|
|
|
|
action.addEventListener('click', function (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
closeActionSheet(container);
|
|
void createGuestLink(itemId);
|
|
}, true);
|
|
|
|
return action;
|
|
}
|
|
|
|
function setActionLabel(action) {
|
|
Array.from(action.querySelectorAll('.listItemBodyText.secondary, .listItemAside')).forEach(function (node) {
|
|
node.remove();
|
|
});
|
|
|
|
var text = action.querySelector('.actionSheetItemText') || action.querySelector('.listItemBodyText');
|
|
if (text) {
|
|
text.textContent = actionLabel;
|
|
return;
|
|
}
|
|
|
|
action.textContent = actionLabel;
|
|
}
|
|
|
|
function setActionIcon(action) {
|
|
var icon = action.querySelector('.material-icons');
|
|
if (!icon) {
|
|
icon = document.createElement('span');
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
action.insertBefore(icon, action.firstChild);
|
|
}
|
|
|
|
icon.className = 'actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons share';
|
|
icon.style.removeProperty('visibility');
|
|
}
|
|
|
|
/**
|
|
* Dismisses the native action sheet so our dialog is not stacked on top of it.
|
|
* Clicking the sheet's own close button keeps Jellyfin's animation and state
|
|
* handling; Escape is the fallback when the sheet renders without one.
|
|
*/
|
|
function closeActionSheet(container) {
|
|
var closeButton = container.querySelector('.btnCloseActionSheet');
|
|
if (closeButton) {
|
|
closeButton.click();
|
|
return;
|
|
}
|
|
|
|
container.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'Escape',
|
|
keyCode: 27,
|
|
which: 27,
|
|
bubbles: true,
|
|
cancelable: true
|
|
}));
|
|
}
|
|
|
|
function parseItemIdFromUrl() {
|
|
var sources = [location.hash, location.search, location.href];
|
|
for (var i = 0; i < sources.length; i += 1) {
|
|
var text = sources[i];
|
|
var match = text.match(/[?&](?:id|itemId)=([^&#]+)/i);
|
|
if (match && match[1]) {
|
|
var decoded = decodeURIComponent(match[1]);
|
|
if (isItemGuid(decoded)) {
|
|
return decoded;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findItemIdInDocument() {
|
|
var selectors = [
|
|
'#itemDetailPage',
|
|
'.detailPage',
|
|
'.itemDetailPage',
|
|
'[data-itemid]',
|
|
'[data-id]'
|
|
];
|
|
|
|
for (var i = 0; i < selectors.length; i += 1) {
|
|
var nodes = document.querySelectorAll(selectors[i]);
|
|
for (var j = 0; j < nodes.length; j += 1) {
|
|
var id = readItemIdFromNode(nodes[j]);
|
|
if (id) {
|
|
return id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readItemIdFromNode(node) {
|
|
if (!node || !node.getAttribute) {
|
|
return null;
|
|
}
|
|
|
|
var candidates = [
|
|
node.getAttribute('data-itemid'),
|
|
node.getAttribute('data-id'),
|
|
node.getAttribute('data-item-id')
|
|
];
|
|
|
|
for (var i = 0; i < candidates.length; i += 1) {
|
|
if (isItemGuid(candidates[i])) {
|
|
return candidates[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isAdministrator(user) {
|
|
return !!(user && user.Policy && user.Policy.IsAdministrator === true);
|
|
}
|
|
|
|
function rememberAllowedItemFromRoute() {
|
|
if (!isDetailsOrPlaybackRoute()) {
|
|
return;
|
|
}
|
|
|
|
var itemId = parseItemIdFromUrl() || findItemIdInDocument();
|
|
if (itemId) {
|
|
sessionStorage.setItem(allowedItemStorageKey, itemId);
|
|
}
|
|
}
|
|
|
|
function isDetailsOrPlaybackRoute() {
|
|
return /#\/(?:details|video|playback|list|item)/i.test(location.hash || '');
|
|
}
|
|
|
|
var itemVisibilityCache = {};
|
|
var itemVisibilityInFlight = {};
|
|
|
|
function parseCandidateIdsFromUrl() {
|
|
var sources = [location.hash, location.search, location.href];
|
|
var keys = ['id', 'seriesId', 'parentId', 'topParentId'];
|
|
var found = [];
|
|
|
|
for (var k = 0; k < keys.length; k += 1) {
|
|
var pattern = new RegExp('[?&]' + keys[k] + '=([^&#]+)', 'i');
|
|
for (var i = 0; i < sources.length; i += 1) {
|
|
var text = sources[i];
|
|
var match = text.match(pattern);
|
|
if (match && match[1]) {
|
|
var decoded = decodeURIComponent(match[1]);
|
|
if (isItemGuid(decoded) && found.indexOf(decoded) < 0) {
|
|
found.push(decoded);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Returns true/false when the verdict for the current location is already
|
|
* known, or null while it is still being resolved (or the route is not one
|
|
* we verify). A true/false verdict for a given id is cached so the mutation
|
|
* observer re-running this on every DOM tick does not spam the API.
|
|
*/
|
|
function checkAllowedLocation(allowedItemId) {
|
|
var hash = location.hash || '';
|
|
if (/#\/(?:video|playback)/i.test(hash)) {
|
|
return true;
|
|
}
|
|
|
|
if (!/#\/(?:details|item|list)/i.test(hash)) {
|
|
return false;
|
|
}
|
|
|
|
var candidates = parseCandidateIdsFromUrl();
|
|
var normalizedAllowed = String(allowedItemId || '').toLowerCase();
|
|
|
|
if (candidates.length === 0) {
|
|
// Details/list route we could not extract an id from: fall back to
|
|
// the strict same-item check rather than guessing.
|
|
return false;
|
|
}
|
|
|
|
if (candidates.some(function (id) { return id.toLowerCase() === normalizedAllowed; })) {
|
|
return true;
|
|
}
|
|
|
|
// None of the candidate ids is the shared item itself. Ask the server
|
|
// whether the current user (the guest) can actually fetch any of them -
|
|
// the AllowedTags policy is the real access boundary, so a successful
|
|
// fetch means the guest is allowed to be here (e.g. a season/episode
|
|
// inside a shared series or season).
|
|
return resolveServerVisibility(candidates);
|
|
}
|
|
|
|
function resolveServerVisibility(candidates) {
|
|
var pending = false;
|
|
|
|
for (var i = 0; i < candidates.length; i += 1) {
|
|
var id = candidates[i].toLowerCase();
|
|
if (Object.prototype.hasOwnProperty.call(itemVisibilityCache, id)) {
|
|
if (itemVisibilityCache[id] === true) {
|
|
return true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
pending = true;
|
|
if (!itemVisibilityInFlight[id]) {
|
|
itemVisibilityInFlight[id] = fetchItemVisibility(id);
|
|
}
|
|
}
|
|
|
|
// No definitive "allowed" verdict yet. If at least one candidate is
|
|
// still being checked, stay neutral (no redirect) until it settles.
|
|
// Only report a definitive rejection once every candidate has a
|
|
// cached, negative verdict.
|
|
return pending ? null : false;
|
|
}
|
|
|
|
function fetchItemVisibility(id) {
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
var userId = ApiClient.getCurrentUserId();
|
|
return ApiClient.getItem(userId, id);
|
|
})
|
|
.then(function () {
|
|
itemVisibilityCache[id] = true;
|
|
return true;
|
|
})
|
|
.catch(function () {
|
|
itemVisibilityCache[id] = false;
|
|
return false;
|
|
})
|
|
.finally(function () {
|
|
delete itemVisibilityInFlight[id];
|
|
scheduleWork();
|
|
});
|
|
}
|
|
|
|
function navigateToItem(itemId) {
|
|
var target = '#/details?id=' + encodeURIComponent(itemId);
|
|
if (location.hash !== target) {
|
|
location.hash = target;
|
|
}
|
|
}
|
|
|
|
async function createGuestLink(itemId) {
|
|
if (!isItemGuid(itemId)) {
|
|
notify(t('cannotDetermineItem'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
var config = await getConfig();
|
|
var user = await getCurrentUser();
|
|
if (!isAdministrator(user)) {
|
|
notify(t('adminOnly'));
|
|
return;
|
|
}
|
|
|
|
if (config && config.Enabled === false) {
|
|
notify(t('disabled'));
|
|
return;
|
|
}
|
|
|
|
var result = await chooseExpiryHours(config, function (expiryHours) {
|
|
var payload = {
|
|
itemId: itemId,
|
|
expiryHours: expiryHours,
|
|
oneUse: config && config.OneUseDefault !== undefined ? !!config.OneUseDefault : true
|
|
};
|
|
|
|
var shareUrlPromise = apiPost('ShareLinks/Admin/Create', payload).then(function (response) {
|
|
var shareUrl = response && (response.ShareUrl || response.shareUrl);
|
|
if (!shareUrl) {
|
|
throw new Error(t('noShareUrl'));
|
|
}
|
|
|
|
return shareUrl;
|
|
});
|
|
|
|
var copiedPromise = copyTextWhenReady(shareUrlPromise);
|
|
return shareUrlPromise.then(function (shareUrl) {
|
|
return copiedPromise.catch(function () {
|
|
return false;
|
|
}).then(function (copied) {
|
|
return {
|
|
shareUrl: shareUrl,
|
|
copied: copied
|
|
};
|
|
});
|
|
});
|
|
});
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
showShareResult(result.shareUrl, result.copied);
|
|
} catch (error) {
|
|
notify(extractErrorMessage(error, t('couldNotCreate')));
|
|
}
|
|
}
|
|
|
|
function pad2(value) {
|
|
return (value < 10 ? '0' : '') + value;
|
|
}
|
|
|
|
function toLocalDatetimeValue(date) {
|
|
return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate())
|
|
+ 'T' + pad2(date.getHours()) + ':' + pad2(date.getMinutes());
|
|
}
|
|
|
|
function chooseExpiryHours(config, onChoose) {
|
|
var maxHours = clampPositiveInteger(config && config.MaxExpiryHours, 720);
|
|
var options = durationOptions.filter(function (option) {
|
|
return option.hours <= maxHours;
|
|
}).map(function (option) {
|
|
return {
|
|
label: durationLabel(option.hours),
|
|
hours: option.hours
|
|
};
|
|
});
|
|
var nowMs = Date.now();
|
|
var minDate = new Date(nowMs + 5 * 60000);
|
|
var maxDate = new Date(nowMs + maxHours * 3600000);
|
|
var defaultDate = new Date(nowMs + 168 * 3600000);
|
|
if (defaultDate.getTime() > maxDate.getTime()) {
|
|
defaultDate = maxDate;
|
|
}
|
|
|
|
return openModal({
|
|
title: t('modalTitle'),
|
|
body: t('modalBody'),
|
|
options: options,
|
|
onChoose: onChoose,
|
|
cancelText: t('cancel'),
|
|
datePicker: {
|
|
min: toLocalDatetimeValue(minDate),
|
|
max: toLocalDatetimeValue(maxDate),
|
|
value: toLocalDatetimeValue(defaultDate),
|
|
maxHours: maxHours
|
|
}
|
|
});
|
|
}
|
|
|
|
function copyTextWhenReady(textPromise) {
|
|
if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem && window.Blob) {
|
|
try {
|
|
var blobPromise = Promise.resolve(textPromise).then(function (text) {
|
|
return new Blob([text], { type: 'text/plain' });
|
|
});
|
|
return navigator.clipboard.write([
|
|
new ClipboardItem({ 'text/plain': blobPromise })
|
|
]).then(function () {
|
|
return true;
|
|
}).catch(function () {
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
});
|
|
} catch (error) {
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
}
|
|
}
|
|
|
|
return Promise.resolve(textPromise).then(copyText);
|
|
}
|
|
|
|
function copyText(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
return navigator.clipboard.writeText(text).then(function () {
|
|
return true;
|
|
}).catch(function () {
|
|
return fallbackCopy(text);
|
|
});
|
|
}
|
|
|
|
return Promise.resolve(fallbackCopy(text));
|
|
}
|
|
|
|
function notify(message) {
|
|
ensureShareLinksUi();
|
|
var toast = document.createElement('div');
|
|
toast.className = 'sharelinks-toast';
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
window.setTimeout(function () {
|
|
toast.classList.add('is-visible');
|
|
}, 20);
|
|
window.setTimeout(function () {
|
|
toast.classList.remove('is-visible');
|
|
window.setTimeout(function () {
|
|
toast.remove();
|
|
}, 180);
|
|
}, 3600);
|
|
}
|
|
|
|
function showShareResult(shareUrl, copied) {
|
|
ensureShareLinksUi();
|
|
var body = document.createElement('div');
|
|
var note = document.createElement('p');
|
|
note.className = 'sharelinks-note';
|
|
note.textContent = copied
|
|
? t('copiedNote')
|
|
: t('notCopiedNote');
|
|
body.appendChild(note);
|
|
|
|
var urlBox = document.createElement('textarea');
|
|
urlBox.className = 'sharelinks-url';
|
|
urlBox.readOnly = true;
|
|
urlBox.value = shareUrl;
|
|
body.appendChild(urlBox);
|
|
|
|
openModalElement({
|
|
title: copied ? t('resultCopiedTitle') : t('resultCreatedTitle'),
|
|
bodyElement: body,
|
|
actions: [
|
|
{
|
|
label: t('copy'),
|
|
primary: true,
|
|
handler: function () {
|
|
return copyText(shareUrl).then(function (ok) {
|
|
if (ok) {
|
|
notify(t('toastCopied'));
|
|
} else {
|
|
urlBox.focus();
|
|
urlBox.select();
|
|
notify(t('toastManual'));
|
|
}
|
|
return ok;
|
|
});
|
|
}
|
|
},
|
|
{
|
|
label: t('done'),
|
|
close: true
|
|
}
|
|
],
|
|
onOpen: function () {
|
|
urlBox.focus();
|
|
urlBox.select();
|
|
}
|
|
});
|
|
}
|
|
|
|
function openModal(settings) {
|
|
var body = document.createElement('div');
|
|
var text = document.createElement('p');
|
|
text.className = 'sharelinks-note';
|
|
text.textContent = settings.body;
|
|
body.appendChild(text);
|
|
|
|
var grid = document.createElement('div');
|
|
grid.className = 'sharelinks-duration-grid';
|
|
body.appendChild(grid);
|
|
|
|
var dateInput = null;
|
|
if (settings.datePicker) {
|
|
var dateRow = document.createElement('div');
|
|
dateRow.className = 'sharelinks-date-row';
|
|
|
|
var dateLabel = document.createElement('label');
|
|
dateLabel.className = 'sharelinks-date-label';
|
|
dateLabel.textContent = t('dateLabel');
|
|
|
|
dateInput = document.createElement('input');
|
|
dateInput.type = 'datetime-local';
|
|
dateInput.className = 'sharelinks-date-input';
|
|
dateInput.min = settings.datePicker.min;
|
|
dateInput.max = settings.datePicker.max;
|
|
dateInput.value = settings.datePicker.value;
|
|
|
|
dateLabel.setAttribute('for', 'sharelinksExpiryDate');
|
|
dateInput.id = 'sharelinksExpiryDate';
|
|
|
|
dateRow.appendChild(dateLabel);
|
|
dateRow.appendChild(dateInput);
|
|
body.appendChild(dateRow);
|
|
}
|
|
|
|
return new Promise(function (resolve) {
|
|
var modal;
|
|
var actions = [];
|
|
|
|
if (settings.datePicker) {
|
|
actions.push({
|
|
label: t('create'),
|
|
primary: true,
|
|
handler: function () {
|
|
var raw = dateInput && dateInput.value;
|
|
if (!raw) {
|
|
notify(t('pickDateFirst'));
|
|
return;
|
|
}
|
|
|
|
var target = new Date(raw);
|
|
if (isNaN(target.getTime())) {
|
|
notify(t('dateInvalid'));
|
|
return;
|
|
}
|
|
|
|
var hours = Math.ceil((target.getTime() - Date.now()) / 3600000);
|
|
if (hours < 1) {
|
|
notify(t('pickFuture'));
|
|
return;
|
|
}
|
|
|
|
if (settings.datePicker.maxHours && hours > settings.datePicker.maxHours) {
|
|
hours = settings.datePicker.maxHours;
|
|
}
|
|
|
|
if (modal) {
|
|
modal.close();
|
|
}
|
|
resolve(settings.onChoose ? settings.onChoose(hours) : hours);
|
|
}
|
|
});
|
|
}
|
|
|
|
actions.push({
|
|
label: settings.cancelText || t('cancel'),
|
|
close: true,
|
|
handler: function () {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
modal = openModalElement({
|
|
title: settings.title,
|
|
bodyElement: body,
|
|
actions: actions,
|
|
onDismiss: function () {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
settings.options.forEach(function (option) {
|
|
var button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'sharelinks-duration-button';
|
|
button.textContent = option.label;
|
|
|
|
button.addEventListener('click', function () {
|
|
modal.close();
|
|
if (settings.onChoose) {
|
|
resolve(settings.onChoose(option.hours));
|
|
} else {
|
|
resolve(option.hours);
|
|
}
|
|
});
|
|
grid.appendChild(button);
|
|
});
|
|
});
|
|
}
|
|
|
|
function openModalElement(settings) {
|
|
ensureShareLinksUi();
|
|
var closed = false;
|
|
var overlay = document.createElement('div');
|
|
overlay.className = 'sharelinks-overlay';
|
|
overlay.setAttribute('role', 'presentation');
|
|
|
|
var dialog = document.createElement('div');
|
|
dialog.className = 'sharelinks-dialog';
|
|
dialog.setAttribute('role', 'dialog');
|
|
dialog.setAttribute('aria-modal', 'true');
|
|
dialog.setAttribute('aria-label', settings.title);
|
|
overlay.appendChild(dialog);
|
|
|
|
var title = document.createElement('h3');
|
|
title.className = 'sharelinks-title';
|
|
title.textContent = settings.title;
|
|
dialog.appendChild(title);
|
|
|
|
dialog.appendChild(settings.bodyElement);
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'sharelinks-actions';
|
|
dialog.appendChild(actions);
|
|
|
|
function close() {
|
|
if (closed) {
|
|
return;
|
|
}
|
|
closed = true;
|
|
overlay.classList.remove('is-visible');
|
|
window.setTimeout(function () {
|
|
overlay.remove();
|
|
}, 160);
|
|
}
|
|
|
|
(settings.actions || []).forEach(function (action) {
|
|
var button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = action.primary ? 'sharelinks-action primary' : 'sharelinks-action';
|
|
button.textContent = action.label;
|
|
button.addEventListener('click', function () {
|
|
var result = action.handler ? action.handler() : null;
|
|
Promise.resolve(result).finally(function () {
|
|
if (action.close) {
|
|
close();
|
|
}
|
|
});
|
|
});
|
|
actions.appendChild(button);
|
|
});
|
|
|
|
overlay.addEventListener('click', function (event) {
|
|
if (event.target === overlay) {
|
|
close();
|
|
if (settings.onDismiss) {
|
|
settings.onDismiss();
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener('keydown', function onKeyDown(event) {
|
|
if (event.key === 'Escape' && !closed) {
|
|
document.removeEventListener('keydown', onKeyDown, true);
|
|
close();
|
|
if (settings.onDismiss) {
|
|
settings.onDismiss();
|
|
}
|
|
}
|
|
}, true);
|
|
|
|
document.body.appendChild(overlay);
|
|
window.setTimeout(function () {
|
|
overlay.classList.add('is-visible');
|
|
var firstButton = overlay.querySelector('button:not([disabled])');
|
|
if (firstButton) {
|
|
firstButton.focus();
|
|
}
|
|
if (settings.onOpen) {
|
|
settings.onOpen();
|
|
}
|
|
}, 20);
|
|
|
|
return { close: close, element: overlay };
|
|
}
|
|
|
|
function ensureShareLinksUi() {
|
|
if (document.getElementById('ShareLinksUiStyle')) {
|
|
return;
|
|
}
|
|
|
|
var style = document.createElement('style');
|
|
style.id = 'ShareLinksUiStyle';
|
|
style.textContent = [
|
|
'.sharelinks-overlay{position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,.58);display:grid;place-items:center;padding:24px;opacity:0;transition:opacity .16s ease;}',
|
|
'.sharelinks-overlay.is-visible{opacity:1;}',
|
|
'.sharelinks-dialog{width:min(520px,100%);background:var(--background-color,#202020);color:var(--text-color,#fff);box-shadow:0 18px 60px rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:22px;}',
|
|
'.sharelinks-title{font-size:1.25rem;line-height:1.3;margin:0 0 14px;font-weight:600;}',
|
|
'.sharelinks-note{margin:0 0 16px;color:var(--text-secondary-color,#cfcfcf);line-height:1.45;}',
|
|
'.sharelinks-duration-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:4px;}',
|
|
'.sharelinks-duration-button,.sharelinks-action{border:0;border-radius:6px;background:rgba(255,255,255,.12);color:inherit;padding:10px 12px;min-height:42px;cursor:pointer;font:inherit;}',
|
|
'.sharelinks-duration-button:hover:not(:disabled),.sharelinks-action:hover{background:rgba(255,255,255,.18);}',
|
|
'.sharelinks-duration-button:disabled{opacity:.35;cursor:not-allowed;}',
|
|
'.sharelinks-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:18px;}',
|
|
'.sharelinks-action.primary{background:var(--theme-primary-color,#00a4dc);color:#fff;}',
|
|
'.sharelinks-url{width:100%;min-height:88px;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px;font:inherit;resize:vertical;}',
|
|
'.sharelinks-date-row{margin-top:18px;display:flex;flex-direction:column;gap:8px;}',
|
|
'.sharelinks-date-label{color:var(--text-secondary-color,#cfcfcf);font-size:.92rem;line-height:1.4;}',
|
|
'.sharelinks-date-input{width:100%;box-sizing:border-box;border:1px solid rgba(255,255,255,.2);border-radius:6px;background:rgba(0,0,0,.18);color:inherit;padding:10px 12px;min-height:42px;font:inherit;color-scheme:dark;}',
|
|
'.sharelinks-toast{position:fixed;left:24px;bottom:24px;z-index:1000000;max-width:min(460px,calc(100vw - 48px));background:rgba(24,24,24,.96);color:#fff;border:1px solid rgba(255,255,255,.14);border-radius:6px;padding:11px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease;}',
|
|
'.sharelinks-toast.is-visible{opacity:1;transform:translateY(0);}',
|
|
'@media (max-width:520px){.sharelinks-duration-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.sharelinks-dialog{padding:18px;}.sharelinks-actions{justify-content:stretch;}.sharelinks-action{flex:1;}}'
|
|
].join('');
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
function fallbackCopy(text) {
|
|
var textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.setAttribute('readonly', 'readonly');
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.top = '-1000px';
|
|
textarea.style.left = '-1000px';
|
|
document.body.appendChild(textarea);
|
|
textarea.focus();
|
|
textarea.select();
|
|
var copied = false;
|
|
try {
|
|
copied = document.execCommand('copy');
|
|
} catch (error) {
|
|
copied = false;
|
|
}
|
|
textarea.remove();
|
|
return copied;
|
|
}
|
|
|
|
function clampPositiveInteger(value, fallback) {
|
|
var parsed = parseInt(value, 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function extractErrorMessage(error, fallback) {
|
|
if (!error) {
|
|
return fallback;
|
|
}
|
|
|
|
if (typeof error === 'string') {
|
|
return error;
|
|
}
|
|
|
|
if (error.responseJSON && error.responseJSON.error) {
|
|
return error.responseJSON.error;
|
|
}
|
|
|
|
if (error.responseText) {
|
|
return error.responseText;
|
|
}
|
|
|
|
if (error.message) {
|
|
return error.message;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
start();
|
|
})();
|