Compare commits

...

13 Commits

Author SHA1 Message Date
9700X_PC e2d353afe3 Implement background message handlers for user info, sayings, and banned words management. Refactor settings and API calls to streamline data retrieval through background script. Update UI elements and improve error handling across various modules. 2025-11-27 23:37:57 +09:00
9700X_PC bdde76d7a6 Merge branch 'extended' of ssh://cckb9998.synology.me:30022/ckh08045/SearchTrademark into extended 2025-11-27 15:05:01 +09:00
9700X_PC 9f41be733c Supabase URL 및 ANON KEY를 안전한 HTTPS 주소로 변경. 모든 관련 파일에서 HTTP 주소를 HTTPS로 업데이트하여 보안 강화. 2025-11-27 15:04:42 +09:00
Envy_PC 312b13a73f 백그라운드 스크립트에서 자동 품앗이 알람 기능 개선 및 UI 요소 업데이트. 설정 페이지에서 품앗이 찜 기능과 포모도로 기능의 사용자 경험 향상. 2025-07-19 22:38:57 +09:00
Envy_PC a0af4de657 Merge branch 'extended' of ssh://cckb9998.synology.me:30022/ckh08045/SearchTrademark into extended 2025-07-19 18:32:22 +09:00
Envy_PC 3c8f0419b9 API 키 파일 삭제 및 자동 품앗이 기능 추가. 백그라운드 스크립트에서 1시간마다 자동 품앗이 알람 설정, 품앗이 찜 기능 구현. 설정 페이지에서 포모도로 기능 추가 및 관련 UI 개선. 2025-07-19 18:32:19 +09:00
9700X_PC a532433d9a 찜하기 완료 후 2초 후 자동으로 창을 닫는 기능 추가 2025-07-13 00:13:52 +09:00
9700X_PC 7e9dc74a61 지재권 검색 및 번역 기능 개선. 선택된 텍스트 감지 방식을 다각화하고, 클립보드에서 텍스트를 가져오는 기능 추가. UI 요소 및 스타일 개선, 찜하기 기능에 대한 설정 옵션 추가. 2025-06-26 23:56:05 +09:00
9700X_PC 08759f6615 반영 2025-06-25 23:35:22 +09:00
9700X_PC 5d932a87c5 API 키 관리 방식 변경 및 시간 알람 기능 추가. Supabase를 통한 API 키 조회 로직 구현, 설정 페이지에서 시간 알람 설정 관리 기능 추가. UI 텍스트 및 버튼 이름을 '타냐대장경'으로 변경하여 일관성 유지. 2025-06-24 16:12:48 +09:00
9700X_PC 881da5efde 컨텍스트 메뉴에 멀티번역 기능 추가 및 단축키 설정. 로딩 인디케이터 구현으로 사용자 경험 개선. 번역 결과 표시를 위한 툴팁 기능 추가. 설정 페이지 및 찜 관리 기능 구현. 2025-06-24 02:18:31 +09:00
9700X_PC 8f142399ff 블록 기반 에디터 기능 추가 및 기존 텍스트 에디터와의 하위 호환성 유지. 어록 상세 보기 및 편집 모달 구현, 이미지 업로드 및 미리보기 기능 개선. 2025-06-23 22:33:51 +09:00
9700X_PC 5ea5f1ec23 백그라운드 스크립트에서 새 어록 확인 기능 개선 및 안전한 스토리지 접근 방식 적용. 금지어 추가 기능 구현 및 사용자 ID 저장 로직 추가. 2025-06-23 01:58:18 +09:00
53 changed files with 15669 additions and 1195 deletions

View File

@ -0,0 +1,4 @@
chrome.runtime.onInstalled.addListener(() => {
console.log("찜하기 자동화 확장 프로그램이 설치되었습니다.");
});

View File

@ -0,0 +1,166 @@
(() => {
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
window.alert = function(msg) {
console.warn("차단된 alert:", msg);
};
if (window.__zzimAutomationRunning) {
console.warn("이미 실행 중인 자동화 스크립트가 있습니다.");
return;
}
window.__zzimAutomationRunning = true;
let isRunning = true;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
isRunning = false;
console.log("찜하기 자동화 중단됨 (ESC)");
}
});
function getStoreName() {
const el = document.querySelector('.KasFrJs3SA');
return el ? el.innerText.trim() : "스토어";
}
function waitForZzimButtons(callback) {
const check = () => {
const buttons = document.querySelectorAll('button.zzim_button[type="button"]');
if (buttons.length > 0) {
callback(buttons);
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
}
async function waitForButtonToChange(btn, timeout = 1000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const pressed = btn.getAttribute("aria-pressed");
const label = btn.innerText?.trim();
if (pressed === "true" || (label && !label.includes("찜하기"))) {
return true;
}
await sleep(50);
}
return false;
}
function createProgressUI() {
const ui = document.createElement("div");
ui.id = "zzim-progress-ui";
ui.style.position = "fixed";
ui.style.bottom = "20px";
ui.style.right = "20px";
ui.style.background = "#222";
ui.style.color = "#fff";
ui.style.padding = "10px 15px";
ui.style.borderRadius = "8px";
ui.style.zIndex = "99999";
ui.style.fontSize = "14px";
ui.style.whiteSpace = "pre-line";
ui.style.boxShadow = "0 0 5px rgba(0,0,0,0.5)";
ui.innerText = "찜하기 자동화 시작...";
document.body.appendChild(ui);
return ui;
}
async function runAutomation(startPage, finalPage) {
const url = new URL(window.location.href);
const storeName = getStoreName();
let currentPage = parseInt(url.searchParams.get("page") || startPage);
let totalClicked = 0;
const progressUI = createProgressUI();
const today = new Date().toISOString().split("T")[0];
if (currentPage > finalPage) {
progressUI.innerText = `${storeName}
페이지 ${finalPage} / ${finalPage}
완료: 0
완료`;
alert(`${storeName} 작업 이미 완료됨`);
return;
}
while (isRunning && currentPage <= finalPage) {
progressUI.innerText = `${storeName}
페이지 ${currentPage} / ${finalPage}
완료: ${totalClicked}
진행중`;
await new Promise(resolve => waitForZzimButtons(resolve));
const buttons = Array.from(document.querySelectorAll('button.zzim_button[type="button"]'));
for (let i = 0; i < buttons.length; i++) {
const btn = buttons[i];
if (!isRunning) break;
const pressed = btn.getAttribute("aria-pressed");
const label = btn.innerText?.trim();
if (pressed === "false" && label.includes("찜하기")) {
try {
btn.click();
const changed = await waitForButtonToChange(btn);
if (changed) {
totalClicked++;
}
progressUI.innerText = `${storeName}
페이지 ${currentPage} / ${finalPage}
완료: ${totalClicked}
진행중`;
await sleep(200);
} catch (e) {
console.warn("❌ 클릭 실패:", e);
}
}
}
if (!isRunning || currentPage >= finalPage) break;
const nextPage = currentPage + 1;
url.searchParams.set("page", nextPage);
await sleep(1000);
window.location.href = url.toString();
setTimeout(() => document.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter"})), 1500);
return;
}
// 🔒 루프 종료 후 항상 최종 처리
const now = new Date();
const timestamp = now.getFullYear() + "-" +
String(now.getMonth()+1).padStart(2, '0') + "-" +
String(now.getDate()).padStart(2, '0') + " " +
String(now.getHours()).padStart(2, '0') + ":" +
String(now.getMinutes()).padStart(2, '0');
progressUI.innerText = `${storeName}
페이지 ${currentPage} / ${finalPage}
완료: ${totalClicked}
완료`;
alert(`${storeName}\n${timestamp}\n${totalClicked}개 찜 완료`);
}
chrome.storage.local.get(["isZzimRun", "pageCount", "_zzimStartPage", "_zzimFinalPage"], (res) => {
if (!res.isZzimRun) return;
// 제거는 완료 시점에만 수행됨
const url = new URL(window.location.href);
const current = parseInt(url.searchParams.get("page") || "1");
if (!res._zzimStartPage || !res._zzimFinalPage) {
const startPage = current;
const finalPage = startPage + (res.pageCount || 1) - 1;
chrome.storage.local.set({ _zzimStartPage: startPage, _zzimFinalPage: finalPage }, () => {
runAutomation(startPage, finalPage);
});
} else {
runAutomation(res._zzimStartPage, res._zzimFinalPage);
}
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

View File

@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "찜하기 자동화",
"version": "1.0",
"description": "네이버 스마트스토어에서 찜하기 버튼을 자동으로 누릅니다.",
"permissions": ["scripting", "storage", "tabs"],
"host_permissions": ["https://smartstore.naver.com/*"],
"action": {
"default_icon": {
"16": "icon.png"
},
"default_title": "찜하기 자동화",
"default_popup": "options.html"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://smartstore.naver.com/*"],
"js": ["content.js"]
}
]
}

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
body { font-family: sans-serif; padding: 10px; }
.title { color: red; font-weight: bold; font-size: 18px; }
.label { color: red; margin-top: 10px; display: block; }
</style>
</head>
<body>
<div class="title"></div>
<label class="label">페이지 수 :
<input id="pageCount" type="number" value="20">
</label>
<button id="runBtn" style="width:60px; height:30px;">실행</button>
<button id="stopBtn" style="width:60px; height:30px;">종료</button></button>
<script src="options.js"></script>
</body>
</html>

View File

@ -0,0 +1,23 @@
document.getElementById("runBtn").addEventListener("click", () => {
const pageCount = parseInt(document.getElementById("pageCount").value);
const startedAt = new Date().toISOString().slice(0, 16).replace("T", " ");
chrome.storage.local.remove(["_zzimStartPage", "_zzimFinalPage", "isZzimRun", "startedAt"], () => {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs[0]) {
chrome.storage.local.set({ pageCount, isZzimRun: true, startedAt }, () => {
chrome.scripting.executeScript({
target: { tabId: tabs[0].id },
files: ["content.js"]
});
});
}
});
});
});
document.getElementById("stopBtn").addEventListener("click", () => {
chrome.storage.local.set({ isZzimRun: false }, () => {
alert("자동화가 종료되었습니다.");
});
});

File diff suppressed because it is too large Load Diff

View File

