// ==UserScript==
// @name 에펨코리아 메모
// @name:ko 에펨코리아 메모
// @namespace https://fmkorea.com
// @author 에펨코리아 메모
// @version 250617
// @description FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램
// @description:ko FM코리아 게시글 작성자에 대해 메모를 달 수 있는 확장 프로그램
// @match https://www.fmkorea.com/*
// @icon https://www.fmkorea.com/favicon.ico?2
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @run-at document-body
// @license MIT
// ==/UserScript==
(async () => {
'use strict';
const SCRIPT_NAME = 'FMK-MEMO-TM';
const DEBUG_MODE = false;
const log = (...args) => DEBUG_MODE && console.log(`[${SCRIPT_NAME}]`, ...args);
const error = (...args) => console.error(`[${SCRIPT_NAME}]`, ...args);
if (window.__fmkMemoAlreadyLoaded) {
log('스크립트가 이미 로드되어 있어 중복 실행을 방지합니다.');
return;
}
window.__fmkMemoAlreadyLoaded = true;
log('스크립트 실행 시작');
const CONSTANTS = {
SORT_BY_DATE: 'date',
SORT_BY_NAME: 'name',
MEMBER_CLASS_PREFIX: 'member_',
PROCESSED_ATTR: 'data-fmk-processed',
INLINE_MEMO_CLASS: 'fmk-inline-memo'
};
GM_addStyle(`
body:not(.night_mode) {
--fmk-bg-primary: #ffffff;
--fmk-bg-secondary: #ffffff;
--fmk-bg-tertiary: #f0f2f5;
--fmk-text-primary: #1c1e21;
--fmk-text-secondary: #65676b;
--fmk-border-primary: #dce0e4;
--fmk-border-secondary: #bec3c9;
--fmk-shadow-color: rgba(0, 0, 0, 0.15);
--fmk-button-bg: #f5f6f7;
--fmk-button-hover-bg: #e9eaec;
--fmk-button-action-bg: #007bff;
--fmk-button-action-text: #ffffff;
}
body.night_mode {
--fmk-bg-primary: #2a2a2a;
--fmk-bg-secondary: #3a3a3a;
--fmk-bg-tertiary: #4a4a4a;
--fmk-text-primary: #e4e6eb;
--fmk-text-secondary: #b0b3b8;
--fmk-border-primary: #555555;
--fmk-border-secondary: #666666;
--fmk-shadow-color: rgba(0, 0, 0, 0.6);
--fmk-button-bg: #444444;
--fmk-button-hover-bg: #555555;
--fmk-button-action-bg: #4b87ff;
--fmk-button-action-text: #ffffff;
}
#fmk-memo-manager-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 999998; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(2px);
}
#fmk-memo-manager-container {
font-family: 'Segoe UI', Arial, sans-serif;
background-color: var(--fmk-bg-primary);
color: var(--fmk-text-primary);
width: 350px; max-height: 90vh; border-radius: 8px;
box-shadow: 0 5px 25px var(--fmk-shadow-color);
display: flex; flex-direction: column;
animation: fmk-manager-fadein 0.2s ease-out;
border: 1px solid var(--fmk-border-primary);
}
@keyframes fmk-manager-fadein { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
#fmk-memo-manager-container .manager-header { padding: 16px; border-bottom: 1px solid var(--fmk-border-primary); }
#fmk-memo-manager-container h1 { font-size: 18px; margin: 0; font-weight: 600; color: var(--fmk-text-primary); }
#fmk-memo-manager-container .manager-content { padding: 16px; overflow-y: auto; flex-grow: 1; }
#searchMemo {
width: 100%; box-sizing: border-box; margin-bottom: 12px; padding: 8px 12px;
background-color: var(--fmk-bg-secondary);
color: var(--fmk-text-primary);
border: 1px solid var(--fmk-border-primary);
border-radius: 6px; font-size: 14px; transition: all 0.2s;
}
#searchMemo::placeholder { color: var(--fmk-text-secondary); }
#searchMemo:focus { outline: none; border-color: var(--fmk-border-secondary); }
.items-container {
max-height: 280px; overflow-y: auto; margin-bottom: 16px;
border: 1px solid var(--fmk-border-primary);
border-radius: 8px; padding: 10px;
background-color: var(--fmk-bg-secondary);
}
.item {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 2px; padding: 4px; border-radius: 6px;
transition: background-color 0.2s; cursor: pointer;
}
.item:hover { background-color: var(--fmk-bg-tertiary); }
.item span { flex: 1; word-break: break-word; color: var(--fmk-text-primary); }
.import-export-area { display: flex; gap: 8px; }
.import-export-area button, #fmk-memo-manager-container .manager-footer button {
flex: 1; background-color: var(--fmk-button-bg); color: var(--fmk-text-primary);
border: 1px solid var(--fmk-border-primary); border-radius: 6px;
padding: 6px 12px; cursor: pointer; transition: all 0.2s; font-size: 12px;
}
.import-export-area button:hover, #fmk-memo-manager-container .manager-footer button:hover {
background-color: var(--fmk-button-hover-bg); border-color: var(--fmk-border-secondary);
}
#fmk-memo-manager-container .manager-footer { padding: 12px 16px; border-top: 1px solid var(--fmk-border-primary); text-align: right; }
.fmk-context-popup {
position: absolute; z-index: 999999;
background: var(--fmk-bg-primary);
color: var(--fmk-text-primary);
border: 1px solid var(--fmk-border-primary);
border-radius: 8px; padding: 12px; width: 260px; font-size: 13px;
box-shadow: 0 4px 12px var(--fmk-shadow-color);
animation: fmk-popup-fadein 0.15s ease-out;
display: flex; flex-direction: column; gap: 8px;
}
.fmk-popup-title {
font-weight:700; margin-bottom:4px;
border-bottom:1px solid var(--fmk-border-primary); padding-bottom:8px;
}
.fmk-context-popup textarea {
width:100%; box-sizing:border-box; background:var(--fmk-bg-secondary);
color:var(--fmk-text-primary); border:1px solid var(--fmk-border-primary);
border-radius:6px; padding:6px; resize:vertical;
}
.fmk-context-popup textarea::placeholder { color: var(--fmk-text-secondary); }
.fmk-history-container {
margin-top: 4px; border-top: 1px solid var(--fmk-border-primary); padding-top: 8px;
}
.fmk-history-title {
font-weight: bold; font-size: 12px; margin-bottom: 5px; color: var(--fmk-text-secondary);
}
.fmk-history-container ul {
list-style: none; padding: 0; margin: 0; max-height: 80px; overflow-y: auto;
font-size: 12px; color: var(--fmk-text-primary);
}
.fmk-history-container ul li { margin-bottom: 3px; }
.fmk-color-wrap { margin-top: 4px; font-size: 12px; }
.fmk-popup-buttons { display:flex; gap:8px; margin-top: 4px; }
.fmk-popup-buttons button {
flex: 1; padding: 8px 0; border-radius: 6px; font-weight: 600;
font-size: 13px; cursor: pointer;
background: var(--fmk-button-bg);
border: 1px solid var(--fmk-border-primary);
color: var(--fmk-text-primary);
transition: all .15s;
}
.fmk-popup-buttons button:hover {
background: var(--fmk-button-hover-bg);
border-color: var(--fmk-border-secondary);
}
.fmk-popup-buttons button:first-child:hover {
background: var(--fmk-button-action-bg);
border-color: var(--fmk-button-action-bg);
color: var(--fmk-button-action-text);
}
.fmk-info-panel {
background: var(--fmk-bg-secondary);
color: var(--fmk-text-primary);
border: 1px solid var(--fmk-border-primary);
margin: 32px 0; padding: 20px; border-radius: 8px;
display: flex; gap: 24px; flex-wrap: nowrap;
max-height: 250px; overflow: hidden;
}
.fmk-info-panel-box {
flex: 1 1 auto;
border: 1px solid var(--fmk-border-primary);
background: var(--fmk-bg-primary);
padding: 14px; min-width: 300px; overflow-y: auto;
display: flex; flex-direction: column; border-radius: 6px;
}
.fmk-info-panel-left { flex-grow: 0; flex-shrink: 0; flex-basis: 320px; }
.fmk-info-panel-right { overflow: hidden; }
.fmk-info-panel-title { font-weight: 700; margin: 0 0 12px; color: var(--fmk-text-primary); flex-shrink: 0; }
.fmk-info-panel-content { list-style: disc; padding-left: 18px; margin: 0; flex-grow: 1; overflow-y: auto; }
.fmk-info-panel-content a { color: #4ea6ff; text-decoration: none; }
.fmk-info-panel-content a:hover { text-decoration: underline; }
.fmk-info-panel-content .fmk-post-meta { color: var(--fmk-text-secondary); font-size: 12px; }
.fmk-info-panel-box table th,
.fmk-info-panel-box table td {
background-color: transparent !important;
color: var(--fmk-text-primary) !important;
}
body.night_mode .fmk-info-panel {
background: #1e1e1e;
border: 1px solid #444;
}
body.night_mode .fmk-info-panel-box {
background: #1e1e1e;
border-color: #555;
}
@keyframes fmk-popup-fadein { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
.fmk-inline-memo { display: inline; font-weight: bold; padding: 1px 2px; border-radius: 4px; }
.author > span { display: inline !important; }
li a .fmk-inline-memo { margin-left: 0px !important; }
li a .fmk-inline-memo:before, .fmk-inline-memo:before { content: "["; margin-right: 1px; }
li a .fmk-inline-memo:after, .fmk-inline-memo:after { content: "]"; margin-left: 1px; }
@keyframes fmk-nick-blink {
0%,100% { outline: 2px solid #ffb84d; outline-offset:2px; }
50% { outline-color: transparent; }
}
.fmk-nick-blink { animation: fmk-nick-blink 1.5s ease-in-out 1; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--fmk-bg-secondary); border-radius: 4px; }
::-webkit-scrollbar-thumb { background: var(--fmk-bg-tertiary); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--fmk-border-secondary); }
input[type="color"] {
vertical-align: middle; border-radius: 4px; border: 1px solid var(--fmk-border-primary);
width: 35px; height: 20px; cursor: pointer; background-color: var(--fmk-bg-secondary); padding: 0;
}
.fmk-sort-controls {
margin-bottom: 12px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
color: var(--fmk-text-secondary);
}
.fmk-sort-controls button {
background: none;
border: 1px solid var(--fmk-border-primary);
color: var(--fmk-text-secondary);
padding: 3px 8px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.fmk-sort-controls button:hover {
border-color: var(--fmk-border-secondary);
color: var(--fmk-text-primary);
}
.fmk-sort-controls button.active {
border-color: var(--fmk-button-action-bg);
background-color: var(--fmk-button-action-bg);
color: var(--fmk-button-action-text);
}
.item-wrapper {
display: flex;
flex-direction: column;
border-radius: 6px;
transition: background-color 0.2s;
}
.item-wrapper:hover {
background-color: var(--fmk-bg-tertiary);
}
.item { cursor: pointer; }
.history-toggle-btn {
margin-left: 6px; background-color: transparent; color: var(--fmk-text-secondary);
border: 1px solid var(--fmk-border-secondary); border-radius: 6px; padding: 2px 6px;
cursor: pointer; transition: all 0.2s; font-size: 10px; flex-shrink: 0;
}
.history-toggle-btn:hover { background-color: var(--fmk-button-hover-bg); }
.item-history-container {
padding: 8px 10px 4px 10px;
margin: 0 4px 4px 4px;
border-top: 1px solid var(--fmk-border-primary);
animation: fmk-history-fadein 0.3s ease;
}
@keyframes fmk-history-fadein { from { opacity: 0; } to { opacity: 1; } }
.item-history-title {
font-weight: bold; font-size: 11px; margin-bottom: 5px; color: var(--fmk-text-secondary);
}
.item-history-container ul { list-style: none; padding: 0; margin: 0; font-size: 11px; color: var(--fmk-text-secondary); }
.item-history-container ul li { margin-bottom: 3px; }
#fmk-memo-import-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 999999;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
#fmk-memo-import-popup {
background-color: var(--fmk-bg-primary);
color: var(--fmk-text-primary);
width: 320px;
border-radius: 8px;
box-shadow: 0 5px 25px var(--fmk-shadow-color);
display: flex;
flex-direction: column;
animation: fmk-manager-fadein 0.2s ease-out;
border: 1px solid var(--fmk-border-primary);
}
#fmk-memo-import-popup .popup-header {
padding: 14px;
border-bottom: 1px solid var(--fmk-border-primary);
font-size: 16px;
font-weight: 600;
}
#fmk-memo-import-popup .popup-content {
padding: 16px;
}
#fmk-memo-import-popup #importPopupText {
width: 100%;
height: 120px;
box-sizing: border-box;
background: var(--fmk-bg-secondary);
color: var(--fmk-text-primary);
border: 1px solid var(--fmk-border-primary);
border-radius: 6px;
padding: 8px;
resize: vertical;
font-size: 13px;
}
#fmk-memo-import-popup .popup-footer {
padding: 12px 16px;
border-top: 1px solid var(--fmk-border-primary);
display: flex;
justify-content: flex-end;
gap: 8px;
}
#fmk-memo-import-popup .popup-footer button {
background-color: var(--fmk-button-bg);
color: var(--fmk-text-primary);
border: 1px solid var(--fmk-border-primary);
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
}
#fmk-memo-import-popup .popup-footer button#doImportBtn {
background-color: var(--fmk-button-action-bg);
border-color: var(--fmk-button-action-bg);
color: var(--fmk-button-action-text);
}
`);
log('CSS 스타일 주입 완료');
const cached = {};
let panelInserted = false;
let currentContextPopup = null;
let lastRun = 0;
const today = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const getThemeAwareRandomColor = () => {
const isDarkMode = document.body.classList.contains('night_mode');
let r, g, b;
if (isDarkMode) {
r = Math.floor(128 + Math.random() * 128);
g = Math.floor(128 + Math.random() * 128);
b = Math.floor(128 + Math.random() * 128);
} else {
r = Math.floor(Math.random() * 128);
g = Math.floor(Math.random() * 128);
b = Math.floor(Math.random() * 128);
}
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).padStart(6, '0')}`;
};
const getNick = el => el.innerText.trim().replace(/^\/\s*/, '') || 'Unknown';
const clampPopup = p => {
const r = p.getBoundingClientRect(), pad = 8;
let left = parseInt(p.style.left, 10);
let top = parseInt(p.style.top, 10);
if (r.right > window.innerWidth - pad) left = window.innerWidth - pad - r.width;
if (r.bottom > window.innerHeight - pad) top = window.innerHeight - pad - r.height;
if (left < pad) left = pad;
if (top < pad) top = pad;
p.style.left = left + 'px';
p.style.top = top + 'px';
};
function updNick(u, newNick) {
u.nickHistory ??= [];
const last = u.nickHistory.at(-1);
const currentDate = today();
if (!last || last.nick !== newNick) {
const todayLogIndex = u.nickHistory.findIndex(entry => entry.date === currentDate);
if (todayLogIndex > -1) {
return false;
}
log(`닉네임 변경 감지: '${last?.nick || '(없음)'}' -> '${newNick}'`);
u.nickHistory.push({ date: currentDate, nick: newNick });
if (u.nickHistory.length > 30) u.nickHistory.splice(0, u.nickHistory.length - 30);
u.nickname = newNick;
return true;
}
return false;
}
function normalizeMemo(m) {
if (!m) return null;
if (!Array.isArray(m.history)) m.history = m.text ? [{ date: m.lastUpdate || today(), text: m.text }] : [];
if (!Array.isArray(m.nickHistory)) {
const seeds = Array.isArray(m.nicknames) ? m.nicknames : m.nickname ? [m.nickname] : [];
m.nickHistory = seeds.map(n => ({ date: m.lastUpdate || today(), nick: n }));
}
if (!m.nickname && m.nickHistory.length) m.nickname = m.nickHistory.at(-1).nick;
return m;
}
const setMemo = (el, t, c) => {
let sp = el.querySelector(`.${CONSTANTS.INLINE_MEMO_CLASS}`);
if (!sp) {
sp = document.createElement('span');
sp.className = CONSTANTS.INLINE_MEMO_CLASS;
el.appendChild(sp);
}
sp.textContent = t;
Object.assign(sp.style, { color: c || '#ff7676', marginLeft: '2px', display: 'inline' });
};
const delMemo = el => el.querySelector(`.${CONSTANTS.INLINE_MEMO_CLASS}`)?.remove();
function closeContextPopup() {
if (currentContextPopup) {
document.body.removeChild(currentContextPopup);
currentContextPopup = null;
document.removeEventListener('mousedown', outsideContextPopup);
}
}
const outsideContextPopup = e => {
if (currentContextPopup && !currentContextPopup.contains(e.target)) {
closeContextPopup();
}
};
async function showMemoContextPopup(uid, u, x, y, cb) {
closeContextPopup();
log(`우클릭 메모 팝업 열기: 사용자 ID ${uid}`);
const p = document.createElement('div');
p.className = 'fmk-context-popup';
Object.assign(p.style, { left: x + 'px', top: y + 'px' });
p.innerHTML = `<div class="fmk-popup-title">${u.nickname || 'Unknown'}[${uid}]</div>`;
const main = document.createElement('textarea');
main.rows = 2;
main.maxLength = 20;
main.placeholder = '메모 (20자)';
main.value = u.text || '';
p.appendChild(main);
const detail = document.createElement('textarea');
detail.rows = 5;
detail.placeholder = '세부 메모';
detail.value = u.detail || '';
p.appendChild(detail);
const historyContainer = document.createElement('div');
historyContainer.className = 'fmk-history-container';
const historyTitle = document.createElement('div');
historyTitle.className = 'fmk-history-title';
historyTitle.textContent = '닉네임 변경 이력';
historyContainer.appendChild(historyTitle);
const historyList = document.createElement('ul');
if (u.nickHistory && u.nickHistory.length > 1) {
u.nickHistory.slice().reverse().forEach(entry => {
const li = document.createElement('li');
li.textContent = `• ${entry.date}: ${entry.nick}`;
historyList.appendChild(li);
});
} else {
const li = document.createElement('li');
li.textContent = '변경 이력이 없습니다.';
li.style.fontStyle = 'italic';
historyList.appendChild(li);
}
historyContainer.appendChild(historyList);
p.appendChild(historyContainer);
const colorWrap = document.createElement('div');
colorWrap.className = 'fmk-color-wrap';
colorWrap.innerHTML = '글자 색상: ';
const colorI = document.createElement('input');
colorI.type = 'color';
colorI.value = u.color || getThemeAwareRandomColor();
colorWrap.appendChild(colorI);
p.appendChild(colorWrap);
const btns = document.createElement('div');
btns.className = 'fmk-popup-buttons';
const save = document.createElement('button');
save.textContent = '저장';
const del = document.createElement('button');
del.textContent = '삭제';
const cancel = document.createElement('button');
cancel.textContent = '취소';
if (!u.text) del.hidden = true;
btns.append(save, del, cancel);
p.appendChild(btns);
document.body.appendChild(p);
currentContextPopup = p;
document.addEventListener('mousedown', outsideContextPopup);
clampPopup(p);
save.onclick = async () => {
const t = main.value.trim().slice(0, 20);
if (!t) {
del.onclick();
return;
}
if (u.text !== t) {
u.history ??= [];
u.history.push({ date: today(), text: t });
if (u.history.length > 50) u.history.splice(0, u.history.length - 50);
}
u.text = t;
u.detail = detail.value.trim();
u.color = colorI.value;
u.lastUpdate = today();
cached[uid] = u;
log(`메모 저장: ID ${uid}, 내용 '${t}'`);
await GM_setValue(uid, u);
closeContextPopup();
cb?.({ text: t, color: u.color }, false);
};
del.onclick = async () => {
log(`메모 삭제: ID ${uid}`);
await GM_deleteValue(uid);
delete cached[uid];
closeContextPopup();
cb?.(null, true);
};
cancel.onclick = closeContextPopup;
}
function showImportPopup(onSuccess) {
if (document.getElementById('fmk-memo-import-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'fmk-memo-import-overlay';
const popup = document.createElement('div');
popup.id = 'fmk-memo-import-popup';
popup.innerHTML = `
<div class="popup-header">메모 가져오기</div>
<div class="popup-content">
<textarea id="importPopupText" placeholder="백업된 JSON 데이터를 여기에 붙여넣으세요..."></textarea>
</div>
<div class="popup-footer">
<button id="cancelImportBtn">취소</button>
<button id="doImportBtn">가져오기</button>
</div>
`;
overlay.appendChild(popup);
document.body.appendChild(overlay);
const closePopup = () => document.body.removeChild(overlay);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closePopup();
}
});
popup.querySelector('#cancelImportBtn').addEventListener('click', closePopup);
popup.querySelector('#doImportBtn').addEventListener('click', async () => {
const importText = popup.querySelector('#importPopupText');
const jsonStr = importText.value.trim();
if (!jsonStr) {
alert('가져올 데이터가 없습니다. 텍스트 영역에 JSON 데이터를 붙여넣어 주세요.');
return;
}
try {
const newData = JSON.parse(jsonStr);
if (typeof newData !== 'object' || newData === null) {
throw new Error('올바른 JSON 객체 형식이 아닙니다.');
}
const keys = Object.keys(newData);
if (keys.length === 0) {
alert('가져올 데이터가 비어있습니다.');
return;
}
const merge = confirm('기존 메모에 가져온 데이터를 병합(추가/덮어쓰기)하시겠습니까?\n[취소]를 누르면 모든 기존 메모를 삭제하고 새로 가져옵니다.');
if (!merge) {
log('기존 데이터 삭제 후 가져오기 시작');
const allKeys = await GM_listValues();
for (const key of allKeys) {
await GM_deleteValue(key);
}
Object.keys(cached).forEach(key => delete cached[key]);
}
log(`메모 가져오기: ${keys.length}개 항목 처리 시작`);
let importedCount = 0;
for (const key of keys) {
if (isNaN(key)) continue;
const normalized = normalizeMemo(newData[key]);
if (normalized) {
cached[key] = normalized;
await GM_setValue(key, normalized);
importedCount++;
}
}
alert(`성공적으로 ${importedCount}개의 메모를 가져왔습니다.`);
log('메모 가져오기 완료.');
onSuccess?.(newData);
closePopup();
} catch (err) {
error('데이터 가져오기 실패:', err);
alert(`데이터를 가져오는 데 실패했습니다. JSON 형식이 올바른지 확인해주세요.\n\n오류: ${err.message}`);
}
});
}
async function showManagementPanel() {
log('메모 관리자 패널 열기');
if (document.getElementById('fmk-memo-manager-overlay')) return;
let currentSortBy = CONSTANTS.SORT_BY_DATE;
const overlay = document.createElement('div');
overlay.id = 'fmk-memo-manager-overlay';
const container = document.createElement('div');
container.id = 'fmk-memo-manager-container';
container.innerHTML = `
<div class="manager-header">
<h1>메모 관리</h1>
</div>
<div class="manager-content">
<input type="text" id="searchMemo" placeholder="메모 검색..." />
<div class="fmk-sort-controls">
<span>정렬:</span>
<button data-sort="${CONSTANTS.SORT_BY_DATE}" class="active">최신순</button>
<button data-sort="${CONSTANTS.SORT_BY_NAME}">이름순</button>
</div>
<div class="items-container" id="memoList"></div>
<div class="import-export-area">
<button id="exportBtn">클립보드로 복사</button>
<button id="showImportPopupBtn">붙여넣기로 가져오기</button>
</div>
</div>
<div class="manager-footer">
<button id="closeManagerBtn">닫기</button>
</div>
`;
overlay.appendChild(container);
document.body.appendChild(overlay);
const searchInput = container.querySelector('#searchMemo');
const sortButtons = container.querySelectorAll('.fmk-sort-controls button');
const exportBtn = container.querySelector("#exportBtn");
const closeManagerBtn = container.querySelector("#closeManagerBtn");
const showImportPopupBtn = container.querySelector("#showImportPopupBtn");
const closePanel = () => {
log('메모 관리자 패널 닫기');
document.body.removeChild(overlay);
};
closeManagerBtn.addEventListener('click', closePanel);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closePanel(); });
showImportPopupBtn.addEventListener("click", () => {
showImportPopup(() => {
renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy);
run();
});
});
exportBtn.addEventListener("click", async () => {
const dataToExport = {};
Object.keys(cached).forEach(key => { if (!isNaN(key)) { dataToExport[key] = cached[key]; } });
const jsonStr = JSON.stringify(dataToExport, null, 2);
try {
await navigator.clipboard.writeText(jsonStr);
log('메모 데이터가 클립보드에 복사되었습니다.');
const originalText = exportBtn.textContent;
exportBtn.textContent = '복사 완료!';
exportBtn.disabled = true;
setTimeout(() => {
exportBtn.textContent = originalText;
exportBtn.disabled = false;
}, 2000);
} catch (err) {
error('클립보드 복사 실패:', err);
alert('클립보드 복사에 실패했습니다. 브라우저 콘솔을 확인해주세요.');
}
});
searchInput.addEventListener("input", () => {
renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy);
});
sortButtons.forEach(button => {
button.addEventListener('click', () => {
currentSortBy = button.dataset.sort;
log(`정렬 순서 변경: ${currentSortBy}`);
sortButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy);
});
});
renderMemoList(cached, '', currentSortBy);
}
function renderMemoList(data, keyword = "", sortBy = CONSTANTS.SORT_BY_DATE) {
const memoListEl = document.getElementById("memoList");
if (!memoListEl) return;
memoListEl.innerHTML = "";
const searchInput = document.getElementById('searchMemo');
let filteredData = Object.entries(data).filter(([key, val]) => {
if (isNaN(key)) return false;
const text = val?.text ?? "";
const detail = val?.detail ?? "";
const nickname = val?.nickname ?? "";
const combinedText = `${key} ${nickname} ${text} ${detail}`.toLowerCase();
return !keyword || combinedText.includes(keyword);
});
if (sortBy === CONSTANTS.SORT_BY_NAME) {
filteredData.sort(([, a], [, b]) => (a.nickname || '').localeCompare(b.nickname || ''));
} else {
filteredData.sort(([, a], [, b]) => new Date(b.lastUpdate || 0) - new Date(a.lastUpdate || 0));
}
if (filteredData.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.textContent = keyword ? "일치하는 메모가 없습니다." : "저장된 메모가 없습니다.";
emptyMsg.style.padding = "10px";
emptyMsg.style.textAlign = "center";
emptyMsg.style.color = "var(--fmk-text-secondary)";
memoListEl.appendChild(emptyMsg);
return;
}
for (const [key, val] of filteredData) {
const itemEl = document.createElement('div');
itemEl.className = 'item-wrapper';
let historyHtml = '';
if (val.history && val.history.length > 1) {
const historyItems = val.history.slice().reverse().map(h => `<li>• ${h.date}: ${h.text}</li>`).join('');
historyHtml = `
<div class="item-history-container">
<div class="item-history-title">메모 변경 이력</div>
<ul>${historyItems}</ul>
</div>`;
}
itemEl.innerHTML = `
<div class="item">
<span></span>
<button class="history-toggle-btn" ${!historyHtml ? 'style="display:none;"' : ''}>이력</button>
</div>
${historyHtml}
`;
const nickSpan = itemEl.querySelector('.item span');
const displayName = `${val.nickname || 'Unknown'}[${key}]`;
nickSpan.textContent = `${displayName} : ${val.text}`;
if (val.color) {
nickSpan.style.borderLeft = `3px solid ${val.color}`;
nickSpan.style.paddingLeft = '6px';
}
itemEl.querySelector('.item').addEventListener('click', (e) => {
e.stopPropagation();
showMemoContextPopup(key, val, e.pageX, e.pageY, () => {
log(`메모가 관리자 패널에서 수정되어 목록과 페이지를 새로고침합니다.`);
const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE;
renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy);
run();
});
});
const historyToggleBtn = itemEl.querySelector('.history-toggle-btn');
const historyContainer = itemEl.querySelector('.item-history-container');
if (historyToggleBtn && historyContainer) {
historyContainer.style.display = 'none';
historyToggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = historyContainer.style.display !== 'none';
historyContainer.style.display = isVisible ? 'none' : 'block';
});
}
memoListEl.appendChild(itemEl);
}
}
GM_registerMenuCommand('메모 관리자 열기', showManagementPanel);
log('Tampermonkey 메뉴에 "메모 관리자 열기" 등록 완료');
function bindPlate(a) {
if (a.getAttribute(CONSTANTS.PROCESSED_ATTR)) return;
a.setAttribute(CONSTANTS.PROCESSED_ATTR, '1');
const m = a.className.match(/member_(\d+)/);
if (!m) return;
const uid = m[1];
const nick = getNick(a);
let u = cached[uid];
if (u) {
if (updNick(u, nick)) {
GM_setValue(uid, u);
a.classList.add('fmk-nick-blink');
setTimeout(() => a.classList.remove('fmk-nick-blink'), 1500);
}
if (u.lastUpdate !== today()) {
u.lastUpdate = today();
GM_setValue(uid, u);
}
u.text && setMemo(a, u.text, u.color);
}
a.addEventListener('contextmenu', e => {
e.preventDefault();
u = cached[uid] ||= { color: getThemeAwareRandomColor(), nickname: nick, nickHistory: [{ date: today(), nick }], history: [], lastUpdate: today() };
showMemoContextPopup(uid, u, e.pageX, e.pageY, (up, rm) => {
document.querySelectorAll(`.${CONSTANTS.MEMBER_CLASS_PREFIX}${uid}`).forEach(el => {
if (rm) {
delMemo(el);
} else if (up?.text) {
setMemo(el, up.text, up.color);
}
});
});
});
}
const bindAuthor = el => {
if (el.getAttribute(CONSTANTS.PROCESSED_ATTR)) return;
el.setAttribute(CONSTANTS.PROCESSED_ATTR, '1');
const n = getNick(el);
for (const [uid, u] of Object.entries(cached)) {
if (u.nickname === n && u.text) {
setMemo(el, u.text, u.color);
break;
}
}
};
const detectMid = () => {
const IGNORE_MIDS = ['best', 'best2', 'best_day'];
let mid = new URL(location.href).searchParams.get('mid');
if (mid && !IGNORE_MIDS.includes(mid)) {
return mid;
}
if (mid && IGNORE_MIDS.includes(mid)) {
log(`포텐 관련 mid ('${mid}')를 감지하여 무시하고 다른 방법으로 재탐색합니다.`);
}
if (window.document_mid && !IGNORE_MIDS.includes(window.document_mid)) {
return window.document_mid;
}
const docLink = document.querySelector('.bd_tl h1 a[href^="/"]:not([href="/"])');
if (docLink) {
const m = docLink.getAttribute('href').match(/^\/([^/?#]+)/);
if (m && !IGNORE_MIDS.includes(m[1])) return m[1];
}
const p = location.pathname.split('/').filter(Boolean);
if (p.length > 0 && isNaN(p[0]) && !IGNORE_MIDS.includes(p[0])) return p[0];
if (p.length > 1 && isNaN(p[1]) && !IGNORE_MIDS.includes(p[1])) return p[1];
if (window.__fm_best_config?.target_mid) return window.__fm_best_config.target_mid;
const og = document.querySelector('meta[property="og:url"]')?.content;
const mOg = og?.match(/^https?:\/\/[^/]+\/([^/?#]+)/);
if (mOg && !IGNORE_MIDS.includes(mOg[1])) return mOg[1];
return 'stock';
};
const authorAnchor = () => document.querySelector('.rd_hd .btm_area a.member_plate[class*="member_"]') || document.querySelector('.author a[class*="member_"]');
async function injectPanel() {
if (panelInserted) return;
const body = document.querySelector('.rd_body');
if (!body) return;
const anc = authorAnchor();
const m = anc?.className.match(/member_(\d+)/);
if (!m) return;
panelInserted = true;
const memberId = m[1];
const searchMid = detectMid();
const pageContextMid = new URL(location.href).searchParams.get('mid') || searchMid;
log(`패널 데이터 요청 시작: 회원정보(mid=${pageContextMid}), 최근글(mid=${searchMid}), 회원번호(${memberId})`);
const wrap = document.createElement('div');
wrap.className = 'fmk-info-panel';
const leftBox = document.createElement('div');
leftBox.className = 'fmk-info-panel-box fmk-info-panel-left';
leftBox.innerHTML = `<p class="fmk-info-panel-title">🛈 회원 정보</p>`;
const rightBox = document.createElement('div');
rightBox.className = 'fmk-info-panel-box fmk-info-panel-right';
rightBox.innerHTML = `<p class="fmk-info-panel-title">📝 최근 작성글</p>`;
wrap.append(leftBox, rightBox);
body.parentNode.insertBefore(wrap, body.nextSibling);
try {
const [infoHtml, postHtmlRaw] = await Promise.all([
fetch(`https://www.fmkorea.com/index.php?mid=${pageContextMid}&act=dispMemberInfo&member_srl=${memberId}`, { credentials: 'include' }).then(r => r.text()),
fetch(`https://www.fmkorea.com/search.php?mid=${searchMid}&search_target=member_srl&search_keyword=${memberId}`, { credentials: 'include' }).then(r => r.text())
]);
const infoDoc = new DOMParser().parseFromString(infoHtml, 'text/html');
const infoTbl = infoDoc.querySelector('table.table.row');
if (infoTbl) {
log('회원 정보 테이블 파싱 성공');
const nickElement = infoTbl.querySelector('a.member_plate > b, a.member_plate');
if (nickElement) {
const latestNick = nickElement.innerText.trim();
const u = cached[memberId];
if (u && u.nickname !== latestNick) {
log(`[교정] 닉네임 강제 교정: '${u.nickname}' -> '${latestNick}'`);
u.nickname = latestNick;
const currentDate = today();
const todayLogIndex = u.nickHistory.findIndex(entry => entry.date === currentDate);
if (todayLogIndex > -1) {
u.nickHistory[todayLogIndex].nick = latestNick;
} else {
u.nickHistory.push({ date: currentDate, nick: latestNick });
}
await GM_setValue(memberId, u);
}
}
infoTbl.querySelectorAll('th').forEach(th => { if (th.textContent.trim().startsWith('블라인드 유저')) th.textContent = '블라인드 유저'; });
infoTbl.style.tableLayout = 'fixed';
infoTbl.querySelectorAll('th').forEach(th => (th.style.width = '90px'));
infoTbl.querySelector('td.profile_image')?.parentElement?.remove();
[...infoTbl.rows].find(tr => tr.querySelector('th[colspan]')?.innerText.trim() === '기본 정보')?.remove();
infoTbl.querySelectorAll('th,td').forEach(c => (c.style.padding = '4px 10px'));
leftBox.appendChild(infoTbl);
} else {
log('회원 정보 테이블 파싱 실패');
leftBox.appendChild(document.createTextNode('정보를 불러올 수 없습니다.'));
}
let postHtml = postHtmlRaw;
if (!/list_?tbody|tbody/i.test(postHtmlRaw)) {
log('최근 글 목록이 없어 전역 재검색 시도');
postHtml = await fetch(`https://www.fmkorea.com/search.php?search_target=member_srl&search_keyword=${memberId}`, { credentials: 'include' }).then(r => r.text());
}
const postDoc = new DOMParser().parseFromString(postHtml, 'text/html');
const rows = [...postDoc.querySelectorAll('tbody tr, .list_tbody .list_row, .fm_best_widget li.li')].filter(el => el.querySelector('.title a, h3.title a, td.title a'));
log(`최근 글 파싱 완료. ${rows.length}개 항목 발견.`);
const ul = document.createElement('ul');
ul.className = 'fmk-info-panel-content';
if (rows.length > 0) {
rows.forEach(el => {
const a = el.querySelector('.title a, h3.title a, td.title a');
if (!a) return;
const id = (a.href.match(/document_srl=(\d+)/) || [])[1];
const href = id ? `/${id}` : a.href;
const date = (el.querySelector('.time, td.time, .regdate')?.textContent || '').trim();
const views = (el.querySelector('.m_no, td.m_no')?.textContent || '').trim();
const votes = (el.querySelector('.m_no_voted, td.m_no_voted, .pc_voted_count .count')?.textContent || '').trim();
const meta = [date, views && `조회 ${views}`, votes && `추천 ${votes}`].filter(Boolean).join(' · ');
const li = document.createElement('li');
li.innerHTML = `<a href="${href}" target="_blank">${a.textContent.trim()}</a>${meta ? ` <span class="fmk-post-meta">· ${meta}</span>` : ''}`;
ul.appendChild(li);
});
} else {
ul.textContent = '최근 작성한 글이 없습니다.';
ul.style.listStyle = 'none';
ul.style.paddingLeft = '0';
}
rightBox.appendChild(ul);
} catch (e) {
error('작성자 정보 패널 데이터 로딩 중 오류 발생:', e);
leftBox.appendChild(document.createTextNode('정보 로딩 중 오류가 발생했습니다.'));
rightBox.appendChild(document.createTextNode('정보 로딩 중 오류가 발생했습니다.'));
}
}
function run(container = document.body) {
container.querySelectorAll('a[data-class]:not([class*="member_"])').forEach(a => {
const id = a.getAttribute('data-class');
if (id) {
a.classList.add(`${CONSTANTS.MEMBER_CLASS_PREFIX}${id}`);
}
});
container.querySelectorAll(`[class*="${CONSTANTS.MEMBER_CLASS_PREFIX}"]`).forEach(bindPlate);
container.querySelectorAll('.author:not(:has(.member_plate))').forEach(bindAuthor);
if (!panelInserted) {
injectPanel();
}
}
async function initialize() {
log('데이터 초기화 시작...');
const keys = await GM_listValues();
log(`저장된 키 ${keys.length}개 발견`);
for (const key of keys) {
if (!isNaN(key)) {
const memo = await GM_getValue(key);
cached[key] = normalizeMemo(memo);
}
}
log(`데이터 캐싱 완료. ${Object.keys(cached).length}개의 메모 로드.`);
run();
const observer = new MutationObserver(mutations => {
let processed = false;
const runOnce = () => {
if (processed) return;
run(document.body);
processed = true;
setTimeout(() => { processed = false; }, 0);
};
for (const mutation of mutations) {
if (mutation.type === 'childList') {
runOnce();
break;
}
if (mutation.type === 'attributes') {
if (mutation.attributeName === 'style' && mutation.target.style.display !== 'none') {
runOnce();
break;
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
log('최적화된 MutationObserver 시작. 페이지 변경 및 속성 변경을 감시합니다.');
}
initialize();
})();