Files
home/index.html

1109 lines
46 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 = {
// (선택) API 서버가 다른 도메인이면 지정. 예: https://api.ncue.net
apiBase: "",
auth0: {
domain: "",
clientId: "",
},
// Auth0 connection 이름(선택). 예: google-oauth2
connections: {
google: "",
},
// 관리자 이메일(대소문자 무시)
adminEmails: [],
};
</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-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>
<!-- 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": "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"
},
{
"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": "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": "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"
}
]
</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 AUTH_OVERRIDE_KEY = "links_home_auth_override_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"),
snsLogin: document.getElementById("snsLogin"),
btnNaver: document.getElementById("btnNaver"),
btnKakao: document.getElementById("btnKakao"),
btnGoogle: document.getElementById("btnGoogle"),
btnLogout: document.getElementById("btnLogout"),
user: document.getElementById("user"),
userText: document.getElementById("userText"),
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]));
// Access levels (same as script.js)
const ACCESS_ANON_IDS = new Set(["dsyoon-ncue-net", "family-ncue-net", "link-ncue-net"]);
const ACCESS_USER_IDS = new Set([
"dsyoon-ncue-net",
"family-ncue-net",
"tts-ncue-net",
"meeting-ncue-net",
"link-ncue-net",
"dreamgirl-ncue-net",
]);
let sessionEmail = "";
function isAdminEmail(email) {
const cfg = getAuthConfig();
const admins = cfg && Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
const e = String(email || "").trim().toLowerCase();
if (admins.length) return admins.includes(e);
// fallback default
return ["dsyoon@ncue.net", "dosangyoon@gmail.com", "dosangyoon2@gmail.com", "dosangyoon3@gmail.com"].includes(e);
}
function canAccessLink(link) {
const id = String(link && link.id ? link.id : "");
const email = String(sessionEmail || "").trim().toLowerCase();
if (email && isAdminEmail(email)) return true;
if (email) return ACCESS_USER_IDS.has(id);
return ACCESS_ANON_IDS.has(id);
}
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 accessible = canAccessLink(link);
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 lockTag = accessible ? "" : `<span class="tag lock">접근 제한</span>`;
const letter = esc((link.title || d || "L").trim().slice(0, 1).toUpperCase());
function faviconCandidates(rawUrl) {
try {
const uu = new URL(String(rawUrl || ""));
const host = String(uu.hostname || "").toLowerCase();
const isNcue = host === "ncue.net" || host.endsWith(".ncue.net");
const parts = uu.pathname.split("/").filter(Boolean);
const rootFav = `${uu.origin}/favicon.ico`;
const candidates = [];
if (host === "mail.ncue.net") {
candidates.push(
`${uu.origin}/roundcube/skins/elastic/images/favicon.ico`,
`${uu.origin}/roundcube/skins/larry/images/favicon.ico`,
`${uu.origin}/roundcube/skins/classic/images/favicon.ico`,
`${uu.origin}/skins/elastic/images/favicon.ico`,
`${uu.origin}/skins/larry/images/favicon.ico`,
`${uu.origin}/skins/classic/images/favicon.ico`,
`${uu.origin}/roundcube/favicon.ico`
);
}
const pathFav = isNcue && parts.length ? `${uu.origin}/${parts[0]}/favicon.ico` : "";
const list = [];
if (pathFav) list.push(pathFav);
list.push(...candidates);
list.push(rootFav);
const uniq = [];
const seen = new Set();
for (const x of list) {
const v = String(x || "").trim();
if (!v) continue;
if (seen.has(v)) continue;
seen.add(v);
uniq.push(v);
}
return { primary: uniq[0] || "", fallbackList: uniq.slice(1) };
} catch {
return { primary: "", fallbackList: [] };
}
}
function buildOpenUrl(rawUrl) {
const url0 = String(rawUrl || "").trim();
if (!url0) return "";
let host = "";
try {
host = new URL(url0).hostname.toLowerCase();
} catch {
return url0;
}
const isNcue = host === "ncue.net" || host.endsWith(".ncue.net");
if (!isNcue) return url0;
const qs = new URLSearchParams();
qs.set("u", url0);
const email = String(sessionEmail || "").trim().toLowerCase();
if (email) qs.set("e", email);
return `/go?${qs.toString()}`;
}
const openHref = esc(buildOpenUrl(link.url));
const openHtml = accessible
? `<a class="btn mini" href="${openHref}" target="_blank" rel="noopener noreferrer">열기</a>`
: `<button class="btn mini" type="button" disabled aria-disabled="true" title="이 링크는 현재 권한으로 접근할 수 없습니다.">열기</button>`;
const copyAttrs = accessible ? "" : ` disabled aria-disabled="true" title="이 링크는 현재 권한으로 접근할 수 없습니다."`;
const fav = faviconCandidates(link.url);
const faviconHtml = fav && fav.primary
? `<img src="${esc(fav.primary)}" data-fb="${esc((fav.fallbackList || []).join("|"))}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" />`
: `<div class="letter">${letter}</div>`;
return `
<article class="card${accessible ? "" : " disabled"}" data-id="${esc(link.id)}" data-access="${accessible ? "1" : "0"}">
<div class="card-head">
<div class="card-title">
<div class="favicon" aria-hidden="true" data-letter="${letter}">${faviconHtml}</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}${lockTag}${tags}</div>
<div class="card-actions">
${openHtml}
<button class="btn mini" type="button" data-act="copy"${copyAttrs}>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("");
// bind favicon fallback chain (no inline onerror; works under CSP)
if (el.grid) {
const imgs = el.grid.querySelectorAll("img[data-fb]");
for (const img of imgs) {
if (img.dataset.bound === "1") continue;
img.dataset.bound = "1";
img.addEventListener(
"error",
() => {
const fb = String(img.dataset.fb || "");
const list = fb ? fb.split("|").filter(Boolean) : [];
const next = list.shift();
if (next) {
img.dataset.fb = list.join("|");
img.src = next;
return;
}
const p = img.parentNode;
const letter = p && p.getAttribute ? p.getAttribute("data-letter") : "";
img.remove();
if (p) p.insertAdjacentHTML("beforeend", `<div class="letter">${esc(letter || "L")}</div>`);
},
{ passive: true }
);
}
}
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");
})();
// Auth (fallback): wire SNS quick login buttons
const auth = {
client: null,
user: null,
ready: false,
};
function apiUrl(pathname) {
// same-origin only in fallback
return pathname;
}
async function hydrateAuthConfigFromServerIfNeeded() {
const cfg = getAuthConfig();
const hasLocal = Boolean(cfg.auth0.domain && cfg.auth0.clientId && cfg.connections.google);
if (hasLocal) return true;
try {
const r = await fetch(apiUrl("/api/config/auth"), { cache: "no-store" });
if (!r.ok) return false;
const data = await r.json();
if (!data || !data.ok || !data.value) return false;
const v = data.value;
const auth0 = v.auth0 || {};
const connections = v.connections || {};
const adminEmails = Array.isArray(v.adminEmails) ? v.adminEmails : Array.isArray(v.allowedEmails) ? v.allowedEmails : [];
const domain = String(auth0.domain || "").trim();
const clientId = String(auth0.clientId || "").trim();
const google = String(connections.google || "").trim();
if (!domain || !clientId || !google) return false;
localStorage.setItem(
AUTH_OVERRIDE_KEY,
JSON.stringify({
auth0: { domain, clientId },
connections: { google },
adminEmails,
})
);
return true;
} catch {
return false;
}
}
function applyManageLock() {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
if (el.btnAdd) el.btnAdd.disabled = !canManage;
if (el.btnImport) el.btnImport.disabled = !canManage;
// 요청: 로그인 전 내보내기 비활성화
if (el.btnExport) el.btnExport.disabled = !auth.user;
}
function loadAuthOverride() {
const raw = localStorage.getItem(AUTH_OVERRIDE_KEY);
const data = raw ? safeJsonParse(raw, null) : null;
if (!data || typeof data !== "object") return null;
const auth0 = data.auth0 && typeof data.auth0 === "object" ? data.auth0 : {};
const connections = data.connections && typeof data.connections === "object" ? data.connections : {};
// legacy: allowedEmails -> adminEmails
const adminEmails = Array.isArray(data.adminEmails)
? data.adminEmails
: Array.isArray(data.allowedEmails)
? data.allowedEmails
: [];
return {
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections: {
google: String(connections.google || "").trim(),
kakao: String(connections.kakao || "").trim(),
naver: String(connections.naver || "").trim(),
},
adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
}
function getAuthConfig() {
const cfg = window.AUTH_CONFIG && typeof window.AUTH_CONFIG === "object" ? window.AUTH_CONFIG : {};
const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {};
const connections = cfg.connections && typeof cfg.connections === "object" ? cfg.connections : {};
// legacy: allowedEmails -> adminEmails
const adminEmails = Array.isArray(cfg.adminEmails)
? cfg.adminEmails
: Array.isArray(cfg.allowedEmails)
? cfg.allowedEmails
: [];
const base = {
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections: {
google: String(connections.google || "").trim(),
kakao: String(connections.kakao || "").trim(),
naver: String(connections.naver || "").trim(),
},
adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
const over = loadAuthOverride();
if (!over) return base;
return {
auth0: {
domain: over.auth0.domain || base.auth0.domain,
clientId: over.auth0.clientId || base.auth0.clientId,
},
connections: {
google: over.connections.google || base.connections.google,
kakao: over.connections.kakao || base.connections.kakao,
naver: over.connections.naver || base.connections.naver,
},
adminEmails: over.adminEmails.length ? over.adminEmails : base.adminEmails,
};
}
function currentUrlNoQuery() {
const u = new URL(location.href);
u.searchParams.delete("code");
u.searchParams.delete("state");
return u.toString();
}
async function ensureAuthClient() {
if (auth.client) return auth.client;
await hydrateAuthConfigFromServerIfNeeded();
const cfg = getAuthConfig();
if (!cfg.auth0.domain || !cfg.auth0.clientId) return null;
if (typeof window.createAuth0Client !== "function") return null;
auth.client = await window.createAuth0Client({
domain: cfg.auth0.domain,
clientId: cfg.auth0.clientId,
authorizationParams: {
redirect_uri: location.origin === "null" ? location.href : location.origin + location.pathname,
},
cacheLocation: "localstorage",
useRefreshTokens: true,
});
return auth.client;
}
async function initAuth() {
auth.ready = true;
const client = await ensureAuthClient();
if (!client) {
// No config: keep buttons visible but disabled style
applyManageLock();
return;
}
const u = new URL(location.href);
const isCallback = u.searchParams.has("code") && u.searchParams.has("state");
if (isCallback) {
try {
await client.handleRedirectCallback();
} finally {
history.replaceState({}, document.title, currentUrlNoQuery());
}
}
const isAuthed = await client.isAuthenticated();
auth.user = isAuthed ? await client.getUser() : null;
sessionEmail = auth.user && auth.user.email ? String(auth.user.email).trim().toLowerCase() : "";
if (el.btnLogout) el.btnLogout.hidden = !auth.user;
if (el.snsLogin) el.snsLogin.hidden = Boolean(auth.user);
if (el.user) el.user.hidden = !auth.user;
if (el.userText && auth.user) el.userText.textContent = auth.user.email || auth.user.name || "로그인됨";
// sync user to server (upsert ncue_user)
if (auth.user) {
try {
const claims = await client.getIdTokenClaims();
const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) {
const cfg = getAuthConfig();
await fetch(apiUrl("/api/auth/sync"), {
method: "POST",
headers: {
Authorization: `Bearer ${raw}`,
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
"X-Auth0-ClientId": cfg.auth0.clientId,
},
}).catch(() => {});
}
} catch {
// ignore
}
}
applyManageLock();
render();
}
async function loginWithConnection(provider) {
const cfg = getAuthConfig();
const client = await ensureAuthClient();
if (!client) {
toast("간편로그인은 관리자 설정 후 사용 가능합니다.");
return;
}
const conn = cfg.connections[provider] || "";
if (!conn) {
toast("관리자가 Auth0 connection 이름을 설정해야 합니다.");
return;
}
await client.loginWithRedirect({ authorizationParams: { connection: conn } });
}
async function logout() {
const client = await ensureAuthClient();
if (!client) return;
sessionEmail = "";
await client.logout({
logoutParams: { returnTo: location.origin === "null" ? location.href : location.origin + location.pathname },
});
}
// 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");
if ((btn.getAttribute("data-act") === "copy" || btn.getAttribute("data-act") === "open") && card.getAttribute("data-access") === "0") {
toast("이 링크는 현재 권한으로 접근할 수 없습니다.");
e.preventDefault();
return;
}
// block manage actions unless admin
const act0 = btn.getAttribute("data-act");
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
if (!canManage && (act0 === "fav" || act0 === "edit" || act0 === "del")) {
toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
return;
}
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", () => {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail);
if (!canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
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");
});
if (el.btnGoogle) el.btnGoogle.addEventListener("click", () => loginWithConnection("google").catch(() => toast("로그인에 실패했습니다.")));
if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다.")));
render();
initAuth().catch(() => {});
applyManageLock();
toast("스크립트 로딩 문제로 폴백 모드로 실행 중입니다.");
}, 200);
})();
</script>
<noscript>
<div class="noscript">이 페이지는 JavaScript가 필요합니다.</div>
</noscript>
</body>
</html>