// ==UserScript==
// @name 에펨코리아 메모
// @name:ko 에펨코리아 메모
// @namespace https://fmkorea.com
// @author 에펨코리아 메모
// @version 2506202
// @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 MEMBER_CLASS_PREFIX = 'member_';
const CONSTANTS = {
SORT_BY_DATE: 'date',
SORT_BY_NAME: 'name',
MEMBER_CLASS_PREFIX: MEMBER_CLASS_PREFIX,
PROCESSED_ATTR: 'data-fmk-processed',
INLINE_MEMO_CLASS: 'fmk-inline-memo',
SELECTORS: {
managerOverlay: '#fmk-memo-manager-overlay',
importOverlay: '#fmk-memo-import-overlay',
memoList: '#memoList',
unprocessedPlate: `a[data-class]:not([class*="${MEMBER_CLASS_PREFIX}"])`,
processedPlate: (uid) => `.${MEMBER_CLASS_PREFIX}${uid}`,
allPlates: `[class*="${MEMBER_CLASS_PREFIX}"]`,
unprocessedAuthor: '.author:not(:has(.member_plate))',
postBody: '.rd_body',
authorAnchor: `.rd_hd .btm_area a.member_plate[class*="${MEMBER_CLASS_PREFIX}"], .author a[class*="${MEMBER_CLASS_PREFIX}"]`,
docTitleLink: '.bd_tl h1 a[href^="/"]:not([href="/"])',
ogUrlMeta: 'meta[property="og:url"]',
memberInfoTable: 'table.table.row',
loginFormPasswordInput: 'input[name="password"]',
profileImageCell: 'td.profile_image',
baseInfoRow: 'th[colspan]',
postListRows: 'tbody tr, .list_tbody .list_row, .fm_best_widget li.li',
postTitleLink: '.title a, h3.title a, td.title a',
postCategoryLink: '.cate a',
postTime: '.time, td.time, .regdate',
postViews: '.m_no, td.m_no',
postVotes: '.m_no_voted, td.m_no_voted, .pc_voted_count .count',
inlineMemo: `.${'fmk-inline-memo'}`,
}
};
function createElement(tag, properties = {}, children = []) {
const el = document.createElement(tag);
for (const key in properties) {
if (Object.prototype.hasOwnProperty.call(properties, key)) {
if (key === 'style' && typeof properties.style === 'object') {
Object.assign(el.style, properties.style);
} else if (key === 'dataset' && typeof properties.dataset === 'object') {
Object.assign(el.dataset, properties.dataset);
} else if (key in el) {
try { el[key] = properties[key]; } catch (e) { el.setAttribute(key, properties[key]); }
} else {
el.setAttribute(key, properties[key]);
}
}
}
children.forEach(child => { if(child) el.append(child); });
return el;
}
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 {
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 {
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);
display: flex; justify-content: flex-end; gap: 8px;
}
#fmk-memo-manager-container .manager-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-manager-container .manager-footer button:hover {
background-color: var(--fmk-button-hover-bg); border-color: var(--fmk-border-secondary);
}
.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-area {
display: flex; justify-content: space-between; align-items: center;
padding-bottom: 8px; border-bottom: 1px solid var(--fmk-border-primary); margin-bottom: 4px;
}
.fmk-popup-title {
font-weight: 700; margin-right: 8px; flex-shrink: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.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.save-btn: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;
justify-content: space-between; gap: 8px; color: var(--fmk-text-secondary);
}
.fmk-sort-group { display: flex; align-items: center; gap: 8px; }
.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);
}
#clearAllBtn_inline {
background: none; border: 1px solid #d9534f; color: #d9534f;
padding: 3px 8px; border-radius: 12px; cursor: pointer; font-size: 12px; transition: all 0.2s;
}
#clearAllBtn_inline:hover { background-color: #d9534f; color: white; }
.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 { display: flex; justify-content: space-between; align-items: center; margin-bottom: 3px; }
.history-delete-btn {
background: none; border: none; color: var(--fmk-text-secondary); cursor: pointer;
font-size: 16px; font-weight: bold; padding: 0 5px; border-radius: 50%;
line-height: 1; width: 20px; height: 20px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; transition: all 0.2s;
}
.history-delete-btn:hover { background-color: var(--fmk-button-hover-bg); color: #f44336; }
#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);
}
.quick-delete-btn {
background: none; border: none; color: var(--fmk-text-secondary); cursor: pointer;
font-size: 16px; font-weight: bold; padding: 0 5px; border-radius: 50%;
line-height: 1; width: 20px; height: 20px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s; margin-left: 6px;
}
.quick-delete-btn:hover { background-color: var(--fmk-button-hover-bg); color: #d9534f; }
.fmk-activity-tracker {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.fmk-activity-tracker input[type="checkbox"] {
margin: 0;
width: 15px;
height: 15px;
vertical-align: middle;
cursor: pointer;
}
.fmk-activity-tracker-button {
font-size: 11px;
font-weight: normal;
padding: 3px 7px;
border-radius: 4px;
color: var(--fmk-text-secondary);
background-color: transparent;
border: 1px solid transparent;
transition: all 0.25s ease-in-out;
cursor: default;
}
.fmk-activity-tracker input[type="checkbox"]:checked + .fmk-activity-tracker-button {
font-weight: bold;
color: var(--fmk-button-action-text);
background-color: var(--fmk-button-action-bg);
border-color: var(--fmk-button-action-bg);
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.fmk-activity-tracker input[type="checkbox"]:checked + .fmk-activity-tracker-button:hover {
filter: brightness(1.1);
}
.fmk-simple-popup {
position: absolute;
z-index: 1000000;
background: var(--fmk-bg-primary);
color: var(--fmk-text-primary);
width: 500px; /* 너비를 320px에서 450px로 확장 */
max-height: 500px; /* 최대 높이도 약간 확장 */
border-radius: 8px;
box-shadow: 0 5px 25px var(--fmk-shadow-color);
display: flex;
flex-direction: column;
animation: fmk-popup-fadein .15s ease-out;
border: 1px solid var(--fmk-border-primary);
}
/* 팝업 헤더 */
.fmk-simple-popup-header {
padding: 12px 16px;
border-bottom: 1px solid var(--fmk-border-primary);
font-size: 14px;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.fmk-simple-popup-close-btn {
background: none;
border: none;
color: var(--fmk-text-secondary);
cursor: pointer;
font-size: 20px;
font-weight: bold;
padding: 0 8px;
border-radius: 50%;
line-height: 1;
transition: all 0.2s;
}
.fmk-simple-popup-close-btn:hover {
background-color: var(--fmk-button-hover-bg);
color: #f44336;
}
/* 팝업 본문 (스크롤 영역) */
.fmk-simple-popup-body {
padding: 0; /* 내부 리스트에서 패딩을 관리하므로 0으로 설정 */
overflow-y: auto;
flex-grow: 1;
}
.fmk-simple-popup-body ul {
list-style: none;
margin: 0;
padding: 0;
}
/* 목록의 각 항목(li) - Flexbox 컨테이너 */
.fmk-simple-popup-body li {
padding: 10px 16px; /* 좌우 패딩을 헤더와 맞춤 */
border-bottom: 1px solid var(--fmk-border-primary);
display: flex;
align-items: center; /* 세로 상단 정렬 */
gap: 12px; /* 왼쪽과 오른쪽 영역 사이의 간격 */
}
.fmk-simple-popup-body li:last-child {
border-bottom: none;
}
/* 게시판 이름 (왼쪽 영역) */
.activity-log-board {
font-size: 13px;
font-weight: bold;
color: var(--fmk-text-secondary);
flex-shrink: 0; /* 너비가 줄어들지 않도록 고정 */
max-width: 120px;
white-space: nowrap; /* 이름이 길어도 한 줄로 표시 */
overflow: hidden; /* 넘치는 부분은 숨김 */
text-overflow: ellipsis; /* 넘치는 부분은 ...으로 표시 */
}
/* 글 제목 + 메타 정보 (오른쪽 영역) */
.activity-log-main {
display: flex;
flex-direction: column; /* 제목과 메타정보를 세로로 정렬 */
gap: 4px; /* 제목과 메타정보 사이 간격 */
flex-grow: 1; /* 남은 공간을 모두 차지 */
min-width: 0; /* Flex 아이템이 넘칠 때 내부 요소가 올바르게 줄바꿈되도록 함 */
}
.activity-log-main a {
font-size: 14px;
color: #4ea6ff;
text-decoration: none;
word-break: break-all; /* 매우 긴 글 제목이 레이아웃을 깨뜨리는 것을 방지 */
}
.activity-log-main a:hover {
text-decoration: underline;
}
/* 메타 정보 (작성시간, 조회수 등) */
.activity-log-meta {
font-size: 11px;
color: var(--fmk-text-secondary);
}
/* 로딩 메시지 */
.fmk-simple-popup-loading {
text-align: center;
padding: 40px;
color: var(--fmk-text-secondary);
}
`);
log('CSS 스타일 주입 완료');
const cached = {};
let panelInserted = false;
let currentContextPopup = null;
let currentActivityPanel = null;
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), 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) { if (u.nickHistory.findIndex(entry => entry.date === currentDate) > -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;
m.isMarked = m.isMarked || false;
m.activityLog = m.activityLog || [];
return m;
}
const setMemo = (el, t, c) => { let sp = el.querySelector(CONSTANTS.SELECTORS.inlineMemo); 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.SELECTORS.inlineMemo)?.remove();
function closeContextPopup() { if (currentContextPopup) { document.body.removeChild(currentContextPopup); currentContextPopup = null; document.removeEventListener('mousedown', outsideContextPopup); } }
const outsideContextPopup = e => {
if (currentContextPopup && !currentContextPopup.contains(e.target)) {
const trackerButton = currentContextPopup.querySelector('.fmk-activity-tracker-button');
if (!trackerButton || !trackerButton.contains(e.target)) {
closeContextPopup();
closeActivityPanel();
}
}
};
function closeActivityPanel() { if (currentActivityPanel) { document.body.removeChild(currentActivityPanel); currentActivityPanel = null; } }
async function showActivityLogPanel(uid, u, x, y) {
log(`[활동 기록 열기 시작] 사용자: ${uid}`);
closeActivityPanel();
const closeBtn = createElement('button', { className: 'fmk-simple-popup-close-btn', textContent: '×', title: '닫기', onclick: (e) => { e.stopPropagation(); closeActivityPanel(); } });
const panelHeader = createElement('div', { className: 'fmk-simple-popup-header' }, [ createElement('span', { textContent: `${u.nickname}님의 전체 활동 기록` }), closeBtn ]);
const panelBody = createElement('div', { className: 'fmk-simple-popup-body' });
const panel = createElement('div', { className: 'fmk-simple-popup', style: { left: `${x}px`, top: `${y + 30}px` } }, [ panelHeader, panelBody ]);
document.body.appendChild(panel);
currentActivityPanel = panel;
clampPopup(panel);
panelBody.innerHTML = '<div class="fmk-simple-popup-loading">최신 활동을 불러오는 중... (게시판 이름 확인 중)</div>';
try {
const searchMid = detectMid();
log(`[데이터 가져오기] 현재 게시판(${searchMid})에서 검색 시작...`);
const postHtml = await fetch(`https://www.fmkorea.com/search.php?mid=${searchMid}&search_target=member_srl&search_keyword=${uid}`).then(r => r.text());
log('[데이터 가져오기] Fetch 완료.');
const postDoc = new DOMParser().parseFromString(postHtml, 'text/html');
const rows = [...postDoc.querySelectorAll(CONSTANTS.SELECTORS.postListRows)];
log(`[데이터 파싱] ${rows.length}개의 행 발견.`);
// 변경 시작: 각 게시글의 상세 페이지를 비동기 병렬로 요청하여 정확한 게시판 이름을 가져옵니다.
const postPromises = rows.map(async (el) => {
const a = el.querySelector(CONSTANTS.SELECTORS.postTitleLink);
if (!a) return null;
const docSrlMatch = a.href.match(/\/(\d+)(?:\?|#|$)|document_srl=(\d+)/);
if (!docSrlMatch) return null;
const docSrl = docSrlMatch[1] || docSrlMatch[2];
if (!docSrl) return null;
const postRelativeHref = `/${docSrl}`;
let boardName = '알 수 없음'; // 기본값
try {
// 각 게시글 페이지를 fetch
const singlePostHtml = await fetch(postRelativeHref).then(res => res.text());
const singlePostDoc = new DOMParser().parseFromString(singlePostHtml, 'text/html');
// '.bd_tl' 내부의 첫 번째 a 태그에서 게시판 이름 추출
const boardNameEl = singlePostDoc.querySelector('.bd_tl h1 a[href^="/"]');
if (boardNameEl) {
boardName = boardNameEl.textContent.trim();
} else {
// 만약 못 찾으면 기존 방식으로 fallback
const fallbackBoardNameEl = el.querySelector('td.cate a, .category a');
if(fallbackBoardNameEl) boardName = fallbackBoardNameEl.textContent.trim();
}
} catch (fetchErr) {
error(`[Activity Log] 게시판 이름 가져오기 실패 (게시글: ${docSrl}):`, fetchErr);
// 실패 시 기존 방식으로 시도
const fallbackBoardNameEl = el.querySelector('td.cate a, .category a');
if(fallbackBoardNameEl) boardName = fallbackBoardNameEl.textContent.trim();
}
const date = (el.querySelector(CONSTANTS.SELECTORS.postTime)?.textContent || '').trim();
const views = (el.querySelector(CONSTANTS.SELECTORS.postViews)?.textContent || '').trim();
const votes = (el.querySelector(CONSTANTS.SELECTORS.postVotes)?.textContent || '').trim();
return {
id: docSrl,
title: a.textContent.trim(),
href: postRelativeHref,
board: boardName, // 새로 가져온 게시판 이름 사용
date,
views,
votes,
timestamp: new Date(date).getTime() || Date.now()
};
});
// 모든 Promise가 완료될 때까지 기다린 후 null 값을 필터링
const resolvedPosts = await Promise.all(postPromises);
const newPosts = resolvedPosts.filter(p => p !== null);
// 변경 끝
const existingLogIds = new Set(u.activityLog.map(p => p.id));
let hasNewData = newPosts.some(p => !existingLogIds.has(p.id));
log(`[데이터 파싱] ${newPosts.length}개의 유효한 글 파싱 완료.`);
const combinedLog = [...u.activityLog, ...newPosts];
const uniqueLog = Array.from(new Map(combinedLog.map(item => [item.id, item])).values());
uniqueLog.sort((a, b) => b.timestamp - a.timestamp);
const finalLog = uniqueLog.slice(0, 100);
log(`[데이터 처리] 최종 기록 ${finalLog.length}개 생성 완료.`);
panelBody.innerHTML = '';
if (finalLog.length === 0) {
panelBody.innerHTML = '<div class="fmk-simple-popup-loading">기록된 활동이 없습니다.</div>';
} else {
const ul = createElement('ul');
finalLog.forEach(post => {
const metaParts = [];
if (post.date) metaParts.push(post.date);
if (post.views) metaParts.push(`조회 ${post.views}`);
if (post.votes) metaParts.push(`추천 ${post.votes}`);
const metaText = metaParts.join(' · ');
const boardSpan = createElement('div', {
className: 'activity-log-board',
textContent: `[${post.board || '기타'}]`,
title: post.board || '기타'
});
const titleLink = createElement('a', {
href: post.href,
textContent: post.title,
target: '_blank'
});
const metaInfo = createElement('div', {
className: 'activity-log-meta',
textContent: metaText
});
const mainArea = createElement('div', {
className: 'activity-log-main'
}, [titleLink, metaInfo]);
ul.appendChild(createElement('li', {}, [ boardSpan, mainArea ]));
});
panelBody.appendChild(ul);
}
if (hasNewData || u.activityLog.length !== finalLog.length) {
log('[데이터 저장] 변경 사항 감지. 스토리지 업데이트 시작...');
u.activityLog = finalLog;
await GM_setValue(uid, u);
log('[데이터 저장] 스토리지 업데이트 완료.');
} else {
log('[데이터 저장] 변경 사항 없음. 저장을 건너뜁니다.');
}
} catch (err) {
error('활동 기록을 불러오는 중 오류 발생:', err);
panelBody.innerHTML = '<div class="fmk-simple-popup-loading">오류가 발생했습니다.</div>';
}
}
async function showMemoContextPopup(uid, u, x, y, cb) {
closeContextPopup();
closeActivityPanel();
log(`우클릭 메모 팝업 열기: 사용자 ID ${uid}`);
const main = createElement('textarea', { rows: 2, maxLength: 20, placeholder: '메모 (20자)', value: u.text || '' });
const detail = createElement('textarea', { rows: 5, placeholder: '세부 메모', value: u.detail || '' });
const activityCheckbox = createElement('input', { type: 'checkbox', checked: u.isMarked });
const activityButton = createElement('div', { className: 'fmk-activity-tracker-button', textContent: '활동 기록' });
const activityTracker = createElement('div', { className: 'fmk-activity-tracker' }, [
activityCheckbox,
activityButton,
]);
const titleArea = createElement('div', { className: 'fmk-popup-title-area' }, [
createElement('div', { className: 'fmk-popup-title', textContent: `${u.nickname || 'Unknown'}[${uid}]` }),
activityTracker
]);
const saveBtn = createElement('button', { textContent: '저장', className: 'save-btn' });
const delBtn = createElement('button', { textContent: '삭제', style: { display: (u.text || u.isMarked) ? 'block' : 'none' } });
const cancelBtn = createElement('button', { textContent: '취소' });
const btns = createElement('div', { className: 'fmk-popup-buttons' }, [saveBtn, delBtn, cancelBtn]);
const historyList = createElement('ul');
if (u.nickHistory && u.nickHistory.length > 1) { u.nickHistory.slice().reverse().forEach(entry => historyList.appendChild(createElement('li', { textContent: `• ${entry.date}: ${entry.nick}` }))); } else { historyList.appendChild(createElement('li', { textContent: '변경 이력이 없습니다.', style: { fontStyle: 'italic' } })); }
const historyContainer = createElement('div', { className: 'fmk-history-container' }, [createElement('div', { className: 'fmk-history-title', textContent: '닉네임 변경 이력' }), historyList]);
const colorI = createElement('input', { type: 'color', value: u.color || getThemeAwareRandomColor() });
const colorWrap = createElement('div', { className: 'fmk-color-wrap', innerHTML: '글자 색상: ' }, [colorI]);
const p = createElement('div', { className: 'fmk-context-popup', style: { left: `${x}px`, top: `${y}px` } }, [
titleArea, main, detail, historyContainer, colorWrap, btns
]);
document.body.appendChild(p);
currentContextPopup = p;
document.addEventListener('mousedown', outsideContextPopup);
clampPopup(p);
activityButton.onclick = (e) => {
if (activityCheckbox.checked) {
showActivityLogPanel(uid, u, e.pageX, e.pageY);
}
};
const updateDelButtonVisibility = () => {
delBtn.style.display = (main.value.trim() || activityCheckbox.checked) ? 'block' : 'none';
};
activityCheckbox.onchange = updateDelButtonVisibility;
main.oninput = updateDelButtonVisibility;
saveBtn.onclick = async () => {
const isMarked = activityCheckbox.checked;
const t = main.value.trim().slice(0, 20);
if (!t && !isMarked) {
delBtn.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.isMarked = isMarked;
u.lastUpdate = Date.now();
if (!isMarked) u.activityLog = [];
cached[uid] = u;
log(`메모 저장: ID ${uid}, 내용 '${t}', 추적: ${u.isMarked}`);
await GM_setValue(uid, u);
closeContextPopup();
closeActivityPanel();
cb?.({ text: t, color: u.color }, false);
};
delBtn.onclick = async () => { log(`메모 삭제: ID ${uid}`); await GM_deleteValue(uid); delete cached[uid]; closeContextPopup(); closeActivityPanel(); cb?.(null, true); };
cancelBtn.onclick = () => { closeContextPopup(); closeActivityPanel(); };
}
function showImportPopup(onSuccess) {
if (document.querySelector(CONSTANTS.SELECTORS.importOverlay)) return;
const importText = createElement('textarea', { id: 'importPopupText', placeholder: '백업된 JSON 데이터를 여기에 붙여넣으세요...' });
const cancelImportBtn = createElement('button', { id: 'cancelImportBtn', textContent: '취소' });
const doImportBtn = createElement('button', { id: 'doImportBtn', textContent: '가져오기' });
const popup = createElement('div', { id: 'fmk-memo-import-popup' }, [
createElement('div', { className: 'popup-header', textContent: '메모 가져오기' }),
createElement('div', { className: 'popup-content' }, [importText]),
createElement('div', { className: 'popup-footer' }, [cancelImportBtn, doImportBtn])
]);
const overlay = createElement('div', { id: 'fmk-memo-import-overlay' }, [popup]);
document.body.appendChild(overlay);
const closePopup = () => document.body.removeChild(overlay);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closePopup();
});
cancelImportBtn.onclick = closePopup;
doImportBtn.onclick = async () => {
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) if (!isNaN(key)) 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() {
if (document.querySelector(CONSTANTS.SELECTORS.managerOverlay)) return;
let currentSortBy = CONSTANTS.SORT_BY_DATE;
const searchInput = createElement('input', { type: 'text', id: 'searchMemo', placeholder: '메모 검색...' });
const memoListEl = createElement('div', { className: 'items-container', id: 'memoList' });
const exportBtn = createElement('button', { id: 'exportBtn', textContent: '클립보드로 복사' });
const showImportPopupBtn = createElement('button', { id: 'showImportPopupBtn', textContent: '붙여넣기로 가져오기' });
const closeManagerBtn = createElement('button', { id: 'closeManagerBtn', textContent: '닫기' });
const clearAllBtn = createElement('button', { id: 'clearAllBtn_inline', textContent: '전체 초기화' });
const sortBtnDate = createElement('button', { textContent: '최신순', className: 'active', dataset: { sort: CONSTANTS.SORT_BY_DATE } });
const sortBtnName = createElement('button', { textContent: '이름순', dataset: { sort: CONSTANTS.SORT_BY_NAME } });
const sortButtons = [sortBtnDate, sortBtnName];
const container = createElement('div', { id: 'fmk-memo-manager-container' }, [ createElement('div', { className: 'manager-header' }, [createElement('h1', { textContent: '메모 관리' })]), createElement('div', { className: 'manager-content' }, [ searchInput, createElement('div', { className: 'fmk-sort-controls' }, [ createElement('div', { className: 'fmk-sort-group' }, [ createElement('span', { textContent: '정렬:' }), sortBtnDate, sortBtnName ]), clearAllBtn ]), memoListEl, createElement('div', { className: 'import-export-area' }, [exportBtn, showImportPopupBtn]) ]), createElement('div', { className: 'manager-footer' }, [closeManagerBtn]) ]);
const overlay = createElement('div', { id: 'fmk-memo-manager-overlay' }, [container]);
document.body.appendChild(overlay);
const closePanel = () => { log('메모 관리자 패널 닫기'); document.body.removeChild(overlay); };
closeManagerBtn.onclick = closePanel;
overlay.addEventListener('click', (e) => { if (e.target === overlay) closePanel(); });
showImportPopupBtn.onclick = () => showImportPopup(() => { renderMemoList(cached, searchInput.value.trim().toLowerCase(), currentSortBy); run(); });
clearAllBtn.onclick = async () => { if (!confirm('모든 메모를 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.')) return; log('모든 메모 데이터 초기화 시작'); const allKeys = await GM_listValues(); for (const key of allKeys) if (!isNaN(key)) await GM_deleteValue(key); Object.keys(cached).forEach(key => delete cached[key]); renderMemoList(cached, '', currentSortBy); clearAllInlineMemos(); alert('모든 메모가 성공적으로 삭제되었습니다.'); };
exportBtn.onclick = async () => { const dataToExport = Object.fromEntries(Object.entries(cached).filter(([key]) => !isNaN(key))); const jsonStr = JSON.stringify(dataToExport, null, 2); try { await navigator.clipboard.writeText(jsonStr); 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; 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, openedHistoryUid = null) {
const memoListEl = document.getElementById("memoList");
if (!memoListEl) return;
const scrollPosition = memoListEl.scrollTop;
memoListEl.innerHTML = "";
let filteredData = Object.entries(data).filter(([key, val]) => { if (isNaN(key) || (!val.text && !val.isMarked)) return false; const combinedText = `${key} ${val?.nickname ?? ""} ${val?.text ?? ""} ${val?.detail ?? ""}`.toLowerCase(); return !keyword || combinedText.includes(keyword); });
filteredData.sort(([, a], [, b]) => (sortBy === CONSTANTS.SORT_BY_NAME) ? (a.nickname || '').localeCompare(b.nickname || '') : (b.lastUpdate || 0) - (a.lastUpdate || 0));
if (filteredData.length === 0) { memoListEl.appendChild(createElement('div', { textContent: keyword ? "일치하는 메모가 없습니다." : "저장된 메모가 없습니다.", style: { padding: "10px", textAlign: "center", color: "var(--fmk-text-secondary)" } })); return; }
for (const [key, val] of filteredData) {
const itemWrapper = createElement('div', { className: 'item-wrapper', dataset: { uid: key } });
let historyHtml = '', buttonHtml = '';
const markedIndicator = val.isMarked ? '📌' : '';
if (val.history && val.history.length > 1) {
const historyItems = val.history.slice().reverse().map((h, reverseIndex) => { const originalIndex = val.history.length - 1 - reverseIndex; return `<li data-history-index="${originalIndex}"><span>• ${h.date}: ${h.text}</span><button class="history-delete-btn" title="이력 삭제">×</button></li>`; }).join('');
const historyDisplayStyle = (openedHistoryUid === key) ? 'display: block;' : 'display: none;';
historyHtml = `<div class="item-history-container" style="${historyDisplayStyle}"><div class="item-history-title">메모 변경 이력</div><ul>${historyItems}</ul></div>`;
buttonHtml = `<button class="history-toggle-btn">이력</button>`;
} else { buttonHtml = `<button class="quick-delete-btn" title="메모 삭제">×</button>`; }
itemWrapper.innerHTML = `<div class="item"><span style="${val.color ? `border-left: 3px solid ${val.color}; padding-left: 6px;` : ''}">${markedIndicator} ${val.nickname || 'Unknown'}[${key}] : ${val.text || '(추적중)'}</span>${buttonHtml}</div>${historyHtml}`;
itemWrapper.querySelector('.item')?.addEventListener('click', (e) => { if (e.target.closest('button')) return; showMemoContextPopup(key, val, e.pageX, e.pageY, () => { const searchInput = document.getElementById('searchMemo'); const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE; renderMemoList(cached, searchInput.value.trim(), currentSortBy); run(); }); });
itemWrapper.querySelector('.history-toggle-btn')?.addEventListener('click', (e) => { e.stopPropagation(); const historyContainer = itemWrapper.querySelector('.item-history-container'); if (historyContainer) { historyContainer.style.display = historyContainer.style.display === 'none' ? 'block' : 'none'; } });
memoListEl.appendChild(itemWrapper);
}
memoListEl.scrollTop = scrollPosition;
}
document.addEventListener('click', async (e) => {
const memoListEl = e.target.closest(CONSTANTS.SELECTORS.memoList);
if (!memoListEl) return;
if (e.target.classList.contains('quick-delete-btn')) {
e.stopPropagation();
const itemWrapper = e.target.closest('.item-wrapper[data-uid]');
if (!itemWrapper) return;
const uid = itemWrapper.dataset.uid;
const userMemo = cached[uid];
const displayName = userMemo?.nickname || `ID ${uid}`;
if (confirm(`'${displayName}' 님의 메모 전체를 삭제하시겠습니까? (추적 정보도 함께 삭제됩니다)`)) {
await GM_deleteValue(uid);
delete cached[uid];
const searchInput = document.getElementById('searchMemo');
const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE;
renderMemoList(cached, searchInput.value.trim(), currentSortBy);
document.querySelectorAll(CONSTANTS.SELECTORS.processedPlate(uid)).forEach(delMemo);
}
} else if (e.target.classList.contains('history-delete-btn')) {
e.stopPropagation();
const itemWrapper = e.target.closest('.item-wrapper[data-uid]');
if (!itemWrapper) return;
const uid = itemWrapper.dataset.uid;
const li = e.target.closest('li[data-history-index]');
const index = parseInt(li.dataset.historyIndex, 10);
const userMemo = cached[uid];
if (userMemo && userMemo.history && !isNaN(index)) {
userMemo.history.splice(index, 1);
await GM_setValue(uid, userMemo);
const searchInput = document.getElementById('searchMemo');
const currentSortBy = document.querySelector('.fmk-sort-controls button.active')?.dataset.sort || CONSTANTS.SORT_BY_DATE;
const uidToKeepOpen = userMemo.history.length > 1 ? uid : null;
renderMemoList(cached, searchInput.value.trim(), currentSortBy, uidToKeepOpen);
}
}
});
GM_registerMenuCommand('메모 관리자 열기', showManagementPanel);
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 (typeof u.lastUpdate === 'string') { u.lastUpdate = Date.now(); 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: Date.now(), isMarked: false, activityLog: [] };
showMemoContextPopup(uid, u, e.pageX, e.pageY, (up, rm) => {
document.querySelectorAll(CONSTANTS.SELECTORS.processedPlate(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_FOR_ORIGIN = ['best', 'best2', 'best_day']; let mid = new URL(location.href).searchParams.get('mid'); if (mid && !IGNORE_FOR_ORIGIN.includes(mid)) return mid; if (window.__fm_best_config?.target_mid) return window.__fm_best_config.target_mid; if (window.document_mid && !IGNORE_FOR_ORIGIN.includes(window.document_mid)) return window.document_mid; const docLink = document.querySelector(CONSTANTS.SELECTORS.docTitleLink); if (docLink) { const m = docLink.getAttribute('href').match(/^\/([^/?#]+)/); if (m && !IGNORE_FOR_ORIGIN.includes(m[1])) return m[1]; } const p = location.pathname.split('/').filter(Boolean); if (p.length > 0 && isNaN(p[0])) return p[0]; if (p.length > 1 && isNaN(p[1])) return p[1]; const og = document.querySelector(CONSTANTS.SELECTORS.ogUrlMeta)?.content; const mOg = og?.match(/^https?:\/\/[^/]+\/([^/?#]+)/); if (mOg) return mOg[1]; return 'stock'; };
const authorAnchor = () => document.querySelector(CONSTANTS.SELECTORS.authorAnchor);
async function injectPanel() {
if (panelInserted || !document.querySelector(CONSTANTS.SELECTORS.postBody)) 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;
const wrap = createElement('div', { className: 'fmk-info-panel' });
const leftBox = createElement('div', { className: 'fmk-info-panel-box fmk-info-panel-left' }, [createElement('p', { className: 'fmk-info-panel-title', innerHTML: '🛈 회원 정보' })]);
const rightBox = createElement('div', { className: 'fmk-info-panel-box fmk-info-panel-right' }, [createElement('p', { className: 'fmk-info-panel-title', innerHTML: '📝 최근 작성글' })]);
wrap.append(leftBox, rightBox);
const bodyEl = document.querySelector(CONSTANTS.SELECTORS.postBody);
bodyEl.parentNode.insertBefore(wrap, bodyEl.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(CONSTANTS.SELECTORS.memberInfoTable);
if (infoTbl) {
if (infoTbl.querySelector(CONSTANTS.SELECTORS.loginFormPasswordInput)) { leftBox.appendChild(createElement('p', { textContent: '회원 정보를 보려면 로그인이 필요합니다.', style: { color: 'var(--fmk-text-secondary)', padding: '20px', textAlign: 'center' }})); } else {
const latestNick = infoTbl.querySelector('a.member_plate > b, a.member_plate')?.innerText.trim();
if (latestNick && cached[memberId] && cached[memberId].nickname !== latestNick) { updNick(cached[memberId], latestNick); await GM_setValue(memberId, cached[memberId]); }
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(CONSTANTS.SELECTORS.profileImageCell)?.parentElement?.remove();
[...infoTbl.rows].find(tr => tr.querySelector(CONSTANTS.SELECTORS.baseInfoRow)?.innerText.trim() === '기본 정보')?.remove();
infoTbl.querySelectorAll('th,td').forEach(c => (c.style.padding = '4px 10px'));
leftBox.appendChild(infoTbl);
}
} else { leftBox.appendChild(createElement('p', { textContent: '정보를 불러올 수 없습니다.'})); }
let postHtml = /list_?tbody|tbody/i.test(postHtmlRaw) ? postHtmlRaw : 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(CONSTANTS.SELECTORS.postListRows)].filter(el => el.querySelector(CONSTANTS.SELECTORS.postTitleLink));
const ul = createElement('ul', { className: 'fmk-info-panel-content' });
if (rows.length > 0) {
rows.forEach(el => {
if (el.querySelector(CONSTANTS.SELECTORS.postCategoryLink)?.textContent.trim() === '공지') return;
const a = el.querySelector(CONSTANTS.SELECTORS.postTitleLink); if (!a) return;
const id = (a.href.match(/document_srl=(\d+)/) || [])[1];
const meta = [ (el.querySelector(CONSTANTS.SELECTORS.postTime)?.textContent || '').trim(), (el.querySelector(CONSTANTS.SELECTORS.postViews)?.textContent || '').trim() && `조회 ${el.querySelector(CONSTANTS.SELECTORS.postViews).textContent.trim()}`, (el.querySelector(CONSTANTS.SELECTORS.postVotes)?.textContent || '').trim() && `추천 ${el.querySelector(CONSTANTS.SELECTORS.postVotes).textContent.trim()}` ].filter(Boolean).join(' · ');
ul.appendChild(createElement('li', { innerHTML: `<a href="${id ? `/${id}` : a.href}" target="_blank">${a.textContent.trim()}</a>${meta ? ` <span class="fmk-post-meta">· ${meta}</span>` : ''}` }));
});
} else { Object.assign(ul, { textContent: '최근 작성한 글이 없습니다.', style: { listStyle: 'none', paddingLeft: '0' } }); }
rightBox.appendChild(ul);
} catch (e) { error('작성자 정보 패널 데이터 로딩 중 오류 발생:', e); leftBox.appendChild(createElement('p', { textContent: '정보 로딩 중 오류가 발생했습니다.'})); rightBox.appendChild(createElement('p', { textContent: '정보 로딩 중 오류가 발생했습니다.'})); }
}
function clearAllInlineMemos() { document.querySelectorAll(CONSTANTS.SELECTORS.inlineMemo).forEach(el => el.remove()); }
function run(container = document.body) { container.querySelectorAll(CONSTANTS.SELECTORS.unprocessedPlate).forEach(a => { const id = a.getAttribute('data-class'); if (id) a.classList.add(`${MEMBER_CLASS_PREFIX}${id}`); }); container.querySelectorAll(CONSTANTS.SELECTORS.allPlates).forEach(bindPlate); container.querySelectorAll(CONSTANTS.SELECTORS.unprocessedAuthor).forEach(bindAuthor); if (!panelInserted) injectPanel(); }
async function initialize() {
log('데이터 초기화 시작...');
const keys = await GM_listValues();
log(`저장된 키 ${keys.length}개 발견`);
let migrationCount = 0;
for (const key of keys) {
if (!isNaN(key)) {
let memo = await GM_getValue(key);
let needsUpdate = false;
if (memo && typeof memo.lastUpdate === 'string') { memo.lastUpdate = new Date(memo.lastUpdate).getTime(); needsUpdate = true; }
if (memo && !memo.lastUpdate) { memo.lastUpdate = new Date(0).getTime(); needsUpdate = true; }
if (needsUpdate) { await GM_setValue(key, memo); migrationCount++; }
cached[key] = normalizeMemo(memo);
}
}
if (migrationCount > 0) log(`${migrationCount}개의 기존 메모 데이터 형식을 성공적으로 변환했습니다.`);
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' && mutation.attributeName === 'style' && mutation.target.style.display !== 'none') { runOnce(); break; } } });
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
log('최적화된 MutationObserver 시작. 페이지 변경 및 속성 변경을 감시합니다.');
}
initialize();
})();