@ -2,12 +2,12 @@
class BannedWordsManager { class BannedWordsManager {
constructor() { constructor() {
// 초기값 설정 (chrome.storage에서 로드될 때까지 임시) // 초기값 설정 (chrome.storage에서 로드될 때까지 임시)
this.SUPABASE_URL = null; // SUPABASE 설정은 background.js에서 중앙 관리됨
this.SUPABASE_ANON_KEY = null;
this.DEBUG_MODE = true; this.DEBUG_MODE = true;
this.ACCESS_TOKEN = null; this.ACCESS_TOKEN = null;
this.isConfigLoaded = false; this.isConfigLoaded = false;
this.buttonEventListenerAttached = false; // 이벤트 리스너 중복 등록 방지 this.buttonEventListenerAttached = false; // 이벤트 리스너 중복 등록 방지
this.userId = null; // 사용자 ID 저장
this.debugLog('BannedWordsManager 생성자 시작 - 설정 로드 대기 중'); this.debugLog('BannedWordsManager 생성자 시작 - 설정 로드 대기 중');
@ -60,8 +60,6 @@ class BannedWordsManager {
if (configData.bannedWords_config) { if (configData.bannedWords_config) {
const config = configData.bannedWords_config; const config = configData.bannedWords_config;
this.debugLog('bannedWords_config에서 설정 로드', { this.debugLog('bannedWords_config에서 설정 로드', {
hasUrl: !!config.SUPABASE_URL,
hasKey: !!config.SUPABASE_ANON_KEY,
hasToken: !!config.ACCESS_TOKEN, hasToken: !!config.ACCESS_TOKEN,
debugMode: config.DEBUG_MODE, debugMode: config.DEBUG_MODE,
timestamp: config.timestamp, timestamp: config.timestamp,
@ -73,8 +71,6 @@ class BannedWordsManager {
this.debugLog('⚠️ 설정이 오래되었습니다', { age: Date.now() - config.timestamp }); this.debugLog('⚠️ 설정이 오래되었습니다', { age: Date.now() - config.timestamp });
} }
this.SUPABASE_URL = config.SUPABASE_URL;
this.SUPABASE_ANON_KEY = config.SUPABASE_ANON_KEY;
this.DEBUG_MODE = config.DEBUG_MODE !== undefined ? config.DEBUG_MODE : true; this.DEBUG_MODE = config.DEBUG_MODE !== undefined ? config.DEBUG_MODE : true;
this.ACCESS_TOKEN = config.ACCESS_TOKEN; this.ACCESS_TOKEN = config.ACCESS_TOKEN;
@ -82,41 +78,25 @@ class BannedWordsManager {
// 2. 개별 설정 확인 (fallback) // 2. 개별 설정 확인 (fallback)
this.debugLog('bannedWords_config가 없음, 개별 설정 확인 중'); this.debugLog('bannedWords_config가 없음, 개별 설정 확인 중');
const storageData = await chrome.storage.local.get([ const storageData = await chrome.storage.local.get(['access_token']);
'access_token',
'SUPABASE_URL',
'SUPABASE_ANON_KEY'
]);
this.debugLog('개별 설정 조회 결과', { this.debugLog('개별 설정 조회 결과', {
hasToken: !!storageData.access_token, hasToken: !!storageData.access_token
hasUrl: !!storageData.SUPABASE_URL,
hasKey: !!storageData.SUPABASE_ANON_KEY
}); });
// 기본값 설정
this.SUPABASE_URL = storageData.SUPABASE_URL || 'http://146.56.101.199:8000';
this.SUPABASE_ANON_KEY = storageData.SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey AgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE';
this.ACCESS_TOKEN = storageData.access_token; this.ACCESS_TOKEN = storageData.access_token;
} }
this.isConfigLoaded = true; this.isConfigLoaded = true;
this.debugLog('설정 로드 완료', { this.debugLog('설정 로드 완료', {
SUPABASE_URL: this.SUPABASE_URL,
hasToken: !!this.ACCESS_TOKEN, hasToken: !!this.ACCESS_TOKEN,
tokenLength: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.length : 0, tokenLength: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.length : 0,
DEBUG_MODE: this.DEBUG_MODE, DEBUG_MODE: this.DEBUG_MODE,
isConfigLoaded: this.isConfigLoaded isConfigLoaded: this.isConfigLoaded
}); });
// 설정 검증 // 설정 검증 - SUPABASE 설정은 background.js에서 관리
if (!this.SUPABASE_URL) {
throw new Error('SUPABASE_URL이 설정되지 않았습니다');
}
if (!this.SUPABASE_ANON_KEY) {
throw new Error('SUPABASE_ANON_KEY가 설정되지 않았습니다');
}
if (!this.ACCESS_TOKEN) { if (!this.ACCESS_TOKEN) {
throw new Error('ACCESS_TOKEN이 없습니다. 로그인이 필요합니다'); throw new Error('ACCESS_TOKEN이 없습니다. 로그인이 필요합니다');
} }
@ -197,96 +177,41 @@ class BannedWordsManager {
// 토큰 유효성 검증 // 토큰 유효성 검증
async validateToken() { async validateToken() {
try { try {
this.debugLog('토큰 검증 API 호출 준비', { this.debugLog('토큰 검증 API 호출 준비 (background.js 경유)', {
url: `${this.SUPABASE_URL}/auth/v1/user`,
hasToken: !!this.ACCESS_TOKEN, hasToken: !!this.ACCESS_TOKEN,
tokenLength: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.length : 0, tokenLength: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.length : 0,
tokenPreview: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.substring(0, 30) + '...' : 'none' tokenPreview: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.substring(0, 30) + '...' : 'none'
}); });
const headers = { // background.js를 통해 토큰 검증
'Authorization': `Bearer ${this.ACCESS_TOKEN}`, const response = await chrome.runtime.sendMessage({
'apikey': this.SUPABASE_ANON_KEY, action: 'validateToken',
'Content-Type': 'application/json', token: this.ACCESS_TOKEN
'Accept': 'application/json'
};
this.debugLog('요청 헤더 정보', {
hasAuthorization: !!headers.Authorization,
hasApikey: !!headers.apikey,
authPreview: headers.Authorization ? headers.Authorization.substring(0, 20) + '...' : 'none'
});
const authRes = await fetch(`${this.SUPABASE_URL}/auth/v1/user`, {
method: 'GET',
headers: headers,
mode: 'cors', // CORS 모드 명시적 설정
credentials: 'omit' // 크로스 오리진 요청에서 자격 증명 제외
}); });
this.debugLog('토큰 검증 API 응답', { this.debugLog('토큰 검증 API 응답', {
status: authRes.status, success: response?.success,
statusText: authRes.statusText, hasUser: !!response?.user
ok: authRes.ok,
type: authRes.type,
url: authRes.url,
headers: Object.fromEntries(authRes.headers.entries())
}); });
if (!authRes.ok) { if (!response || !response.success) {
let errorText = ''; throw new Error(response?.error || '토큰 검증 실패');
let errorJson = null;
try {
const responseText = await authRes.text();
errorText = responseText;
// JSON 파싱 시도
if (responseText.trim().startsWith('{')) {
errorJson = JSON.parse(responseText);
}
} catch (parseError) {
this.debugLog('응답 파싱 실패', { parseError: parseError.message });
} }
this.debugLog('토큰 검증 실패 - 상세 정보', { const userData = response.user;
status: authRes.status,
statusText: authRes.statusText,
errorText: errorText,
errorJson: errorJson
});
throw new Error(`토큰이 유효하지 않습니다 (${authRes.status}: ${authRes.statusText})\n응답: ${errorText}`);
}
const userData = await authRes.json();
this.debugLog('토큰 검증 성공', { this.debugLog('토큰 검증 성공', {
email: userData.email, email: userData.email,
id: userData.id, id: userData.id
userData: userData
}); });
return userData; return userData;
} catch (error) { } catch (error) {
this.debugLog('토큰 검증 중 오류 - 상세 분석', { this.debugLog('토큰 검증 중 오류', {
errorName: error.name, errorName: error.name,
errorMessage: error.message, errorMessage: error.message
errorStack: error.stack,
isNetworkError: error.name === 'TypeError' && error.message.includes('fetch'),
isCorsError: error.message.includes('CORS') || error.message.includes('cors'),
isTimeoutError: error.message.includes('timeout') || error.message.includes('Timeout')
}); });
// 에러 타입별 상세 메시지
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error(`네트워크 연결 오류: 서버에 연결할 수 없습니다.\n- URL: ${this.SUPABASE_URL}/auth/v1/user\n- 원본 에러: ${error.message}`);
} else if (error.message.includes('CORS')) {
throw new Error(`CORS 오류: 크로스 오리진 요청이 차단되었습니다.\n- 서버 CORS 설정을 확인해주세요\n- 원본 에러: ${error.message}`);
} else if (error.message.includes('timeout')) {
throw new Error(`요청 시간 초과: 서버 응답이 지연되고 있습니다.\n- 원본 에러: ${error.message}`);
}
throw error; throw error;
} }
} }
@ -309,7 +234,7 @@ class BannedWordsManager {
// 금지어 목록 로드 // 금지어 목록 로드
async loadBannedWords() { async loadBannedWords() {
this.debugLog('금지어 목록 로드 시작'); this.debugLog('금지어 목록 로드 시작 (background.js 경유)');
const loading = document.getElementById("banned-words-loading"); const loading = document.getElementById("banned-words-loading");
const tbody = document.getElementById("banned-words-tbody"); const tbody = document.getElementById("banned-words-tbody");
@ -318,117 +243,34 @@ class BannedWordsManager {
tbody.innerHTML = ""; tbody.innerHTML = "";
try { try {
// 공통 헤더 설정 // background.js를 통해 금지어 목록 조회
const headers = { const response = await chrome.runtime.sendMessage({
'Authorization': `Bearer ${this.ACCESS_TOKEN}`, action: 'getBannedWords',
'apikey': this.SUPABASE_ANON_KEY, token: this.ACCESS_TOKEN
'Content-Type': 'application/json',
'Accept': 'application/json'
};
const fetchOptions = {
method: 'GET',
headers: headers,
mode: 'cors',
credentials: 'omit'
};
// 현재 사용자의 ID 가져오기
this.debugLog('Auth API 호출 중...');
const authRes = await fetch(`${this.SUPABASE_URL}/auth/v1/user`, fetchOptions);
this.debugLog('Auth API 응답', {
status: authRes.status,
statusText: authRes.statusText,
ok: authRes.ok,
url: authRes.url
}); });
if (!authRes.ok) { this.debugLog('금지어 목록 API 응답', {
const errorDetail = await authRes.text(); success: response?.success,
this.debugLog('Auth API 에러 상세', { count: response?.bannedWords?.length
status: authRes.status,
error: errorDetail
}); });
throw new Error(`사용자 정보를 가져올 수 없습니다 (${authRes.status}: ${authRes.statusText})\n응답: ${errorDetail}`);
if (!response || !response.success) {
throw new Error(response?.error || '금지어 목록 조회 실패');
} }
const authUser = await authRes.json(); const bannedWords = response.bannedWords;
this.debugLog('Auth 사용자 정보 수신', { this.userId = response.userId; // 사용자 ID 저장 (추가 시 필요)
email: authUser.email,
id: authUser.id
});
// 사용자의 user_id 가져오기
this.debugLog('Users API 호출 중...');
const userRes = await fetch(`${this.SUPABASE_URL}/rest/v1/users?select=id&email=eq.${encodeURIComponent(authUser.email)}&limit=1`, fetchOptions);
this.debugLog('Users API 응답', {
status: userRes.status,
statusText: userRes.statusText,
ok: userRes.ok,
url: userRes.url
});
if (!userRes.ok) {
const errorDetail = await userRes.text();
this.debugLog('Users API 에러 상세', {
status: userRes.status,
error: errorDetail
});
throw new Error(`사용자 정보를 찾을 수 없습니다 (${userRes.status}: ${userRes.statusText})\n응답: ${errorDetail}`);
}
const userData = await userRes.json();
this.debugLog('Users 데이터 수신', {
count: userData.length,
data: userData
});
if (!userData || userData.length === 0) {
throw new Error('사용자 데이터를 찾을 수 없습니다');
}
const userId = userData[0].id;
this.debugLog('사용자 ID 확인', { userId });
// 금지어 목록 가져오기
this.debugLog('BannedWords API 호출 중...');
const bannedWordsRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words?select=*&user_id=eq.${userId}&order=created_at.desc`, fetchOptions);
this.debugLog('BannedWords API 응답', {
status: bannedWordsRes.status,
statusText: bannedWordsRes.statusText,
ok: bannedWordsRes.ok,
url: bannedWordsRes.url
});
if (!bannedWordsRes.ok) {
const errorDetail = await bannedWordsRes.text();
this.debugLog('BannedWords API 에러 상세', {
status: bannedWordsRes.status,
error: errorDetail
});
throw new Error(`금지어 목록을 가져올 수 없습니다 (${bannedWordsRes.status}: ${bannedWordsRes.statusText})\n응답: ${errorDetail}`);
}
const bannedWords = await bannedWordsRes.json();
this.debugLog('금지어 목록 로드 완료', { count: bannedWords.length }); this.debugLog('금지어 목록 로드 완료', { count: bannedWords.length });
this.displayBannedWords(bannedWords); this.displayBannedWords(bannedWords);
} catch (error) { } catch (error) {
this.debugLog('금지어 목록 로드 실패', { this.debugLog('금지어 목록 로드 실패', {
errorName: error.name, errorName: error.name,
errorMessage: error.message, errorMessage: error.message
isNetworkError: error.name === 'TypeError' && error.message.includes('fetch')
}); });
let errorMessage = error.message; tbody.innerHTML = `<tr><td colspan="4" style="text-align: center; color: red;">오류: ${error.message}</td></tr>`;
if (error.name === 'TypeError' && error.message.includes('fetch')) {
errorMessage = `네트워크 연결 오류: ${error.message}`;
}
tbody.innerHTML = `<tr><td colspan="4" style="text-align: center; color: red;">오류: ${errorMessage}</td></tr>`;
} finally { } finally {
loading.style.display = "none"; loading.style.display = "none";
} }
@ -723,35 +565,22 @@ class BannedWordsManager {
throw new Error('로그인이 필요합니다'); throw new Error('로그인이 필요합니다');
} }
this.debugLog('등급 업데이트 API 호출', { wordId, selectedGrade }); this.debugLog('등급 업데이트 API 호출 (background.js 경유)', { wordId, selectedGrade });
const updateRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words?word_id=eq.${wordId}`, { // background.js를 통해 등급 수정
method: 'PATCH', const response = await chrome.runtime.sendMessage({
headers: { action: 'updateBannedWordGrade',
'Authorization': `Bearer ${this.ACCESS_TOKEN}`, token: this.ACCESS_TOKEN,
'apikey': this.SUPABASE_ANON_KEY, wordId: wordId,
'Content-Type': 'application/json', grade: selectedGrade
'Accept': 'application/json'
},
body: JSON.stringify({
grade: selectedGrade,
updated_at: new Date().toISOString()
})
}); });
this.debugLog('등급 업데이트 API 응답', { this.debugLog('등급 업데이트 API 응답', {
status: updateRes.status, success: response?.success
statusText: updateRes.statusText,
ok: updateRes.ok
}); });
if (!updateRes.ok) { if (!response || !response.success) {
const errorDetail = await updateRes.text(); throw new Error(response?.error || '등급 업데이트 실패');
this.debugLog('등급 업데이트 실패', {
status: updateRes.status,
error: errorDetail
});
throw new Error(`등급 업데이트 실패 (${updateRes.status}: ${updateRes.statusText})\n응답: ${errorDetail}`);
} }
this.debugLog('등급 수정 성공', { wordId, selectedGrade }); this.debugLog('등급 수정 성공', { wordId, selectedGrade });
@ -828,31 +657,21 @@ class BannedWordsManager {
throw new Error('로그인이 필요합니다'); throw new Error('로그인이 필요합니다');
} }
this.debugLog('금지어 삭제 API 호출', { wordId, word }); this.debugLog('금지어 삭제 API 호출 (background.js 경유)', { wordId, word });
const deleteRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words?word_id=eq.${wordId}`, { // background.js를 통해 금지어 삭제
method: 'DELETE', const response = await chrome.runtime.sendMessage({
headers: { action: 'deleteBannedWord',
'Authorization': `Bearer ${this.ACCESS_TOKEN}`, token: this.ACCESS_TOKEN,
'apikey': this.SUPABASE_ANON_KEY, wordId: wordId
'Content-Type': 'application/json',
'Accept': 'application/json'
}
}); });
this.debugLog('금지어 삭제 API 응답', { this.debugLog('금지어 삭제 API 응답', {
status: deleteRes.status, success: response?.success
statusText: deleteRes.statusText,
ok: deleteRes.ok
}); });
if (!deleteRes.ok) { if (!response || !response.success) {
const errorDetail = await deleteRes.text(); throw new Error(response?.error || '금지어 삭제 실패');
this.debugLog('금지어 삭제 실패', {
status: deleteRes.status,
error: errorDetail
});
throw new Error(`금지어 삭제 실패 (${deleteRes.status}: ${deleteRes.statusText})\n응답: ${errorDetail}`);
} }
this.debugLog('금지어 삭제 성공', { wordId, word }); this.debugLog('금지어 삭제 성공', { wordId, word });
@ -914,42 +733,28 @@ class BannedWordsManager {
throw new Error('word_id가 없습니다. 데이터베이스에서 word_id 값을 확인해주세요.'); throw new Error('word_id가 없습니다. 데이터베이스에서 word_id 값을 확인해주세요.');
} }
this.debugLog('키프리스 데이터 조회 API 호출', { this.debugLog('키프리스 데이터 조회 API 호출 (background.js 경유)', {
wordWordId,
word,
queryUrl: `${this.SUPABASE_URL}/rest/v1/user_banned_words_kipris?select=*&banned_word_id=eq.${wordWordId}&order=created_at.desc`
});
// 올바른 테이블 이름과 필드명 사용: word_id → banned_word_id
const resultsRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words_kipris?select=*&banned_word_id=eq.${wordWordId}&order=created_at.desc`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
this.debugLog('키프리스 데이터 API 응답', {
status: resultsRes.status,
statusText: resultsRes.statusText,
ok: resultsRes.ok,
url: resultsRes.url
});
if (!resultsRes.ok) {
const errorDetail = await resultsRes.text();
this.debugLog('키프리스 데이터 조회 실패', {
status: resultsRes.status,
error: errorDetail,
wordWordId, wordWordId,
word word
}); });
throw new Error(`키프리스 결과를 가져올 수 없습니다 (${resultsRes.status}: ${resultsRes.statusText})\n응답: ${errorDetail}`);
// background.js를 통해 키프리스 결과 조회
const response = await chrome.runtime.sendMessage({
action: 'getKiprisResults',
token: this.ACCESS_TOKEN,
wordId: wordWordId
});
this.debugLog('키프리스 데이터 API 응답', {
success: response?.success,
count: response?.kiprisData?.length
});
if (!response || !response.success) {
throw new Error(response?.error || '키프리스 결과 조회 실패');
} }
const kiprisData = await resultsRes.json(); const kiprisData = response.kiprisData;
this.debugLog('키프리스 결과 로드 성공', { this.debugLog('키프리스 결과 로드 성공', {
count: kiprisData.length, count: kiprisData.length,
wordWordId, wordWordId,
@ -961,7 +766,8 @@ class BannedWordsManager {
hasClassificationCode: !!kiprisData[0].classification_code, hasClassificationCode: !!kiprisData[0].classification_code,
hasCategoryDescription: !!kiprisData[0].category_description, hasCategoryDescription: !!kiprisData[0].category_description,
hasDrawing: !!kiprisData[0].drawing, hasDrawing: !!kiprisData[0].drawing,
bannedWordId: kiprisData[0].banned_word_id bannedWordId: kiprisData[0].banned_word_id,
hasTradeMarkName: !!kiprisData[0].tradeMark_name
} : null } : null
}); });
@ -983,15 +789,50 @@ class BannedWordsManager {
<div style="background: #f0f8ff; padding: 12px; border-radius: 8px; margin-bottom: 16px; border-left: 4px solid #007bff;"> <div style="background: #f0f8ff; padding: 12px; border-radius: 8px; margin-bottom: 16px; border-left: 4px solid #007bff;">
<strong>📊 검색 결과 요약:</strong> ${kiprisData.length} . <strong>📊 검색 결과 요약:</strong> ${kiprisData.length} .
</div> </div>
${kiprisData.map((item, index) => ` ${kiprisData.map((item, index) => {
// 상표명 불일치 검사 (빈값이 아닐 때만)
const tradeMarkName = item.tradeMark_name;
const isNameMismatch = tradeMarkName &&
tradeMarkName.trim() !== '' &&
word &&
word.trim() !== '' &&
tradeMarkName.toLowerCase() !== word.toLowerCase() &&
!tradeMarkName.toLowerCase().includes(word.toLowerCase()) &&
!word.toLowerCase().includes(tradeMarkName.toLowerCase());
return `
<div class="kipris-item" style="border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-bottom: 16px; background: #f9f9f9;"> <div class="kipris-item" style="border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-bottom: 16px; background: #f9f9f9;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h4 style="margin: 0; color: #333;">📋 검색 결과 ${index + 1}</h4> <h4 style="margin: 0; color: #333;">📋 검색 결과 ${index + 1}</h4>
<small style="color: #666;">등록일: ${new Date(item.created_at).toLocaleDateString()}</small> <small style="color: #666;">등록일: ${new Date(item.created_at).toLocaleDateString()}</small>
</div> </div>
<!-- 상표명 불일치 경고 표시 -->
${isNameMismatch ? `
<div style="background: #ffebee; border: 1px solid #f44336; border-radius: 4px; padding: 12px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<span style="color: #f44336; font-size: 18px; margin-right: 8px;"></span>
<strong style="color: #f44336;">상표명 불일치 감지</strong>
</div>
<div style="font-size: 14px; color: #666;">
<div><strong>검색 키워드:</strong> "${word}"</div>
<div><strong>등록 상표명:</strong> <span style="color: red; font-weight: bold;">"${tradeMarkName}" ()</span></div>
</div>
</div>
` : ''}
<div class="kipris-content" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;"> <div class="kipris-content" style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px;">
<div class="kipris-info"> <div class="kipris-info">
<div class="kipris-field" style="margin-bottom: 12px;">
<strong style="color: #555;">🏷 상표명:</strong>
<div style="padding: 4px 8px; background: ${tradeMarkName ? '#fff3e0' : '#f5f5f5'}; border-radius: 4px; margin-top: 4px;">
${isNameMismatch ?
`<span style="color: red; font-weight: bold;">${tradeMarkName} 불일치(확인필요)</span>` :
(tradeMarkName || '정보 없음')
}
</div>
</div>
<div class="kipris-field" style="margin-bottom: 12px;"> <div class="kipris-field" style="margin-bottom: 12px;">
<strong style="color: #555;">📋 출원상태:</strong> <strong style="color: #555;">📋 출원상태:</strong>
<div style="padding: 4px 8px; background: ${item.application_status ? '#e3f2fd' : '#f5f5f5'}; border-radius: 4px; margin-top: 4px;"> <div style="padding: 4px 8px; background: ${item.application_status ? '#e3f2fd' : '#f5f5f5'}; border-radius: 4px; margin-top: 4px;">
@ -1055,7 +896,8 @@ class BannedWordsManager {
</div> </div>
</div> </div>
</div> </div>
`).join('')} `;
}).join('')}
</div> </div>
`; `;
@ -1092,9 +934,9 @@ class BannedWordsManager {
// 현재 상태 정보 수집 // 현재 상태 정보 수집
const statusInfo = { const statusInfo = {
'초기화 상태': this.isConfigLoaded ? '✅ 완료' : '❌ 미완료', '초기화 상태': this.isConfigLoaded ? '✅ 완료' : '❌ 미완료',
'SUPABASE_URL': this.SUPABASE_URL || '❌ 설정되지 않음',
'ACCESS_TOKEN': this.ACCESS_TOKEN ? `✅ 있음 (${this.ACCESS_TOKEN.length}자)` : '❌ 없음', 'ACCESS_TOKEN': this.ACCESS_TOKEN ? `✅ 있음 (${this.ACCESS_TOKEN.length}자)` : '❌ 없음',
'DEBUG_MODE': this.DEBUG_MODE ? '✅ 활성화' : '❌ 비활성화', 'DEBUG_MODE': this.DEBUG_MODE ? '✅ 활성화' : '❌ 비활성화',
'사용자 ID': this.userId || '❌ 없음',
'현재 시간': new Date().toLocaleString() '현재 시간': new Date().toLocaleString()
}; };
@ -1247,7 +1089,7 @@ class BannedWordsManager {
return; return;
} }
this.debugLog('추가할 금지어 목록', { wordsToAdd, selectedGrade, count: wordsToAdd.length }); this.debugLog('추가할 금지어 목록 (background.js 경유)', { wordsToAdd, selectedGrade, count: wordsToAdd.length });
try { try {
confirmBtn.textContent = '🔄 추가 중...'; confirmBtn.textContent = '🔄 추가 중...';
@ -1258,61 +1100,24 @@ class BannedWordsManager {
throw new Error('로그인이 필요합니다'); throw new Error('로그인이 필요합니다');
} }
// 현재 사용자 정보 가져오기 // 사용자 ID 확인 (loadBannedWords에서 저장됨)
const authRes = await fetch(`${this.SUPABASE_URL}/auth/v1/user`, { const userId = this.userId;
method: 'GET', if (!userId) {
headers: { throw new Error('사용자 ID를 찾을 수 없습니다. 페이지를 새로고침해주세요.');
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json'
} }
// background.js를 통해 기존 금지어 목록 조회 (중복 검사용)
const existingResponse = await chrome.runtime.sendMessage({
action: 'getExistingBannedWords',
token: this.ACCESS_TOKEN,
userId: userId
}); });
if (!authRes.ok) { if (!existingResponse || !existingResponse.success) {
throw new Error('사용자 인증 실패'); throw new Error(existingResponse?.error || '기존 금지어 목록 조회 실패');
} }
const authUser = await authRes.json(); const existingWords = existingResponse.existingWords;
// 사용자 ID 가져오기
const userRes = await fetch(`${this.SUPABASE_URL}/rest/v1/users?select=id&email=eq.${encodeURIComponent(authUser.email)}&limit=1`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Accept': '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;
// 기존 금지어 목록 가져오기 (중복 검사용)
const existingRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words?select=banned_word&user_id=eq.${userId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (!existingRes.ok) {
throw new Error('기존 금지어 목록 조회 실패');
}
const existingWords = await existingRes.json();
const existingWordSet = new Set(existingWords.map(item => item.banned_word.toLowerCase())); const existingWordSet = new Set(existingWords.map(item => item.banned_word.toLowerCase()));
// 중복 검사 // 중복 검사
@ -1374,32 +1179,22 @@ class BannedWordsManager {
created_at: new Date().toISOString() created_at: new Date().toISOString()
})); }));
this.debugLog('금지어 배치 추가 API 호출', { count: wordsData.length, grade: selectedGrade }); this.debugLog('금지어 배치 추가 API 호출 (background.js 경유)', { count: wordsData.length, grade: selectedGrade });
const addRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words`, { // background.js를 통해 금지어 추가
method: 'POST', const addResponse = await chrome.runtime.sendMessage({
headers: { action: 'addBannedWords',
'Authorization': `Bearer ${this.ACCESS_TOKEN}`, token: this.ACCESS_TOKEN,
'apikey': this.SUPABASE_ANON_KEY, userId: userId,
'Content-Type': 'application/json', wordsData: wordsData
'Accept': 'application/json'
},
body: JSON.stringify(wordsData)
}); });
this.debugLog('금지어 추가 API 응답', { this.debugLog('금지어 추가 API 응답', {
status: addRes.status, success: addResponse?.success
statusText: addRes.statusText,
ok: addRes.ok
}); });
if (!addRes.ok) { if (!addResponse || !addResponse.success) {
const errorDetail = await addRes.text(); throw new Error(addResponse?.error || '금지어 추가 실패');
this.debugLog('금지어 추가 실패', {
status: addRes.status,
error: errorDetail
});
throw new Error(`금지어 추가 실패 (${addRes.status}: ${addRes.statusText})\n응답: ${errorDetail}`);
} }
this.debugLog('금지어 추가 성공', { newWords, selectedGrade }); this.debugLog('금지어 추가 성공', { newWords, selectedGrade });

2375
content.js

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,32 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "내차는언제타냐 - 지재권 검색 확장 (컨텍스트 메뉴)", "name": "내차는언제타냐 통합 확장",
"version": "1.2", "version": "1.3",
"description": "드래그한 텍스트를 우클릭 → '지재권 검색'으로 MarkInfo 검색을 수행하고 결과를 툴팁으로 표시합니다.", "description": "드래그한 텍스트를 우클릭 → '지재권 검색'으로 MarkInfo 검색을 수행하고 결과를 툴팁으로 표시합니다.",
"permissions": [ "permissions": [
"storage",
"activeTab",
"contextMenus", "contextMenus",
"storage", "storage",
"notifications", "notifications",
"alarms", "alarms",
"activeTab" "tabs",
"scripting",
"clipboardRead"
], ],
"host_permissions": [ "host_permissions": [
"https://markinfo.kr/*", "*://markinfo.kr/*",
"http://146.56.101.199:8000/*" "https://ko.wrmc.cc/*",
"https://oci1ckh08045.duckdns.org:8000/*",
"*://smartstore.naver.com/*",
"*://translate.googleapis.com/*",
"*://api.mymemory.translated.net/*",
"https://market.m.taobao.com/*",
"*://openapi.naver.com/*",
"*://api-free.deepl.com/*",
"*://api.deepl.com/*",
"*://api.openai.com/*",
"*://generativelanguage.googleapis.com/*"
], ],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
@ -21,16 +35,57 @@
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["content.js"], "js": ["content.js"],
"all_frames": true,
"run_at": "document_end" "run_at": "document_end"
} }
], ],
"action": { "action": {
"default_popup": "popup.html", "default_popup": "popup.html",
"default_icon": "icon.png" "default_icon": "icon.png",
"default_title": "내차는언제타냐 통합 확장"
},
"commands": {
"trademark-search": {
"suggested_key": {
"default": "Ctrl+Shift+S"
},
"description": "지재권 검색"
},
"multi-translate": {
"suggested_key": {
"default": "Ctrl+Shift+E"
},
"description": "멀티번역"
},
"korean-to-chinese": {
"suggested_key": {
"default": "Ctrl+Shift+Z"
},
"description": "한국어↔중국어 양방향 번역"
},
"direct-translate": {
"suggested_key": {
"default": "Ctrl+Shift+K"
},
"description": "직번역 (텍스트 바로 대체)"
}
}, },
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["bannedWords.html", "bannedWords.js"], "resources": [
"bannedWords.html",
"bannedWords.js",
"sayings.html",
"sayings.js",
"zzim.html",
"zzim.js",
"settings.html",
"settings.js",
"rest-modal.html",
"rest-modal.js",
"manual.html",
"manual.js"
],
"matches": ["<all_urls>"] "matches": ["<all_urls>"]
} }
] ]

650
manual.html Normal file
View File

@ -0,0 +1,650 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>내차는언제타냐 통합확장기 매뉴얼</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.2rem;
opacity: 0.9;
}
.tabs-container {
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
overflow: hidden;
}
.tabs {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
overflow-x: auto;
}
.tab {
padding: 15px 25px;
cursor: pointer;
border: none;
background: none;
font-size: 16px;
font-weight: 500;
color: #6c757d;
transition: all 0.3s ease;
white-space: nowrap;
min-width: 150px;
}
.tab:hover {
background: #e9ecef;
color: #495057;
}
.tab.active {
background: #007bff;
color: white;
position: relative;
}
.tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: #0056b3;
}
.tab-content {
display: none;
padding: 30px;
min-height: 500px;
}
.tab-content.active {
display: block;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.feature-card {
background: #f8f9fa;
border-radius: 10px;
padding: 25px;
margin-bottom: 20px;
border-left: 5px solid #007bff;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.feature-card:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.feature-card h3 {
color: #007bff;
margin-bottom: 15px;
font-size: 1.4rem;
}
.feature-card p {
line-height: 1.6;
margin-bottom: 15px;
}
.feature-list {
list-style: none;
padding-left: 0;
}
.feature-list li {
padding: 8px 0;
border-bottom: 1px solid #e9ecef;
position: relative;
padding-left: 25px;
}
.feature-list li:before {
content: '✓';
position: absolute;
left: 0;
color: #28a745;
font-weight: bold;
}
.feature-list li:last-child {
border-bottom: none;
}
.screenshot {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
margin: 15px 0;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
transform: translateY(-1px);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
transform: translateY(-1px);
}
.alert {
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
}
.alert-info {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
}
.alert-warning {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.version-info {
background: #e9ecef;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
text-align: center;
}
.keyboard-shortcut {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 5px 10px;
font-family: monospace;
font-size: 0.9rem;
margin: 0 5px;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.header h1 {
font-size: 2rem;
}
.tab {
padding: 12px 15px;
font-size: 14px;
min-width: 120px;
}
.tab-content {
padding: 20px;
}
.feature-card {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📚 내차는언제타냐 통합확장기</h1>
<p>매뉴얼 및 기능 가이드</p>
</div>
<div class="tabs-container">
<div class="tabs">
<button class="tab active" data-tab="overview">🏠 개요</button>
<button class="tab" data-tab="trademark">🔍 지재권검색</button>
<button class="tab" data-tab="translation">🌐 멀티번역</button>
<button class="tab" data-tab="timer">⏰ 시간관리</button>
<button class="tab" data-tab="banned-words">🚫 금지어</button>
<button class="tab" data-tab="sayings">💬 타냐대장경</button>
<button class="tab" data-tab="settings">⚙️ 설정</button>
</div>
<div class="tab-content active" id="overview">
<h2>🏠 확장 프로그램 개요</h2>
<div class="alert alert-info">
<strong>환영합니다!</strong> 내차는언제타냐 통합확장기는 지재권검색, 멀티번역, 시간관리 등 다양한 기능을 제공하는 올인원 도구입니다.
</div>
<div class="alert alert-warning">
<strong>주의사항:</strong> 지재권검색 및 멀티번역은 API 호출량을 소모합니다. 회원등급에 따라 일일 사용량이 제한됩니다.
</div>
<div class="grid">
<div class="feature-card">
<h3>🔍 지재권검색 기능</h3>
<p>키프리스(KIPRIS) 지재권검색을 자동화하여 빠르고 정확한 상표조사를 지원합니다.</p>
<ul class="feature-list">
<li>검색할 단어를 드래그 후 단축키(컨트롤+쉬프트+S) 입력</li>
<li>또는 우클릭으로 해당 지재권을 Search하고 결과 수집</li>
<li>현재 활성된 상태의 결과만 필터링</li>
<li>해당하는 상품군을 바로 확인할 수 있음</li>
<li>내 금지어목록에 바로 적용 및 편집알바생과 연동</li>
</ul>
</div>
<div class="feature-card">
<h3>🌐 멀티번역 기능</h3>
<p>여러 언어로 동시 번역하여 애매한 중국어를 이해할수 있도록 지원합니다.</p>
<ul class="feature-list">
<li><strong>멀티번역:</strong> Ctrl+Shift+E</li>
<li>또는 우클릭으로 해당 문장을 Translate하고 결과 수집</li>
<li>무료로 구글번역과 메모리 번역지원</li>
<li>일정등급 이상은 딥러닝 번역 DeepL 사용가능</li>
<li>챗GPT로 의역 지원가능 (Gemini는 현재 점검 중)</li>
<li>원클릭으로 많은 엔진의 결과를 확인할수 있음</li>
</ul>
</div>
<div class="feature-card">
<h3>🌐 원키번역 기능</h3>
<p>구글 기계번역으로 매우 빠르게 번역할수 있습니다.</p>
<ul class="feature-list">
<li>번역할 문장이나 단어를 드래그 후 단축키(컨트롤+쉬프트+Z) 입력</li>
<li>웹페이지에서 바로 한글과 중국어를 빠르게 번역</li>
<li>위챗등의 대화에서 한글 입력 후 선택 및 단축키로 바로 번역[현재 위챗의 특수성으로 적용되지않음]</li>
<li>웹페이지에서 중국어를 드래그 후 단축키로 바로 번역 결과 확인</li>
</ul>
</div>
<div class="feature-card">
<h3>⏰ 시간관리 기능</h3>
<p>포모도로 기법 기반의 시간관리로 생산성을 극대화합니다.</p>
<ul class="feature-list">
<li>자동 작업/휴식 타이머</li>
<li>휴식시간 추천 활동</li>
<li>실시간 타이머 알림</li>
<li>개인화된 시간 설정</li>
<li></li>[포모도로 타이머가 활성화 되면 35분/5분으로 기본설정되며 4사이클 총180분(3시간) 휴식시간 추천 활동 제공]</li>
</ul>
</div>
<div class="feature-card">
<h3>🚫 금지어 관리</h3>
<p>지재권검색 시 제외할 키워드를 효율적으로 관리합니다.</p>
<ul class="feature-list">
<li>개인별 금지어 목록</li>
<li>실시간 금지어 추가/삭제</li>
<li>금지어 등급관리 및 검색결과 확인</li>
<li>수동금지어 등록 및 파생금지어 등록가능</li>
</ul>
</div>
</div>
<div class="version-info">
<strong>현재 버전:</strong> v1.0.0 | <strong>최종 업데이트:</strong> 2025년 6월
</div>
</div>
<div class="tab-content" id="trademark">
<h2>🔍 지재권검색 사용법</h2>
<div class="alert alert-info">
키프리스(KIPRIS)에서 자동으로 지재권검색을 수행하고 결과를 정리해주는 기능입니다.
</div>
<div class="feature-card">
<h3>1⃣ 지재권검색 시작하기</h3>
<ul class="feature-list">
<li>검색하고 싶은 단어를 드래그 후 우클릭 - 지재권 검색 선택</li>
<li>검색하고 싶은 단어를 드래그 후 단축키(Search) - Ctrl + Shift + S</li>
<li>로그인 후 지재권검색 기능 활성화</li>
</ul>
</div>
<div class="feature-card">
<h3>2⃣ 검색 과정</h3>
<ul class="feature-list">
<li>해당단어를 키프리스에서 검색실행</li>
<li>검색 결과 유효한 권리상태만 필터링</li>
<li>지재권 해당하는 상품군 정리</li>
<li>실시간 진행상황 팝업 표시</li>
</ul>
</div>
<div class="feature-card">
<h3>3⃣ 결과 확인 및 추가</h3>
<ul class="feature-list">
<li>검색 완료 후 결과 요약 표시</li>
<li>검색결과를 내 금지어에 원클릭 등록가능</li>
<li>상표 이미지 및 상세정보 포함</li>
</ul>
</div>
<!--
<div class="alert alert-warning">
<strong>주의사항:</strong> 지재권검색은 API 호출량을 소모합니다. 회원등급에 따라 일일 사용량이 제한됩니다.
</div> -->
</div>
<div class="tab-content" id="translation">
<h2>🌐 멀티번역 사용법</h2>
<div class="alert alert-info">
하나의 텍스트나 문장을 여러 번역엔진으로 동시에 번역하여 번역기 마다 다른 애매한 내용들을 정리해줍니다.
</div>
<div class="feature-card">
<h3>1⃣ 번역 시작하기</h3>
<ul class="feature-list">
<li>번역할 텍스트를 드래그 후 우클릭 - 멀티번역 선택</li>
<li>번역할 텍스트를 드래그 후 단축키(Translate) - Ctrl + Shift + T</li>
<li>설정에서 선택한 엔진들로 번역</li>
</ul>
</div>
<div class="feature-card">
<h3>2⃣ 지원 엔진</h3>
<div class="grid">
<div>
<strong>무료 엔진:</strong>
<ul class="feature-list">
<li>구글 기계번역</li>
<li>myMemory 메모리 번역</li>
</ul>
</div>
<div>
<strong>프리미엄등급 엔진:</strong>
<ul class="feature-list">
<li>무료엔진</li>
<li>DeepL 딥러닝 번역</li>
</ul>
</div>
</div>
<div>
<strong>VIP등급 엔진 - 의역 지원:</strong>
<ul class="feature-list">
<li>챗GPT 의역 번역</li>
<li>구글 제미나이 의역 번역</li>
</ul>
</div>
</div>
<div class="feature-card">
<h3>3⃣ 번역 결과 활용</h3>
<ul class="feature-list">
<li>실시간 번역 결과 확인</li>
<li>자연스러운 딥러닝 엔진인 Deepl 사용</li>
<li>맥락을 이해하고 현재 단어나 문장이 어떤의미인지 어떨때 쓰이는지 파악</li>
</ul>
</div>
</div>
<div class="tab-content" id="timer">
<h2>⏰ 시간관리 기능</h2>
<div class="alert alert-info">
포모도로 기법을 활용한 자동 시간관리로 생산성을 향상시킵니다.
</div>
<div class="feature-card">
<h3>1⃣ 시간관리 설정</h3>
<ul class="feature-list">
<li>설정 페이지에서 "시간 알림" 활성화</li>
<li>작업 시간 설정 (기본: 60분)</li>
<li>휴식 시간 설정 (기본: 5분)</li>
<li>알림 방식 선택</li>
</ul>
</div>
<div class="feature-card">
<h3>2⃣ 자동 타이머 작동</h3>
<ul class="feature-list">
<li>로그인 시 자동으로 작업 타이머 시작</li>
<li>작업 시간 완료 시 휴식 모달 표시</li>
<li>휴식 시간 동안 추천 활동 제공</li>
<li>휴식 시간 동안 타냐대장경 제공</li>
<li>휴식 완료 후 자동으로 다음 작업 시작</li>
</ul>
</div>
<div class="feature-card">
<h3>3⃣ 휴식 시간 추천 활동</h3>
<div class="grid">
<div>
<strong>신체 활동:</strong>
<ul class="feature-list">
<li>가벼운 스트레칭</li>
<li>목과 어깨 마사지</li>
<li>잠깐 산책하기</li>
</ul>
</div>
<div>
<strong>정신 건강:</strong>
<ul class="feature-list">
<li>깊은 호흡 연습</li>
<li>간단한 명상</li>
<li>긍정적 생각하기</li>
</ul>
</div>
</div>
</div>
<div class="feature-card">
<h3>4⃣ 타이머 상태 확인</h3>
<ul class="feature-list">
<li>팝업에서 다음 휴식까지 남은 시간 확인</li>
<li>작업/휴식 사이클 진행상황 표시</li>
<li>일일 작업 시간 통계</li>
<li>생산성 향상 팁 제공</li>
</ul>
</div>
</div>
<div class="tab-content" id="banned-words">
<h2>🚫 금지어 관리</h2>
<div class="alert alert-info">
상품편집시 지식재산권을 피하기 위해금지 키워드를 관리합니다.
</div>
<div class="feature-card">
<h3>1⃣ 금지어 추가하기</h3>
<ul class="feature-list">
<li>설정 페이지에서 "금지어 관리" 선택</li>
<li>새로운 금지어 입력</li>
<li>"추가" 버튼 클릭하여 저장</li>
<li>실시간으로 금지어 목록 업데이트</li>
</ul>
</div>
<div class="feature-card">
<h3>2⃣ 금지어 관리</h3>
<ul class="feature-list">
<li>기존 금지어 목록 확인</li>
<li>불필요한 금지어 삭제</li>
<li>금지어 등급변경</li>
<li>금지어 검색 및 필터링</li>
</ul>
</div>
<div class="feature-card">
<h3>3⃣ 금지어 적용</h3>
<ul class="feature-list">
<li>편집알바생과 연동된 금지어 목록</li>
</ul>
</div>
<div class="alert alert-warning">
<strong>팁:</strong> 자주 나타나는 불필요한 키워드들을 금지어로 등록하면 편집알바생의 효율성이 크게 향상됩니다.
</div>
<div class="btn-group">
<button class="btn btn-primary" onclick="openBannedWords()">🚫 금지어 관리</button>
</div>
</div>
<div class="tab-content" id="sayings">
<h2>💬 타냐대장경</h2>
<div class="alert alert-info">
업무에 도움이 되는 타냐센세의 대장경과 조언을 모아놓은 특별한 기능입니다.
</div>
<div class="feature-card">
<h3>1⃣ 타냐대장경이란?</h3>
<ul class="feature-list">
<li>업무 효율성을 높이는 대장경 모음</li>
<li>동기부여와 영감을 주는 메시지</li>
<li>관리자가 승인한 검증된 조언</li>
</ul>
</div>
<div class="feature-card">
<h3>2⃣ 사용 방법</h3>
<ul class="feature-list">
<li>팝업에서 "타냐대장경" 버튼 클릭</li>
<li>오늘의 대장경 확인</li>
<li>대장경 카테고리별 탐색</li>
</ul>
</div>
<div class="feature-card">
<h3>3⃣ 대장경 카테고리</h3>
<div class="grid">
<div>
<strong>업무 효율성:</strong>
<ul class="feature-list">
<li>시간 관리 조언</li>
<li>생산성 향상 팁</li>
<li>마켓의 로직변화관리</li>
<li>타냐센세의 계절별 조언</li>
</ul>
</div>
<div>
<strong>동기부여:</strong>
<ul class="feature-list">
<li>성공 마인드셋</li>
<li>도전 정신 격려</li>
<li>긍정적 사고방식</li>
</ul>
</div>
</div>
</div>
</div>
<div class="tab-content" id="settings">
<h2>⚙️ 설정 가이드</h2>
<div class="alert alert-info">
확장 프로그램의 모든 기능을 개인 취향에 맞게 설정할 수 있습니다.
</div>
<div class="feature-card">
<h3>1⃣ 번역 설정</h3>
<ul class="feature-list">
<li>번역엔진 활성화</li>
</ul>
</div>
<div class="feature-card">
<h3>2⃣ 시간관리 설정</h3>
<ul class="feature-list">
<li>시간 알림 ON/OFF</li>
<li>작업 시간 설정 (분 단위)</li>
<li>휴식 시간 설정 (분 단위)</li>
<li>알림 방식 선택</li>
</ul>
</div>
<div class="feature-card">
<h3>3⃣ 찜 관리 설정</h3>
<ul class="feature-list">
<li>추후 업데이트</li>
<li>찜 품앗이</li>
</ul>
</div>
</div>
</div>
<script src="manual.js"></script>
</body>
</html>

38
manual.js Normal file
View File

@ -0,0 +1,38 @@
// 탭 전환 기능
document.addEventListener('DOMContentLoaded', function() {
// 탭 전환 기능
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
console.log('탭 클릭됨:', tab.dataset.tab);
// 모든 탭과 콘텐츠에서 active 클래스 제거
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
// 클릭된 탭과 해당 콘텐츠에 active 클래스 추가
tab.classList.add('active');
const targetContent = document.getElementById(tab.dataset.tab);
if (targetContent) {
targetContent.classList.add('active');
console.log('탭 전환 완료:', tab.dataset.tab);
} else {
console.error('탭 콘텐츠를 찾을 수 없습니다:', tab.dataset.tab);
}
});
});
console.log('매뉴얼 탭 기능 초기화 완료');
});
// 필요한 버튼 클릭 이벤트 함수들만 유지
function openSettings() {
chrome.tabs.create({ url: chrome.runtime.getURL('settings.html') });
}
function openBannedWords() {
chrome.tabs.create({ url: chrome.runtime.getURL('bannedWords.html') });
}
function openSayings() {
chrome.tabs.create({ url: chrome.runtime.getURL('sayings.html') });
}

View File

@ -13,7 +13,8 @@
padding: 20px; padding: 20px;
width: 320px; width: 320px;
height: 100vh; height: 100vh;
overflow: hidden; /* overflow: hidden; */
overflow-y: auto;
} }
h2 { h2 {
text-align: center; text-align: center;
@ -118,6 +119,25 @@
background: #005a9e; background: #005a9e;
} }
/* 로그인 화면과 사용자 정보 화면 여백 추가 */
#login-section {
padding: 20px 0;
}
#user-info-section {
padding: 20px 0;
}
/* 설정 버튼 스타일 */
.settings-btn {
background: #9b59b6;
margin-bottom: 8px;
}
.settings-btn:hover {
background: #8e44ad;
}
/* 관리 버튼 스타일 */ /* 관리 버튼 스타일 */
.management-buttons { .management-buttons {
margin: 15px 0; margin: 15px 0;
@ -343,6 +363,7 @@
min-width: 60px; min-width: 60px;
text-align: center; text-align: center;
} }
</style> </style>
</head> </head>
<body> <body>
@ -395,10 +416,21 @@
<p><strong>오늘 호출량:</strong> <span id="user-usage"></span></p> <p><strong>오늘 호출량:</strong> <span id="user-usage"></span></p>
<p><strong>등급 만료일:</strong> <span id="user-expire"></span></p> <p><strong>등급 만료일:</strong> <span id="user-expire"></span></p>
<!-- 휴식 시간 카운트다운 -->
<div id="break-timer-section" style="margin: 15px 0; padding: 10px; background: #e8f5e8; border-radius: 6px; border: 1px solid #c3e6c3;">
<p style="margin: 0; font-weight: bold; color: #2d5a2d;">⏰ 다음 휴식까지</p>
<p id="break-countdown" style="margin: 5px 0 0 0; font-size: 16px; font-weight: bold; color: #1e7e1e;">계산 중...</p>
</div>
<!-- 관리 버튼들 --> <!-- 관리 버튼들 -->
<div class="management-buttons"> <div class="management-buttons">
<button class="btn management-btn" id="settings-btn">⚙️ 설정</button>
<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> <button class="btn management-btn" id="sayings-btn">💬 타냐대장경</button>
<!-- <button class="btn management-btn" id="zzim-btn" disabled>💝 찜관리</button> -->
<button class="btn management-btn" id="zzim-btn">💝 찜관리</button>
<button class="btn management-btn" id="manual-btn">📚 매뉴얼</button>
</div> </div>
<button class="btn" id="logout-btn">로그아웃</button> <button class="btn" id="logout-btn">로그아웃</button>

944
popup.js

File diff suppressed because it is too large Load Diff

296
rest-modal.html Normal file
View File

@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>휴식 시간</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #333;
overflow: hidden;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.modal-container {
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 600px;
width: 90%;
text-align: center;
position: relative;
animation: modalFadeIn 0.5s ease-out;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: scale(0.8) translateY(-50px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.close-btn {
position: absolute;
top: 15px;
right: 20px;
background: none;
border: none;
font-size: 24px;
color: #999;
cursor: pointer;
padding: 5px;
border-radius: 50%;
width: 35px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.close-btn:hover {
background: #f0f0f0;
color: #666;
}
.rest-icon {
font-size: 4rem;
margin-bottom: 20px;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
.rest-title {
font-size: 2.5rem;
color: #4a5568;
margin-bottom: 15px;
font-weight: 700;
}
.rest-subtitle {
font-size: 1.2rem;
color: #718096;
margin-bottom: 30px;
}
.timer-display {
font-size: 3rem;
color: #667eea;
font-weight: bold;
margin-bottom: 30px;
font-family: 'Courier New', monospace;
}
.activity-section {
background: #f8f9fa;
border-radius: 12px;
padding: 25px;
margin-bottom: 25px;
}
.activity-title {
font-size: 1.3rem;
color: #4a5568;
margin-bottom: 15px;
font-weight: 600;
}
.activity-suggestion {
font-size: 1.1rem;
color: #2d3748;
margin-bottom: 20px;
padding: 15px;
background: white;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.saying-section {
background: linear-gradient(135deg, #ffeaa7, #fab1a0);
border-radius: 12px;
padding: 25px;
margin-bottom: 25px;
color: #2d3748;
}
.saying-title {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.saying-content {
font-size: 1.1rem;
font-style: italic;
line-height: 1.6;
margin-bottom: 10px;
}
.saying-author {
font-size: 0.9rem;
color: #666;
text-align: right;
}
.actions {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 20px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.btn-secondary:hover {
background: #cbd5e0;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
margin-top: 20px;
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 3px;
transition: width 1s ease;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="modal-overlay" id="modalOverlay">
<div class="modal-container">
<button class="close-btn" id="closeBtn" title="ESC키로 닫기">×</button>
<div class="rest-icon">🧘‍♀️</div>
<h1 class="rest-title">휴식 시간입니다!</h1>
<p class="rest-subtitle">열심히 일한 당신, 잠시 휴식을 취하세요</p>
<div class="timer-display" id="timerDisplay">05:00</div>
<div class="activity-section">
<h3 class="activity-title">💡 추천 활동</h3>
<div class="activity-suggestion" id="activitySuggestion">
<div class="loading-spinner"></div>
활동을 불러오는 중...
</div>
</div>
<div class="saying-section">
<h3 class="saying-title">
<span></span>
<span>타냐의 한마디</span>
<span></span>
</h3>
<div class="saying-content" id="sayingContent">
<div class="loading-spinner"></div>
어록을 불러오는 중...
</div>
<div class="saying-author" id="sayingAuthor"></div>
</div>
<div class="actions">
<button class="btn btn-secondary" id="skipBtn">휴식 건너뛰기</button>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
</div>
<script src="rest-modal.js"></script>
</body>
</html>

302
rest-modal.js Normal file
View File

@ -0,0 +1,302 @@
class RestModal {
constructor() {
this.restTime = 5; // 기본 5분
this.currentTime = this.restTime * 60; // 초 단위
this.timer = null;
this.config = null;
this.currentSaying = null;
this.autoZzim = false;
// 기본 추천 활동 목록 (백엔드 연결 실패 시 사용)
this.defaultActivities = [
"🚶‍♀️ 가벼운 산책을 해보세요",
"💧 물 한 잔을 마시며 수분을 보충하세요",
"🧘‍♀️ 심호흡을 하며 명상을 해보세요",
"🤸‍♀️ 간단한 스트레칭으로 몸을 풀어보세요",
"👀 눈 운동을 하며 눈의 피로를 풀어보세요",
"🚽 화장실을 다녀오세요",
"🌱 창밖을 보며 자연을 감상해보세요",
"📱 잠시 휴대폰을 내려놓고 마음을 비워보세요",
"☕ 따뜻한 차 한 잔을 마셔보세요",
"🎵 좋아하는 음악을 들으며 휴식하세요",
"📚 짧은 글이나 명언을 읽어보세요",
"🤝 동료나 가족과 간단한 대화를 나누세요",
"🧴 손 마사지나 목 마사지를 해보세요",
"🏃‍♀️ 제자리에서 가볍게 몸을 움직여보세요",
"🍎 건강한 간식을 드세요"
];
this.activities = []; // 백엔드에서 가져온 활동 목록
}
async init() {
try {
await this.loadSettings();
await this.loadConfig();
this.setupEventListeners();
await this.loadRestActivities();
this.showRandomActivity();
await this.loadRandomSaying();
this.startTimer();
// 자동 품앗이 체크 및 실행
this.checkAutoZzim();
} catch (error) {
console.error('[RestModal] 초기화 실패:', error);
}
}
async checkAutoZzim() {
if (this.autoZzim) {
console.log('[RestModal] 휴식 중 자동 품앗이 시작 (Fast Mode: 10ms)');
// 백그라운드에 메시지 전송 (100ms 딜레이로 고속 실행)
try {
chrome.runtime.sendMessage({
action: 'autoMutualZzim',
delay: 100
});
} catch (e) {
console.error('[RestModal] 자동 품앗이 요청 실패:', e);
}
}
}
async loadSettings() {
try {
const result = await chrome.storage.local.get('time_alarm_settings');
const settings = result.time_alarm_settings || {};
this.restTime = settings.restTime || 5;
this.autoZzim = settings.autoZzim || false;
this.currentTime = this.restTime * 60;
console.log('[RestModal] 설정 로드:', { restTime: this.restTime, autoZzim: this.autoZzim });
} catch (error) {
console.error('[RestModal] 설정 로드 실패:', error);
}
}
async loadConfig() {
try {
const result = await chrome.storage.local.get('settings_config');
this.config = result.settings_config || {};
console.log('[RestModal] 설정 정보 로드:', this.config);
} catch (error) {
console.error('[RestModal] 설정 정보 로드 실패:', error);
}
}
setupEventListeners() {
// 닫기 버튼
document.getElementById('closeBtn').addEventListener('click', () => {
this.closeModal();
});
// 건너뛰기 버튼
document.getElementById('skipBtn').addEventListener('click', () => {
this.closeModal();
});
// ESC 키로 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModal();
}
});
}
async loadRestActivities() {
try {
if (!this.config.ACCESS_TOKEN) {
throw new Error('Access token not found');
}
console.log('[RestModal] 추천활동 API 호출 (background.js 경유)');
// background.js를 통해 API 호출
const response = await chrome.runtime.sendMessage({
action: 'getRestActivities',
token: this.config.ACCESS_TOKEN
});
if (!response || !response.success) {
throw new Error(response?.error || 'API 호출 실패');
}
const events = response.activities;
if (events && events.length > 0) {
// message 필드에서 JSON 파싱하여 활동 목록 생성
this.activities = [];
events.forEach(event => {
try {
// message 필드가 JSON 형식인 경우 파싱
const messageData = JSON.parse(event.message);
// 활동 텍스트 추출 (다양한 형식 지원)
if (messageData.activity) {
this.activities.push(messageData.activity);
} else if (messageData.text) {
this.activities.push(messageData.text);
} else if (typeof messageData === 'string') {
this.activities.push(messageData);
}
} catch (parseError) {
// JSON 파싱 실패 시 문자열 그대로 사용
if (typeof event.message === 'string' && event.message.trim()) {
this.activities.push(event.message);
}
}
});
console.log('[RestModal] 백엔드에서 추천활동 로드 완료:', this.activities.length + '개');
} else {
throw new Error('추천활동 데이터가 없습니다');
}
} catch (error) {
console.error('[RestModal] 추천활동 로드 실패:', error);
// 기본 활동 목록 사용
this.activities = [...this.defaultActivities];
console.log('[RestModal] 기본 추천활동 사용:', this.activities.length + '개');
}
}
showRandomActivity() {
if (this.activities.length === 0) {
this.activities = [...this.defaultActivities];
}
const randomActivity = this.activities[Math.floor(Math.random() * this.activities.length)];
document.getElementById('activitySuggestion').innerHTML = randomActivity;
}
async loadRandomSaying() {
try {
if (!this.config.ACCESS_TOKEN) {
throw new Error('Access token not found');
}
console.log('[RestModal] 어록 API 호출 (background.js 경유)');
// background.js를 통해 API 호출
const response = await chrome.runtime.sendMessage({
action: 'getRandomSaying',
token: this.config.ACCESS_TOKEN
});
if (!response || !response.success) {
throw new Error(response?.error || 'API 호출 실패');
}
const sayings = response.sayings;
if (sayings && sayings.length > 0) {
// 랜덤하게 하나 선택
const randomSaying = sayings[Math.floor(Math.random() * sayings.length)];
this.currentSaying = randomSaying;
// 어록 표시
const sayingContent = document.getElementById('sayingContent');
const sayingAuthor = document.getElementById('sayingAuthor');
sayingContent.innerHTML = `"${randomSaying.saying}"`;
// 카테고리와 대상 정보 표시
const category = randomSaying.sayings_cat?.saying_cat || '';
const target = randomSaying.sayings_target?.target || '';
const dateStr = new Date(randomSaying.created_at).toLocaleDateString('ko-KR');
sayingAuthor.innerHTML = `${category} ${target ? `${target}` : ''}${dateStr}`;
console.log('[RestModal] 어록 로드 완료:', randomSaying);
} else {
throw new Error('어록이 없습니다');
}
} catch (error) {
console.error('[RestModal] 어록 로드 실패:', error);
// 기본 메시지 표시
document.getElementById('sayingContent').innerHTML = '"열심히 일한 당신, 잠시 휴식을 취하며 에너지를 충전하세요!"';
document.getElementById('sayingAuthor').innerHTML = '타냐 • 휴식 메시지';
}
}
startTimer() {
this.updateTimerDisplay();
this.updateProgressBar();
this.timer = setInterval(() => {
this.currentTime--;
this.updateTimerDisplay();
this.updateProgressBar();
if (this.currentTime <= 0) {
this.onTimerComplete();
}
}, 1000);
}
updateTimerDisplay() {
const minutes = Math.floor(this.currentTime / 60);
const seconds = this.currentTime % 60;
const display = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
document.getElementById('timerDisplay').textContent = display;
}
updateProgressBar() {
const totalTime = this.restTime * 60;
const elapsed = totalTime - this.currentTime;
const percentage = (elapsed / totalTime) * 100;
document.getElementById('progressFill').style.width = `${percentage}%`;
}
async onTimerComplete() {
clearInterval(this.timer);
// 완료 메시지 표시
this.showCompletionMessage();
// 3초 후 자동 닫기
setTimeout(() => {
this.closeModal();
}, 3000);
}
showCompletionMessage() {
const modalContainer = document.querySelector('.modal-container');
modalContainer.innerHTML = `
<div class="rest-icon">🚀</div>
<h1 class="rest-title">휴식 완료!</h1>
<p class="rest-subtitle">이제 다시 열심히 월매출 1억을 향해 달려가세요!</p>
<div style="font-size: 1.2rem; color: #667eea; margin-top: 20px;">
💪 화이팅! 성공은 바로 앞에 있습니다!
</div>
<div class="actions" style="margin-top: 30px;">
<button class="btn btn-primary" onclick="window.close()">확인</button>
</div>
`;
}
closeModal() {
if (this.timer) {
clearInterval(this.timer);
}
// 창 닫기
window.close();
}
}
// 페이지 로드 시 휴식 모달 초기화
document.addEventListener('DOMContentLoaded', () => {
const restModal = new RestModal();
restModal.init();
});

View File

@ -537,14 +537,412 @@
color: #1976d2; color: #1976d2;
font-weight: 500; font-weight: 500;
} }
/* 새로운 블록 에디터 스타일 */
.content-blocks-editor {
border: 2px dashed #e0e0e0;
border-radius: 12px;
padding: 20px;
background: #fafafa;
transition: all 0.3s ease;
}
.content-blocks-editor:hover {
border-color: #4CAF50;
background: #f8fff8;
}
.editor-toolbar {
display: flex;
gap: 10px;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #e0e0e0;
}
.toolbar-btn {
background: linear-gradient(135deg, #4CAF50, #45a049);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.3s ease;
}
.toolbar-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
}
.content-blocks {
min-height: 100px;
}
.content-block {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 10px;
padding: 15px;
position: relative;
transition: all 0.3s ease;
}
.content-block:hover {
border-color: #4CAF50;
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.1);
}
.content-block.dragging {
opacity: 0.5;
transform: rotate(5deg);
}
.block-controls {
position: absolute;
top: -5px;
right: -5px;
display: flex;
gap: 5px;
opacity: 0;
transition: opacity 0.3s ease;
}
.content-block:hover .block-controls {
opacity: 1;
}
.block-control-btn {
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.block-control-btn.move {
background: #2196F3;
color: white;
}
.block-control-btn.delete {
background: #f44336;
color: white;
}
.block-control-btn:hover {
transform: scale(1.1);
}
.text-block textarea {
width: 100%;
min-height: 80px;
border: 1px solid #ddd;
border-radius: 6px;
padding: 10px;
font-family: inherit;
resize: vertical;
}
.image-block {
text-align: center;
}
.image-upload-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px 20px;
cursor: pointer;
transition: all 0.3s ease;
background: #f9f9f9;
}
.image-upload-area:hover {
border-color: #4CAF50;
background: #f0fff0;
}
.image-upload-area.dragover {
border-color: #4CAF50;
background: #e8f5e8;
transform: scale(1.02);
}
.uploaded-image {
max-width: 100%;
max-height: 200px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.3s ease;
}
.uploaded-image:hover {
transform: scale(1.05);
}
.image-info {
margin-top: 10px;
font-size: 12px;
color: #666;
display: flex;
justify-content: space-between;
align-items: center;
}
.image-alt-input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 10px;
font-size: 14px;
}
/* 이미지 모달 */
.image-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.image-modal.active {
opacity: 1;
visibility: visible;
}
.image-modal img {
max-width: 90%;
max-height: 90%;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.image-modal-close {
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 40px;
cursor: pointer;
z-index: 10001;
}
/* 드래그 앤 드롭 표시 */
.drop-indicator {
height: 3px;
background: #4CAF50;
border-radius: 2px;
margin: 5px 0;
opacity: 0;
transition: opacity 0.3s ease;
}
.drop-indicator.active {
opacity: 1;
}
/* 반응형 */
@media (max-width: 768px) {
.editor-toolbar {
flex-direction: column;
}
.toolbar-btn {
justify-content: center;
}
.image-info {
flex-direction: column;
gap: 5px;
}
}
/* 상세 보기 모달 스타일 */
.view-field {
padding: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
color: #2c3e50;
}
.view-content {
padding: 15px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
min-height: 100px;
line-height: 1.6;
}
.view-content .content-text-block {
margin-bottom: 15px;
padding: 10px;
background: white;
border-radius: 4px;
}
.view-content .content-image-block {
margin: 15px 0;
text-align: center;
}
.view-content .content-image {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.3s ease;
}
.view-content .content-image:hover {
transform: scale(1.02);
}
.view-content .image-caption {
font-size: 12px;
color: #666;
margin-top: 8px;
font-style: italic;
}
.view-meta {
display: flex;
gap: 10px;
align-items: center;
}
.view-meta .meta-badge {
padding: 6px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
}
.view-meta .category-badge {
background: #e3f2fd;
color: #1976d2;
border: 1px solid #bbdefb;
}
.view-meta .target-badge {
background: #f3e5f5;
color: #7b1fa2;
border: 1px solid #e1bee7;
}
.view-author-info {
padding: 12px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.author-details {
display: flex;
align-items: center;
gap: 10px;
}
.author-avatar {
width: 32px;
height: 32px;
background: #007bff;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
}
.author-name {
font-weight: 500;
color: #2c3e50;
}
.creation-date {
color: #6c757d;
font-size: 14px;
}
/* 어록 카드 클릭 효과 */
.saying-card {
cursor: pointer;
transition: all 0.3s ease;
}
.saying-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
border-left-color: #28a745;
}
.saying-card:active {
transform: translateY(0);
}
/* 삭제 버튼 스타일 */
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
/* 편집 모드 전환 애니메이션 */
#view-mode, #edit-mode {
transition: opacity 0.3s ease;
}
/* 모달 버튼 그룹 */
.modal-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-buttons .btn {
min-width: 100px;
}
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="header">
<h1>📚 어록 관리</h1> <h1>📚 타냐대장경 관리</h1>
<div class="header-right"> <div class="header-right">
<div id="current-user" class="current-user">👤 로딩 중...</div> <div id="current-user" class="current-user">👤 로딩 중...</div>
<button id="add-saying-btn" class="add-saying-btn">✨ 어록 등록</button> <button id="add-saying-btn" class="add-saying-btn">타냐대장경 등록</button>
</div> </div>
</div> </div>
@ -586,7 +984,7 @@
<div class="filter-group"> <div class="filter-group">
<label for="search-input">검색</label> <label for="search-input">검색</label>
<input type="text" id="search-input" placeholder="어록 검색..." class="search-input"> <input type="text" id="search-input" placeholder="타냐대장경 검색..." class="search-input">
</div> </div>
<div class="filter-buttons"> <div class="filter-buttons">
@ -602,7 +1000,7 @@
<!-- 통계 정보가 여기에 표시됩니다 --> <!-- 통계 정보가 여기에 표시됩니다 -->
</div> </div>
<button class="add-saying-btn" id="add-saying-btn"> <button class="add-saying-btn" id="add-saying-btn">
어록 등록 타냐대장경 등록
</button> </button>
</div> </div>
@ -610,29 +1008,42 @@
<div id="add-saying-modal" class="modal"> <div id="add-saying-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">어록 등록</h3> <h3 class="modal-title">타냐대장경 등록</h3>
<button class="modal-close" id="modal-close">&times;</button> <button class="modal-close" id="modal-close">&times;</button>
</div> </div>
<form id="saying-form"> <form id="saying-form">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="saying-title">어록 제목</label> <label class="form-label" for="saying-title">타냐대장경 제목</label>
<input type="text" id="saying-title" class="form-input" placeholder="어록의 제목을 입력하세요" required> <input type="text" id="saying-title" class="form-input" placeholder="타냐대장경의 제목을 입력하세요" required>
<div class="form-help">어록을 대표하는 제목을 입력해주세요.</div> <div class="form-help">타냐대장경을 대표하는 제목을 입력해주세요.</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="saying-content">어록 내용</label> <label class="form-label" for="saying-content">타냐대장경 내용</label>
<div class="preview-tabs"> <div class="preview-tabs">
<div class="preview-tab active" data-tab="edit">✏️ 편집</div> <div class="preview-tab active" data-tab="edit">✏️ 편집</div>
<div class="preview-tab" data-tab="preview">👁️ 미리보기</div> <div class="preview-tab" data-tab="preview">👁️ 미리보기</div>
</div> </div>
<textarea id="saying-content" class="form-textarea" placeholder="어록 내용을 입력하세요 (마크다운 지원)" required></textarea>
<!-- 새로운 블록 기반 에디터 -->
<div id="content-blocks-editor" class="content-blocks-editor">
<div class="editor-toolbar">
<button type="button" class="toolbar-btn" id="add-text-block">📝 텍스트 추가</button>
<button type="button" class="toolbar-btn" id="add-image-block">🖼️ 이미지 추가</button>
</div>
<div id="content-blocks" class="content-blocks">
<!-- 블록들이 동적으로 추가됩니다 -->
</div>
</div>
<!-- 기존 텍스트 에리어 (하위 호환성) -->
<textarea id="saying-content" class="form-textarea" placeholder="어록 내용을 입력하세요 (마크다운 지원)" style="display: none;"></textarea>
<div id="markdown-preview" class="markdown-preview" style="display: none;"></div> <div id="markdown-preview" class="markdown-preview" style="display: none;"></div>
<div class="form-help"> <div class="form-help">
💡 마크다운 문법을 사용할 수 있습니다. 💡 텍스트와 이미지를 자유롭게 조합할 수 있습니다.
<strong>**굵게**</strong>, <em>*기울임*</em>, 이미지는 자동으로 압축되어 저장됩니다. (최대 2MB)
<code>`코드`</code>, 링크 등을 지원합니다.
</div> </div>
</div> </div>
@ -642,7 +1053,7 @@
<option value="">카테고리를 선택하세요</option> <option value="">카테고리를 선택하세요</option>
<!-- 동적으로 로드됩니다 --> <!-- 동적으로 로드됩니다 -->
</select> </select>
<div class="form-help">어록의 성격에 맞는 카테고리를 선택해주세요.</div> <div class="form-help">타냐대장경의 성격에 맞는 카테고리를 선택해주세요.</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -651,7 +1062,7 @@
<option value="">타겟을 선택하세요</option> <option value="">타겟을 선택하세요</option>
<!-- 동적으로 로드됩니다 --> <!-- 동적으로 로드됩니다 -->
</select> </select>
<div class="form-help">어록의 대상을 선택해주세요. (기본값: 누구나)</div> <div class="form-help">타냐대장경의 대상을 선택해주세요. (기본값: 누구나)</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -670,19 +1081,112 @@
</div> </div>
</div> </div>
<!-- 어록 상세 보기/편집 모달 -->
<div id="view-saying-modal" class="modal">
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h3 class="modal-title" id="view-modal-title">📖 타냐대장경 상세 보기</h3>
<button class="modal-close" id="view-modal-close">&times;</button>
</div>
<!-- 보기 모드 -->
<div id="view-mode" style="display: none;">
<div class="form-group">
<label class="form-label">제목</label>
<div id="view-title" class="view-field"></div>
</div>
<div class="form-group">
<label class="form-label">내용</label>
<div id="view-content" class="view-content"></div>
</div>
<div class="form-group">
<label class="form-label">카테고리 / 타겟</label>
<div id="view-meta" class="view-meta"></div>
</div>
<div class="form-group">
<label class="form-label">등록 정보</label>
<div id="view-author-info" class="view-author-info"></div>
</div>
</div>
<!-- 편집 모드 -->
<div id="edit-mode" style="display: none;">
<form id="edit-saying-form">
<div class="form-group">
<label class="form-label" for="edit-saying-title">타냐대장경 제목</label>
<input type="text" id="edit-saying-title" class="form-input" placeholder="타냐대장경의 제목을 입력하세요" required>
</div>
<div class="form-group">
<label class="form-label" for="edit-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>
<!-- 편집용 블록 에디터 -->
<div id="edit-content-blocks-editor" class="content-blocks-editor">
<div class="editor-toolbar">
<button type="button" class="toolbar-btn" id="edit-add-text-block">📝 텍스트 추가</button>
<button type="button" class="toolbar-btn" id="edit-add-image-block">🖼️ 이미지 추가</button>
</div>
<div id="edit-content-blocks" class="content-blocks">
<!-- 블록들이 동적으로 추가됩니다 -->
</div>
</div>
<!-- 기존 텍스트 에리어 (하위 호환성) -->
<textarea id="edit-saying-content" class="form-textarea" placeholder="타냐대장경 내용을 입력하세요" style="display: none;"></textarea>
<div id="edit-markdown-preview" class="markdown-preview" style="display: none;"></div>
</div>
<div class="form-group">
<label class="form-label" for="edit-saying-category">카테고리</label>
<select id="edit-saying-category" class="form-select" required>
<option value="">카테고리를 선택하세요</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="edit-saying-target">타겟</label>
<select id="edit-saying-target" class="form-select" required>
<option value="">타겟을 선택하세요</option>
</select>
</div>
</form>
</div>
<div class="modal-buttons">
<!-- 보기 모드 버튼 -->
<div id="view-mode-buttons" style="display: none;">
<button type="button" class="btn btn-secondary" id="view-close-btn">닫기</button>
<button type="button" class="btn btn-primary" id="edit-mode-btn" style="display: none;">✏️ 편집하기</button>
</div>
<!-- 편집 모드 버튼 -->
<div id="edit-mode-buttons" style="display: none;">
<button type="button" class="btn btn-secondary" id="edit-cancel-btn">취소</button>
<button type="button" class="btn btn-danger" id="delete-saying-btn" style="margin-right: auto;">🗑️ 삭제</button>
<button type="submit" class="btn btn-primary" id="update-saying-btn">💾 수정 완료</button>
</div>
</div>
</div>
</div>
<!-- 어록 컨테이너 --> <!-- 어록 컨테이너 -->
<div id="sayings-container" class="sayings-container"> <div id="sayings-container" class="sayings-container">
<!-- 동적으로 생성됨 --> <!-- 동적으로 생성됨 -->
</div> </div>
<div class="loading" id="sayings-loading" style="display: none;"> <div class="loading" id="sayings-loading" style="display: none;">
🔄 어록을 불러오는 중... 🔄 타냐대장경을 불러오는 중...
</div> </div>
<!-- 마크다운 라이브러리를 직접 로드 --> <!-- 마크다운 라이브러리는 sayings.js 내장 함수 사용 (CSP 호환) -->
<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> <script src="sayings.js"></script>
</body> </body>
</html> </html>

1699
sayings.js

File diff suppressed because it is too large Load Diff

590
settings.html Normal file
View File

@ -0,0 +1,590 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설정 - 내차는언제타냐 통합확장기</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #9b59b6, #8e44ad);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 600;
}
.header p {
margin: 10px 0 0 0;
opacity: 0.9;
font-size: 16px;
}
/* 탭 스타일 */
.tab-container {
display: flex;
background: #f8f9fa;
border-bottom: 2px solid #e9ecef;
}
.tab-button {
flex: 1;
padding: 15px 20px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: #6c757d;
transition: all 0.3s ease;
border-bottom: 3px solid transparent;
}
.tab-button:hover {
background: #e9ecef;
color: #495057;
}
.tab-button.active {
color: #9b59b6;
border-bottom-color: #9b59b6;
background: #fff;
}
.tab-content {
display: none;
padding: 30px;
}
.tab-content.active {
display: block;
}
.content {
padding: 0;
}
.membership-info {
background: linear-gradient(135deg, #e3f2fd, #bbdefb);
border: 1px solid #90caf9;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
text-align: center;
}
.membership-title {
font-size: 18px;
font-weight: bold;
color: #1976d2;
margin-bottom: 10px;
}
.membership-engines {
color: #424242;
line-height: 1.6;
font-size: 14px;
}
.section {
margin-bottom: 30px;
}
.section-title {
font-size: 20px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.section-description {
color: #6c757d;
font-size: 14px;
margin-bottom: 20px;
line-height: 1.5;
}
.engine-list {
display: grid;
gap: 15px;
}
.engine-item {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 20px;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: space-between;
}
.engine-item:hover {
border-color: #9b59b6;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.engine-item.disabled {
opacity: 0.6;
background: #f1f3f4;
border-color: #d1d5db;
}
.engine-item.disabled:hover {
transform: none;
box-shadow: none;
border-color: #d1d5db;
}
.engine-info {
flex: 1;
}
.engine-name {
font-size: 16px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
}
.engine-badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
font-weight: 500;
}
.badge-free {
background: #d4edda;
color: #155724;
}
.badge-premium {
background: #fff3cd;
color: #856404;
}
.badge-vip {
background: #f8d7da;
color: #721c24;
}
.engine-description {
color: #6c757d;
font-size: 14px;
line-height: 1.4;
margin-bottom: 8px;
}
.engine-features {
font-size: 12px;
color: #9b59b6;
font-weight: 500;
}
.toggle-switch {
position: relative;
width: 60px;
height: 30px;
background: #ccc;
border-radius: 30px;
cursor: pointer;
transition: background 0.3s;
margin-left: 15px;
}
.toggle-switch.active {
background: #9b59b6;
}
.toggle-switch.disabled {
background: #e9ecef;
cursor: not-allowed;
}
.toggle-switch::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
transition: left 0.3s;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.toggle-switch.active::after {
left: 33px;
}
/* 시간 알람 설정 스타일 */
.time-alarm-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.time-input-group {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.time-input-group label {
font-weight: 500;
color: #495057;
min-width: 80px;
}
.time-input-group input {
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
width: 80px;
}
.time-input-group span {
color: #6c757d;
font-size: 14px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 10px;
margin-top: 15px;
}
.checkbox-group input[type="checkbox"] {
width: 18px;
height: 18px;
}
.checkbox-group label {
font-size: 14px;
color: #495057;
}
.actions {
text-align: center;
padding: 20px 0;
border-top: 1px solid #e9ecef;
margin-top: 30px;
}
.btn {
background: linear-gradient(135deg, #9b59b6, #8e44ad);
color: white;
border: none;
padding: 12px 30px;
border-radius: 25px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin: 0 10px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(155, 89, 182, 0.4);
}
.btn-secondary {
background: linear-gradient(135deg, #6c757d, #5a6268);
}
.btn-secondary:hover {
box-shadow: 0 5px 15px rgba(108, 117, 125, 0.4);
}
.loading {
display: none;
text-align: center;
color: #9b59b6;
font-size: 14px;
margin-top: 15px;
}
.message {
padding: 15px;
border-radius: 8px;
margin: 15px 0;
font-size: 14px;
display: none;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.footer {
background: #f8f9fa;
padding: 20px;
text-align: center;
color: #6c757d;
font-size: 14px;
}
@media (max-width: 768px) {
.container {
margin: 10px;
border-radius: 8px;
}
.header {
padding: 20px;
}
.tab-content {
padding: 20px;
}
.engine-item {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.toggle-switch {
margin-left: 0;
align-self: flex-end;
}
.time-input-group {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚙️ 설정</h1>
<p>번역 엔진 및 시간 알람 설정을 관리합니다</p>
</div>
<!-- 탭 메뉴 -->
<div class="tab-container">
<button class="tab-button active" data-tab="translation">🌐 번역 엔진</button>
<button class="tab-button" data-tab="time-alarm">⏰ 시간 알람</button>
</div>
<div class="content">
<!-- 번역 엔진 설정 탭 -->
<div class="tab-content active" id="translation-tab">
<!-- 회원등급 정보 -->
<div class="membership-info">
<div class="membership-title">현재 회원등급: <span id="current-membership">로딩 중...</span></div>
<div class="membership-engines" id="membership-engines-info">회원 정보를 불러오는 중입니다...</div>
</div>
<!-- 번역 엔진 설정 -->
<div class="section">
<div class="section-title">
<span>📋</span>
노출할 번역 엔진 선택
</div>
<div class="section-description">
체크된 엔진만 멀티번역 결과에 표시됩니다. 회원등급에 따라 일부 엔진은 사용할 수 없습니다.
</div>
<div class="engine-list">
<div class="engine-item" data-engine="google">
<div class="engine-info">
<div class="engine-name">
<span>🌐</span>
Google 번역
<span class="engine-badge badge-free">무료</span>
</div>
<div class="engine-description">
빠른 속도와 다양한 언어 지원을 제공하는 구글의 무료 번역 서비스
</div>
<div class="engine-features">
✓ 100개 이상 언어 지원 ✓ 실시간 번역 ✓ 무료 사용
</div>
</div>
<div class="toggle-switch" data-engine="google"></div>
</div>
<div class="engine-item" data-engine="mymemory">
<div class="engine-info">
<div class="engine-name">
<span>💾</span>
MyMemory 번역
<span class="engine-badge badge-free">무료</span>
</div>
<div class="engine-description">
번역 메모리 기반으로 높은 품질의 번역을 제공하는 무료 서비스
</div>
<div class="engine-features">
✓ 번역 메모리 활용 ✓ 높은 번역 품질 ✓ 무료 사용
</div>
</div>
<div class="toggle-switch" data-engine="mymemory"></div>
</div>
<div class="engine-item" data-engine="deepl">
<div class="engine-info">
<div class="engine-name">
<span>🎯</span>
DeepL 번역
<span class="engine-badge badge-premium">프리미엄</span>
</div>
<div class="engine-description">
높은 번역 품질로 유명한 AI 번역 서비스
</div>
<div class="engine-features">
✓ 최고 품질 번역 ✓ 문맥 이해 ✓ 프리미엄 이상 사용 가능
</div>
</div>
<div class="toggle-switch" data-engine="deepl"></div>
</div>
<div class="engine-item" data-engine="openai">
<div class="engine-info">
<div class="engine-name">
<span>🤖</span>
OpenAI ChatGPT
<span class="engine-badge badge-vip">VIP</span>
</div>
<div class="engine-description">
문맥을 이해하는 고품질 AI 번역, 의역 및 설명 포함
</div>
<div class="engine-features">
✓ AI 기반 번역 ✓ 문맥 이해 ✓ 의역 제공 ✓ VIP 전용
</div>
</div>
<div class="toggle-switch" data-engine="openai"></div>
</div>
<div class="engine-item disabled" data-engine="gemini">
<div class="engine-info">
<div class="engine-name">
<span>💎</span>
Google Gemini
<span class="engine-badge badge-vip">비활성화</span>
</div>
<div class="engine-description">
현재 사용할 수 없습니다 (서비스 점검 중)
</div>
<div class="engine-features">
✗ 현재 사용 불가 ✗ 서비스 점검 중
</div>
</div>
<div class="toggle-switch disabled" data-engine="gemini"></div>
</div>
</div>
</div>
</div>
<!-- 시간 알람 설정 탭 -->
<div class="tab-content" id="time-alarm-tab">
<div class="section">
<div class="section-title">
<span></span>
시간 알람 설정
</div>
<div class="section-description">
작업 시간과 휴식 시간을 설정하여 건강한 작업 패턴을 유지하세요.
</div>
<div class="time-alarm-section">
<div class="time-input-group">
<label>시간 알람:</label>
<div class="toggle-switch" id="timeAlarmToggle"></div>
<span>알람 활성화/비활성화</span>
</div>
<!-- 포모도로 토글 -->
<div class="time-input-group">
<label>포모도로:</label>
<div class="toggle-switch" id="pomodoroToggle"></div>
<span>35분 작업 / 5분 휴식 (4회) 고정</span>
</div>
<div class="time-input-group">
<label>작업 시간:</label>
<input type="number" id="workTimeInput" min="60" max="480" value="60">
<span>분 (작업 후 휴식 알림)</span>
</div>
<div class="time-input-group">
<label>휴식 시간:</label>
<input type="number" id="restTimeInput" min="1" max="15" value="5">
<span>분 (휴식 시간 길이)</span>
</div>
<div class="checkbox-group">
<!-- <input type="checkbox" id="autoZzimCheckbox" disabled> -->
<input type="checkbox" id="autoZzimCheckbox">
<label for="autoZzimCheckbox">휴식 중 자동 찜 기능 활성화</label>
</div>
</div>
</div>
</div>
<div class="message" id="message"></div>
<div class="loading" id="loading">⏳ 설정을 저장하는 중...</div>
<div class="actions">
<button class="btn" id="save-settings">💾 설정 저장</button>
<button class="btn btn-secondary" id="reset-settings">🔄 기본값으로 복원</button>
</div>
</div>
<div class="footer">
<p>© 2024 내차는언제타냐 통합확장기 - 번역 엔진 및 시간 알람 설정</p>
</div>
</div>
<script src="settings.js"></script>
</body>
</html>

529
settings.js Normal file
View File

@ -0,0 +1,529 @@
// 설정 페이지 JavaScript
class SettingsManager {
constructor() {
this.config = null;
this.userInfo = null;
this.currentSettings = {};
this.timeAlarmSettings = {};
// 회원등급별 사용 가능한 엔진 정의
this.availableEngines = {
'basic': ['google', 'mymemory'],
'premium': ['google', 'mymemory', 'deepl'],
'vip': ['google', 'mymemory', 'deepl', 'openai']
};
// 엔진별 상세 정보
this.engineInfo = {
'google': {
name: 'Google 번역',
description: '빠른 속도와 다양한 언어 지원을 제공하는 구글의 무료 번역 서비스',
level: 'basic'
},
'mymemory': {
name: 'MyMemory 번역',
description: '번역 메모리 기반으로 높은 품질의 번역을 제공하는 무료 서비스',
level: 'basic'
},
'deepl': {
name: 'DeepL 번역',
description: '높은 번역 품질로 유명한 AI 번역 서비스',
level: 'premium'
},
'openai': {
name: 'OpenAI ChatGPT',
description: '문맥을 이해하는 고품질 AI 번역, 의역 및 설명 포함',
level: 'vip'
},
'gemini': {
name: 'Google Gemini',
description: '구글의 최신 AI 모델, 자연스러운 번역과 의역 제공',
level: 'vip'
}
};
this.init();
}
async init() {
try {
console.log('설정 페이지 초기화 시작');
// 탭 기능 초기화
this.initTabs();
await this.loadConfig();
await this.loadUserInfo();
await this.loadCurrentSettings();
await this.loadTimeAlarmSettings();
// UI 초기화
this.initializeUI();
this.initializeTimeAlarmUI();
this.setupEventListeners();
this.updateUI();
console.log('설정 페이지 초기화 완료');
} catch (error) {
console.error('설정 페이지 초기화 실패:', error);
this.showMessage('설정 페이지를 초기화하는 중 오류가 발생했습니다.', 'error');
}
}
// 탭 기능 초기화
initTabs() {
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const targetTab = button.dataset.tab;
// 모든 탭 버튼과 컨텐츠 비활성화
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// 선택된 탭 활성화
button.classList.add('active');
const targetContent = document.getElementById(`${targetTab}-tab`);
if (targetContent) {
targetContent.classList.add('active');
}
console.log(`탭 전환: ${targetTab}`);
});
});
}
async loadConfig() {
try {
const result = await chrome.storage.local.get('settings_config');
this.config = result.settings_config || {};
console.log('설정을 settings_config에서 로드함:', this.config);
} catch (error) {
console.error('설정 로드 실패:', error);
this.config = {};
}
}
async loadUserInfo() {
try {
console.log('사용자 정보 로드 시작...');
if (!this.config || !this.config.ACCESS_TOKEN) {
console.warn('액세스 토큰이 없습니다. 기본 회원으로 설정합니다.');
this.userInfo = { membership_level: 'basic' };
return;
}
// Background Script에 사용자 정보 요청
const response = await chrome.runtime.sendMessage({
action: 'getUserInfo',
token: this.config.ACCESS_TOKEN
});
if (!response || !response.success) {
throw new Error(response?.error || '사용자 정보를 불러올 수 없습니다.');
}
this.userInfo = response.user;
// membership_level이 없으면 기본값 설정
if (!this.userInfo.membership_level) {
this.userInfo.membership_level = 'basic';
}
console.log('사용자 정보 로드 완료:', this.userInfo);
} catch (error) {
console.error('사용자 정보 로드 실패:', error);
this.userInfo = { membership_level: 'basic' };
console.log('기본 회원으로 설정됨');
}
}
async loadCurrentSettings() {
try {
const result = await chrome.storage.local.get('translation_engine_settings');
this.currentSettings = result.translation_engine_settings || {};
// 기본값 설정 (구글만 활성화, 나머지는 비활성화)
const defaultSettings = {
google: true, // 구글만 기본 활성화
mymemory: false, // MyMemory 비활성화
deepl: false, // DeepL 비활성화
openai: false, // OpenAI 비활성화
gemini: false // Gemini 비활성화
};
// 기존 설정이 없으면 기본값 사용
Object.keys(defaultSettings).forEach(engine => {
if (this.currentSettings[engine] === undefined) {
this.currentSettings[engine] = defaultSettings[engine];
}
});
console.log('현재 설정 로드:', this.currentSettings);
} catch (error) {
console.error('현재 설정 로드 실패:', error);
// 기본값으로 구글만 활성화
this.currentSettings = {
google: true,
mymemory: false,
deepl: false,
openai: false,
gemini: false
};
}
}
async loadTimeAlarmSettings() {
try {
const result = await chrome.storage.local.get('time_alarm_settings');
this.timeAlarmSettings = result.time_alarm_settings || {
enabled: true,
workTime: 60, // 분
restTime: 5, // 분
autoZzim: false,
pomodoro: false,
cycle: 0
};
console.log('시간 알람 설정 로드 완료:', this.timeAlarmSettings);
} catch (error) {
console.error('시간 알람 설정 로드 실패:', error);
this.timeAlarmSettings = {
enabled: true,
workTime: 60,
restTime: 5,
autoZzim: false,
pomodoro: false,
cycle: 0
};
}
}
initializeUI() {
// 회원등급 정보 표시
const membershipElement = document.getElementById('current-membership');
const membershipInfoElement = document.getElementById('membership-engines-info');
if (membershipElement && this.userInfo) {
const level = this.userInfo.membership_level || 'basic';
const levelNames = {
'basic': '기본 회원',
'premium': '프리미엄 회원',
'vip': 'VIP 회원'
};
membershipElement.textContent = levelNames[level.toLowerCase()] || '기본 회원';
const availableEngines = this.availableEngines[level.toLowerCase()] || this.availableEngines.basic;
const engineNames = availableEngines.map(engine => this.engineInfo[engine].name);
membershipInfoElement.innerHTML = `
<strong>사용 가능한 번역 엔진:</strong><br>
${engineNames.join(', ')}<br><br>
<strong>등급별 혜택:</strong><br>
기본: Google, MyMemory (무료 엔진)<br>
프리미엄: + DeepL (고품질 엔진)<br>
VIP: + ChatGPT
`;
console.log('회원등급 UI 업데이트 완료:', level);
} else {
console.error('회원등급 표시 요소를 찾을 수 없습니다.');
}
}
// 시간 알람 UI 초기화
initializeTimeAlarmUI() {
this.updateTimeAlarmUI();
}
setupEventListeners() {
// 토글 스위치 이벤트
const toggleSwitches = document.querySelectorAll('.toggle-switch');
toggleSwitches.forEach(toggle => {
toggle.addEventListener('click', (e) => {
const engine = e.target.dataset.engine;
if (engine && !e.target.classList.contains('disabled')) {
this.toggleEngine(engine);
} else if (e.target.id === 'timeAlarmToggle') {
// 시간 알람 토글
this.timeAlarmSettings.enabled = !this.timeAlarmSettings.enabled;
this.updateTimeAlarmUI();
console.log('시간 알람 토글:', this.timeAlarmSettings.enabled);
}
});
});
// 저장 버튼
const saveButton = document.getElementById('save-settings');
if (saveButton) {
saveButton.addEventListener('click', () => this.saveSettings());
}
// 기본값 복원 버튼
const resetButton = document.getElementById('reset-settings');
if (resetButton) {
resetButton.addEventListener('click', () => this.resetToDefaults());
}
// 시간 입력 필드 이벤트
const workTimeInput = document.getElementById('workTimeInput');
const restTimeInput = document.getElementById('restTimeInput');
const autoZzimCheckbox = document.getElementById('autoZzimCheckbox');
const pomodoroToggle = document.getElementById('pomodoroToggle');
if (workTimeInput) {
workTimeInput.addEventListener('change', (e) => {
this.timeAlarmSettings.workTime = parseInt(e.target.value) || 60;
});
}
if (restTimeInput) {
restTimeInput.addEventListener('change', (e) => {
this.timeAlarmSettings.restTime = parseInt(e.target.value) || 5;
});
}
if (autoZzimCheckbox) {
autoZzimCheckbox.addEventListener('change', (e) => {
this.timeAlarmSettings.autoZzim = e.target.checked;
});
}
if (pomodoroToggle) {
pomodoroToggle.addEventListener('click', () => {
this.timeAlarmSettings.pomodoro = !this.timeAlarmSettings.pomodoro;
// 포모도로가 활성화되면 고정 값 적용
if (this.timeAlarmSettings.pomodoro) {
this.timeAlarmSettings.workTime = 35;
this.timeAlarmSettings.restTime = 5;
}
this.updateTimeAlarmUI();
});
}
}
updateUI() {
const userLevel = (this.userInfo?.membership_level || 'basic').toLowerCase();
const availableEngines = this.availableEngines[userLevel] || this.availableEngines.basic;
// 각 엔진 아이템 업데이트
const engineItems = document.querySelectorAll('.engine-item');
engineItems.forEach(item => {
const engine = item.dataset.engine;
const toggle = item.querySelector('.toggle-switch');
if (availableEngines.includes(engine)) {
// 사용 가능한 엔진
item.classList.remove('disabled');
toggle.classList.remove('disabled');
// 현재 설정에 따라 토글 상태 설정
if (this.currentSettings[engine]) {
toggle.classList.add('active');
} else {
toggle.classList.remove('active');
}
} else {
// 사용 불가능한 엔진
item.classList.add('disabled');
toggle.classList.add('disabled');
toggle.classList.remove('active');
}
});
// 시간 알람 UI 업데이트
this.updateTimeAlarmUI();
}
updateTimeAlarmUI() {
const timeAlarmToggle = document.getElementById('timeAlarmToggle');
const workTimeInput = document.getElementById('workTimeInput');
const restTimeInput = document.getElementById('restTimeInput');
const autoZzimCheckbox = document.getElementById('autoZzimCheckbox');
if (timeAlarmToggle) {
if (this.timeAlarmSettings.enabled) {
timeAlarmToggle.classList.add('active');
} else {
timeAlarmToggle.classList.remove('active');
}
}
const pomodoroToggle = document.getElementById('pomodoroToggle');
if (pomodoroToggle) {
if (this.timeAlarmSettings.pomodoro) {
pomodoroToggle.classList.add('active');
} else {
pomodoroToggle.classList.remove('active');
}
}
if (workTimeInput) {
workTimeInput.value = this.timeAlarmSettings.workTime;
workTimeInput.disabled = this.timeAlarmSettings.pomodoro;
}
if (restTimeInput) {
restTimeInput.value = this.timeAlarmSettings.restTime;
restTimeInput.disabled = this.timeAlarmSettings.pomodoro;
}
if (autoZzimCheckbox) {
autoZzimCheckbox.checked = this.timeAlarmSettings.autoZzim;
}
}
toggleEngine(engine) {
this.currentSettings[engine] = !this.currentSettings[engine];
this.updateUI();
console.log(`${engine} 엔진 토글:`, this.currentSettings[engine]);
}
async saveSettings() {
try {
this.showLoading(true);
// 시간 알람 설정 업데이트
if (this.timeAlarmSettings.pomodoro) {
// 강제 고정 값
this.timeAlarmSettings.workTime = 35;
this.timeAlarmSettings.restTime = 5;
}
const workTimeInput = document.getElementById('workTimeInput');
const restTimeInput = document.getElementById('restTimeInput');
const autoZzimCheckbox = document.getElementById('autoZzimCheckbox');
if (workTimeInput) {
this.timeAlarmSettings.workTime = parseInt(workTimeInput.value) || 60;
}
if (restTimeInput) {
this.timeAlarmSettings.restTime = parseInt(restTimeInput.value) || 5;
}
if (autoZzimCheckbox) {
this.timeAlarmSettings.autoZzim = autoZzimCheckbox.checked;
}
// 번역 엔진 설정 저장
await chrome.storage.local.set({
'translation_engine_settings': this.currentSettings
});
// 시간 알람 설정 저장
await chrome.storage.local.set({
'time_alarm_settings': this.timeAlarmSettings
});
// Background script에 시간 알람 설정 변경 알림
chrome.runtime.sendMessage({
action: 'updateTimeAlarmSettings',
settings: this.timeAlarmSettings
});
console.log('설정 저장 완료:', {
translation: this.currentSettings,
timeAlarm: this.timeAlarmSettings
});
this.showMessage('설정이 성공적으로 저장되었습니다.', 'success');
} catch (error) {
console.error('설정 저장 실패:', error);
this.showMessage('설정 저장 중 오류가 발생했습니다.', 'error');
} finally {
this.showLoading(false);
}
}
async resetToDefaults() {
if (!confirm('모든 설정을 기본값으로 복원하시겠습니까?')) {
return;
}
try {
this.showLoading(true);
// 기본값으로 재설정 (구글만 활성화)
this.currentSettings = {
google: true,
mymemory: false,
deepl: false,
openai: false,
gemini: false
};
// 저장
await chrome.storage.local.set({
'translation_engine_settings': this.currentSettings
});
// UI 업데이트
this.updateUI();
// 시간 알람 설정 초기화
this.timeAlarmSettings = {
enabled: true,
workTime: 60,
restTime: 5,
autoZzim: false,
pomodoro: false,
cycle: 0
};
// 저장소에서 시간 알람 설정 제거
await chrome.storage.local.remove('time_alarm_settings');
// Background script에 알림
chrome.runtime.sendMessage({
action: 'updateTimeAlarmSettings',
settings: this.timeAlarmSettings
});
console.log('기본값 복원 완료');
this.showMessage('설정이 기본값으로 복원되었습니다.', 'success');
} catch (error) {
console.error('기본값 복원 실패:', error);
this.showMessage('기본값 복원 중 오류가 발생했습니다.', 'error');
} finally {
this.showLoading(false);
}
}
showMessage(text, type = 'info') {
const messageElement = document.getElementById('message');
if (messageElement) {
messageElement.textContent = text;
messageElement.className = `message ${type}`;
messageElement.style.display = 'block';
// 3초 후 자동 숨김
setTimeout(() => {
messageElement.style.display = 'none';
}, 3000);
}
}
showLoading(show) {
const loadingElement = document.getElementById('loading');
if (loadingElement) {
loadingElement.style.display = show ? 'block' : 'none';
}
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
console.log('설정 페이지 DOM 로드 완료');
new SettingsManager();
});
// 창 닫기 전 확인
window.addEventListener('beforeunload', (e) => {
// 설정이 변경되었는지 확인하는 로직을 추가할 수 있음
// 현재는 단순히 로그만 출력
console.log('설정 페이지 종료');
});

1
test/README.MD Normal file
View File

@ -0,0 +1 @@
npx web-ext run --target=chromium --start-url="https://www.naver.com"

195
test/database_schema.sql Normal file
View File

@ -0,0 +1,195 @@
-- 찜관리 기능을 위한 데이터베이스 스키마
-- 1. 찜 기록 테이블 (public.jjim)
CREATE TABLE IF NOT EXISTS public.jjim (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
market_url TEXT NOT NULL,
market_name TEXT,
market_nickname TEXT,
zzim_count INTEGER DEFAULT 0,
zzim_type VARCHAR(20) NOT NULL CHECK (zzim_type IN ('my_market', 'mutual')),
mileage_earned INTEGER DEFAULT 0,
target_user_id UUID,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT fk_jjim_user_id FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE,
CONSTRAINT fk_jjim_target_user_id FOREIGN KEY (target_user_id) REFERENCES auth.users(id) ON DELETE SET NULL
);
-- 2. 사용자 마켓 통합 테이블 (public.user_markets) - 찜 관련 필드 추가
CREATE TABLE IF NOT EXISTS public.user_markets (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
-- 마켓 목록 (JSONB 배열)
my_markets JSONB DEFAULT '[]'::jsonb,
-- 찜 통계 필드들 (users 테이블에서 이관)
my_zzim INTEGER DEFAULT 0, -- 받은 찜 총 개수
zzim_mile INTEGER DEFAULT 0, -- 총 마일리지
available_zzim_mile INTEGER DEFAULT 0, -- 사용 가능한 마일리지
today_zzim_count INTEGER DEFAULT 0, -- 오늘 찜한 개수
today_zzim_date DATE DEFAULT CURRENT_DATE, -- 마지막 찜한 날짜
-- 회원등급 (users 테이블과 동기화)
membership_level VARCHAR(20) DEFAULT 'basic',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT fk_user_markets_user_id FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE,
CONSTRAINT unique_user_markets_user_id UNIQUE (user_id)
);
-- 3. 회원등급별 찜 제한 테이블 (public.membership_zzim_limits) - 개선
CREATE TABLE IF NOT EXISTS public.membership_zzim_limits (
id BIGSERIAL PRIMARY KEY,
membership_level VARCHAR(20) NOT NULL UNIQUE,
-- 찜 제한 설정
daily_zzim_limit INTEGER DEFAULT 50, -- 일일 찜 제한
max_zzim_mileage INTEGER DEFAULT 500, -- 최대 마일리지 보유량
mileage_per_zzim INTEGER DEFAULT 1, -- 찜당 마일리지
-- 추가 제한 설정
max_markets INTEGER DEFAULT 5, -- 최대 등록 가능한 마켓 수
mutual_zzim_enabled BOOLEAN DEFAULT TRUE, -- 품앗이 기능 사용 가능 여부
background_zzim_enabled BOOLEAN DEFAULT FALSE, -- 백그라운드 찜 기능 사용 가능 여부
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 4. 기본 회원등급 데이터 삽입
INSERT INTO public.membership_zzim_limits (membership_level, daily_zzim_limit, max_zzim_mileage, mileage_per_zzim, max_markets, mutual_zzim_enabled, background_zzim_enabled)
VALUES
('basic', 50, 500, 1, 3, TRUE, FALSE),
('premium', 100, 1000, 1, 10, TRUE, TRUE),
('vip', 200, 2000, 1, 20, TRUE, TRUE)
ON CONFLICT (membership_level) DO UPDATE SET
daily_zzim_limit = EXCLUDED.daily_zzim_limit,
max_zzim_mileage = EXCLUDED.max_zzim_mileage,
mileage_per_zzim = EXCLUDED.mileage_per_zzim,
max_markets = EXCLUDED.max_markets,
mutual_zzim_enabled = EXCLUDED.mutual_zzim_enabled,
background_zzim_enabled = EXCLUDED.background_zzim_enabled,
updated_at = NOW();
-- 5. 마켓 데이터 구조 예시 (my_markets JSONB 필드 내용)
/*
my_markets JSONB :
[
{
"market_url": "https://smartstore.naver.com/example",
"market_name": "예시 마켓",
"market_nickname": "내 마켓",
"is_visible": true, -- 다른 사람에게 노출 여부
"for_zzim": true, -- 내 마켓 찜하기 대상 여부
"for_mutual_zzim": true, -- 품앗이 대상 여부 (새로 추가)
"zzim_received_count": 0, -- 받은 찜 개수
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]
*/
-- 6. 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_jjim_user_id ON public.jjim(user_id);
CREATE INDEX IF NOT EXISTS idx_jjim_target_user_id ON public.jjim(target_user_id);
CREATE INDEX IF NOT EXISTS idx_jjim_created_at ON public.jjim(created_at);
CREATE INDEX IF NOT EXISTS idx_jjim_zzim_type ON public.jjim(zzim_type);
CREATE INDEX IF NOT EXISTS idx_user_markets_user_id ON public.user_markets(user_id);
CREATE INDEX IF NOT EXISTS idx_user_markets_membership_level ON public.user_markets(membership_level);
CREATE INDEX IF NOT EXISTS idx_user_markets_today_zzim_date ON public.user_markets(today_zzim_date);
-- 7. RLS (Row Level Security) 정책 설정
-- user_markets 테이블: 품앗이를 위해 읽기는 모든 사용자에게 허용, 쓰기는 본인만
ALTER TABLE public.user_markets ENABLE ROW LEVEL SECURITY;
-- 본인 데이터 읽기/쓰기 허용
CREATE POLICY "Users can view and edit their own user_markets data" ON public.user_markets
FOR ALL USING (auth.uid() = user_id);
-- 품앗이를 위한 다른 사용자 데이터 읽기 허용 (민감하지 않은 정보만)
CREATE POLICY "Users can view others user_markets for mutual zzim" ON public.user_markets
FOR SELECT USING (true);
-- jjim 테이블: 본인이 한 찜 기록만 조회/수정 가능
ALTER TABLE public.jjim ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view and edit their own jjim records" ON public.jjim
FOR ALL USING (auth.uid() = user_id);
-- membership_zzim_limits 테이블: 모든 사용자가 읽기 가능
ALTER TABLE public.membership_zzim_limits ENABLE ROW LEVEL SECURITY;
CREATE POLICY "All users can view membership limits" ON public.membership_zzim_limits
FOR SELECT USING (true);
-- 8. 데이터 마이그레이션 함수 (users 테이블에서 user_markets로 찜 데이터 이관)
CREATE OR REPLACE FUNCTION migrate_zzim_data_to_user_markets()
RETURNS void AS $$
BEGIN
-- users 테이블의 찜 관련 데이터를 user_markets로 이관
INSERT INTO public.user_markets (
user_id,
my_zzim,
zzim_mile,
available_zzim_mile,
today_zzim_count,
today_zzim_date,
membership_level
)
SELECT
id,
COALESCE(my_zzim, 0),
COALESCE(zzim_mile, 0),
COALESCE(available_zzim_mile, COALESCE(zzim_mile, 0)),
COALESCE(today_zzim_count, 0),
COALESCE(today_zzim_date, CURRENT_DATE),
COALESCE(membership_level, 'basic')
FROM auth.users
WHERE id NOT IN (SELECT user_id FROM public.user_markets)
ON CONFLICT (user_id) DO UPDATE SET
my_zzim = EXCLUDED.my_zzim,
zzim_mile = EXCLUDED.zzim_mile,
available_zzim_mile = EXCLUDED.available_zzim_mile,
today_zzim_count = EXCLUDED.today_zzim_count,
today_zzim_date = EXCLUDED.today_zzim_date,
membership_level = EXCLUDED.membership_level,
updated_at = NOW();
RAISE NOTICE 'Zzim data migration completed';
END;
$$ LANGUAGE plpgsql;
-- 9. 트리거 함수: user_markets와 users 테이블 동기화
CREATE OR REPLACE FUNCTION sync_user_markets_to_users()
RETURNS TRIGGER AS $$
BEGIN
-- user_markets 테이블의 변경사항을 users 테이블에 반영
UPDATE auth.users
SET
my_zzim = NEW.my_zzim,
zzim_mile = NEW.zzim_mile,
available_zzim_mile = NEW.available_zzim_mile,
today_zzim_count = NEW.today_zzim_count,
today_zzim_date = NEW.today_zzim_date,
membership_level = NEW.membership_level
WHERE id = NEW.user_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 트리거 생성
CREATE TRIGGER sync_user_markets_to_users_trigger
AFTER INSERT OR UPDATE ON public.user_markets
FOR EACH ROW
EXECUTE FUNCTION sync_user_markets_to_users();
-- 10. 마이그레이션 실행 (주석 해제하여 실행)
-- SELECT migrate_zzim_data_to_user_markets();

207
test/h.html Normal file
View File

@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>회원등급 및 가격안내</title>
<style>
body {
background: #181d27;
color: #f3f6fc;
font-family: 'Noto Sans KR', 'Malgun Gothic', sans-serif;
margin: 0;
padding: 30px 0;
}
.container {
max-width: 650px;
margin: 0 auto;
padding: 24px;
background: #23293a;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(30,40,70,0.10);
}
h2 {
color: #38b6ff;
margin-top: 0;
font-size: 2em;
text-shadow: 1px 1px 7px #111b;
}
/* 등급 카드 영역 */
.grade-cards {
display: flex;
gap: 18px;
margin-bottom: 28px;
flex-wrap: wrap;
justify-content: center;
}
.grade-card {
flex: 1 1 140px;
min-width: 180px;
background: #222b3d;
border-radius: 13px;
padding: 20px 18px 14px 18px;
box-shadow: 0 2px 12px rgba(56,182,255,0.10);
border: 2px solid #1b2a44;
position: relative;
text-align: center;
transition: transform 0.15s;
}
.grade-card:hover {
transform: translateY(-7px) scale(1.04);
border-color: #38b6ff;
box-shadow: 0 5px 26px #38b6ff33;
}
.grade-title {
font-size: 1.19em;
font-weight: bold;
margin-bottom: 7px;
letter-spacing: 1px;
}
.basic { color: #8efaff; }
.premium { color: #ffe066; }
.vip { color: #fc7676; }
.grade-detail {
font-size: 1em;
color: #c3d7ee;
margin-bottom: 2px;
}
.api-limit {
background: #172436;
border-radius: 9px;
display: inline-block;
padding: 3px 14px;
margin-top: 5px;
font-size: 0.97em;
color: #5be0ff;
font-weight: bold;
letter-spacing: 1px;
}
/* 가격 안내 표 */
.price-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
margin-top: 18px;
background: none;
}
.price-table th, .price-table td {
padding: 13px 10px;
text-align: center;
}
.price-table th {
background: #243350;
color: #88e6ff;
font-size: 1.08em;
letter-spacing: 0.5px;
border-radius: 8px 8px 0 0;
border-bottom: 2px solid #38b6ff99;
}
.price-table td {
background: #192339;
font-size: 1.07em;
border-radius: 6px;
color: #fff;
}
.price-table .highlight {
font-weight: bold;
background: #222d48;
color: #38b6ff;
border-left: 5px solid #38b6ff99;
}
.bonus {
color: #ffe066;
font-size: 0.95em;
}
/* 모바일 대응 */
@media (max-width: 650px) {
.container { padding: 8px 2vw;}
.grade-cards {flex-direction: column; gap: 9px;}
.grade-card { min-width: 0; padding: 14px 10px 8px 10px;}
.price-table th, .price-table td { padding: 10px 5px; font-size: 0.98em;}
}
</style>
</head>
<body>
<div class="container">
<h2>회원등급 안내</h2>
<div class="grade-cards">
<div class="grade-card">
<div class="grade-title basic">Basic</div>
<div class="grade-detail">기본기능 제공</div>
<div class="grade-detail">API 20회/일</div>
<div class="api-limit">OCR 미포함</div>
</div>
<div class="grade-card">
<div class="grade-title premium">Premium</div>
<div class="grade-detail">Basic + OCR</div>
<div class="grade-detail">API 50회/일</div>
<div class="api-limit" style="color:#ffe066">OCR 포함</div>
</div>
<div class="grade-card">
<div class="grade-title vip">VIP</div>
<div class="grade-detail">Premium + API 한도 UP</div>
<div class="grade-detail">API 200회/일</div>
<div class="api-limit" style="color:#fc7676">모든 기능 무제한</div>
</div>
</div>
<h2 style="margin-top:38px;">가격 안내</h2>
<table class="price-table">
<tr>
<th>기간</th>
<th>등급</th>
<th>가격</th>
<th>혜택</th>
</tr>
<tr>
<td rowspan="3" class="highlight">1개월</td>
<td>Basic</td>
<td>5만원</td>
<td></td>
</tr>
<tr>
<td>Premium</td>
<td>7만원</td>
<td></td>
</tr>
<tr>
<td>VIP</td>
<td>10만원</td>
<td></td>
</tr>
<tr>
<td rowspan="3" class="highlight">3개월</td>
<td>Basic</td>
<td>15만원</td>
<td class="bonus">+ 추가 15일 제공</td>
</tr>
<tr>
<td>Premium</td>
<td>21만원</td>
<td class="bonus">+ 추가 15일 제공</td>
</tr>
<tr>
<td>VIP</td>
<td>30만원</td>
<td class="bonus">+ 추가 15일 제공</td>
</tr>
<tr>
<td rowspan="3" class="highlight">6개월</td>
<td>Basic</td>
<td>30만원</td>
<td class="bonus">+ 추가 1개월 제공</td>
</tr>
<tr>
<td>Premium</td>
<td>42만원</td>
<td class="bonus">+ 추가 1개월 제공</td>
</tr>
<tr>
<td>VIP</td>
<td>60만원</td>
<td class="bonus">+ 추가 1개월 제공</td>
</tr>
</table>
</div>
</body>
</html>

56
test/readme_zzim.md Normal file
View File

@ -0,0 +1,56 @@
1) public.users
회원(=auth.users)의 확장 테이블로 사용
집계형 필드만 둡니다.
id (PK, auth.users FK)
membership_level TEXT → FK public.membership_levels.level
my_zzim INT -- 내가 받은 찜 총합
zzim_mile INT -- 누적 마일리지
available_zzim_mile INT -- 사용가능(미소모) 마일리지
today_zzim_count INT
today_zzim_date DATE
created_at, updated_at
2) public.membership_levels
level PK ('basic','premium'…)
api_call_limit INT
daily_zzim_limit INT
max_zzim_mileage INT
mileage_per_zzim INT
3) public.user_markets
id PK
user_id FK → public.users.id
my_markets JSONB -- [{ market_url , … , zzim_received_count , … }]
created_at, updated_at
4) 뷰(View) 하나 추가하면 코드가 훨씬 단순합니다.
CREATE OR REPLACE VIEW public.v_user_market_stats AS
SELECT
u.id AS user_id,
u.membership_level,
u.available_zzim_mile,
u.my_zzim,
u.zzim_mile,
m.my_markets
FROM public.users u
LEFT JOIN public.user_markets m ON m.user_id = u.id;
-- 한번만 실행
WITH s AS (
SELECT user_id,
COALESCE(SUM((m->>'zzim_received_count')::INT),0) AS total_zzim
FROM public.user_markets,
LATERAL jsonb_array_elements(my_markets) m
GROUP BY user_id
)
UPDATE public.users u
SET my_zzim = s.total_zzim
FROM s
WHERE u.id = s.user_id;

View File

@ -0,0 +1,34 @@
-- public.events 테이블에 휴식 시간 추천활동 샘플 데이터 추가
-- event_type = 'rest_time', message 필드는 JSON 형식으로 저장
INSERT INTO public.events (event_type, message, created_at, event_start, amount_day) VALUES
('rest_time', '{"activity": "🚶‍♀️ 가벼운 산책을 하며 신선한 공기를 마셔보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "💧 물 한 잔을 마시며 수분을 보충하고 몸을 깨워보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🧘‍♀️ 5분간 심호흡을 하며 명상으로 마음을 정리해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🤸‍♀️ 간단한 목과 어깨 스트레칭으로 뭉친 근육을 풀어보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "👀 20-20-20 법칙: 20초간 20피트(6m) 떨어진 곳을 보며 눈을 쉬게 해주세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🚽 화장실을 다녀오며 잠시 자리에서 일어나보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🌱 창밖을 보며 자연을 감상하고 마음의 평화를 찾아보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "📱 스마트폰을 내려놓고 디지털 디톡스 시간을 가져보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "☕ 따뜻한 차나 커피 한 잔으로 여유로운 시간을 만들어보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🎵 좋아하는 음악을 들으며 잠시 현실을 잊고 휴식해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "📚 짧은 명언이나 좋은 글을 읽으며 마음에 영양을 공급해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🤝 동료나 가족과 간단한 안부 인사를 나누며 소통해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🧴 손목, 목, 어깨를 가볍게 마사지하며 피로를 풀어보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🏃‍♀️ 제자리에서 가볍게 몸을 움직이며 혈액순환을 도와보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🍎 건강한 간식(견과류, 과일)으로 에너지를 충전해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🌬️ 발코니나 창가에서 신선한 공기를 마시며 기분전환을 해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🧹 책상 주변을 정리하며 작업 환경을 깔끔하게 만들어보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🎯 오늘의 목표를 다시 한번 점검하며 동기부여를 해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "💪 간단한 팔굽혀펴기나 스쿼트로 몸에 활력을 불어넣어보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🌟 지금까지 완료한 업무를 되돌아보며 성취감을 느껴보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🎨 간단한 낙서나 그림을 그리며 창의력을 자극해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🧠 브레인 스토밍: 새로운 아이디어나 해결책을 자유롭게 생각해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "📞 소중한 사람에게 안부 전화를 걸어 따뜻한 대화를 나누어보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🔥 업무 스트레스를 떨쳐내고 월매출 1억 달성 의지를 다져보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "💼 성공한 미래의 모습을 상상하며 동기부여 시간을 가져보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"text": "🏆 오늘 하루도 열심히 달려온 자신에게 박수를 보내세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "⚡ 5분간 파워 낮잠으로 에너지를 완전히 충전해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🎪 좋아하는 유튜브 영상을 하나 보며 웃음으로 스트레스를 날려보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🌈 긍정적인 생각으로 마음을 채우고 다시 시작할 준비를 해보세요"}', NOW(), NOW(), 9999),
('rest_time', '{"activity": "🎲 새로운 도전을 위한 작은 계획을 세워보는 시간을 가져보세요"}', NOW(), NOW(), 9999);

View File

@ -0,0 +1,11 @@
{
"deepl": {
"authKey": "6f07317d-f155-46f9-84a0-033ed942c9c6:fx"
},
"openai": {
"apiKey": "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"
},
"gemini": {
"apiKey": "AIzaSyByn419VWPyQlbdYJXderUEFz7_PeMvZhY"
}
}

View File

@ -1,83 +0,0 @@
// background.js (Service Worker)
// 1) 확장 프로그램 설치/업데이트 시 컨텍스트 메뉴 생성
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: "searchTrademark",
title: "지재권 검색",
contexts: ["selection"] // 텍스트를 드래그 선택한 상태에서만 메뉴 표시
});
// (선택) 서비스 워커 keep-alive
chrome.alarms.create("keepAlive", { periodInMinutes: 4 });
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "keepAlive") {
console.log("[background.js] 서비스 워커 유지 알람 실행됨");
}
});
// 2) 컨텍스트 메뉴 클릭 시 처리
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === "searchTrademark") {
// 선택된 텍스트(드래그된 내용)
const keyword = info.selectionText.trim();
if (!keyword) return;
console.log("[background.js] 컨텍스트 메뉴 검색 키워드:", keyword);
// MarkInfo 검색 URL (키워드 검색 예시)
const url = buildMarkInfoUrl(keyword);
// fetch로 마크인포 요청
fetch(url)
.then((resp) => {
if (!resp.ok) {
throw new Error(`네트워크 오류: ${resp.status}`);
}
return resp.text();
})
.then((html) => {
// __NUXT_DATA__ 추출 (정규표현식 예시)
const match = /<script[^>]*id="__NUXT_DATA__"[^>]*>([\s\S]*?)<\/script>/i.exec(html);
if (!match) {
throw new Error("__NUXT_DATA__ 태그를 찾을 수 없습니다.");
}
const jsonString = match[1];
const globalData = JSON.parse(jsonString);
// 예: globalData 배열 중 첫 번째 아이템이 주요 정보라고 가정
let firstItem = null;
if (Array.isArray(globalData) && globalData.length > 0) {
firstItem = globalData[0];
}
// 노티피케이션에 표시할 문자열 생성
let msg = "";
if (firstItem && typeof firstItem === "object") {
// 예: applicationNum, trademarkName 등 필요한 필드만 추출
const applicationNum = firstItem.applicationNum || "(출원번호 없음)";
const trademarkName = firstItem.trademarkName || "(상표명 없음)";
msg = `출원번호: ${applicationNum}\n상표명: ${trademarkName}`;
} else {
// globalData 전체 중 일부만 표시
msg = JSON.stringify(globalData).slice(0, 80) + "...";
}
// 알림 표시
chrome.notifications.create({
type: "basic",
iconUrl: "icon.png",
title: "지재권 검색 결과",
message: `검색 키워드: ${keyword}\n결과: ${msg}`
});
});
}
});
// 3) URL 생성 함수
function buildMarkInfoUrl(keyword) {
const encoded = encodeURIComponent(keyword);
return `https://markinfo.kr/search?page=1&size=20&sort=_score,desc&sort=applicationDate,desc&searchType=ST01&searchKeyword=${encoded}&statuses=APPLICATION&statuses=PUBLICATION&statuses=REGISTRATION`;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@ -1,21 +0,0 @@
{
"manifest_version": 3,
"name": "지재권 검색 확장 (컨텍스트 메뉴)",
"version": "1.0",
"description": "드래그한 텍스트를 우클릭 → '지재권 검색'으로 마크인포 검색을 수행합니다.",
"permissions": [
"contextMenus",
"storage",
"notifications",
"alarms"
],
"host_permissions": [
"https://markinfo.kr/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_icon": "icon.png"
}
}

827
zzim.html Normal file
View File

@ -0,0 +1,827 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>찜관리</title>
<style>
body {
font-family: 'Segoe UI', sans-serif;
margin: 0;
padding: 20px;
background: #f8f9fa;
}
.stats-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
text-align: center;
position: relative;
overflow: hidden;
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
/* 오늘 찜한 갯수: 파란색/활동적인 느낌 */
.stat-card.action-card {
background: linear-gradient(135deg, #ffffff 0%, #f0f7ff 100%);
border: 1px solid #cce5ff;
}
.stat-card.action-card .stat-title {
color: #0056b3;
font-weight: 600;
}
.stat-card.action-card .stat-value {
color: #007bff;
}
/* 찜 마일리지: 금색/화폐 느낌 */
.stat-card.mileage-card {
background: linear-gradient(135deg, #fffdf0 0%, #fff8e1 100%);
border: 1px solid #ffeeba;
}
.stat-card.mileage-card .stat-title {
color: #856404;
font-weight: 600;
}
.stat-card.mileage-card .stat-value {
color: #d39e00;
}
.stat-title {
font-size: 14px;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.stat-value {
font-size: 28px;
font-weight: 800;
font-family: 'Segoe UI', sans-serif;
}
.stat-limit {
font-size: 12px;
color: #888;
margin-top: 5px;
}
.section {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.section-title {
font-size: 18px;
font-weight: bold;
color: #2c3e50;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.market-form {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 10px;
align-items: center;
margin-bottom: 15px;
}
.market-form input[type="text"] {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.market-form input[type="text"]:focus {
outline: none;
border-color: #3498db;
border-color: #3498db;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: background-color 0.2s;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #219a52;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover {
background: #e67e22;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-large {
padding: 15px 30px;
font-size: 16px;
margin: 10px;
}
.market-list {
margin-top: 20px;
}
.market-item {
border: 1px solid #e1e8ed;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.market-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.market-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
opacity: 0;
transition: opacity 0.3s ease;
}
.market-item:hover::before {
opacity: 1;
}
.market-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.market-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.market-checkboxes {
display: flex;
flex-direction: column;
gap: 5px;
}
.market-checkbox-input {
width: 18px;
height: 18px;
accent-color: #667eea;
cursor: pointer;
}
.market-checkbox-label {
font-size: 12px;
font-weight: 600;
color: #2c3e50;
cursor: pointer;
user-select: none;
}
.market-info {
flex: 1;
margin: 0 15px;
}
.market-name {
font-weight: bold;
color: #2c3e50;
margin-bottom: 5px;
font-size: 16px;
}
.market-nickname {
color: #666;
font-size: 14px;
margin-bottom: 8px;
font-weight: 500;
}
.market-url {
color: #888;
font-size: 12px;
word-break: break-all;
background: #f8f9fa;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid #e1e8ed;
margin-bottom: 10px;
font-family: 'Courier New', monospace;
}
.market-stats {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
.zzim-count {
font-size: 12px;
color: #666;
background: #e8f4f8;
padding: 4px 8px;
border-radius: 12px;
font-weight: 500;
}
.visibility-status {
font-size: 12px;
padding: 4px 10px;
border-radius: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.visibility-status.visible {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.visibility-status.hidden {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.created-date {
font-size: 12px;
color: #888;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 12px;
}
.market-actions {
display: flex;
gap: 8px;
flex-direction: column;
}
.market-actions .btn {
padding: 8px 12px;
font-size: 12px;
border-radius: 6px;
min-width: 70px;
font-weight: 600;
transition: all 0.2s ease;
}
.market-actions .btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.action-buttons {
display: flex;
gap: 15px;
justify-content: center;
margin-top: 20px;
}
.loading {
text-align: center;
color: #3498db;
font-size: 14px;
margin: 20px 0;
}
.error {
color: #e74c3c;
font-size: 14px;
margin: 10px 0;
}
.success {
color: #27ae60;
font-size: 14px;
margin: 10px 0;
}
.url-prefix {
background: #f8f9fa;
padding: 8px 12px;
border: 1px solid #ddd;
border-right: none;
border-radius: 4px 0 0 4px;
font-size: 14px;
color: #666;
white-space: nowrap;
}
.url-input {
border-radius: 0 4px 4px 0 !important;
border-left: none !important;
}
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(3px);
animation: fadeIn 0.3s ease;
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 0;
border: none;
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease;
overflow: hidden;
}
.modal-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 25px;
border-bottom: none;
position: relative;
}
.modal-title {
margin: 0;
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.modal-close {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.modal-close:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.modal-body {
padding: 25px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #2c3e50;
font-size: 14px;
}
.form-input {
width: 100%;
padding: 12px 15px;
border: 2px solid #e1e8ed;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-textarea {
min-height: 80px;
resize: vertical;
font-family: inherit;
}
.modal-footer {
padding: 20px 25px;
border-top: 1px solid #e1e8ed;
display: flex;
justify-content: flex-end;
gap: 10px;
background-color: #f8f9fa;
}
.btn-modal {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 80px;
}
.btn-modal-cancel {
background-color: #6c757d;
color: white;
}
.btn-modal-cancel:hover {
background-color: #5a6268;
}
.btn-modal-save {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-modal-save:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-modal-save:disabled {
background: #cccccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-50px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 체크박스 스타일링 */
.checkbox-container {
display: flex;
align-items: center;
gap: 10px;
margin-top: 15px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 2px solid #e1e8ed;
transition: border-color 0.3s ease;
}
.checkbox-container:hover {
border-color: #667eea;
}
.custom-checkbox {
position: relative;
display: inline-block;
width: 20px;
height: 20px;
}
.custom-checkbox input {
opacity: 0;
width: 0;
height: 0;
}
.checkbox-checkmark {
position: absolute;
top: 0;
left: 0;
height: 20px;
width: 20px;
background-color: white;
border: 2px solid #ddd;
border-radius: 4px;
transition: all 0.3s ease;
}
.custom-checkbox:hover input ~ .checkbox-checkmark {
border-color: #667eea;
}
.custom-checkbox input:checked ~ .checkbox-checkmark {
background-color: #667eea;
border-color: #667eea;
}
.checkbox-checkmark:after {
content: "";
position: absolute;
display: none;
}
.custom-checkbox input:checked ~ .checkbox-checkmark:after {
display: block;
}
.custom-checkbox .checkbox-checkmark:after {
left: 6px;
top: 2px;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-label {
font-weight: 600;
color: #2c3e50;
cursor: pointer;
user-select: none;
}
.checkbox-description {
font-size: 12px;
color: #666;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="stats-container">
<!-- 오늘 활동량 카드 (블루 테마) -->
<div class="stat-card action-card">
<div class="stat-title">⚡ 오늘 찜한 갯수</div>
<div class="stat-value" id="today-zzim-count">0</div>
<div class="stat-limit">/ <span id="today-zzim-limit">300</span></div>
</div>
<!-- 마일리지 카드 (골드 테마) -->
<div class="stat-card mileage-card">
<div class="stat-title">💰 찜 마일리지</div>
<div class="stat-value" id="zzim-mileage">0</div>
<div class="stat-limit">보유중 (품앗이 보상 +10%)</div>
</div>
</div>
<div class="section">
<div class="section-title">
🏪 내 마켓 등록
</div>
<div class="market-form">
<span class="url-prefix">https://smartstore.naver.com/</span>
<input type="text" id="market-url" placeholder="마켓 주소 (예: abcd1234)" class="url-input">
<button class="btn btn-primary" id="add-market-btn">등록</button>
</div>
<div class="market-form">
<label>마켓 이름:</label>
<input type="text" id="market-name" placeholder="마켓 이름">
<span></span>
</div>
<div class="market-form">
<label>마켓 별명:</label>
<input type="text" id="market-nickname" placeholder="마켓 별명">
<span></span>
</div>
<div class="market-list">
<h4>내 마켓 목록</h4>
<div id="my-markets-list">
<!-- 마켓 목록이 여기에 표시됩니다 -->
</div>
</div>
</div>
<div class="section">
<div class="section-title">
💝 찜하기 작업
</div>
<!-- 백그라운드 실행 옵션 추가 -->
<div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 6px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox" id="background-mode" style="transform: scale(1.2);">
<span style="font-weight: bold; color: #2c3e50;">🔄 백그라운드 모드</span>
</label>
<div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 25px;">
체크하면 새 탭에서 백그라운드로 찜하기를 실행합니다.
</div>
</div>
<!-- 찜하기 옵션 설정 -->
<div style="margin-bottom: 20px; padding: 20px; background: #f8f9fa; border-radius: 8px; border: 2px solid #e1e8ed;">
<h4 style="margin: 0 0 15px 0; color: #2c3e50; font-size: 16px;">⚙️ 찜하기 옵션</h4>
<!-- 찜하기 속도 설정 -->
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: #2c3e50; font-size: 14px;">
⏱️ 찜하기 속도 (찜 간격)
</label>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 5px;">
<span style="font-size: 12px; color: #666;">기본 간격:</span>
<input type="number" id="base-delay" value="300" min="200" max="5000" step="100"
style="width: 80px; padding: 4px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px;"
disabled>
<span style="font-size: 12px; color: #666;">ms</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<span style="font-size: 12px; color: #666;">+ 추가 간격:</span>
<input type="number" id="user-delay" value="0" min="0" max="9000" step="100"
style="width: 80px; padding: 4px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px;">
<span style="font-size: 12px; color: #666;">ms</span>
</div>
<div style="display: flex; align-items: center; gap: 5px;">
<span style="font-size: 12px; color: #666;">= 총 간격:</span>
<span id="total-delay" style="font-weight: bold; color: #667eea;">300ms</span>
</div>
</div>
<div style="font-size: 11px; color: #888; margin-top: 5px;">
💡 찜과 찜 사이의 대기 시간을 설정합니다. (0.2초~10초)
</div>
</div>
<!-- 최신상품 우선 찜하기 옵션 -->
<div style="margin-bottom: 10px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox" id="latest-first" style="transform: scale(1.2);">
<span style="font-weight: 600; color: #2c3e50;">🆕 최신상품 우선 찜하기</span>
</label>
<div style="font-size: 12px; color: #666; margin-top: 5px; margin-left: 25px;">
체크하면 전체상품(/category/ALL) 대신 최신상품(/best)부터 찜합니다.
</div>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-success btn-large" id="my-market-zzim-btn">
💝 내 마켓 찜하기
</button>
<button class="btn btn-warning btn-large" id="mutual-zzim-btn">
🤝 품앗이 찜하기
</button>
<button class="btn btn-danger btn-large" id="stop-zzim-btn" style="display: none;">
⏹️ 찜하기 중단
</button>
</div>
<!-- 진행률 표시 -->
<div id="zzim-progress" style="display: none; margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span id="progress-text">진행 중...</span>
<span id="progress-percent">0%</span>
</div>
<div style="width: 100%; height: 8px; background: #e0e0e0; border-radius: 4px; overflow: hidden;">
<div id="progress-bar" style="height: 100%; background: #3498db; width: 0%; transition: width 0.3s ease;"></div>
</div>
</div>
<div id="zzim-status" class="loading" style="display: none;"></div>
<div id="zzim-error" class="error" style="display: none;"></div>
<div id="zzim-success" class="success" style="display: none;"></div>
</div>
<script src="zzim.js"></script>
<!-- 마켓 수정 모달 -->
<div id="edit-market-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">
✏️ 마켓 정보 수정
</h3>
<button class="modal-close" id="modal-close-btn">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">마켓 URL</label>
<input type="text" id="edit-market-url" class="form-input" placeholder="https://smartstore.naver.com/your-store">
</div>
<div class="form-group">
<label class="form-label">마켓 이름</label>
<input type="text" id="edit-market-name" class="form-input" placeholder="마켓 이름을 입력하세요">
</div>
<div class="form-group">
<label class="form-label">마켓 별명</label>
<input type="text" id="edit-market-nickname" class="form-input" placeholder="마켓 별명을 입력하세요">
</div>
<div class="checkbox-container">
<label class="custom-checkbox">
<input type="checkbox" id="edit-market-for-zzim">
<span class="checkbox-checkmark"></span>
</label>
<div>
<label class="checkbox-label" for="edit-market-for-zzim">내가 찜하기 할 때 포함</label>
<div class="checkbox-description">체크하면 내 마켓 찜하기 작업 시 이 마켓이 포함됩니다.</div>
</div>
</div>
<div class="checkbox-container">
<label class="custom-checkbox">
<input type="checkbox" id="edit-market-for-mutual-zzim">
<span class="checkbox-checkmark"></span>
</label>
<div>
<label class="checkbox-label" for="edit-market-for-mutual-zzim">품앗이 대상으로 포함</label>
<div class="checkbox-description">체크하면 다른 사람들이 이 마켓에 품앗이 찜을 할 수 있습니다. (자동 노출)</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-modal btn-modal-cancel" id="modal-cancel-btn">취소</button>
<button class="btn-modal btn-modal-save" id="modal-save-btn">저장</button>
</div>
</div>
</div>
</body>
</html>

2015
zzim.js Normal file

File diff suppressed because it is too large Load Diff

BIN
매뉴얼/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
매뉴얼/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
매뉴얼/11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

BIN
매뉴얼/12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

BIN
매뉴얼/13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

BIN
매뉴얼/14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

BIN
매뉴얼/15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

BIN
매뉴얼/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

BIN
매뉴얼/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
매뉴얼/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

BIN
매뉴얼/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

BIN
매뉴얼/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
매뉴얼/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
매뉴얼/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
매뉴얼/8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
매뉴얼/9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
매뉴얼/ext/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
매뉴얼/ext/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

BIN
매뉴얼/ext/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

BIN
매뉴얼/ext/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
매뉴얼/ext/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB