// ==UserScript==
// @name VNDB Friends List
// @namespace http://tampermonkey.net/
// @version 1.69.8
// @description Add friends list and friend votes display for VNDB
// @author ALVIBO
// @match https://vndb.org/v*
// @match https://vndb.org/u*
// @match https://vndb.org/t/u*
// @match https://vndb.org/w?u=u*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addValueChangeListener
// @require https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js
// @connect api.vndb.org
// @license http://creativecommons.org/licenses/by-nc-sa/4.0/
// @thanks For the cover preview on mouseover, I drew some inspiration and used a few lines from the original VNDB Cover Preview script by Kuro_scripts
// ==/UserScript==
(function() {
'use strict';
let bc;
if ('BroadcastChannel' in window) {
bc = new BroadcastChannel('vndb_friends_channel');
bc.onmessage = function(e) {
if (e.data && e.data.type === 'friends_update') {
friends = e.data.friends;
friendsCache = e.data.friendsCache || friendsCache;
console.log('Friends list updated from another tab:', friends);
const userPageMatch = location.pathname.match(/^\/u(\d+)/) ||
location.pathname.match(/^\/t\/u(\d+)/) ||
location.search.match(/[?&]u=u(\d+)/);
if (userPageMatch) {
const userId = userPageMatch[1];
const friendId = 'u' + userId;
const friendBtn = document.querySelector('header nav menu li a[href="#"]:not(:contains("friends"))');
if (friendBtn) {
friendBtn.textContent = friends.includes(friendId) ? 'remove the friend' : 'add a friend';
}
}
}
};
}
window.addEventListener('storage', event => {
if (event.key === 'vndb_friends') {
try {
const newFriends = JSON.parse(event.newValue);
friends = newFriends;
console.log('Friends list updated via storage event:', friends);
const userPageMatch = location.pathname.match(/^\/u(\d+)/) ||
location.pathname.match(/^\/t\/u(\d+)/) ||
location.search.match(/[?&]u=u(\d+)/);
if (userPageMatch) {
const userId = userPageMatch[1];
const friendId = 'u' + userId;
const friendBtn = document.querySelector('header nav menu li a[href="#"]:not(:contains("friends"))');
if (friendBtn) {
friendBtn.textContent = friends.includes(friendId) ? 'remove the friend' : 'add a friend';
}
}
} catch (e) {
console.error(e);
}
}
});
let friends = [];
const gmFriends = GM_getValue('vndb_friends', []);
if (Array.isArray(gmFriends) && gmFriends.length > 0) {
friends = gmFriends;
}
if (friends.length === 0) {
try {
const localFriends = JSON.parse(localStorage.getItem('vndb_friends') || '[]');
if (Array.isArray(localFriends) && localFriends.length > 0) {
friends = localFriends;
}
} catch (e) {
console.error('Error reading from localStorage:', e);
}
}
if (friends.length === 0) {
try {
let friendsCache = GM_getValue('vndb_friends_cache', {});
if (Object.keys(friendsCache).length === 0) {
try {
friendsCache = JSON.parse(localStorage.getItem('vndb_friends_cache') || '{}');
} catch (e) {
console.error('Error reading cache from localStorage:', e);
}
}
if (Object.keys(friendsCache).length > 0) {
friends = Object.keys(friendsCache).filter(key => /^u\d+$/.test(key));
}
} catch (e) {
console.error('Error reading from cache:', e);
}
}
let friendsCache = GM_getValue('vndb_friends_cache', {});
for (const key in friendsCache) {
if (!/^u\d+$/.test(key) && friendsCache[key] && /^u\d+$/.test(friendsCache[key].id)) {
const properKey = friendsCache[key].id;
friendsCache[properKey] = friendsCache[key];
delete friendsCache[key];
}
}
GM_setValue('vndb_friends_cache', friendsCache);
localStorage.setItem('vndb_friends_cache', JSON.stringify(friendsCache)); (function() {
const vnPageMatch = location.pathname.match(/^\/v(\d+)/);
if (!vnPageMatch) return;
let settings = GM_getValue('vndb_friends_settings', {
textColor: null,
buttonTextColor: null,
backgroundColor: null,
buttonBackgroundColor: null,
titleColor: null,
borderColor: null,
separatorColor: null,
fontSize: 17,
buttonFontSize: 16,
tabFontSize: 18,
opacity: null,
cacheDuration: 3,
gamesPerFriend: 5,
maxActivities: 51,
friendsVotesEnabled: true
});
if (!settings.friendsVotesEnabled) {
console.log("VNDB Friend Votes: Disabled by settings");
return;
}
const vnId = 'v' + vnPageMatch[1];
console.log(`VNDB Friend Votes: Loading for ${vnId}`);
if (friends.length === 0) {
console.log('VNDB Friend Votes: No friends found in any storage');
return;
}
console.log(`VNDB Friend Votes: Found ${friends.length} friends`);
processFriends(friends).catch(err => {
console.error('VNDB Friend Votes: Error processing friends:', err);
});
async function processFriends(friendsList) {
function fetchFriendVote(userId) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.vndb.org/kana/ulist',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
user: userId,
filters: ['id', '=', vnId],
fields: 'id,vote'
}),
onload(resp) {
try {
const data = JSON.parse(resp.responseText);
if (data.results && data.results.length > 0 && data.results[0].vote != null) {
resolve({ userId, vote: data.results[0].vote });
} else {
resolve(null);
}
} catch (e) {
console.error(`Error processing response for ${userId}:`, e);
resolve(null);
}
},
onerror() {
console.error(`Request failed for ${userId}`);
resolve(null);
}
});
});
}
async function ensureUsernames(votes) {
let cache = GM_getValue('vndb_friends_cache', {});
if (Object.keys(cache).length === 0) {
try {
const localCache = JSON.parse(localStorage.getItem('vndb_friends_cache') || '{}');
if (Object.keys(localCache).length > 0) {
cache = localCache;
}
} catch (e) {
console.error('VNDB Friend Votes: Error reading cache from localStorage:', e);
}
}
const missing = votes.filter(v => !cache[v.userId] || !cache[v.userId].username);
if (missing.length > 0) {
console.log(`VNDB Friend Votes: Fetching ${missing.length} missing usernames`);
await Promise.all(missing.map(v =>
new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.vndb.org/kana/user?q=${v.userId}&fields=username`,
headers: { 'Content-Type': 'application/json' },
onload(r) {
try {
const data = JSON.parse(r.responseText);
if (data.results && data.results.length > 0) {
cache[v.userId] = { username: data.results[0].username };
} else {
cache[v.userId] = { username: v.userId };
}
} catch (e) {
console.error(`Error fetching username for ${v.userId}:`, e);
cache[v.userId] = { username: v.userId };
}
resolve();
},
onerror() {
console.error(`Username request failed for ${v.userId}`);
cache[v.userId] = { username: v.userId };
resolve();
}
});
})
));
GM_setValue('vndb_friends_cache', cache);
friendsCache = cache;
}
return votes.map(v => ({
userId: v.userId,
username: cache[v.userId]?.username || v.userId,
vote: v.vote
}));
} function formatVote(vote) {
if (vote >= 10) {
return (vote / 10).toFixed(1);
}
return vote.toFixed(1);
}
async function renderFriendVotes(friendVotes) {
const data = await ensureUsernames(friendVotes);
data.sort((a, b) => b.vote - a.vote);
const statsContainer = document.querySelector('.votestats');
if (!statsContainer) {
console.error('VNDB Friend Votes: could not locate .votestats');
return;
}
let wrapper = statsContainer.closest('[data-vndb-friends-wrapper="true"]');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.dataset.vndbFriendsWrapper = 'true';
wrapper.style.display = 'flex';
wrapper.style.flexWrap = 'wrap';
wrapper.style.alignItems = 'flex-start';
wrapper.style.gap = '1em';
wrapper.style.justifyContent = 'center';
wrapper.style.maxWidth = '850px';
wrapper.style.margin = '0 auto';
const statsArticle = statsContainer.closest('article#stats');
const parentElement = statsArticle || statsContainer.parentNode;
if (parentElement) {
parentElement.insertBefore(wrapper, statsContainer);
wrapper.appendChild(statsContainer);
} else {
console.error('VNDB Friend Votes: Could not find suitable parent for wrapper.');
statsContainer.parentNode.insertBefore(wrapper, statsContainer.nextSibling);
wrapper.appendChild(statsContainer);
}
} else {
wrapper.style.justifyContent = 'center';
wrapper.style.maxWidth = '850px';
wrapper.style.margin = '0 auto';
}
const oldTable = wrapper.querySelector('table.friends-votes-table');
if (oldTable) oldTable.remove();
const oldBubbleSection = wrapper.querySelector('.friends-votes-section');
if (oldBubbleSection) oldBubbleSection.remove();
const oldTagSection = wrapper.querySelector('.friends-votes-tag-section');
if (oldTagSection) oldTagSection.remove();
const voteColors = {};
const voteGraphTable = statsContainer.querySelector('table.votegraph');
const defaultVoteColor = '#555';
if (voteGraphTable) {
const numberCells = voteGraphTable.querySelectorAll('tbody td.number');
numberCells.forEach(cell => {
const voteNumber = cell.textContent.trim();
if (voteNumber && !isNaN(voteNumber)) {
voteColors[voteNumber] = window.getComputedStyle(cell).color;
}
});
} else {
console.warn("VNDB Friend Votes: Could not find votegraph to extract colors.");
}
const friendsTagSection = document.createElement('div');
friendsTagSection.className = 'friends-votes-tag-section';
friendsTagSection.style.flexBasis = '300px';
friendsTagSection.style.flexGrow = '1';
friendsTagSection.style.minWidth = '250px';
friendsTagSection.style.display = 'flex';
friendsTagSection.style.flexDirection = 'column';
friendsTagSection.style.alignItems = 'center';
const header = document.createElement('h3');
header.textContent = `Friends' votes (${data.length})`;
const recentVotesHeaderCell = statsContainer.querySelector('table.recentvotes.stripe thead td');
if (recentVotesHeaderCell) {
const sourceStyle = window.getComputedStyle(recentVotesHeaderCell);
header.style.fontSize = sourceStyle.fontSize;
header.style.fontWeight = sourceStyle.fontWeight;
header.style.fontFamily = sourceStyle.fontFamily;
header.style.color = sourceStyle.color;
header.style.lineHeight = sourceStyle.lineHeight;
header.style.letterSpacing = sourceStyle.letterSpacing;
header.style.margin = '0';
header.style.marginTop = '1em';
header.style.marginBottom = '0.5em';
header.style.borderBottom = '1px dotted #ccc';
header.style.paddingBottom = '0.3em';
header.style.textAlign = 'center';
header.style.width = '100%';
header.style.maxWidth = 'calc(100% - 1em)';
header.style.boxSizing = 'border-box';
} else {
console.warn("Could not find Recent votes header cell to copy style.");
header.style.marginTop = '1em';
header.style.marginBottom = '0.5em';
header.style.fontSize = '1.1em';
header.style.borderBottom = '1px dotted #ccc';
header.style.paddingBottom = '0.3em';
header.style.textAlign = 'center';
header.style.width = '100%';
header.style.maxWidth = 'calc(100% - 1em)';
header.style.boxSizing = 'border-box';
}
friendsTagSection.appendChild(header);
const tagContainer = document.createElement('div');
tagContainer.className = 'friends-votes-tag-container';
tagContainer.style.display = 'flex';
tagContainer.style.flexWrap = 'wrap';
tagContainer.style.gap = '0.3em 0.7em';
tagContainer.style.justifyContent = 'center';
data.forEach(friend => {
const friendSpan = document.createElement('span');
friendSpan.className = 'friend-vote-tag';
friendSpan.style.whiteSpace = 'nowrap';
const nameLink = document.createElement('a');
nameLink.href = `/u${friend.userId.slice(1)}`;
nameLink.textContent = friend.username;
const voteSmall = document.createElement('small');
voteSmall.textContent = formatVote(friend.vote);
voteSmall.style.marginLeft = '0.4em';
let voteKey;
if (friend.vote >= 10) {
voteKey = '10';
} else if (friend.vote >= 1) {
voteKey = Math.floor(friend.vote).toString();
} else {
voteKey = '1';
}
voteSmall.style.color = voteColors[voteKey] || defaultVoteColor;
friendSpan.appendChild(nameLink);
friendSpan.appendChild(voteSmall);
tagContainer.appendChild(friendSpan);
});
friendsTagSection.appendChild(tagContainer);
if (wrapper.contains(statsContainer)) {
statsContainer.parentNode.insertBefore(friendsTagSection, statsContainer.nextSibling);
} else {
wrapper.appendChild(friendsTagSection);
}
console.log('VNDB Friend Votes: UI rendered successfully (Centered Tag Layout, After Stats)');
}
console.log('VNDB Friend Votes: Starting API requests');
Promise.all(friendsList.map(fetchFriendVote))
.then(results => results.filter(Boolean))
.then(friendVotes => {
if (friendVotes.length === 0) {
console.log('VNDB Friend Votes: No friend votes found for this VN');
return;
}
console.log(`VNDB Friend Votes: Found ${friendVotes.length} friend votes`);
return renderFriendVotes(friendVotes);
})
.catch(err => {
console.error('VNDB Friend Votes: Error:', err);
});
}
GM_addValueChangeListener('vndb_friends', () => {
console.log('VNDB Friend Votes: Friends list changed, reloading page');
location.reload();
});
GM_addValueChangeListener('vndb_friends_cache', () => {
console.log('VNDB Friend Votes: Friends cache changed, reloading page');
location.reload();
});
})();
(function() {
const userPageMatch = location.pathname.match(/^\/u(\d+)/) ||
location.pathname.match(/^\/t\/u(\d+)/) ||
location.search.match(/[?&]u=u(\d+)/);
if (!userPageMatch) return;
const userId = userPageMatch[1];
let activityTabClicked = false;
let editLink = document.querySelector('header nav menu li a[href$="/edit"]');
GM_addValueChangeListener('vndb_friends', (name, old_value, new_value, isRemote) => {
if (isRemote) {
friends = new_value;
displayFriendsList();
}
});
GM_addValueChangeListener('vndb_friends_cache', (name, old_value, new_value, isRemote) => {
if (isRemote) {
friendsCache = new_value;
displayFriendsList();
}
});
(async function migrateIfNeeded() {
if (friends.some(f => !/^u\d+$/.test(f))) {
async function migrateOldFriends() {
const newFriends = [];
for (const friend of friends) {
if (/^u\d+$/.test(friend)) {
newFriends.push(friend);
} else {
const userData = await fetchFriendData(friend);
if (userData && userData.id) {
newFriends.push(userData.id);
friendsCache[userData.id] = userData;
} else {
console.warn(`Warning: Could not fetch data for friend "${friend}". Entry skipped.`);
}
}
}
friends = newFriends;
GM_setValue('vndb_friends', friends);
GM_setValue('vndb_friends_cache', friendsCache);
localStorage.setItem('vndb_friends', JSON.stringify(friends));
localStorage.setItem('vndb_friends_cache', JSON.stringify(friendsCache));
displayFriendsList();
}
await migrateOldFriends();
}
})();
let currentPage = parseInt(localStorage.getItem('vndb_friends_current_page')) || 1;
const friendsPerPage = 10; let settings = GM_getValue('vndb_friends_settings', {
textColor: null,
buttonTextColor: null,
backgroundColor: null,
buttonBackgroundColor: null,
titleColor: null,
borderColor: null,
separatorColor: null,
fontSize: 17,
buttonFontSize: 16,
tabFontSize: 18,
opacity: null,
cacheDuration: 3,
gamesPerFriend: 5,
maxActivities: 51,
friendsVotesEnabled: true
});
let isUpdatingActivity = false;
let currentRequestId = null;
let reloadTimeout;
const baseUserUrl = location.pathname.split('/')[1] + (location.pathname.split('/')[2] || '');
function getBackgroundColor() {
const bodyBg = window.getComputedStyle(document.body).backgroundColor;
const rgb = bodyBg.match(/\d+/g);
return rgb ? rgb.map(Number) : [255, 255, 255];
}
function createThemeColors() {
const bgColor = getBackgroundColor();
const mainTextColor = window.getComputedStyle(document.body).color;
const articleH1 = document.querySelector('article h1');
const titleColor = articleH1 ? window.getComputedStyle(articleH1).color : mainTextColor;
const opacity = settings.opacity || 0.70;
return {
containerBg: settings.backgroundColor ?
`rgba(${parseInt(settings.backgroundColor.slice(1,3),16)},
${parseInt(settings.backgroundColor.slice(3,5),16)},
${parseInt(settings.backgroundColor.slice(5,7),16)},
${opacity})` :
`rgba(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]}, ${opacity})`,
borderColor: mainTextColor,
textColor: settings.textColor || mainTextColor,
linkColor: settings.titleColor || titleColor
};
}
const friendsContainer = document.createElement('div');
const themeColors = createThemeColors();
friendsContainer.innerHTML = `
<style>
.friends-container {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
border: 1px solid ${themeColors.borderColor};
z-index: 1000;
min-width: 300px;
font-size: ${settings.fontSize || '17px'};
max-height: 80vh;
max-width: 90vw;
overflow-y: auto;
}
.friends-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
backdrop-filter: blur(5px);
z-index: -1;
}
.friends-settings {
margin-top: 10px;
border-top: 1px solid ${themeColors.borderColor};
padding-top: 10px;
display: none;
}
.settings-group {
margin: 5px 0;
display: flex;
align-items: center;
gap: 5px;
}
.settings-group label {
min-width: 120px;
}
.color-inputs {
display: flex;
gap: 5px;
align-items: center;
}
.color-inputs input[type="text"],
.color-inputs input[type="number"] {
width: 70px;
padding: 2px 4px;
border: 1px solid;
border-radius: 3px;
background: inherit;
}
.settings-toggle {
margin-top: 10px;
text-align: center;
}
.friends-container h2,
.friends-container h3 {
color: ${themeColors.linkColor};
}
.friends-container .friend-link {
color: ${themeColors.textColor} !important;
}
.tab-buttons {
display: flex;
margin-bottom: 15px;
border-bottom: 1px solid ${themeColors.borderColor};
}
.tab-button {
padding: 8px 16px;
border: none;
background: none;
color: ${themeColors.textColor};
cursor: pointer;
}
.tab-button.active {
border-bottom: 2px solid ${themeColors.linkColor};
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.activity-item {
margin: 8px 0;
padding: 8px;
border-bottom: 1px solid ${settings.separatorColor || themeColors.borderColor};
word-break: break-word;
overflow-wrap: break-word;
}
.activity-item:first-child {
padding-top: 0;
}
.activity-date {
color: ${themeColors.textColor};
opacity: 0.8;
font-size: 0.9em;
}
.friends-container::-webkit-scrollbar {
width: 8px;
}
.friends-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
.friends-container::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.5);
border-radius: 4px;
}
#activityFeed {
max-height: calc(80vh - 300px);
overflow-y: auto;
margin-bottom: 15px;
}
#activityFeed::-webkit-scrollbar {
width: 8px;
}
#activityFeed::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
#activityFeed::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.5);
border-radius: 4px;
}
.activity-controls {
margin-top: 10px;
text-align: center;
}
.friends-container button:not(.tab-button) {
font-size: ${settings.buttonFontSize ? `${settings.buttonFontSize}px` : '16px'} !important;
}
.tab-button {
font-size: ${settings.tabFontSize ? `${settings.tabFontSize}px` : '18px'} !important;
}
</style>
<div class="friends-container">
<h2>Friends List</h2>
<div class="tab-buttons">
<button class="tab-button active" data-tab="friendsList">Friends List</button>
<button class="tab-button" data-tab="activityFeed">Recent Activity</button>
</div>
<div id="friendsList" class="tab-content active"></div>
<div id="activityFeed" class="tab-content"></div>
<div class="activity-controls tab-content" data-tab="activityFeed">
<button id="reloadActivity">Reload Activity</button>
</div>
<div id="pagination" style="margin-top: 10px; text-align: center;"></div>
<div style="margin-top: 10px;">
<input type="text" id="newFriend" placeholder="Username" style="margin-right: 5px;">
<button id="addFriend">Add Friend</button>
</div>
<div class="settings-toggle">
<button id="toggleSettings">Show Settings</button>
</div>
<div class="friends-settings">
<h3>Settings</h3>
<div class="settings-group">
<label>Title Color:</label>
<div class="color-inputs">
<input type="color" id="titleColor">
<input type="text" id="titleColorHex" placeholder="#hex">
</div>
<button class="resetButton" data-setting="titleColor">Reset</button>
</div>
<div class="settings-group">
<label>Text Color:</label>
<div class="color-inputs">
<input type="color" id="textColor">
<input type="text" id="textColorHex" placeholder="#hex">
</div>
<button class="resetButton" data-setting="textColor">Reset</button>
</div>
<div class="settings-group">
<label>Button Text:</label>
<div class="color-inputs">
<input type="color" id="buttonTextColor">
<input type="text" id="buttonTextColorHex" placeholder="#hex">
</div>
<button class="resetButton" data-setting="buttonTextColor">Reset</button>
</div>
<div class="settings-group">
<label>Background:</label>
<div class="color-inputs">
<input type="color" id="backgroundColor">
<input type="text" id="backgroundColorHex" placeholder="#hex">
</div>
<button class="resetButton" data-setting="backgroundColor">Reset</button>
</div>
<div class="settings-group">
<label>Button Color:</label>
<div class="color-inputs">
<input type="color" id="buttonBackgroundColor">
<input type="text" id="buttonBackgroundColorHex" placeholder="#hex">
</div>
<button class="resetButton" data-setting="buttonBackgroundColor">Reset</button>
</div>
<div class="settings-group">
<label>Border Color:</label>
<div class="color-inputs">
<input type="color" id="borderColor">
<input type="text" id="borderColorHex" placeholder="#hex">
</div>
<button class="resetButton" data-setting="borderColor">Reset</button>
</div>
<div class="settings-group">
<label>Separator Color:</label>
<div class="color-inputs">
<input type="color" id="separatorColor">
<input type="text" id="separatorColorHex" placeholder="#hex">
</div>
<button class="resetButton" data-setting="separatorColor">Reset</button>
</div>
<div class="settings-group">
<label>Font Size:</label>
<div class="color-inputs">
<input type="number" id="fontSize" min="8" max="24" step="1">
<span>px</span>
</div>
<button class="resetButton" data-setting="fontSize">Reset</button>
</div>
<div class="settings-group">
<label>Button Text Size:</label>
<div class="color-inputs">
<input type="number" id="buttonFontSize" min="8" max="24" step="1">
<span>px</span>
</div>
<button class="resetButton" data-setting="buttonFontSize">Reset</button>
</div>
<div class="settings-group">
<label>Tab Text Size:</label>
<div class="color-inputs">
<input type="number" id="tabFontSize" min="8" max="24" step="1">
<span>px</span>
</div>
<button class="resetButton" data-setting="tabFontSize">Reset</button>
</div>
<div class="settings-group">
<label>Opacity:</label>
<input type="range" id="opacity" min="0" max="100" step="5">
<span id="opacityValue"></span>%
<button class="resetButton" data-setting="opacity">Reset</button>
</div>
<div class="settings-group">
<label>Cache Duration:</label>
<div class="color-inputs">
<input type="number" id="cacheDuration" min="1" max="60" step="1">
<span>minutes</span>
</div>
<button class="resetButton" data-setting="cacheDuration">Reset</button>
</div>
<div class="settings-group">
<label>Games per Friend:</label>
<div class="color-inputs">
<input type="number" id="gamesPerFriend" min="1" max="50" step="1">
<span>games</span>
</div>
<button class="resetButton" data-setting="gamesPerFriend">Reset</button>
</div> <div class="settings-group">
<label>Max Activities:</label>
<div class="color-inputs">
<input type="number" id="maxActivities" min="5" max="100" step="1">
<span>total</span>
</div>
<button class="resetButton" data-setting="maxActivities">Reset</button>
</div>
<div class="settings-group">
<label>Show Friends' Votes on VN Pages:</label>
<input type="checkbox" id="friendsVotesToggle">
</div>
</div>
<button id="closeFriends" style="margin-top: 10px;">Close</button>
</div>
`;
const STATE_UPDATE_KEY = 'vndb_friends_state_update';
let lastStateUpdate = Date.now();
if (!document.querySelector('.friends-container')) {
document.body.appendChild(friendsContainer);
}
const container = friendsContainer.querySelector('.friends-container');
const settingsPanel = container.querySelector('.friends-settings');
updateContainerStyle();
const domCache = {
friendsList: document.getElementById('friendsList'),
activityFeed: document.getElementById('activityFeed'),
pagination: document.getElementById('pagination'),
newFriend: document.getElementById('newFriend'),
reloadActivity: document.getElementById('reloadActivity'),
closeFriends: document.getElementById('closeFriends'),
toggleSettings: document.getElementById('toggleSettings'),
addFriend: document.getElementById('addFriend')
};
function updateContainerStyle() {
const themeColors = createThemeColors();
container.style.border = `1px solid ${settings.borderColor || themeColors.borderColor}`;
container.style.background = themeColors.containerBg;
container.style.color = settings.textColor || themeColors.textColor;
container.style.fontSize = settings.fontSize ? `${settings.fontSize}px` : '17px';
const titles = container.querySelectorAll('h2, h3');
titles.forEach(title => {
title.style.setProperty('color', settings.titleColor || themeColors.linkColor, 'important');
});
const friendLinks = container.querySelectorAll('.friend-link');
friendLinks.forEach(link => {
link.style.setProperty('color', settings.textColor || themeColors.textColor, 'important');
});
}
const dynamicStyles = document.createElement('style');
document.head.appendChild(dynamicStyles);
function updateDynamicStyles() {
const themeColors = createThemeColors();
const opacity = settings.opacity || 0.70;
let backgroundStyle = themeColors.containerBg;
if (settings.backgroundColor) {
const hex = settings.backgroundColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
backgroundStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
dynamicStyles.textContent = `
.friends-container {
border: 1px solid ${settings.borderColor || themeColors.borderColor} !important;
background: ${backgroundStyle} !important;
}
.friends-container .friend-link {
color: ${settings.textColor || themeColors.textColor} !important;
}
.friends-container h2,
.friends-container h3 {
color: ${settings.titleColor || themeColors.linkColor} !important;
}
.friends-container button {
background-color: ${settings.buttonBackgroundColor || 'inherit'} !important;
color: ${settings.buttonTextColor || themeColors.textColor} !important;
font-size: ${settings.buttonFontSize ? `${settings.buttonFontSize}px` : '16px'} !important;
}
.friends-settings {
border-top: 1px solid ${settings.separatorColor || themeColors.borderColor} !important;
}
.activity-item {
border-bottom: 1px solid ${settings.separatorColor || themeColors.borderColor} !important;
}
.activity-date {
color: ${settings.textColor || themeColors.textColor} !important;
opacity: 0.8;
}
.tab-button {
font-size: ${settings.tabFontSize ? `${settings.tabFontSize}px` : '18px'} !important;
}
.tab-buttons {
border-bottom: 1px solid ${settings.separatorColor || themeColors.borderColor} !important;
}
.tab-button.active {
border-bottom: 2px solid ${themeColors.linkColor} !important;
}
`;
}
let lastStyleState = null;
function forceStyleUpdate() {
lastStyleState = JSON.stringify(settings);
updateContainerStyle();
updateDynamicStyles();
const activityItems = document.querySelectorAll('.activity-item');
if (activityItems.length > 0) {
const themeColors = createThemeColors();
activityItems.forEach(item => {
item.style.borderBottom = `1px solid ${settings.separatorColor || themeColors.borderColor}`;
});
const activityDates = document.querySelectorAll('.activity-date');
activityDates.forEach(date => {
date.style.color = settings.textColor || themeColors.textColor;
});
}
const buttons = container.querySelectorAll('button:not(.tab-button)');
buttons.forEach(button => {
if (settings.buttonFontSize) {
button.style.setProperty('font-size', `${settings.buttonFontSize}px`, 'important');
} else {
button.style.removeProperty('font-size');
}
});
const tabButtons = container.querySelectorAll('.tab-button');
tabButtons.forEach(tab => {
if (settings.tabFontSize) {
tab.style.setProperty('font-size', `${settings.tabFontSize}px`, 'important');
} else {
tab.style.removeProperty('font-size');
}
});
}
const themeObserver = new MutationObserver((mutations) => {
if (container.style.display === 'block') {
requestAnimationFrame(forceStyleUpdate);
}
});
themeObserver.observe(document.head, { attributes: true, childList: true, subtree: true });
themeObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
const debouncedForceStyleUpdate = debounce(forceStyleUpdate, 300);
setInterval(() => {
debouncedForceStyleUpdate();
}, 1000);
function resetSetting(setting) {
switch(setting) {
case 'cacheDuration':
settings[setting] = 3;
const cacheDurationInput = document.getElementById('cacheDuration');
if (cacheDurationInput) {
cacheDurationInput.value = 3;
activityCache.timestamp = 0;
localStorage.removeItem('vndb_activity_cache');
if (document.querySelector('.tab-button[data-tab="activityFeed"]').classList.contains('active')) {
updateActivityFeed();
}
}
break;
case 'gamesPerFriend':
settings[setting] = 5;
const gamesPerFriendInput = document.getElementById('gamesPerFriend');
if (gamesPerFriendInput) gamesPerFriendInput.value = 5;
break;
case 'maxActivities':
settings[setting] = 51;
const maxActivitiesInput = document.getElementById('maxActivities');
if (maxActivitiesInput) maxActivitiesInput.value = 51;
break;
case 'fontSize':
settings[setting] = 17;
const fontSizeInput = document.getElementById('fontSize');
if (fontSizeInput) fontSizeInput.value = 17;
break;
case 'buttonFontSize':
settings[setting] = 16;
const buttonFontSizeInput = document.getElementById('buttonFontSize');
if (buttonFontSizeInput) buttonFontSizeInput.value = 16;
break;
case 'tabFontSize':
settings[setting] = 18;
const tabFontSizeInput = document.getElementById('tabFontSize');
if (tabFontSizeInput) tabFontSizeInput.value = 18;
break;
case 'opacity':
settings[setting] = 0.70;
const opacityInput = document.getElementById('opacity');
const opacityValue = document.getElementById('opacityValue');
if (opacityInput) {
opacityInput.value = 70;
opacityValue.textContent = '70';
}
break;
default:
settings[setting] = null;
const colorInput = document.getElementById(setting);
const hexInput = document.getElementById(setting + 'Hex');
if (colorInput && hexInput) {
colorInput.value = '#000000';
hexInput.value = '';
}
}
GM_setValue('vndb_friends_settings', settings);
forceStyleUpdate();
}
const settingsInputs = {
textColor: document.getElementById('textColor'),
backgroundColor: document.getElementById('backgroundColor'),
fontSize: document.getElementById('fontSize'),
opacity: document.getElementById('opacity')
};
Object.entries(settingsInputs).forEach(([setting, input]) => {
if (settings[setting]) {
if (setting === 'opacity') {
input.value = settings[setting] * 100;
document.getElementById('opacityValue').textContent = input.value;
} else if (setting === 'fontSize') {
input.value = settings[setting];
} else {
input.value = settings[setting];
}
} else if (setting === 'opacity') {
input.value = 70;
document.getElementById('opacityValue').textContent = '70';
}
input.addEventListener('change', function() {
let value = this.value;
if (setting === 'opacity') {
value = this.value / 100;
document.getElementById('opacityValue').textContent = this.value;
} else if (setting === 'fontSize') {
value = parseInt(this.value);
}
settings[setting] = value;
GM_setValue('vndb_friends_settings', settings);
updateContainerStyle();
});
});
const menu = document.querySelector('header nav menu');
if (editLink && !menu.querySelector('li a[href="#"]')) {
const friendsLink = document.createElement('li');
friendsLink.innerHTML = `<a href="#">friends</a>`;
menu.appendChild(friendsLink);
} const isNotifiesPage = location.pathname.includes('/notifies');
if (!editLink && !isNotifiesPage) {
const friendLi = document.createElement('li');
const friendBtn = document.createElement('a');
friendBtn.href = "#";
const friendId = 'u' + userId;
const isAlreadyFriend = friends.includes(friendId);
friendBtn.textContent = isAlreadyFriend ? 'remove the friend' : 'add a friend';
friendLi.appendChild(friendBtn);
menu.appendChild(friendLi);
friendBtn.addEventListener('click', (e) => {
e.preventDefault();
if (friends.includes(friendId)) {
removeFriend(friendId);
friendBtn.textContent = 'add a friend';
} else {
addFriend(friendId);
friendBtn.textContent = 'remove the friend';
}
});
}
function updatePagination() {
const totalPages = Math.ceil(friends.length / friendsPerPage);
const pagination = document.getElementById('pagination');
const activeTabElement = document.querySelector('.tab-button.active');
const activeTab = activeTabElement ? activeTabElement.dataset.tab : 'friendsList';
if (activeTab === 'friendsList' && totalPages > 1) {
pagination.style.display = 'block';
pagination.innerHTML = `
${currentPage > 1 ? `<button class="pageButton" data-page="${currentPage - 1}">←</button>` : ''}
Page ${currentPage} of ${totalPages}
${currentPage < totalPages ? `<button class="pageButton" data-page="${currentPage + 1}">→</button>` : ''}
`;
const pageButtons = pagination.querySelectorAll('.pageButton');
pageButtons.forEach(button => {
button.addEventListener('click', function() {
changePage(parseInt(this.dataset.page));
});
});
} else {
pagination.style.display = 'none';
}
}
function handleTabSwitch(tabId) {
const pagination = document.getElementById('pagination');
if (!pagination) return;
try {
if (tabId === 'activityFeed') {
pagination.style.display = 'none';
} else if (tabId === 'friendsList') {
const friendsList = document.getElementById('friendsList');
if (friendsList) friendsList.offsetHeight;
updatePagination();
}
} catch (e) {
console.warn('Error during tab switch:', e);
if (pagination) pagination.style.display = 'none';
}
}
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', () => {
const tabId = button.dataset.tab;
try {
localStorage.setItem('vndb_friends_active_tab', tabId);
} catch (e) {
console.warn('Failed to save active tab state:', e);
}
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
button.classList.add('active');
document.querySelectorAll(`.tab-content[data-tab="${tabId}"], #${tabId}`).forEach(content => {
content.classList.add('active');
});
if (tabId === 'activityFeed') {
activityTabClicked = true;
const pagination = document.getElementById('pagination');
if (pagination) pagination.style.display = 'none';
updateActivityFeed();
} else if (tabId === 'friendsList') {
sessionStorage.removeItem('vndb_activity_scroll');
const friendsList = document.getElementById('friendsList');
if (friendsList) friendsList.offsetHeight;
updatePagination();
}
});
});
window.addEventListener('load', () => {
const activeTab = localStorage.getItem('vndb_friends_active_tab') || 'friendsList';
const pagination = document.getElementById('pagination');
if (activeTab === 'activityFeed') {
if (pagination) pagination.style.display = 'none';
} else {
handleTabSwitch(activeTab);
updatePagination();
}
});
function changePage(newPage) {
currentPage = newPage;
localStorage.setItem('vndb_friends_current_page', currentPage.toString());
displayFriendsList();
}
function displayFriendsList() {
const friendsList = document.getElementById('friendsList');
friendsList.innerHTML = '';
const totalPages = Math.ceil(friends.length / friendsPerPage);
if (currentPage > totalPages) {
currentPage = Math.max(1, totalPages);
localStorage.setItem('vndb_friends_current_page', currentPage.toString());
}
const startIndex = (currentPage - 1) * friendsPerPage;
const endIndex = startIndex + friendsPerPage;
const currentFriends = friends.slice(startIndex, endIndex);
for (const friend of currentFriends) {
const userData = friendsCache[friend];
if (userData) {
const friendDiv = document.createElement('div');
friendDiv.style.margin = '5px 0';
friendDiv.innerHTML = `
<a href="/u${userData.id.slice(1)}" class="friend-link">${userData.username}</a>
<button class="removeFriend" data-username="${friend}" style="margin-left: 10px;">Remove</button>
`;
friendsList.appendChild(friendDiv);
}
}
forceStyleUpdate();
setTimeout(forceStyleUpdate, 100);
setTimeout(forceStyleUpdate, 300);
const removeButtons = friendsList.querySelectorAll('.removeFriend');
removeButtons.forEach(button => {
button.addEventListener('click', function() {
removeFriend(this.dataset.username);
});
});
updatePagination();
}
async function fetchFriendData(username, forceUpdate = false) {
try {
const cachedData = friendsCache[username];
const now = Date.now();
if (!forceUpdate && cachedData && cachedData.lastUpdate &&
(now - cachedData.lastUpdate) < 24 * 60 * 60 * 1000) {
return cachedData;
}
const response = await new Promise((resolve, reject) => {
const requestId = GM_xmlhttpRequest({
method: 'GET',
url: `https://api.vndb.org/kana/user?q=${encodeURIComponent(username)}`,
headers: { 'Content-Type': 'application/json' },
onload: function(response) {
resolve(JSON.parse(response.responseText));
},
onerror: reject
});
currentRequestId = requestId;
});
if (response[username]) {
friendsCache[username] = { ...response[username], lastUpdate: Date.now() };
GM_setValue('vndb_friends_cache', friendsCache);
return friendsCache[username];
}
return null;
} catch (error) {
console.error(`Error fetching data for friend ${username}:`, error);
return null;
}
}
function updateFriends(newFriends, newCache) {
friends = newFriends;
if (newCache) {
friendsCache = newCache;
}
GM_setValue('vndb_friends', friends);
GM_setValue('vndb_friends_cache', friendsCache);
localStorage.setItem('vndb_friends', JSON.stringify(friends));
localStorage.setItem('vndb_friends_cache', JSON.stringify(friendsCache));
if (bc) {
bc.postMessage({
type: 'friends_update',
friends: friends,
friendsCache: friendsCache
});
}
}
async function addFriend(username) {
if (!username) return;
const isAlreadyInList = friends.some(f => f.toLowerCase() === username.toLowerCase()) ||
Object.values(friendsCache).some(user =>
user.id.toLowerCase() === username.toLowerCase() ||
user.username.toLowerCase() === username.toLowerCase()
);
if (isAlreadyInList) {
removeFriend(username);
return;
}
const userData = await fetchFriendData(username);
if (userData) {
const updatedFriends = [...friends];
if (!updatedFriends.includes(userData.id)) {
updatedFriends.push(userData.id);
}
const updatedCache = {...friendsCache};
updatedCache[userData.id] = userData;
updateFriends(updatedFriends, updatedCache);
currentPage = Math.ceil(friends.length / friendsPerPage);
displayFriendsList();
updatePagination();
//alert('Friend added!');
} else {
alert('User not found!');
}
} function removeFriend(username) {
const updatedFriends = friends.filter(f => f !== username);
const updatedCache = {...friendsCache};
delete updatedCache[username];
updateFriends(updatedFriends, updatedCache);
const totalPages = Math.ceil(friends.length / friendsPerPage);
if (currentPage > totalPages) {
currentPage = Math.max(1, totalPages);
}
displayFriendsList();
updatePagination();
}
const friendsLink = document.querySelector('header nav menu li a[href="#"]');
if (friendsLink) {
friendsLink.addEventListener('click', async (e) => {
e.preventDefault();
const container = document.querySelector('.friends-container');
const isOpen = sessionStorage.getItem('vndb_friends_container_open') === 'true';
if (isOpen) {
container.style.display = 'none';
sessionStorage.setItem('vndb_friends_container_open', 'false');
} else {
showContainer();
}
});
friendsLink.addEventListener('mouseover', checkAndRefreshCache);
}
document.getElementById('closeFriends').addEventListener('click', () => {
const container = document.querySelector('.friends-container');
container.style.display = 'none';
sessionStorage.setItem('vndb_friends_container_open', 'false');
});
document.getElementById('addFriend').addEventListener('click', () => {
const input = document.getElementById('newFriend');
const username = input.value.trim();
addFriend(username);
input.value = '';
});
document.getElementById('newFriend').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const input = document.getElementById('newFriend');
const username = input.value.trim();
addFriend(username);
input.value = '';
}
});
const toggleButton = document.getElementById('toggleSettings');
toggleButton.addEventListener('click', () => {
const isVisible = settingsPanel.style.display === 'block';
settingsPanel.style.display = isVisible ? 'none' : 'block';
toggleButton.textContent = isVisible ? 'Show Settings' : 'Hide Settings';
});
const resetButtons = document.querySelectorAll('.resetButton');
resetButtons.forEach(button => {
button.addEventListener('click', function() {
resetSetting(this.dataset.setting);
});
});
(async function preloadFriendData() {
for (const friend of friends) {
if (!friendsCache[friend]) {
await fetchFriendData(friend);
}
}
})();
document.addEventListener('visibilitychange', () => {
if (!document.hidden && container.style.display === 'block') {
forceStyleUpdate();
}
});
window.addEventListener('resize', () => {
const container = document.querySelector('.friends-container');
if (!container) return;
const isOpen = sessionStorage.getItem('vndb_friends_container_open') === 'true';
if (!isOpen) return;
if (window.innerWidth >= 300 && window.innerHeight >= 200) {
if (container.style.display === 'none') {
container.style.display = 'block';
adjustContainerPosition();
}
} else {
container.style.display = 'none';
}
});
container.addEventListener('animationend', forceStyleUpdate);
container.addEventListener('transitionend', forceStyleUpdate);
const containerObserver = new MutationObserver(() => {
if (container.style.display === 'block') {
forceStyleUpdate();
}
});
containerObserver.observe(container, {
attributes: true,
childList: true,
subtree: true,
characterData: true
});
function syncColorInputs(colorId, hexId) {
const colorInput = document.getElementById(colorId);
const hexInput = document.getElementById(hexId);
colorInput.addEventListener('input', (e) => {
hexInput.value = e.target.value;
settings[colorId] = e.target.value;
GM_setValue('vndb_friends_settings', settings);
forceStyleUpdate();
});
hexInput.addEventListener('input', (e) => {
const hex = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
colorInput.value = hex;
settings[colorId] = hex;
GM_setValue('vndb_friends_settings', settings);
forceStyleUpdate();
}
});
}
function initializeColorInputs() {
const colorPairs = [
['titleColor', 'titleColorHex'],
['textColor', 'textColorHex'],
['buttonTextColor', 'buttonTextColorHex'],
['backgroundColor', 'backgroundColorHex'],
['buttonBackgroundColor', 'buttonBackgroundColorHex'],
['borderColor', 'borderColorHex'],
['separatorColor', 'separatorColorHex']
];
colorPairs.forEach(([colorId, hexId]) => {
const colorInput = document.getElementById(colorId);
const hexInput = document.getElementById(hexId);
if (settings[colorId]) {
colorInput.value = settings[colorId];
hexInput.value = settings[colorId];
}
syncColorInputs(colorId, hexId);
});
const numericInputs = [
'fontSize',
'buttonFontSize',
'tabFontSize',
'cacheDuration',
'gamesPerFriend',
'maxActivities'
]; numericInputs.forEach(settingId => {
const input = document.getElementById(settingId);
if (input && settings[settingId] !== null) {
input.value = settings[settingId];
}
input.addEventListener('change', function() {
settings[settingId] = parseInt(this.value) || null;
GM_setValue('vndb_friends_settings', settings);
forceStyleUpdate();
if (settingId === 'cacheDuration' ||
settingId === 'gamesPerFriend' ||
settingId === 'maxActivities') {
activityCache.timestamp = 0;
localStorage.removeItem('vndb_activity_cache');
if (document.querySelector('.tab-button[data-tab="activityFeed"]').classList.contains('active')) {
updateActivityFeed();
}
}
});
});
const friendsVotesToggle = document.getElementById('friendsVotesToggle');
if (friendsVotesToggle) {
if (settings.friendsVotesEnabled === undefined) {
settings.friendsVotesEnabled = true;
GM_setValue('vndb_friends_settings', settings);
}
friendsVotesToggle.checked = settings.friendsVotesEnabled;
friendsVotesToggle.addEventListener('change', function() {
settings.friendsVotesEnabled = this.checked;
GM_setValue('vndb_friends_settings', settings);
});
}
const opacityInput = document.getElementById('opacity');
const opacityValue = document.getElementById('opacityValue');
if (settings.opacity !== null) {
opacityInput.value = settings.opacity * 100;
opacityValue.textContent = Math.round(settings.opacity * 100);
} else {
opacityInput.value = 70;
opacityValue.textContent = '70';
settings.opacity = 0.70;
GM_setValue('vndb_friends_settings', settings);
}
opacityInput.addEventListener('input', function() {
opacityValue.textContent = this.value;
settings.opacity = this.value / 100;
GM_setValue('vndb_friends_settings', settings);
forceStyleUpdate();
});
}
const importExportHTML = `
<div class="settings-group" style="margin-top: 20px;">
<label>Backup:</label>
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
<button id="exportData">Export All</button>
<button id="importData">Import</button>
</div>
</div>
<div id="importOptions" style="display: none; margin-top: 10px;">
<div style="margin-bottom: 10px;">
<input type="file" id="importFile" accept=".json" style="display: none;">
<label>Import options:</label>
<div style="margin-top: 5px;">
<label style="font-weight: normal;">
<input type="checkbox" id="importFriends" checked> Friends List
</label>
<label style="font-weight: normal; margin-left: 10px;">
<input type="checkbox" id="importSettings" checked> Settings
</label>
</div>
<div style="margin-top: 10px;">
<button id="confirmImport">Confirm Import</button>
<button id="cancelImport">Cancel</button>
</div>
</div>
</div>
`;
function setupImportExport() {
const exportButton = document.getElementById('exportData');
const importButton = document.getElementById('importData');
const importOptions = document.getElementById('importOptions');
const importFile = document.getElementById('importFile');
const confirmImport = document.getElementById('confirmImport');
const cancelImport = document.getElementById('cancelImport');
const importFriendsCheck = document.getElementById('importFriends');
const importSettingsCheck = document.getElementById('importSettings');
exportButton.addEventListener('click', () => {
const exportData = {
friends: friends,
friendsCache: friendsCache,
settings: settings
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `vndb_friends_backup_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
importButton.addEventListener('click', () => {
importOptions.style.display = 'block';
importButton.style.display = 'none';
});
cancelImport.addEventListener('click', () => {
importOptions.style.display = 'none';
importButton.style.display = 'block';
importFile.value = '';
});
confirmImport.addEventListener('click', () => {
importFile.click();
});
importFile.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const importData = JSON.parse(event.target.result);
if (importFriendsCheck.checked) {
friends = importData.friends || [];
friendsCache = importData.friendsCache || {};
friends = friends.map(f => /^u\d+$/.test(f)
? f
: (friendsCache[f] && friendsCache[f].id ? friendsCache[f].id : f));
for (const key in friendsCache) {
if (!friendsCache[key].hasOwnProperty('lastUpdate') || !friendsCache[key].lastUpdate) {
friendsCache[key].lastUpdate = Date.now();
}
}
for (const key in friendsCache) {
if (!/^u\d+$/.test(key) && friendsCache[key] && /^u\d+$/.test(friendsCache[key].id)) {
const properKey = friendsCache[key].id;
friendsCache[properKey] = friendsCache[key];
delete friendsCache[key];
}
}
GM_setValue('vndb_friends', friends);
GM_setValue('vndb_friends_cache', friendsCache);
displayFriendsList();
}
if (importSettingsCheck.checked && importData.settings) {
const newSettings = {
textColor: null,
buttonTextColor: null,
backgroundColor: null,
buttonBackgroundColor: null,
titleColor: null,
borderColor: null,
separatorColor: null,
fontSize: 17,
buttonFontSize: 16,
tabFontSize: 18,
opacity: null,
cacheDuration: 3,
gamesPerFriend: 5,
maxActivities: 51,
friendsVotesEnabled: true,
...importData.settings
};
settings = newSettings;
GM_setValue('vndb_friends_settings', settings);
initializeColorInputs();
}
if (localStorage.getItem('vndb_friends_active_tab') === 'activityFeed') {
updateActivityFeed();
}
alert('Import completed successfully!');
} catch (error) {
alert('Error importing data. Please check the file format.');
console.error('Import error:', error);
}
};
reader.readAsText(file);
});
function updateImportButton() {
confirmImport.disabled = !importFriendsCheck.checked && !importSettingsCheck.checked;
}
importFriendsCheck.addEventListener('change', updateImportButton);
importSettingsCheck.addEventListener('change', updateImportButton);
}
function initializeImportExport() {
const settingsPanel = container.querySelector('.friends-settings');
const existingSection = settingsPanel.querySelector('#importExportSection');
if (existingSection) {
existingSection.remove();
}
const importExportDiv = document.createElement('div');
importExportDiv.id = 'importExportSection';
importExportDiv.innerHTML = importExportHTML;
settingsPanel.appendChild(importExportDiv);
setupImportExport();
}
let isContainerOpen = sessionStorage.getItem('vndb_friends_container_open') === 'true';
if (isContainerOpen && editLink) {
container.style.display = 'block';
initializeColorInputs();
initializeImportExport();
forceStyleUpdate();
displayFriendsList();
} let activityCache;
try {
const storedCache = localStorage.getItem('vndb_activity_cache') || '';
activityCache = storedCache ? JSON.parse(storedCache) : { timestamp: 0, data: [] };
} catch (e) {
console.error('Error parsing vndb_activity_cache, resetting cache:', e);
activityCache = { timestamp: 0, data: [] };
localStorage.setItem('vndb_activity_cache', JSON.stringify(activityCache));
}
window.addEventListener('storage', (e) => {
if (e.key === 'vndb_activity_cache') {
try {
const newCache = e.newValue ? JSON.parse(e.newValue) : { timestamp: 0, data: [] };
activityCache = newCache;
} catch (e) {
console.error('Error parsing updated vndb_activity_cache:', e);
activityCache = { timestamp: 0, data: [] };
}
}
});
async function fetchFriendActivity(username) {
try {
const userData = friendsCache[username];
if (!userData || !userData.id) {
console.error(`No cached data found for user ${username}`);
return [];
}
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.vndb.org/kana/ulist',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
"user": userData.id,
"fields": "id, vote, voted, vn.title",
"filters": ["label", "=", 7],
"sort": "voted",
"reverse": true,
"results": settings.gamesPerFriend || 5
}),
onload: function(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
if (data && Array.isArray(data.results)) {
resolve(data);
} else {
console.error('Invalid API response structure:', data);
resolve({ results: [] });
}
} catch (e) {
console.error('JSON parse error:', e);
resolve({ results: [] });
}
} else {
console.error('API error:', response.responseText);
resolve({ results: [] });
}
},
onerror: function(error) {
console.error('Request error:', error);
resolve({ results: [] });
}
});
});
if (!response.results) {
return [];
}
return response.results.map(item => ({
username,
vnId: item.id,
vnTitle: item.vn.title,
vote: item.vote / 10,
voted: item.voted
}));
} catch (error) {
console.error(`Error fetching activity for ${username}:`, error);
return [];
}
}
function preloadActivityData() {
const now = Date.now();
const cacheDurationMs = (settings.cacheDuration || 3) * 60 * 1000;
if (!activityCache.data || activityCache.data.length === 0 || now - activityCache.timestamp >= cacheDurationMs) {
updateActivityFeed();
}
}
async function updateActivityFeed() {
if (isUpdatingActivity && currentRequestId !== null) {
GM_xmlhttpRequest.abort(currentRequestId);
currentRequestId = null;
}
isUpdatingActivity = true;
const now = Date.now();
const cacheDurationMs = (settings.cacheDuration || 3) * 60 * 1000; if (activityCache.data && activityCache.data.length > 0 && now - activityCache.timestamp < cacheDurationMs) {
displayActivityFeed(activityCache.data);
if (activityTabClicked) {
const cacheMsg = document.createElement('div');
cacheMsg.style.textAlign = 'center';
cacheMsg.style.opacity = '0.7';
cacheMsg.style.fontSize = '0.8em';
cacheMsg.style.paddingTop = '3px';
const timeLeft = Math.round((cacheDurationMs - (now - activityCache.timestamp)) / 1000);
cacheMsg.textContent = `Loaded from cache (expires in ${timeLeft}s)`;
cacheMsg.style.visibility = 'hidden';
activityFeed.insertAdjacentElement('afterbegin', cacheMsg);
const targetHeight = 20;
let fontSizePx = parseFloat(window.getComputedStyle(cacheMsg).fontSize);
while (cacheMsg.offsetHeight > targetHeight && fontSizePx > 10) {
fontSizePx -= 1;
cacheMsg.style.fontSize = fontSizePx + "px";
}
cacheMsg.style.visibility = 'visible';
activityFeed.scrollTop = 4;
setTimeout(() => {
cacheMsg.remove();
}, 1500);
} else {
let savedPos = sessionStorage.getItem('vndb_activity_scroll');
activityFeed.scrollTop = savedPos ? parseInt(savedPos, 10) : 4;
}
isUpdatingActivity = false;
return;
}
if (friends.length > 200) {
activityFeed.innerHTML = '<div class="error">Too many friends to fetch activity (200 max).<br>Please reduce your friends list or increase cache duration in settings.</div>';
isUpdatingActivity = false;
return;
}
activityFeed.innerHTML = '<div class="loading">Fetching new activity data...</div>';
try {
const requests = friends.map(friend => new Promise((resolve) => {
const requestId = GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.vndb.org/kana/ulist',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
"user": friendsCache[friend] && friendsCache[friend].id,
"fields": "id, vote, voted, vn.title",
"filters": ["label", "=", 7],
"sort": "voted",
"reverse": true,
"results": settings.gamesPerFriend || 5
}),
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
if (data && Array.isArray(data.results)) {
resolve(data.results.map(item => ({
username: friend,
vnId: item.id,
vnTitle: item.vn.title,
vote: item.vote / 10,
voted: item.voted
})));
} else {
resolve([]);
}
} catch(e) {
resolve([]);
}
},
onerror: function(err) {
resolve([]);
}
});
currentRequestId = requestId;
}));
const activityResults = await Promise.all(requests);
const activities = activityResults.flat();
activities.sort((a, b) => b.voted - a.voted);
const maxActivities = settings.maxActivities || 51;
const limitedActivities = activities.slice(0, maxActivities);
activityCache = { timestamp: now, data: limitedActivities };
localStorage.setItem('vndb_activity_cache', JSON.stringify(activityCache));
displayActivityFeed(limitedActivities);
if (activityTabClicked) {
activityFeed.scrollTop = 4;
activityTabClicked = false;
} else {
let savedPos = sessionStorage.getItem('vndb_activity_scroll');
activityFeed.scrollTop = savedPos ? parseInt(savedPos, 10) : 4;
}
} catch (error) {
console.error('Error updating activity feed:', error);
activityFeed.innerHTML = '<div class="error">Error loading activity feed</div>';
} finally {
isUpdatingActivity = false;
currentRequestId = null;
}
}
function displayActivityFeed(activities) {
const activityFeed = document.getElementById('activityFeed');
activityFeed.innerHTML = '';
if (!activities || activities.length === 0) {
activityFeed.innerHTML = '<div class="no-activity">No recent activity</div>';
return;
}
const maxActivities = settings.maxActivities || 51;
const limitedActivities = activities.slice(0, maxActivities);
limitedActivities.forEach(activity => {
if (!activity.voted || !activity.vnTitle) return;
const date = new Date(activity.voted * 1000);
const formattedDate = `${date.getDate().toString().padStart(2, '0')}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getFullYear()}`;
const activityItem = document.createElement('div');
activityItem.className = 'activity-item';
const userData = friendsCache[activity.username];
const userId = userData ? userData.id.slice(1) : '';
activityItem.innerHTML = `
<div>
<strong><a href="/u${userId}" class="friend-link">${userData ? userData.username : activity.username}</a></strong> rated
<a href="/v${activity.vnId.toString().replace('v', '')}" class="friend-link vn-link">${activity.vnTitle}</a>
<strong>${activity.vote}</strong>
</div>
<div class="activity-date">${formattedDate}</div>
`;
activityFeed.appendChild(activityItem);
});
const vnLinks = activityFeed.querySelectorAll('a.vn-link');
vnLinks.forEach(link => {
link.addEventListener('mouseenter', function() {
handleFriendsMouseOver.call(this);
});
link.addEventListener('mouseleave', function() {
handleFriendsMouseLeave.call(this);
});
});
adjustContainerPosition();
window.addEventListener('scroll', () => {
if ($('#friendsPopover').css('display') === 'block') {
$('#friendsPopover').friendsCenter();
}
});
}
let activeTab = localStorage.getItem('vndb_friends_active_tab') || 'friendsList';
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', () => {
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
button.classList.add('active');
const tabId = button.dataset.tab;
document.querySelectorAll(`.tab-content[data-tab="${tabId}"], #${tabId}`).forEach(content => {
content.classList.add('active');
});
localStorage.setItem('vndb_friends_active_tab', tabId);
activeTab = tabId;
if (tabId === 'activityFeed') {
updateActivityFeed();
}
});
});
if (isContainerOpen && editLink) {
container.style.display = 'block';
initializeColorInputs();
initializeImportExport();
forceStyleUpdate();
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
const activeTabButton = document.querySelector(`.tab-button[data-tab="${activeTab}"]`);
const activeTabContent = document.getElementById(activeTab);
if (activeTabButton && activeTabContent) {
activeTabButton.classList.add('active');
activeTabContent.classList.add('active');
document.querySelectorAll(`.tab-content[data-tab="${activeTab}"]`).forEach(content => {
content.classList.add('active');
});
if (activeTab === 'activityFeed') {
updateActivityFeed();
} else {
displayFriendsList();
}
}
}
function adjustContainerPosition() {
requestAnimationFrame(() => {
const container = document.querySelector('.friends-container');
if (!container) return;
container.style.maxWidth = '90vw';
container.style.maxHeight = '80vh';
container.style.top = '50%';
container.style.left = '50%';
container.style.transform = 'translate(-50%, -50%)';
const isOpen = sessionStorage.getItem('vndb_friends_container_open') === 'true';
if (!isOpen || window.innerWidth < 300 || window.innerHeight < 200) {
container.style.display = 'none';
return;
}
container.style.display = 'block';
const viewportHeight = window.innerHeight;
const containerHeight = container.offsetHeight;
if (containerHeight > viewportHeight * 0.9) {
container.style.maxHeight = `${viewportHeight * 0.9}px`;
container.style.top = `50%`;
container.style.transform = `translate(-50%, -50%)`;
}
});
}
const settingsObserver = new MutationObserver(() => {
requestAnimationFrame(() => {
const container = document.querySelector('.friends-container');
if (container.style.display === 'block') {
adjustContainerPosition();
}
});
});
settingsObserver.observe(document.querySelector('.friends-settings'), { attributes: true, attributeFilter: ['style'] });
function showContainer() {
const editLink = document.querySelector('header nav menu li a[href$="/edit"]');
const container = document.querySelector('.friends-container');
if (!editLink || !container) {
sessionStorage.setItem('vndb_friends_container_open', 'false');
if (container) container.style.display = 'none';
return;
}
if (window.innerWidth < 300 || window.innerHeight < 200) {
alert('The viewport is too small to display the friends list. Please resize your browser window.');
sessionStorage.setItem('vndb_friends_container_open', 'false');
container.style.display = 'none';
return;
}
const now = Date.now();
const lastRefresh = GM_getValue('vndb_friends_last_refresh', 0);
if (now - lastRefresh > 86400000) {
Promise.all(friends.map(friend => fetchFriendData(friend)))
.then(() => GM_setValue('vndb_friends_last_refresh', now))
.catch(console.error);
}
sessionStorage.setItem('vndb_friends_container_open', 'true');
container.style.display = 'block';
requestAnimationFrame(() => {
adjustContainerPosition();
setTimeout(adjustContainerPosition, 100);
});
initializeColorInputs();
initializeImportExport();
forceStyleUpdate();
displayFriendsList();
preloadActivityData();
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
const debouncedAdjustContainerPosition = debounce(() => {
const isOpen = sessionStorage.getItem('vndb_friends_container_open') === 'true';
if (isOpen) {
adjustContainerPosition();
if (window.innerWidth >= 300 && window.innerHeight >= 200) {
showContainer();
}
}
}, 100);
window.addEventListener('resize', debouncedAdjustContainerPosition);
window.addEventListener('scroll', debouncedAdjustContainerPosition);
let timeoutId;
$('body').append('<div id="friendsPopover"></div>');
$('#friendsPopover').css({
position: 'absolute',
zIndex: '1001',
boxShadow: '0px 0px 5px black',
display: 'none'
});
jQuery.fn.friendsCenter = function () {
const windowHeight = $(window).height();
const boxHeight = $(this).outerHeight();
const scrollOffset = $(window).scrollTop();
const hoveredLink = $('.activity-item a:hover').get(0);
if (!hoveredLink) return this;
const rect = hoveredLink.getBoundingClientRect();
const leftoffset = rect.left;
const topoffset = rect.top;
let newTopOffset;
if (topoffset - boxHeight / 2 < 10) {
newTopOffset = 10;
} else if (topoffset + boxHeight / 2 > windowHeight - 10) {
newTopOffset = windowHeight - boxHeight - 10;
} else {
newTopOffset = topoffset - boxHeight / 2;
}
this.css("top", newTopOffset + scrollOffset);
this.css("left", Math.max(0, leftoffset - $(this).outerWidth() - 25));
return this;
};
function handleFriendsMouseOver() {
const activeTab = localStorage.getItem('vndb_friends_active_tab');
if (activeTab !== 'activityFeed') return;
const vnId = this.getAttribute('href');
if (!vnId) return;
const pagelink = 'https://vndb.org' + vnId;
timeoutId = setTimeout(() => {
if (GM_getValue(pagelink)) {
const retrievedLink = GM_getValue(pagelink);
$('#friendsPopover').empty().append('<img src="' + retrievedLink + '"></img>');
$('#friendsPopover img').on('load', function() {
if (this.height === 0) {
GM_deleteValue(pagelink);
} else {
$('#friendsPopover').friendsCenter().css('display', 'block');
}
});
} else {
$.ajax({
url: pagelink,
dataType: 'text',
success: function (data) {
const parser = new DOMParser();
const dataDOC = parser.parseFromString(data, 'text/html');
const imagelink = dataDOC.querySelector(".vnimg img").src;
if (!imagelink) return;
const img = new Image();
img.onload = function() {
const currentTab = localStorage.getItem('vndb_friends_active_tab');
if (currentTab !== 'activityFeed') return;
if (this.height === 0) return;
$('#friendsPopover').empty().append(this).friendsCenter().css('display', 'block');
GM_setValue(pagelink, imagelink);
};
img.src = imagelink;
}
});
}
}, 250);
}
function handleFriendsMouseLeave() {
const activeTab = localStorage.getItem('vndb_friends_active_tab');
if (activeTab !== 'activityFeed') return;
clearTimeout(timeoutId);
$('#friendsPopover').css('display', 'none');
}
const pageObserver = new MutationObserver(() => {
if (document.querySelector('.friends-container').style.display === 'block') {
adjustContainerPosition();
}
});
pageObserver.observe(document.body, { childList: true, subtree: true, attributes: true });
function checkAndRefreshCache() {
const now = Date.now();
const cacheDurationMs = (settings.cacheDuration || 3) * 60 * 1000;
if (now - activityCache.timestamp >= cacheDurationMs) {
updateActivityFeed();
}
}
const reloadButton = document.getElementById('reloadActivity');
reloadButton.addEventListener('click', async () => {
reloadButton.disabled = true;
clearTimeout(reloadTimeout);
reloadTimeout = setTimeout(() => {
activityCache.timestamp = 0;
updateActivityFeed().finally(() => {
reloadButton.disabled = false;
});
}, 300);
});
if (!editLink) {
const menuObserver = new MutationObserver((mutations) => {
const currentEditLink = document.querySelector('header nav menu li a[href$="/edit"]');
if (currentEditLink) {
editLink = currentEditLink;
const isOpen = sessionStorage.getItem('vndb_friends_container_open') === 'true';
if (isOpen) safeShowContainer();
menuObserver.disconnect();
}
});
menuObserver.observe(document.body, { childList: true, subtree: true });
}
function validateContainerState() {
const isValidState = () => {
const editLinkExists = !!document.querySelector('header nav menu li a[href$="/edit"]');
const containerExists = !!document.querySelector('.friends-container');
const storedState = sessionStorage.getItem('vndb_friends_container_open') === 'true';
return editLinkExists && containerExists && storedState;
};
if (!isValidState()) {
sessionStorage.setItem('vndb_friends_container_open', 'false');
const container = document.querySelector('.friends-container');
if (container) container.style.display = 'none';
}
}
setInterval(validateContainerState, 2000);
function ensureContainerExists() {
if (!document.querySelector('.friends-container')) {
document.body.appendChild(friendsContainer);
console.warn('Recreated missing friends container');
}
}
setInterval(ensureContainerExists, 5000);
let retryCount = 0;
function showContainerWithRetry() {
if (retryCount > 3) return;
try {
showContainer();
retryCount = 0;
} catch (error) {
console.error('Container show error:', error);
retryCount++;
setTimeout(showContainerWithRetry, 500 * retryCount);
}
}
function safeShowContainer() {
try {
showContainer();
} catch (error) {
console.error('Error showing container:', error);
sessionStorage.setItem('vndb_friends_container_open', 'false');
ensureContainerExists();
setTimeout(() => {
document.querySelector('.friends-container').style.display = 'none';
}, 100);
}
}
const visibilityEvents = ['pageshow', 'focus', 'hashchange'];
visibilityEvents.forEach(event => {
window.addEventListener(event, () => {
setTimeout(handleContainerVisibility, 100);
});
});
const originalPushState = history.pushState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
setTimeout(handleContainerVisibility, 50);
};
const originalReplaceState = history.replaceState;
history.replaceState = function(...args) {
originalReplaceState.apply(this, args);
setTimeout(handleContainerVisibility, 50);
};
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
editLink = document.querySelector('header nav menu li a[href$="/edit"]');
setTimeout(() => {
handleContainerVisibility();
adjustContainerPosition();
initializeColorInputs();
initializeImportExport();
displayFriendsList();
forceStyleUpdate();
if (sessionStorage.getItem('vndb_friends_container_open') === 'true') {
showContainerWithRetry();
}
}, 150);
}
});
function handleContainerVisibility() {
const editLink = document.querySelector('header nav menu li a[href$="/edit"]');
const container = document.querySelector('.friends-container');
const storedState = sessionStorage.getItem('vndb_friends_container_open') === 'true';
const shouldBeVisible = () => {
if (!editLink || !container) return false;
if (window.innerWidth < 300 || window.innerHeight < 200) return false;
return storedState;
};
if (shouldBeVisible() && container.style.display !== 'block') {
showContainerWithRetry();
} else if (!shouldBeVisible() && container.style.display === 'block') {
container.style.display = 'none';
}
}
setTimeout(handleContainerVisibility, 500);
document.getElementById('activityFeed').addEventListener('scroll', function () {
sessionStorage.setItem('vndb_activity_scroll', this.scrollTop);
});
})();
})();