Compare commits

..

No commits in common. "extended" and "master" have entirely different histories.

53 changed files with 1186 additions and 15660 deletions

View File

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

View File

@ -1,166 +0,0 @@
(() => {
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.

Before

Width:  |  Height:  |  Size: 153 B

View File

@ -1,25 +0,0 @@
{
"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

@ -1,21 +0,0 @@
<!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

@ -1,23 +0,0 @@
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 {
constructor() {
// 초기값 설정 (chrome.storage에서 로드될 때까지 임시)
// SUPABASE 설정은 background.js에서 중앙 관리됨
this.SUPABASE_URL = null;
this.SUPABASE_ANON_KEY = null;
this.DEBUG_MODE = true;
this.ACCESS_TOKEN = null;
this.isConfigLoaded = false;
this.buttonEventListenerAttached = false; // 이벤트 리스너 중복 등록 방지
this.userId = null; // 사용자 ID 저장
this.debugLog('BannedWordsManager 생성자 시작 - 설정 로드 대기 중');
@ -60,6 +60,8 @@ class BannedWordsManager {
if (configData.bannedWords_config) {
const config = configData.bannedWords_config;
this.debugLog('bannedWords_config에서 설정 로드', {
hasUrl: !!config.SUPABASE_URL,
hasKey: !!config.SUPABASE_ANON_KEY,
hasToken: !!config.ACCESS_TOKEN,
debugMode: config.DEBUG_MODE,
timestamp: config.timestamp,
@ -71,6 +73,8 @@ class BannedWordsManager {
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.ACCESS_TOKEN = config.ACCESS_TOKEN;
@ -78,25 +82,41 @@ class BannedWordsManager {
// 2. 개별 설정 확인 (fallback)
this.debugLog('bannedWords_config가 없음, 개별 설정 확인 중');
const storageData = await chrome.storage.local.get(['access_token']);
const storageData = await chrome.storage.local.get([
'access_token',
'SUPABASE_URL',
'SUPABASE_ANON_KEY'
]);
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.isConfigLoaded = true;
this.debugLog('설정 로드 완료', {
SUPABASE_URL: this.SUPABASE_URL,
hasToken: !!this.ACCESS_TOKEN,
tokenLength: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.length : 0,
DEBUG_MODE: this.DEBUG_MODE,
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) {
throw new Error('ACCESS_TOKEN이 없습니다. 로그인이 필요합니다');
}
@ -177,41 +197,96 @@ class BannedWordsManager {
// 토큰 유효성 검증
async validateToken() {
try {
this.debugLog('토큰 검증 API 호출 준비 (background.js 경유)', {
this.debugLog('토큰 검증 API 호출 준비', {
url: `${this.SUPABASE_URL}/auth/v1/user`,
hasToken: !!this.ACCESS_TOKEN,
tokenLength: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.length : 0,
tokenPreview: this.ACCESS_TOKEN ? this.ACCESS_TOKEN.substring(0, 30) + '...' : 'none'
});
// background.js를 통해 토큰 검증
const response = await chrome.runtime.sendMessage({
action: 'validateToken',
token: this.ACCESS_TOKEN
const headers = {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'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 응답', {
success: response?.success,
hasUser: !!response?.user
status: authRes.status,
statusText: authRes.statusText,
ok: authRes.ok,
type: authRes.type,
url: authRes.url,
headers: Object.fromEntries(authRes.headers.entries())
});
if (!response || !response.success) {
throw new Error(response?.error || '토큰 검증 실패');
if (!authRes.ok) {
let errorText = '';
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('토큰 검증 실패 - 상세 정보', {
status: authRes.status,
statusText: authRes.statusText,
errorText: errorText,
errorJson: errorJson
});
throw new Error(`토큰이 유효하지 않습니다 (${authRes.status}: ${authRes.statusText})\n응답: ${errorText}`);
}
const userData = response.user;
const userData = await authRes.json();
this.debugLog('토큰 검증 성공', {
email: userData.email,
id: userData.id
id: userData.id,
userData: userData
});
return userData;
} catch (error) {
this.debugLog('토큰 검증 중 오류', {
this.debugLog('토큰 검증 중 오류 - 상세 분석', {
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;
}
}
@ -234,7 +309,7 @@ class BannedWordsManager {
// 금지어 목록 로드
async loadBannedWords() {
this.debugLog('금지어 목록 로드 시작 (background.js 경유)');
this.debugLog('금지어 목록 로드 시작');
const loading = document.getElementById("banned-words-loading");
const tbody = document.getElementById("banned-words-tbody");
@ -243,34 +318,117 @@ class BannedWordsManager {
tbody.innerHTML = "";
try {
// background.js를 통해 금지어 목록 조회
const response = await chrome.runtime.sendMessage({
action: 'getBannedWords',
token: this.ACCESS_TOKEN
// 공통 헤더 설정
const headers = {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'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
});
this.debugLog('금지어 목록 API 응답', {
success: response?.success,
count: response?.bannedWords?.length
});
if (!response || !response.success) {
throw new Error(response?.error || '금지어 목록 조회 실패');
if (!authRes.ok) {
const errorDetail = await authRes.text();
this.debugLog('Auth API 에러 상세', {
status: authRes.status,
error: errorDetail
});
throw new Error(`사용자 정보를 가져올 수 없습니다 (${authRes.status}: ${authRes.statusText})\n응답: ${errorDetail}`);
}
const bannedWords = response.bannedWords;
this.userId = response.userId; // 사용자 ID 저장 (추가 시 필요)
const authUser = await authRes.json();
this.debugLog('Auth 사용자 정보 수신', {
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.displayBannedWords(bannedWords);
} catch (error) {
this.debugLog('금지어 목록 로드 실패', {
errorName: error.name,
errorMessage: error.message
errorMessage: error.message,
isNetworkError: error.name === 'TypeError' && error.message.includes('fetch')
});
tbody.innerHTML = `<tr><td colspan="4" style="text-align: center; color: red;">오류: ${error.message}</td></tr>`;
let errorMessage = error.message;
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 {
loading.style.display = "none";
}
@ -565,22 +723,35 @@ class BannedWordsManager {
throw new Error('로그인이 필요합니다');
}
this.debugLog('등급 업데이트 API 호출 (background.js 경유)', { wordId, selectedGrade });
this.debugLog('등급 업데이트 API 호출', { wordId, selectedGrade });
// background.js를 통해 등급 수정
const response = await chrome.runtime.sendMessage({
action: 'updateBannedWordGrade',
token: this.ACCESS_TOKEN,
wordId: wordId,
grade: selectedGrade
const updateRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words?word_id=eq.${wordId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
grade: selectedGrade,
updated_at: new Date().toISOString()
})
});
this.debugLog('등급 업데이트 API 응답', {
success: response?.success
status: updateRes.status,
statusText: updateRes.statusText,
ok: updateRes.ok
});
if (!response || !response.success) {
throw new Error(response?.error || '등급 업데이트 실패');
if (!updateRes.ok) {
const errorDetail = await updateRes.text();
this.debugLog('등급 업데이트 실패', {
status: updateRes.status,
error: errorDetail
});
throw new Error(`등급 업데이트 실패 (${updateRes.status}: ${updateRes.statusText})\n응답: ${errorDetail}`);
}
this.debugLog('등급 수정 성공', { wordId, selectedGrade });
@ -657,21 +828,31 @@ class BannedWordsManager {
throw new Error('로그인이 필요합니다');
}
this.debugLog('금지어 삭제 API 호출 (background.js 경유)', { wordId, word });
this.debugLog('금지어 삭제 API 호출', { wordId, word });
// background.js를 통해 금지어 삭제
const response = await chrome.runtime.sendMessage({
action: 'deleteBannedWord',
token: this.ACCESS_TOKEN,
wordId: wordId
const deleteRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words?word_id=eq.${wordId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
this.debugLog('금지어 삭제 API 응답', {
success: response?.success
status: deleteRes.status,
statusText: deleteRes.statusText,
ok: deleteRes.ok
});
if (!response || !response.success) {
throw new Error(response?.error || '금지어 삭제 실패');
if (!deleteRes.ok) {
const errorDetail = await deleteRes.text();
this.debugLog('금지어 삭제 실패', {
status: deleteRes.status,
error: errorDetail
});
throw new Error(`금지어 삭제 실패 (${deleteRes.status}: ${deleteRes.statusText})\n응답: ${errorDetail}`);
}
this.debugLog('금지어 삭제 성공', { wordId, word });
@ -733,28 +914,42 @@ class BannedWordsManager {
throw new Error('word_id가 없습니다. 데이터베이스에서 word_id 값을 확인해주세요.');
}
this.debugLog('키프리스 데이터 조회 API 호출 (background.js 경유)', {
this.debugLog('키프리스 데이터 조회 API 호출', {
wordWordId,
word
word,
queryUrl: `${this.SUPABASE_URL}/rest/v1/user_banned_words_kipris?select=*&banned_word_id=eq.${wordWordId}&order=created_at.desc`
});
// background.js를 통해 키프리스 결과 조회
const response = await chrome.runtime.sendMessage({
action: 'getKiprisResults',
token: this.ACCESS_TOKEN,
wordId: wordWordId
// 올바른 테이블 이름과 필드명 사용: 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 응답', {
success: response?.success,
count: response?.kiprisData?.length
status: resultsRes.status,
statusText: resultsRes.statusText,
ok: resultsRes.ok,
url: resultsRes.url
});
if (!response || !response.success) {
throw new Error(response?.error || '키프리스 결과 조회 실패');
if (!resultsRes.ok) {
const errorDetail = await resultsRes.text();
this.debugLog('키프리스 데이터 조회 실패', {
status: resultsRes.status,
error: errorDetail,
wordWordId,
word
});
throw new Error(`키프리스 결과를 가져올 수 없습니다 (${resultsRes.status}: ${resultsRes.statusText})\n응답: ${errorDetail}`);
}
const kiprisData = response.kiprisData;
const kiprisData = await resultsRes.json();
this.debugLog('키프리스 결과 로드 성공', {
count: kiprisData.length,
wordWordId,
@ -766,8 +961,7 @@ class BannedWordsManager {
hasClassificationCode: !!kiprisData[0].classification_code,
hasCategoryDescription: !!kiprisData[0].category_description,
hasDrawing: !!kiprisData[0].drawing,
bannedWordId: kiprisData[0].banned_word_id,
hasTradeMarkName: !!kiprisData[0].tradeMark_name
bannedWordId: kiprisData[0].banned_word_id
} : null
});
@ -789,50 +983,15 @@ class BannedWordsManager {
<div style="background: #f0f8ff; padding: 12px; border-radius: 8px; margin-bottom: 16px; border-left: 4px solid #007bff;">
<strong>📊 검색 결과 요약:</strong> ${kiprisData.length} .
</div>
${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 `
${kiprisData.map((item, index) => `
<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;">
<h4 style="margin: 0; color: #333;">📋 검색 결과 ${index + 1}</h4>
<small style="color: #666;">등록일: ${new Date(item.created_at).toLocaleDateString()}</small>
</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-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;">
<strong style="color: #555;">📋 출원상태:</strong>
<div style="padding: 4px 8px; background: ${item.application_status ? '#e3f2fd' : '#f5f5f5'}; border-radius: 4px; margin-top: 4px;">
@ -896,8 +1055,7 @@ class BannedWordsManager {
</div>
</div>
</div>
`;
}).join('')}
`).join('')}
</div>
`;
@ -934,9 +1092,9 @@ class BannedWordsManager {
// 현재 상태 정보 수집
const statusInfo = {
'초기화 상태': this.isConfigLoaded ? '✅ 완료' : '❌ 미완료',
'SUPABASE_URL': this.SUPABASE_URL || '❌ 설정되지 않음',
'ACCESS_TOKEN': this.ACCESS_TOKEN ? `✅ 있음 (${this.ACCESS_TOKEN.length}자)` : '❌ 없음',
'DEBUG_MODE': this.DEBUG_MODE ? '✅ 활성화' : '❌ 비활성화',
'사용자 ID': this.userId || '❌ 없음',
'현재 시간': new Date().toLocaleString()
};
@ -1089,7 +1247,7 @@ class BannedWordsManager {
return;
}
this.debugLog('추가할 금지어 목록 (background.js 경유)', { wordsToAdd, selectedGrade, count: wordsToAdd.length });
this.debugLog('추가할 금지어 목록', { wordsToAdd, selectedGrade, count: wordsToAdd.length });
try {
confirmBtn.textContent = '🔄 추가 중...';
@ -1100,24 +1258,61 @@ class BannedWordsManager {
throw new Error('로그인이 필요합니다');
}
// 사용자 ID 확인 (loadBannedWords에서 저장됨)
const userId = this.userId;
if (!userId) {
throw new Error('사용자 ID를 찾을 수 없습니다. 페이지를 새로고침해주세요.');
}
// background.js를 통해 기존 금지어 목록 조회 (중복 검사용)
const existingResponse = await chrome.runtime.sendMessage({
action: 'getExistingBannedWords',
token: this.ACCESS_TOKEN,
userId: userId
// 현재 사용자 정보 가져오기
const authRes = await fetch(`${this.SUPABASE_URL}/auth/v1/user`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
});
if (!existingResponse || !existingResponse.success) {
throw new Error(existingResponse?.error || '기존 금지어 목록 조회 실패');
if (!authRes.ok) {
throw new Error('사용자 인증 실패');
}
const existingWords = existingResponse.existingWords;
const authUser = await authRes.json();
// 사용자 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()));
// 중복 검사
@ -1179,22 +1374,32 @@ class BannedWordsManager {
created_at: new Date().toISOString()
}));
this.debugLog('금지어 배치 추가 API 호출 (background.js 경유)', { count: wordsData.length, grade: selectedGrade });
this.debugLog('금지어 배치 추가 API 호출', { count: wordsData.length, grade: selectedGrade });
// background.js를 통해 금지어 추가
const addResponse = await chrome.runtime.sendMessage({
action: 'addBannedWords',
token: this.ACCESS_TOKEN,
userId: userId,
wordsData: wordsData
const addRes = await fetch(`${this.SUPABASE_URL}/rest/v1/user_banned_words`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.ACCESS_TOKEN}`,
'apikey': this.SUPABASE_ANON_KEY,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(wordsData)
});
this.debugLog('금지어 추가 API 응답', {
success: addResponse?.success
status: addRes.status,
statusText: addRes.statusText,
ok: addRes.ok
});
if (!addResponse || !addResponse.success) {
throw new Error(addResponse?.error || '금지어 추가 실패');
if (!addRes.ok) {
const errorDetail = await addRes.text();
this.debugLog('금지어 추가 실패', {
status: addRes.status,
error: errorDetail
});
throw new Error(`금지어 추가 실패 (${addRes.status}: ${addRes.statusText})\n응답: ${errorDetail}`);
}
this.debugLog('금지어 추가 성공', { newWords, selectedGrade });

2525
content.js

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,18 @@
{
"manifest_version": 3,
"name": "내차는언제타냐 통합 확장",
"version": "1.3",
"name": "내차는언제타냐 - 지재권 검색 확장 (컨텍스트 메뉴)",
"version": "1.2",
"description": "드래그한 텍스트를 우클릭 → '지재권 검색'으로 MarkInfo 검색을 수행하고 결과를 툴팁으로 표시합니다.",
"permissions": [
"storage",
"activeTab",
"contextMenus",
"storage",
"notifications",
"alarms",
"tabs",
"scripting",
"clipboardRead"
"activeTab"
],
"host_permissions": [
"*://markinfo.kr/*",
"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/*"
"https://markinfo.kr/*",
"http://146.56.101.199:8000/*"
],
"background": {
"service_worker": "background.js"
@ -35,57 +21,16 @@
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"all_frames": true,
"run_at": "document_end"
}
],
"action": {
"default_popup": "popup.html",
"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": "직번역 (텍스트 바로 대체)"
}
"default_icon": "icon.png"
},
"web_accessible_resources": [
{
"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"
],
"resources": ["bannedWords.html", "bannedWords.js"],
"matches": ["<all_urls>"]
}
]

View File

@ -1,650 +0,0 @@
<!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>

View File

@ -1,38 +0,0 @@
// 탭 전환 기능
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,8 +13,7 @@
padding: 20px;
width: 320px;
height: 100vh;
/* overflow: hidden; */
overflow-y: auto;
overflow: hidden;
}
h2 {
text-align: center;
@ -119,25 +118,6 @@
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 {
margin: 15px 0;
@ -363,7 +343,6 @@
min-width: 60px;
text-align: center;
}
</style>
</head>
<body>
@ -416,21 +395,10 @@
<p><strong>오늘 호출량:</strong> <span id="user-usage"></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">
<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="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>
<button class="btn management-btn" id="sayings-btn">💬 어록보기</button>
</div>
<button class="btn" id="logout-btn">로그아웃</button>

988
popup.js

File diff suppressed because it is too large Load Diff

View File

@ -1,296 +0,0 @@
<!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>

View File

@ -1,302 +0,0 @@
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,412 +537,14 @@
color: #1976d2;
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>
</head>
<body>
<div class="header">
<h1>📚 타냐대장경 관리</h1>
<h1>📚 어록 관리</h1>
<div class="header-right">
<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>
@ -984,7 +586,7 @@
<div class="filter-group">
<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 class="filter-buttons">
@ -1000,7 +602,7 @@
<!-- 통계 정보가 여기에 표시됩니다 -->
</div>
<button class="add-saying-btn" id="add-saying-btn">
타냐대장경 등록
어록 등록
</button>
</div>
@ -1008,42 +610,29 @@
<div id="add-saying-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">타냐대장경 등록</h3>
<h3 class="modal-title">어록 등록</h3>
<button class="modal-close" id="modal-close">&times;</button>
</div>
<form id="saying-form">
<div class="form-group">
<label class="form-label" for="saying-title">타냐대장경 제목</label>
<input type="text" id="saying-title" class="form-input" placeholder="타냐대장경의 제목을 입력하세요" required>
<div class="form-help">타냐대장경을 대표하는 제목을 입력해주세요.</div>
<label class="form-label" for="saying-title">어록 제목</label>
<input type="text" id="saying-title" class="form-input" placeholder="어록의 제목을 입력하세요" required>
<div class="form-help">어록을 대표하는 제목을 입력해주세요.</div>
</div>
<div class="form-group">
<label class="form-label" for="saying-content">타냐대장경 내용</label>
<label class="form-label" for="saying-content">어록 내용</label>
<div class="preview-tabs">
<div class="preview-tab active" data-tab="edit">✏️ 편집</div>
<div class="preview-tab" data-tab="preview">👁️ 미리보기</div>
</div>
<!-- 새로운 블록 기반 에디터 -->
<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>
<textarea id="saying-content" class="form-textarea" placeholder="어록 내용을 입력하세요 (마크다운 지원)" required></textarea>
<div id="markdown-preview" class="markdown-preview" style="display: none;"></div>
<div class="form-help">
💡 텍스트와 이미지를 자유롭게 조합할 수 있습니다.
이미지는 자동으로 압축되어 저장됩니다. (최대 2MB)
💡 마크다운 문법을 사용할 수 있습니다.
<strong>**굵게**</strong>, <em>*기울임*</em>,
<code>`코드`</code>, 링크 등을 지원합니다.
</div>
</div>
@ -1053,7 +642,7 @@
<option value="">카테고리를 선택하세요</option>
<!-- 동적으로 로드됩니다 -->
</select>
<div class="form-help">타냐대장경의 성격에 맞는 카테고리를 선택해주세요.</div>
<div class="form-help">어록의 성격에 맞는 카테고리를 선택해주세요.</div>
</div>
<div class="form-group">
@ -1062,7 +651,7 @@
<option value="">타겟을 선택하세요</option>
<!-- 동적으로 로드됩니다 -->
</select>
<div class="form-help">타냐대장경의 대상을 선택해주세요. (기본값: 누구나)</div>
<div class="form-help">어록의 대상을 선택해주세요. (기본값: 누구나)</div>
</div>
<div class="form-group">
@ -1081,112 +670,19 @@
</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>
<div class="loading" id="sayings-loading" style="display: none;">
🔄 타냐대장경을 불러오는 중...
🔄 어록을 불러오는 중...
</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>
</body>
</html>
</html>

1701
sayings.js

File diff suppressed because it is too large Load Diff

View File

@ -1,590 +0,0 @@
<!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>

View File

@ -1,529 +0,0 @@
// 설정 페이지 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('설정 페이지 종료');
});

View File

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

View File

@ -1,195 +0,0 @@
-- 찜관리 기능을 위한 데이터베이스 스키마
-- 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();

View File

@ -1,207 +0,0 @@
<!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>

View File

@ -1,56 +0,0 @@
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

@ -1,34 +0,0 @@
-- 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

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

83
test_final/background.js Normal file
View File

@ -0,0 +1,83 @@
// 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`;
}

BIN
test_final/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

21
test_final/manifest.json Normal file
View File

@ -0,0 +1,21 @@
{
"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
View File

@ -1,827 +0,0 @@
<!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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB