messenger-probe / index.html
dshyang32's picture
νŒŒμΌΒ·μ‚¬μ§„ 첨뢀: Bucket μ—…λ‘œλ“œ, 이미지 미리보기, λ‹€μš΄λ‘œλ“œ
c64c618 verified
Raw History Blame Contribute Delete
50.3 kB
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<!-- HF static serving injects its OAuth <script> and corrupts the multi-byte character that straddles byte 8192 of this file. Keep the first 8 KB pure ASCII: padding follows. -->
<!-- -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Team Messenger (probe)</title>
<style>
:root{
--bg:#ffffff; --bg2:#f7f7f5; --bg3:#efefed; --fg:#37352f; --dim:#787774; --line:#e9e9e7;
--accent:#2383e2; --accent-fg:#fff; --mine:#2383e2; --theirs:#f1f1ef; --danger:#e03e3e; --ok:#0f7b6c; --warn:#d9730d;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#191919; --bg2:#202020; --bg3:#2a2a2a; --fg:#e6e6e4; --dim:#9b9a97; --line:#2f2f2f; --theirs:#2a2a2a;
}
}
:root[data-theme="dark"]{ --bg:#191919; --bg2:#202020; --bg3:#2a2a2a; --fg:#e6e6e4; --dim:#9b9a97; --line:#2f2f2f; --theirs:#2a2a2a; }
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{font:14px/1.45 ui-sans-serif,-apple-system,"Segoe UI","Apple SD Gothic Neo","Noto Sans KR",sans-serif;background:var(--bg);color:var(--fg)}
button{font:inherit;color:inherit;cursor:pointer}
input,textarea{font:inherit;color:inherit}
.hidden{display:none!important}
.btn{border:1px solid var(--line);background:var(--bg);border-radius:6px;padding:6px 12px}
.btn:hover{background:var(--bg2)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}
.btn.wide{width:100%;padding:9px}
.field{width:100%;border:1px solid var(--line);background:var(--bg);border-radius:6px;padding:8px 10px}
.av{width:32px;height:32px;border-radius:50%;flex-shrink:0;background:var(--bg3);object-fit:cover}
.av.sm{width:26px;height:26px}
/* center screens */
.center{display:flex;align-items:center;justify-content:center;min-height:100%;padding:16px;background:var(--bg2)}
.card{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:24px;max-width:380px;width:100%}
.card h1{font-size:18px;margin:0 0 4px}
.card p{color:var(--dim);margin:0 0 16px;font-size:13px}
.sep{display:flex;align-items:center;gap:8px;color:var(--dim);font-size:12px;margin:14px 0}
.sep:before,.sep:after{content:"";flex:1;height:1px;background:var(--line)}
.err{color:var(--danger);font-size:12.5px;margin-top:10px;white-space:pre-wrap}
.note{color:var(--dim);font-size:12px;margin-top:8px}
/* app shell */
#app{display:flex;flex-direction:column;height:100%}
.topbar{height:44px;flex-shrink:0;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid var(--line)}
.topbar b{font-size:14px}
.topbar .sp{flex:1}
.topbar .who{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12.5px}
.body{flex:1;display:flex;min-height:0}
.nav{width:56px;flex-shrink:0;border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;padding:8px 0;gap:2px;background:var(--bg2)}
.nav button{width:44px;border:none;background:none;border-radius:6px;padding:6px 0 4px;display:flex;flex-direction:column;align-items:center;gap:1px;color:var(--dim);font-size:10px}
.nav button svg{width:19px;height:19px}
.nav button:hover{background:var(--bg3)}
.nav button.on{color:var(--fg);background:var(--bg3)}
.nav button:disabled{opacity:.35;cursor:default}
.list{width:280px;flex-shrink:0;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
.list .hd{padding:10px 12px 6px}
.list .hd h2{font-size:16px;margin:0 0 8px}
.list .scroll{flex:1;overflow-y:auto;padding:0 6px 10px}
.row{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:6px;cursor:pointer}
.row:hover{background:var(--bg2)}
.row.on{background:var(--bg3)}
.row .tx{flex:1;min-width:0}
.row .nm{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.row .lm{color:var(--dim);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;gap:6px}
.row .lm span:first-child{overflow:hidden;text-overflow:ellipsis}
.row.unread .nm,.row.unread .lm span:first-child{font-weight:600;color:var(--fg)}
.badge{background:var(--accent);color:#fff;border-radius:10px;font-size:11px;font-weight:600;padding:0 6px;min-width:18px;text-align:center;line-height:18px}
.empty{color:var(--dim);text-align:center;padding:40px 16px;font-size:13px}
.main{flex:1;display:flex;flex-direction:column;min-width:0;min-height:0}
.chead{height:52px;flex-shrink:0;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;padding:0 14px}
.chead .t{font-weight:600}
.chead .s{color:var(--dim);font-size:12px}
.back{display:none;border:none;background:none;font-size:18px;padding:2px 6px}
#msgs{flex:1;overflow-y:auto;padding:12px 14px}
.day{text-align:center;color:var(--dim);font-size:11.5px;margin:12px 0 6px}
.m{display:flex;gap:8px;margin-top:6px;align-items:flex-end}
.m.cont{margin-top:2px}
.m .avs{width:26px;flex-shrink:0}
.m .col{max-width:min(70%,560px);display:flex;flex-direction:column}
.m .who{font-size:11.5px;color:var(--dim);margin:0 0 2px 10px}
.bub{background:var(--theirs);border-radius:16px;padding:7px 12px;white-space:pre-wrap;overflow-wrap:anywhere}
.m .tm{font-size:10.5px;color:var(--dim);margin:0 6px 2px;white-space:nowrap}
.m.mine{flex-direction:row-reverse}
.m.mine .col{align-items:flex-end}
.m.mine .bub{background:var(--mine);color:#fff}
.m.pending .bub{opacity:.55}
.m.failed .bub{background:var(--danger);color:#fff}
.composer{flex-shrink:0;border-top:1px solid var(--line);padding:8px 10px;display:flex;gap:8px;align-items:flex-end}
.composer textarea{flex:1;resize:none;border:1px solid var(--line);border-radius:18px;padding:8px 14px;background:var(--bg2);max-height:140px;outline:none;line-height:1.4}
.composer textarea:focus{border-color:var(--accent)}
.iconbtn{border:none;background:none;border-radius:50%;width:34px;height:34px;display:flex;align-items:center;justify-content:center;color:var(--dim);flex-shrink:0}
.iconbtn:hover{background:var(--bg2)}
.iconbtn:disabled{opacity:.35;cursor:default}
.send{color:var(--accent)}
.dpane{flex:1;display:flex;min-width:0;min-height:0}
.diag{flex:1;overflow-y:auto;padding:16px 20px}
.diag h2{font-size:16px;margin:0 0 4px}
.diag table{border-collapse:collapse;width:100%;max-width:760px;margin:12px 0}
.diag td{border-bottom:1px solid var(--line);padding:7px 8px;vertical-align:top;font-size:13px}
.diag td:first-child{width:30px}
.diag td:nth-child(2){width:200px;color:var(--dim)}
.ok{color:var(--ok)} .bad{color:var(--danger)} .warn{color:var(--warn)}
.bub .txt{white-space:pre-wrap}
.bub.media{background:none!important;padding:0}
.bub img.att-img{display:block;max-width:min(320px,100%);max-height:280px;border-radius:12px;border:1px solid var(--line);cursor:zoom-in;background:var(--bg3);min-width:80px;min-height:60px}
.att-file{display:flex;align-items:center;gap:10px;padding:8px 10px;margin-top:4px;border-radius:10px;background:var(--bg);color:var(--fg);border:1px solid var(--line);cursor:pointer;text-decoration:none;max-width:320px}
.att-file:hover{background:var(--bg2)}
.att-file .ic{width:34px;height:34px;border-radius:6px;background:var(--bg3);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:var(--dim);flex-shrink:0}
.att-file .fn{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.att-file .fs{font-size:11.5px;color:var(--dim)}
.att-gap{height:4px}
.dropping #msgs{outline:2px dashed var(--accent);outline-offset:-6px}
#lightbox{position:fixed;inset:0;background:rgba(0,0,0,.8);display:flex;align-items:center;justify-content:center;z-index:25;cursor:zoom-out}
#lightbox img{max-width:94vw;max-height:94vh;border-radius:6px}
#toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:#37352f;color:#fff;padding:8px 14px;border-radius:6px;font-size:13px;z-index:20;max-width:90vw}
#fatal{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;padding:8px 12px;font-size:12.5px;z-index:30;white-space:pre-wrap}
/* embed mode: Notion */
.embed .topbar{height:38px}
.embed .topbar .brand{display:none}
.embed .nav{width:48px}
.embed .nav button span{display:none}
.embed .list{width:240px}
/* narrow: messenger-style single pane */
@media (max-width:720px){
.list{width:auto;flex:1;border-right:none}
.main{display:none}
.body.inchat .list{display:none}
.body.inchat .main{display:flex}
.back{display:block}
.nav{width:48px}
.nav button span{display:none}
}
</style>
<script>
// Surface load/runtime errors visibly β€” this probe is debugged from the user's browser only.
window.addEventListener("error", function (e) { showFatal(e.message + (e.filename ? " @" + e.filename.split("/").pop() + ":" + e.lineno : "")); });
window.addEventListener("unhandledrejection", function (e) { showFatal(String(e.reason && e.reason.message || e.reason)); });
function showFatal(msg) { var d = document.getElementById("fatal"); if (!d) { d = document.createElement("div"); d.id = "fatal"; document.body.appendChild(d); } d.textContent = "였λ₯˜: " + msg; }
</script>
</head>
<body>
<!-- login -->
<div id="loginView" class="center hidden">
<div class="card">
<h1>Team Messenger</h1>
<p>CJ Lab μ „μš© Β· Hugging Face κ³„μ •μœΌλ‘œ 둜그인 (μ‹œν—˜νŒ)</p>
<button id="btnPopup" class="btn primary wide">Hugging Face둜 둜그인</button>
<button id="btnRedirect" class="btn wide" style="margin-top:6px">이 μ°½μ—μ„œ 둜그인</button>
<div class="sep">λ˜λŠ”</div>
<input id="tokenIn" class="field" type="password" placeholder="hf_… μ•‘μ„ΈμŠ€ 토큰">
<button id="btnToken" class="btn wide" style="margin-top:6px">ν† ν°μœΌλ‘œ 둜그인</button>
<div id="loginErr" class="err"></div>
<div class="note" id="loginNote"></div>
</div>
</div>
<!-- popup callback / waiting -->
<div id="popupView" class="center hidden"><div class="card"><h1 id="popupTitle">둜그인 쀑…</h1><p id="popupMsg"></p></div></div>
<!-- access denied (spec wording) -->
<div id="deniedView" class="center hidden">
<div class="card">
<h1>Access denied.</h1>
<p>This messenger is restricted to members of the configured Hugging Face Organization.</p>
<p id="deniedDetail" style="font-size:12px"></p>
<button id="btnDeniedLogout" class="btn">λ‹€λ₯Έ κ³„μ •μœΌλ‘œ 둜그인</button>
</div>
</div>
<div id="app" class="hidden">
<div class="topbar">
<b class="brand">Team Messenger</b><span class="warn" style="font-size:11px">μ‹œν—˜νŒ</span>
<span class="sp"></span>
<span class="who"><img id="meAv" class="av sm" alt=""><span id="meName"></span></span>
</div>
<div class="body" id="body">
<nav class="nav" id="nav">
<button data-nav="chats" title="Chats"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span>Chats</span></button>
<button data-nav="people" title="People"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg><span>People</span></button>
<button disabled title="Groups β€” λ‹€μŒ 단계"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg><span>Groups</span></button>
<button disabled title="Files β€” λ‹€μŒ 단계"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg><span>Files</span></button>
<span style="flex:1"></span>
<button data-nav="settings" title="Settings Β· 진단"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span>Settings</span></button>
</nav>
<section class="list" id="listPane">
<div class="hd"><h2 id="listTitle">Chats</h2><input id="search" class="field" placeholder="Search"></div>
<div class="scroll" id="listBody"></div>
</section>
<section class="main" id="chatPane">
<div class="chead">
<button class="back" id="back">β€Ή</button>
<img id="cAv" class="av" alt="">
<div><div class="t" id="cName"></div><div class="s" id="cSub"></div></div>
</div>
<div id="msgs"><div class="empty">μ™Όμͺ½μ—μ„œ λŒ€ν™”λ₯Ό κ³ λ₯΄κ±°λ‚˜ Peopleμ—μ„œ νŒ€μ›μ„ λˆ„λ₯΄μ„Έμš”.</div></div>
<div class="composer" id="composer">
<button class="iconbtn" id="attachBtn" title="νŒŒμΌΒ·μ‚¬μ§„ 첨뢀"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
<textarea id="input" rows="1" placeholder="Message…"></textarea>
<button class="iconbtn send" id="sendBtn" disabled title="Send"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2 21l21-9L2 3v7l15 2-15 2z"/></svg></button>
</div>
</section>
<section class="dpane hidden" id="diagPane"><div class="diag" id="diagBody"></div></section>
</div>
</div>
<div id="toast" class="hidden"></div>
<input type="file" id="filePick" multiple class="hidden">
<div id="lightbox" class="hidden"><img alt=""></div>
<script type="module">
import { oauthLoginUrl, oauthHandleRedirectIfPresent, uploadFiles } from "https://cdn.jsdelivr.net/npm/@huggingface/hub@2.17.5/+esm";
// ───────────────────────────── config (no secrets here: everything is public in a static Space)
const CFG = {
ORG: "CJLabKIST",
BUCKET: "CJLabKIST/messenger-probe",
HANDOFF_BUCKET: "CJLabKIST/messenger-handoff", // public; holds only AES-encrypted, short-lived login handoffs
HUB: "https://huggingface.co",
POLL_CHAT_MS: 4000,
POLL_LIST_MS: 15000,
};
const $ = (s) => document.querySelector(s);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const params = new URLSearchParams(location.search);
const IN_IFRAME = (() => { try { return window.self !== window.top; } catch { return true; } })();
const EMBED = params.get("embed") === "1" || IN_IFRAME;
if (EMBED) document.body.classList.add("embed");
// ───────────────────────────── diagnostics log (shown on Settings, copyable)
const DIAG = {};
function diag(key, ok, detail) { DIAG[key] = { ok, detail: String(detail ?? ""), at: new Date().toLocaleTimeString("ko-KR") }; if (state.view === "settings") renderDiag(); }
// ───────────────────────────── storage that may be blocked (iframe partitioning, Safari, etc.)
const store = {
ok: (() => { try { localStorage.setItem("__t", "1"); localStorage.removeItem("__t"); return true; } catch { return false; } })(),
mem: {},
get(k, d) { try { const v = this.ok ? localStorage.getItem("tm_" + k) : this.mem[k]; return v == null ? d : JSON.parse(v); } catch { return d; } },
set(k, v) { const s = JSON.stringify(v); if (this.ok) { try { localStorage.setItem("tm_" + k, s); } catch {} } else this.mem[k] = s; },
del(k) { if (this.ok) { try { localStorage.removeItem("tm_" + k); } catch {} } delete this.mem[k]; },
};
const state = {
token: null, me: null, loginMethod: null,
members: [], view: "chats", convId: null,
convs: new Map(), // convId -> { id, peer, files: [paths], last: msg }
msgCache: new Map(), // path -> message (bucket files are immutable)
pending: [], // optimistic messages in current conv
};
function toast(t, ms = 2500) { const e = $("#toast"); e.textContent = t; e.classList.remove("hidden"); clearTimeout(toast.t); toast.t = setTimeout(() => e.classList.add("hidden"), ms); }
function show(id) { for (const v of ["loginView", "popupView", "deniedView", "app"]) $("#" + v).classList.toggle("hidden", v !== id); }
const avatarUrl = (u) => (!u ? "" : u.startsWith("http") ? u : CFG.HUB + u);
// ───────────────────────────── HF API layer (all Hub calls go through here)
async function hf(path, opts = {}) {
const r = await fetch(path.startsWith("http") ? path : CFG.HUB + path, {
...opts, cache: "no-store",
headers: { ...(state.token ? { Authorization: "Bearer " + state.token } : {}), ...(opts.headers || {}) },
});
if (!r.ok) { let m = r.statusText; try { const j = await r.json(); m = j.error || m; } catch {} const e = new Error(r.status + " " + m); e.status = r.status; throw e; }
return r;
}
const Bucket = {
async list(prefix) {
try { return await (await hf(`/api/buckets/${CFG.BUCKET}/tree/${prefix}?recursive=true`)).json(); }
catch (e) { if (e.status === 404) return []; throw e; }
},
async readJson(path) {
const r = await hf(`/buckets/${CFG.BUCKET}/resolve/${path.split("/").map(encodeURIComponent).join("/")}`);
return r.json();
},
async writeJson(path, obj) {
await uploadFiles({
repo: { type: "bucket", name: CFG.BUCKET }, accessToken: state.token, useWebWorkers: false,
files: [{ path, content: new Blob([JSON.stringify(obj)], { type: "application/json" }) }],
});
},
};
// ───────────────────────────── login handoff (iframe ⇄ popup)
// HF pages send COOP: same-origin, so a popup opened from the Notion iframe loses window.opener,
// and partitioned storage means the popup and the iframe share nothing. Bridge them through HF itself:
// the iframe makes a random key K and passes it in the URL fragment (never sent to any server);
// the popup logs in, encrypts the token with K and drops it in a public bucket under sha256(K);
// the iframe polls that path, decrypts, then deletes the file.
const b64u = {
enc: (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""),
dec: (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4)), (c) => c.charCodeAt(0)),
};
async function sha256hex(s) { const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, "0")).join(""); }
const aesKey = (k) => crypto.subtle.importKey("raw", b64u.dec(k), "AES-GCM", false, ["encrypt", "decrypt"]);
async function seal(k, obj) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await aesKey(k), new TextEncoder().encode(JSON.stringify(obj)));
return { iv: b64u.enc(iv), ct: b64u.enc(ct) };
}
async function unseal(k, box) {
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64u.dec(box.iv) }, await aesKey(k), b64u.dec(box.ct));
return JSON.parse(new TextDecoder().decode(pt));
}
const handoffPath = async (k) => `handoff/${await sha256hex(k)}.json`;
async function handoffPut(k, payload) {
await uploadFiles({
repo: { type: "bucket", name: CFG.HANDOFF_BUCKET }, accessToken: payload.token, useWebWorkers: false,
files: [{ path: await handoffPath(k), content: new Blob([JSON.stringify(await seal(k, payload))], { type: "application/json" }) }],
});
}
async function handoffDelete(path, token) {
await fetch(`${CFG.HUB}/api/buckets/${CFG.HANDOFF_BUCKET}/batch`, {
method: "POST", headers: { Authorization: "Bearer " + token, "Content-Type": "application/x-ndjson" },
body: JSON.stringify({ type: "deleteFile", path }) + "\n",
});
}
async function handoffPoll(k) {
const path = await handoffPath(k), until = Date.now() + 180000;
while (Date.now() < until && !state.me) {
await new Promise((r) => setTimeout(r, 1500));
let r;
try { r = await fetch(`${CFG.HUB}/buckets/${CFG.HANDOFF_BUCKET}/resolve/${path}?t=${Date.now()}`, { cache: "no-store" }); } catch { continue; }
if (r.status === 404) continue;
if (!r.ok) { diag("popup", false, "handoff 읽기 μ‹€νŒ¨: HTTP " + r.status); return; }
try {
const p = await unseal(k, await r.json());
diag("popup", true, "νŒμ—… 둜그인 β†’ μ•”ν˜Έν™” handoff(Bucket)둜 토큰 λ°›μŒ");
handoffDelete(path, p.token).then(() => diag("handoffDel", true, "handoff 파일 μ‚­μ œλ¨"), (e) => diag("handoffDel", false, e.message));
await acceptLogin(p.token, p.exp, "oauth-popup", p.orgs);
} catch (e) { diag("popup", false, "handoff λ³΅ν˜Έν™” μ‹€νŒ¨: " + e.message); }
return;
}
if (!state.me) { diag("popup", false, "3λΆ„ μ•ˆμ— νŒμ—… 둜그인 κ²°κ³Όκ°€ μ˜€μ§€ μ•ŠμŒ"); $("#loginErr").textContent = "νŒμ—… 둜그인 κ²°κ³Όλ₯Ό λ°›μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•˜κ±°λ‚˜ ν† ν°μœΌλ‘œ λ‘œκ·ΈμΈν•΄ μ£Όμ„Έμš”."; }
}
// ───────────────────────────── auth
// huggingface.co login pages send X-Frame-Options: SAMEORIGIN, so inside any iframe (Notion, the
// HF Space page) login must happen in a top-level popup; the token comes back via postMessage when
// window.opener survives, otherwise via the encrypted bucket handoff above.
async function boot() {
const vars = window.huggingface?.variables || {};
diag("iframe", true, IN_IFRAME ? "iframe μ•ˆ (embed)" + (document.referrer ? " Β· λΆ€λͺ¨: " + new URL(document.referrer).host : "") : "μ΅œμƒμœ„ μ°½");
diag("storage", store.ok, store.ok ? "localStorage μ‚¬μš© κ°€λŠ₯" : "localStorage λ§‰νž˜ β†’ μƒˆλ‘œκ³ μΉ¨ν•˜λ©΄ 둜그인이 ν’€λ¦Ό");
diag("oauthcfg", !!vars.OAUTH_CLIENT_ID, vars.OAUTH_CLIENT_ID ? "OAuth μ„€μ • μ£Όμž…λ¨ Β· scopes: " + vars.OAUTH_SCOPES : "OAuth μ„€μ • μ—†μŒ (HF Space λ°–μ—μ„œ μ—΄μ—ˆκ±°λ‚˜ README μ„€μ • λˆ„λ½)");
// 1) returning from HF OAuth?
let res = null;
try { res = await oauthHandleRedirectIfPresent(); } catch (e) { diag("oauth", false, "콜백 처리 μ‹€νŒ¨: " + e.message); }
if (res) {
history.replaceState(null, "", location.pathname + (EMBED && !IN_IFRAME ? "?embed=1" : ""));
const payload = { type: "tm-oauth", token: res.accessToken, exp: +new Date(res.accessTokenExpiresAt), orgs: res.userInfo.orgs || [] };
if (res.state === "popup") {
show("popupView");
const k = store.get("handoffK"); store.del("handoffK");
const via = [];
if (window.opener) { try { window.opener.postMessage(payload, location.origin); via.push("postMessage"); } catch (e) {} }
if (k) {
$("#popupMsg").textContent = "μ›λž˜ ν™”λ©΄μœΌλ‘œ 둜그인 정보λ₯Ό λ³΄λ‚΄λŠ” 쀑…";
try { await handoffPut(k, payload); via.push("μ•”ν˜Έν™” handoff"); } catch (e) { $("#popupMsg").textContent = "handoff μ €μž₯ μ‹€νŒ¨: " + e.message; }
}
if (via.length) {
$("#popupTitle").textContent = "둜그인 μ™„λ£Œ";
$("#popupMsg").textContent = `μ›λž˜ ν™”λ©΄μœΌλ‘œ λŒμ•„κ°€μ„Έμš” (${via.join(" + ")}). 이 창은 κ³§ λ‹«νž™λ‹ˆλ‹€.`;
setTimeout(() => window.close(), 1500);
} else {
$("#popupTitle").textContent = "λ‘œκ·ΈμΈμ€ λμ§€λ§Œ μ›λž˜ ν™”λ©΄μœΌλ‘œ λŒλ €λ³΄λ‚΄μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€";
$("#popupMsg").textContent = "window.opener도 handoff 킀도 μ—†μŠ΅λ‹ˆλ‹€. 이 창을 λ‹«κ³  토큰 λ‘œκ·ΈμΈμ„ 써 μ£Όμ„Έμš”.";
}
return;
}
await acceptLogin(payload.token, payload.exp, "oauth-redirect", payload.orgs);
return;
}
// 2) popup opened by the iframe: go straight to HF authorize (must be top-level)
if (params.get("popup") === "1") {
show("popupView");
const k = new URLSearchParams(location.hash.slice(1)).get("k");
if (k) store.set("handoffK", k);
try { location.href = await oauthLoginUrl({ scopes: vars.OAUTH_SCOPES, state: "popup", redirectUrl: location.origin + location.pathname }); }
catch (e) { $("#popupTitle").textContent = "둜그인 μ‹œμž‘ μ‹€νŒ¨"; $("#popupMsg").textContent = e.message; }
return;
}
// 3) saved session
const saved = store.get("auth");
if (saved && (!saved.exp || saved.exp > Date.now() + 60000)) {
if (await acceptLogin(saved.token, saved.exp, saved.method + " (μ €μž₯된 μ„Έμ…˜)", null, true)) return;
}
showLogin();
}
function showLogin(msg) {
show("loginView");
const has = !!window.huggingface?.variables?.OAUTH_CLIENT_ID;
$("#btnPopup").disabled = !has; $("#btnRedirect").disabled = !has;
$("#btnRedirect").classList.toggle("hidden", IN_IFRAME); // HF login page refuses to render inside iframes (X-Frame-Options)
$("#loginNote").textContent = IN_IFRAME ? "λ‹€λ₯Έ νŽ˜μ΄μ§€(Notion, HF Space ν™”λ©΄) μ•ˆμ—μ„œλŠ” 둜그인이 νŒμ—… 창으둜 μ—΄λ¦½λ‹ˆλ‹€." : "";
if (msg) $("#loginErr").textContent = msg;
}
window.addEventListener("message", (ev) => {
if (ev.origin !== location.origin || ev.data?.type !== "tm-oauth") return;
diag("popup", true, "νŒμ—…μ—μ„œ 토큰 λ°›μŒ");
acceptLogin(ev.data.token, ev.data.exp, "oauth-popup", ev.data.orgs);
});
$("#btnPopup").onclick = () => {
const k = b64u.enc(crypto.getRandomValues(new Uint8Array(32)));
// open synchronously inside the click handler so popup blockers allow it
const w = window.open(location.origin + location.pathname + "?popup=1#k=" + k, "tm-login", "width=520,height=720");
if (!w) { diag("popup", false, "νŒμ—…μ΄ 차단됨"); $("#loginErr").textContent = "νŒμ—…μ΄ μ°¨λ‹¨λμŠ΅λ‹ˆλ‹€. νŒμ—…μ„ ν—ˆμš©ν•˜κ±°λ‚˜ ν† ν°μœΌλ‘œ λ‘œκ·ΈμΈν•΄ μ£Όμ„Έμš”."; return; }
diag("popup", null, "νŒμ—… μ—΄λ¦Ό Β· 응닡 κΈ°λ‹€λ¦¬λŠ” 쀑");
$("#loginErr").textContent = ""; $("#loginNote").textContent = "νŒμ—… μ°½μ—μ„œ λ‘œκ·ΈμΈμ„ 마치면 μ—¬κΈ°λ‘œ μžλ™μœΌλ‘œ λ“€μ–΄μ˜΅λ‹ˆλ‹€β€¦";
handoffPoll(k);
};
$("#btnRedirect").onclick = async () => {
try { location.href = await oauthLoginUrl({ scopes: window.huggingface.variables.OAUTH_SCOPES, state: "redirect", redirectUrl: location.origin + location.pathname }); }
catch (e) { $("#loginErr").textContent = "둜그인 μ‹œμž‘ μ‹€νŒ¨: " + e.message; }
};
$("#btnToken").onclick = async () => {
const t = $("#tokenIn").value.trim(); if (!t) return;
$("#loginErr").textContent = "";
if (!(await acceptLogin(t, null, "token"))) { if (!$("#loginErr").textContent) $("#loginErr").textContent = "토큰이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."; }
};
$("#tokenIn").onkeydown = (e) => { if (e.key === "Enter") $("#btnToken").click(); };
$("#btnDeniedLogout").onclick = () => logout();
async function acceptLogin(token, exp, method, oauthOrgs, silent) {
state.token = token;
let u;
try { u = await (await hf("/api/whoami-v2")).json(); }
catch (e) { state.token = null; diag("login", false, method + " μ‹€νŒ¨: " + e.message); if (!silent) $("#loginErr").textContent = "둜그인 확인 μ‹€νŒ¨: " + e.message; else store.del("auth"); return false; }
state.me = { name: u.name, fullname: u.fullname || u.name, avatar: avatarUrl(u.avatarUrl) };
state.loginMethod = method;
diag("login", true, `${method} Β· @${u.name} Β· 토큰 μ’…λ₯˜: ${u.auth?.type === "oauth" ? "OAuth" : u.auth?.accessToken?.role || "?"}`);
// Organization gate. Membership comes from the Hub, never from client-provided claims.
const orgs = (u.orgs || []).map((o) => o.name);
const oauthOrgNames = (oauthOrgs || []).map((o) => o.preferred_username || o.name);
const isMember = orgs.includes(CFG.ORG) || oauthOrgNames.includes(CFG.ORG);
diag("org", isMember, isMember ? `${CFG.ORG} 멀버 확인` : `${CFG.ORG} μ—†μŒ Β· λ³΄μ΄λŠ” 쑰직: ${orgs.join(", ") || "(μ—†μŒ)"}`);
if (!isMember) {
show("deniedView");
$("#deniedDetail").textContent = `@${u.name} Β· 이 둜그인으둜 λ³΄μ΄λŠ” 쑰직: ${orgs.join(", ") || "μ—†μŒ"}` +
(u.auth?.type === "oauth" ? " β€” 둜그인 λ™μ˜ ν™”λ©΄μ—μ„œ 쑰직 접근을 ν—ˆμš©ν•˜μ§€ μ•Šμ•˜μ„ 수 μžˆμŠ΅λ‹ˆλ‹€." : "");
store.del("auth"); return true;
}
store.set("auth", { token, exp, method: method.split(" ")[0] });
await startApp();
return true;
}
function logout() { store.del("auth"); state.token = null; state.me = null; stopTimers(); showLogin(); }
// ───────────────────────────── app
let timers = [];
function stopTimers() { timers.forEach(clearInterval); timers = []; }
async function startApp() {
show("app");
$("#meAv").src = state.me.avatar; $("#meName").textContent = state.me.fullname;
await Promise.all([loadMembers(), loadConversations()]);
const last = store.get("conv");
setView("chats");
if (last && state.convs.has(last)) openConv(last);
stopTimers();
timers.push(setInterval(() => { if (!document.hidden && state.convId) refreshConv(); }, CFG.POLL_CHAT_MS));
timers.push(setInterval(() => { if (!document.hidden) loadConversations(); }, CFG.POLL_LIST_MS));
}
$("#nav").onclick = (e) => { const b = e.target.closest("button[data-nav]"); if (b) setView(b.dataset.nav); };
function setView(v) {
state.view = v;
document.querySelectorAll("#nav button[data-nav]").forEach((b) => b.classList.toggle("on", b.dataset.nav === v));
$("#listPane").classList.toggle("hidden", v === "settings");
$("#chatPane").classList.toggle("hidden", v === "settings");
$("#diagPane").classList.toggle("hidden", v !== "settings");
$("#listTitle").textContent = v === "people" ? "People" : "Chats";
if (v === "settings") renderDiag(); else renderList();
}
$("#search").oninput = () => renderList();
// members: real HF organization members
async function loadMembers() {
try {
const r = await (await hf(`/api/organizations/${CFG.ORG}/members?limit=500`)).json();
state.members = r.map((m) => ({ name: m.user, fullname: m.fullname || m.user, avatar: avatarUrl(m.avatarUrl), role: m.role }));
diag("members", true, `${state.members.length}λͺ…: ${state.members.map((m) => m.name).join(", ")}`);
} catch (e) { diag("members", false, e.message); }
}
const isMe = (name) => !!state.me && String(name).toLowerCase() === state.me.name.toLowerCase();
function member(name) {
const m = state.members.find((x) => x.name.toLowerCase() === String(name).toLowerCase()) ||
(isMe(name) ? { name: state.me.name, fullname: state.me.fullname, avatar: state.me.avatar } : { name, fullname: name, avatar: "" });
return m;
}
// Self-chat ("note to self") is just the DM of a user with themselves: dm__me__me.
const convTitle = (c) => (isMe(c.peer) ? "λ‚˜μ—κ²Œ (λ©”λͺ¨)" : member(c.peer).fullname);
// Deterministic DM id: same pair β†’ same conversation, regardless of who starts it.
function dmId(a, b) { const [x, y] = [a.toLowerCase(), b.toLowerCase()].sort(); return `dm__${x}__${y}`; }
function peerOf(id) { const [, x, y] = id.split("__"); return x === state.me.name.toLowerCase() ? y : x; }
// Storage layout: one immutable JSON file per message β€”
// messages/<conversation_id>/<ISO-time>__<sender>__<rand>.json
// No read-modify-write, so two people sending at once can never overwrite each other.
async function loadConversations() {
let files;
try { files = await Bucket.list("messages"); diag("bucketList", true, `λͺ©λ‘ 읽기 성곡 Β· 파일 ${files.length}개`); }
catch (e) { diag("bucketList", false, "Bucket λͺ©λ‘ μ‹€νŒ¨: " + e.message); return; }
const me = state.me.name.toLowerCase();
const byConv = new Map();
for (const f of files) {
if (f.type !== "file") continue;
const parts = f.path.split("/"); if (parts.length !== 3) continue;
const id = parts[1];
if (!id.startsWith("dm__") || !id.split("__").slice(1).includes(me)) continue; // probe: client-side filter only
(byConv.get(id) || byConv.set(id, []).get(id)).push(f.path);
}
for (const [id, paths] of byConv) {
paths.sort();
const c = state.convs.get(id) || { id, peer: peerOf(id) };
c.files = paths;
const lastPath = paths[paths.length - 1];
if (!c.last || c.last._path !== lastPath) {
try { c.last = await getMsg(lastPath); } catch {}
}
state.convs.set(id, c);
}
if (state.view !== "settings") renderList();
}
async function getMsg(path) {
if (state.msgCache.has(path)) return state.msgCache.get(path);
const m = await Bucket.readJson(path); m._path = path;
state.msgCache.set(path, m); return m;
}
function unread(c) {
const seen = store.get("seen", {})[c.id];
if (!c.files) return 0;
return c.files.filter((p) => (!seen || p > seen) && !p.includes(`__${state.me.name}__`)).length;
}
function relTime(iso) {
const s = (Date.now() - new Date(iso)) / 1000;
if (s < 60) return "방금"; if (s < 3600) return Math.floor(s / 60) + "λΆ„"; if (s < 86400) return Math.floor(s / 3600) + "μ‹œκ°„";
return new Date(iso).toLocaleDateString("ko-KR", { month: "numeric", day: "numeric" });
}
function renderList() {
const q = $("#search").value.trim().toLowerCase();
const body = $("#listBody");
if (state.view === "people") {
const ms = state.members.filter((m) => !q || (m.name + m.fullname + (isMe(m.name) ? "λ‚˜μ—κ²Œ λ©”λͺ¨" : "")).toLowerCase().includes(q))
.sort((a, b) => (isMe(b.name) ? 1 : 0) - (isMe(a.name) ? 1 : 0));
body.innerHTML = ms.length ? ms.map((m) => `<div class="row" data-peer="${esc(m.name)}"><img class="av" src="${esc(m.avatar)}" alt="">
<div class="tx"><div class="nm">${esc(m.fullname)}${isMe(m.name) ? " (λ‚˜)" : ""}</div><div class="lm"><span>${isMe(m.name) ? "λ‚˜μ—κ²Œ 보내기 Β· λ©”λͺ¨" : "@" + esc(m.name) + " Β· " + esc(m.role || "")}</span></div></div></div>`).join("")
: `<div class="empty">검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€</div>`;
return;
}
const convs = [...state.convs.values()].filter((c) => {
if (!q) return true; const m = member(c.peer);
return (m.name + convTitle(c) + (c.last ? preview(c.last) : "")).toLowerCase().includes(q);
}).sort((a, b) => (b.last?.created_at || "").localeCompare(a.last?.created_at || ""));
body.innerHTML = convs.length ? convs.map((c) => {
const m = member(c.peer), un = c.id === state.convId ? 0 : unread(c);
const lm = c.last ? (c.last.sender === state.me.name && !isMe(c.peer) ? "λ‚˜: " : "") + preview(c.last) : "";
return `<div class="row ${c.id === state.convId ? "on" : ""} ${un ? "unread" : ""}" data-conv="${esc(c.id)}"><img class="av" src="${esc(m.avatar)}" alt="">
<div class="tx"><div class="nm">${esc(convTitle(c))}</div><div class="lm"><span>${esc(lm.split("\n")[0])}</span><span>${c.last ? relTime(c.last.created_at) : ""}</span></div></div>
${un ? `<span class="badge">${un}</span>` : ""}</div>`;
}).join("") : `<div class="empty">아직 λŒ€ν™”κ°€ μ—†μŠ΅λ‹ˆλ‹€.<br>Peopleμ—μ„œ νŒ€μ›μ„ 눌러 μ‹œμž‘ν•˜μ„Έμš”.</div>`;
}
$("#listBody").onclick = (e) => {
const r = e.target.closest(".row"); if (!r) return;
if (r.dataset.conv) openConv(r.dataset.conv);
else if (r.dataset.peer) { const id = dmId(state.me.name, r.dataset.peer); if (!state.convs.has(id)) state.convs.set(id, { id, peer: peerOf(id), files: [] }); setView("chats"); openConv(id); }
};
$("#back").onclick = () => { $("#body").classList.remove("inchat"); state.convId = null; renderList(); };
async function openConv(id) {
state.convId = id; state.pending = []; store.set("conv", id);
$("#body").classList.add("inchat");
const c = state.convs.get(id), m = member(c.peer);
$("#cAv").src = m.avatar; $("#cName").textContent = convTitle(c); $("#cSub").textContent = isMe(c.peer) ? "λ‚˜λ§Œ λ³΄λŠ” λ©”λͺ¨ μ±„νŒ…" : "@" + m.name;
$("#msgs").innerHTML = `<div class="empty">λΆˆλŸ¬μ˜€λŠ” 쀑…</div>`;
renderList();
await refreshConv(true);
$("#input").focus();
}
async function refreshConv(force) {
const id = state.convId; if (!id) return;
let files;
try { files = (await Bucket.list("messages/" + id)).filter((f) => f.type === "file").map((f) => f.path).sort(); }
catch (e) { if (force) $("#msgs").innerHTML = `<div class="empty">뢈러였기 μ‹€νŒ¨: ${esc(e.message)}</div>`; return; }
if (state.convId !== id) return;
const c = state.convs.get(id); const changed = force || !c.files || c.files.length !== files.length;
c.files = files;
if (!changed) return;
const msgs = await Promise.all(files.map((p) => getMsg(p).catch(() => null)));
if (state.convId !== id) return;
const real = msgs.filter(Boolean);
if (real.length) c.last = real[real.length - 1];
// drop optimistic entries the server now has
state.pending = state.pending.filter((p) => !real.some((r) => r.id === p.id));
if (files.length) { const seen = store.get("seen", {}); seen[id] = files[files.length - 1]; store.set("seen", seen); }
renderMsgs(real.concat(state.pending));
renderList();
}
// ───────────────────────────── attachments
// Files go to the same private bucket: attachments/<conversation_id>/<time>__<sender>__<name>.
// Private bucket files need the Authorization header, so <img>/<a> can't point at them directly:
// we fetch each one once as a blob and use an object URL.
const IMAGE_EXT = ["png", "jpg", "jpeg", "webp", "gif"];
const extOf = (n) => (n.includes(".") ? n.split(".").pop().toLowerCase() : "");
const isImage = (a) => IMAGE_EXT.includes(extOf(a.name)) || /^image\/(png|jpe?g|webp|gif)$/.test(a.mime || "");
const fmtSize = (n) => (n < 1024 ? n + " B" : n < 1048576 ? (n / 1024).toFixed(1) + " KB" : n < 1073741824 ? (n / 1048576).toFixed(1) + " MB" : (n / 1073741824).toFixed(2) + " GB");
const safeName = (n) => (n || "file").replace(/[\\/:*?"<>|#%]+/g, "_").replace(/\s+/g, " ").trim().slice(0, 120);
const blobUrls = new Map(); // path -> Promise<objectURL>
function attUrl(path) {
if (!blobUrls.has(path)) {
const pr = hf(`/buckets/${CFG.BUCKET}/resolve/${path.split("/").map(encodeURIComponent).join("/")}`)
.then((r) => r.blob()).then((b) => URL.createObjectURL(b));
pr.catch(() => blobUrls.delete(path));
blobUrls.set(path, pr);
}
return blobUrls.get(path);
}
function attHtml(a, local) {
if (isImage(a)) return `<img class="att-img" data-path="${esc(a.path || "")}" ${local ? `src="${esc(local)}"` : ""} alt="${esc(a.name)}" title="${esc(a.name)}">`;
return `<a class="att-file" data-path="${esc(a.path || "")}" data-name="${esc(a.name)}"><span class="ic">${esc((extOf(a.name) || "FILE").slice(0, 4).toUpperCase())}</span>
<span style="min-width:0"><div class="fn">${esc(a.name)}</div><div class="fs">${fmtSize(a.size)}</div></span></a>`;
}
const preview = (m) => (m.content ? m.content : m.attachments?.length ? (m.attachments.every(isImage) ? "πŸ“· 사진" : "πŸ“Ž " + m.attachments[0].name) + (m.attachments.length > 1 ? ` μ™Έ ${m.attachments.length - 1}개` : "") : "");
function renderMsgs(msgs) {
const box = $("#msgs"); const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
if (!msgs.length) { box.innerHTML = `<div class="empty">첫 λ©”μ‹œμ§€λ₯Ό 보내 λ³΄μ„Έμš”.</div>`; return; }
let html = "", prevDay = "", prev = null;
for (const m of msgs) {
const d = new Date(m.created_at), day = d.toDateString();
if (day !== prevDay) { html += `<div class="day">${d.toLocaleDateString("ko-KR", { month: "long", day: "numeric", weekday: "short" })}</div>`; prevDay = day; prev = null; }
const mine = m.sender === state.me.name, cont = prev && prev.sender === m.sender && d - new Date(prev.created_at) < 120000;
const who = member(m.sender);
const atts = m.attachments || [];
const onlyMedia = !m.content && atts.length && atts.every(isImage);
const body = (m.content ? `<div class="txt">${esc(m.content)}</div>` : "") +
atts.map((a, i) => (i || m.content ? `<div class="att-gap"></div>` : "") + attHtml(a, m._local?.[i])).join("");
html += `<div class="m ${mine ? "mine" : ""} ${cont ? "cont" : ""} ${m._pending ? "pending" : ""} ${m._failed ? "failed" : ""}">
${mine ? "" : `<div class="avs">${cont ? "" : `<img class="av sm" src="${esc(who.avatar)}" alt="">`}</div>`}
<div class="col">${!mine && !cont ? `<div class="who">${esc(who.fullname)}</div>` : ""}<div class="bub ${onlyMedia ? "media" : ""}">${body}</div></div>
<div class="tm">${m._failed ? "전솑 μ‹€νŒ¨" : m._pending ? (atts.length ? "μ˜¬λ¦¬λŠ” 쀑" : "λ³΄λ‚΄λŠ” 쀑") : d.toLocaleTimeString("ko-KR", { hour: "numeric", minute: "2-digit" })}</div></div>`;
prev = m;
}
box.innerHTML = html;
box.querySelectorAll("img.att-img[data-path]").forEach((img) => {
if (img.getAttribute("src") || !img.dataset.path) return;
attUrl(img.dataset.path).then((u) => { img.src = u; if (atBottom) box.scrollTop = box.scrollHeight; }, () => { img.alt = "⚠ 이미지λ₯Ό λΆˆλŸ¬μ˜€μ§€ λͺ»ν•¨"; });
});
if (atBottom || msgs.some((m) => m._pending)) box.scrollTop = box.scrollHeight;
}
$("#msgs").onclick = async (e) => {
const img = e.target.closest("img.att-img");
if (img && img.src) { $("#lightbox img").src = img.src; $("#lightbox").classList.remove("hidden"); return; }
const f = e.target.closest(".att-file");
if (f && f.dataset.path) {
toast("λ‚΄λ €λ°›λŠ” 쀑…", 60000);
try { const u = await attUrl(f.dataset.path); const a = document.createElement("a"); a.href = u; a.download = f.dataset.name; a.click(); toast("λ‹€μš΄λ‘œλ“œ: " + f.dataset.name); }
catch (er) { toast("λ‹€μš΄λ‘œλ“œ μ‹€νŒ¨: " + er.message, 5000); }
}
};
$("#lightbox").onclick = () => $("#lightbox").classList.add("hidden");
const input = $("#input");
input.oninput = () => { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 140) + "px"; $("#sendBtn").disabled = !input.value.trim(); };
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); } };
$("#sendBtn").onclick = () => send();
$("#attachBtn").onclick = () => { if (state.convId) $("#filePick").click(); else toast("λ¨Όμ € λŒ€ν™”λ₯Ό μ—¬μ„Έμš”"); };
$("#filePick").onchange = (e) => { const fs = [...e.target.files]; e.target.value = ""; if (fs.length) send(fs); };
input.addEventListener("paste", (e) => { const fs = [...(e.clipboardData?.files || [])]; if (fs.length) { e.preventDefault(); send(fs); } });
const chatPane = $("#chatPane");
chatPane.addEventListener("dragover", (e) => { if (state.convId && e.dataTransfer?.types?.includes("Files")) { e.preventDefault(); chatPane.classList.add("dropping"); } });
chatPane.addEventListener("dragleave", (e) => { if (!chatPane.contains(e.relatedTarget)) chatPane.classList.remove("dropping"); });
chatPane.addEventListener("drop", (e) => { e.preventDefault(); chatPane.classList.remove("dropping"); const fs = [...(e.dataTransfer?.files || [])]; if (fs.length && state.convId) send(fs); });
async function send(files = []) {
const text = input.value.trim(); if ((!text && !files.length) || !state.convId) return;
input.value = ""; input.oninput();
const convId = state.convId, now = new Date(), stamp = now.toISOString().replace(/[:.]/g, "-");
const attachments = files.map((f) => ({
name: f.name || "image.png", size: f.size, mime: f.type || "",
path: `attachments/${convId}/${stamp}__${state.me.name}__${safeName(f.name || "image.png")}`,
}));
const msg = {
id: "msg_" + now.getTime().toString(36) + Math.random().toString(36).slice(2, 8),
conversation_id: convId, sender: state.me.name,
type: !attachments.length ? "text" : attachments.every(isImage) ? "image" : "file",
content: text, created_at: now.toISOString(), reply_to: null, attachments, reactions: {}, read_by: [state.me.name],
};
const path = `messages/${convId}/${stamp}__${state.me.name}__${msg.id.slice(-6)}.json`;
const pend = { ...msg, _pending: true, _local: files.map((f, i) => (isImage(attachments[i]) ? URL.createObjectURL(f) : null)) };
state.pending.push(pend);
const c = state.convs.get(convId);
const rerender = () => { if (state.convId === convId) renderMsgs((c.files || []).map((p) => state.msgCache.get(p)).filter(Boolean).concat(state.pending)); };
rerender();
const t0 = performance.now();
try {
if (files.length) {
const total = files.reduce((s, f) => s + f.size, 0);
toast(`μ˜¬λ¦¬λŠ” 쀑… ${files.length}개 Β· ${fmtSize(total)}`, 600000);
await uploadFiles({
repo: { type: "bucket", name: CFG.BUCKET }, accessToken: state.token, useWebWorkers: false,
files: files.map((f, i) => ({ path: attachments[i].path, content: f })),
});
// reuse the local blob for our own images so they don't need a re-download
attachments.forEach((a, i) => { if (pend._local[i]) blobUrls.set(a.path, Promise.resolve(pend._local[i])); });
diag("attach", true, `첨뢀 ${files.length}개 Β· ${fmtSize(total)} Β· ${Math.round(performance.now() - t0)} ms`);
toast("첨뢀 μ™„λ£Œ");
}
await Bucket.writeJson(path, msg);
diag("bucketWrite", true, `λ©”μ‹œμ§€ μ €μž₯ 성곡 Β· ${Math.round(performance.now() - t0)} ms`);
store.set("lastSent", { path, id: msg.id, at: msg.created_at });
await refreshConv(true);
} catch (e) {
pend._failed = true; pend._pending = false;
diag(files.length ? "attach" : "bucketWrite", false, "μ €μž₯ μ‹€νŒ¨: " + e.message);
rerender();
toast("전솑 μ‹€νŒ¨: " + e.message, 6000);
}
}
// ───────────────────────────── Settings Β· 진단 (feasibility checklist)
const CHECKS = [
["iframe", "μ‹€ν–‰ μœ„μΉ˜"], ["storage", "λΈŒλΌμš°μ € μ €μž₯μ†Œ"], ["oauthcfg", "OAuth μ„€μ •"], ["popup", "νŒμ—… 둜그인"],
["handoffDel", "handoff 정리"], ["login", "둜그인"], ["org", "쑰직 멀버 확인"], ["members", "νŒ€μ› λͺ©λ‘"], ["bucketList", "Bucket 읽기"],
["bucketWrite", "Bucket μ“°κΈ°"], ["attach", "파일 첨뢀"], ["persist", "μƒˆλ‘œκ³ μΉ¨ ν›„ μœ μ§€"],
];
async function checkPersist() {
const ls = store.get("lastSent");
if (!ls) return diag("persist", null, "아직 보낸 λ©”μ‹œμ§€ μ—†μŒ β€” λ©”μ‹œμ§€λ₯Ό ν•˜λ‚˜ 보낸 λ’€ μƒˆλ‘œκ³ μΉ¨ν•˜κ³  λ‹€μ‹œ λ³΄μ„Έμš”");
try { const m = await Bucket.readJson(ls.path); diag("persist", m.id === ls.id, m.id === ls.id ? `λ§ˆμ§€λ§‰μœΌλ‘œ 보낸 λ©”μ‹œμ§€(${new Date(ls.at).toLocaleTimeString("ko-KR")})κ°€ Bucket에 있음` : "λ‹€λ₯Έ λ‚΄μš©μ΄ 읽힘"); }
catch (e) { diag("persist", false, "λ§ˆμ§€λ§‰ λ©”μ‹œμ§€λ₯Ό λͺ» 읽음: " + e.message); }
}
function renderDiag() {
const mark = (v) => (v === true ? `<span class="ok">βœ”</span>` : v === false ? `<span class="bad">✘</span>` : `<span class="warn">…</span>`);
$("#diagBody").innerHTML = `<h2>Settings Β· 진단</h2>
<div style="color:var(--dim);font-size:12.5px">이 μ‹œν—˜νŒμœΌλ‘œ HF만으둜 λ˜λŠ”μ§€ ν™•μΈν•˜λŠ” ν•­λͺ©μž…λ‹ˆλ‹€. λ¬Έμ œκ°€ 있으면 [κ²°κ³Ό 볡사]둜 보내 μ£Όμ„Έμš”.</div>
<table>${CHECKS.map(([k, label]) => `<tr><td>${DIAG[k] ? mark(DIAG[k].ok) : "Β·"}</td><td>${label}</td><td>${esc(DIAG[k]?.detail || "아직 확인 μ•ˆ 함")}</td></tr>`).join("")}</table>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn" id="dPersist">μƒˆλ‘œκ³ μΉ¨ ν›„ μœ μ§€ 확인</button>
<button class="btn" id="dCopy">κ²°κ³Ό 볡사</button>
<button class="btn" id="dTheme">ν…Œλ§ˆ μ „ν™˜</button>
<button class="btn" id="dLogout">λ‘œκ·Έμ•„μ›ƒ</button>
</div>
<div class="note" style="margin-top:14px;max-width:760px">μ‹œν—˜νŒ ν•œκ³„: μ„œλ²„κ°€ μ—†μ–΄μ„œ λΈŒλΌμš°μ €κ°€ λ‘œκ·ΈμΈν•œ μ‚¬λžŒμ˜ ν† ν°μœΌλ‘œ Bucket에 직접 μ ‘κ·Όν•©λ‹ˆλ‹€.
κ·Έλž˜μ„œ <b>DM λΉ„κ³΅κ°œκ°€ 보μž₯λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€</b>(쑰직원은 μ €μž₯μ†Œλ₯Ό 직접 μ—΄λ©΄ λͺ¨λ“  λŒ€ν™”λ₯Ό λ³Ό 수 있음). λ³Έ μ œν’ˆ(Docker Space + FastAPI)μ—μ„œλŠ” μ„œλ²„κ°€ λŒ€ν™” μ°Έμ—¬μžλ§Œ 읽게 λ§‰μŠ΅λ‹ˆλ‹€.</div>`;
$("#dPersist").onclick = checkPersist;
$("#dCopy").onclick = async () => {
const txt = CHECKS.map(([k, l]) => `${DIAG[k] ? (DIAG[k].ok === true ? "OK" : DIAG[k].ok === false ? "FAIL" : "..") : "-"}\t${l}\t${DIAG[k]?.detail || ""}`).join("\n") + `\nUA: ${navigator.userAgent}`;
try { await navigator.clipboard.writeText(txt); toast("λ³΅μ‚¬ν–ˆμŠ΅λ‹ˆλ‹€"); } catch { prompt("λ³΅μ‚¬ν•˜μ„Έμš”", txt); }
};
$("#dTheme").onclick = () => { const d = document.documentElement; d.dataset.theme = (d.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")) === "dark" ? "light" : "dark"; };
$("#dLogout").onclick = logout;
}
document.addEventListener("visibilitychange", () => { if (!document.hidden && state.convId) refreshConv(); });
boot().then(() => { if (state.me) checkPersist(); });
</script>
</body>
</html>