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:
dsyoon
2026-02-07 17:33:14 +09:00
commit 02082eb16d
5 changed files with 1596 additions and 0 deletions

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# Links (개인 링크 홈)
정적 파일(HTML/CSS/JS)만으로 만든 개인 링크 대시보드입니다.
## 사용법
- **가장 간단한 방법**: `index.html`을 브라우저로 열기
- 즐겨찾기/추가/편집/삭제/정렬/검색/가져오기/내보내기 기능은 정상 동작합니다.
- 기본 링크 목록은 `index.html` 내부의 `linksData`(JSON)에서 읽기 때문에 **파이썬 실행 없이도** 순서가 그대로 반영됩니다.
- (선택) `links.json`을 별도 파일로 운용하고 싶다면 로컬 서버로 실행
```bash
python3 -m http.server 8000
```
그 후 브라우저에서 `http://localhost:8000`으로 접속합니다.
## 데이터 저장
- 기본 링크: `links.json`
- 사용자가 추가/편집/삭제한 내용: 브라우저 `localStorage`에 저장됩니다.
- 내보내기: 현재 화면 기준 링크를 JSON으로 다운로드합니다.
- 가져오기: 내보내기 JSON(배열 또는 `{links:[...]}`)을 다시 불러옵니다.

251
index.html Normal file
View File

@@ -0,0 +1,251 @@
<!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" />
<title>NCue | 개인 링크 홈</title>
<meta name="description" content="개인 서비스 링크를 모아 관리하는 홈 화면" />
<link rel="stylesheet" href="./styles.css" />
<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>
</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" hidden>
<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": "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": "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": "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"
},
{
"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"
}
]
</script>
<noscript>
<div class="noscript">이 페이지는 JavaScript가 필요합니다.</div>
</noscript>
</body>
</html>

82
links.json Normal file
View File

@@ -0,0 +1,82 @@
[
{
"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": "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": "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": "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"
},
{
"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"
}
]

629
script.js Normal file
View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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=&quot;letter&quot;>${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 || "&nbsp;"}</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("초기화에 실패했습니다.");
});
})();

609
styles.css Normal file
View File

