From 02082eb16df15858fc1634a912ef7b8f2b49fd73 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Sat, 7 Feb 2026 17:33:14 +0900 Subject: [PATCH] Add static links dashboard Includes JSON-ordered link cards, search/sort, favorites, CRUD, and import/export with localStorage. Co-authored-by: Cursor --- README.md | 25 +++ index.html | 251 +++++++++++++++++++++ links.json | 82 +++++++ script.js | 629 +++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 609 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1596 insertions(+) create mode 100644 README.md create mode 100644 index.html create mode 100644 links.json create mode 100644 script.js create mode 100644 styles.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..12e1204 --- /dev/null +++ b/README.md @@ -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:[...]}`)을 다시 불러옵니다. + diff --git a/index.html b/index.html new file mode 100644 index 0000000..6ef22ed --- /dev/null +++ b/index.html @@ -0,0 +1,251 @@ + + + + + + + NCue | 개인 링크 홈 + + + + + + + +
+
+
+ +
+
NCue
+
개인 링크 관리
+
+
+ +
+ + + + +
+
+
+ +
+
+
+ + + + + + +
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + diff --git a/links.json b/links.json new file mode 100644 index 0000000..524fa50 --- /dev/null +++ b/links.json @@ -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" + } +] diff --git a/script.js b/script.js new file mode 100644 index 0000000..41b715d --- /dev/null +++ b/script.js @@ -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 ? `★ 즐겨찾기` : "", + ...tags.map((t) => `#${escapeHtml(t)}`), + ] + .filter(Boolean) + .join(""); + + const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase()); + + return ` +
+
+
+ +
+
${title}
+
${domain}
+
+
+ +
+ +
${desc || " "}
+
${tagHtml || ""}
+ +
+ 열기 + + + +
+
+ `; + } + + 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("초기화에 실패했습니다."); + }); +})(); + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..e5e1356 --- /dev/null +++ b/styles.css @@ -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); +} +