Add static links dashboard
Includes JSON-ordered link cards, search/sort, favorites, CRUD, and import/export with localStorage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
629
script.js
Normal file
629
script.js
Normal file
@@ -0,0 +1,629 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const STORAGE_KEY = "links_home_v1";
|
||||
const THEME_KEY = "links_home_theme_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"),
|
||||
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,
|
||||
};
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function safeJsonParse(s, fallback) {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function loadStore() {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const data = raw ? safeJsonParse(raw, null) : null;
|
||||
const store = {
|
||||
overridesById: {},
|
||||
tombstones: [],
|
||||
custom: [],
|
||||
};
|
||||
if (!data || typeof data !== "object") return store;
|
||||
if (data.overridesById && typeof data.overridesById === "object") store.overridesById = data.overridesById;
|
||||
if (Array.isArray(data.tombstones)) store.tombstones = data.tombstones;
|
||||
if (Array.isArray(data.custom)) store.custom = data.custom;
|
||||
return store;
|
||||
}
|
||||
|
||||
function saveStore() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.store));
|
||||
}
|
||||
|
||||
function normalizeUrl(url) {
|
||||
const u = String(url || "").trim();
|
||||
if (!u) return "";
|
||||
if (/^https?:\/\//i.test(u)) return u;
|
||||
return "https://" + u;
|
||||
}
|
||||
|
||||
function normalizeTags(tagsText) {
|
||||
if (!tagsText) return [];
|
||||
return String(tagsText)
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function getDomain(url) {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//i, "").split("/")[0] || "";
|
||||
}
|
||||
}
|
||||
|
||||
function faviconUrl(url) {
|
||||
const domainUrl = encodeURIComponent(url);
|
||||
return `https://www.google.com/s2/favicons?domain_url=${domainUrl}&sz=64`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.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() {
|
||||
const tomb = new Set(state.store.tombstones || []);
|
||||
const overrides = state.store.overridesById || {};
|
||||
|
||||
const byId = new Map();
|
||||
for (const base of state.baseLinks) {
|
||||
if (!base || !base.id) continue;
|
||||
if (tomb.has(base.id)) continue;
|
||||
const o = overrides[base.id];
|
||||
byId.set(base.id, { ...base, ...(o || {}) });
|
||||
}
|
||||
for (const c of state.store.custom || []) {
|
||||
const n = normalizeLink(c);
|
||||
byId.set(n.id, n);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
function matchesQuery(link, q) {
|
||||
if (!q) return true;
|
||||
const hay = [
|
||||
link.title,
|
||||
link.url,
|
||||
getDomain(link.url),
|
||||
link.description || "",
|
||||
(link.tags || []).join(" "),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
}
|
||||
|
||||
function toTime(s) {
|
||||
const t = Date.parse(String(s || ""));
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
|
||||
function orderKey(link) {
|
||||
const idx = state.baseOrder.get(link.id);
|
||||
if (typeof idx === "number") return idx;
|
||||
// custom/imported links go after base list, in creation order
|
||||
return 1_000_000 + toTime(link.createdAt);
|
||||
}
|
||||
|
||||
function compareLinks(a, b) {
|
||||
const key = state.sortKey;
|
||||
if (key === "json") {
|
||||
const oa = orderKey(a);
|
||||
const ob = orderKey(b);
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.title.localeCompare(b.title, "ko");
|
||||
}
|
||||
if (key === "favorite") {
|
||||
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
|
||||
// tie-breaker: keep json order
|
||||
const oa = orderKey(a);
|
||||
const ob = orderKey(b);
|
||||
if (oa !== ob) return oa - ob;
|
||||
}
|
||||
if (key === "name") return a.title.localeCompare(b.title, "ko");
|
||||
if (key === "domain") return getDomain(a.url).localeCompare(getDomain(b.url), "en");
|
||||
// recent (default)
|
||||
return String(b.updatedAt).localeCompare(String(a.updatedAt));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const q = state.query.trim().toLowerCase();
|
||||
const all = getMergedLinks();
|
||||
const filtered = all
|
||||
.filter((l) => (state.onlyFav ? l.favorite : true))
|
||||
.filter((l) => matchesQuery(l, q))
|
||||
.sort(compareLinks);
|
||||
|
||||
el.grid.innerHTML = filtered.map(cardHtml).join("");
|
||||
el.empty.hidden = filtered.length !== 0;
|
||||
|
||||
const favCount = all.filter((l) => l.favorite).length;
|
||||
el.meta.textContent = `표시 ${filtered.length}개 · 전체 ${all.length}개 · 즐겨찾기 ${favCount}개`;
|
||||
el.subtitle.textContent = all.length ? `링크 ${all.length}개` : "개인 링크 관리";
|
||||
}
|
||||
|
||||
function cardHtml(link) {
|
||||
const domain = escapeHtml(getDomain(link.url));
|
||||
const title = escapeHtml(link.title);
|
||||
const desc = escapeHtml(link.description || "");
|
||||
const url = escapeHtml(link.url);
|
||||
const starClass = link.favorite ? "star on" : "star";
|
||||
const tags = (link.tags || []).slice(0, 8);
|
||||
const tagHtml = [
|
||||
link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : "",
|
||||
...tags.map((t) => `<span class="tag">#${escapeHtml(t)}</span>`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
|
||||
const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase());
|
||||
|
||||
return `
|
||||
<article class="card" data-id="${escapeHtml(link.id)}">
|
||||
<div class="card-head">
|
||||
<div class="card-title">
|
||||
<div class="favicon" aria-hidden="true">
|
||||
<img src="${faviconUrl(link.url)}" alt="" onerror="this.remove(); this.parentNode.insertAdjacentHTML('beforeend','<div class="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" title="즐겨찾기">
|
||||
<span class="${starClass}" aria-hidden="true">★</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-desc">${desc || " "}</div>
|
||||
<div class="tags">${tagHtml || ""}</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<a class="btn mini" href="${url}" target="_blank" rel="noopener noreferrer" data-act="open">열기</a>
|
||||
<button class="btn mini" type="button" data-act="copy">URL 복사</button>
|
||||
<button class="btn mini" type="button" data-act="edit">편집</button>
|
||||
<button class="btn mini mini-danger" type="button" data-act="del">삭제</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);
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast("복사했습니다.");
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "readonly");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
ta.remove();
|
||||
toast("복사했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
function upsertCustom(link) {
|
||||
const n = normalizeLink(link);
|
||||
const idx = state.store.custom.findIndex((c) => c && c.id === n.id);
|
||||
if (idx >= 0) state.store.custom[idx] = n;
|
||||
else state.store.custom.push(n);
|
||||
saveStore();
|
||||
}
|
||||
|
||||
function deleteLink(id) {
|
||||
if (isBaseId(id)) {
|
||||
const s = new Set(state.store.tombstones || []);
|
||||
s.add(id);
|
||||
state.store.tombstones = [...s];
|
||||
removeOverride(id);
|
||||
saveStore();
|
||||
toast("기본 링크를 숨겼습니다.");
|
||||
return;
|
||||
}
|
||||
const before = state.store.custom.length;
|
||||
state.store.custom = (state.store.custom || []).filter((c) => c && c.id !== id);
|
||||
saveStore();
|
||||
if (state.store.custom.length !== before) toast("삭제했습니다.");
|
||||
}
|
||||
|
||||
function toggleFavorite(id) {
|
||||
const link = getLinkById(id);
|
||||
if (!link) return;
|
||||
const next = !link.favorite;
|
||||
if (isBaseId(id)) {
|
||||
setOverride(id, { favorite: next, updatedAt: nowIso() });
|
||||
} else {
|
||||
upsertCustom({ ...link, favorite: next, updatedAt: nowIso() });
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function editLink(id) {
|
||||
const link = getLinkById(id);
|
||||
if (!link) return;
|
||||
openModal("edit", link);
|
||||
}
|
||||
|
||||
async function loadBaseLinks() {
|
||||
// 1) index.html 내부 내장 데이터(서버 없이도 동작)
|
||||
const dataEl = document.getElementById("linksData");
|
||||
if (dataEl && dataEl.textContent) {
|
||||
const parsed = safeJsonParse(dataEl.textContent, null);
|
||||
if (Array.isArray(parsed)) return parsed.map(normalizeLink);
|
||||
}
|
||||
|
||||
// 2) 동일 디렉토리의 links.json (서버 환경에서 권장)
|
||||
const candidates = [
|
||||
new URL("./links.json", document.baseURI).toString(),
|
||||
new URL("links.json", document.baseURI).toString(),
|
||||
];
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) continue;
|
||||
return data.map(normalizeLink);
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
|
||||
const hint =
|
||||
location.protocol === "file:"
|
||||
? "기본 링크 데이터가 없습니다. index.html의 linksData를 확인하세요."
|
||||
: "links.json을 불러오지 못했습니다. 배포 경로에 links.json이 있는지 확인하세요.";
|
||||
toast(hint);
|
||||
return DEFAULT_LINKS_INLINE.map(normalizeLink);
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const t = theme === "light" ? "light" : "dark";
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
el.btnTheme.setAttribute("aria-pressed", t === "dark" ? "true" : "false");
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
if (saved === "light" || saved === "dark") return applyTheme(saved);
|
||||
const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
applyTheme(prefersLight ? "light" : "dark");
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
const data = getMergedLinks().sort((a, b) => a.title.localeCompare(b.title, "ko"));
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `links-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(a.href);
|
||||
a.remove();
|
||||
}, 0);
|
||||
toast("내보내기 파일을 생성했습니다.");
|
||||
}
|
||||
|
||||
function importJsonText(text) {
|
||||
const parsed = safeJsonParse(text, null);
|
||||
if (!parsed) throw new Error("JSON 파싱 실패");
|
||||
|
||||
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.links) ? parsed.links : null;
|
||||
if (!list) throw new Error("JSON 형식이 올바르지 않습니다. (배열 또는 {links:[...]} )");
|
||||
|
||||
const merged = getMergedLinks();
|
||||
const used = new Set(merged.map((l) => l.id));
|
||||
|
||||
let added = 0;
|
||||
for (const item of list) {
|
||||
if (!item) continue;
|
||||
const n0 = normalizeLink(item);
|
||||
let n = n0;
|
||||
if (used.has(n.id)) {
|
||||
n = { ...n, id: newId("import"), createdAt: nowIso(), updatedAt: nowIso() };
|
||||
}
|
||||
used.add(n.id);
|
||||
// 가져오기는 custom로 추가(기본과 충돌 방지)
|
||||
state.store.custom.push(n);
|
||||
added++;
|
||||
}
|
||||
saveStore();
|
||||
toast(`가져오기 완료: ${added}개`);
|
||||
}
|
||||
|
||||
function onGridClick(e) {
|
||||
const btn = e.target.closest("[data-act]");
|
||||
if (!btn) return;
|
||||
const card = e.target.closest(".card");
|
||||
if (!card) return;
|
||||
const id = card.getAttribute("data-id");
|
||||
if (!id) return;
|
||||
|
||||
const act = btn.getAttribute("data-act");
|
||||
if (act === "fav") {
|
||||
toggleFavorite(id);
|
||||
return;
|
||||
}
|
||||
if (act === "copy") {
|
||||
const link = getLinkById(id);
|
||||
if (link) copyText(link.url);
|
||||
return;
|
||||
}
|
||||
if (act === "edit") {
|
||||
editLink(id);
|
||||
return;
|
||||
}
|
||||
if (act === "del") {
|
||||
const link = getLinkById(id);
|
||||
const name = link ? link.title : id;
|
||||
if (confirm(`삭제할까요?\n\n- ${name}`)) {
|
||||
deleteLink(id);
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
const isEdit = Boolean(el.id.value);
|
||||
|
||||
const title = String(el.title.value || "").trim();
|
||||
const url = normalizeUrl(el.url.value);
|
||||
if (!title) return toast("제목을 입력하세요.");
|
||||
if (!url) return toast("URL을 입력하세요.");
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return toast("URL 형식이 올바르지 않습니다.");
|
||||
}
|
||||
if (!/^https?:$/.test(parsed.protocol)) return toast("http/https URL만 지원합니다.");
|
||||
|
||||
const description = String(el.description.value || "").trim();
|
||||
const tags = normalizeTags(el.tags.value);
|
||||
const favorite = Boolean(el.favorite.checked);
|
||||
|
||||
if (isEdit) {
|
||||
const id = el.id.value;
|
||||
const current = getLinkById(id);
|
||||
if (!current) {
|
||||
closeModal();
|
||||
toast("편집 대상이 없습니다.");
|
||||
return;
|
||||
}
|
||||
const patch = {
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
tags,
|
||||
favorite,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
if (isBaseId(id)) setOverride(id, patch);
|
||||
else upsertCustom({ ...current, ...patch });
|
||||
closeModal();
|
||||
render();
|
||||
toast("저장했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = newId("custom");
|
||||
upsertCustom({
|
||||
id,
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
tags,
|
||||
favorite,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
});
|
||||
closeModal();
|
||||
render();
|
||||
toast("추가했습니다.");
|
||||
}
|
||||
|
||||
function wire() {
|
||||
el.q.addEventListener("input", () => {
|
||||
state.query = el.q.value || "";
|
||||
render();
|
||||
});
|
||||
|
||||
el.sort.addEventListener("change", () => {
|
||||
state.sortKey = el.sort.value || "json";
|
||||
render();
|
||||
});
|
||||
|
||||
el.onlyFav.addEventListener("change", () => {
|
||||
state.onlyFav = Boolean(el.onlyFav.checked);
|
||||
render();
|
||||
});
|
||||
|
||||
el.grid.addEventListener("click", onGridClick);
|
||||
|
||||
el.btnAdd.addEventListener("click", () => 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", () => 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");
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
initTheme();
|
||||
wire();
|
||||
state.baseLinks = await loadBaseLinks();
|
||||
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
|
||||
el.sort.value = state.sortKey;
|
||||
render();
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
toast("초기화에 실패했습니다.");
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user