@@ -0,0 +1,609 @@
:root {
--bg: #0b1020;
--panel: rgba(255, 255, 255, 0.06);
--panel2: rgba(255, 255, 255, 0.09);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.72);
--muted2: rgba(255, 255, 255, 0.58);
--border: rgba(255, 255, 255, 0.12);
--accent: #7c3aed;
--accent2: #22c55e;
--danger: #ef4444;
--shadow: 0 16px 60px rgba(0, 0, 0, 0.45);
--radius: 16px;
--radius2: 12px;
--max: 1120px;
--focus: 0 0 0 3px rgba(124, 58, 237, 0.35);
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
"Segoe UI Emoji";
}
html[data-theme="light"] {
--bg: #f7f7fb;
--panel: rgba(0, 0, 0, 0.04);
--panel2: rgba(0, 0, 0, 0.06);
--text: rgba(0, 0, 0, 0.9);
--muted: rgba(0, 0, 0, 0.66);
--muted2: rgba(0, 0, 0, 0.52);
--border: rgba(0, 0, 0, 0.12);
--shadow: 0 18px 60px rgba(0, 0, 0, 0.12);
--focus: 0 0 0 3px rgba(124, 58, 237, 0.2);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--sans);
color: var(--text);
background: radial-gradient(1200px 600px at 20% -10%, rgba(124, 58, 237, 0.35), transparent 60%),
radial-gradient(900px 500px at 90% 10%, rgba(34, 197, 94, 0.22), transparent 55%),
radial-gradient(800px 500px at 40% 110%, rgba(59, 130, 246, 0.16), transparent 60%), var(--bg);
min-height: 100vh;
}
.wrap {
width: min(var(--max), calc(100% - 32px));
margin: 0 auto;
}
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
.skip-link:focus {
left: 16px;
top: 16px;
width: auto;
height: auto;
padding: 10px 12px;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
outline: none;
z-index: 9999;
}
.topbar {
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(14px);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0));
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
html[data-theme="light"] .topbar {
background: linear-gradient(to bottom, rgba(247, 247, 251, 0.92), rgba(247, 247, 251, 0));
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.topbar .wrap {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 0;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 200px;
}
.logo {
width: 40px;
height: 40px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(124, 58, 237, 0.9), rgba(34, 197, 94, 0.7));
display: grid;
place-items: center;
box-shadow: 0 14px 40px rgba(124, 58, 237, 0.22);
}
.logo svg {
width: 22px;
height: 22px;
color: rgba(255, 255, 255, 0.92);
}
.brand-title {
font-weight: 760;
letter-spacing: -0.02em;
font-size: 18px;
line-height: 1.2;
}
.brand-sub {
margin-top: 2px;
font-size: 12px;
color: var(--muted2);
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn {
appearance: none;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
padding: 10px 12px;
border-radius: 12px;
cursor: pointer;
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
font-size: 13px;
line-height: 1;
display: inline-flex;
align-items: center;
gap: 8px;
user-select: none;
}
.btn:hover {
background: var(--panel2);
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn:focus-visible {
outline: none;
box-shadow: var(--focus);
}
.btn-ico {
width: 18px;
height: 18px;
display: inline-grid;
place-items: center;
font-weight: 900;
border-radius: 6px;
background: rgba(124, 58, 237, 0.22);
color: rgba(255, 255, 255, 0.92);
}
html[data-theme="light"] .btn-ico {
color: rgba(0, 0, 0, 0.82);
}
.btn-primary {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.92), rgba(99, 102, 241, 0.86));
border-color: rgba(124, 58, 237, 0.35);
}
.btn-primary:hover {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.98), rgba(99, 102, 241, 0.92));
}
.btn-ghost {
background: transparent;
}
main {
padding: 18px 0 48px;
}
.panel {
border: 1px solid var(--border);
background: var(--panel);
border-radius: var(--radius);
padding: 14px;
box-shadow: var(--shadow);
}
.controls {
display: grid;
grid-template-columns: 1.3fr 0.6fr auto auto;
gap: 12px;
align-items: end;
}
@media (max-width: 880px) {
.controls {
grid-template-columns: 1fr 1fr;
align-items: center;
}
.meta {
grid-column: 1 / -1;
justify-self: start;
}
}
@media (max-width: 520px) {
.topbar .wrap {
align-items: flex-start;
}
.brand {
min-width: 0;
}
.controls {
grid-template-columns: 1fr;
}
}
.field {
display: grid;
gap: 6px;
}
.field-label {
font-size: 12px;
color: var(--muted2);
}
.input,
.select {
width: 100%;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border-radius: 12px;
padding: 10px 12px;
font-size: 13px;
outline: none;
}
html[data-theme="light"] .input,
html[data-theme="light"] .select {
background: rgba(255, 255, 255, 0.75);
}
.input:focus,
.select:focus {
box-shadow: var(--focus);
border-color: rgba(124, 58, 237, 0.55);
}
.hint {
margin-top: 6px;
font-size: 12px;
color: var(--muted2);
}
.check {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
user-select: none;
padding: 10px 10px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
}
.check input {
width: 16px;
height: 16px;
}
.meta {
justify-self: end;
color: var(--muted2);
font-size: 12px;
}
.grid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 1020px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
.card {
position: relative;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius);
padding: 14px;
overflow: hidden;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.22);
transition: transform 140ms ease, background 140ms ease, border-color 140ms ease;
}
.card:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.06);
}
.card:focus-within {
box-shadow: var(--shadow), var(--focus);
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.card-title {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
.favicon {
width: 40px;
height: 40px;
border-radius: 14px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
display: grid;
place-items: center;
overflow: hidden;
flex: 0 0 auto;
}
.favicon img {
width: 22px;
height: 22px;
}
.favicon .letter {
font-weight: 850;
letter-spacing: -0.02em;
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
}
html[data-theme="light"] .favicon .letter {
color: rgba(0, 0, 0, 0.78);
}
.title-wrap {
min-width: 0;
}
.title {
font-weight: 760;
letter-spacing: -0.02em;
font-size: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.domain {
margin-top: 2px;
font-size: 12px;
font-family: var(--mono);
color: var(--muted2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-desc {
margin-top: 10px;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
min-height: 18px;
}
.tags {
margin-top: 10px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tag {
font-size: 12px;
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
background: rgba(255, 255, 255, 0.03);
}
.tag.fav {
border-color: rgba(34, 197, 94, 0.35);
background: rgba(34, 197, 94, 0.08);
color: rgba(180, 255, 210, 0.9);
}
html[data-theme="light"] .tag.fav {
color: rgba(0, 120, 70, 0.92);
}
.card-actions {
margin-top: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.mini {
padding: 9px 10px;
border-radius: 12px;
font-size: 12px;
}
.mini-danger {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
color: rgba(255, 200, 200, 0.92);
}
html[data-theme="light"] .mini-danger {
color: rgba(140, 20, 20, 0.9);
}
.icon-btn {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
width: 36px;
height: 36px;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
user-select: none;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.07);
}
.icon-btn:focus-visible {
outline: none;
box-shadow: var(--focus);
}
.star {
color: rgba(255, 255, 255, 0.75);
}
.star.on {
color: #fbbf24;
}
.empty {
margin-top: 14px;
border: 1px dashed var(--border);
background: rgba(255, 255, 255, 0.03);
border-radius: var(--radius);
padding: 28px 16px;
text-align: center;
color: var(--muted);
}
.empty-title {
font-weight: 760;
color: var(--text);
margin-bottom: 6px;
}
.empty-sub {
color: var(--muted2);
font-size: 13px;
}
.modal[hidden],
.toast[hidden] {
display: none !important;
}
.modal {
position: fixed;
inset: 0;
z-index: 200;
display: grid;
place-items: center;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.52);
backdrop-filter: blur(4px);
}
.modal-card {
position: relative;
width: min(560px, calc(100% - 32px));
border: 1px solid var(--border);
background: rgba(15, 18, 33, 0.92);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
html[data-theme="light"] .modal-card {
background: rgba(255, 255, 255, 0.92);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 10px;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-weight: 800;
letter-spacing: -0.02em;
}
.modal-body {
padding: 14px;
display: grid;
gap: 12px;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 6px;
}
.toast {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 300;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.72);
color: rgba(255, 255, 255, 0.92);
border-radius: 14px;
padding: 12px 14px;
box-shadow: var(--shadow);
max-width: min(520px, calc(100% - 32px));
font-size: 13px;
line-height: 1.4;
}
html[data-theme="light"] .toast {
background: rgba(255, 255, 255, 0.92);
color: rgba(0, 0, 0, 0.86);
}
.noscript {
position: fixed;
left: 16px;
bottom: 16px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--panel2);
padding: 12px 14px;
color: var(--muted);
box-shadow: var(--shadow);
}