diff --git a/plugins/cjCardTweaks/README.md b/plugins/cjCardTweaks/README.md
index 8de12288..f68c5235 100644
--- a/plugins/cjCardTweaks/README.md
+++ b/plugins/cjCardTweaks/README.md
@@ -20,3 +20,8 @@ Adds an additional dimension to the rating banners.

Modify the performer cards to use a traditional profile design
+
+### Stash ID icon
+
+
+Adds a box icon to performer cards that have one or more Stash IDs (GUIDs) attached. The icon appears in the top-left corner of the performer card thumbnail and displays a tooltip showing the count of Stash IDs when hovered. This helps quickly identify performers that are linked to external Stash databases.
diff --git a/plugins/cjCardTweaks/cjCardTweaks.js b/plugins/cjCardTweaks/cjCardTweaks.js
index 923e5640..beae48f6 100644
--- a/plugins/cjCardTweaks/cjCardTweaks.js
+++ b/plugins/cjCardTweaks/cjCardTweaks.js
@@ -26,7 +26,8 @@
if (
key === "fileCount" ||
key === "addBannerDimension" ||
- key === "performerProfileCards"
+ key === "performerProfileCards" ||
+ key === "stashIDIcon"
) {
acc[key] = settings[key];
} else {
@@ -42,6 +43,8 @@
".performer-card:hover img.performer-card-image{box-shadow: 0 0 0 rgb(0 0 0 / 20%), 0 0 6px rgb(0 0 0 / 90%);transition: box-shadow .5s .5s}@media (min-width: 1691px){.performer-recommendations .card .performer-card-image{height: unset}}button.btn.favorite-button.not-favorite,button.btn.favorite-button.favorite{transition: filter .5s .5s}.performer-card:hover .thumbnail-section button.btn.favorite-button.not-favorite, .performer-card:hover .thumbnail-section button.btn.favorite-button.favorite{filter: drop-shadow(0 0 2px rgba(0, 0, 0, .9))}.performer-card .thumbnail-section button.btn.favorite-button.not-favorite, .performer-card .thumbnail-section button.btn.favorite-button.favorite{top: 10px;filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .9))}.item-list-container .performer-card__age,.recommendation-row .performer-card__age,.item-list-container .performer-card .card-section-title,.recommendation-row .performer-card .card-section-title,.item-list-container .performer-card .thumbnail-section,.recommendation-row .performer-card .thumbnail-section{display: flex;align-content: center;justify-content: center}.item-list-container .performer-card .thumbnail-section a,.recommendation-row .performer-card .thumbnail-section a{display: contents}.item-list-container .performer-card-image,.recommendation-row .performer-card-image{aspect-ratio: 1 / 1;display: flex;object-fit: cover;border: 3px solid var(--plex-yelow);border-radius: 50%;min-width: unset;position: relative;width: 58%;margin: auto;z-index: 1;margin-top: 1.5rem;box-shadow:0 13px 26px rgb(0 0 0 / 20%),0 3px 6px rgb(0 0 0 / 90%);object-position: center;transition: box-shadow .5s .5s}.item-list-container .performer-card hr,.recommendation-row .performer-card hr{width: 90%}.item-list-container .performer-card .fi,.recommendation-row .performer-card .fi{position: absolute;top: 81.5%;left: 69%;border-radius: 50% !important;background-size: cover;margin-left: -1px;height: 1.5rem;width: 1.5rem;z-index: 10;border: solid 2px #252525;box-shadow: unset}.item-list-container .performer-card .card-popovers .btn,.recommendation-row .performer-card .card-popovers .btn{font-size: 0.9rem}";
const RATING_BANNER_3D_STYLE =
".grid-card{overflow:unset}.detail-group .rating-banner-3d,.rating-banner{display:none}.grid-card:hover .rating-banner-3d{opacity:0;transition:opacity .5s}.rating-banner-3d{height:110px;left:-6px;overflow:hidden;position:absolute;top:-6px;width:110px}.rating-banner-3d span{box-shadow:0 5px 4px rgb(0 0 0 / 50%);position:absolute;display:block;width:170px;padding:10px 5px 10px 0;background-color:#ff6a07;color:#fff;font:700 1rem/1 Lato,sans-serif;text-shadow:0 1px 1px rgba(0,0,0,.2);text-transform:uppercase;text-align:center;letter-spacing:1px;right:-20px;top:24px;transform:rotate(-45deg)}.rating-banner-3d::before{top:0;right:0;position:absolute;z-index:-1;content:'';display:block;border:5px solid #a34405;border-top-color:transparent;border-left-color:transparent}.rating-banner-3d::after{bottom:0;left:0;position:absolute;z-index:-1;content:'';display:block;border:5px solid #963e04}";
+ const STASH_ID_ICON_STYLE =
+ ".stash-id-count{display:inline-flex;align-items:center;flex-direction:row}.stash-id-count-number{display:inline-block;margin-right:0.25rem}.stash-id-icon{display:inline-flex;align-items:center}.stash-id-icon svg{width:0.875rem;height:0.875rem;fill:currentColor;color:#fff}";
/**
* Element to inject custom CSS styles.
@@ -54,6 +57,8 @@
styleElement.innerHTML += RATING_BANNER_3D_STYLE;
if (SETTINGS.performerProfileCards)
styleElement.innerHTML += PERFORMER_PROFILE_CARD_STYLE;
+ if (SETTINGS.stashIDIcon)
+ styleElement.innerHTML += STASH_ID_ICON_STYLE;
function createElementFromHTML(htmlString) {
const div = document.createElement("div");
@@ -93,7 +98,7 @@
}
/**
- * Handles gallery cards to specific paths in Stash.
+ * Handles gallery cards to specific paths in Stash.
*
* The supported paths are:
* - /galleries
@@ -207,6 +212,36 @@
cards.forEach((card) => {
maybeAddFileCount(card, stashData, isContentCard);
maybeAddDimensionToBanner(card);
+ if (cardClass === "performer-card") {
+ maybeAddStashIDIcon(card, stashData);
+
+ // Also set up a MutationObserver to watch for card-popovers being added
+ if (SETTINGS.stashIDIcon && !card.querySelector(".stash-id-count")) {
+ const observer = new MutationObserver((mutations) => {
+ const cardPopovers = card.querySelector(".card-popovers.btn-group") ||
+ card.querySelector(".card-popovers") ||
+ card.querySelector('[role="group"].btn-group');
+ if (cardPopovers && !cardPopovers.querySelector(".stash-id-count")) {
+ const link = card.querySelector(".thumbnail-section > a");
+ if (link) {
+ const id = new URL(link.href).pathname.split("/").pop();
+ const idNum = parseInt(id, 10);
+ // Query GraphQL for stash IDs
+ queryStashIDs(card, id, idNum);
+ observer.disconnect();
+ }
+ }
+ });
+
+ observer.observe(card, {
+ childList: true,
+ subtree: true
+ });
+
+ // Disconnect after 5 seconds to avoid memory leaks
+ setTimeout(() => observer.disconnect(), 5000);
+ }
+ }
});
}
@@ -269,4 +304,113 @@
link.parentElement.appendChild(el);
oldBanner.remove();
}
+
+ /**
+ * Add Stash ID count and icon to performer cards in the card-popovers btn-group
+ *
+ * @param {Element} card - Card element from cards list.
+ * @param {Object} stashData - Data fetched from the GraphQL interceptor. e.g. stash.performers.
+ */
+ function maybeAddStashIDIcon(card, stashData) {
+ if (!SETTINGS.stashIDIcon) return;
+
+ // Verify this function was not run twice on the same card
+ const existingCount = card.querySelector(".stash-id-count");
+ if (existingCount) return;
+
+ const link = card.querySelector(".thumbnail-section > a");
+ if (!link) return;
+
+ const id = new URL(link.href).pathname.split("/").pop();
+ const idNum = parseInt(id, 10);
+
+ // Query GraphQL for stash IDs
+ queryStashIDs(card, id, idNum);
+ }
+
+ /**
+ * Query GraphQL for performer stash IDs
+ * @param {Element} card - Card element
+ * @param {string} id - Performer ID as string
+ * @param {number} idNum - Performer ID as number
+ */
+ async function queryStashIDs(card, id, idNum) {
+ const query = `
+ query FindPerformer($id: ID!) {
+ findPerformer(id: $id) {
+ id
+ stash_ids {
+ endpoint
+ stash_id
+ }
+ }
+ }
+ `;
+
+ const variables = {
+ id: idNum
+ };
+
+ try {
+ const response = await fetch('/graphql', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ query: query,
+ variables: variables
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.errors) return;
+
+ const performer = result.data?.findPerformer;
+ if (!performer) return;
+
+ const stashIDs = performer.stash_ids || [];
+ const stashIDCount = Array.isArray(stashIDs) ? stashIDs.length : 0;
+
+ // Only show if count is greater than 0
+ if (stashIDCount > 0) {
+ // Find card-popovers and add button
+ const cardPopovers = card.querySelector(".card-popovers.btn-group") ||
+ card.querySelector(".card-popovers") ||
+ card.querySelector('[role="group"].btn-group');
+
+ if (cardPopovers && !cardPopovers.querySelector(".stash-id-count")) {
+ addStashIDButton(cardPopovers, stashIDCount);
+ }
+ }
+ } catch (error) {
+ // On error, don't show anything (silent fail)
+ }
+ }
+
+ /**
+ * Helper function to add the stash ID button to the card-popovers
+ */
+ function addStashIDButton(cardPopovers, stashIDCount) {
+ // Check if already added
+ if (cardPopovers.querySelector(".stash-id-count")) return;
+
+ // Box-open icon SVG (StashApp logo style - open box)
+ const boxIconSVG = ``;
+
+ // Create a wrapper div similar to the tag-count structure
+ const wrapper = document.createElement("div");
+
+ // Create button with count FIRST, then icon (as requested)
+ const button = createElementFromHTML(
+ ``
+ );
+
+ wrapper.appendChild(button);
+ cardPopovers.appendChild(wrapper);
+ }
})();
diff --git a/plugins/cjCardTweaks/cjCardTweaks.yml b/plugins/cjCardTweaks/cjCardTweaks.yml
index f24ff003..3fd2ad3f 100644
--- a/plugins/cjCardTweaks/cjCardTweaks.yml
+++ b/plugins/cjCardTweaks/cjCardTweaks.yml
@@ -24,3 +24,7 @@ settings:
displayName: Performer profile cards
description: "Tweaks performer cards to use a traditional profile design."
type: BOOLEAN
+ stashIDIcon:
+ displayName: Stash ID icon
+ description: "Adds a Stash ID icon to the performer cards."
+ type: BOOLEAN
\ No newline at end of file