Add Auth0 login gate for admin actions
Show login status in header, guard manage actions behind allowed emails, and add Auth0 SPA SDK with CDN fallback. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
16
README.md
16
README.md
@@ -16,6 +16,22 @@ python3 -m http.server 8000
|
|||||||
|
|
||||||
그 후 브라우저에서 `http://localhost:8000`으로 접속합니다.
|
그 후 브라우저에서 `http://localhost:8000`으로 접속합니다.
|
||||||
|
|
||||||
|
## 로그인(관리 기능 잠금)
|
||||||
|
|
||||||
|
이 프로젝트는 **정적 사이트**에서 동작하도록, 관리 기능(추가/편집/삭제/가져오기)을 **로그인 후(허용 이메일)** 에만 활성화할 수 있습니다.
|
||||||
|
|
||||||
|
- **지원 방식**: Auth0 SPA SDK + Auth0 Universal Login
|
||||||
|
- **구글/카카오/네이버**: Auth0 대시보드에서 Social/Custom OAuth 연결로 구성합니다.
|
||||||
|
|
||||||
|
설정 방법:
|
||||||
|
|
||||||
|
1. Auth0에서 **Single Page Application** 생성
|
||||||
|
2. `index.html`의 `window.AUTH_CONFIG`에 `domain`, `clientId` 입력
|
||||||
|
3. Auth0 Application 설정에서 아래 URL들을 등록
|
||||||
|
- Allowed Callback URLs: 사이트 주소 (예: `https://example.com/`)
|
||||||
|
- Allowed Logout URLs: 사이트 주소 (예: `https://example.com/`)
|
||||||
|
4. `allowedEmails`에 관리 허용 이메일 목록을 입력
|
||||||
|
|
||||||
## 데이터 저장
|
## 데이터 저장
|
||||||
|
|
||||||
- 기본 링크: `links.json`
|
- 기본 링크: `links.json`
|
||||||
|
|||||||
36
index.html
36
index.html
@@ -7,6 +7,26 @@
|
|||||||
<title>NCue | 개인 링크 홈</title>
|
<title>NCue | 개인 링크 홈</title>
|
||||||
<meta name="description" content="개인 서비스 링크를 모아 관리하는 홈 화면" />
|
<meta name="description" content="개인 서비스 링크를 모아 관리하는 홈 화면" />
|
||||||
<link rel="stylesheet" href="./styles.css" />
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
<!-- Auth0 SPA SDK (정적 사이트용) -->
|
||||||
|
<!-- jsDelivr 차단/실패 시 unpkg로 자동 대체 -->
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/@auth0/auth0-spa-js@2/dist/auth0-spa-js.production.js"
|
||||||
|
onerror="this.onerror=null;this.src='https://unpkg.com/@auth0/auth0-spa-js@2/dist/auth0-spa-js.production.js';"
|
||||||
|
></script>
|
||||||
|
<script>
|
||||||
|
// 로그인 설정 (관리 기능 잠금용)
|
||||||
|
// 1) Auth0에서 Application(SPA) 생성 후 domain/clientId를 입력하세요.
|
||||||
|
// 2) Allowed Callback URLs / Allowed Logout URLs에 현재 사이트 주소를 등록하세요.
|
||||||
|
// 예: https://drive.daewoongai.com/apps/dashboard/
|
||||||
|
window.AUTH_CONFIG = {
|
||||||
|
auth0: {
|
||||||
|
domain: "",
|
||||||
|
clientId: "",
|
||||||
|
},
|
||||||
|
// 관리 허용 이메일(대소문자 무시)
|
||||||
|
allowedEmails: [],
|
||||||
|
};
|
||||||
|
</script>
|
||||||
<script defer src="./script.js"></script>
|
<script defer src="./script.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -47,6 +67,12 @@
|
|||||||
<button class="btn" id="btnTheme" type="button" aria-pressed="false" title="테마 전환">
|
<button class="btn" id="btnTheme" type="button" aria-pressed="false" title="테마 전환">
|
||||||
테마
|
테마
|
||||||
</button>
|
</button>
|
||||||
|
<div class="user" id="user" hidden>
|
||||||
|
<span class="user-dot" aria-hidden="true"></span>
|
||||||
|
<span class="user-text" id="userText">로그인 필요</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="btnLogin" type="button">로그인</button>
|
||||||
|
<button class="btn" id="btnLogout" type="button" hidden>로그아웃</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -201,6 +227,16 @@
|
|||||||
"createdAt": "2026-02-07T00:00:00.000Z",
|
"createdAt": "2026-02-07T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-07T00:00:00.000Z"
|
"updatedAt": "2026-02-07T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "tts-ncue-net",
|
||||||
|
"title": "TTS",
|
||||||
|
"url": "https://tts.ncue.net/",
|
||||||
|
"description": "입력한 text를 mp3로 변환",
|
||||||
|
"tags": ["text", "mp3", "ncue"],
|
||||||
|
"favorite": false,
|
||||||
|
"createdAt": "2026-02-07T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-02-07T00:00:00.000Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "meeting-ncue-net",
|
"id": "meeting-ncue-net",
|
||||||
"title": "Meeting",
|
"title": "Meeting",
|
||||||
|
|||||||
11
links.json
11
links.json
@@ -39,6 +39,17 @@
|
|||||||
"createdAt": "2026-02-07T00:00:00.000Z",
|
"createdAt": "2026-02-07T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-07T00:00:00.000Z"
|
"updatedAt": "2026-02-07T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "tts-ncue-net",
|
||||||
|
"title": "TTS",
|
||||||
|
"url": "https://tts.ncue.net/",
|
||||||
|
"description": "입력한 text를 mp3로 변환",
|
||||||
|
"tags": ["text", "mp3", "ncue"],
|
||||||
|
"favorite": false,
|
||||||
|
"createdAt": "2026-02-07T00:00:00.000Z",
|
||||||
|
"updatedAt": "2026-02-07T00:00:00.000Z"
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"id": "meeting-ncue-net",
|
"id": "meeting-ncue-net",
|
||||||
"title": "Meeting",
|
"title": "Meeting",
|
||||||
|
|||||||
221
script.js
221
script.js
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
const STORAGE_KEY = "links_home_v1";
|
const STORAGE_KEY = "links_home_v1";
|
||||||
const THEME_KEY = "links_home_theme_v1";
|
const THEME_KEY = "links_home_theme_v1";
|
||||||
|
const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1";
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
subtitle: document.getElementById("subtitle"),
|
subtitle: document.getElementById("subtitle"),
|
||||||
@@ -16,6 +17,10 @@
|
|||||||
btnImport: document.getElementById("btnImport"),
|
btnImport: document.getElementById("btnImport"),
|
||||||
btnExport: document.getElementById("btnExport"),
|
btnExport: document.getElementById("btnExport"),
|
||||||
btnTheme: document.getElementById("btnTheme"),
|
btnTheme: document.getElementById("btnTheme"),
|
||||||
|
user: document.getElementById("user"),
|
||||||
|
userText: document.getElementById("userText"),
|
||||||
|
btnLogin: document.getElementById("btnLogin"),
|
||||||
|
btnLogout: document.getElementById("btnLogout"),
|
||||||
modal: document.getElementById("modal"),
|
modal: document.getElementById("modal"),
|
||||||
btnClose: document.getElementById("btnClose"),
|
btnClose: document.getElementById("btnClose"),
|
||||||
btnCancel: document.getElementById("btnCancel"),
|
btnCancel: document.getElementById("btnCancel"),
|
||||||
@@ -43,6 +48,15 @@
|
|||||||
query: "",
|
query: "",
|
||||||
sortKey: "json",
|
sortKey: "json",
|
||||||
onlyFav: false,
|
onlyFav: false,
|
||||||
|
canManage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = {
|
||||||
|
client: null,
|
||||||
|
user: null,
|
||||||
|
authorized: false,
|
||||||
|
ready: false,
|
||||||
|
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
|
||||||
};
|
};
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
@@ -233,6 +247,8 @@
|
|||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase());
|
const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase());
|
||||||
|
const lockAttr = state.canManage ? "" : ' disabled aria-disabled="true"';
|
||||||
|
const lockTitle = state.canManage ? "" : ' title="관리 기능은 로그인 후 사용 가능합니다."';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<article class="card" data-id="${escapeHtml(link.id)}">
|
<article class="card" data-id="${escapeHtml(link.id)}">
|
||||||
@@ -246,7 +262,7 @@
|
|||||||
<div class="domain" title="${domain}">${domain}</div>
|
<div class="domain" title="${domain}">${domain}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn" type="button" data-act="fav" title="즐겨찾기">
|
<button class="icon-btn" type="button" data-act="fav"${lockAttr}${lockTitle}>
|
||||||
<span class="${starClass}" aria-hidden="true">★</span>
|
<span class="${starClass}" aria-hidden="true">★</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,8 +273,8 @@
|
|||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<a class="btn mini" href="${url}" target="_blank" rel="noopener noreferrer" data-act="open">열기</a>
|
<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="copy">URL 복사</button>
|
||||||
<button class="btn mini" type="button" data-act="edit">편집</button>
|
<button class="btn mini" type="button" data-act="edit"${lockAttr}${lockTitle}>편집</button>
|
||||||
<button class="btn mini mini-danger" type="button" data-act="del">삭제</button>
|
<button class="btn mini mini-danger" type="button" data-act="del"${lockAttr}${lockTitle}>삭제</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
@@ -316,6 +332,184 @@
|
|||||||
}, 2400);
|
}, 2400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toastOnce(key, msg) {
|
||||||
|
const k = `${AUTH_TOAST_ONCE_KEY}:${key}`;
|
||||||
|
if (localStorage.getItem(k)) return;
|
||||||
|
localStorage.setItem(k, "1");
|
||||||
|
toast(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthConfig() {
|
||||||
|
const cfg = globalThis.AUTH_CONFIG && typeof globalThis.AUTH_CONFIG === "object" ? globalThis.AUTH_CONFIG : {};
|
||||||
|
const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {};
|
||||||
|
const allowedEmails = Array.isArray(cfg.allowedEmails) ? cfg.allowedEmails : [];
|
||||||
|
return {
|
||||||
|
auth0: {
|
||||||
|
domain: String(auth0.domain || "").trim(),
|
||||||
|
clientId: String(auth0.clientId || "").trim(),
|
||||||
|
},
|
||||||
|
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUrlNoQuery() {
|
||||||
|
// Auth0 callback 후 URL 정리용
|
||||||
|
const u = new URL(location.href);
|
||||||
|
u.searchParams.delete("code");
|
||||||
|
u.searchParams.delete("state");
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedEmail(email) {
|
||||||
|
const { allowedEmails } = getAuthConfig();
|
||||||
|
if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용
|
||||||
|
const e = String(email || "").trim().toLowerCase();
|
||||||
|
return allowedEmails.includes(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAuthUi() {
|
||||||
|
// 항상 상태를 보여주고, 버튼은 상태에 따라 비활성/숨김 처리
|
||||||
|
el.user.hidden = false;
|
||||||
|
const email = auth.user && auth.user.email ? String(auth.user.email) : "";
|
||||||
|
const name = auth.user && auth.user.name ? String(auth.user.name) : "";
|
||||||
|
let label = email ? email : name ? name : auth.user ? "로그인됨" : "로그인 필요";
|
||||||
|
if (auth.mode === "misconfigured") label = "로그인 설정 필요";
|
||||||
|
if (auth.mode === "sdk_missing") label = "로그인 SDK 로드 실패";
|
||||||
|
el.userText.textContent = label;
|
||||||
|
if (auth.authorized) el.user.setAttribute("data-auth", "ok");
|
||||||
|
else el.user.removeAttribute("data-auth");
|
||||||
|
|
||||||
|
// 로그인 기능이 활성(enabled)일 때만 로그인/로그아웃 버튼을 의미 있게 노출
|
||||||
|
const enabled = auth.mode === "enabled";
|
||||||
|
el.btnLogin.hidden = enabled ? Boolean(auth.user) : false;
|
||||||
|
el.btnLogout.hidden = !(enabled && auth.user);
|
||||||
|
// 설정/SDK 문제 상태에서도 버튼은 "클릭 가능"하게 두고, 클릭 시 토스트로 안내합니다.
|
||||||
|
el.btnLogin.disabled = false;
|
||||||
|
el.btnLogout.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyManageLock() {
|
||||||
|
// AUTH_CONFIG가 없는 상태에서는 기존처럼 자유롭게 관리 가능.
|
||||||
|
// 로그인 기능이 "enabled"일 때만 관리 잠금을 적용합니다.
|
||||||
|
state.canManage = auth.mode === "enabled" ? Boolean(auth.user && auth.authorized) : true;
|
||||||
|
|
||||||
|
const lockMsg = "관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.";
|
||||||
|
el.btnAdd.disabled = !state.canManage;
|
||||||
|
el.btnImport.disabled = !state.canManage;
|
||||||
|
|
||||||
|
if (!state.canManage) {
|
||||||
|
el.btnAdd.title = lockMsg;
|
||||||
|
el.btnImport.title = lockMsg;
|
||||||
|
} else {
|
||||||
|
el.btnAdd.title = "";
|
||||||
|
el.btnImport.title = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAuth() {
|
||||||
|
auth.ready = true;
|
||||||
|
const cfg = getAuthConfig();
|
||||||
|
const hasAuth0 = cfg.auth0.domain && cfg.auth0.clientId;
|
||||||
|
const hasSdk = typeof globalThis.createAuth0Client === "function";
|
||||||
|
|
||||||
|
if (!hasAuth0) {
|
||||||
|
// 설정이 없으면: 로그인은 비활성(안내만), 관리 기능은 잠그지 않음
|
||||||
|
auth.client = null;
|
||||||
|
auth.user = null;
|
||||||
|
auth.authorized = false;
|
||||||
|
auth.mode = "misconfigured";
|
||||||
|
updateAuthUi();
|
||||||
|
applyManageLock();
|
||||||
|
toastOnce(
|
||||||
|
"authcfg",
|
||||||
|
"로그인 설정이 비어있습니다. index.html의 AUTH_CONFIG(auth0.domain/clientId, allowedEmails)를 채워주세요."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSdk) {
|
||||||
|
toastOnce("auth0sdk", "로그인 SDK 로드에 실패했습니다. 네트워크/차단 설정을 확인하세요.");
|
||||||
|
auth.client = null;
|
||||||
|
auth.user = null;
|
||||||
|
auth.authorized = false;
|
||||||
|
auth.mode = "sdk_missing";
|
||||||
|
updateAuthUi();
|
||||||
|
applyManageLock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.protocol === "file:") {
|
||||||
|
toastOnce("file", "소셜 로그인은 보통 HTTPS 사이트에서만 동작합니다. (file://에서는 제한될 수 있어요)");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
auth.client = await createAuth0Client({
|
||||||
|
domain: cfg.auth0.domain,
|
||||||
|
clientId: cfg.auth0.clientId,
|
||||||
|
authorizationParams: {
|
||||||
|
redirect_uri: location.origin === "null" ? location.href : location.origin + location.pathname,
|
||||||
|
},
|
||||||
|
cacheLocation: "localstorage",
|
||||||
|
useRefreshTokens: true,
|
||||||
|
});
|
||||||
|
auth.mode = "enabled";
|
||||||
|
} catch {
|
||||||
|
auth.client = null;
|
||||||
|
auth.user = null;
|
||||||
|
auth.authorized = false;
|
||||||
|
auth.mode = "sdk_missing";
|
||||||
|
toastOnce("authinit", "로그인 초기화에 실패했습니다. AUTH_CONFIG 값과 Auth0 설정(Callback URL)을 확인하세요.");
|
||||||
|
updateAuthUi();
|
||||||
|
applyManageLock();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const u = new URL(location.href);
|
||||||
|
const isCallback = u.searchParams.has("code") && u.searchParams.has("state");
|
||||||
|
if (isCallback) {
|
||||||
|
try {
|
||||||
|
await auth.client.handleRedirectCallback();
|
||||||
|
} finally {
|
||||||
|
history.replaceState({}, document.title, currentUrlNoQuery());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthed = await auth.client.isAuthenticated();
|
||||||
|
auth.user = isAuthed ? await auth.client.getUser() : null;
|
||||||
|
const email = auth.user && auth.user.email ? auth.user.email : "";
|
||||||
|
auth.authorized = Boolean(auth.user) && isAllowedEmail(email);
|
||||||
|
|
||||||
|
if (auth.user && !auth.authorized) {
|
||||||
|
toastOnce("deny", "로그인은 되었지만 허용 이메일이 아니라서 관리 기능이 잠금 상태입니다.");
|
||||||
|
} else if (auth.user && getAuthConfig().allowedEmails.length === 0) {
|
||||||
|
toastOnce("allowall", "주의: 허용 이메일 목록이 비어있어서 로그인한 모든 계정이 관리 가능 상태입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAuthUi();
|
||||||
|
applyManageLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
if (auth.mode !== "enabled" || !auth.client) {
|
||||||
|
toast("로그인 설정이 필요합니다. index.html의 AUTH_CONFIG(auth0.domain/clientId, allowedEmails)를 확인하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await auth.client.loginWithRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
if (auth.mode !== "enabled" || !auth.client) return;
|
||||||
|
auth.user = null;
|
||||||
|
auth.authorized = false;
|
||||||
|
updateAuthUi();
|
||||||
|
applyManageLock();
|
||||||
|
await auth.client.logout({
|
||||||
|
logoutParams: {
|
||||||
|
returnTo: location.origin === "null" ? location.href : location.origin + location.pathname,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function copyText(text) {
|
async function copyText(text) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@@ -476,6 +670,10 @@
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
const act = btn.getAttribute("data-act");
|
const act = btn.getAttribute("data-act");
|
||||||
|
if (auth.mode === "enabled" && !state.canManage && (act === "fav" || act === "edit" || act === "del")) {
|
||||||
|
toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (act === "fav") {
|
if (act === "fav") {
|
||||||
toggleFavorite(id);
|
toggleFavorite(id);
|
||||||
return;
|
return;
|
||||||
@@ -579,7 +777,11 @@
|
|||||||
|
|
||||||
el.grid.addEventListener("click", onGridClick);
|
el.grid.addEventListener("click", onGridClick);
|
||||||
|
|
||||||
el.btnAdd.addEventListener("click", () => openModal("add", null));
|
el.btnAdd.addEventListener("click", () => {
|
||||||
|
if (auth.mode === "enabled" && !state.canManage)
|
||||||
|
return toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
|
||||||
|
openModal("add", null);
|
||||||
|
});
|
||||||
el.btnClose.addEventListener("click", closeModal);
|
el.btnClose.addEventListener("click", closeModal);
|
||||||
el.btnCancel.addEventListener("click", closeModal);
|
el.btnCancel.addEventListener("click", closeModal);
|
||||||
el.modal.addEventListener("click", (e) => {
|
el.modal.addEventListener("click", (e) => {
|
||||||
@@ -593,7 +795,11 @@
|
|||||||
el.form.addEventListener("submit", onFormSubmit);
|
el.form.addEventListener("submit", onFormSubmit);
|
||||||
|
|
||||||
el.btnExport.addEventListener("click", exportJson);
|
el.btnExport.addEventListener("click", exportJson);
|
||||||
el.btnImport.addEventListener("click", () => el.file.click());
|
el.btnImport.addEventListener("click", () => {
|
||||||
|
if (auth.mode === "enabled" && !state.canManage)
|
||||||
|
return toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
|
||||||
|
el.file.click();
|
||||||
|
});
|
||||||
el.file.addEventListener("change", async () => {
|
el.file.addEventListener("change", async () => {
|
||||||
const f = el.file.files && el.file.files[0];
|
const f = el.file.files && el.file.files[0];
|
||||||
el.file.value = "";
|
el.file.value = "";
|
||||||
@@ -611,14 +817,19 @@
|
|||||||
const cur = document.documentElement.getAttribute("data-theme") || "dark";
|
const cur = document.documentElement.getAttribute("data-theme") || "dark";
|
||||||
applyTheme(cur === "dark" ? "light" : "dark");
|
applyTheme(cur === "dark" ? "light" : "dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (el.btnLogin) el.btnLogin.addEventListener("click", () => login().catch(() => toast("로그인에 실패했습니다.")));
|
||||||
|
if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다.")));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
initTheme();
|
initTheme();
|
||||||
wire();
|
wire();
|
||||||
|
await initAuth();
|
||||||
state.baseLinks = await loadBaseLinks();
|
state.baseLinks = await loadBaseLinks();
|
||||||
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
|
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
|
||||||
el.sort.value = state.sortKey;
|
el.sort.value = state.sortKey;
|
||||||
|
applyManageLock();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
styles.css
60
styles.css
@@ -138,6 +138,54 @@ html[data-theme="light"] .topbar {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .user-dot {
|
||||||
|
background: rgba(0, 0, 0, 0.38);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user[data-auth="ok"] {
|
||||||
|
color: rgba(180, 255, 210, 0.9);
|
||||||
|
border-color: rgba(34, 197, 94, 0.28);
|
||||||
|
background: rgba(34, 197, 94, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .user[data-auth="ok"] {
|
||||||
|
color: rgba(0, 120, 70, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user[data-auth="ok"] .user-dot {
|
||||||
|
background: rgba(34, 197, 94, 0.9);
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-text {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -155,6 +203,18 @@ html[data-theme="light"] .topbar {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn[disabled],
|
||||||
|
.icon-btn[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn[disabled]:hover,
|
||||||
|
.icon-btn[disabled]:hover {
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: var(--panel2);
|
background: var(--panel2);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
|||||||
Reference in New Issue
Block a user