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