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:
dsyoon
2026-02-07 18:25:54 +09:00
parent 37fe555941
commit 812a59bc9f
2 changed files with 438 additions and 49 deletions

View File

@@ -341,59 +341,446 @@
] ]
</script> </script>
<!-- Fallback: if script.js fails to load, render basic cards --> <!-- Fallback: if script.js fails, run full-feature inline app -->
<script> <script>
setTimeout(() => { (function () {
if (window.__LINKS_APP_BOOTED__) return; function safeJsonParse(s, fallback) {
const grid = document.getElementById("grid");
const empty = document.getElementById("empty");
const dataEl = document.getElementById("linksData");
if (!grid || !dataEl) return;
try { try {
const links = JSON.parse(dataEl.textContent || "[]"); return JSON.parse(s);
if (!Array.isArray(links) || links.length === 0) return; } catch {
const esc = (s) => return fallback;
String(s) }
}
function esc(s) {
return String(s)
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
.replaceAll(">", "&gt;") .replaceAll(">", "&gt;")
.replaceAll('"', "&quot;") .replaceAll('"', "&quot;")
.replaceAll("'", "&#039;"); .replaceAll("'", "&#039;");
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 { try {
return new URL(u).host; return new URL(u).host;
} catch { } catch {
return String(u || "").replace(/^https?:\/\//i, "").split("/")[0] || ""; 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) => { function toast(msg) {
const t = esc(l.title || domain(l.url) || "Link"); if (!el.toast) return;
const d = esc(domain(l.url)); el.toast.textContent = msg;
const u = esc(l.url || "#"); el.toast.hidden = false;
const desc = esc(l.description || ""); 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 ` return `
<article class="card"> <article class="card" data-id="${esc(link.id)}">
<div class="card-head"> <div class="card-head">
<div class="card-title"> <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-wrap">
<div class="title">${t}</div> <div class="title" title="${t}">${t}</div>
<div class="domain">${d}</div> <div class="domain" title="${d}">${d}</div>
</div> </div>
</div> </div>
<button class="icon-btn" type="button" data-act="fav" title="즐겨찾기">
<span class="${star}" aria-hidden="true">★</span>
</button>
</div> </div>
<div class="card-desc">${desc || "&nbsp;"}</div> <div class="card-desc">${desc || "&nbsp;"}</div>
<div class="tags">${favTag}${tags}</div>
<div class="card-actions"> <div class="card-actions">
<a class="btn mini" href="${u}" target="_blank" rel="noopener noreferrer">열기</a> <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> </div>
</article> </article>
`; `;
}) }
.join("");
if (empty) empty.hidden = true; function render() {
} catch {} const q = (state.query || "").trim().toLowerCase();
}, 80); 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> </script>
<noscript> <noscript>

View File

@@ -1,6 +1,8 @@
(() => { (() => {
"use strict"; "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 // Mark boot so index.html fallback won't run
globalThis.__LINKS_APP_BOOTED__ = true; globalThis.__LINKS_APP_BOOTED__ = true;