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:
25
README.md
Normal file
25
README.md
Normal 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
251
index.html
Normal 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
82
links.json
Normal 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
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("초기화에 실패했습니다.");
|
||||
});
|
||||
})();
|
||||
|
||||
609
styles.css
Normal file
609
styles.css
Normal 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user