Files
home/index.html
dsyoon 812a59bc9f Add full-feature inline fallback when script.js fails
If external JS fails to load on some static hosts, run an inline version supporting add/edit/delete/favorite/search/sort/import/export/theme so the UI is never dead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 18:25:54 +09:00

791 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>NCue | 개인 링크 홈</title>
<meta name="description" content="개인 서비스 링크를 모아 관리하는 홈 화면" />
<link rel="stylesheet" href="./styles.css" />
<!-- Auth0 SPA SDK (정적 사이트용) -->
<!-- jsDelivr 차단/실패 시 unpkg로 자동 대체 -->
<script
src="https://cdn.jsdelivr.net/npm/@auth0/auth0-spa-js@2/dist/auth0-spa-js.production.js"
onerror="this.onerror=null;this.src='https://unpkg.com/@auth0/auth0-spa-js@2/dist/auth0-spa-js.production.js';"
></script>
<script>
// 로그인 설정 (관리 기능 잠금용)
// 1) Auth0에서 Application(SPA) 생성 후 domain/clientId를 입력하세요.
// 2) Allowed Callback URLs / Allowed Logout URLs에 현재 사이트 주소를 등록하세요.
// 예: https://drive.daewoongai.com/apps/dashboard/
window.AUTH_CONFIG = {
auth0: {
domain: "",
clientId: "",
},
// 관리 허용 이메일(대소문자 무시)
allowedEmails: [],
};
</script>
<script defer src="./script.js"></script>
</head>
<body>
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
<header class="topbar">
<div class="wrap">
<div class="brand">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path
d="M10.5 13.5l3-3m-1.24-3.13l-2.86-2.86a4 4 0 0 0-5.66 5.66l2.86 2.86"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
<path
d="M13.5 10.5l2.86 2.86a4 4 0 0 1-5.66 5.66l-2.86-2.86"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</div>
<div class="brand-text">
<div class="brand-title">NCue</div>
<div class="brand-sub" id="subtitle">개인 링크 관리</div>
</div>
</div>
<div class="actions">
<button class="btn" id="btnAdd" type="button">
<span class="btn-ico" aria-hidden="true">+</span>
추가
</button>
<button class="btn" id="btnImport" type="button">가져오기</button>
<button class="btn" id="btnExport" type="button">내보내기</button>
<button class="btn" id="btnTheme" type="button" aria-pressed="false" title="테마 전환">
테마
</button>
<span class="divider" aria-hidden="true"></span>
<div class="user" id="user" hidden>
<span class="user-dot" aria-hidden="true"></span>
<span class="user-text" id="userText"></span>
</div>
<div class="sns-login" id="snsLogin">
<span class="sns-label">간편로그인</span>
<div class="sns-row">
<button class="sns-btn sns-naver is-disabled" id="btnNaver" type="button" aria-label="네이버로 로그인">
<span class="sns-letter">N</span>
</button>
<button class="sns-btn sns-kakao is-disabled" id="btnKakao" type="button" aria-label="카카오로 로그인">
<span class="sns-kakao-bubble" aria-hidden="true"></span>
</button>
<button class="sns-btn sns-google is-disabled" id="btnGoogle" type="button" aria-label="구글로 로그인">
<span class="sns-google-g">G</span>
</button>
</div>
</div>
<button class="btn" id="btnLogout" type="button" hidden>로그아웃</button>
</div>
</div>
</header>
<main class="wrap" id="main">
<section class="panel">
<div class="controls">
<label class="field">
<span class="field-label">검색</span>
<input
id="q"
class="input"
type="search"
placeholder="제목/도메인/태그 검색…"
autocomplete="off"
spellcheck="false"
/>
</label>
<label class="field">
<span class="field-label">정렬</span>
<select id="sort" class="select">
<option value="json">파일 순서</option>
<option value="recent">최근 수정</option>
<option value="name">이름</option>
<option value="domain">도메인</option>
<option value="favorite">즐겨찾기 우선</option>
</select>
</label>
<label class="check">
<input id="onlyFav" type="checkbox" />
<span>즐겨찾기만</span>
</label>
<div class="meta" id="meta" aria-live="polite"></div>
</div>
</section>
<section class="grid" id="grid" aria-label="링크 목록"></section>
<section class="empty" id="empty">
<div class="empty-title">표시할 링크가 없습니다.</div>
<div class="empty-sub">상단의 “추가” 버튼으로 새 링크를 등록하세요.</div>
</section>
</main>
<!-- Modal -->
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" hidden>
<div class="modal-backdrop" data-close="1"></div>
<div class="modal-card" role="document">
<div class="modal-head">
<div class="modal-title" id="modalTitle">링크 추가</div>
<button class="icon-btn" type="button" id="btnClose" title="닫기" aria-label="닫기">×</button>
</div>
<form id="form" class="modal-body">
<input type="hidden" id="id" />
<label class="field">
<span class="field-label">제목</span>
<input id="title" class="input" type="text" required maxlength="80" placeholder="예: Git" />
</label>
<label class="field">
<span class="field-label">URL</span>
<input
id="url"
class="input"
type="url"
required
placeholder="예: https://example.com"
inputmode="url"
autocomplete="off"
spellcheck="false"
/>
<div class="hint">http(s) URL을 권장합니다. (미입력 시 자동으로 https://가 붙습니다)</div>
</label>
<label class="field">
<span class="field-label">설명</span>
<input id="description" class="input" type="text" maxlength="140" placeholder="선택" />
</label>
<label class="field">
<span class="field-label">태그</span>
<input id="tags" class="input" type="text" placeholder="예: ncue, dev (쉼표로 구분)" />
</label>
<label class="check">
<input id="favorite" type="checkbox" />
<span>즐겨찾기</span>
</label>
<div class="modal-foot">
<button class="btn btn-ghost" type="button" id="btnCancel">취소</button>
<button class="btn btn-primary" type="submit" id="btnSave">저장</button>
</div>
</form>
</div>
</div>
<!-- Auth Config Modal -->
<div class="modal" id="authModal" role="dialog" aria-modal="true" aria-labelledby="authModalTitle" hidden>
<div class="modal-backdrop" data-auth-close="1"></div>
<div class="modal-card" role="document">
<div class="modal-head">
<div class="modal-title" id="authModalTitle">로그인 설정</div>
<button class="icon-btn" type="button" id="btnAuthClose" title="닫기" aria-label="닫기">×</button>
</div>
<form id="authForm" class="modal-body">
<label class="field">
<span class="field-label">Auth0 Domain</span>
<input id="authDomain" class="input" type="text" placeholder="예: your-tenant.us.auth0.com" />
<div class="hint">Auth0 테넌트 도메인입니다. (비밀값 아님)</div>
</label>
<label class="field">
<span class="field-label">Auth0 Client ID</span>
<input id="authClientId" class="input" type="text" placeholder="예: AbCdEf..." />
<div class="hint">Auth0 SPA Application의 Client ID입니다. (비밀값 아님)</div>
</label>
<label class="field">
<span class="field-label">허용 이메일</span>
<input id="authAllowedEmails" class="input" type="text" placeholder="예: me@example.com, admin@example.com" />
<div class="hint">쉼표로 구분합니다. 비워두면 “로그인한 모든 계정”이 관리 가능해집니다.</div>
</label>
<label class="field">
<span class="field-label">Connection 이름(선택)</span>
<input id="authConnGoogle" class="input" type="text" placeholder="Google 예: google-oauth2" />
<input id="authConnKakao" class="input" type="text" placeholder="Kakao 예: kakao" />
<input id="authConnNaver" class="input" type="text" placeholder="Naver 예: naver" />
<div class="hint">Auth0에서 설정한 connection 이름입니다. 비우면 “로그인” 버튼으로 통합 로그인 화면을 띄웁니다.</div>
</label>
<div class="modal-foot">
<button class="btn btn-ghost" type="button" id="btnAuthReset">초기화</button>
<button class="btn btn-primary" type="submit" id="btnAuthSave">저장</button>
</div>
</form>
</div>
</div>
<!-- Hidden file input for import -->
<input id="file" type="file" accept="application/json" hidden />
<!-- Toast -->
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<!--
기본 링크 데이터(서버 없이 index.html만 열어도 동작)
이 배열 순서가 "파일 순서" 정렬 기준이 됩니다.
-->
<script id="linksData" type="application/json">
[
{
"id": "dsyoon-ncue-net",
"title": "DSYoon",
"url": "https://ncue.net/dsyoon",
"description": "개인 페이지",
"tags": ["personal", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "family-ncue-net",
"title": "Family",
"url": "https://ncue.net/family",
"description": "Family",
"tags": ["personal", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "git-ncue-net",
"title": "Git",
"url": "https://git.ncue.net/",
"description": "NCUE Git 서비스",
"tags": ["dev", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "mail-ncue-net",
"title": "Mail",
"url": "https://mail.ncue.net/",
"description": "NCUE 메일",
"tags": ["mail", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "tts-ncue-net",
"title": "TTS",
"url": "https://tts.ncue.net/",
"description": "입력한 text를 mp3로 변환",
"tags": ["text", "mp3", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "meeting-ncue-net",
"title": "Meeting",
"url": "https://meeting.ncue.net/",
"description": "NCUE 미팅",
"tags": ["meeting", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "openclaw-ncue-net",
"title": "OpenClaw",
"url": "https://openclaw.ncue.net/",
"description": "OpenClaw",
"tags": ["tool", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "link-ncue-net",
"title": "Link",
"url": "https://link.ncue.net/",
"description": "NCUE 링크 허브",
"tags": ["link", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
},
{
"id": "dreamgirl-ncue-net",
"title": "DreamGirl",
"url": "https://ncue.net/dreamgirl",
"description": "DreamGirl",
"tags": ["personal", "ncue"],
"favorite": false,
"createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "2026-02-07T00:00:00.000Z"
}
]
</script>
<!-- Fallback: if script.js fails, run full-feature inline app -->
<script>
(function () {
function safeJsonParse(s, fallback) {
try {
return JSON.parse(s);
} catch {
return fallback;
}
}
function esc(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function normalizeUrl(url) {
const u = String(url || "").trim();
if (!u) return "";
if (/^https?:\/\//i.test(u)) return u;
return "https://" + u;
}
function domain(u) {
try {
return new URL(u).host;
} catch {
return String(u || "").replace(/^https?:\/\//i, "").split("/")[0] || "";
}
}
function nowIso() {
return new Date().toISOString();
}
// Wait a tick for external script.js to possibly boot.
setTimeout(() => {
if (window.__LINKS_APP_BOOTED__) return;
window.__LINKS_APP_BOOTED__ = true;
const STORAGE_KEY = "links_home_v1";
const THEME_KEY = "links_home_theme_v1";
const el = {
q: document.getElementById("q"),
sort: document.getElementById("sort"),
onlyFav: document.getElementById("onlyFav"),
meta: document.getElementById("meta"),
grid: document.getElementById("grid"),
empty: document.getElementById("empty"),
btnAdd: document.getElementById("btnAdd"),
btnImport: document.getElementById("btnImport"),
btnExport: document.getElementById("btnExport"),
btnTheme: document.getElementById("btnTheme"),
toast: document.getElementById("toast"),
modal: document.getElementById("modal"),
btnClose: document.getElementById("btnClose"),
btnCancel: document.getElementById("btnCancel"),
form: document.getElementById("form"),
id: document.getElementById("id"),
title: document.getElementById("title"),
url: document.getElementById("url"),
description: document.getElementById("description"),
tags: document.getElementById("tags"),
favorite: document.getElementById("favorite"),
file: document.getElementById("file"),
};
function toast(msg) {
if (!el.toast) return;
el.toast.textContent = msg;
el.toast.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => (el.toast.hidden = true), 2200);
}
function loadStore() {
const raw = localStorage.getItem(STORAGE_KEY);
const data = raw ? safeJsonParse(raw, null) : null;
const store = { overridesById: {}, tombstones: [], custom: [] };
if (!data || typeof data !== "object") return store;
if (data.overridesById && typeof data.overridesById === "object") store.overridesById = data.overridesById;
if (Array.isArray(data.tombstones)) store.tombstones = data.tombstones;
if (Array.isArray(data.custom)) store.custom = data.custom;
return store;
}
function saveStore() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.store));
}
function normalizeTags(tagsText) {
if (!tagsText) return [];
return String(tagsText)
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 12);
}
function normalizeLink(link) {
const url = normalizeUrl(link.url);
const id = String(link.id || "").trim() || "link-" + Math.random().toString(16).slice(2);
const title = String(link.title || "").trim() || domain(url) || "Link";
const description = String(link.description || "").trim();
const tags = Array.isArray(link.tags) ? link.tags.map((t) => String(t).trim()).filter(Boolean) : [];
const favorite = Boolean(link.favorite);
const createdAt = String(link.createdAt || nowIso());
const updatedAt = String(link.updatedAt || createdAt);
return { id, title, url, description, tags, favorite, createdAt, updatedAt };
}
const baseLinks = (() => {
const dataEl = document.getElementById("linksData");
const parsed = dataEl ? safeJsonParse(dataEl.textContent || "[]", []) : [];
return Array.isArray(parsed) ? parsed.map(normalizeLink) : [];
})();
const baseOrder = new Map(baseLinks.map((l, i) => [l.id, i]));
const state = {
store: loadStore(),
query: "",
sortKey: (el.sort && el.sort.value) || "json",
onlyFav: Boolean(el.onlyFav && el.onlyFav.checked),
};
function toTime(s) {
const t = Date.parse(String(s || ""));
return Number.isFinite(t) ? t : 0;
}
function orderKey(link) {
const idx = baseOrder.get(link.id);
if (typeof idx === "number") return idx;
return 1_000_000 + toTime(link.createdAt);
}
function getMergedLinks() {
const tomb = new Set(state.store.tombstones || []);
const overrides = state.store.overridesById || {};
const byId = new Map();
for (const base of baseLinks) {
if (!base || !base.id) continue;
if (tomb.has(base.id)) continue;
const o = overrides[base.id];
byId.set(base.id, Object.assign({}, base, o || {}));
}
for (const c of state.store.custom || []) {
const n = normalizeLink(c);
byId.set(n.id, n);
}
return Array.from(byId.values());
}
function compare(a, b) {
const key = state.sortKey || "json";
if (key === "json") return orderKey(a) - orderKey(b);
if (key === "favorite") {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
return orderKey(a) - orderKey(b);
}
if (key === "name") return a.title.localeCompare(b.title, "ko");
if (key === "domain") return domain(a.url).localeCompare(domain(b.url), "en");
return String(b.updatedAt).localeCompare(String(a.updatedAt));
}
function matches(link, q) {
if (!q) return true;
const hay = [link.title, link.url, domain(link.url), link.description || "", (link.tags || []).join(" ")]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
function cardHtml(link) {
const d = esc(domain(link.url));
const t = esc(link.title);
const u = esc(link.url);
const desc = esc(link.description || "");
const star = link.favorite ? "star on" : "star";
const tags = (link.tags || []).slice(0, 8).map((x) => `<span class="tag">#${esc(x)}</span>`).join("");
const favTag = link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : "";
const letter = esc((link.title || d || "L").trim().slice(0, 1).toUpperCase());
return `
<article class="card" data-id="${esc(link.id)}">
<div class="card-head">
<div class="card-title">
<div class="favicon" aria-hidden="true"><div class="letter">${letter}</div></div>
<div class="title-wrap">
<div class="title" title="${t}">${t}</div>
<div class="domain" title="${d}">${d}</div>
</div>
</div>
<button class="icon-btn" type="button" data-act="fav" title="즐겨찾기">
<span class="${star}" aria-hidden="true">★</span>
</button>
</div>
<div class="card-desc">${desc || "&nbsp;"}</div>
<div class="tags">${favTag}${tags}</div>
<div class="card-actions">
<a class="btn mini" href="${u}" target="_blank" rel="noopener noreferrer">열기</a>
<button class="btn mini" type="button" data-act="copy">URL 복사</button>
<button class="btn mini" type="button" data-act="edit">편집</button>
<button class="btn mini mini-danger" type="button" data-act="del">삭제</button>
</div>
</article>
`;
}
function render() {
const q = (state.query || "").trim().toLowerCase();
const all = getMergedLinks();
const filtered = all
.filter((l) => (state.onlyFav ? l.favorite : true))
.filter((l) => matches(l, q))
.sort(compare);
if (el.grid) el.grid.innerHTML = filtered.map(cardHtml).join("");
if (el.empty) el.empty.hidden = filtered.length !== 0;
if (el.meta) {
const favCount = all.filter((l) => l.favorite).length;
el.meta.textContent = `표시 ${filtered.length}개 · 전체 ${all.length}개 · 즐겨찾기 ${favCount}`;
}
}
function openModal(link) {
if (!el.modal) return;
el.modal.hidden = false;
document.body.style.overflow = "hidden";
document.getElementById("modalTitle").textContent = link ? "링크 편집" : "링크 추가";
el.id.value = link ? link.id : "";
el.title.value = link ? link.title : "";
el.url.value = link ? link.url : "";
el.description.value = link ? link.description || "" : "";
el.tags.value = link ? (link.tags || []).join(", ") : "";
el.favorite.checked = link ? Boolean(link.favorite) : false;
setTimeout(() => el.title && el.title.focus(), 0);
}
function closeModal() {
if (!el.modal) return;
el.modal.hidden = true;
document.body.style.overflow = "";
if (el.form) el.form.reset();
if (el.id) el.id.value = "";
}
function isBaseId(id) {
return baseOrder.has(id);
}
function getById(id) {
return getMergedLinks().find((l) => l.id === id) || null;
}
function setOverride(id, patch) {
state.store.overridesById[id] = Object.assign({}, state.store.overridesById[id] || {}, patch);
saveStore();
}
function upsertCustom(link) {
const n = normalizeLink(link);
const idx = (state.store.custom || []).findIndex((c) => c && c.id === n.id);
if (idx >= 0) state.store.custom[idx] = n;
else state.store.custom.push(n);
saveStore();
}
function delLink(id) {
if (isBaseId(id)) {
const s = new Set(state.store.tombstones || []);
s.add(id);
state.store.tombstones = Array.from(s);
saveStore();
return;
}
state.store.custom = (state.store.custom || []).filter((c) => c && c.id !== id);
saveStore();
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
toast("복사했습니다.");
} catch {
toast("복사에 실패했습니다.");
}
}
function exportJson() {
const data = getMergedLinks();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `links-export-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 0);
}
function importJsonText(text) {
const parsed = safeJsonParse(text, null);
const list = Array.isArray(parsed) ? parsed : parsed && Array.isArray(parsed.links) ? parsed.links : null;
if (!list) throw new Error("JSON 형식이 올바르지 않습니다.");
for (const item of list) {
if (!item) continue;
const n = normalizeLink(item);
// 충돌 방지
if (getById(n.id)) n.id = "import-" + Math.random().toString(16).slice(2);
state.store.custom.push(n);
}
saveStore();
}
function applyTheme(theme) {
const t = theme === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", t);
localStorage.setItem(THEME_KEY, t);
}
(function initTheme() {
const saved = localStorage.getItem(THEME_KEY);
if (saved === "light" || saved === "dark") return applyTheme(saved);
const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
applyTheme(prefersLight ? "light" : "dark");
})();
// Wire events
if (el.q)
el.q.addEventListener("input", () => {
state.query = el.q.value || "";
render();
});
if (el.sort)
el.sort.addEventListener("change", () => {
state.sortKey = el.sort.value || "json";
render();
});
if (el.onlyFav)
el.onlyFav.addEventListener("change", () => {
state.onlyFav = Boolean(el.onlyFav.checked);
render();
});
if (el.btnAdd) el.btnAdd.addEventListener("click", () => openModal(null));
if (el.btnClose) el.btnClose.addEventListener("click", closeModal);
if (el.btnCancel) el.btnCancel.addEventListener("click", closeModal);
if (el.modal)
el.modal.addEventListener("click", (e) => {
const close = e.target && e.target.getAttribute && e.target.getAttribute("data-close");
if (close) closeModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});
if (el.form)
el.form.addEventListener("submit", (e) => {
e.preventDefault();
const id = String(el.id.value || "").trim();
const title = String(el.title.value || "").trim();
const url = normalizeUrl(el.url.value);
if (!title) return toast("제목을 입력하세요.");
if (!url) return toast("URL을 입력하세요.");
const description = String(el.description.value || "").trim();
const tags = normalizeTags(el.tags.value);
const favorite = Boolean(el.favorite.checked);
const patch = { title, url, description, tags, favorite, updatedAt: nowIso() };
if (id) {
const cur = getById(id);
if (!cur) return toast("편집 대상이 없습니다.");
if (isBaseId(id)) setOverride(id, patch);
else upsertCustom(Object.assign({}, cur, patch));
toast("저장했습니다.");
} else {
upsertCustom({
id: "custom-" + Date.now() + "-" + Math.random().toString(16).slice(2),
title,
url,
description,
tags,
favorite,
createdAt: nowIso(),
updatedAt: nowIso(),
});
toast("추가했습니다.");
}
closeModal();
render();
});
if (el.grid)
el.grid.addEventListener("click", (e) => {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const card = e.target.closest(".card");
if (!card) return;
const id = card.getAttribute("data-id");
const act = btn.getAttribute("data-act");
const link = id ? getById(id) : null;
if (!link) return;
if (act === "fav") {
const next = !link.favorite;
if (isBaseId(id)) setOverride(id, { favorite: next, updatedAt: nowIso() });
else upsertCustom(Object.assign({}, link, { favorite: next, updatedAt: nowIso() }));
render();
} else if (act === "copy") {
copy(link.url);
} else if (act === "edit") {
openModal(link);
} else if (act === "del") {
if (confirm(`삭제할까요?\n\n- ${link.title}`)) {
delLink(id);
render();
}
}
});
if (el.btnExport) el.btnExport.addEventListener("click", exportJson);
if (el.btnImport)
el.btnImport.addEventListener("click", () => {
if (el.file) el.file.click();
});
if (el.file)
el.file.addEventListener("change", async () => {
const f = el.file.files && el.file.files[0];
el.file.value = "";
if (!f) return;
try {
importJsonText(await f.text());
toast("가져오기 완료");
render();
} catch (err) {
toast(String(err && err.message ? err.message : "가져오기 실패"));
}
});
if (el.btnTheme)
el.btnTheme.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") || "dark";
applyTheme(cur === "dark" ? "light" : "dark");
});
render();
toast("스크립트 로딩 문제로 폴백 모드로 실행 중입니다.");
}, 200);
})();
</script>
<noscript>
<div class="noscript">이 페이지는 JavaScript가 필요합니다.</div>
</noscript>
</body>
</html>