// ==UserScript==
// @name AO3 FicTracker
// @author infiniMotis
// @version 1.1.5
// @namespace https://github.com/infiniMotis/AO3-FicTracker
// @description Enhances the AO3 experience by allowing users to track their favorite, finished, and to-read fanfics. The tracking data is stored as bookmark tags, ensuring sync with the user's AO3 profile across devices, making it resilient to cache clearing. Custom to-read tag enables users to filter and search through their tracked fanfics. Tracked works are highlighted on search and listing pages, making them easy to spot. All options are fully customizable through UI on the preferences page
// @license GNU GPLv3
// @icon https://archiveofourown.org/favicon.ico
// @match *://archiveofourown.org/*
// @run-at document-end
// @grant GM_getResourceText
// @resource settingsPanelHtml https://raw.githubusercontent.com/infiniMotis/AO3-FicTracker/refs/heads/main/settingsPanel.html
// @supportURL https://github.com/infiniMotis/AO3-FicTracker/issues
// @contributionURL https://ko-fi.com/infinimotis
// @contributionAmount 1 USD
// ==/UserScript==
// Description:
// FicTracker is designed for you to effectively manage their fanfics on AO3.
// It allows you to mark fics as finished, favorite, or to-read, providing an easy way to organize their reading list.
// Key Features:
// **Custom "To-Read" Feature:** Users can filter and search through their to-read list, enhancing the experience beyond AO3's default functionality.
// **Data Synchronization:** Information is linked to the user's AO3 account, enabling seamless syncing across devices. This means users can access their tracked fics anywhere without worrying about data loss or cache clearing.
// **User-Friendly Access:** Users can conveniently access tracking options from a dropdown menu, making the process intuitive and straightforward.
// **Optimized:** The script runs features only on relevant pages, ensuring quick and efficient performance.
// Usage Instructions:
// 1. **Tracking Fics:** On the fics page, click the button to mark a work as finished, favorite, or to-read.
// 2. **Settings Panel:** At the end of the user preferences page, you will find a settings panel to customize your tracking options.
// 3. **Accessing Your Lists:** In the dropdown menu at the top right corner, you'll find links to your tracked lists for easy access.
(function() {
'use strict';
// Default script settings
let settings = {
statuses: [{
tag: 'Finished Reading',
dropdownLabel: 'My Finished Fanfics',
positiveLabel: '✔️ Mark as Finished',
negativeLabel: '🗑️ Remove from Finished',
selector: 'finished_reading_btn',
storageKey: 'FT_finished',
highlight: true,
highlightColor: "#000",
borderSize: 2
},
{
tag: 'Favorite',
dropdownLabel: 'My Favorite Fanfics',
positiveLabel: '❤️ Mark as Favorite',
negativeLabel: '💔 Remove from Favorites',
selector: 'favorite_btn',
storageKey: 'FT_favorites',
highlight: true,
highlightColor: "#F95454",
borderSize: 2
},
{
tag: 'To Read',
dropdownLabel: 'My To Read Fanfics',
positiveLabel: '📚 Mark as To Read',
negativeLabel: '🧹 Remove from To Read',
selector: 'to_read_btn',
storageKey: 'FT_toread',
highlight: true,
highlightColor: "#3BA7C4",
borderSize: 2
}
],
loadingLabel: '⏳Loading...',
hideDefaultToreadBtn: true,
newBookmarksPrivate: true,
newBookmarksRec: false,
lastExportTimestamp: null,
bottom_action_buttons: true,
debug: false
};
// Toggle debug info
let DEBUG = settings.debug;
// Utility class for injecting CSS
class StyleManager {
// Method to add custom styles to the page
static addCustomStyles(styles) {
const customStyle = document.createElement('style');
customStyle.innerHTML = styles;
document.head.appendChild(customStyle);
DEBUG && console.info('[FicTracker] Custom styles added successfully.');
}
}
// Class for handling API requests
class RequestManager {
constructor(baseApiUrl) {
this.baseApiUrl = baseApiUrl;
}
// Send an API request with the specified method
sendRequest(url, formData, headers, method = "POST") {
return fetch(url, {
method: method,
mode: "cors",
credentials: "include",
headers: headers,
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response;
})
.catch(error => {
DEBUG && console.error('[FicTracker] Error during API request:', error);
throw error;
});
}
// Create a bookmark for fanfic with given data
createBookmark(workId, authenticityToken, bookmarkData) {
const url = `${this.baseApiUrl}/works/${workId}/bookmarks`;
const headers = this.getRequestHeaders();
const formData = this.createFormData(authenticityToken, bookmarkData);
DEBUG && console.info('[FicTracker] Sending CREATE request for bookmark:', {
url,
headers,
bookmarkData
});
return this.sendRequest(url, formData, headers)
.then(response => {
if (response.ok) {
const bookmarkId = response.url.split('/').pop();
DEBUG && console.log('[FicTracker] Created bookmark ID:', bookmarkId);
return bookmarkId;
} else {
throw new Error("Failed to create bookmark. Status: " + response.status);
}
})
.catch(error => {
DEBUG && console.error('[FicTracker] Error creating bookmark:', error);
throw error;
});
}
// Update a bookmark for fanfic with given data
updateBookmark(bookmarkId, authenticityToken, updatedData) {
const url = `${this.baseApiUrl}/bookmarks/${bookmarkId}`;
const headers = this.getRequestHeaders();
const formData = this.createFormData(authenticityToken, updatedData, 'update');
DEBUG && console.info('[FicTracker] Sending UPDATE request for bookmark:', {
url,
headers,
updatedData
});
return this.sendRequest(url, formData, headers)
.then(data => {
DEBUG && console.log('[FicTracker] Bookmark updated successfully:', data);
})
.catch(error => {
DEBUG && console.error('[FicTracker] Error updating bookmark:', error);
});
}
// Delete a bookmark by ID
deleteBookmark(bookmarkId, authenticityToken) {
const url = `${this.baseApiUrl}/bookmarks/${bookmarkId}`;
const headers = this.getRequestHeaders();
// FormData for this one is minimalist, method call is not needed
const formData = new FormData();
formData.append('authenticity_token', authenticityToken);
formData.append('_method', 'delete');
DEBUG && console.info('[FicTracker] Sending DELETE request for bookmark:', {
url,
headers,
authenticityToken
});
return this.sendRequest(url, formData, headers)
.then(data => {
DEBUG && console.log('[FicTracker] Bookmark deleted successfully:', data);
})
.catch(error => {
DEBUG && console.error('[FicTracker] Error deleting bookmark:', error);
});
}
// Retrieve the request headers
getRequestHeaders() {
const headers = {
"Accept": "text/html", // Accepted content type
"Cache-Control": "no-cache", // Prevent caching
"Pragma": "no-cache", // HTTP 1.0 compatibility
};
DEBUG && console.log('[FicTracker] Retrieving request headers:', headers);
return headers;
}
// Create FormData for bookmarking actions based on action type
createFormData(authenticityToken, bookmarkData, type = 'create') {
const formData = new FormData();
// Append required data to FormData
formData.append('authenticity_token', authenticityToken);
formData.append("bookmark[pseud_id]", bookmarkData.pseudId);
formData.append("bookmark[bookmarker_notes]", bookmarkData.notes);
formData.append("bookmark[tag_string]", bookmarkData.tags.join(','));
formData.append("bookmark[collection_names]", bookmarkData.collections.join(','));
formData.append("bookmark[private]", +bookmarkData.isPrivate);
formData.append("bookmark[rec]", +bookmarkData.isRec);
// Append action type
formData.append("commit", type === 'create' ? "Create" : "Update");
if (type === 'update') {
formData.append("_method", "put");
}
DEBUG && console.log('[FicTracker] FormData created successfully:');
DEBUG && console.table(Array.from(formData.entries()));
return formData;
}
}
// Class for managing storage caching
class StorageManager {
// Store a value in local storage
setItem(key, value) {
localStorage.setItem(key, value);
}
// Retrieve a value from local storage
getItem(key) {
const value = localStorage.getItem(key);
return value;
}
// Add an ID to a specific category
addIdToCategory(category, id) {
const existingIds = this.getItem(category);
const idsArray = existingIds ? existingIds.split(',') : [];
if (!idsArray.includes(id)) {
idsArray.push(id);
this.setItem(category, idsArray.join(',')); // Update the category with new ID
DEBUG && console.debug(`[FicTracker] Added ID to category "${category}": ${id}`);
}
}
// Remove an ID from a specific category
removeIdFromCategory(category, id) {
const existingIds = this.getItem(category);
const idsArray = existingIds ? existingIds.split(',') : [];
const idx = idsArray.indexOf(id);
if (idx !== -1) {
idsArray.splice(idx, 1); // Remove the ID
this.setItem(category, idsArray.join(',')); // Update the category
DEBUG && console.debug(`[FicTracker] Removed ID from category "${category}": ${id}`);
}
}
// Get IDs from a specific category
getIdsFromCategory(category) {
const existingIds = this.getItem(category) || '';
const idsArray = existingIds.split(',');
DEBUG && console.debug(`[FicTracker] Retrieved IDs from category "${category}"`);
return idsArray;
}
}
// Class for managing bookmark status updates
class BookmarkManager {
constructor(baseApiUrl) {
this.requestManager = new RequestManager(baseApiUrl);
this.storageManager = new StorageManager();
// Extract bookmark-related data from the DOM
this.workId = this.getWorkId();
this.bookmarkId = this.getBookmarkId();
this.pseudId = this.getPseudId();
this.bookmarkDataItems = document.querySelectorAll('#bookmark-form form dd');
DEBUG && console.log(`[FicTracker] Retrieved bookmark data items #:`, this.bookmarkDataItems.length);
this.bookmarkNotes = this.bookmarkDataItems[0].querySelector('textarea').innerHTML;
this.bookmarkTags = Array.from(this.bookmarkDataItems[1].querySelectorAll('li.added.tag')).map(element => {
return element.textContent.slice(0, -2).trim();
});
this.bookmarkCollections = Array.from(this.bookmarkDataItems[2].querySelectorAll('li.added.tag')).map(element => {
return element.textContent.slice(0, -2).trim();
});
this.isBookmarkPrivate = document.querySelector('#bookmark_private').checked;
this.isBookmarkRec = document.querySelector('#bookmark_rec').checked;
DEBUG && console.log(`[FicTracker] Initialized BookmarkManager with data:`);
DEBUG && console.table({
bookmarkId: this.bookmarkId,
notes: this.bookmarkNotes,
tags: this.bookmarkTags,
collections: this.bookmarkCollections,
isPrivate: this.isBookmarkPrivate,
isRec: this.isBookmarkRec,
})
// Hide the default "to read" button if specified in settings
if (settings.hideDefaultToreadBtn) {
document.querySelector('li.mark').style.display = "none";
}
this.addButtons();
}
// Add action buttons to the UI for each status
addButtons() {
const actionsMenu = document.querySelector('ul.work.navigation.actions');
const bottomActionsMenu = document.querySelector('div#feedback > ul');
settings.statuses.forEach(({
tag,
positiveLabel,
negativeLabel,
selector
}) => {
const isTagged = this.bookmarkTags.includes(tag);
const buttonHtml = `<li class="mark-as-read" id="${selector}"><a href="#">${isTagged ? negativeLabel : positiveLabel}</a></li>`;
actionsMenu.insertAdjacentHTML('beforeend', buttonHtml);
// insert button duplicate at the bottom
if (settings.bottom_action_buttons) {
bottomActionsMenu.insertAdjacentHTML('beforeend', buttonHtml);
}
});
this.setupClickListeners();
}
// Set up click listeners for each action button
setupClickListeners() {
settings.statuses.forEach(({ selector, tag, positiveLabel, negativeLabel, storageKey }) => {
// Use querySelectorAll to get all elements with the duplicate ID (bottom menu)
document.querySelectorAll(`#${selector}`).forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
this.handleActionButton(tag, positiveLabel, negativeLabel, selector, storageKey);
});
});
});
}
// Handle the action for adding/removing/deleting a bookmark tag
async handleActionButton(tag, positiveLabel, negativeLabel, selector, storageKey) {
const authenticityToken = this.getAuthenticityToken();
const bookmarkData = this.getBookmarkData();
// Consider button bottom menu duplication
const buttons = document.querySelectorAll(`#${selector} a`);
// Disable the buttons and show loading state
buttons.forEach((btn) => {
btn.innerHTML = settings.loadingLabel;
btn.disabled = true;
});
try {
const isTagPresent = this.bookmarkTags.includes(tag);
// Toggle the bookmark tag and log the action
if (isTagPresent) {
console.log(`[FicTracker] Removing tag: ${tag}`);
this.bookmarkTags.splice(this.bookmarkTags.indexOf(tag), 1);
this.storageManager.removeIdFromCategory(storageKey, this.workId);
} else {
console.log(`[FicTracker] Adding tag: ${tag}`);
this.bookmarkTags.push(tag);
this.storageManager.addIdToCategory(storageKey, this.workId);
}
// If the bookmark exists - update it, if not - create a new one
if (this.workId !== this.bookmarkId) {
// Update the existing bookmark
await this.requestManager.updateBookmark(this.bookmarkId, authenticityToken, bookmarkData);
} else {
// Create a new bookmark
bookmarkData.isPrivate = settings.newBookmarksPrivate;
bookmarkData.isRec = settings.newBookmarksRec;
this.bookmarkId = await this.requestManager.createBookmark(this.workId, authenticityToken, bookmarkData);
DEBUG && console.log(`[FicTracker] Created bookmark ID: ${this.bookmarkId}`);
}
// Update the labels for all buttons
buttons.forEach((btn) => {
btn.innerHTML = isTagPresent ? positiveLabel : negativeLabel;
});
} catch (error) {
console.error(`[FicTracker] Error during bookmark operation:`, error);
buttons.forEach((btn) => {
btn.innerHTML = 'Error! Try Again';
});
} finally {
buttons.forEach((btn) => {
btn.disabled = false;
});
}
}
// Get the work ID from the DOM
getWorkId() {
return document.getElementById('kudo_commentable_id')?.value || null;
}
// Get the bookmark ID from the form's action attribute
getBookmarkId() {
const bookmarkForm = document.querySelector('div#bookmark_form_placement form');
return bookmarkForm ? bookmarkForm.getAttribute('action').split('/')[2] : null;
}
// Get the pseud ID from the input
getPseudId() {
let singlePseud = document.querySelector('input#bookmark_pseud_id');
if (singlePseud) {
return singlePseud.value;
} else {
// If user has multiple pseuds - use the default one to create bookmark
let pseudSelect = document.querySelector('select#bookmark_pseud_id');
return pseudSelect.value;
}
}
// Gather all bookmark-related data into an obj
getBookmarkData() {
return {
workId: this.workId,
id: this.bookmarkId,
pseudId: this.pseudId,
items: this.bookmarkDataItems,
notes: this.bookmarkNotes,
tags: this.bookmarkTags,
collections: this.bookmarkCollections,
isPrivate: this.isBookmarkPrivate,
isRec: this.isBookmarkRec
};
}
// Retrieve the authenticity token from a meta tag
getAuthenticityToken() {
const metaTag = document.querySelector('meta[name="csrf-token"]');
return metaTag ? metaTag.getAttribute('content') : null;
}
}
// Class for handling features on works list page
class WorksListHandler {
constructor() {
this.storageManager = new StorageManager();
// Retrieve stored IDs for different statuses
this.finishedReadingIds = this.storageManager.getIdsFromCategory(settings.statuses[0].storageKey);
this.favoriteWorksIds = this.storageManager.getIdsFromCategory(settings.statuses[1].storageKey);
this.toReadWorksIds = this.storageManager.getIdsFromCategory(settings.statuses[2].storageKey);
// Update the work list upon initialization
this.updateWorkList();
}
// Execute features for each work on the page
updateWorkList() {
const works = document.querySelectorAll('li.work.blurb, li.bookmark.blurb');
works.forEach(work => {
const workId = this.getWorkId(work);
// Only status highlighting for now, TBA
this.highlightWorkStatus(work, workId);
});
}
// Get the work ID from DOM
getWorkId(work) {
const link = work.querySelector('h4.heading a');
const workId = link.href.split('/').pop();
return workId;
}
// Change the visuals of each work's status
highlightWorkStatus(work, workId) {
if (this.favoriteWorksIds.includes(workId)) {
this.highlightFavorite(work);
}
if (this.toReadWorksIds.includes(workId)) {
this.highlightToRead(work);
}
if (this.finishedReadingIds.includes(workId)) {
this.highlightFinishedReading(work);
}
}
// Highlight the work as a favorite
highlightFavorite(work) {
this.addStatusEmoji(work, '❤️');
work.classList.add('glowing-border-favorite');
}
// Highlight the work as "to read"
highlightToRead(work) {
this.addStatusEmoji(work, '📚');
work.classList.add('glowing-border-toread');
}
// Highlight the work as finished reading
highlightFinishedReading(work) {
this.addStatusEmoji(work, '✔️');
work.style.opacity = '0.3'; // Dim the work to indicate completion
}
// Add an Emoji to indicate work status
addStatusEmoji(work, emoji) {
let statusContainer = work.querySelector('#work_status_container');
if (!statusContainer) {
console.debug(`[FicTracker] Adding status container to work: ${this.getWorkId(work)}`);
statusContainer = document.createElement('div');
statusContainer.id = 'work_status_container';
statusContainer.style.marginLeft = '10px';
statusContainer.style.position = 'absolute';
statusContainer.style.top = '20px';
statusContainer.style.right = 0;
work.querySelector('div.header.module').appendChild(statusContainer);
}
console.debug(`[FicTracker] Adding status emoji: ${emoji} to work: ${this.getWorkId(work)}`);
const statusLabel = document.createElement('span');
statusLabel.textContent = `${emoji}`;
statusLabel.style.fontSize = '1.2rem';
statusContainer.appendChild(statusLabel);
}
}
// Class for handling the UI & logic for the script settings panel
class SettingsPageHandler {
constructor(settings) {
this.settings = settings;
this.init();
}
init() {
// Inject PetiteVue & insert the UI after
this.injectVueScript(() => {
this.loadSettingsPanel();
});
}
// Adding lightweight Vue.js fork (6kb) via CDN
// Using it saves a ton of repeated LOC to attach event handlers & data binding
// PetiteVue Homepage: https://github.com/vuejs/petite-vue
injectVueScript(callback) {
const vueScript = document.createElement('script');
vueScript.src = 'https://unpkg.com/petite-vue';
document.head.appendChild(vueScript);
vueScript.onload = callback;
}
// Load HTML template for the settings panel from GitHub repo
// Insert into the AO3 preferences page & attach Vue app
loadSettingsPanel() {
const container = document.createElement('fieldset');
// Fetching the HTML for settings panel, outsourced for less clutter
container.innerHTML = GM_getResourceText('settingsPanelHtml');
document.querySelector('#main').appendChild(container);
// Initialize the Vue app instance
PetiteVue.createApp({
selectedStatus: 1,
ficTrackerSettings: this.settings,
// Computed prop for retrieving settings updates
get currentSettings() {
return this.ficTrackerSettings.statuses[this.selectedStatus];
},
// Computed prop for updating the preview box styles
get previewStyle() {
return {
height: '50px',
border: `${this.currentSettings.borderSize}px solid ${this.currentSettings.highlightColor}`,
'box-shadow': `0 0 10px ${this.currentSettings.highlightColor}, 0 0 20px ${this.currentSettings.highlightColor}`,
};
},
// Bind exportData and importData directly to class methods
exportData: this.exportSettings.bind(this),
importData: this.importSettings.bind(this),
// Save the settings to the storage
saveSettings() {
localStorage.setItem('FT_settings', JSON.stringify(this.ficTrackerSettings));
DEBUG && console.log('[FicTracker] Settings saved.');
},
}).mount();
}
// Exports user data (favorites, finished, toread) into a JSON file
exportSettings() {
// Formatted timestamp for export
const exportTimestamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
const exportData = {
FT_favorites: localStorage.getItem('FT_favorites'),
FT_finished: localStorage.getItem('FT_finished'),
FT_toread: localStorage.getItem('FT_toread'),
};
// Create a Blob object from the export data, converting it to JSON format
const blob = new Blob([JSON.stringify(exportData)], {
type: 'application/json'
});
// Generate a URL for the Blob object to enable downloading
const url = URL.createObjectURL(blob);
// Create a temp link to downlad the generate file data
const a = document.createElement('a');
a.href = url;
a.download = `fictracker_export_${exportTimestamp}.json`;
document.body.appendChild(a);
// Trigger a click on the link to initiate the download
a.click();
// Cleanup after the download
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Update the last export timestamp
this.settings.lastExportTimestamp = exportTimestamp;
localStorage.setItem('FT_settings', JSON.stringify(this.settings));
DEBUG && console.log('[FicTracker] Data exported at:', exportTimestamp);
}
// Imports user data (favorites, finished, toread) from a JSON file
// Existing storage data is not removed, only new items from file are appended
importSettings(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedData = JSON.parse(e.target.result);
this.mergeImportedData(importedData);
} catch (err) {
DEBUG && console.error('[FicTracker] Error importing data:', err);
}
};
reader.readAsText(file);
}
mergeImportedData(importedData) {
const keys = ['FT_favorites', 'FT_finished', 'FT_toread'];
let newEntries = [];
for (const key of keys) {
const currentData = localStorage.getItem(key) ? localStorage.getItem(key).split(',') : [];
const newData = importedData[key].split(',') || [];
const initialLen = currentData.length;
const mergedData = [...new Set([...currentData, ...newData])];
newEntries.push(mergedData.length - initialLen);
localStorage.setItem(key, mergedData.join(','));
}
alert(`Data imported successfully!\nNew favorite entries: ${newEntries[0]}\nNew finished entries: ${newEntries[1]}\nNew To-Read entries: ${newEntries[2]}`);
DEBUG && console.log('[FicTracker] Data imported successfully. Stats:', newEntries);
}
}
// Class for managing URL patterns and executing corresponding handlers based on the current path
class URLHandler {
constructor() {
this.handlers = [];
}
// Add a new handler with associated patterns to the handlers array
addHandler(patterns, handler) {
this.handlers.push({
patterns,
handler
});
}
// Iterate through registered handlers to find a match for the current path
matchAndHandle(currentPath) {
for (const {
patterns,
handler
}
of this.handlers) {
if (patterns.some(pattern => pattern.test(currentPath))) {
// Execute the corresponding handler if a match is found
handler();
DEBUG && console.log('[FicTracker] Matched pattern for path:', currentPath);
return true;
}
}
DEBUG && console.log('[FicTracker] Unrecognized page', currentPath);
return false;
}
}
// Main controller that integrates all components of the AO3 FicTracker
class FicTracker {
constructor() {
// Load settings and initialize other features
this.settings = this.loadSettings();
this.initStyles();
this.addDropdownOptions();
this.setupURLHandlers();
}
// Load settings from the storage or fallback to default ones
loadSettings() {
// Measure performance of loading settings from localStorage
const startTime = performance.now();
let savedSettings = localStorage.getItem('FT_settings');
if (savedSettings) {
try {
settings = JSON.parse(savedSettings);
DEBUG = settings.debug;
DEBUG && console.log(`[FicTracker] Settings loaded successfully:`, savedSettings);
} catch (error) {
DEBUG && console.error(`[FicTracker] Error parsing settings: ${error}`);
}
} else {
DEBUG && console.warn(`[FicTracker] No saved settings found, using default settings.`);
}
const endTime = performance.now();
DEBUG && console.log(`[FicTracker] Settings loaded in ${endTime - startTime} ms`);
return settings;
}
// Initialize custom styles based on loaded settings
initStyles() {
const favColor = this.settings.statuses[1].highlightColor;
const toReadColor = this.settings.statuses[2].highlightColor;
StyleManager.addCustomStyles(`
.glowing-border-favorite {
border: ${this.settings.statuses[1].borderSize}px solid ${favColor} !important;
border-radius: 8px !important;
padding: 15px !important;
background-color: transparent !important;
box-shadow: 0 0 10px ${favColor}, 0 0 20px ${favColor} !important;
transition: box-shadow 0.3s ease !important;
}
.glowing-border-favorite:hover {
box-shadow: 0 0 15px ${favColor}, 0 0 30px ${favColor} !important;
}
.glowing-border-toread {
border: ${this.settings.statuses[2].borderSize}px solid ${toReadColor} !important;
border-radius: 8px !important;
padding: 15px !important;
background-color: transparent !important;
box-shadow: 0 0 10px ${toReadColor}, 0 0 20px ${toReadColor} !important;
transition: box-shadow 0.3s ease !important;
}
.glowing-border-toread:hover {
box-shadow: 0 0 15px ${toReadColor}, 0 0 30px ${toReadColor} !important;
}
`);
}
// Add new dropdown options for each status to the user menu
addDropdownOptions() {
const userMenu = document.querySelector('ul.menu.dropdown-menu');
const username = userMenu?.previousElementSibling?.getAttribute('href')?.split('/').pop() ?? '';
if (username) {
// Loop through each status and add corresponding dropdown options
this.settings.statuses.forEach(({
tag,
dropdownLabel
}) => {
userMenu.insertAdjacentHTML(
'beforeend',
`<li><a href="https://archiveofourown.org/bookmarks?bookmark_search%5Bother_bookmark_tag_names%5D=${tag}&user_id=${username}">${dropdownLabel}</a></li>`
);
});
} else {
DEBUG && console.warn('[FicTracker] Cannot parse the username!');
}
DEBUG && console.log('[FicTracker] Successfully added dropdown options!');
}
// Setup URL handlers for different pages
setupURLHandlers() {
const urlHandler = new URLHandler();
// Handler for fanfic pages (chapters, entire work, one shot)
urlHandler.addHandler(
[/\/works\/.*(?:chapters|view_full_work)/, /works\/\d+(#\w+-?\w*)?$/],
() => {
const bookmarkManager = new BookmarkManager("https://archiveofourown.org/");
}
);
// Handler for fanfics search/tag list pages & other pages that include a list of fics
urlHandler.addHandler([
/\/works\/search/,
/\/works\?.*/,
/\/bookmarks$/,
/\/users\/bookmarks/,
/\/series\/.+/,
/\/collections\/.+/,
/\/works\?commit=Sort/,
/\/works\?work_search/,
/\/tags\/.*\/works/
],
() => {
const worksListHandler = new WorksListHandler();
}
);
// Handler for user preferences page
urlHandler.addHandler(
[/\/users\/.+\/preferences/],
() => {
const settingsPage = new SettingsPageHandler(this.settings);
}
);
// Execute handler based on the current URL
const currentPath = window.location.href;
urlHandler.matchAndHandle(currentPath);
}
}
// Instantiate the FicTracker class
const ficTracker = new FicTracker();
})();