Add full-feature inline fallback when script.js fails
If external JS fails to load on some static hosts, run an inline version supporting add/edit/delete/favorite/search/sort/import/export/theme so the UI is never dead. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
441
index.html
441
index.html
@@ -341,59 +341,446 @@
|
||||
]
|
||||
</script>
|
||||
|
||||
<!-- Fallback: if script.js fails to load, render basic cards -->
|
||||
<!-- Fallback: if script.js fails, run full-feature inline app -->
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
if (window.__LINKS_APP_BOOTED__) return;
|
||||
const grid = document.getElementById("grid");
|
||||
const empty = document.getElementById("empty");
|
||||
const dataEl = document.getElementById("linksData");
|
||||
if (!grid || !dataEl) return;
|
||||
(function () {
|
||||
function safeJsonParse(s, fallback) {
|
||||
try {
|
||||
const links = JSON.parse(dataEl.textContent || "[]");
|
||||
if (!Array.isArray(links) || links.length === 0) return;
|
||||
const esc = (s) =>
|
||||
String(s)
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
const domain = (u) => {
|
||||
}
|
||||
function normalizeUrl(url) {
|
||||
const u = String(url || "").trim();
|
||||
if (!u) return "";
|
||||
if (/^https?:\/\//i.test(u)) return u;
|
||||
return "https://" + u;
|
||||
}
|
||||
function domain(u) {
|
||||
try {
|
||||
return new URL(u).host;
|
||||
} catch {
|
||||
return String(u || "").replace(/^https?:\/\//i, "").split("/")[0] || "";
|
||||
}
|
||||
}
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// Wait a tick for external script.js to possibly boot.
|
||||
setTimeout(() => {
|
||||
if (window.__LINKS_APP_BOOTED__) return;
|
||||
window.__LINKS_APP_BOOTED__ = true;
|
||||
|
||||
const STORAGE_KEY = "links_home_v1";
|
||||
const THEME_KEY = "links_home_theme_v1";
|
||||
|
||||
const el = {
|
||||
q: document.getElementById("q"),
|
||||
sort: document.getElementById("sort"),
|
||||
onlyFav: document.getElementById("onlyFav"),
|
||||
meta: document.getElementById("meta"),
|
||||
grid: document.getElementById("grid"),
|
||||
empty: document.getElementById("empty"),
|
||||
btnAdd: document.getElementById("btnAdd"),
|
||||
btnImport: document.getElementById("btnImport"),
|
||||
btnExport: document.getElementById("btnExport"),
|
||||
btnTheme: document.getElementById("btnTheme"),
|
||||
toast: document.getElementById("toast"),
|
||||
modal: document.getElementById("modal"),
|
||||
btnClose: document.getElementById("btnClose"),
|
||||
btnCancel: document.getElementById("btnCancel"),
|
||||
form: document.getElementById("form"),
|
||||
id: document.getElementById("id"),
|
||||
title: document.getElementById("title"),
|
||||
url: document.getElementById("url"),
|
||||
description: document.getElementById("description"),
|
||||
tags: document.getElementById("tags"),
|
||||
favorite: document.getElementById("favorite"),
|
||||
file: document.getElementById("file"),
|
||||
};
|
||||
grid.innerHTML = links
|
||||
.map((l) => {
|
||||
const t = esc(l.title || domain(l.url) || "Link");
|
||||
const d = esc(domain(l.url));
|
||||
const u = esc(l.url || "#");
|
||||
const desc = esc(l.description || "");
|
||||
|
||||
function toast(msg) {
|
||||
if (!el.toast) return;
|
||||
el.toast.textContent = msg;
|
||||
el.toast.hidden = false;
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => (el.toast.hidden = true), 2200);
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const data = raw ? safeJsonParse(raw, null) : null;
|
||||
const store = { overridesById: {}, tombstones: [], custom: [] };
|
||||
if (!data || typeof data !== "object") return store;
|
||||
if (data.overridesById && typeof data.overridesById === "object") store.overridesById = data.overridesById;
|
||||
if (Array.isArray(data.tombstones)) store.tombstones = data.tombstones;
|
||||
if (Array.isArray(data.custom)) store.custom = data.custom;
|
||||
return store;
|
||||
}
|
||||
function saveStore() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.store));
|
||||
}
|
||||
|
||||
function normalizeTags(tagsText) {
|
||||
if (!tagsText) return [];
|
||||
return String(tagsText)
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function normalizeLink(link) {
|
||||
const url = normalizeUrl(link.url);
|
||||
const id = String(link.id || "").trim() || "link-" + Math.random().toString(16).slice(2);
|
||||
const title = String(link.title || "").trim() || domain(url) || "Link";
|
||||
const description = String(link.description || "").trim();
|
||||
const tags = Array.isArray(link.tags) ? link.tags.map((t) => String(t).trim()).filter(Boolean) : [];
|
||||
const favorite = Boolean(link.favorite);
|
||||
const createdAt = String(link.createdAt || nowIso());
|
||||
const updatedAt = String(link.updatedAt || createdAt);
|
||||
return { id, title, url, description, tags, favorite, createdAt, updatedAt };
|
||||
}
|
||||
|
||||
const baseLinks = (() => {
|
||||
const dataEl = document.getElementById("linksData");
|
||||
const parsed = dataEl ? safeJsonParse(dataEl.textContent || "[]", []) : [];
|
||||
return Array.isArray(parsed) ? parsed.map(normalizeLink) : [];
|
||||
})();
|
||||
const baseOrder = new Map(baseLinks.map((l, i) => [l.id, i]));
|
||||
|
||||
const state = {
|
||||
store: loadStore(),
|
||||
query: "",
|
||||
sortKey: (el.sort && el.sort.value) || "json",
|
||||
onlyFav: Boolean(el.onlyFav && el.onlyFav.checked),
|
||||
};
|
||||
|
||||
function toTime(s) {
|
||||
const t = Date.parse(String(s || ""));
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
function orderKey(link) {
|
||||
const idx = baseOrder.get(link.id);
|
||||
if (typeof idx === "number") return idx;
|
||||
return 1_000_000 + toTime(link.createdAt);
|
||||
}
|
||||
|
||||
function getMergedLinks() {
|
||||
const tomb = new Set(state.store.tombstones || []);
|
||||
const overrides = state.store.overridesById || {};
|
||||
const byId = new Map();
|
||||
for (const base of baseLinks) {
|
||||
if (!base || !base.id) continue;
|
||||
if (tomb.has(base.id)) continue;
|
||||
const o = overrides[base.id];
|
||||
byId.set(base.id, Object.assign({}, base, o || {}));
|
||||
}
|
||||
for (const c of state.store.custom || []) {
|
||||
const n = normalizeLink(c);
|
||||
byId.set(n.id, n);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function compare(a, b) {
|
||||
const key = state.sortKey || "json";
|
||||
if (key === "json") return orderKey(a) - orderKey(b);
|
||||
if (key === "favorite") {
|
||||
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
|
||||
return orderKey(a) - orderKey(b);
|
||||
}
|
||||
if (key === "name") return a.title.localeCompare(b.title, "ko");
|
||||
if (key === "domain") return domain(a.url).localeCompare(domain(b.url), "en");
|
||||
return String(b.updatedAt).localeCompare(String(a.updatedAt));
|
||||
}
|
||||
|
||||
function matches(link, q) {
|
||||
if (!q) return true;
|
||||
const hay = [link.title, link.url, domain(link.url), link.description || "", (link.tags || []).join(" ")]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
}
|
||||
|
||||
function cardHtml(link) {
|
||||
const d = esc(domain(link.url));
|
||||
const t = esc(link.title);
|
||||
const u = esc(link.url);
|
||||
const desc = esc(link.description || "");
|
||||
const star = link.favorite ? "star on" : "star";
|
||||
const tags = (link.tags || []).slice(0, 8).map((x) => `<span class="tag">#${esc(x)}</span>`).join("");
|
||||
const favTag = link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : "";
|
||||
const letter = esc((link.title || d || "L").trim().slice(0, 1).toUpperCase());
|
||||
return `
|
||||
<article class="card">
|
||||
<article class="card" data-id="${esc(link.id)}">
|
||||
<div class="card-head">
|
||||
<div class="card-title">
|
||||
<div class="favicon" aria-hidden="true"><div class="letter">${t.slice(0, 1)}</div></div>
|
||||
<div class="favicon" aria-hidden="true"><div class="letter">${letter}</div></div>
|
||||
<div class="title-wrap">
|
||||
<div class="title">${t}</div>
|
||||
<div class="domain">${d}</div>
|
||||
<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}${tags}</div>
|
||||
<div class="card-actions">
|
||||
<a class="btn mini" href="${u}" target="_blank" rel="noopener noreferrer">열기</a>
|
||||
<button class="btn mini" type="button" data-act="copy">URL 복사</button>
|
||||
<button class="btn mini" type="button" data-act="edit">편집</button>
|
||||
<button class="btn mini mini-danger" type="button" data-act="del">삭제</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
if (empty) empty.hidden = true;
|
||||
} catch {}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const q = (state.query || "").trim().toLowerCase();
|
||||
const all = getMergedLinks();
|
||||
const filtered = all
|
||||
.filter((l) => (state.onlyFav ? l.favorite : true))
|
||||
.filter((l) => matches(l, q))
|
||||
.sort(compare);
|
||||
if (el.grid) el.grid.innerHTML = filtered.map(cardHtml).join("");
|
||||
if (el.empty) el.empty.hidden = filtered.length !== 0;
|
||||
if (el.meta) {
|
||||
const favCount = all.filter((l) => l.favorite).length;
|
||||
el.meta.textContent = `표시 ${filtered.length}개 · 전체 ${all.length}개 · 즐겨찾기 ${favCount}개`;
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(link) {
|
||||
if (!el.modal) return;
|
||||
el.modal.hidden = false;
|
||||
document.body.style.overflow = "hidden";
|
||||
document.getElementById("modalTitle").textContent = link ? "링크 편집" : "링크 추가";
|
||||
el.id.value = link ? link.id : "";
|
||||
el.title.value = link ? link.title : "";
|
||||
el.url.value = link ? link.url : "";
|
||||
el.description.value = link ? link.description || "" : "";
|
||||
el.tags.value = link ? (link.tags || []).join(", ") : "";
|
||||
el.favorite.checked = link ? Boolean(link.favorite) : false;
|
||||
setTimeout(() => el.title && el.title.focus(), 0);
|
||||
}
|
||||
function closeModal() {
|
||||
if (!el.modal) return;
|
||||
el.modal.hidden = true;
|
||||
document.body.style.overflow = "";
|
||||
if (el.form) el.form.reset();
|
||||
if (el.id) el.id.value = "";
|
||||
}
|
||||
|
||||
function isBaseId(id) {
|
||||
return baseOrder.has(id);
|
||||
}
|
||||
function getById(id) {
|
||||
return getMergedLinks().find((l) => l.id === id) || null;
|
||||
}
|
||||
function setOverride(id, patch) {
|
||||
state.store.overridesById[id] = Object.assign({}, state.store.overridesById[id] || {}, patch);
|
||||
saveStore();
|
||||
}
|
||||
function upsertCustom(link) {
|
||||
const n = normalizeLink(link);
|
||||
const idx = (state.store.custom || []).findIndex((c) => c && c.id === n.id);
|
||||
if (idx >= 0) state.store.custom[idx] = n;
|
||||
else state.store.custom.push(n);
|
||||
saveStore();
|
||||
}
|
||||
function delLink(id) {
|
||||
if (isBaseId(id)) {
|
||||
const s = new Set(state.store.tombstones || []);
|
||||
s.add(id);
|
||||
state.store.tombstones = Array.from(s);
|
||||
saveStore();
|
||||
return;
|
||||
}
|
||||
state.store.custom = (state.store.custom || []).filter((c) => c && c.id !== id);
|
||||
saveStore();
|
||||
}
|
||||
|
||||
async function copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("복사했습니다.");
|
||||
} catch {
|
||||
toast("복사에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
const data = getMergedLinks();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `links-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(a.href);
|
||||
a.remove();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function importJsonText(text) {
|
||||
const parsed = safeJsonParse(text, null);
|
||||
const list = Array.isArray(parsed) ? parsed : parsed && Array.isArray(parsed.links) ? parsed.links : null;
|
||||
if (!list) throw new Error("JSON 형식이 올바르지 않습니다.");
|
||||
for (const item of list) {
|
||||
if (!item) continue;
|
||||
const n = normalizeLink(item);
|
||||
// 충돌 방지
|
||||
if (getById(n.id)) n.id = "import-" + Math.random().toString(16).slice(2);
|
||||
state.store.custom.push(n);
|
||||
}
|
||||
saveStore();
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const t = theme === "light" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
}
|
||||
(function initTheme() {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
if (saved === "light" || saved === "dark") return applyTheme(saved);
|
||||
const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
applyTheme(prefersLight ? "light" : "dark");
|
||||
})();
|
||||
|
||||
// Wire events
|
||||
if (el.q)
|
||||
el.q.addEventListener("input", () => {
|
||||
state.query = el.q.value || "";
|
||||
render();
|
||||
});
|
||||
if (el.sort)
|
||||
el.sort.addEventListener("change", () => {
|
||||
state.sortKey = el.sort.value || "json";
|
||||
render();
|
||||
});
|
||||
if (el.onlyFav)
|
||||
el.onlyFav.addEventListener("change", () => {
|
||||
state.onlyFav = Boolean(el.onlyFav.checked);
|
||||
render();
|
||||
});
|
||||
if (el.btnAdd) el.btnAdd.addEventListener("click", () => openModal(null));
|
||||
if (el.btnClose) el.btnClose.addEventListener("click", closeModal);
|
||||
if (el.btnCancel) el.btnCancel.addEventListener("click", closeModal);
|
||||
if (el.modal)
|
||||
el.modal.addEventListener("click", (e) => {
|
||||
const close = e.target && e.target.getAttribute && e.target.getAttribute("data-close");
|
||||
if (close) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
|
||||
if (el.form)
|
||||
el.form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const id = String(el.id.value || "").trim();
|
||||
const title = String(el.title.value || "").trim();
|
||||
const url = normalizeUrl(el.url.value);
|
||||
if (!title) return toast("제목을 입력하세요.");
|
||||
if (!url) return toast("URL을 입력하세요.");
|
||||
const description = String(el.description.value || "").trim();
|
||||
const tags = normalizeTags(el.tags.value);
|
||||
const favorite = Boolean(el.favorite.checked);
|
||||
const patch = { title, url, description, tags, favorite, updatedAt: nowIso() };
|
||||
if (id) {
|
||||
const cur = getById(id);
|
||||
if (!cur) return toast("편집 대상이 없습니다.");
|
||||
if (isBaseId(id)) setOverride(id, patch);
|
||||
else upsertCustom(Object.assign({}, cur, patch));
|
||||
toast("저장했습니다.");
|
||||
} else {
|
||||
upsertCustom({
|
||||
id: "custom-" + Date.now() + "-" + Math.random().toString(16).slice(2),
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
tags,
|
||||
favorite,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
toast("추가했습니다.");
|
||||
}
|
||||
closeModal();
|
||||
render();
|
||||
});
|
||||
|
||||
if (el.grid)
|
||||
el.grid.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-act]");
|
||||
if (!btn) return;
|
||||
const card = e.target.closest(".card");
|
||||
if (!card) return;
|
||||
const id = card.getAttribute("data-id");
|
||||
const act = btn.getAttribute("data-act");
|
||||
const link = id ? getById(id) : null;
|
||||
if (!link) return;
|
||||
if (act === "fav") {
|
||||
const next = !link.favorite;
|
||||
if (isBaseId(id)) setOverride(id, { favorite: next, updatedAt: nowIso() });
|
||||
else upsertCustom(Object.assign({}, link, { favorite: next, updatedAt: nowIso() }));
|
||||
render();
|
||||
} else if (act === "copy") {
|
||||
copy(link.url);
|
||||
} else if (act === "edit") {
|
||||
openModal(link);
|
||||
} else if (act === "del") {
|
||||
if (confirm(`삭제할까요?\n\n- ${link.title}`)) {
|
||||
delLink(id);
|
||||
render();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (el.btnExport) el.btnExport.addEventListener("click", exportJson);
|
||||
if (el.btnImport)
|
||||
el.btnImport.addEventListener("click", () => {
|
||||
if (el.file) el.file.click();
|
||||
});
|
||||
if (el.file)
|
||||
el.file.addEventListener("change", async () => {
|
||||
const f = el.file.files && el.file.files[0];
|
||||
el.file.value = "";
|
||||
if (!f) return;
|
||||
try {
|
||||
importJsonText(await f.text());
|
||||
toast("가져오기 완료");
|
||||
render();
|
||||
} catch (err) {
|
||||
toast(String(err && err.message ? err.message : "가져오기 실패"));
|
||||
}
|
||||
});
|
||||
|
||||
if (el.btnTheme)
|
||||
el.btnTheme.addEventListener("click", () => {
|
||||
const cur = document.documentElement.getAttribute("data-theme") || "dark";
|
||||
applyTheme(cur === "dark" ? "light" : "dark");
|
||||
});
|
||||
|
||||
render();
|
||||
toast("스크립트 로딩 문제로 폴백 모드로 실행 중입니다.");
|
||||
}, 200);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<noscript>
|
||||
|
||||
Reference in New Issue
Block a user