새 어록 감지 기능 추가 및 모달 스타일 개선, 자동 로그인 기능 구현

This commit is contained in:
9700X_PC 2025-06-22 23:58:16 +09:00
parent 035f045a75
commit 87da00a872
8 changed files with 4867 additions and 420 deletions

View File

@ -7,11 +7,102 @@ chrome.runtime.onInstalled.addListener(() => {
contexts: ["selection"] contexts: ["selection"]
}); });
chrome.alarms.create("keepAlive", { periodInMinutes: 4 }); chrome.alarms.create("keepAlive", { periodInMinutes: 4 });
// 새 어록 감지 알람 생성 (1분마다)
chrome.alarms.create("checkNewSayings", { periodInMinutes: 1 });
// 초기 마지막 확인 시간 설정
chrome.storage.local.set({ lastSayingsCheck: Date.now() });
}); });
chrome.alarms.onAlarm.addListener((alarm) => { chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepAlive") { if (alarm.name === "keepAlive") {
console.log("[background.js] 서비스 워커 유지 알람 실행됨"); console.log("[background.js] 서비스 워커 유지 알람 실행됨");
} else if (alarm.name === "checkNewSayings") {
checkForNewSayings();
}
});
// 새 어록 확인 함수
async function checkForNewSayings() {
try {
console.log("[background.js] 새 어록 확인 시작");
// 마지막 확인 시간 가져오기
const { lastSayingsCheck } = await chrome.storage.local.get("lastSayingsCheck");
const lastCheckTime = lastSayingsCheck || Date.now() - 60000; // 기본값: 1분 전
// Supabase에서 새 어록 확인
const { access_token } = await chrome.storage.local.get("access_token");
if (!access_token) {
console.log("[background.js] 액세스 토큰이 없어 새 어록 확인을 건너뜁니다.");
return;
}
const response = await fetch('https://kbvpvbabvlzjfgcnfxsg.supabase.co/rest/v1/sayings?select=*,sayings_cat(name),sayings_target(name)&created_at=gte.' + new Date(lastCheckTime).toISOString() + '&order=created_at.desc', {
method: 'GET',
headers: {
'apikey': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtidnB2YmFidmx6amZnY25meHNnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU2NDcxMjEsImV4cCI6MjA1MTIyMzEyMX0.BrPBMGI_zz6-UZpUJGQdJGCFKLEGJBE7CdNLKJgMZNM',
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const newSayings = await response.json();
if (newSayings && newSayings.length > 0) {
console.log(`[background.js] ${newSayings.length}개의 새 어록을 발견했습니다.`);
// 브라우저 알림 표시
chrome.notifications.create('newSayings', {
type: 'basic',
iconUrl: 'icon.png',
title: '새 어록 알림',
message: `${newSayings.length}개의 새로운 어록이 등록되었습니다.`,
buttons: [
{ title: '확인하기' },
{ title: '나중에' }
]
});
// 새 어록 데이터를 스토리지에 저장
chrome.storage.local.set({
pendingNewSayings: newSayings,
hasNewSayings: true
});
} else {
console.log("[background.js] 새 어록이 없습니다.");
}
// 마지막 확인 시간 업데이트
chrome.storage.local.set({ lastSayingsCheck: Date.now() });
} else {
console.error("[background.js] 새 어록 확인 실패:", response.status, response.statusText);
}
} catch (error) {
console.error("[background.js] 새 어록 확인 중 오류:", error);
}
}
// 알림 클릭 처리
chrome.notifications.onClicked.addListener((notificationId) => {
if (notificationId === 'newSayings') {
// 어록 관리 페이지 열기
chrome.tabs.create({ url: chrome.runtime.getURL('sayings.html') });
chrome.notifications.clear(notificationId);
}
});
// 알림 버튼 클릭 처리
chrome.notifications.onButtonClicked.addListener((notificationId, buttonIndex) => {
if (notificationId === 'newSayings') {
if (buttonIndex === 0) { // 확인하기
chrome.tabs.create({ url: chrome.runtime.getURL('sayings.html') });
}
chrome.notifications.clear(notificationId);
} }
}); });

244
bannedWords.html Normal file
View File

@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>금지어 관리</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Segoe UI', sans-serif;
background: #f4f6f9;
padding: 20px;
}
h2 {
text-align: center;
margin-bottom: 20px;
color: #2c3e50;
}
/* 통계 정보 스타일 */
.stats-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin-bottom: 20px;
font-size: 14px;
font-weight: bold;
color: #495057;
text-align: center;
}
/* 테이블 스타일 */
.banned-words-table-container {
overflow-x: auto;
margin-top: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
#banned-words-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
#banned-words-table th,
#banned-words-table td {
border: 1px solid #ddd;
padding: 12px 8px;
text-align: left;
white-space: nowrap;
}
#banned-words-table th {
background-color: #f8f9fa;
font-weight: bold;
position: sticky;
top: 0;
z-index: 1;
}
#banned-words-table tr:nth-child(even) {
background-color: #f9f9f9;
}
/* 순번 열 스타일 */
#banned-words-table th:first-child,
#banned-words-table td:first-child {
width: 60px;
text-align: center;
}
/* 액션 버튼 스타일 */
.action-btn {
padding: 6px 10px;
margin: 2px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
white-space: nowrap;
display: inline-block;
min-width: 40px;
}
.view-btn {
background: #3498db;
color: white;
}
.edit-btn {
background: #f39c12;
color: white;
}
.delete-btn {
background: #e74c3c;
color: white;
}
.action-btn:hover {
opacity: 0.8;
}
/* 등급 표시 스타일 */
.grade-display {
display: inline-block;
padding: 4px 8px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
color: #495057;
min-width: 60px;
text-align: center;
}
/* 로딩 표시 */
.loading {
text-align: center;
padding: 20px;
font-size: 14px;
color: #3498db;
}
/* 키프리스 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
position: relative;
background-color: #fefefe;
margin: 15px auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 800px;
border-radius: 8px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #dee2e6;
}
.modal-header h3 {
margin: 0;
}
.close {
font-size: 24px;
font-weight: bold;
cursor: pointer;
color: #666;
}
.close:hover {
color: #000;
}
/* 디버그 정보 */
.debug-info {
margin-top: 20px;
padding: 10px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 12px;
color: #6c757d;
}
</style>
</head>
<body>
<h2>🚫 금지어 관리</h2>
<!-- 디버그 정보 -->
<div class="debug-info" id="debug-info" style="display: block;">
초기화 중...
<br>
<button id="token-check-btn" style="margin-top: 10px; padding: 5px 10px; font-size: 12px;">🔍 토큰 상태 확인</button>
</div>
<!-- 통계 정보 -->
<div id="banned-words-stats" class="stats-info">
<!-- 통계 정보가 여기에 표시됩니다 -->
</div>
<!-- 금지어 테이블 -->
<div class="banned-words-table-container">
<table id="banned-words-table">
<thead>
<tr>
<th>순번</th>
<th>금지어</th>
<th>등급</th>
<th>작업</th>
</tr>
</thead>
<tbody id="banned-words-tbody">
<!-- 동적으로 생성됨 -->
</tbody>
</table>
</div>
<div class="loading" id="banned-words-loading" style="display: none;">
🔄 금지어 목록을 불러오는 중...
</div>
<!-- 키프리스 결과 모달 -->
<div id="kipris-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>🔍 키프리스 검색 결과</h3>
<span class="close" id="close-kipris">&times;</span>
</div>
<div id="kipris-word-title"></div>
<div id="kipris-results">
<!-- 동적으로 생성됨 -->
</div>
<div class="loading" id="kipris-loading" style="display: none;">
🔄 키프리스 결과를 불러오는 중...
</div>
</div>
</div>
<script src="bannedWords.js"></script>
</body>
</html>

