diff --git a/manifest.json b/manifest.json index ee12cf4..931fa30 100644 --- a/manifest.json +++ b/manifest.json @@ -104,6 +104,17 @@ "css": [ "src/styles/content/tag-presets.scss" ] + }, + { + "matches": [ + "*://*.furbooru.org/duplicate_reports" + ], + "js": [ + "src/content/duplicate-reports.ts" + ], + "css": [ + "src/styles/content/duplicate-reports.scss" + ] } ], "action": { diff --git a/src/content/components/philomena/ImageContainer.ts b/src/content/components/philomena/ImageContainer.ts index 227da52..10430a5 100644 --- a/src/content/components/philomena/ImageContainer.ts +++ b/src/content/components/philomena/ImageContainer.ts @@ -24,4 +24,17 @@ export default class ImageContainer extends BaseComponent { return JSON.parse(jsonUris); } + + extractSources(): string[] { + const jsonSourceUrls = this.container.dataset.sourceUrls; + let sourceUrls: string[] | null = null; + + try { + sourceUrls = JSON.parse(jsonSourceUrls || '[]'); + } catch (e) { + console.warn('Failed to parse source URLs for the image!', this, e); + } + + return sourceUrls || []; + } } diff --git a/src/content/components/philomena/dupe/DupeDiff.ts b/src/content/components/philomena/dupe/DupeDiff.ts new file mode 100644 index 0000000..636411b --- /dev/null +++ b/src/content/components/philomena/dupe/DupeDiff.ts @@ -0,0 +1,86 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import { createFontAwesomeIcon } from "$lib/dom-utils"; + +const brandsSubtype = 'brands'; + +const blueskyIcon = ['bluesky', brandsSubtype]; +const twitterIcon = ['x-twitter', brandsSubtype]; + +export default class DupeDiff extends BaseComponent { + #sourcesLine: HTMLElement | null = null; + + protected init() { + const { + "6": sourcesLine, + } = this.container.querySelectorAll('table tr > td') + + this.#sourcesLine = sourcesLine; + } + + renderSources(leftSources: string[], rightSources: string[]): void { + if (!this.#sourcesLine) { + console.error("Can't render sources since no sources line in diff fonud!"); + return; + } + + for (const childElement of this.#sourcesLine.children) { + childElement.remove(); + } + + const sourceIconsContainer = document.createElement('span'); + sourceIconsContainer.classList.add('source-icons'); + + sourceIconsContainer.append( + this.#renderSourceIcons(leftSources), + " vs ", + this.#renderSourceIcons(rightSources), + ); + + this.#sourcesLine.append(sourceIconsContainer); + } + + #renderSourceIcons(sourcesList: string[]): HTMLElement { + const iconsContainer = document.createElement('span'); + iconsContainer.classList.add('source-icons__list'); + + for (const sourceUrl of sourcesList) { + iconsContainer.append(this.#renderIcon(sourceUrl)); + } + + if (!sourcesList.length) { + iconsContainer.append('(none)'); + } + + return iconsContainer; + } + + #renderIcon(url: string): HTMLElement { + let iconSlug = 'globe'; + let maybeSubtype: string | undefined; + + for (const [iconOrIconWithSubtype, singleOrMultiPattern] of DupeDiff.#iconsToPatternsMap) { + if ( + typeof singleOrMultiPattern === 'string' && url.includes(singleOrMultiPattern) + || Array.isArray(singleOrMultiPattern) && singleOrMultiPattern.some(singlePattern => url.includes(singlePattern)) + ) { + if (Array.isArray(iconOrIconWithSubtype)) { + [iconSlug, maybeSubtype] = iconOrIconWithSubtype; + } else { + iconSlug = iconOrIconWithSubtype; + } + break; + } + } + + const sourceIcon = createFontAwesomeIcon(iconSlug, maybeSubtype); + sourceIcon.title = url; + + return sourceIcon; + } + + static #iconsToPatternsMap = new Map([ + [blueskyIcon, 'bsky.app'], + ['paw', 'furaffinity.net'], + [twitterIcon, ['x.com', 'twitter.com']], + ]); +} diff --git a/src/content/components/philomena/dupe/DupeImage.ts b/src/content/components/philomena/dupe/DupeImage.ts new file mode 100644 index 0000000..b6f93d7 --- /dev/null +++ b/src/content/components/philomena/dupe/DupeImage.ts @@ -0,0 +1,19 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import ImageContainer from "$content/components/philomena/ImageContainer"; + +export default class DupeImage extends BaseComponent { + readonly imageContainer: ImageContainer; + + constructor(container: HTMLElement) { + super(container); + + const imageContainerElement = container.querySelector('.image-container'); + + if (!imageContainerElement) { + throw new Error('Missing image container inside the dupe row!'); + } + + this.imageContainer = new ImageContainer(imageContainerElement); + this.imageContainer.initialize(); + } +} diff --git a/src/content/components/philomena/dupe/DupeReportRow.ts b/src/content/components/philomena/dupe/DupeReportRow.ts new file mode 100644 index 0000000..1fcdbf7 --- /dev/null +++ b/src/content/components/philomena/dupe/DupeReportRow.ts @@ -0,0 +1,34 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import DupeDiff from "$content/components/philomena/dupe/DupeDiff"; +import DupeImage from "$content/components/philomena/dupe/DupeImage"; + +export default class DupeReportRow extends BaseComponent { + #leftImage: DupeImage; + #rightImage: DupeImage; + #difference: DupeDiff; + + constructor(leftCell: HTMLElement, rightCell: HTMLElement, diffCell: HTMLElement, reportOptions: HTMLElement) { + super(reportOptions); + + this.#leftImage = new DupeImage(leftCell); + this.#rightImage = new DupeImage(rightCell); + this.#difference = new DupeDiff(diffCell); + } + + protected build() { + this.#leftImage.initialize(); + this.#rightImage.initialize(); + this.#difference.initialize(); + } + + protected init() { + this.#extractAndRenderSourcesIcons(); + } + + #extractAndRenderSourcesIcons() { + this.#difference.renderSources( + this.#leftImage.imageContainer.extractSources(), + this.#rightImage.imageContainer.extractSources(), + ); + } +} diff --git a/src/content/components/philomena/dupe/GridDupeReportsList.ts b/src/content/components/philomena/dupe/GridDupeReportsList.ts new file mode 100644 index 0000000..1e8db67 --- /dev/null +++ b/src/content/components/philomena/dupe/GridDupeReportsList.ts @@ -0,0 +1,49 @@ +import { BaseComponent } from "$content/components/base/BaseComponent"; +import DupeReportRow from "$content/components/philomena/dupe/DupeReportRow"; + +export default class GridDupeReportsList extends BaseComponent { + readonly #reports: DupeReportRow[] = []; + + protected build() { + const childrenElements = this.container.children; + + for (let cellIndex = 0; cellIndex < this.container.childElementCount; cellIndex += 4) { + const startingCell = childrenElements.item(cellIndex); + + // First 4 cells are actually table headers, skipping them. + if (!(startingCell instanceof HTMLElement) || startingCell.tagName === 'P') { + continue; + } + + const rightImageCell = startingCell.nextElementSibling; + const diffCell = rightImageCell?.nextElementSibling || null; + const reportOptionsCell = diffCell?.nextElementSibling || null; + + if (!(rightImageCell instanceof HTMLElement) || !(diffCell instanceof HTMLElement) || !(reportOptionsCell instanceof HTMLElement)) { + console.error(`Unable to capture duplicate report row from starting cell at index ${cellIndex}!`); + continue; + } + + this.#reports.push( + new DupeReportRow( + startingCell, + rightImageCell, + diffCell, + reportOptionsCell, + ) + ); + } + } + + protected init() { + for (const report of this.#reports) { + report.initialize(); + } + } + + static findAndInitialize() { + for (const container of document.querySelectorAll('.grid--dupe-report-list')) { + new GridDupeReportsList(container).initialize(); + } + } +} diff --git a/src/content/duplicate-reports.ts b/src/content/duplicate-reports.ts new file mode 100644 index 0000000..866a71b --- /dev/null +++ b/src/content/duplicate-reports.ts @@ -0,0 +1,3 @@ +import GridDupeReportsList from "$content/components/philomena/dupe/GridDupeReportsList"; + +GridDupeReportsList.findAndInitialize(); diff --git a/src/styles/content/duplicate-reports.scss b/src/styles/content/duplicate-reports.scss new file mode 100644 index 0000000..7cb33e5 --- /dev/null +++ b/src/styles/content/duplicate-reports.scss @@ -0,0 +1,10 @@ +.dr__diff { + .source-icons { + margin-left: .5em; + + &__list { + display: inline-flex; + gap: .25em; + } + } +}