Files
home/script.js
dsyoon 3370ca562e Tighten header quick login layout
Shrink SNS circular buttons, move '간편로그인' label to the left, and hide the user/status badge until authenticated.

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

1062 lines
35 KiB
JavaScript

(() => {
"use strict";
const STORAGE_KEY = "links_home_v1";
const THEME_KEY = "links_home_theme_v1";
const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1";
const AUTH_OVERRIDE_KEY = "links_home_auth_override_v1";
const el = {
subtitle: document.getElementById("subtitle"),
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"),
user: document.getElementById("user"),
userText: document.getElementById("userText"),
btnLogin: document.getElementById("btnLogin"),
btnLogout: document.getElementById("btnLogout"),
snsLogin: document.getElementById("snsLogin"),
btnGoogle: document.getElementById("btnGoogle"),
btnKakao: document.getElementById("btnKakao"),
btnNaver: document.getElementById("btnNaver"),
authModal: document.getElementById("authModal"),
btnAuthClose: document.getElementById("btnAuthClose"),
authForm: document.getElementById("authForm"),
authDomain: document.getElementById("authDomain"),
authClientId: document.getElementById("authClientId"),
authAllowedEmails: document.getElementById("authAllowedEmails"),
authConnGoogle: document.getElementById("authConnGoogle"),
authConnKakao: document.getElementById("authConnKakao"),
authConnNaver: document.getElementById("authConnNaver"),
btnAuthReset: document.getElementById("btnAuthReset"),
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"),
toast: document.getElementById("toast"),
};
// NOTE:
// 예전에는 links.json을 못 읽는 환경(file:// 등)에서 "내장 기본 목록"으로 조용히 대체했는데,
// 그러면 links.json의 순서/내용 변경이 반영되지 않아 혼란이 생깁니다.
// 이제는 links.json 로드를 우선하며, 실패 시 경고를 띄우고 빈 목록(또는 localStorage 커스텀)으로 동작합니다.
const DEFAULT_LINKS_INLINE = [];
const state = {
baseLinks: [],
baseOrder: new Map(),
store: loadStore(),
query: "",
sortKey: "json",
onlyFav: false,
canManage: false,
};
const auth = {
client: null,
user: null,
authorized: false,
ready: false,
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
serverCanManage: null,
};
function nowIso() {
return new Date().toISOString();
}
function safeJsonParse(s, fallback) {
try {
return JSON.parse(s);
} catch {
return fallback;
}
}
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 normalizeUrl(url) {
const u = String(url || "").trim();
if (!u) return "";
if (/^https?:\/\//i.test(u)) return u;
return "https://" + u;
}
function normalizeTags(tagsText) {
if (!tagsText) return [];
return String(tagsText)
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 12);
}
function getDomain(url) {
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//i, "").split("/")[0] || "";
}
}
function faviconUrl(url) {
const domainUrl = encodeURIComponent(url);
return `https://www.google.com/s2/favicons?domain_url=${domainUrl}&sz=64`;
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function idFromUrl(url) {
const d = getDomain(url).toLowerCase();
const cleaned = d.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return cleaned || "link";
}
function newId(prefix = "custom") {
if (globalThis.crypto && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function normalizeLink(link) {
const url = normalizeUrl(link.url);
const id = String(link.id || "").trim() || idFromUrl(url) || newId("link");
const title = String(link.title || "").trim() || getDomain(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 };
}
function getMergedLinks() {
const tomb = new Set(state.store.tombstones || []);
const overrides = state.store.overridesById || {};
const byId = new Map();
for (const base of state.baseLinks) {
if (!base || !base.id) continue;
if (tomb.has(base.id)) continue;
const o = overrides[base.id];
byId.set(base.id, { ...base, ...(o || {}) });
}
for (const c of state.store.custom || []) {
const n = normalizeLink(c);
byId.set(n.id, n);
}
return [...byId.values()];
}
function matchesQuery(link, q) {
if (!q) return true;
const hay = [
link.title,
link.url,
getDomain(link.url),
link.description || "",
(link.tags || []).join(" "),
]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
function toTime(s) {
const t = Date.parse(String(s || ""));
return Number.isFinite(t) ? t : 0;
}
function orderKey(link) {
const idx = state.baseOrder.get(link.id);
if (typeof idx === "number") return idx;
// custom/imported links go after base list, in creation order
return 1_000_000 + toTime(link.createdAt);
}
function compareLinks(a, b) {
const key = state.sortKey;
if (key === "json") {
const oa = orderKey(a);
const ob = orderKey(b);
if (oa !== ob) return oa - ob;
return a.title.localeCompare(b.title, "ko");
}
if (key === "favorite") {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
// tie-breaker: keep json order
const oa = orderKey(a);
const ob = orderKey(b);
if (oa !== ob) return oa - ob;
}
if (key === "name") return a.title.localeCompare(b.title, "ko");
if (key === "domain") return getDomain(a.url).localeCompare(getDomain(b.url), "en");
// recent (default)
return String(b.updatedAt).localeCompare(String(a.updatedAt));
}
function render() {
const q = state.query.trim().toLowerCase();
const all = getMergedLinks();
const filtered = all
.filter((l) => (state.onlyFav ? l.favorite : true))
.filter((l) => matchesQuery(l, q))
.sort(compareLinks);
el.grid.innerHTML = filtered.map(cardHtml).join("");
el.empty.hidden = filtered.length !== 0;
const favCount = all.filter((l) => l.favorite).length;
el.meta.textContent = `표시 ${filtered.length}개 · 전체 ${all.length}개 · 즐겨찾기 ${favCount}`;
el.subtitle.textContent = all.length ? `링크 ${all.length}` : "개인 링크 관리";
}
function cardHtml(link) {
const domain = escapeHtml(getDomain(link.url));
const title = escapeHtml(link.title);
const desc = escapeHtml(link.description || "");
const url = escapeHtml(link.url);
const starClass = link.favorite ? "star on" : "star";
const tags = (link.tags || []).slice(0, 8);
const tagHtml = [
link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : "",
...tags.map((t) => `<span class="tag">#${escapeHtml(t)}</span>`),
]
.filter(Boolean)
.join("");
const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase());
const lockAttr = state.canManage ? "" : ' disabled aria-disabled="true"';
const lockTitle = state.canManage ? "" : ' title="관리 기능은 로그인 후 사용 가능합니다."';
return `
<article class="card" data-id="${escapeHtml(link.id)}">
<div class="card-head">
<div class="card-title">
<div class="favicon" aria-hidden="true">
<img src="${faviconUrl(link.url)}" alt="" onerror="this.remove(); this.parentNode.insertAdjacentHTML('beforeend','<div class=&quot;letter&quot;>${letter}</div>');" />
</div>
<div class="title-wrap">
<div class="title" title="${title}">${title}</div>
<div class="domain" title="${domain}">${domain}</div>
</div>
</div>
<button class="icon-btn" type="button" data-act="fav"${lockAttr}${lockTitle}>
<span class="${starClass}" aria-hidden="true">★</span>
</button>
</div>
<div class="card-desc">${desc || "&nbsp;"}</div>
<div class="tags">${tagHtml || ""}</div>
<div class="card-actions">
<a class="btn mini" href="${url}" target="_blank" rel="noopener noreferrer" data-act="open">열기</a>
<button class="btn mini" type="button" data-act="copy">URL 복사</button>
<button class="btn mini" type="button" data-act="edit"${lockAttr}${lockTitle}>편집</button>
<button class="btn mini mini-danger" type="button" data-act="del"${lockAttr}${lockTitle}>삭제</button>
</div>
</article>
`;
}
function openModal(mode, link) {
el.modal.hidden = false;
document.body.style.overflow = "hidden";
const isEdit = mode === "edit";
document.getElementById("modalTitle").textContent = isEdit ? "링크 편집" : "링크 추가";
el.id.value = isEdit ? link.id : "";
el.title.value = isEdit ? link.title : "";
el.url.value = isEdit ? link.url : "";
el.description.value = isEdit ? link.description || "" : "";
el.tags.value = isEdit ? (link.tags || []).join(", ") : "";
el.favorite.checked = isEdit ? Boolean(link.favorite) : false;
setTimeout(() => el.title.focus(), 0);
}
function closeModal() {
el.modal.hidden = true;
document.body.style.overflow = "";
el.form.reset();
el.id.value = "";
}
function getLinkById(id) {
return getMergedLinks().find((l) => l.id === id) || null;
}
function isBaseId(id) {
return state.baseLinks.some((l) => l.id === id);
}
function setOverride(id, patch) {
state.store.overridesById[id] = { ...(state.store.overridesById[id] || {}), ...patch };
saveStore();
}
function removeOverride(id) {
if (state.store.overridesById && state.store.overridesById[id]) {
delete state.store.overridesById[id];
}
saveStore();
}
function toast(msg) {
el.toast.textContent = msg;
el.toast.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => {
el.toast.hidden = true;
}, 2400);
}
function toastOnce(key, msg) {
const k = `${AUTH_TOAST_ONCE_KEY}:${key}`;
if (localStorage.getItem(k)) return;
localStorage.setItem(k, "1");
toast(msg);
}
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 allowedEmails = Array.isArray(data.allowedEmails) ? data.allowedEmails : [];
return {
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections:
data.connections && typeof data.connections === "object"
? {
google: String(data.connections.google || "").trim(),
kakao: String(data.connections.kakao || "").trim(),
naver: String(data.connections.naver || "").trim(),
}
: { google: "", kakao: "", naver: "" },
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
}
function saveAuthOverride(cfg) {
localStorage.setItem(AUTH_OVERRIDE_KEY, JSON.stringify(cfg));
}
function clearAuthOverride() {
localStorage.removeItem(AUTH_OVERRIDE_KEY);
}
function getAuthConfig() {
const cfg = globalThis.AUTH_CONFIG && typeof globalThis.AUTH_CONFIG === "object" ? globalThis.AUTH_CONFIG : {};
const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {};
const allowedEmails = Array.isArray(cfg.allowedEmails) ? cfg.allowedEmails : [];
const base = {
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections: {
google: "",
kakao: "",
naver: "",
},
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
const override = loadAuthOverride();
if (!override) return base;
// override가 있으면 우선 적용 (서버 재배포 없이 테스트 가능)
return {
auth0: {
domain: override.auth0.domain || base.auth0.domain,
clientId: override.auth0.clientId || base.auth0.clientId,
},
connections: {
google: override.connections?.google || "",
kakao: override.connections?.kakao || "",
naver: override.connections?.naver || "",
},
allowedEmails: override.allowedEmails.length ? override.allowedEmails : base.allowedEmails,
};
}
function currentUrlNoQuery() {
// Auth0 callback 후 URL 정리용
const u = new URL(location.href);
u.searchParams.delete("code");
u.searchParams.delete("state");
return u.toString();
}
function isAllowedEmail(email) {
const { allowedEmails } = getAuthConfig();
if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용
const e = String(email || "").trim().toLowerCase();
return allowedEmails.includes(e);
}
function updateAuthUi() {
// 로그인 전에는 사용자 배지를 숨김(요청: "로그인 설정 필요" 영역 제거)
if (!auth.user) {
el.user.hidden = true;
} else {
el.user.hidden = false;
const email = auth.user && auth.user.email ? String(auth.user.email) : "";
const name = auth.user && auth.user.name ? String(auth.user.name) : "";
const label = email ? email : name ? name : "로그인됨";
el.userText.textContent = label;
if (auth.authorized) el.user.setAttribute("data-auth", "ok");
else el.user.removeAttribute("data-auth");
}
// 로그인 기능이 활성(enabled)일 때만 로그인/로그아웃 버튼을 의미 있게 노출
const enabled = auth.mode === "enabled";
el.btnLogin.hidden = enabled ? Boolean(auth.user) : false;
// 로그아웃은 로그인 된 이후에만 노출
el.btnLogout.hidden = !auth.user;
// 설정/SDK 문제 상태에서도 버튼은 "클릭 가능"하게 두고, 클릭 시 토스트로 안내합니다.
el.btnLogin.disabled = false;
el.btnLogout.disabled = false;
// 간편 로그인 버튼 노출
// - 설정 전(로그인 설정 필요)에도 디자인이 보이도록 영역은 항상 노출
// - connection이 없으면 클릭 시 설정 모달을 띄움(핸들러에서 처리)
const cfg = getAuthConfig();
const showQuick = !auth.user;
if (el.snsLogin) el.snsLogin.hidden = !showQuick;
if (el.btnGoogle) {
el.btnGoogle.hidden = !showQuick;
el.btnGoogle.classList.toggle("is-disabled", !cfg.connections.google);
}
if (el.btnKakao) {
el.btnKakao.hidden = !showQuick;
el.btnKakao.classList.toggle("is-disabled", !cfg.connections.kakao);
}
if (el.btnNaver) {
el.btnNaver.hidden = !showQuick;
el.btnNaver.classList.toggle("is-disabled", !cfg.connections.naver);
}
}
function applyManageLock() {
// AUTH_CONFIG가 없는 상태에서는 기존처럼 자유롭게 관리 가능.
// 로그인 기능이 "enabled"일 때만 관리 잠금을 적용합니다.
state.canManage = auth.mode === "enabled" ? Boolean(auth.user && auth.authorized) : true;
const lockMsg = "관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.";
el.btnAdd.disabled = !state.canManage;
el.btnImport.disabled = !state.canManage;
if (!state.canManage) {
el.btnAdd.title = lockMsg;
el.btnImport.title = lockMsg;
} else {
el.btnAdd.title = "";
el.btnImport.title = "";
}
}
async function initAuth() {
auth.ready = true;
const cfg = getAuthConfig();
const hasAuth0 = cfg.auth0.domain && cfg.auth0.clientId;
const hasSdk = typeof globalThis.createAuth0Client === "function";
if (!hasAuth0) {
// 설정이 없으면: 로그인은 비활성(안내만), 관리 기능은 잠그지 않음
auth.client = null;
auth.user = null;
auth.authorized = false;
auth.mode = "misconfigured";
updateAuthUi();
applyManageLock();
toastOnce(
"authcfg",
"로그인 설정이 비어있습니다. index.html의 AUTH_CONFIG(auth0.domain/clientId, allowedEmails)를 채워주세요."
);
return;
}
if (!hasSdk) {
toastOnce("auth0sdk", "로그인 SDK 로드에 실패했습니다. 네트워크/차단 설정을 확인하세요.");
auth.client = null;
auth.user = null;
auth.authorized = false;
auth.mode = "sdk_missing";
updateAuthUi();
applyManageLock();
return;
}
if (location.protocol === "file:") {
toastOnce("file", "소셜 로그인은 보통 HTTPS 사이트에서만 동작합니다. (file://에서는 제한될 수 있어요)");
}
try {
auth.client = await 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,
});
auth.mode = "enabled";
} catch {
auth.client = null;
auth.user = null;
auth.authorized = false;
auth.mode = "sdk_missing";
toastOnce("authinit", "로그인 초기화에 실패했습니다. AUTH_CONFIG 값과 Auth0 설정(Callback URL)을 확인하세요.");
updateAuthUi();
applyManageLock();
return;
}
const u = new URL(location.href);
const isCallback = u.searchParams.has("code") && u.searchParams.has("state");
if (isCallback) {
try {
await auth.client.handleRedirectCallback();
} finally {
history.replaceState({}, document.title, currentUrlNoQuery());
}
}
const isAuthed = await auth.client.isAuthenticated();
auth.user = isAuthed ? await auth.client.getUser() : null;
const email = auth.user && auth.user.email ? auth.user.email : "";
auth.authorized = Boolean(auth.user) && isAllowedEmail(email);
auth.serverCanManage = null;
// 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만)
if (auth.user) {
try {
const claims = await auth.client.getIdTokenClaims();
const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) {
const cfg = getAuthConfig();
const r = await fetch("/api/auth/sync", {
method: "POST",
headers: {
Authorization: `Bearer ${raw}`,
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
"X-Auth0-ClientId": cfg.auth0.clientId,
},
});
if (r.ok) {
const data = await r.json();
if (data && data.ok) {
auth.serverCanManage = Boolean(data.canManage);
auth.authorized = auth.serverCanManage;
}
}
}
} catch {
// ignore: server not running or blocked
}
}
if (auth.user && !auth.authorized) {
toastOnce("deny", "로그인은 되었지만 허용 이메일이 아니라서 관리 기능이 잠금 상태입니다.");
} else if (auth.user && getAuthConfig().allowedEmails.length === 0) {
toastOnce("allowall", "주의: 허용 이메일 목록이 비어있어서 로그인한 모든 계정이 관리 가능 상태입니다.");
}
updateAuthUi();
applyManageLock();
}
async function login() {
if (auth.mode !== "enabled" || !auth.client) {
openAuthModal();
return;
}
await auth.client.loginWithRedirect();
}
async function loginWithConnection(connection) {
if (auth.mode !== "enabled" || !auth.client) {
openAuthModal();
return;
}
await auth.client.loginWithRedirect({
authorizationParams: { connection },
});
}
async function logout() {
if (auth.mode !== "enabled" || !auth.client) return;
auth.user = null;
auth.authorized = false;
updateAuthUi();
applyManageLock();
await auth.client.logout({
logoutParams: {
returnTo: location.origin === "null" ? location.href : location.origin + location.pathname,
},
});
}
function openAuthModal() {
if (!el.authModal || !el.authForm) {
toast("로그인 설정 UI를 찾지 못했습니다. 새로고침 후 다시 시도하세요.");
return;
}
const cfg = getAuthConfig();
el.authDomain.value = cfg.auth0.domain || "";
el.authClientId.value = cfg.auth0.clientId || "";
el.authAllowedEmails.value = (cfg.allowedEmails || []).join(", ");
if (el.authConnGoogle) el.authConnGoogle.value = cfg.connections.google || "";
if (el.authConnKakao) el.authConnKakao.value = cfg.connections.kakao || "";
if (el.authConnNaver) el.authConnNaver.value = cfg.connections.naver || "";
el.authModal.hidden = false;
document.body.style.overflow = "hidden";
setTimeout(() => el.authDomain.focus(), 0);
}
function closeAuthModal() {
if (!el.authModal) return;
el.authModal.hidden = true;
document.body.style.overflow = "";
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
toast("복사했습니다.");
} catch {
// fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "readonly");
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
toast("복사했습니다.");
}
}
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 deleteLink(id) {
if (isBaseId(id)) {
const s = new Set(state.store.tombstones || []);
s.add(id);
state.store.tombstones = [...s];
removeOverride(id);
saveStore();
toast("기본 링크를 숨겼습니다.");
return;
}
const before = state.store.custom.length;
state.store.custom = (state.store.custom || []).filter((c) => c && c.id !== id);
saveStore();
if (state.store.custom.length !== before) toast("삭제했습니다.");
}
function toggleFavorite(id) {
const link = getLinkById(id);
if (!link) return;
const next = !link.favorite;
if (isBaseId(id)) {
setOverride(id, { favorite: next, updatedAt: nowIso() });
} else {
upsertCustom({ ...link, favorite: next, updatedAt: nowIso() });
}
render();
}
function editLink(id) {
const link = getLinkById(id);
if (!link) return;
openModal("edit", link);
}
async function loadBaseLinks() {
// 1) index.html 내부 내장 데이터(서버 없이도 동작)
const dataEl = document.getElementById("linksData");
if (dataEl && dataEl.textContent) {
const parsed = safeJsonParse(dataEl.textContent, null);
if (Array.isArray(parsed)) return parsed.map(normalizeLink);
}
// 2) 동일 디렉토리의 links.json (서버 환경에서 권장)
const candidates = [
new URL("./links.json", document.baseURI).toString(),
new URL("links.json", document.baseURI).toString(),
];
for (const url of candidates) {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) continue;
const data = await res.json();
if (!Array.isArray(data)) continue;
return data.map(normalizeLink);
} catch {
// try next
}
}
const hint =
location.protocol === "file:"
? "기본 링크 데이터가 없습니다. index.html의 linksData를 확인하세요."
: "links.json을 불러오지 못했습니다. 배포 경로에 links.json이 있는지 확인하세요.";
toast(hint);
return DEFAULT_LINKS_INLINE.map(normalizeLink);
}
function applyTheme(theme) {
const t = theme === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", t);
el.btnTheme.setAttribute("aria-pressed", t === "dark" ? "true" : "false");
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");
}
function exportJson() {
const data = getMergedLinks().sort((a, b) => a.title.localeCompare(b.title, "ko"));
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);
toast("내보내기 파일을 생성했습니다.");
}
function importJsonText(text) {
const parsed = safeJsonParse(text, null);
if (!parsed) throw new Error("JSON 파싱 실패");
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.links) ? parsed.links : null;
if (!list) throw new Error("JSON 형식이 올바르지 않습니다. (배열 또는 {links:[...]} )");
const merged = getMergedLinks();
const used = new Set(merged.map((l) => l.id));
let added = 0;
for (const item of list) {
if (!item) continue;
const n0 = normalizeLink(item);
let n = n0;
if (used.has(n.id)) {
n = { ...n, id: newId("import"), createdAt: nowIso(), updatedAt: nowIso() };
}
used.add(n.id);
// 가져오기는 custom로 추가(기본과 충돌 방지)
state.store.custom.push(n);
added++;
}
saveStore();
toast(`가져오기 완료: ${added}`);
}
function onGridClick(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 (!id) return;
const act = btn.getAttribute("data-act");
if (auth.mode === "enabled" && !state.canManage && (act === "fav" || act === "edit" || act === "del")) {
toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
return;
}
if (act === "fav") {
toggleFavorite(id);
return;
}
if (act === "copy") {
const link = getLinkById(id);
if (link) copyText(link.url);
return;
}
if (act === "edit") {
editLink(id);
return;
}
if (act === "del") {
const link = getLinkById(id);
const name = link ? link.title : id;
if (confirm(`삭제할까요?\n\n- ${name}`)) {
deleteLink(id);
render();
}
return;
}
}
function onFormSubmit(e) {
e.preventDefault();
const isEdit = Boolean(el.id.value);
const title = String(el.title.value || "").trim();
const url = normalizeUrl(el.url.value);
if (!title) return toast("제목을 입력하세요.");
if (!url) return toast("URL을 입력하세요.");
let parsed;
try {
parsed = new URL(url);
} catch {
return toast("URL 형식이 올바르지 않습니다.");
}
if (!/^https?:$/.test(parsed.protocol)) return toast("http/https URL만 지원합니다.");
const description = String(el.description.value || "").trim();
const tags = normalizeTags(el.tags.value);
const favorite = Boolean(el.favorite.checked);
if (isEdit) {
const id = el.id.value;
const current = getLinkById(id);
if (!current) {
closeModal();
toast("편집 대상이 없습니다.");
return;
}
const patch = {
title,
url,
description,
tags,
favorite,
updatedAt: nowIso(),
};
if (isBaseId(id)) setOverride(id, patch);
else upsertCustom({ ...current, ...patch });
closeModal();
render();
toast("저장했습니다.");
return;
}
const id = newId("custom");
upsertCustom({
id,
title,
url,
description,
tags,
favorite,
createdAt: nowIso(),
updatedAt: nowIso(),
});
closeModal();
render();
toast("추가했습니다.");
}
function wire() {
el.q.addEventListener("input", () => {
state.query = el.q.value || "";
render();
});
el.sort.addEventListener("change", () => {
state.sortKey = el.sort.value || "json";
render();
});
el.onlyFav.addEventListener("change", () => {
state.onlyFav = Boolean(el.onlyFav.checked);
render();
});
el.grid.addEventListener("click", onGridClick);
el.btnAdd.addEventListener("click", () => {
if (auth.mode === "enabled" && !state.canManage)
return toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
openModal("add", null);
});
el.btnClose.addEventListener("click", closeModal);
el.btnCancel.addEventListener("click", closeModal);
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" && !el.modal.hidden) closeModal();
});
el.form.addEventListener("submit", onFormSubmit);
el.btnExport.addEventListener("click", exportJson);
el.btnImport.addEventListener("click", () => {
if (auth.mode === "enabled" && !state.canManage)
return toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
el.file.click();
});
el.file.addEventListener("change", async () => {
const f = el.file.files && el.file.files[0];
el.file.value = "";
if (!f) return;
try {
const text = await f.text();
importJsonText(text);
render();
} catch (err) {
toast(String(err && err.message ? err.message : "가져오기 실패"));
}
});
el.btnTheme.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") || "dark";
applyTheme(cur === "dark" ? "light" : "dark");
});
if (el.btnLogin) el.btnLogin.addEventListener("click", () => login().catch(() => toast("로그인에 실패했습니다.")));
if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다.")));
if (el.btnGoogle)
el.btnGoogle.addEventListener("click", () => {
const c = getAuthConfig().connections.google;
if (!c) return openAuthModal();
return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다."));
});
if (el.btnKakao)
el.btnKakao.addEventListener("click", () => {
const c = getAuthConfig().connections.kakao;
if (!c) return openAuthModal();
return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다."));
});
if (el.btnNaver)
el.btnNaver.addEventListener("click", () => {
const c = getAuthConfig().connections.naver;
if (!c) return openAuthModal();
return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다."));
});
if (el.btnAuthClose) el.btnAuthClose.addEventListener("click", closeAuthModal);
if (el.authModal) {
el.authModal.addEventListener("click", (e) => {
const close = e.target && e.target.getAttribute && e.target.getAttribute("data-auth-close");
if (close) closeAuthModal();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && el.authModal && !el.authModal.hidden) closeAuthModal();
});
if (el.btnAuthReset) {
el.btnAuthReset.addEventListener("click", () => {
clearAuthOverride();
toast("로그인 설정을 초기화했습니다.");
closeAuthModal();
// 초기화 후 재로딩(상태 정리)
setTimeout(() => location.reload(), 200);
});
}
if (el.authForm) {
el.authForm.addEventListener("submit", (e) => {
e.preventDefault();
const domain = String(el.authDomain.value || "").trim();
const clientId = String(el.authClientId.value || "").trim();
const emails = String(el.authAllowedEmails.value || "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const connGoogle = el.authConnGoogle ? String(el.authConnGoogle.value || "").trim() : "";
const connKakao = el.authConnKakao ? String(el.authConnKakao.value || "").trim() : "";
const connNaver = el.authConnNaver ? String(el.authConnNaver.value || "").trim() : "";
if (!domain || !clientId) {
toast("Domain과 Client ID를 입력하세요.");
return;
}
saveAuthOverride({
auth0: { domain, clientId },
connections: { google: connGoogle, kakao: connKakao, naver: connNaver },
allowedEmails: emails,
});
toast("저장했습니다. 페이지를 새로고침합니다.");
closeAuthModal();
setTimeout(() => location.reload(), 200);
});
}
}
async function main() {
initTheme();
wire();
await initAuth();
state.baseLinks = await loadBaseLinks();
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
el.sort.value = state.sortKey;
applyManageLock();
render();
}
main().catch(() => {
toast("초기화에 실패했습니다.");
});
})();