1615
bannedWords.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -27,5 +27,11 @@
"action": { "action": {
"default_popup": "popup.html", "default_popup": "popup.html",
"default_icon": "icon.png" "default_icon": "icon.png"
},
"web_accessible_resources": [
{
"resources": ["bannedWords.html", "bannedWords.js"],
"matches": ["<all_urls>"]
} }
]
} }

View File

@ -12,6 +12,8 @@
background: #f4f6f9; background: #f4f6f9;
padding: 20px; padding: 20px;
width: 320px; width: 320px;
height: 100vh;
overflow: hidden;
} }
h2 { h2 {
text-align: center; text-align: center;
@ -130,41 +132,55 @@
background: #219a52; background: #219a52;
} }
/* 모달 스타일 */ /* 모달 스타일 수정 */
.modal { .modal {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
left: 0; left: 0;
top: 0; top: 0;
width: 100%; width: 100vw;
height: 100%; height: 100vh;
background-color: rgba(0,0,0,0.5); background-color: rgba(0,0,0,0.5);
display: none;
} }
.modal-content { .modal-content {
background-color: white; position: relative;
margin: 5% auto; background-color: #fefefe;
padding: 0; margin: 15px auto;
border-radius: 8px; padding: 20px;
width: 90%; border: 1px solid #888;
max-width: 600px; width: 80%;
max-height: 80%; max-width: 900px;
overflow: hidden; min-width: 400px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3); min-height: 300px;
border-radius: 5px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
} }
.modal-header { .modal-header {
background: #3498db; cursor: move;
color: white; padding: 10px;
padding: 15px 20px; margin: -20px -20px 20px -20px;
display: flex; background: #f8f9fa;
justify-content: space-between; border-bottom: 1px solid #dee2e6;
align-items: center; border-radius: 5px 5px 0 0;
user-select: none;
} }
.modal-header h3 { .resizer {
margin: 0; width: 10px;
font-size: 18px; height: 10px;
background: #6c757d;
position: absolute;
right: 0;
bottom: 0;
cursor: se-resize;
border-radius: 0 0 5px 0;
}
.resizer:hover {
background: #5a6268;
} }
.close { .close {
@ -180,26 +196,55 @@
.modal-body { .modal-body {
padding: 20px; padding: 20px;
max-height: 500px; height: calc(100% - 56px); /* 헤더 높이를 제외한 나머지 */
overflow-y: auto; overflow-y: auto;
} }
/* 테이블 컨테이너 높이 조정 */
.banned-words-table-container {
height: calc(100% - 80px); /* 통계 정보 높이를 제외한 나머지 */
overflow: auto;
}
#banned-words-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
font-size: 14px;
}
/* 테이블 헤더 고정 */
#banned-words-table thead {
position: sticky;
top: 0;
background: #f8f9fa;
z-index: 1;
}
/* 통계 정보 스타일 */
.stats-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin-bottom: 20px;
font-size: 14px;
font-weight: bold;
color: #495057;
text-align: center;
}
/* 테이블 스타일 */ /* 테이블 스타일 */
.banned-words-table-container { .banned-words-table-container {
overflow-x: auto; overflow-x: auto;
} }
#banned-words-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
#banned-words-table th, #banned-words-table th,
#banned-words-table td { #banned-words-table td {
border: 1px solid #ddd; border: 1px solid #ddd;
padding: 8px; padding: 12px 8px;
text-align: left; text-align: left;
white-space: nowrap;
} }
#banned-words-table th { #banned-words-table th {
@ -211,14 +256,24 @@
background-color: #f9f9f9; background-color: #f9f9f9;
} }
/* 순번 열 스타일 */
#banned-words-table th:first-child,
#banned-words-table td:first-child {
width: 60px;
text-align: center;
}
/* 액션 버튼 스타일 */ /* 액션 버튼 스타일 */
.action-btn { .action-btn {
padding: 4px 8px; padding: 6px 10px;
margin: 2px; margin: 2px;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
white-space: nowrap;
display: inline-block;
min-width: 40px;
} }
.view-btn { .view-btn {
@ -242,13 +297,13 @@
/* 키프리스 결과 스타일 */ /* 키프리스 결과 스타일 */
.kipris-results-container { .kipris-results-container {
max-height: 400px; max-height: 500px;
overflow-y: auto; overflow-y: auto;
} }
.kipris-item { .kipris-item {
border: 1px solid #ddd; border: 1px solid #ddd;
margin-bottom: 10px; margin-bottom: 15px;
padding: 15px; padding: 15px;
border-radius: 6px; border-radius: 6px;
background: #f9f9f9; background: #f9f9f9;
@ -275,12 +330,17 @@
border-radius: 4px; border-radius: 4px;
} }
/* 등급 수정 입력 필드 */ /* 등급 표시 스타일 */
.grade-input { .grade-display {
width: 60px; display: inline-block;
padding: 2px 4px; padding: 4px 8px;
border: 1px solid #ddd; background-color: #f8f9fa;
border-radius: 3px; border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
color: #495057;
min-width: 60px;
text-align: center; text-align: center;
} }
</style> </style>
@ -338,23 +398,31 @@
<!-- 관리 버튼들 --> <!-- 관리 버튼들 -->
<div class="management-buttons"> <div class="management-buttons">
<button class="btn management-btn" id="banned-words-btn">🚫 금지어 관리</button> <button class="btn management-btn" id="banned-words-btn">🚫 금지어 관리</button>
<button class="btn management-btn" id="sayings-btn">💬 어록보기</button>
</div> </div>
<button class="btn" id="logout-btn">로그아웃</button> <button class="btn" id="logout-btn">로그아웃</button>
</div> </div>
<!-- 스크립트를 일반 스크립트로 변경 -->
<script src="popup.js"></script>
<!-- 금지어 관리 모달 --> <!-- 금지어 관리 모달 -->
<div id="banned-words-modal" class="modal" style="display: none;"> <div id="banned-words-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>🚫 금지어 관리</h3> <h3>🚫 금지어 관리</h3>
<span class="close" id="close-banned-words">&times;</span> <span class="close" id="close-banned-words">&times;</span>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="banned-words-stats" class="stats-info">
<!-- 통계 정보가 여기에 표시됩니다 -->
</div>
<div class="banned-words-table-container"> <div class="banned-words-table-container">
<table id="banned-words-table"> <table id="banned-words-table">
<thead> <thead>
<tr> <tr>
<th>순번</th>
<th>금지어</th> <th>금지어</th>
<th>등급</th> <th>등급</th>
<th>작업</th> <th>작업</th>
@ -373,7 +441,7 @@
</div> </div>
<!-- 키프리스 결과 모달 --> <!-- 키프리스 결과 모달 -->
<div id="kipris-modal" class="modal" style="display: none;"> <div id="kipris-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>🔍 키프리스 검색 결과</h3> <h3>🔍 키프리스 검색 결과</h3>
@ -393,8 +461,5 @@
</div> </div>
</div> </div>
<!-- 스크립트를 일반 스크립트로 변경 -->
<script src="popup.js"></script>
</body> </body>
</html> </html>

751
popup.js
View File

@ -4,6 +4,7 @@ const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9s
// 디버그 모드 플래그 (개발 시에만 true로 설정) // 디버그 모드 플래그 (개발 시에만 true로 설정)
const DEBUG_MODE = true; // false로 설정하면 디버그 정보 숨김 const DEBUG_MODE = true; // false로 설정하면 디버그 정보 숨김
// const DEBUG_MODE = false; // false로 설정하면 디버그 정보 숨김
// 로그 레벨 정의 // 로그 레벨 정의
const LOG_LEVELS = { const LOG_LEVELS = {
@ -200,7 +201,11 @@ function isValidEmail(email) {
async function supabaseAuth(email, password) { async function supabaseAuth(email, password) {
await logger.log(LOG_LEVELS.INFO, '로그인 API 호출 시작', { email }); await logger.log(LOG_LEVELS.INFO, '로그인 API 호출 시작', { email });
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, { const url = `${SUPABASE_URL}/auth/v1/token?grant_type=password`;
await logger.log(LOG_LEVELS.DEBUG, '로그인 API URL', { url });
try {
const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -212,6 +217,13 @@ async function supabaseAuth(email, password) {
}) })
}); });
await logger.log(LOG_LEVELS.DEBUG, '로그인 API 응답 상태', {
status: response.status,
statusText: response.statusText,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries())
});
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
await logger.log(LOG_LEVELS.ERROR, '로그인 API 응답 오류', { await logger.log(LOG_LEVELS.ERROR, '로그인 API 응답 오류', {
@ -221,8 +233,257 @@ async function supabaseAuth(email, password) {
throw new Error(error.error_description || error.message || '로그인 실패'); throw new Error(error.error_description || error.message || '로그인 실패');
} }
await logger.log(LOG_LEVELS.INFO, '로그인 API 응답 성공'); const result = await response.json();
return await response.json(); await logger.log(LOG_LEVELS.INFO, '로그인 API 응답 성공', {
hasAccessToken: !!result.access_token,
tokenLength: result.access_token ? result.access_token.length : 0
});
return result;
} catch (error) {
await logger.log(LOG_LEVELS.ERROR, '로그인 API 호출 중 네트워크 오류', {
error: error.message,
name: error.name,
url: url
});
// 네트워크 에러 메시지 개선
if (error.name === 'TypeError' && (error.message.includes('fetch') || error.message.includes('Failed to fetch'))) {
throw new Error(`서버에 연결할 수 없습니다.\n- 인터넷 연결을 확인해주세요\n- 서버가 실행 중인지 확인해주세요\n- 방화벽 설정을 확인해주세요\n\n기술적 세부사항: ${error.message}`);
}
throw error;
}
}
// 모달 드래그 기능
function makeModalDraggable(modal) {
const modalContent = modal.querySelector('.modal-content');
const modalHeader = modal.querySelector('.modal-header');
let isDragging = false;
let currentX;
let currentY;
let initialX;
let initialY;
let xOffset = 0;
let yOffset = 0;
modalHeader.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
function dragStart(e) {
if (e.target.classList.contains('close')) return;
initialX = e.clientX - xOffset;
initialY = e.clientY - yOffset;
if (e.target === modalHeader) {
isDragging = true;
}
}
function drag(e) {
if (isDragging) {
e.preventDefault();
currentX = e.clientX - initialX;
currentY = e.clientY - initialY;
xOffset = currentX;
yOffset = currentY;
setTranslate(currentX, currentY, modalContent);
}
}
function dragEnd(e) {
initialX = currentX;
initialY = currentY;
isDragging = false;
}
function setTranslate(xPos, yPos, el) {
el.style.transform = `translate(${xPos}px, ${yPos}px)`;
}
}
// 모달 리사이즈 기능
function makeModalResizable(modal) {
const modalContent = modal.querySelector('.modal-content');
const resizer = document.createElement('div');
resizer.className = 'resizer';
modalContent.appendChild(resizer);
let isResizing = false;
let originalWidth;
let originalHeight;
let originalX;
let originalY;
resizer.addEventListener('mousedown', initResize);
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', stopResize);
function initResize(e) {
isResizing = true;
originalWidth = modalContent.offsetWidth;
originalHeight = modalContent.offsetHeight;
originalX = e.clientX;
originalY = e.clientY;
e.preventDefault();
}
function resize(e) {
if (!isResizing) return;
const width = originalWidth + (e.clientX - originalX);
const height = originalHeight + (e.clientY - originalY);
if (width > 400) {
modalContent.style.width = width + 'px';
}
if (height > 300) {
modalContent.style.height = height + 'px';
}
}
function stopResize() {
isResizing = false;
}
}
// 모달 초기화 함수
function initializeModal(modalId) {
const modal = document.getElementById(modalId);
if (!modal) return;
makeModalDraggable(modal);
makeModalResizable(modal);
// 모달 외부 클릭 시 닫기
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.style.display = 'none';
}
});
// 닫기 버튼
const closeBtn = modal.querySelector('.close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
modal.style.display = 'none';
});
}
}
// JWT 토큰 유효성 검증 함수 추가
async function validateToken(token) {
if (!token) {
await logger.log(LOG_LEVELS.DEBUG, '토큰이 없음');
return false;
}
try {
// JWT 토큰 만료 시간 확인 (클라이언트 사이드)
const payload = JSON.parse(atob(token.split('.')[1]));
const currentTime = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < currentTime) {
await logger.log(LOG_LEVELS.WARN, '토큰이 만료됨', {
exp: payload.exp,
currentTime,
expired: true
});
return false;
}
// 서버에서 토큰 유효성 검증
const authUrl = `${SUPABASE_URL}/auth/v1/user`;
const authRes = await fetch(authUrl, {
headers: {
Authorization: `Bearer ${token}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json'
}
});
if (authRes.ok) {
await logger.log(LOG_LEVELS.INFO, '토큰 유효성 검증 성공');
return true;
} else {
await logger.log(LOG_LEVELS.WARN, '토큰 유효성 검증 실패', {
status: authRes.status,
statusText: authRes.statusText
});
return false;
}
} catch (error) {
await logger.log(LOG_LEVELS.ERROR, '토큰 유효성 검증 중 오류', {
error: error.message
});
return false;
}
}
// 자동 로그인 시도 함수 추가
async function attemptAutoLogin() {
updateDebugInfo('🔄 자동 로그인 확인 중...');
try {
// 1. 기존 토큰 확인
const { access_token } = await chrome.storage.local.get("access_token");
if (access_token) {
updateDebugInfo('🔍 기존 토큰 유효성 검증 중...');
await logger.log(LOG_LEVELS.INFO, '기존 토큰 발견, 유효성 검증 시작');
const isValidToken = await validateToken(access_token);
if (isValidToken) {
// 토큰이 유효하면 바로 사용자 정보 로드
updateDebugInfo('✅ 유효한 토큰으로 자동 로그인 중...');
await logger.log(LOG_LEVELS.INFO, '유효한 토큰으로 자동 로그인 성공');
await loadUserInfo(access_token);
return true;
} else {
// 토큰이 유효하지 않으면 제거
updateDebugInfo('🗑️ 만료된 토큰 제거 중...');
await logger.log(LOG_LEVELS.WARN, '만료된 토큰 제거');
await chrome.storage.local.remove("access_token");
}
}
// 2. 자동 로그인 설정 확인
const { autoLogin, savedEmail, savedPassword } = await chrome.storage.local.get([
"autoLogin", "savedEmail", "savedPassword"
]);
if (autoLogin && savedEmail && savedPassword) {
updateDebugInfo('🔄 저장된 계정으로 자동 로그인 시도 중...');
await logger.log(LOG_LEVELS.INFO, '저장된 계정으로 자동 로그인 시도');
// 저장된 정보로 자동 로그인
document.getElementById("email").value = savedEmail;
document.getElementById("password").value = savedPassword;
document.getElementById("save-login").checked = true;
document.getElementById("auto-login").checked = true;
// 약간의 지연 후 로그인 시도
setTimeout(doLogin, 500);
return true;
}
// 3. 자동 로그인이 불가능한 경우
updateDebugInfo('✅ 초기화 완료 - 로그인이 필요합니다');
await logger.log(LOG_LEVELS.DEBUG, '자동 로그인 불가 - 수동 로그인이 필요합니다');
return false;
} catch (error) {
updateDebugInfo(`❌ 자동 로그인 확인 실패: ${error.message}`);
await logger.log(LOG_LEVELS.ERROR, '자동 로그인 확인 중 오류', { error: error.message });
return false;
}
} }
// DOM 로드 완료 후 실행 // DOM 로드 완료 후 실행
@ -273,9 +534,7 @@ document.addEventListener('DOMContentLoaded', async function() {
updateDebugInfo('✅ 모든 필수 요소 확인됨'); updateDebugInfo('✅ 모든 필수 요소 확인됨');
await logger.log(LOG_LEVELS.DEBUG, '모든 필수 요소 확인됨'); await logger.log(LOG_LEVELS.DEBUG, '모든 필수 요소 확인됨');
updateDebugInfo('저장된 설정 로드 중...'); // 저장된 로그인 정보 로드 (UI 표시용)
// 자동 입력 및 자동 로그인
try { try {
const saved = await chrome.storage.local.get(["savedEmail", "savedPassword", "autoLogin"]); const saved = await chrome.storage.local.get(["savedEmail", "savedPassword", "autoLogin"]);
await logger.log(LOG_LEVELS.DEBUG, '저장된 설정 로드 완료', { await logger.log(LOG_LEVELS.DEBUG, '저장된 설정 로드 완료', {
@ -288,13 +547,6 @@ document.addEventListener('DOMContentLoaded', async function() {
document.getElementById("save-login").checked = !!saved.savedEmail; document.getElementById("save-login").checked = !!saved.savedEmail;
document.getElementById("auto-login").checked = !!saved.autoLogin; document.getElementById("auto-login").checked = !!saved.autoLogin;
if (saved.autoLogin && saved.savedEmail && saved.savedPassword) {
updateDebugInfo('🔄 자동 로그인 시도 중...');
await logger.log(LOG_LEVELS.INFO, '자동 로그인 시도');
setTimeout(doLogin, 500); // 약간의 지연 후 로그인
} else {
updateDebugInfo('✅ 초기화 완료 - 로그인 준비됨');
}
} catch (error) { } catch (error) {
updateDebugInfo(`❌ 설정 로드 실패: ${error.message}`); updateDebugInfo(`❌ 설정 로드 실패: ${error.message}`);
await logger.log(LOG_LEVELS.ERROR, '저장된 설정 로드 실패', { error: error.message }); await logger.log(LOG_LEVELS.ERROR, '저장된 설정 로드 실패', { error: error.message });
@ -376,7 +628,7 @@ document.addEventListener('DOMContentLoaded', async function() {
}); });
} }
// 로그인 버튼 // 로그인 버튼
const loginBtn = document.getElementById("login-btn"); const loginBtn = document.getElementById("login-btn");
if (loginBtn) { if (loginBtn) {
await logger.log(LOG_LEVELS.DEBUG, '로그인 버튼 이벤트 등록'); await logger.log(LOG_LEVELS.DEBUG, '로그인 버튼 이벤트 등록');
@ -388,7 +640,7 @@ document.addEventListener('DOMContentLoaded', async function() {
}); });
} }
// 로그아웃 버튼 // 로그아웃 버튼
const logoutBtn = document.getElementById("logout-btn"); const logoutBtn = document.getElementById("logout-btn");
if (logoutBtn) { if (logoutBtn) {
await logger.log(LOG_LEVELS.DEBUG, '로그아웃 버튼 이벤트 등록'); await logger.log(LOG_LEVELS.DEBUG, '로그아웃 버튼 이벤트 등록');
@ -396,7 +648,11 @@ document.addEventListener('DOMContentLoaded', async function() {
logoutBtn.addEventListener("click", async (e) => { logoutBtn.addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
await logger.log(LOG_LEVELS.INFO, '로그아웃 버튼 클릭됨'); await logger.log(LOG_LEVELS.INFO, '로그아웃 버튼 클릭됨');
await chrome.storage.local.remove("access_token");
// 토큰과 자동로그인 설정 제거
await chrome.storage.local.remove(["access_token", "autoLogin"]);
await logger.log(LOG_LEVELS.INFO, '로그아웃 완료 - 토큰 및 자동로그인 설정 제거');
location.reload(); location.reload();
}); });
} }
@ -409,51 +665,129 @@ document.addEventListener('DOMContentLoaded', async function() {
bannedWordsBtn.addEventListener("click", async (e) => { bannedWordsBtn.addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
await logger.log(LOG_LEVELS.INFO, '금지어 관리 버튼 클릭됨'); await logger.log(LOG_LEVELS.INFO, '금지어 관리 버튼 클릭됨');
await openBannedWordsModal();
});
}
// 모달 닫기 이벤트들
const closeBannedWords = document.getElementById("close-banned-words");
if (closeBannedWords) {
closeBannedWords.addEventListener("click", () => {
document.getElementById("banned-words-modal").style.display = "none";
});
}
const closeKipris = document.getElementById("close-kipris");
if (closeKipris) {
closeKipris.addEventListener("click", () => {
document.getElementById("kipris-modal").style.display = "none";
});
}
// 모달 외부 클릭 시 닫기
window.addEventListener("click", (e) => {
if (e.target.classList.contains("modal")) {
e.target.style.display = "none";
}
});
// 기존 토큰이 있으면 사용자 정보 로드
try { try {
// 현재 로그인된 토큰 확인
const { access_token } = await chrome.storage.local.get("access_token"); const { access_token } = await chrome.storage.local.get("access_token");
if (access_token) { if (!access_token) {
updateDebugInfo('🔄 기존 토큰으로 사용자 정보 로드 중...'); alert('로그인이 필요합니다.');
await logger.log(LOG_LEVELS.INFO, '기존 토큰 발견, 사용자 정보 로드'); return;
await loadUserInfo(access_token);
} else {
await logger.log(LOG_LEVELS.DEBUG, '저장된 토큰 없음');
if (!document.getElementById("auto-login").checked) {
updateDebugInfo('✅ 초기화 완료 - 모든 기능 준비됨');
}
}
} catch (error) {
updateDebugInfo(`❌ 토큰 확인 오류: ${error.message}`);
await logger.log(LOG_LEVELS.ERROR, '토큰 확인 중 오류', { error: error.message });
} }
await logger.log(LOG_LEVELS.INFO, '초기화 완료 - 모든 기능 준비됨'); // 토큰 유효성 재확인
const isValid = await validateToken(access_token);
if (!isValid) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
await chrome.storage.local.remove("access_token");
location.reload();
return;
}
// chrome.storage에 설정값 저장 (새 창에서 사용할 수 있도록)
await chrome.storage.local.set({
'bannedWords_config': {
SUPABASE_URL: SUPABASE_URL,
SUPABASE_ANON_KEY: SUPABASE_ANON_KEY,
DEBUG_MODE: DEBUG_MODE,
ACCESS_TOKEN: access_token,
timestamp: Date.now()
}
});
await logger.log(LOG_LEVELS.INFO, '금지어 관리 창 설정 저장 완료', {
hasUrl: !!SUPABASE_URL,
hasKey: !!SUPABASE_ANON_KEY,
hasToken: !!access_token,
debugMode: DEBUG_MODE
});
// 크롬 익스텐션 리소스 URL로 새 창 열기
const url = chrome.runtime.getURL('bannedWords.html');
const newWindow = window.open(url, '_blank', 'width=1000,height=700');
if (!newWindow) {
throw new Error('새 창을 열 수 없습니다. 팝업 차단을 확인해주세요.');
}
await logger.log(LOG_LEVELS.INFO, '금지어 관리 창 열기 완료');
} catch (error) {
await logger.log(LOG_LEVELS.ERROR, '금지어 관리 창 열기 실패', { error: error.message });
alert('금지어 관리 창을 열 수 없습니다: ' + error.message);
}
});
}
// 어록보기 버튼 클릭 이벤트
document.getElementById('sayings-btn').addEventListener('click', async () => {
console.log('어록보기 버튼 클릭됨');
try {
// 현재 로그인된 토큰 확인
const { access_token } = await chrome.storage.local.get("access_token");
if (!access_token) {
alert('로그인이 필요합니다.');
return;
}
// 토큰 유효성 재확인
const isValid = await validateToken(access_token);
if (!isValid) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
await chrome.storage.local.remove("access_token");
location.reload();
return;
}
// chrome.storage에 설정 저장 (금지어 관리와 동일한 설정 사용)
const settingsToSave = {
SUPABASE_URL: SUPABASE_URL,
SUPABASE_ANON_KEY: SUPABASE_ANON_KEY,
DEBUG_MODE: DEBUG_MODE,
ACCESS_TOKEN: access_token,
timestamp: Date.now()
};
console.log('어록보기용 설정을 chrome.storage에 저장 중...', {
url: !!settingsToSave.SUPABASE_URL,
key: !!settingsToSave.SUPABASE_ANON_KEY,
token: !!settingsToSave.ACCESS_TOKEN,
debug: settingsToSave.DEBUG_MODE
});
await chrome.storage.local.set({
'sayings_config': settingsToSave
});
console.log('어록보기 설정 저장 완료, 새 창 열기');
// 새 창에서 어록보기 페이지 열기
chrome.windows.create({
url: chrome.runtime.getURL('sayings.html'),
type: 'popup',
width: 1200,
height: 800
});
} catch (error) {
console.error('어록보기 창 열기 실패:', error);
alert('어록보기 창을 열 수 없습니다: ' + error.message);
}
});
// 모달 초기화
initializeModal('banned-words-modal');
initializeModal('kipris-modal');
// 🚀 자동 로그인 시도 (가장 중요한 부분)
updateDebugInfo('🚀 자동 로그인 시도 중...');
const autoLoginSuccess = await attemptAutoLogin();
if (!autoLoginSuccess) {
updateDebugInfo('✅ 초기화 완료 - 수동 로그인이 필요합니다');
}
await logger.log(LOG_LEVELS.INFO, '초기화 완료', { autoLoginSuccess });
} catch (error) { } catch (error) {
updateDebugInfo(`❌ 초기화 실패: ${error.message}`); updateDebugInfo(`❌ 초기화 실패: ${error.message}`);
@ -527,7 +861,11 @@ async function doLogin() {
await logger.log(LOG_LEVELS.INFO, '로그인 성공, 토큰 저장'); await logger.log(LOG_LEVELS.INFO, '로그인 성공, 토큰 저장');
const token = authResult.access_token; const token = authResult.access_token;
await chrome.storage.local.set({ access_token: token }); await chrome.storage.local.set({
access_token: token,
SUPABASE_URL,
SUPABASE_ANON_KEY
});
// 로그인 정보 저장 처리 // 로그인 정보 저장 처리
if (document.getElementById("save-login").checked) { if (document.getElementById("save-login").checked) {
@ -731,302 +1069,3 @@ async function loadUserInfo(token) {
console.error('사용자 정보 로드 오류:', error); console.error('사용자 정보 로드 오류:', error);
} }
} }
// 금지어 관리 모달 열기
async function openBannedWordsModal() {
await logger.log(LOG_LEVELS.INFO, '금지어 관리 모달 열기');
const modal = document.getElementById("banned-words-modal");
const loading = document.getElementById("banned-words-loading");
const tbody = document.getElementById("banned-words-tbody");
// 모달 표시
modal.style.display = "block";
loading.style.display = "block";
tbody.innerHTML = "";
try {
const { access_token } = await chrome.storage.local.get("access_token");
if (!access_token) {
throw new Error('로그인이 필요합니다');
}
await loadBannedWords(access_token);
} catch (error) {
await logger.log(LOG_LEVELS.ERROR, '금지어 목록 로드 실패', { error: error.message });
tbody.innerHTML = `<tr><td colspan="3" style="text-align: center; color: red;">오류: ${error.message}</td></tr>`;
} finally {
loading.style.display = "none";
}
}
// 금지어 목록 로드
async function loadBannedWords(token) {
await logger.log(LOG_LEVELS.INFO, '금지어 목록 API 호출');
// 현재 사용자의 ID 가져오기
const authRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: {
Authorization: `Bearer ${token}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json'
}
});
if (!authRes.ok) {
throw new Error('사용자 정보를 가져올 수 없습니다');
}
const authUser = await authRes.json();
// 사용자의 user_id 가져오기
const userRes = await fetch(`${SUPABASE_URL}/rest/v1/users?select=id&email=eq.${encodeURIComponent(authUser.email)}&limit=1`, {
headers: {
Authorization: `Bearer ${token}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json'
}
});
if (!userRes.ok) {
throw new Error('사용자 정보를 찾을 수 없습니다');
}
const userData = await userRes.json();
if (!userData || userData.length === 0) {
throw new Error('사용자 데이터를 찾을 수 없습니다');
}
const userId = userData[0].id;
await logger.log(LOG_LEVELS.DEBUG, '사용자 ID 확인', { userId });
// 금지어 목록 가져오기
const bannedWordsRes = await fetch(`${SUPABASE_URL}/rest/v1/users_banned_words?select=*&user_id=eq.${userId}&order=created_at.desc`, {
headers: {
Authorization: `Bearer ${token}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json'
}
});
if (!bannedWordsRes.ok) {
throw new Error('금지어 목록을 가져올 수 없습니다');
}
const bannedWords = await bannedWordsRes.json();
await logger.log(LOG_LEVELS.INFO, '금지어 목록 로드 완료', { count: bannedWords.length });
displayBannedWords(bannedWords);
}
// 금지어 목록 표시
function displayBannedWords(bannedWords) {
const tbody = document.getElementById("banned-words-tbody");
if (bannedWords.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center; color: #666;">등록된 금지어가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = bannedWords.map(word => `
<tr data-word-id="${word.id}">
<td>${word.banned_word}</td>
<td>
<input type="number" class="grade-input" value="${word.grade || 0}"
data-word-id="${word.id}" data-original-grade="${word.grade || 0}">
</td>
<td>
<button class="action-btn view-btn" onclick="viewKiprisResults(${word.id}, '${word.banned_word}')">보기</button>
<button class="action-btn edit-btn" onclick="updateGrade(${word.id})">수정</button>
<button class="action-btn delete-btn" onclick="deleteBannedWord(${word.id}, '${word.banned_word}')">삭제</button>
</td>
</tr>
`).join('');
logger.log(LOG_LEVELS.DEBUG, '금지어 목록 UI 업데이트 완료');
}
// 등급 수정
async function updateGrade(wordId) {
await logger.log(LOG_LEVELS.INFO, '등급 수정 시작', { wordId });
const input = document.querySelector(`input[data-word-id="${wordId}"]`);
const newGrade = parseInt(input.value);
const originalGrade = parseInt(input.dataset.originalGrade);
if (newGrade === originalGrade) {
await logger.log(LOG_LEVELS.DEBUG, '등급 변경 없음');
return;
}
if (isNaN(newGrade) || newGrade < 0) {
alert('올바른 등급을 입력하세요 (0 이상의 숫자)');
input.value = originalGrade;
return;
}
try {
const { access_token } = await chrome.storage.local.get("access_token");
if (!access_token) {
throw new Error('로그인이 필요합니다');
}
const updateRes = await fetch(`${SUPABASE_URL}/rest/v1/users_banned_words?id=eq.${wordId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${access_token}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({ grade: newGrade })
});
if (!updateRes.ok) {
throw new Error('등급 수정에 실패했습니다');
}
input.dataset.originalGrade = newGrade;
await logger.log(LOG_LEVELS.INFO, '등급 수정 완료', { wordId, newGrade });
alert('등급이 성공적으로 수정되었습니다.');
} catch (error) {
await logger.log(LOG_LEVELS.ERROR, '등급 수정 실패', { error: error.message });
alert('등급 수정 중 오류가 발생했습니다: ' + error.message);
input.value = originalGrade;
}
}
// 금지어 삭제
async function deleteBannedWord(wordId, bannedWord) {
await logger.log(LOG_LEVELS.INFO, '금지어 삭제 확인', { wordId, bannedWord });
if (!confirm(`'${bannedWord}' 금지어를 정말 삭제하시겠습니까?\n\n삭제된 금지어는 복구할 수 없습니다.`)) {
await logger.log(LOG_LEVELS.DEBUG, '금지어 삭제 취소됨');
return;
}
try {
const { access_token } = await chrome.storage.local.get("access_token");
if (!access_token) {
throw new Error('로그인이 필요합니다');
}
const deleteRes = await fetch(`${SUPABASE_URL}/rest/v1/users_banned_words?id=eq.${wordId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${access_token}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json'
}
});
if (!deleteRes.ok) {
throw new Error('금지어 삭제에 실패했습니다');
}
// 테이블에서 해당 행 제거
const row = document.querySelector(`tr[data-word-id="${wordId}"]`);
if (row) {
row.remove();
}
await logger.log(LOG_LEVELS.INFO, '금지어 삭제 완료', { wordId, bannedWord });
alert('금지어가 성공적으로 삭제되었습니다.');
// 목록이 비어있으면 메시지 표시
const tbody = document.getElementById("banned-words-tbody");
if (tbody.children.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center; color: #666;">등록된 금지어가 없습니다.</td></tr>';
}
} catch (error) {
await logger.log(LOG_LEVELS.ERROR, '금지어 삭제 실패', { error: error.message });
alert('금지어 삭제 중 오류가 발생했습니다: ' + error.message);
}
}
// 키프리스 결과 보기
async function viewKiprisResults(wordId, bannedWord) {
await logger.log(LOG_LEVELS.INFO, '키프리스 결과 조회 시작', { wordId, bannedWord });
const modal = document.getElementById("kipris-modal");
const loading = document.getElementById("kipris-loading");
const results = document.getElementById("kipris-results");
const title = document.getElementById("kipris-word-title");
// 모달 표시
modal.style.display = "block";
loading.style.display = "block";
results.innerHTML = "";
title.innerHTML = `<h4>🔍 "${bannedWord}" 키프리스 검색 결과</h4>`;
try {
const { access_token } = await chrome.storage.local.get("access_token");
if (!access_token) {
throw new Error('로그인이 필요합니다');
}
await loadKiprisResults(access_token, wordId);
} catch (error) {
await logger.log(LOG_LEVELS.ERROR, '키프리스 결과 로드 실패', { error: error.message });
results.innerHTML = `<div style="text-align: center; color: red; padding: 20px;">오류: ${error.message}</div>`;
} finally {
loading.style.display = "none";
}
}
// 키프리스 결과 로드
async function loadKiprisResults(token, wordId) {
await logger.log(LOG_LEVELS.INFO, '키프리스 결과 API 호출', { wordId });
const kiprisRes = await fetch(`${SUPABASE_URL}/rest/v1/users_banned_words_for_kipris?select=*&banned_word_id=eq.${wordId}&order=created_at.desc`, {
headers: {
Authorization: `Bearer ${token}`,
apikey: SUPABASE_ANON_KEY,
'Content-Type': 'application/json'
}
});
if (!kiprisRes.ok) {
throw new Error('키프리스 결과를 가져올 수 없습니다');
}
const kiprisData = await kiprisRes.json();
await logger.log(LOG_LEVELS.INFO, '키프리스 결과 로드 완료', { count: kiprisData.length });
displayKiprisResults(kiprisData);
}
// 키프리스 결과 표시
function displayKiprisResults(kiprisData) {
const results = document.getElementById("kipris-results");
if (kiprisData.length === 0) {
results.innerHTML = '<div style="text-align: center; color: #666; padding: 20px;">키프리스 검색 결과가 없습니다.</div>';
return;
}
results.innerHTML = kiprisData.map((item, index) => `
<div class="kipris-item">
<h4>검색 결과 ${index + 1}</h4>
<div class="kipris-field">
<strong>출원상태:</strong> ${item.application_status || ' '}
</div>
<div class="kipris-field">
<strong>출원인:</strong> ${item.applicant_name || ' '}
</div>
<div class="kipris-field">
<strong>분류:</strong> ${item.category_description || ' '}
</div>
${item.drawing ? `
<div class="kipris-field">
<strong>도면:</strong><br>
<img src="${item.drawing}" alt="상표 도면" class="kipris-drawing" onerror="this.style.display='none'">
</div>
` : ''}
</div>
`).join('');
logger.log(LOG_LEVELS.DEBUG, '키프리스 결과 UI 업데이트 완료');
}

688
sayings.html Normal file
View File

@ -0,0 +1,688 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>어록 관리</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f8f9fa;
line-height: 1.6;
}
h2 {
text-align: center;
margin-bottom: 20px;
color: #2c3e50;
}
/* 필터 섹션 스타일 */
.filter-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.filter-row {
display: flex;
gap: 15px;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.filter-group label {
font-size: 14px;
font-weight: bold;
color: #555;
}
.filter-group select,
.filter-group input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.filter-buttons {
display: flex;
gap: 10px;
align-items: end;
}
.filter-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
}
.filter-btn.primary {
background: #3498db;
color: white;
}
.filter-btn.secondary {
background: #95a5a6;
color: white;
}
.filter-btn:hover {
opacity: 0.8;
}
/* 통계 정보 스타일 */
.stats-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin-bottom: 20px;
font-size: 14px;
font-weight: bold;
color: #495057;
display: flex;
justify-content: space-between;
align-items: center;
}
/* 어록 추가 버튼 스타일 */
.add-saying-btn {
padding: 10px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
}
.add-saying-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}
.add-saying-btn:active {
transform: translateY(0);
}
/* 어록 카드 컨테이너 */
.sayings-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-top: 20px;
}
/* 어록 카드 스타일 */
.saying-card {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-left: 4px solid #007bff;
transition: all 0.3s ease;
}
.saying-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
}
.saying-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.saying-title {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
margin: 0;
flex: 1;
}
.saying-meta {
display: flex;
gap: 8px;
flex-shrink: 0;
margin-left: 12px;
}
.saying-category, .saying-target {
background: #f8f9fa;
color: #495057;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.saying-category {
background: #e3f2fd;
color: #1976d2;
}
.saying-target {
background: #f3e5f5;
color: #7b1fa2;
}
.saying-content {
margin: 12px 0;
line-height: 1.6;
color: #2c3e50;
}
.saying-content h1, .saying-content h2, .saying-content h3 {
margin-top: 16px;
margin-bottom: 8px;
}
.saying-content p {
margin-bottom: 8px;
}
.saying-content blockquote {
border-left: 4px solid #007bff;
padding-left: 16px;
margin: 12px 0;
color: #6c757d;
font-style: italic;
}
.saying-content code {
background: #f8f9fa;
padding: 2px 4px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.saying-content pre {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
}
.saying-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e9ecef;
font-size: 14px;
color: #6c757d;
}
.saying-author {
font-weight: 500;
}
.saying-date {
color: #adb5bd;
}
/* 로딩 표시 */
.loading {
text-align: center;
padding: 40px;
font-size: 16px;
color: #3498db;
}
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-state .title {
font-size: 18px;
margin-bottom: 8px;
}
.empty-state .description {
font-size: 14px;
color: #999;
}
/* 모달 스타일 */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
min-width: 500px;
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.modal-title {
font-size: 20px;
font-weight: bold;
color: #2c3e50;
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #555;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
padding: 10px 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
min-height: 120px;
resize: vertical;
font-family: inherit;
}
.form-select {
width: 100%;
padding: 10px 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: white;
}
.form-help {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.markdown-preview {
border: 2px solid #ddd;
border-radius: 4px;
padding: 10px 12px;
min-height: 120px;
background: #f9f9f9;
margin-top: 10px;
}
.preview-tabs {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.preview-tab {
padding: 6px 12px;
border: 1px solid #ddd;
background: #f8f9fa;
cursor: pointer;
border-radius: 4px;
font-size: 12px;
}
.preview-tab.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.modal-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: background 0.2s;
}
.btn-primary {
background: #28a745;
color: white;
}
.btn-primary:hover {
background: #218838;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
/* 디버그 정보 */
.debug-info {
margin-top: 20px;
padding: 10px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
font-size: 12px;
color: #6c757d;
}
.debug-info button {
margin-top: 10px;
padding: 5px 10px;
font-size: 12px;
background: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.debug-info button:hover {
background: #005a9e;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.sayings-container {
grid-template-columns: 1fr;
}
.filter-row {
flex-direction: column;
align-items: stretch;
}
.filter-buttons {
justify-content: center;
}
.stats-info {
flex-direction: column;
gap: 15px;
text-align: center;
}
.modal-content {
min-width: 90%;
margin: 20px;
}
.saying-meta {
justify-content: center;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 {
margin: 0;
color: #2c3e50;
font-size: 24px;
}
.header-right {
display: flex;
align-items: center;
gap: 15px;
}
.current-user {
padding: 8px 12px;
background: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 20px;
font-size: 14px;
color: #1976d2;
font-weight: 500;
}
</style>
</head>
<body>
<div class="header">
<h1>📚 어록 관리</h1>
<div class="header-right">
<div id="current-user" class="current-user">👤 로딩 중...</div>
<button id="add-saying-btn" class="add-saying-btn">✨ 어록 등록</button>
</div>
</div>
<!-- 디버그 정보 -->
<div class="debug-info" id="debug-info" style="display: block;">
초기화 중...
<br>
<button id="log-check-btn" style="margin-top: 10px; padding: 5px 10px; font-size: 12px;">📋 로그 확인</button>
</div>
<!-- 필터 섹션 -->
<div class="filter-section">
<div class="filter-row">
<div class="filter-group">
<label for="category-filter">카테고리</label>
<select id="category-filter" class="filter-select">
<option value="all">모든 카테고리</option>
<!-- 동적으로 추가됨 -->
</select>
</div>
<div class="filter-group">
<label for="target-filter">타겟</label>
<select id="target-filter" class="filter-select">
<option value="all">모든 타겟</option>
<!-- 동적으로 추가됨 -->
</select>
</div>
<div class="filter-group">
<label for="date-filter">기간</label>
<select id="date-filter" class="filter-select">
<option value="all">전체 기간</option>
<option value="today">오늘</option>
<option value="week">최근 1주일</option>
<option value="month">최근 1개월</option>
</select>
</div>
<div class="filter-group">
<label for="search-input">검색</label>
<input type="text" id="search-input" placeholder="어록 검색..." class="search-input">
</div>
<div class="filter-buttons">
<button class="filter-btn primary" id="apply-filter">🔍 필터 적용</button>
<button class="filter-btn secondary" id="reset-filters">🔄 초기화</button>
</div>
</div>
</div>
<!-- 통계 정보 -->
<div id="sayings-stats" class="stats-info">
<div>
<!-- 통계 정보가 여기에 표시됩니다 -->
</div>
<button class="add-saying-btn" id="add-saying-btn">
어록 등록
</button>
</div>
<!-- 어록 등록 모달 -->
<div id="add-saying-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title"> 새 어록 등록</h3>
<button class="modal-close" id="modal-close">&times;</button>
</div>
<form id="saying-form">
<div class="form-group">
<label class="form-label" for="saying-title">어록 제목</label>
<input type="text" id="saying-title" class="form-input" placeholder="어록의 제목을 입력하세요" required>
<div class="form-help">어록을 대표하는 제목을 입력해주세요.</div>
</div>
<div class="form-group">
<label class="form-label" for="saying-content">어록 내용</label>
<div class="preview-tabs">
<div class="preview-tab active" data-tab="edit">✏️ 편집</div>
<div class="preview-tab" data-tab="preview">👁️ 미리보기</div>
</div>
<textarea id="saying-content" class="form-textarea" placeholder="어록 내용을 입력하세요 (마크다운 지원)" required></textarea>
<div id="markdown-preview" class="markdown-preview" style="display: none;"></div>
<div class="form-help">
💡 마크다운 문법을 사용할 수 있습니다.
<strong>**굵게**</strong>, <em>*기울임*</em>,
<code>`코드`</code>, 링크 등을 지원합니다.
</div>
</div>
<div class="form-group">
<label class="form-label" for="saying-category">카테고리</label>
<select id="saying-category" class="form-select" required>
<option value="">카테고리를 선택하세요</option>
<!-- 동적으로 로드됩니다 -->
</select>
<div class="form-help">어록의 성격에 맞는 카테고리를 선택해주세요.</div>
</div>
<div class="form-group">
<label class="form-label" for="saying-target">타겟</label>
<select id="saying-target" class="form-select" required>
<option value="">타겟을 선택하세요</option>
<!-- 동적으로 로드됩니다 -->
</select>
<div class="form-help">어록의 대상을 선택해주세요. (기본값: 누구나)</div>
</div>
<div class="form-group">
<label class="form-label">등록자</label>
<div id="modal-current-user" style="padding: 10px; background: #f8f9fa; border-radius: 4px; color: #666;">
로그인된 사용자 정보를 가져오는 중...
</div>
<div class="form-help">현재 로그인한 사용자 이름이 자동으로 등록됩니다.</div>
</div>
</form>
<div class="modal-buttons">
<button type="button" class="btn btn-secondary" id="cancel-btn">취소</button>
<button type="submit" class="btn btn-primary" id="submit-btn">📝 등록하기</button>
</div>
</div>
</div>
<!-- 어록 컨테이너 -->
<div id="sayings-container" class="sayings-container">
<!-- 동적으로 생성됨 -->
</div>
<div class="loading" id="sayings-loading" style="display: none;">
🔄 어록을 불러오는 중...
</div>
<!-- 마크다운 라이브러리를 직접 로드 -->
<script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
<script src="sayings.js"></script>
</body>
</html>

1699
sayings.js Normal file

File diff suppressed because it is too large Load Diff