1109 lines
46 KiB
HTML
1109 lines
46 KiB
HTML
<!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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
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 || " "}</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>
|