1515 lines
50 KiB
JavaScript
1515 lines
50 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
// If index.html inline fallback already booted, do nothing.
|
|
if (globalThis.__LINKS_APP_BOOTED__) return;
|
|
// Mark boot so index.html fallback won't run
|
|
globalThis.__LINKS_APP_BOOTED__ = true;
|
|
|
|
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 AUTH_PKCE_KEY = "links_home_auth_pkce_v1";
|
|
const AUTH_TOKEN_KEY = "links_home_auth_tokens_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"),
|
|
btnLogout: document.getElementById("btnLogout"),
|
|
snsLogin: document.getElementById("snsLogin"),
|
|
btnGoogle: document.getElementById("btnGoogle"),
|
|
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,
|
|
serverMode: false, // true when /api/links is available
|
|
};
|
|
|
|
// Access levels (open/copy)
|
|
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",
|
|
]);
|
|
const DEFAULT_ADMIN_EMAILS = new Set([
|
|
"dsyoon@ncue.net",
|
|
"dosangyoon@gmail.com",
|
|
"dosangyoon2@gmail.com",
|
|
"dosangyoon3@gmail.com",
|
|
]);
|
|
|
|
function getUserEmail() {
|
|
const e = auth && auth.user && auth.user.email ? String(auth.user.email) : "";
|
|
return e.trim().toLowerCase();
|
|
}
|
|
|
|
function isAdminEmail(email) {
|
|
const e = String(email || "").trim().toLowerCase();
|
|
const cfg = getAuthConfig();
|
|
const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
|
|
if (admins.length) return admins.includes(e);
|
|
return DEFAULT_ADMIN_EMAILS.has(e);
|
|
}
|
|
|
|
function canAccessLink(link) {
|
|
const email = getUserEmail();
|
|
if (email && isAdminEmail(email)) return true;
|
|
const id = String(link && link.id ? link.id : "");
|
|
if (email) return ACCESS_USER_IDS.has(id);
|
|
return ACCESS_ANON_IDS.has(id);
|
|
}
|
|
|
|
function buildOpenUrl(rawUrl) {
|
|
const url = String(rawUrl || "").trim();
|
|
if (!url) return "";
|
|
let host = "";
|
|
try {
|
|
host = new URL(url).hostname.toLowerCase();
|
|
} catch {
|
|
return url;
|
|
}
|
|
const isNcue = host === "ncue.net" || host.endsWith(".ncue.net");
|
|
if (!isNcue) return url;
|
|
|
|
const email = getUserEmail();
|
|
const qs = new URLSearchParams();
|
|
qs.set("u", url);
|
|
if (email) qs.set("e", email);
|
|
return `/go?${qs.toString()}`;
|
|
}
|
|
|
|
const auth = {
|
|
client: null,
|
|
user: null,
|
|
authorized: false,
|
|
ready: false,
|
|
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
|
|
serverCanManage: null,
|
|
idTokenRaw: "",
|
|
};
|
|
|
|
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 faviconCandidates(url) {
|
|
try {
|
|
const u = new URL(url);
|
|
const host = String(u.hostname || "").toLowerCase();
|
|
const isNcue = host === "ncue.net" || host.endsWith(".ncue.net");
|
|
const parts = u.pathname.split("/").filter(Boolean);
|
|
const rootFav = `${u.origin}/favicon.ico`;
|
|
|
|
// Host-specific hints (Roundcube etc.)
|
|
const candidates = [];
|
|
if (host === "mail.ncue.net") {
|
|
// common Roundcube skin favicon locations (server files)
|
|
candidates.push(
|
|
`${u.origin}/roundcube/skins/elastic/images/favicon.ico`,
|
|
`${u.origin}/roundcube/skins/larry/images/favicon.ico`,
|
|
`${u.origin}/roundcube/skins/classic/images/favicon.ico`,
|
|
// sometimes Roundcube is mounted at /
|
|
`${u.origin}/skins/elastic/images/favicon.ico`,
|
|
`${u.origin}/skins/larry/images/favicon.ico`,
|
|
`${u.origin}/skins/classic/images/favicon.ico`,
|
|
// legacy attempt
|
|
`${u.origin}/roundcube/favicon.ico`
|
|
);
|
|
}
|
|
|
|
// Path-based favicon like https://ncue.net/dsyoon/favicon.ico (internal only)
|
|
const pathFav = isNcue && parts.length ? `${u.origin}/${parts[0]}/favicon.ico` : "";
|
|
|
|
const list = [];
|
|
if (pathFav) list.push(pathFav);
|
|
list.push(...candidates);
|
|
list.push(rootFav);
|
|
|
|
// de-dup + drop empties
|
|
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);
|
|
}
|
|
|
|
const primary = uniq[0] || "";
|
|
const rest = uniq.slice(1);
|
|
return { primary, fallbackList: rest };
|
|
} catch {
|
|
return { primary: "", fallbackList: [] };
|
|
}
|
|
}
|
|
|
|
function wireFaviconFallbacks() {
|
|
const imgs = el.grid ? 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">${escapeHtml(letter || "L")}</div>`);
|
|
}
|
|
},
|
|
{ passive: true }
|
|
);
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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() {
|
|
if (state.serverMode) {
|
|
// In serverMode, baseLinks is the shared source of truth.
|
|
return (state.baseLinks || []).map(normalizeLink);
|
|
}
|
|
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("");
|
|
wireFaviconFallbacks();
|
|
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 accessible = canAccessLink(link);
|
|
const tags = (link.tags || []).slice(0, 8);
|
|
const tagHtml = [
|
|
link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : "",
|
|
accessible ? "" : `<span class="tag lock">접근 제한</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="관리 기능은 로그인 후 사용 가능합니다."';
|
|
const fav = faviconCandidates(link.url);
|
|
|
|
const accessDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\"";
|
|
const accessDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\"";
|
|
|
|
const openHref = escapeHtml(buildOpenUrl(link.url));
|
|
const openHtml = accessible
|
|
? `<a class="btn mini" href="${openHref}" target="_blank" rel="noopener noreferrer" data-act="open">열기</a>`
|
|
: `<button class="btn mini" type="button"${accessDisabledAttr}${accessDisabledTitle}>열기</button>`;
|
|
|
|
const copyDisabledAttr = accessible ? "" : " disabled aria-disabled=\"true\"";
|
|
const copyDisabledTitle = accessible ? "" : " title=\"이 링크는 현재 권한으로 접근할 수 없습니다.\"";
|
|
|
|
return `
|
|
<article class="card${accessible ? "" : " disabled"}" data-id="${escapeHtml(link.id)}" data-access="${
|
|
accessible ? "1" : "0"
|
|
}">
|
|
<div class="card-head">
|
|
<div class="card-title">
|
|
<div class="favicon" aria-hidden="true" data-letter="${letter}">
|
|
${
|
|
fav && fav.primary
|
|
? `<img src="${escapeHtml(fav.primary)}" data-fb="${escapeHtml((fav.fallbackList || []).join("|"))}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" />`
|
|
: `<div class="letter">${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 || " "}</div>
|
|
<div class="tags">${tagHtml || ""}</div>
|
|
|
|
<div class="card-actions">
|
|
${openHtml}
|
|
<button class="btn mini" type="button" data-act="copy"${copyDisabledAttr}${copyDisabledTitle}>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 : {};
|
|
// 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:
|
|
data.connections && typeof data.connections === "object"
|
|
? {
|
|
google: String(data.connections.google || "").trim(),
|
|
// legacy keys kept for backward compatibility
|
|
kakao: String(data.connections.kakao || "").trim(),
|
|
naver: String(data.connections.naver || "").trim(),
|
|
}
|
|
: { google: "", kakao: "", naver: "" },
|
|
adminEmails: adminEmails.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 apiBase = String(cfg.apiBase || "").trim(); // optional, e.g. https://api.ncue.net
|
|
const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {};
|
|
// legacy: allowedEmails -> adminEmails
|
|
const adminEmails = Array.isArray(cfg.adminEmails)
|
|
? cfg.adminEmails
|
|
: Array.isArray(cfg.allowedEmails)
|
|
? cfg.allowedEmails
|
|
: [];
|
|
const base = {
|
|
apiBase,
|
|
auth0: {
|
|
domain: String(auth0.domain || "").trim(),
|
|
clientId: String(auth0.clientId || "").trim(),
|
|
},
|
|
connections: {
|
|
google: "",
|
|
kakao: "",
|
|
naver: "",
|
|
},
|
|
adminEmails: adminEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
|
};
|
|
const override = loadAuthOverride();
|
|
if (!override) return base;
|
|
// override가 있으면 우선 적용 (서버 재배포 없이 테스트 가능)
|
|
return {
|
|
apiBase,
|
|
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 || "",
|
|
},
|
|
adminEmails: override.adminEmails.length ? override.adminEmails : base.adminEmails,
|
|
};
|
|
}
|
|
|
|
function apiUrl(pathname) {
|
|
const cfg = getAuthConfig();
|
|
const base = cfg.apiBase;
|
|
if (!base) return pathname; // same-origin
|
|
try {
|
|
return new URL(pathname, base).toString();
|
|
} catch {
|
|
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 || {};
|
|
// legacy: allowedEmails -> adminEmails
|
|
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;
|
|
saveAuthOverride({
|
|
auth0: { domain, clientId },
|
|
connections: { google },
|
|
adminEmails,
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function currentUrlNoQuery() {
|
|
// Auth0 callback 후 URL 정리용
|
|
const u = new URL(location.href);
|
|
u.searchParams.delete("code");
|
|
u.searchParams.delete("state");
|
|
return u.toString();
|
|
}
|
|
|
|
function redirectUri() {
|
|
return location.origin === "null" ? location.href : location.origin + location.pathname;
|
|
}
|
|
|
|
function b64urlFromBytes(bytes) {
|
|
let bin = "";
|
|
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
|
|
const b64 = btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
return b64;
|
|
}
|
|
|
|
async function sha256Bytes(input) {
|
|
const data = new TextEncoder().encode(String(input));
|
|
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
return new Uint8Array(hash);
|
|
}
|
|
|
|
function randomString(len = 43) {
|
|
const bytes = new Uint8Array(len);
|
|
crypto.getRandomValues(bytes);
|
|
return b64urlFromBytes(bytes);
|
|
}
|
|
|
|
function decodeJwtPayload(token) {
|
|
try {
|
|
const parts = String(token || "").split(".");
|
|
if (parts.length < 2) return null;
|
|
const b64 = parts[1].replaceAll("-", "+").replaceAll("_", "/");
|
|
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
|
|
const json = atob(b64 + pad);
|
|
return safeJsonParse(json, null);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function loadTokens() {
|
|
const raw = localStorage.getItem(AUTH_TOKEN_KEY);
|
|
const data = raw ? safeJsonParse(raw, null) : null;
|
|
if (!data || typeof data !== "object") return null;
|
|
return {
|
|
id_token: typeof data.id_token === "string" ? data.id_token : "",
|
|
access_token: typeof data.access_token === "string" ? data.access_token : "",
|
|
received_at: typeof data.received_at === "string" ? data.received_at : "",
|
|
expires_in: typeof data.expires_in === "number" ? data.expires_in : 0,
|
|
};
|
|
}
|
|
|
|
function saveTokens(t) {
|
|
localStorage.setItem(
|
|
AUTH_TOKEN_KEY,
|
|
JSON.stringify({
|
|
id_token: t.id_token || "",
|
|
access_token: t.access_token || "",
|
|
expires_in: t.expires_in || 0,
|
|
received_at: nowIso(),
|
|
})
|
|
);
|
|
}
|
|
|
|
function clearTokens() {
|
|
localStorage.removeItem(AUTH_TOKEN_KEY);
|
|
}
|
|
|
|
async function manualAuthorize(connection) {
|
|
const cfg = getAuthConfig();
|
|
if (!cfg.auth0.domain || !cfg.auth0.clientId) {
|
|
toast("로그인 설정이 서버(.env)에 없습니다. 관리자에게 문의하세요.");
|
|
return;
|
|
}
|
|
if (!globalThis.crypto || !crypto.subtle) {
|
|
toast("이 브라우저는 보안 로그인(PKCE)을 지원하지 않습니다.");
|
|
return;
|
|
}
|
|
const verifier = randomString(64);
|
|
const challenge = b64urlFromBytes(await sha256Bytes(verifier));
|
|
const state = randomString(24);
|
|
sessionStorage.setItem(
|
|
AUTH_PKCE_KEY,
|
|
JSON.stringify({
|
|
verifier,
|
|
state,
|
|
redirect_uri: redirectUri(),
|
|
created_at: nowIso(),
|
|
})
|
|
);
|
|
|
|
const u = new URL(`https://${cfg.auth0.domain}/authorize`);
|
|
u.searchParams.set("response_type", "code");
|
|
u.searchParams.set("client_id", cfg.auth0.clientId);
|
|
u.searchParams.set("redirect_uri", redirectUri());
|
|
u.searchParams.set("scope", "openid profile email");
|
|
u.searchParams.set("state", state);
|
|
u.searchParams.set("code_challenge", challenge);
|
|
u.searchParams.set("code_challenge_method", "S256");
|
|
if (connection) u.searchParams.set("connection", connection);
|
|
location.assign(u.toString());
|
|
}
|
|
|
|
async function manualHandleCallbackIfNeeded() {
|
|
const cfg = getAuthConfig();
|
|
const u = new URL(location.href);
|
|
const code = u.searchParams.get("code") || "";
|
|
const stateParam = u.searchParams.get("state") || "";
|
|
if (!code || !stateParam) return null;
|
|
|
|
const raw = sessionStorage.getItem(AUTH_PKCE_KEY);
|
|
const pkce = raw ? safeJsonParse(raw, null) : null;
|
|
if (!pkce || pkce.state !== stateParam) {
|
|
toast("로그인 상태값(state)이 일치하지 않습니다. 다시 시도하세요.");
|
|
return null;
|
|
}
|
|
|
|
const body = new URLSearchParams();
|
|
body.set("grant_type", "authorization_code");
|
|
body.set("client_id", cfg.auth0.clientId);
|
|
body.set("code_verifier", pkce.verifier);
|
|
body.set("code", code);
|
|
body.set("redirect_uri", pkce.redirect_uri || redirectUri());
|
|
|
|
const tokenRes = await fetch(`https://${cfg.auth0.domain}/oauth/token`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
});
|
|
if (!tokenRes.ok) {
|
|
toast("토큰 발급에 실패했습니다. Auth0 Callback URL 설정을 확인하세요.");
|
|
return null;
|
|
}
|
|
const tokenJson = await tokenRes.json();
|
|
const idToken = String(tokenJson.id_token || "");
|
|
const accessToken = String(tokenJson.access_token || "");
|
|
const expiresIn = Number(tokenJson.expires_in || 0) || 0;
|
|
if (!idToken) {
|
|
toast("ID 토큰이 없습니다. 로그인 설정을 확인하세요.");
|
|
return null;
|
|
}
|
|
|
|
saveTokens({ id_token: idToken, access_token: accessToken, expires_in: expiresIn });
|
|
sessionStorage.removeItem(AUTH_PKCE_KEY);
|
|
history.replaceState({}, document.title, currentUrlNoQuery());
|
|
return { idToken, accessToken };
|
|
}
|
|
|
|
async function manualLoadUser() {
|
|
const t = loadTokens();
|
|
if (!t || !t.id_token) return null;
|
|
const payload = decodeJwtPayload(t.id_token);
|
|
if (!payload) return null;
|
|
// 최소 표시/권한 확인용 user shape
|
|
return {
|
|
sub: payload.sub,
|
|
email: payload.email,
|
|
name: payload.name,
|
|
picture: payload.picture,
|
|
};
|
|
}
|
|
|
|
async function syncUserToServerWithIdToken(idToken) {
|
|
try {
|
|
auth.idTokenRaw = String(idToken || "");
|
|
const cfg = getAuthConfig();
|
|
const r = await fetch(apiUrl("/api/auth/sync"), {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${idToken}`,
|
|
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
|
|
"X-Auth0-ClientId": cfg.auth0.clientId,
|
|
},
|
|
});
|
|
if (!r.ok) {
|
|
toastOnce(
|
|
"syncfail",
|
|
`사용자 저장(API)이 실패했습니다. (${r.status}) 정적 호스팅이면 /api 를 서버로 프록시하거나 apiBase를 설정해야 합니다.`
|
|
);
|
|
return null;
|
|
}
|
|
const data = await r.json();
|
|
if (data && data.ok) return Boolean(data.canManage);
|
|
return null;
|
|
} catch {
|
|
toastOnce("syncerr", "사용자 저장(API)에 연결하지 못했습니다. /api 서버 연결을 확인하세요.");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function saveLinksToServer(links) {
|
|
if (!auth.idTokenRaw) return false;
|
|
try {
|
|
const cfg = getAuthConfig();
|
|
const r = await fetch(apiUrl("/api/links"), {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${auth.idTokenRaw}`,
|
|
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
|
|
"X-Auth0-ClientId": cfg.auth0.clientId,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(Array.isArray(links) ? links : []),
|
|
});
|
|
return r.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadLinksFromServer() {
|
|
try {
|
|
const r = await fetch(apiUrl("/api/links"), { cache: "no-store" });
|
|
if (!r.ok) return null;
|
|
const data = await r.json();
|
|
const list = data && Array.isArray(data.links) ? data.links : null;
|
|
if (!list) return null;
|
|
return list.map(normalizeLink);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function persistLinksIfServerMode() {
|
|
if (!state.serverMode) return;
|
|
if (!state.canManage) return;
|
|
const links = getMergedLinks(); // in serverMode this is baseLinks
|
|
const ok = await saveLinksToServer(links);
|
|
if (!ok) toastOnce("savefail", "서버 저장(links.json)에 실패했습니다. 권한/서버 로그를 확인하세요.");
|
|
}
|
|
|
|
function sendLogoutToServer(idToken) {
|
|
if (!idToken) return;
|
|
const cfg = getAuthConfig();
|
|
const payload = JSON.stringify({ t: Date.now() });
|
|
// Prefer sendBeacon to survive navigation
|
|
try {
|
|
const blob = new Blob([payload], { type: "application/json" });
|
|
const ok = navigator.sendBeacon(apiUrl("/api/auth/logout"), blob);
|
|
if (ok) return;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
// Fallback fetch keepalive (best-effort)
|
|
try {
|
|
fetch(apiUrl("/api/auth/logout"), {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${idToken}`,
|
|
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
|
|
"X-Auth0-ClientId": cfg.auth0.clientId,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: payload,
|
|
keepalive: true,
|
|
}).catch(() => {});
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function isManageAdminEmail(email) {
|
|
const cfg = getAuthConfig();
|
|
const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
|
|
const e = String(email || "").trim().toLowerCase();
|
|
if (admins.length) return admins.includes(e);
|
|
// 안전한 기본값: 설정이 비어있으면 기본 관리자만 관리 가능
|
|
return DEFAULT_ADMIN_EMAILS.has(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");
|
|
}
|
|
|
|
// 로그아웃은 로그인 된 이후에만 노출
|
|
el.btnLogout.hidden = !auth.user;
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
// 요청: 로그인 전에는 내보내기도 비활성화
|
|
el.btnExport.disabled = auth.mode === "enabled" ? !auth.user : false;
|
|
|
|
if (!state.canManage) {
|
|
el.btnAdd.title = lockMsg;
|
|
el.btnImport.title = lockMsg;
|
|
} else {
|
|
el.btnAdd.title = "";
|
|
el.btnImport.title = "";
|
|
}
|
|
|
|
if (el.btnExport.disabled) el.btnExport.title = "내보내기는 로그인 후 사용 가능합니다.";
|
|
else el.btnExport.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("misconf", "로그인 설정이 서버(.env)에 없습니다. 관리자에게 문의하세요.");
|
|
return;
|
|
}
|
|
|
|
if (!hasSdk) {
|
|
// SDK가 없어도 PKCE 수동 로그인으로 동작 가능
|
|
auth.client = null;
|
|
auth.mode = "enabled";
|
|
await manualHandleCallbackIfNeeded().catch(() => {});
|
|
auth.user = await manualLoadUser();
|
|
const email = auth.user && auth.user.email ? String(auth.user.email) : "";
|
|
auth.authorized = Boolean(auth.user) && isManageAdminEmail(email);
|
|
auth.serverCanManage = null;
|
|
const t = loadTokens();
|
|
if (auth.user && t && t.id_token) {
|
|
auth.idTokenRaw = String(t.id_token || "");
|
|
const can = await syncUserToServerWithIdToken(t.id_token);
|
|
if (typeof can === "boolean") {
|
|
auth.serverCanManage = can;
|
|
auth.authorized = can;
|
|
}
|
|
}
|
|
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: redirectUri(),
|
|
},
|
|
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) && isManageAdminEmail(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) {
|
|
auth.idTokenRaw = raw;
|
|
const cfg = getAuthConfig();
|
|
const r = 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,
|
|
},
|
|
});
|
|
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", "로그인은 되었지만 관리자 이메일이 아니라서 관리 기능이 잠금 상태입니다.");
|
|
}
|
|
|
|
updateAuthUi();
|
|
applyManageLock();
|
|
}
|
|
|
|
async function login() {
|
|
if (auth.mode !== "enabled" || !auth.client) {
|
|
toast("로그인 설정이 서버(.env)에 필요합니다.");
|
|
return;
|
|
}
|
|
await auth.client.loginWithRedirect();
|
|
}
|
|
|
|
async function loginWithConnection(connection) {
|
|
if (auth.mode !== "enabled") {
|
|
toast("로그인 설정이 서버(.env)에 필요합니다.");
|
|
return;
|
|
}
|
|
if (auth.client) {
|
|
await auth.client.loginWithRedirect({
|
|
authorizationParams: { connection },
|
|
});
|
|
return;
|
|
}
|
|
await manualAuthorize(connection);
|
|
}
|
|
|
|
async function logout() {
|
|
if (auth.mode !== "enabled") return;
|
|
// SDK가 있으면 SDK로, 없으면 수동 로그아웃
|
|
if (auth.client) {
|
|
try {
|
|
const claims = await auth.client.getIdTokenClaims();
|
|
const raw = claims && claims.__raw ? String(claims.__raw) : "";
|
|
if (raw) sendLogoutToServer(raw);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
auth.user = null;
|
|
auth.authorized = false;
|
|
updateAuthUi();
|
|
applyManageLock();
|
|
await auth.client.logout({
|
|
logoutParams: {
|
|
returnTo: redirectUri(),
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
// manual token logout
|
|
const t = loadTokens();
|
|
if (t && t.id_token) sendLogoutToServer(t.id_token);
|
|
clearTokens();
|
|
auth.user = null;
|
|
auth.authorized = false;
|
|
updateAuthUi();
|
|
applyManageLock();
|
|
const cfg = getAuthConfig();
|
|
const u = new URL(`https://${cfg.auth0.domain}/v2/logout`);
|
|
u.searchParams.set("client_id", cfg.auth0.clientId);
|
|
u.searchParams.set("returnTo", redirectUri());
|
|
location.assign(u.toString());
|
|
}
|
|
|
|
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) {
|
|
if (state.serverMode) {
|
|
const n = normalizeLink(link);
|
|
const idx = state.baseLinks.findIndex((c) => c && c.id === n.id);
|
|
if (idx >= 0) state.baseLinks[idx] = n;
|
|
else state.baseLinks.push(n);
|
|
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
|
|
return;
|
|
}
|
|
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 (state.serverMode) {
|
|
const before = state.baseLinks.length;
|
|
state.baseLinks = (state.baseLinks || []).filter((l) => l && l.id !== id);
|
|
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
|
|
if (state.baseLinks.length !== before) toast("삭제했습니다.");
|
|
return;
|
|
}
|
|
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 (state.serverMode) {
|
|
upsertCustom({ ...link, favorite: next, updatedAt: nowIso() });
|
|
render();
|
|
return;
|
|
}
|
|
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로 추가(기본과 충돌 방지)
|
|
if (state.serverMode) state.baseLinks.push(n);
|
|
else state.store.custom.push(n);
|
|
added++;
|
|
}
|
|
if (!state.serverMode) saveStore();
|
|
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
|
|
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");
|
|
// access gate for open/copy
|
|
if ((act === "open" || act === "copy") && card.getAttribute("data-access") === "0") {
|
|
toast("이 링크는 현재 권한으로 접근할 수 없습니다.");
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (auth.mode === "enabled" && !state.canManage && (act === "fav" || act === "edit" || act === "del")) {
|
|
toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
|
|
return;
|
|
}
|
|
if (act === "fav") {
|
|
toggleFavorite(id);
|
|
persistLinksIfServerMode();
|
|
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();
|
|
persistLinksIfServerMode();
|
|
}
|
|
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("저장했습니다.");
|
|
persistLinksIfServerMode();
|
|
return;
|
|
}
|
|
|
|
const id = newId("custom");
|
|
upsertCustom({
|
|
id,
|
|
title,
|
|
url,
|
|
description,
|
|
tags,
|
|
favorite,
|
|
createdAt: nowIso(),
|
|
updatedAt: nowIso(),
|
|
});
|
|
closeModal();
|
|
render();
|
|
toast("추가했습니다.");
|
|
persistLinksIfServerMode();
|
|
}
|
|
|
|
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.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다.")));
|
|
if (el.btnGoogle)
|
|
el.btnGoogle.addEventListener("click", () => {
|
|
const c = getAuthConfig().connections.google;
|
|
if (!c) return toast("서버(.env)에 AUTH0_GOOGLE_CONNECTION 설정이 필요합니다.");
|
|
return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다."));
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
initTheme();
|
|
wire();
|
|
await hydrateAuthConfigFromServerIfNeeded();
|
|
await initAuth();
|
|
const serverLinks = await loadLinksFromServer();
|
|
if (serverLinks) {
|
|
state.serverMode = true;
|
|
state.baseLinks = serverLinks;
|
|
// serverMode에서는 localStorage 기반 커스텀/오버라이드는 사용하지 않음(공유 JSON이 진실)
|
|
state.store = { overridesById: {}, tombstones: [], custom: [] };
|
|
saveStore();
|
|
} else {
|
|
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("초기화에 실패했습니다.");
|
|
});
|
|
})();
|
|
|