From 4450906da9a249b70d2e66074d59fac910c8d8f3 Mon Sep 17 00:00:00 2001 From: Jimmy GALLAND Date: Fri, 18 Sep 2026 21:56:11 +0200 Subject: [PATCH] Add collections, videos, images to shareable items --- .gitignore | 66 + .../Api/ShareLinksController.cs | 16 +- Jellyfin.Plugin.ShareLinks/Web/sharelinks.js | 3190 +++++++++-------- docker/Dockerfile | 7 + 4 files changed, 1799 insertions(+), 1480 deletions(-) create mode 100644 .gitignore create mode 100644 docker/Dockerfile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6df3433 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +## .NET / C# / Visual Studio / Rider + +# Build results +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Rr]elease/ +[Dd]ebug/ +x64/ +x86/ +build/ +bld/ + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Rider / JetBrains +.idea/ +*.sln.iml + +# VS Code +.vscode/ +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# NuGet +*.nupkg +*.snupkg +**/[Pp]ackages/* +!**/[Pp]ackages/build/ +*.nuget.props +*.nuget.targets +project.lock.json +project.fragment.lock.json +artifacts/ + +# Test results +[Tt]est[Rr]esult*/ +*.trx +*.coverage +*.coveragexml + +# Rider/VS temp files +*.tmp +*.log +*.cache + +# OS files +.DS_Store +Thumbs.db + +# Publish output +publish/ +PublishProfiles/ + +# Jellyfin plugin packaging (dossiers de sortie générés lors du zip/publish) +*.zip + +# Docker (si vous ajoutez des overrides locaux) +docker-compose.override.yml \ No newline at end of file diff --git a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs index c744f11..81dcb7c 100644 --- a/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs +++ b/Jellyfin.Plugin.ShareLinks/Api/ShareLinksController.cs @@ -227,7 +227,7 @@ public sealed class ShareLinksController : ControllerBase itemId, item.Name, item.GetType().Name); - return BadRequest(new { error = "Only a movie, series, season or episode can be shared. Open the title's page and try again." }); + return BadRequest(new { error = "Only a video, image, and collection can be shared. Open the title's page and try again." }); } try @@ -526,16 +526,18 @@ setTimeout(function () { window.location.replace({{redirectUrlJson}}); }, 4000); } /// - /// Only real video titles are shareable. Anything else - a person, studio, - /// genre, library, collection, playlist, music track or book - would either - /// give the guest nothing to play or pull unrelated items into the guest's tag - /// policy, so the API refuses it even when a client asks for it. + /// Real video titles, home videos, photos and collections are shareable. + /// Anything else - a person, studio, genre, library, playlist, music track or + /// book - would either give the guest nothing to play or pull unrelated items + /// into the guest's tag policy, so the API refuses it even when a client asks + /// for it. A BoxSet shares every item beneath it in one link: ItemTagService + /// tags the whole tree, same as it does for a series' seasons and episodes. /// private static bool IsShareableItem(BaseItem item) { - return item is Movie or Series or Season or Episode; + return item is Movie or Series or Season or Episode or Video or BoxSet or Photo; } - + private static bool IsExpired(ShareLinkRecord record) { return record.ExpiresAtUtc <= DateTimeOffset.UtcNow; diff --git a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js index 3ba5929..bd4e703 100644 --- a/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js +++ b/Jellyfin.Plugin.ShareLinks/Web/sharelinks.js @@ -1,1603 +1,1847 @@ (function () { - var pluginId = '68540b76-ee74-436d-85ff-2abc884bbea6'; - var copyLabel = 'Copy Stream URL'; - var actionLabel = 'ShareLink'; - var clientVersion = '1.0.6-ui-1'; - 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 } - ]; + var pluginId = "68540b76-ee74-436d-85ff-2abc884bbea6"; + var copyLabel = "Copy Stream URL"; + var actionLabel = "ShareLink"; + var clientVersion = "1.0.6-ui-1"; + 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", + "video", + "photo", + "boxset", + ]; + 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); + 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.", + multiUseLabel: "Let several people use this link", + multiUseHint: + "The link keeps working for anyone you send it to until it expires, instead of dying once the first person opens it.", + multiUseLimit: "Up to {count} of them can watch at the same time.", + resultMultiUseNote: + "Anyone you send this link to can open it until it expires.", + 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.", + multiUseLabel: "Autoriser plusieurs personnes à utiliser ce lien", + multiUseHint: + "Le lien reste valable pour toutes les personnes à qui vous l'envoyez jusqu'à son expiration, au lieu de mourir dès la première ouverture.", + multiUseLimit: + "Jusqu'a {count} d'entre elles peuvent regarder en meme temps.", + resultMultiUseNote: + "Toutes les personnes à qui vous envoyez ce lien peuvent l'ouvrir jusqu'à son expiration.", + 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; } - 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.', - multiUseLabel: 'Let several people use this link', - multiUseHint: 'The link keeps working for anyone you send it to until it expires, instead of dying once the first person opens it.', - multiUseLimit: 'Up to {count} of them can watch at the same time.', - resultMultiUseNote: 'Anyone you send this link to can open it until it expires.', - 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.', - multiUseLabel: 'Autoriser plusieurs personnes à utiliser ce lien', - multiUseHint: 'Le lien reste valable pour toutes les personnes à qui vous l\'envoyez jusqu\'à son expiration, au lieu de mourir dès la première ouverture.', - multiUseLimit: 'Jusqu\'a {count} d\'entre elles peuvent regarder en meme temps.', - resultMultiUseNote: 'Toutes les personnes à qui vous envoyez ce lien peuvent l\'ouvrir jusqu\'à son expiration.', - 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' - } + 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; }; - function t(key) { - var lang = isFrench() ? 'fr' : 'en'; - return (STRINGS[lang] && STRINGS[lang][key]) || STRINGS.en[key] || key; + history.replaceState = function () { + var result = replaceState.apply(this, arguments); + scheduleWork(); + return result; + }; + } + + function scheduleWork() { + if (scanQueued || !ready()) { + return; } - function durationLabel(hours) { - if (hours < 24) { - return hours + ' ' + t(hours === 1 ? 'hour' : 'hours'); + 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 {}; } - var d = hours / 24; - return d + ' ' + t(d === 1 ? 'day' : 'days'); + ); + } + 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; + }); } - window.ShareLinksClientVersion = clientVersion; + return userPromise; + } - function ready() { - return !!window.ApiClient && !!window.document && !!document.body; + 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; + }); } - function start() { - if (booted) { - return; - } + return guestStatePromise; + } - if (!ready()) { - if (!bootRetry) { - bootRetry = window.setTimeout(function () { - bootRetry = null; - start(); - }, 250); - } - return; - } + function hideBlockedGuestMenuItems() { + var nodes = document.querySelectorAll(".actionSheetMenuItem"); + Array.prototype.forEach.call(nodes, function (node) { + if (!node || node.getAttribute("data-sharelinks-blocked") === "1") { + return; + } - booted = true; - installHooks(); + 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"],' + + // The Modern layout (the default from Jellyfin 12) renders its own header + // instead of .skinHeader, so its controls need their own selectors here. + " body." + + guestClassName + + ' .MuiAppBar-root a[href^="#/"],' + + " body." + + guestClassName + + ' .MuiAppBar-root [aria-controls="app-user-menu"],' + + " body." + + guestClassName + + " .MuiAppBar-root .MuiToolbar-root > button:first-child," + + " 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; } - function installHooks() { - if (!historyPatched) { - historyPatched = true; - patchHistory(); - } + stripShareTagChips(); + hideShareTagFilters(); + } - 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; + 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; } - 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)) + removeChipSeparator(chip); + chip.remove(); + removed += 1; + } ); - return { - locked: locked, - allowedItemId: extractAllowedItemId(state) || sessionStorage.getItem(allowedItemStorageKey) || null, - username: username, - prefix: prefix, - lockdownEnabled: lockdownEnabled, - hiddenSelectors: (state && (state.HiddenSelectors || state.hiddenSelectors)) || '' - }; + + 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; } - function extractAllowedItemId(state) { - if (!state) { - return null; + 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; } - return state.AllowedItemId || state.allowedItemId || state.ItemId || state.itemId || state.ShareItemId || state.shareItemId || null; + 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; } - function ensureGuestStyle() { - if (document.body.classList.contains(guestClassName)) { - return; - } + 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)) + ); + } - 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"],' - // The Modern layout (the default from Jellyfin 12) renders its own header - // instead of .skinHeader, so its controls need their own selectors here. - + ' body.' + guestClassName + ' .MuiAppBar-root a[href^="#/"],' - + ' body.' + guestClassName + ' .MuiAppBar-root [aria-controls="app-user-menu"],' - + ' body.' + guestClassName + ' .MuiAppBar-root .MuiToolbar-root > button:first-child,' - + ' 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 isItemMenuAction(node) { + if (!node || !node.getAttribute) { + return false; } - 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); + var id = String(node.getAttribute("data-id") || "").toLowerCase(); + if (id && itemMenuActionIds.indexOf(id) >= 0) { + return true; } - 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']; + return isCopyStreamUrlLabel(getVisibleLabel(node)); + } - 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 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; + } + } } - function shouldHideNode(node, keywords) { - if (!node || node.getAttribute(hiddenAttr) === '1') { - return false; - } + return null; + } - var label = getVisibleLabel(node).toLowerCase(); - if (!label) { - return false; - } - - return keywords.some(function (word) { - return label.indexOf(word) >= 0; - }); + function looksLikeActionContainer(node) { + if ( + node.querySelector( + 'select, input:not([type="checkbox"]):not([type="radio"]), textarea' + ) + ) { + return false; } - function getVisibleLabel(node) { - return normalizeText(node.getAttribute('aria-label') || node.getAttribute('title') || node.textContent || ''); + var items = Array.from( + node.querySelectorAll(".actionSheetMenuItem, .actionsheetMenuItem") + ).filter(isVisible); + if (items.length < 2 || items.length > 40) { + return false; } - function normalizeText(value) { - return String(value || '').replace(/\s+/g, ' ').trim(); + 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; } - 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()); + var scroller = + container.querySelector(".actionSheetScroller") || template.parentElement; + if (!scroller) { + return; } - 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 divider = document.createElement("div"); + divider.className = "actionsheetDivider"; + divider.setAttribute(injectedAttr, "1"); - var container = findOpenActionContainer(); - if (!container || container.querySelector('[' + injectedAttr + '="1"]')) { - return; - } + var action = buildShareAction(template, itemId, container); - 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); + // 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; } - function removeInjectedActions() { - Array.prototype.forEach.call(document.querySelectorAll('[' + injectedAttr + '="1"]'), function (node) { - node.remove(); - }); + 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; } - /** - * 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; - } + action.textContent = actionLabel; + } - menuContext = null; + 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); } - function isMenuTrigger(node) { - if (!node || !node.matches) { - return false; - } + icon.className = + "actionsheetMenuItemIcon listItemIcon listItemIcon-transparent material-icons share"; + icon.style.removeProperty("visibility"); + } - if (node.matches(menuTriggerSelector)) { - return true; - } - - return node.tagName === 'BUTTON' && !!node.querySelector('.material-icons.more_vert'); + /** + * Dismisses the native action sheet so our dialog is not stacked on top of it. + * The sheet's own close button exists only on TV layouts. On desktop, jellyfin-web + * closes a sheet when its dialog container gets a mousedown and a click aimed at + * the container itself (a click on the backdrop), and ignores Escape outside the + * TV layout. Escape stays as the last fallback. + */ + function closeActionSheet(container) { + var closeButton = container.querySelector(".btnCloseActionSheet"); + if (closeButton) { + closeButton.click(); + return; } - function findItemIdInAncestors(node) { - var current = node; - while (current && current !== document) { - var id = readItemIdFromNode(current); - if (id) { - return id; - } - current = current.parentElement; - } - - return null; + var dialogContainer = + container.closest && container.closest(".dialogContainer"); + if (dialogContainer) { + dialogContainer.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true }) + ); + dialogContainer.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + return; } - /** 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; - } + container.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + keyCode: 27, + which: 27, + bubbles: true, + cancelable: true, + }) + ); + } - if (menuContext.itemId) { - return menuContext.itemId; + 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; } - - // 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; - } + return null; + } - if (Object.prototype.hasOwnProperty.call(itemTypeCache, id)) { - return shareableItemTypes.indexOf(itemTypeCache[id]) >= 0; - } + function findItemIdInDocument() { + var selectors = [ + "#itemDetailPage", + ".detailPage", + ".itemDetailPage", + "[data-itemid]", + "[data-id]", + ]; - if (!itemTypeInFlight[id]) { - itemTypeInFlight[id] = fetchItemType(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 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(); - }); + return null; + } + + function readItemIdFromNode(node) { + if (!node || !node.getAttribute) { + return null; } - /** - * 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; + 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; } - - 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; - } + return found; + } - 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'); - } - }); + /** + * 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; } - /** 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; + 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; + } - var previous = chip.previousSibling; - if (previous && previous.nodeType === 3 && separator.test(previous.nodeValue)) { - previous.parentNode.removeChild(previous); - } + pending = true; + if (!itemVisibilityInFlight[id]) { + itemVisibilityInFlight[id] = fetchItemVisibility(id); + } } - function hideShareTagFilters() { - Array.prototype.forEach.call(document.querySelectorAll('.chkTagFilter'), function (input) { - if (input.getAttribute(tagHiddenAttr) === '1') { - return; + // 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, multiUse) { + var payload = { + itemId: itemId, + expiryHours: expiryHours, + oneUse: !multiUse, + }; + + var shareUrlPromise = apiPost( + "ShareLinks/Admin/Create", + payload + ).then(function (response) { + var shareUrl = response && (response.ShareUrl || response.shareUrl); + if (!shareUrl) { + throw new Error(t("noShareUrl")); } - if (!shareTagPattern.test(String(input.getAttribute('data-filter') || ''))) { - return; - } + return shareUrl; + }); - 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. - * The sheet's own close button exists only on TV layouts. On desktop, jellyfin-web - * closes a sheet when its dialog container gets a mousedown and a click aimed at - * the container itself (a click on the backdrop), and ignores Escape outside the - * TV layout. Escape stays as the last fallback. - */ - function closeActionSheet(container) { - var closeButton = container.querySelector('.btnCloseActionSheet'); - if (closeButton) { - closeButton.click(); - return; - } - - var dialogContainer = container.closest && container.closest('.dialogContainer'); - if (dialogContainer) { - dialogContainer.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); - dialogContainer.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - 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; + var copiedPromise = copyTextWhenReady(shareUrlPromise); + return shareUrlPromise.then(function (shareUrl) { + return copiedPromise + .catch(function () { 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, multiUse) { - var payload = { - itemId: itemId, - expiryHours: expiryHours, - oneUse: !multiUse + }) + .then(function (copied) { + return { + shareUrl: shareUrl, + copied: copied, + multiUse: !!multiUse, }; - - 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, - multiUse: !!multiUse - }; - }); - }); - }); - if (!result) { - return; - } - - showShareResult(result.shareUrl, result.copied, result.multiUse); - } catch (error) { - notify(extractErrorMessage(error, t('couldNotCreate'))); + }); + }); } + ); + if (!result) { + return; + } + + showShareResult(result.shareUrl, result.copied, result.multiUse); + } 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; } - function pad2(value) { - return (value < 10 ? '0' : '') + value; + return openModal({ + title: t("modalTitle"), + body: t("modalBody"), + options: options, + onChoose: onChoose, + cancelText: t("cancel"), + toggle: { + label: t("multiUseLabel"), + hint: buildMultiUseHint(config), + checked: !(config && config.OneUseDefault !== false), + }, + datePicker: { + min: toLocalDatetimeValue(minDate), + max: toLocalDatetimeValue(maxDate), + value: toLocalDatetimeValue(defaultDate), + maxHours: maxHours, + }, + }); + } + + function buildMultiUseHint(config) { + var hint = t("multiUseHint"); + var limit = config ? parseInt(config.MaxConcurrentViewers, 10) : NaN; + if (Number.isFinite(limit) && limit > 0) { + hint += " " + t("multiUseLimit").replace("{count}", limit); } - function toLocalDatetimeValue(date) { - return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate()) - + 'T' + pad2(date.getHours()) + ':' + pad2(date.getMinutes()); - } + return hint; + } - 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 - }; + 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" }); }); - 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'), - toggle: { - label: t('multiUseLabel'), - hint: buildMultiUseHint(config), - checked: !(config && config.OneUseDefault !== false) - }, - datePicker: { - min: toLocalDatetimeValue(minDate), - max: toLocalDatetimeValue(maxDate), - value: toLocalDatetimeValue(defaultDate), - maxHours: maxHours - } - }); - } - - function buildMultiUseHint(config) { - var hint = t('multiUseHint'); - var limit = config ? parseInt(config.MaxConcurrentViewers, 10) : NaN; - if (Number.isFinite(limit) && limit > 0) { - hint += ' ' + t('multiUseLimit').replace('{count}', limit); - } - - return hint; - } - - 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 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); + } } - 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(textPromise).then(copyText); + } - return Promise.resolve(fallbackCopy(text)); + function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard + .writeText(text) + .then(function () { + return true; + }) + .catch(function () { + return 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); + 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, multiUse) { + 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); + + if (multiUse) { + var multiUseNote = document.createElement("p"); + multiUseNote.className = "sharelinks-note"; + multiUseNote.textContent = t("resultMultiUseNote"); + body.appendChild(multiUseNote); } - function showShareResult(shareUrl, copied, multiUse) { - 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); - if (multiUse) { - var multiUseNote = document.createElement('p'); - multiUseNote.className = 'sharelinks-note'; - multiUseNote.textContent = t('resultMultiUseNote'); - body.appendChild(multiUseNote); - } - - 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 () { + 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(); - } - }); - } - - 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); - } - - var toggleInput = null; - if (settings.toggle) { - var toggleRow = document.createElement('label'); - toggleRow.className = 'sharelinks-toggle-row'; - - toggleInput = document.createElement('input'); - toggleInput.type = 'checkbox'; - toggleInput.className = 'sharelinks-toggle-input'; - toggleInput.checked = !!settings.toggle.checked; - - var toggleText = document.createElement('span'); - toggleText.className = 'sharelinks-toggle-text'; - - var toggleLabel = document.createElement('span'); - toggleLabel.className = 'sharelinks-toggle-label'; - toggleLabel.textContent = settings.toggle.label; - toggleText.appendChild(toggleLabel); - - if (settings.toggle.hint) { - var toggleHint = document.createElement('span'); - toggleHint.className = 'sharelinks-toggle-hint'; - toggleHint.textContent = settings.toggle.hint; - toggleText.appendChild(toggleHint); - } - - toggleRow.appendChild(toggleInput); - toggleRow.appendChild(toggleText); - body.appendChild(toggleRow); - } - - function toggleChecked() { - return !!(toggleInput && toggleInput.checked); - } - - 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, toggleChecked()) : hours); - } - }); - } - - actions.push({ - label: settings.cancelText || t('cancel'), - close: true, - handler: function () { - resolve(null); - } + notify(t("toastManual")); + } + return ok; }); + }, + }, + { + label: t("done"), + close: true, + }, + ], + onOpen: function () { + urlBox.focus(); + urlBox.select(); + }, + }); + } - modal = openModalElement({ - title: settings.title, - bodyElement: body, - actions: actions, - onDismiss: function () { - resolve(null); - } - }); + function openModal(settings) { + var body = document.createElement("div"); + var text = document.createElement("p"); + text.className = "sharelinks-note"; + text.textContent = settings.body; + body.appendChild(text); - settings.options.forEach(function (option) { - var button = document.createElement('button'); - button.type = 'button'; - button.className = 'sharelinks-duration-button'; - button.textContent = option.label; + var grid = document.createElement("div"); + grid.className = "sharelinks-duration-grid"; + body.appendChild(grid); - button.addEventListener('click', function () { - modal.close(); - if (settings.onChoose) { - resolve(settings.onChoose(option.hours, toggleChecked())); - } else { - resolve(option.hours); - } - }); - grid.appendChild(button); - }); + 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); + } + + var toggleInput = null; + if (settings.toggle) { + var toggleRow = document.createElement("label"); + toggleRow.className = "sharelinks-toggle-row"; + + toggleInput = document.createElement("input"); + toggleInput.type = "checkbox"; + toggleInput.className = "sharelinks-toggle-input"; + toggleInput.checked = !!settings.toggle.checked; + + var toggleText = document.createElement("span"); + toggleText.className = "sharelinks-toggle-text"; + + var toggleLabel = document.createElement("span"); + toggleLabel.className = "sharelinks-toggle-label"; + toggleLabel.textContent = settings.toggle.label; + toggleText.appendChild(toggleLabel); + + if (settings.toggle.hint) { + var toggleHint = document.createElement("span"); + toggleHint.className = "sharelinks-toggle-hint"; + toggleHint.textContent = settings.toggle.hint; + toggleText.appendChild(toggleHint); + } + + toggleRow.appendChild(toggleInput); + toggleRow.appendChild(toggleText); + body.appendChild(toggleRow); + } + + function toggleChecked() { + return !!(toggleInput && toggleInput.checked); + } + + 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, toggleChecked()) + : hours + ); + }, }); - } + } - function openModalElement(settings) { - ensureShareLinksUi(); - var closed = false; - var overlay = document.createElement('div'); - overlay.className = 'sharelinks-overlay'; - overlay.setAttribute('role', 'presentation'); + actions.push({ + label: settings.cancelText || t("cancel"), + close: true, + handler: function () { + resolve(null); + }, + }); - 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); + modal = openModalElement({ + title: settings.title, + bodyElement: body, + actions: actions, + onDismiss: function () { + resolve(null); + }, + }); - var title = document.createElement('h3'); - title.className = 'sharelinks-title'; - title.textContent = settings.title; - dialog.appendChild(title); + settings.options.forEach(function (option) { + var button = document.createElement("button"); + button.type = "button"; + button.className = "sharelinks-duration-button"; + button.textContent = option.label; - 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); + button.addEventListener("click", function () { + modal.close(); + if (settings.onChoose) { + resolve(settings.onChoose(option.hours, toggleChecked())); + } else { + resolve(option.hours); + } }); + grid.appendChild(button); + }); + }); + } - overlay.addEventListener('click', function (event) { - if (event.target === overlay) { - close(); - if (settings.onDismiss) { - settings.onDismiss(); - } - } + 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(); + } }); - document.addEventListener('keydown', function onKeyDown(event) { - if (event.key === 'Escape' && !closed) { - document.removeEventListener('keydown', onKeyDown, true); - close(); - if (settings.onDismiss) { - settings.onDismiss(); - } - } - }, true); + }); + actions.appendChild(button); + }); - 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); + 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 + ); - return { close: close, element: overlay }; + 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; } - 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-toggle-row{display:flex;align-items:flex-start;gap:10px;margin-top:18px;padding-top:16px;border-top:1px solid rgba(255,255,255,.12);cursor:pointer;}", + ".sharelinks-toggle-input{margin:2px 0 0;width:18px;height:18px;flex:0 0 auto;accent-color:var(--theme-primary-color,#00a4dc);cursor:pointer;}", + ".sharelinks-toggle-text{display:flex;flex-direction:column;gap:4px;}", + ".sharelinks-toggle-label{line-height:1.35;}", + ".sharelinks-toggle-hint{color:var(--text-secondary-color,#cfcfcf);font-size:.88rem;line-height:1.4;}", + ".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); + } - 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-toggle-row{display:flex;align-items:flex-start;gap:10px;margin-top:18px;padding-top:16px;border-top:1px solid rgba(255,255,255,.12);cursor:pointer;}', - '.sharelinks-toggle-input{margin:2px 0 0;width:18px;height:18px;flex:0 0 auto;accent-color:var(--theme-primary-color,#00a4dc);cursor:pointer;}', - '.sharelinks-toggle-text{display:flex;flex-direction:column;gap:4px;}', - '.sharelinks-toggle-label{line-height:1.35;}', - '.sharelinks-toggle-hint{color:var(--text-secondary-color,#cfcfcf);font-size:.88rem;line-height:1.4;}', - '.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; } - 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; + if (typeof error === "string") { + return error; } - function clampPositiveInteger(value, fallback) { - var parsed = parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + if (error.responseJSON && error.responseJSON.error) { + return error.responseJSON.error; } - 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; + if (error.responseText) { + return error.responseText; } - start(); + if (error.message) { + return error.message; + } + + return fallback; + } + + start(); })(); diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..453454d --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,7 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 + +WORKDIR /src + +# Le code sera monté en volume, pas copié, pour que les modifs +# faites en local (et les sorties de build) restent synchronisées +ENTRYPOINT ["bash"]