Nitan User Tags

Add user tags to Nitan forum users

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Nitan User Tags
// @namespace    NUT
// @version      0.2.0
// @description  Add user tags to Nitan forum users
// @icon         https://static.thenounproject.com/png/888732-200.png
// @author       s5kf
// @contributor  cucco-io
// @license      CC BY-NC-ND 4.0
// @match        *://www.uscardforum.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // Configuration
    const CONFIG = {
        STORAGE_KEY: 'userTags',
        DEBOUNCE_MS: 100,
        SAVE_ANIMATION_MS: 2000,
        TAG_SEPARATOR: ',',
        PROCESSED_ATTR: 'data-nut-processed',
        USERNAME_ATTR: 'data-nut-username'
    };

    // Storage functions with error handling
    function getTagMap() {
        try {
            const tagJsonStr = localStorage.getItem(CONFIG.STORAGE_KEY);
            return tagJsonStr ? JSON.parse(tagJsonStr) : {};
        } catch (e) {
            console.error('[NUT] Failed to load tags:', e);
            return {};
        }
    }

    function saveTagMap(tagMap) {
        try {
            localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(tagMap));
            return true;
        } catch (e) {
            console.error('[NUT] Failed to save tags:', e);
            return false;
        }
    }

    function getTagsForUser(username) {
        const tagMap = getTagMap();
        const tagString = tagMap[username];
        if (!tagString) return [];
        return tagString.split(CONFIG.TAG_SEPARATOR).map(t => t.trim()).filter(t => t);
    }

    function saveTagsForUser(username, tagString, button, container) {
        const tagMap = getTagMap();
        if (tagString && tagString.trim()) {
            tagMap[username] = tagString.trim();
        } else {
            delete tagMap[username];
        }

        if (saveTagMap(tagMap)) {
            console.log(`[NUT] Saved tags for "${username}": "${tagString}"`);
            showSaveAnimation(button, container);
            refreshUserTags(username);
        }
    }

    function refreshUserTags(username) {
        const userElements = document.querySelectorAll(`.username[${CONFIG.USERNAME_ATTR}="${username}"]`);
        console.log(`[NUT] Refreshing tags for "${username}", found ${userElements.length} elements`);
        userElements.forEach(userElement => {
            updateTagDisplay(userElement, username);

            // Also update the input field in the tag UI
            const tagInput = userElement.querySelector('.tag-input');
            if (tagInput) {
                const currentTags = getTagsForUser(username);
                tagInput.value = currentTags.join(', ');
            }
        });
    }

    // UI display and update functions
    function updateTagDisplay(userElement, username) {
        // Remove existing tag elements
        const existingTags = userElement.querySelectorAll('.user-tag');
        existingTags.forEach(tag => tag.remove());

        // Add current tags
        const tags = getTagsForUser(username);
        tags.forEach((tag, index) => {
            const tagElement = createInteractiveTag(tag, index, username, userElement);

            // Insert before tag icon if it exists, otherwise append
            const tagIcon = userElement.querySelector('.tag-icon');
            if (tagIcon) {
                userElement.insertBefore(tagElement, tagIcon);
            } else {
                userElement.appendChild(tagElement);
            }
        });
    }

    function createInteractiveTag(tagText, index, username, userElement) {
        const tagElement = document.createElement('span');
        tagElement.className = 'user-tag';
        tagElement.setAttribute('data-tag-index', index);
        tagElement.setAttribute('draggable', 'true');

        // Tag text container
        const textSpan = document.createElement('span');
        textSpan.className = 'user-tag-text';
        textSpan.textContent = tagText;

        // Click to edit tag
        textSpan.addEventListener('click', (e) => {
            e.stopPropagation();
            const tagUI = userElement.querySelector('.tag-ui-container');
            const input = tagUI.querySelector('.tag-input');
            const currentTags = getTagsForUser(username);

            // Focus the tag being edited
            input.value = currentTags.join(', ');
            tagUI.style.display = 'flex';
            input.focus();

            // Select the specific tag in the input
            setTimeout(() => {
                const tagPosition = currentTags.slice(0, index).join(', ').length;
                const start = tagPosition + (index > 0 ? 2 : 0); // account for ", "
                const end = start + tagText.length;
                input.setSelectionRange(start, end);
            }, 0);
        });

        // Remove button
        const removeBtn = document.createElement('span');
        removeBtn.className = 'user-tag-remove';
        removeBtn.innerHTML = '×';
        removeBtn.setAttribute('title', 'Remove tag');

        removeBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            const currentTags = getTagsForUser(username);
            currentTags.splice(index, 1);
            const newTagString = currentTags.join(', ');

            const tagMap = getTagMap();
            if (newTagString.trim()) {
                tagMap[username] = newTagString;
            } else {
                delete tagMap[username];
            }
            saveTagMap(tagMap);
            refreshUserTags(username);
        });

        // Drag and drop handlers
        tagElement.addEventListener('dragstart', (e) => {
            e.dataTransfer.effectAllowed = 'move';
            e.dataTransfer.setData('text/plain', index.toString());
            tagElement.classList.add('dragging');
        });

        tagElement.addEventListener('dragend', (e) => {
            tagElement.classList.remove('dragging');
        });

        tagElement.addEventListener('dragover', (e) => {
            e.preventDefault();
            e.dataTransfer.dropEffect = 'move';

            const draggingTag = userElement.querySelector('.user-tag.dragging');
            if (draggingTag && draggingTag !== tagElement) {
                tagElement.classList.add('drag-over');
            }
        });

        tagElement.addEventListener('dragleave', (e) => {
            tagElement.classList.remove('drag-over');
        });

        tagElement.addEventListener('drop', (e) => {
            e.preventDefault();
            tagElement.classList.remove('drag-over');

            const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
            const toIndex = index;

            if (fromIndex !== toIndex) {
                const currentTags = getTagsForUser(username);
                const [movedTag] = currentTags.splice(fromIndex, 1);
                currentTags.splice(toIndex, 0, movedTag);

                const newTagString = currentTags.join(', ');
                const tagMap = getTagMap();
                tagMap[username] = newTagString;
                saveTagMap(tagMap);
                refreshUserTags(username);
            }
        });

        tagElement.appendChild(textSpan);
        tagElement.appendChild(removeBtn);

        return tagElement;
    }

    function showSaveAnimation(button, container) {
        button.innerHTML = '✔️';
        button.classList.add('saved');

        setTimeout(() => {
            button.innerHTML = 'Save';
            button.classList.remove('saved');
            container.style.display = 'none';
        }, CONFIG.SAVE_ANIMATION_MS);
    }

    function createTagUI(userElement) {
        // Prevent duplicate UI creation
        if (userElement.hasAttribute(CONFIG.PROCESSED_ATTR)) {
            return;
        }
        userElement.setAttribute(CONFIG.PROCESSED_ATTR, 'true');

        // Extract clean username before adding any tags
        const username = userElement.textContent.trim();
        if (!username) return;

        // Store username as data attribute for later reference
        userElement.setAttribute(CONFIG.USERNAME_ATTR, username);

        // Skip if inside avatar elements
        if (userElement.closest('.post-avatar') ||
            userElement.closest('.topic-avatar') ||
            userElement.closest('.topic-poster')) {
            return;
        }

        // Add initial tags
        updateTagDisplay(userElement, username);

        // Create tag icon
        const tagIcon = document.createElement('span');
        tagIcon.className = 'tag-icon';
        tagIcon.textContent = '🏷️';

        // Create tag UI container
        const tagUIContainer = document.createElement('div');
        tagUIContainer.className = 'tag-ui-container';
        tagUIContainer.style.display = 'none'; // Initialize display state

        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'tag-input';
        input.placeholder = 'Enter tags (comma-separated)';
        const currentTags = getTagsForUser(username);
        input.value = currentTags.join(', ');

        const buttonContainer = document.createElement('div');
        buttonContainer.className = 'tag-button-container';

        const saveButton = document.createElement('button');
        saveButton.textContent = 'Save';
        saveButton.className = 'tag-save-button';

        const deleteButton = document.createElement('button');
        deleteButton.textContent = 'Delete';
        deleteButton.className = 'tag-delete-button';

        // Event handlers
        saveButton.addEventListener('click', () => {
            saveTagsForUser(username, input.value, saveButton, tagUIContainer);
        });

        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                saveTagsForUser(username, input.value, saveButton, tagUIContainer);
            } else if (e.key === 'Escape') {
                tagUIContainer.style.display = 'none';
            }
        });

        deleteButton.addEventListener('click', () => {
            saveTagsForUser(username, '', saveButton, tagUIContainer);
            input.value = '';
        });

        tagIcon.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            tagUIContainer.style.display = tagUIContainer.style.display === 'none' ? 'flex' : 'none';
            if (tagUIContainer.style.display === 'flex') {
                input.focus();
            }
        });

        // Assemble UI
        buttonContainer.appendChild(saveButton);
        buttonContainer.appendChild(deleteButton);
        tagUIContainer.appendChild(input);
        tagUIContainer.appendChild(buttonContainer);

        userElement.appendChild(tagIcon);
        userElement.appendChild(tagUIContainer);
    }

    function processAllUsers() {
        const userElements = document.querySelectorAll('.username');
        userElements.forEach(userElement => {
            createTagUI(userElement);
        });
    }

    // Inject CSS styles using forum's color scheme
    const style = document.createElement('style');
    style.textContent = `
        :root {
            /* Tag styling - uses tertiary (blue) color */
            --nut-tag-bg: var(--tertiary, #0f82af);
            --nut-tag-color: var(--secondary, #222222);
            --nut-tag-border: var(--tertiary, #0f82af);

            /* Icon styling */
            --nut-icon-color: var(--primary-medium, rgb(143.65, 143.65, 143.65));
            --nut-icon-hover: var(--primary-high, rgb(165.75, 165.75, 165.75));

            /* Input styling - follows forum theme */
            --nut-input-bg: var(--secondary, #222222);
            --nut-input-border: var(--primary-low-mid, rgb(121.55, 121.55, 121.55));
            --nut-input-color: var(--primary, #dddddd);
            --nut-input-focus-border: var(--tertiary, #0f82af);

            /* Button styling - uses tertiary color */
            --nut-button-bg: var(--tertiary, #0f82af);
            --nut-button-color: var(--secondary, #222222);
            --nut-button-hover: var(--highlight, #a87137);

            /* Delete button - uses danger color */
            --nut-delete-bg: transparent;
            --nut-delete-color: var(--danger, #e45735);
            --nut-delete-hover-bg: var(--danger, #e45735);
            --nut-delete-hover-color: var(--secondary, #222222);

            /* Save success - uses success color */
            --nut-saved-bg: var(--success, #1ca551);

            /* UI container */
            --nut-container-bg: var(--secondary, #222222);
            --nut-container-border: var(--primary-medium, rgb(143.65, 143.65, 143.65));
            --nut-container-shadow: rgba(0, 0, 0, 0.3);
        }

        .user-tag {
            display: inline-flex;
            align-items: center;
            gap: 3px;
            margin-left: 4px;
            padding: 1px 6px;
            font-size: 0.857em; /* Matches forum's relative sizing */
            font-weight: 600;
            line-height: 1.3;
            color: var(--nut-tag-color);
            background: var(--nut-tag-bg);
            border-radius: 2px;
            vertical-align: baseline;
            position: relative;
            top: -1px; /* Fine-tune vertical alignment */
            cursor: move;
            transition: all 0.2s ease;
        }

        .user-tag:hover {
            opacity: 0.9;
            transform: translateY(-1px);
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
        }

        .user-tag.dragging {
            opacity: 0.5;
            cursor: grabbing;
        }

        .user-tag.drag-over {
            border: 1px dashed var(--nut-tag-color);
            padding-left: 5px;
            padding-right: 5px;
        }

        .user-tag-text {
            cursor: pointer;
            user-select: none;
        }

        .user-tag-text:hover {
            text-decoration: underline;
        }

        .user-tag-remove {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 14px;
            height: 14px;
            margin-left: 2px;
            font-size: 16px;
            font-weight: 700;
            line-height: 1;
            color: var(--nut-tag-color);
            opacity: 0.6;
            cursor: pointer;
            border-radius: 2px;
            transition: all 0.15s ease;
            user-select: none;
        }

        .user-tag-remove:hover {
            opacity: 1;
            background: rgba(0, 0, 0, 0.2);
            color: var(--danger, #e45735);
        }

        .tag-icon {
            display: inline-block;
            margin-left: 4px;
            font-size: 0.9em; /* Slightly smaller emoji */
            color: var(--nut-icon-color);
            cursor: pointer;
            vertical-align: baseline;
            position: relative;
            top: 0px;
            opacity: 0.7;
            transition: opacity 0.2s ease, color 0.2s ease;
        }

        .tag-icon:hover {
            color: var(--nut-icon-hover);
            opacity: 1;
        }

        .tag-ui-container {
            display: none;
            flex-direction: column;
            gap: 6px;
            margin-top: 6px;
            padding: 8px;
            background: var(--nut-container-bg);
            border: 1px solid var(--nut-container-border);
            border-radius: 3px;
            box-shadow: 0 2px 6px var(--nut-container-shadow);
            max-width: 280px;
        }

        .tag-input {
            width: 100%;
            padding: 6px 8px;
            font-size: 0.929em; /* 13px equivalent, forum-matched */
            color: var(--nut-input-color);
            background: var(--nut-input-bg);
            border: 1px solid var(--nut-input-border);
            border-radius: 2px;
            outline: none;
            box-sizing: border-box;
            transition: border-color 0.2s ease;
            line-height: 1.4;
        }

        .tag-input:focus {
            border-color: var(--nut-input-focus-border);
        }

        .tag-input::placeholder {
            color: var(--primary-medium, rgb(143.65, 143.65, 143.65));
            opacity: 0.8;
        }

        .tag-button-container {
            display: flex;
            gap: 6px;
        }

        .tag-save-button, .tag-delete-button {
            flex: 1;
            padding: 6px 10px;
            font-size: 0.857em; /* 12px equivalent, compact */
            font-weight: 600;
            cursor: pointer;
            border: none;
            border-radius: 2px;
            transition: all 0.2s ease;
            line-height: 1.4;
        }

        .tag-save-button {
            background: var(--nut-button-bg);
            color: var(--nut-button-color);
        }

        .tag-save-button:hover {
            background: var(--nut-button-hover);
            color: var(--nut-button-color);
        }

        .tag-save-button.saved {
            background: var(--nut-saved-bg);
            color: var(--secondary, #222222);
        }

        .tag-delete-button {
            background: var(--nut-delete-bg);
            color: var(--nut-delete-color);
            border: 1px solid var(--nut-delete-color);
        }

        .tag-delete-button:hover {
            background: var(--nut-delete-hover-bg);
            color: var(--nut-delete-hover-color);
            border-color: var(--nut-delete-hover-bg);
        }
    `;
    document.head.appendChild(style);

    // Initialize with debouncing
    function init() {
        console.log('[NUT] Nitan User Tags initialized');
        processAllUsers();

        // Debounced MutationObserver to prevent performance issues
        const observer = new MutationObserver(() => {
            clearTimeout(window.nutProcessTimeout);
            window.nutProcessTimeout = setTimeout(() => {
                processAllUsers();
            }, CONFIG.DEBOUNCE_MS);
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    // Wait for page to load
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();