5c7a5bef1e
- 카테고리 DB를 type 기반(doc/img/vid/zip/code)으로 마이그레이션 - 사이드바·상단탭이 DB 카테고리 API를 공유해 항상 일치 - 기본 뷰를 그리드 → 리스트로 변경 - 로그인 버튼 제거, 관리자만 로그인 가능 - 관리자 패널에서 사용자 관리 메뉴 제거 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1563 lines
83 KiB
HTML
1563 lines
83 KiB
HTML
<!doctype html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>자료실</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
|
||
<style>
|
||
:root{
|
||
--bg: #f8f8f6;
|
||
--surface: #ffffff;
|
||
--surface-2: #f1f1ee;
|
||
--border: rgba(20,20,20,.08);
|
||
--border-strong: rgba(20,20,20,.14);
|
||
--ink: #1a1a1a;
|
||
--ink-2: #3a3a37;
|
||
--muted: #6b6b66;
|
||
--faint: #9a9a93;
|
||
--accent: #3b5bdb;
|
||
--accent-soft: #eef1ff;
|
||
--warn: #d97757;
|
||
--ok: #1f8a5b;
|
||
--radius: 8px;
|
||
--radius-lg: 12px;
|
||
--shadow-sm: 0 1px 0 rgba(20,20,20,.04), 0 1px 2px rgba(20,20,20,.04);
|
||
--shadow-md: 0 6px 20px rgba(20,20,20,.06), 0 2px 4px rgba(20,20,20,.04);
|
||
--shadow-lg: 0 24px 60px rgba(20,20,20,.16), 0 6px 16px rgba(20,20,20,.08);
|
||
--font-sans: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;padding:0;height:100%;background:var(--bg);color:var(--ink);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
||
body{font-size:14px;letter-spacing:-0.005em}
|
||
button{font-family:inherit;letter-spacing:inherit}
|
||
::selection{background:var(--accent-soft);color:var(--accent)}
|
||
|
||
.app{display:grid;grid-template-columns:248px 1fr;height:100vh;overflow:hidden;background:var(--bg)}
|
||
|
||
/* SIDEBAR */
|
||
.sb{display:flex;flex-direction:column;gap:0;padding:14px 10px;border-right:1px solid var(--border);background:var(--bg);overflow-y:auto;min-height:0}
|
||
.sb-brand{display:flex;align-items:center;gap:10px;padding:6px 8px 14px;color:var(--ink)}
|
||
.sb-brand-mark{width:26px;height:26px;border-radius:7px;background:var(--ink);display:grid;place-items:center;color:#fff;font-weight:700;font-size:12px;letter-spacing:-.02em;flex-shrink:0}
|
||
.sb-brand-name{font-weight:600;font-size:15px;letter-spacing:-.01em}
|
||
.sb-brand-sub{font-size:11px;color:var(--muted);font-family:var(--font-mono);margin-top:1px}
|
||
.sb-search{display:flex;align-items:center;gap:8px;padding:7px 10px;background:var(--surface-2);border-radius:7px;margin:2px 4px 12px;color:var(--muted)}
|
||
.sb-search input{flex:1;border:0;background:transparent;outline:none;font-size:13px;color:var(--ink);min-width:0}
|
||
.sb-search input::placeholder{color:var(--faint)}
|
||
.sb-search kbd{font-family:var(--font-mono);font-size:10px;color:var(--faint);background:rgba(20,20,20,.04);border:1px solid var(--border);border-radius:4px;padding:1px 5px}
|
||
.sb-section{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--faint);padding:14px 10px 6px;display:flex;align-items:center;justify-content:space-between}
|
||
.sb-section button{appearance:none;border:0;background:transparent;color:var(--faint);width:18px;height:18px;border-radius:4px;cursor:default;display:grid;place-items:center;font-size:14px}
|
||
.sb-section button:hover{background:rgba(20,20,20,.06);color:var(--ink)}
|
||
.sb-item{display:flex;align-items:center;gap:9px;padding:6px 10px;border-radius:6px;color:var(--ink-2);cursor:default;font-size:13.5px;line-height:1;user-select:none;position:relative}
|
||
.sb-item:hover{background:rgba(20,20,20,.04)}
|
||
.sb-item.active{background:rgba(20,20,20,.06);color:var(--ink);font-weight:500}
|
||
.sb-item .ico{width:16px;height:16px;flex-shrink:0;color:var(--muted)}
|
||
.sb-item.active .ico{color:var(--accent)}
|
||
.sb-item .lbl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.sb-item .cnt{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums;font-family:var(--font-mono)}
|
||
.sb-storage{margin:auto 4px 4px;padding:12px;border-radius:9px;background:var(--surface-2);border:1px solid var(--border)}
|
||
.sb-storage-row{display:flex;justify-content:space-between;align-items:baseline;font-size:12px;color:var(--muted);margin-bottom:8px}
|
||
.sb-storage-row b{color:var(--ink);font-weight:600;font-family:var(--font-mono);font-size:11.5px}
|
||
.sb-bar{height:4px;background:rgba(20,20,20,.08);border-radius:99px;overflow:hidden}
|
||
.sb-bar-fill{height:100%;background:var(--accent);border-radius:99px;transition:width .4s ease}
|
||
.sb-storage-cap{font-size:11px;color:var(--faint);margin-top:8px;font-family:var(--font-mono)}
|
||
|
||
/* MAIN */
|
||
.main{display:flex;flex-direction:column;min-width:0;background:var(--bg);overflow:hidden}
|
||
.hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 28px;border-bottom:1px solid var(--border);background:var(--bg);flex-shrink:0}
|
||
.hdr-l{display:flex;align-items:center;gap:14px;min-width:0}
|
||
.crumbs{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted)}
|
||
.crumbs a{color:var(--muted);text-decoration:none;cursor:default;padding:3px 6px;border-radius:5px}
|
||
.crumbs a:hover{background:rgba(20,20,20,.04);color:var(--ink)}
|
||
.crumbs .sep{color:var(--faint);font-size:11px}
|
||
.crumbs .here{color:var(--ink);font-weight:500;padding:3px 6px}
|
||
.hdr-r{display:flex;align-items:center;gap:8px}
|
||
|
||
.btn{appearance:none;border:1px solid var(--border-strong);background:var(--surface);color:var(--ink);font-size:13px;font-weight:500;padding:7px 12px;border-radius:7px;cursor:default;display:inline-flex;align-items:center;gap:6px;line-height:1;transition:background .15s ease,border-color .15s ease}
|
||
.btn:hover{background:var(--surface-2)}
|
||
.btn-ghost{border-color:transparent;background:transparent;color:var(--ink-2)}
|
||
.btn-ghost:hover{background:rgba(20,20,20,.05);color:var(--ink)}
|
||
.btn-icon{padding:7px;width:32px;height:32px;justify-content:center}
|
||
.btn-primary{background:var(--ink);color:#fff;border-color:var(--ink)}
|
||
.btn-primary:hover{background:#000}
|
||
.btn-accent{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
.btn-accent:hover{filter:brightness(1.05)}
|
||
|
||
/* Toolbar */
|
||
.toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:14px 28px 8px;flex-shrink:0}
|
||
.toolbar-l{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.toolbar-r{display:flex;align-items:center;gap:6px}
|
||
.pill{display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border-radius:99px;font-size:12.5px;color:var(--ink-2);background:transparent;border:1px solid transparent;cursor:default}
|
||
.pill:hover{background:rgba(20,20,20,.04)}
|
||
.pill.on{background:var(--ink);color:#fff;border-color:var(--ink)}
|
||
.pill .x{opacity:.6;margin-left:2px}
|
||
.seg{display:inline-flex;background:var(--surface-2);border-radius:7px;padding:2px;border:1px solid var(--border)}
|
||
.seg button{appearance:none;border:0;background:transparent;color:var(--muted);padding:5px 8px;border-radius:5px;cursor:default;display:grid;place-items:center}
|
||
.seg button.on{background:var(--surface);color:var(--ink);box-shadow:var(--shadow-sm)}
|
||
.sortmenu{position:relative}
|
||
.menu{position:absolute;top:calc(100% + 4px);right:0;background:var(--surface);border:1px solid var(--border-strong);border-radius:9px;box-shadow:var(--shadow-md);padding:4px;min-width:180px;z-index:50}
|
||
.menu .mi{padding:7px 10px;border-radius:5px;font-size:13px;color:var(--ink-2);display:flex;align-items:center;justify-content:space-between;cursor:default}
|
||
.mi:hover{background:var(--surface-2)}
|
||
.mi .check{color:var(--accent);opacity:0}
|
||
.mi.on .check{opacity:1}
|
||
|
||
.scroll{flex:1;overflow-y:auto;padding:6px 28px 28px;min-height:0}
|
||
.sec-h{display:flex;align-items:center;justify-content:space-between;margin:18px 0 10px;padding:0 2px}
|
||
.sec-h h3{margin:0;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
||
.sec-h .right{font-size:12px;color:var(--faint);font-family:var(--font-mono)}
|
||
|
||
/* GRID */
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
|
||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden;cursor:default;transition:border-color .15s ease,box-shadow .15s ease;display:flex;flex-direction:column;position:relative}
|
||
.card:hover{border-color:var(--border-strong);box-shadow:var(--shadow-sm)}
|
||
.card.sel{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||
.card .thumb{aspect-ratio:4/3;background:var(--surface-2);display:grid;place-items:center;position:relative;overflow:hidden;border-bottom:1px solid var(--border)}
|
||
.card .thumb .ext{font-family:var(--font-mono);font-size:11px;color:#fff;background:var(--ink);padding:3px 7px;border-radius:4px;position:absolute;top:8px;left:8px;letter-spacing:.02em;font-weight:500}
|
||
.card .meta{padding:10px 12px 12px;display:flex;flex-direction:column;gap:4px;flex:1}
|
||
.card .name{font-size:13.5px;font-weight:500;color:var(--ink);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;text-overflow:ellipsis;word-break:break-all}
|
||
.card .sub{font-size:11.5px;color:var(--muted);font-family:var(--font-mono);display:flex;justify-content:space-between;align-items:center;margin-top:2px}
|
||
.card .star{position:absolute;top:8px;right:8px;width:26px;height:26px;border-radius:6px;background:rgba(255,255,255,.85);backdrop-filter:blur(8px);display:grid;place-items:center;color:var(--faint);cursor:default;opacity:0;transition:opacity .15s ease}
|
||
.card:hover .star,.card .star.on{opacity:1}
|
||
.card .star.on{color:#e8a317}
|
||
|
||
.tn-doc{background:linear-gradient(180deg,#fafaf7,#eeece4)}
|
||
.tn-img{background:repeating-linear-gradient(45deg,#e8e6dd 0 8px,#efede4 8px 16px)}
|
||
.tn-vid{background:linear-gradient(135deg,#23252b,#0d0e12);color:#fff}
|
||
.tn-zip{background:repeating-linear-gradient(0deg,#f1ede0 0 6px,#e8e2cf 6px 12px)}
|
||
.tn-code{background:#0f1115;color:#a3b8ff;font-family:var(--font-mono);font-size:10px;padding:10px;text-align:left;align-items:flex-start;justify-content:flex-start;line-height:1.5;overflow:hidden}
|
||
.doc-page{width:55%;height:78%;background:#fff;border:1px solid rgba(20,20,20,.1);border-radius:3px;box-shadow:0 4px 16px rgba(20,20,20,.06);padding:8px;display:flex;flex-direction:column;gap:3px}
|
||
.doc-page i{height:2px;background:#dcdac8;border-radius:1px;display:block}
|
||
.doc-page i:nth-child(1){width:60%;background:#bfbcaa;height:3px;margin-bottom:3px}
|
||
.doc-page i:nth-child(odd){width:90%}
|
||
.doc-page i:nth-child(even){width:75%}
|
||
.vid-play{width:38px;height:38px;border-radius:50%;background:rgba(255,255,255,.18);backdrop-filter:blur(8px);display:grid;place-items:center;border:1.5px solid rgba(255,255,255,.5)}
|
||
.vid-play::after{content:"";border:7px solid transparent;border-left:11px solid #fff;margin-left:3px}
|
||
.img-icon{width:42%;height:55%;background:#fff;border-radius:4px;border:1px solid rgba(20,20,20,.08);display:grid;place-items:center;color:#bfbcaa;font-family:var(--font-mono);font-size:9px;letter-spacing:.04em}
|
||
.zip-icon{width:38%;height:50%;background:#fff;border-radius:4px;border:1px solid rgba(20,20,20,.08);position:relative;display:grid;place-items:center}
|
||
.zip-icon::before{content:"";position:absolute;left:50%;top:0;bottom:0;width:6px;background:repeating-linear-gradient(0deg,#bfbcaa 0 4px,transparent 4px 8px);transform:translateX(-50%)}
|
||
|
||
/* LIST */
|
||
.list{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
||
.list-head,.list-row{display:grid;grid-template-columns:32px 1fr 100px 130px 130px 70px 32px;gap:14px;align-items:center;padding:10px 16px}
|
||
.list-head{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--faint);border-bottom:1px solid var(--border);background:var(--surface-2)}
|
||
.list-row{border-bottom:1px solid var(--border);font-size:13.5px;color:var(--ink-2);cursor:default;transition:background .12s ease}
|
||
.list-row:last-child{border-bottom:0}
|
||
.list-row:hover{background:var(--surface-2)}
|
||
.list-row.sel{background:var(--accent-soft)}
|
||
.list-name{display:flex;align-items:center;gap:10px;min-width:0}
|
||
.list-name .ficon{width:28px;height:28px;border-radius:5px;background:var(--surface-2);display:grid;place-items:center;font-family:var(--font-mono);font-size:9px;font-weight:600;color:var(--muted);flex-shrink:0;text-transform:uppercase;letter-spacing:.02em}
|
||
.list-name .ficon.doc{background:#fef4e6;color:#b56b18}
|
||
.list-name .ficon.img{background:#e8f4ee;color:#1f7a52}
|
||
.list-name .ficon.vid{background:#1a1a1a;color:#fff}
|
||
.list-name .ficon.zip{background:#f3eed9;color:#7a6418}
|
||
.list-name .ficon.code{background:#0f1115;color:#a3b8ff}
|
||
.list-name .ftxt{min-width:0;display:flex;flex-direction:column}
|
||
.list-name .ftxt .nm{font-weight:500;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.list-name .ftxt .pth{font-size:11px;color:var(--faint);font-family:var(--font-mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.list-meta{font-family:var(--font-mono);font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||
.check{width:16px;height:16px;border-radius:4px;border:1.5px solid var(--border-strong);display:grid;place-items:center;cursor:default;color:transparent;background:var(--surface);transition:all .12s ease}
|
||
.check:hover{border-color:var(--ink-2)}
|
||
.check.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
||
.iconbtn{appearance:none;border:0;background:transparent;color:var(--faint);width:28px;height:28px;border-radius:5px;cursor:default;display:grid;place-items:center}
|
||
.iconbtn:hover{background:rgba(20,20,20,.06);color:var(--ink)}
|
||
|
||
/* Drop zone */
|
||
.dropzone{position:absolute;inset:0;background:rgba(59,91,219,.06);backdrop-filter:blur(2px);border:2px dashed var(--accent);border-radius:18px;margin:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;pointer-events:none;z-index:200;opacity:0;transition:opacity .15s ease}
|
||
.dropzone.on{opacity:1}
|
||
.dropzone .t{font-size:18px;font-weight:600;color:var(--accent)}
|
||
.dropzone .s{font-size:13px;color:var(--ink-2);font-family:var(--font-mono)}
|
||
|
||
/* Selection bar */
|
||
.selbar{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--ink);color:#fff;padding:8px 8px 8px 18px;border-radius:99px;display:flex;align-items:center;gap:14px;box-shadow:var(--shadow-lg);z-index:100;font-size:13.5px}
|
||
.selbar .selcount{font-weight:500}
|
||
.selbar .selbtns{display:flex;gap:4px}
|
||
.selbar button{appearance:none;border:0;background:rgba(255,255,255,.08);color:#fff;width:32px;height:32px;border-radius:99px;cursor:default;display:grid;place-items:center}
|
||
.selbar button:hover{background:rgba(255,255,255,.18)}
|
||
.selbar .x{background:rgba(255,255,255,.06)}
|
||
|
||
/* Modal */
|
||
.modal-bg{position:fixed;inset:0;background:rgba(20,20,22,.5);backdrop-filter:blur(4px);display:grid;place-items:center;z-index:1000;animation:fadeIn .15s ease}
|
||
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||
@keyframes slideUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
||
.modal{background:var(--surface);border-radius:14px;box-shadow:var(--shadow-lg);width:min(720px,92vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;animation:slideUp .2s ease}
|
||
.modal-h{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)}
|
||
.modal-h h3{margin:0;font-size:15px;font-weight:600}
|
||
.modal-b{padding:20px;overflow-y:auto}
|
||
.updz{border:2px dashed var(--border-strong);border-radius:12px;padding:36px 20px;text-align:center;color:var(--muted);transition:all .15s ease;background:var(--surface-2)}
|
||
.updz.on{border-color:var(--accent);background:var(--accent-soft);color:var(--accent)}
|
||
.updz .t{font-size:15px;font-weight:600;color:var(--ink);margin-bottom:4px}
|
||
.updz .s{font-size:12.5px;color:var(--muted);font-family:var(--font-mono)}
|
||
.updz button{margin-top:14px}
|
||
.uplist{margin-top:14px;display:flex;flex-direction:column;gap:8px}
|
||
.uprow{display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--surface-2);border-radius:8px}
|
||
.uprow .ficon{width:32px;height:32px;border-radius:6px;background:var(--surface);display:grid;place-items:center;font-family:var(--font-mono);font-size:9px;font-weight:600;color:var(--muted);flex-shrink:0}
|
||
.uprow .info{flex:1;min-width:0}
|
||
.uprow .nm{font-size:13px;font-weight:500;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.uprow .progbar{height:3px;background:rgba(20,20,20,.08);border-radius:99px;margin-top:5px;overflow:hidden}
|
||
.uprow .progfill{height:100%;background:var(--accent);transition:width .3s ease}
|
||
.uprow .pct{font-family:var(--font-mono);font-size:11px;color:var(--muted);min-width:36px;text-align:right}
|
||
|
||
/* Preview modal */
|
||
.preview-modal{width:min(960px,94vw);max-height:90vh}
|
||
.preview-body{display:grid;grid-template-columns:1fr 280px;min-height:480px;max-height:calc(90vh - 60px)}
|
||
.preview-stage{background:#1a1a1a;display:grid;place-items:center;padding:30px;overflow:hidden;position:relative}
|
||
.preview-stage .ph{width:100%;height:100%;max-width:520px;max-height:480px;background:#fff;border-radius:6px;box-shadow:0 20px 60px rgba(0,0,0,.4);display:flex;flex-direction:column;padding:32px;gap:8px}
|
||
.preview-stage .ph .h{font-size:18px;font-weight:600;color:#1a1a1a;margin-bottom:8px}
|
||
.preview-stage .ph i{height:6px;background:#e6e4d8;border-radius:2px;display:block}
|
||
.preview-stage .ph i:nth-child(odd){width:88%}
|
||
.preview-stage .ph i:nth-child(even){width:72%}
|
||
.preview-stage .ph i.short{width:40%}
|
||
.preview-info{padding:24px;display:flex;flex-direction:column;gap:18px;overflow-y:auto;background:var(--surface)}
|
||
.preview-info h2{margin:0;font-size:15px;font-weight:600;line-height:1.4;word-break:break-all}
|
||
.preview-info .infogrp{display:flex;flex-direction:column;gap:7px}
|
||
.preview-info .infogrp .lbl{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--faint)}
|
||
.preview-info .row{display:flex;justify-content:space-between;font-size:13px;color:var(--ink-2);font-family:var(--font-mono)}
|
||
.preview-info .row span:last-child{color:var(--ink);font-variant-numeric:tabular-nums}
|
||
.preview-info .actions{display:flex;flex-direction:column;gap:6px;margin-top:auto}
|
||
|
||
.tag{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:99px;font-size:11.5px;background:var(--surface-2);color:var(--muted);border:1px solid var(--border)}
|
||
.tag .dot{width:6px;height:6px;border-radius:50%}
|
||
.tag.public .dot{background:var(--ok)}
|
||
.tag.team .dot{background:var(--accent)}
|
||
.tag.private .dot{background:var(--warn)}
|
||
|
||
/* Skeleton */
|
||
.skeleton-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}
|
||
.skeleton-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
||
.skeleton-thumb{aspect-ratio:4/3;background:var(--surface-2);animation:pulse 1.4s ease infinite}
|
||
.skeleton-meta{padding:10px 12px 12px;display:flex;flex-direction:column;gap:8px}
|
||
.skeleton-line{height:10px;background:var(--surface-2);border-radius:4px;animation:pulse 1.4s ease infinite}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
||
|
||
/* Empty */
|
||
.empty{padding:60px 20px;text-align:center;color:var(--muted)}
|
||
.empty svg{width:42px;height:42px;color:var(--faint);margin-bottom:12px}
|
||
.empty .t{font-size:15px;color:var(--ink);font-weight:500;margin-bottom:4px}
|
||
.empty .s{font-size:13px;color:var(--muted)}
|
||
|
||
/* Toast */
|
||
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%) translateY(20px);background:var(--ink);color:#fff;padding:10px 18px;border-radius:99px;font-size:13px;display:flex;align-items:center;gap:10px;z-index:2000;box-shadow:var(--shadow-lg);opacity:0;transition:all .25s cubic-bezier(.2,.8,.2,1);pointer-events:none}
|
||
.toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
||
.toast .ck{width:18px;height:18px;background:var(--ok);border-radius:50%;display:grid;place-items:center;color:#fff;font-size:11px}
|
||
|
||
.hidden{display:none !important}
|
||
.scrollbar::-webkit-scrollbar{width:8px}
|
||
.scrollbar::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:99px}
|
||
|
||
/* Hamburger */
|
||
.hamburger{display:none;appearance:none;border:0;background:transparent;color:var(--ink);width:32px;height:32px;border-radius:6px;cursor:default;place-items:center;flex-shrink:0;margin-right:2px}
|
||
.hamburger:hover{background:rgba(20,20,20,.06)}
|
||
|
||
/* Type nav tabs */
|
||
.hdr-tabs{display:flex;align-items:flex-end;gap:0;padding:0 28px;border-bottom:1px solid var(--border);background:var(--bg);flex-shrink:0;overflow-x:auto}
|
||
.hdr-tabs::-webkit-scrollbar{display:none}
|
||
.hdr-tab{padding:9px 14px;font-size:13.5px;font-family:inherit;color:var(--muted);border:none;background:transparent;border-bottom:2px solid transparent;cursor:default;white-space:nowrap;font-weight:500;line-height:1;transition:color .12s ease;margin-bottom:-1px}
|
||
.hdr-tab:hover{color:var(--ink-2)}
|
||
.hdr-tab.active{color:var(--ink);font-weight:600;border-bottom-color:var(--accent)}
|
||
|
||
/* Context menu */
|
||
.ctx-menu{position:fixed;background:var(--surface);border:1px solid var(--border-strong);border-radius:9px;box-shadow:var(--shadow-md);padding:4px;min-width:160px;z-index:500}
|
||
.ctx-mi{padding:7px 10px;border-radius:5px;font-size:13px;color:var(--ink-2);display:flex;align-items:center;gap:8px;cursor:default;user-select:none}
|
||
.ctx-mi:hover{background:var(--surface-2)}
|
||
.ctx-mi.danger{color:var(--warn)}
|
||
.ctx-mi.danger:hover{background:rgba(217,119,87,.06)}
|
||
|
||
/* Sidebar overlay */
|
||
.sb-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:299}
|
||
.sb-overlay.on{display:block;animation:fadeIn .15s ease}
|
||
|
||
/* Indeterminate checkbox */
|
||
.check.indeterminate{background:var(--accent);border-color:var(--accent);color:#fff}
|
||
|
||
/* Responsive */
|
||
@media(max-width:768px){
|
||
.app{grid-template-columns:1fr}
|
||
.sb{position:fixed;left:-270px;top:0;bottom:0;z-index:300;width:260px;border-right:1px solid var(--border);background:var(--surface);box-shadow:var(--shadow-lg);transition:left .25s cubic-bezier(.3,.7,.4,1)}
|
||
.sb.open{left:0}
|
||
.hamburger{display:grid}
|
||
.hdr{padding:10px 16px}
|
||
.hdr-tabs{padding:0 16px}
|
||
.toolbar{padding:10px 16px 6px}
|
||
.scroll{padding:6px 16px 28px}
|
||
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
|
||
.list{overflow-x:auto}
|
||
.list-head,.list-row{min-width:580px}
|
||
}
|
||
@media(max-width:480px){
|
||
.grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
|
||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||
|
||
<script type="text/babel">
|
||
// ── tweaks panel ──────────────────────────────────────────────
|
||
|
||
const __TWEAKS_STYLE = `
|
||
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
||
background:rgba(250,249,247,.78);color:#29261b;
|
||
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;color:rgba(41,38,27,.72)}
|
||
.twk-lbl>span:first-child{font-weight:500}
|
||
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||
.twk-sect:first-child{padding-top:0}
|
||
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
|
||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||
select.twk-field{padding-right:22px;
|
||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||
background-repeat:no-repeat;background-position:right 8px center}
|
||
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||
width:14px;height:14px;border-radius:50%;background:#fff;
|
||
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;background:rgba(0,0,0,.06);user-select:none}
|
||
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;overflow-wrap:anywhere}
|
||
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||
.twk-toggle[data-on="1"]{background:#34c759}
|
||
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||
.twk-chips{display:flex;gap:6px}
|
||
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;padding:0;border:0;
|
||
border-radius:6px;overflow:hidden;cursor:default;
|
||
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
||
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
||
.twk-chip:hover{transform:translateY(-1px);box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
||
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),0 2px 6px rgba(0,0,0,.15)}
|
||
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
||
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
||
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
||
.twk-chip>span>i:first-child{box-shadow:none}
|
||
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
||
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;background:transparent;flex-shrink:0}
|
||
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||
`;
|
||
|
||
function useTweaks(defaults) {
|
||
const [values, setValues] = React.useState(defaults);
|
||
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null ? keyOrEdits : { [keyOrEdits]: val };
|
||
setValues(prev => ({ ...prev, ...edits }));
|
||
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
||
}, []);
|
||
return [values, setTweak];
|
||
}
|
||
|
||
function TweaksPanel({ title = 'Tweaks', children }) {
|
||
const [open, setOpen] = React.useState(false);
|
||
const dragRef = React.useRef(null);
|
||
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||
const PAD = 16;
|
||
|
||
const clamp = React.useCallback(() => {
|
||
const p = dragRef.current; if (!p) return;
|
||
const w = p.offsetWidth, h = p.offsetHeight;
|
||
offsetRef.current = {
|
||
x: Math.min(Math.max(PAD, window.innerWidth - w - PAD), Math.max(PAD, offsetRef.current.x)),
|
||
y: Math.min(Math.max(PAD, window.innerHeight - h - PAD), Math.max(PAD, offsetRef.current.y)),
|
||
};
|
||
p.style.right = offsetRef.current.x + 'px';
|
||
p.style.bottom = offsetRef.current.y + 'px';
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
if (!open) return;
|
||
clamp();
|
||
const ro = new ResizeObserver(clamp);
|
||
ro.observe(document.documentElement);
|
||
return () => ro.disconnect();
|
||
}, [open, clamp]);
|
||
|
||
React.useEffect(() => {
|
||
const onMsg = e => {
|
||
const t = e?.data?.type;
|
||
if (t === '__activate_edit_mode') setOpen(true);
|
||
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||
};
|
||
window.addEventListener('message', onMsg);
|
||
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||
return () => window.removeEventListener('message', onMsg);
|
||
}, []);
|
||
|
||
const dismiss = () => { setOpen(false); window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); };
|
||
|
||
const onDragStart = e => {
|
||
const p = dragRef.current; if (!p) return;
|
||
const r = p.getBoundingClientRect();
|
||
const sx = e.clientX, sy = e.clientY;
|
||
const sr = window.innerWidth - r.right, sb = window.innerHeight - r.bottom;
|
||
const move = ev => { offsetRef.current = { x: sr - (ev.clientX - sx), y: sb - (ev.clientY - sy) }; clamp(); };
|
||
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
||
window.addEventListener('mousemove', move);
|
||
window.addEventListener('mouseup', up);
|
||
};
|
||
|
||
if (!open) return null;
|
||
return (
|
||
<>
|
||
<style>{__TWEAKS_STYLE}</style>
|
||
<div ref={dragRef} className="twk-panel" style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||
<div className="twk-hd" onMouseDown={onDragStart}>
|
||
<b>{title}</b>
|
||
<button className="twk-x" onMouseDown={e => e.stopPropagation()} onClick={dismiss}>✕</button>
|
||
</div>
|
||
<div className="twk-body">{children}</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function TweakSection({ label }) { return <div className="twk-sect">{label}</div>; }
|
||
|
||
function TweakRow({ label, value, children, inline = false }) {
|
||
return (
|
||
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||
<div className="twk-lbl"><span>{label}</span>{value != null && <span className="twk-val">{value}</span>}</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||
return (
|
||
<TweakRow label={label} value={`${value}${unit}`}>
|
||
<input type="range" className="twk-slider" min={min} max={max} step={step} value={value} onChange={e => onChange(Number(e.target.value))} />
|
||
</TweakRow>
|
||
);
|
||
}
|
||
|
||
function TweakToggle({ label, value, onChange }) {
|
||
return (
|
||
<div className="twk-row twk-row-h">
|
||
<div className="twk-lbl"><span>{label}</span></div>
|
||
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'} role="switch" aria-checked={!!value} onClick={() => onChange(!value)}><i /></button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TweakRadio({ label, value, options, onChange }) {
|
||
const trackRef = React.useRef(null);
|
||
const [dragging, setDragging] = React.useState(false);
|
||
const valueRef = React.useRef(value);
|
||
valueRef.current = value;
|
||
const labelLen = o => String(typeof o === 'object' ? o.label : o).length;
|
||
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
||
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
||
|
||
if (!fitsAsSegments) {
|
||
return (
|
||
<TweakRow label={label}>
|
||
<select className="twk-field" value={value} onChange={e => onChange(e.target.value)}>
|
||
{options.map(o => { const v = typeof o === 'object' ? o.value : o; const l = typeof o === 'object' ? o.label : o; return <option key={v} value={v}>{l}</option>; })}
|
||
</select>
|
||
</TweakRow>
|
||
);
|
||
}
|
||
|
||
const opts = options.map(o => (typeof o === 'object' ? o : { value: o, label: o }));
|
||
const idx = Math.max(0, opts.findIndex(o => o.value === value));
|
||
const n = opts.length;
|
||
|
||
const segAt = cx => {
|
||
const r = trackRef.current.getBoundingClientRect();
|
||
const i = Math.floor(((cx - r.left - 2) / (r.width - 4)) * n);
|
||
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||
};
|
||
|
||
const onPointerDown = e => {
|
||
setDragging(true);
|
||
const v0 = segAt(e.clientX); if (v0 !== valueRef.current) onChange(v0);
|
||
const move = ev => { if (!trackRef.current) return; const v = segAt(ev.clientX); if (v !== valueRef.current) onChange(v); };
|
||
const up = () => { setDragging(false); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
||
window.addEventListener('pointermove', move);
|
||
window.addEventListener('pointerup', up);
|
||
};
|
||
|
||
return (
|
||
<TweakRow label={label}>
|
||
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown} className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||
<div className="twk-seg-thumb" style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`, width: `calc((100% - 4px) / ${n})` }} />
|
||
{opts.map(o => <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>{o.label}</button>)}
|
||
</div>
|
||
</TweakRow>
|
||
);
|
||
}
|
||
|
||
function __twkIsLight(hex) {
|
||
const h = String(hex).replace('#', '');
|
||
const x = h.length === 3 ? h.replace(/./g, c => c + c) : h.padEnd(6, '0');
|
||
const n = parseInt(x.slice(0, 6), 16);
|
||
if (Number.isNaN(n)) return true;
|
||
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||
return r * 299 + g * 587 + b * 114 > 148000;
|
||
}
|
||
|
||
const __TwkCheck = ({ light }) => (
|
||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||
</svg>
|
||
);
|
||
|
||
function TweakColor({ label, value, options, onChange }) {
|
||
if (!options || !options.length) {
|
||
return (
|
||
<div className="twk-row twk-row-h">
|
||
<div className="twk-lbl"><span>{label}</span></div>
|
||
<input type="color" className="twk-swatch" value={value} onChange={e => onChange(e.target.value)} />
|
||
</div>
|
||
);
|
||
}
|
||
const key = o => String(JSON.stringify(o)).toLowerCase();
|
||
const cur = key(value);
|
||
return (
|
||
<TweakRow label={label}>
|
||
<div className="twk-chips" role="radiogroup">
|
||
{options.map((o, i) => {
|
||
const colors = Array.isArray(o) ? o : [o];
|
||
const [hero, ...rest] = colors;
|
||
const sup = rest.slice(0, 4);
|
||
const on = key(o) === cur;
|
||
return (
|
||
<button key={i} type="button" className="twk-chip" role="radio" aria-checked={on} data-on={on ? '1' : '0'} style={{ background: hero }} onClick={() => onChange(o)}>
|
||
{sup.length > 0 && <span>{sup.map((c, j) => <i key={j} style={{ background: c }} />)}</span>}
|
||
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</TweakRow>
|
||
);
|
||
}
|
||
|
||
// ── icons ─────────────────────────────────────────────────────
|
||
|
||
const Icon = ({ name, size = 16, stroke = 1.75, ...rest }) => {
|
||
const paths = {
|
||
folder: <><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></>,
|
||
"folder-open": <><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v1H3V7Z"/><path d="M3 9h18l-2 8a2 2 0 0 1-2 1.5H5A2 2 0 0 1 3 17V9Z"/></>,
|
||
star: <><path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1L3.2 9.5l6.1-.9L12 3Z"/></>,
|
||
search: <><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></>,
|
||
upload: <><path d="M12 16V4M6 10l6-6 6 6M4 20h16"/></>,
|
||
download: <><path d="M12 4v12M6 12l6 6 6-6M4 20h16"/></>,
|
||
grid: <><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></>,
|
||
list: <><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></>,
|
||
sort: <><path d="M3 6h18M6 12h12M10 18h4"/></>,
|
||
plus: <><path d="M12 5v14M5 12h14"/></>,
|
||
x: <><path d="m6 6 12 12M6 18 18 6"/></>,
|
||
check: <><path d="M5 12.5 10 17 19 7"/></>,
|
||
more: <><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
|
||
"chevron-down": <><path d="m6 9 6 6 6-6"/></>,
|
||
code: <><path d="m16 6 6 6-6 6M8 18l-6-6 6-6"/></>,
|
||
settings: <><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></>,
|
||
clock: <><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>,
|
||
home: <><path d="M3 11 12 3l9 8v9a2 2 0 0 1-2 2h-4v-7h-6v7H5a2 2 0 0 1-2-2v-9Z"/></>,
|
||
trash: <><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M6 6v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6"/></>,
|
||
share: <><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="m8 11 8-4M8 13l8 4"/></>,
|
||
link: <><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 1 0-5.7-5.7L11 7"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 1 0 5.7 5.7L13 17"/></>,
|
||
bell: <><path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></>,
|
||
cloud: <><path d="M17 18a4 4 0 0 0 .8-7.9 6 6 0 0 0-11.6 1A4.5 4.5 0 0 0 7 19h10Z"/></>,
|
||
image: <><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5-9 9"/></>,
|
||
video: <><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m22 8-6 4 6 4V8Z"/></>,
|
||
};
|
||
return (
|
||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" {...rest}>
|
||
{paths[name] || null}
|
||
</svg>
|
||
);
|
||
};
|
||
|
||
// ── 정적 정의 ─────────────────────────────────────────────────
|
||
|
||
const CATEGORIES_DEF = [
|
||
{ id: "design", label: "디자인 리소스", icon: "image" },
|
||
{ id: "marketing", label: "마케팅 자료", icon: "folder" },
|
||
{ id: "education", label: "교육·온보딩", icon: "folder" },
|
||
{ id: "templates", label: "템플릿", icon: "folder" },
|
||
{ id: "code", label: "코드 스니펫", icon: "code" },
|
||
{ id: "media", label: "사진·영상", icon: "video" },
|
||
];
|
||
|
||
|
||
const FOLDERS_DEF = [
|
||
{ id: "all", label: "모든 자료", icon: "folder" },
|
||
{ id: "starred", label: "즐겨찾기", icon: "star" },
|
||
{ id: "recent", label: "최근 다운로드", icon: "clock" },
|
||
{ id: "shared", label: "공유 받은 자료", icon: "share" },
|
||
{ id: "trash", label: "휴지통", icon: "trash" },
|
||
];
|
||
|
||
// ── API 헬퍼 ──────────────────────────────────────────────────
|
||
|
||
async function apiFetch(path, options = {}) {
|
||
const token = sessionStorage.getItem('admin_token');
|
||
const headers = { ...(options.headers || {}) };
|
||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||
const res = await fetch(path, { ...options, headers });
|
||
if (!res.ok) throw new Error(`API ${path} → ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
// ── components ────────────────────────────────────────────────
|
||
|
||
const { useState, useEffect, useRef, useCallback } = React;
|
||
|
||
const FIcon = ({ type, ext, size = 28 }) => (
|
||
<div className={`ficon ${type}`} style={{ width: size, height: size, borderRadius: size * 0.18 }}>{ext}</div>
|
||
);
|
||
|
||
const Thumb = ({ file }) => {
|
||
const k = file.thumb;
|
||
if (k === "doc") return <div className="thumb tn-doc"><span className="ext">{file.ext}</span><div className="doc-page"><i/><i/><i/><i/><i/><i/></div></div>;
|
||
if (k === "img") return <div className="thumb tn-img"><span className="ext">{file.ext}</span><div className="img-icon">IMG</div></div>;
|
||
if (k === "vid") return <div className="thumb tn-vid"><span className="ext">{file.ext}</span><div className="vid-play"></div></div>;
|
||
if (k === "zip") return <div className="thumb tn-zip"><span className="ext">{file.ext}</span><div className="zip-icon"></div></div>;
|
||
if (k === "code") return (
|
||
<div className="thumb tn-code">
|
||
<span className="ext">{file.ext}</span>
|
||
<div style={{ paddingTop: 22 }}>
|
||
<div><span style={{color:"#7d8ec9"}}>const</span> <span style={{color:"#a3b8ff"}}>fetchData</span> = <span style={{color:"#7d8ec9"}}>async</span> () => {'{'}</div>
|
||
<div style={{paddingLeft:14}}><span style={{color:"#7d8ec9"}}>const</span> res = <span style={{color:"#7d8ec9"}}>await</span> fetch(url)</div>
|
||
<div style={{paddingLeft:14}}><span style={{color:"#7d8ec9"}}>return</span> res.json()</div>
|
||
<div>{'}'}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
return <div className="thumb tn-doc"><span className="ext">{file.ext}</span></div>;
|
||
};
|
||
|
||
const VisTag = ({ v }) => {
|
||
const m = { public: "공개", team: "팀 공유", private: "비공개" };
|
||
return <span className={`tag ${v}`}><span className="dot"></span>{m[v]}</span>;
|
||
};
|
||
|
||
const SkeletonGrid = () => (
|
||
<div className="skeleton-grid">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<div key={i} className="skeleton-card">
|
||
<div className="skeleton-thumb" />
|
||
<div className="skeleton-meta">
|
||
<div className="skeleton-line" style={{ width: '75%' }} />
|
||
<div className="skeleton-line" style={{ width: '45%' }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
const Sidebar = ({ activeFolder, setActiveFolder, activeCat, setActiveCat, categories, accent, stats, open, onClose }) => {
|
||
const { folders = {}, categories: catStats = {}, storage = { used: 0, total: 100, remaining: 100 } } = stats;
|
||
const usedPct = Math.min(100, (storage.used / storage.total) * 100).toFixed(0);
|
||
|
||
return (
|
||
<aside className={`sb scrollbar${open ? ' open' : ''}`}>
|
||
<div className="sb-brand">
|
||
<div className="sb-brand-mark" style={{ background: accent }}>자</div>
|
||
<div>
|
||
<div className="sb-brand-name">자료실</div>
|
||
<div className="sb-brand-sub">v2.4 · 워크스페이스</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sb-search">
|
||
<Icon name="search" size={14} />
|
||
<input placeholder="자료 검색..." />
|
||
<kbd>⌘K</kbd>
|
||
</div>
|
||
|
||
<div style={{ display:"flex", flexDirection:"column", gap:1 }}>
|
||
{FOLDERS_DEF.map(f => (
|
||
<div key={f.id}
|
||
className={`sb-item ${activeFolder === f.id && !activeCat ? "active" : ""}`}
|
||
onClick={() => { setActiveFolder(f.id); setActiveCat(null); onClose?.(); }}>
|
||
<Icon name={f.icon} size={15} className="ico" />
|
||
<span className="lbl">{f.label}</span>
|
||
<span className="cnt">{folders[f.id] ?? 0}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="sb-section">
|
||
<span>파일 유형</span>
|
||
</div>
|
||
<div style={{ display:"flex", flexDirection:"column", gap:1 }}>
|
||
{categories.map(c => (
|
||
<div key={c.id}
|
||
className={`sb-item ${activeCat === c.id ? "active" : ""}`}
|
||
onClick={() => { setActiveCat(c.id); setActiveFolder("all"); onClose?.(); }}>
|
||
<Icon name={c.icon} size={15} className="ico" />
|
||
<span className="lbl">{c.label}</span>
|
||
<span className="cnt">{catStats[c.id] ?? 0}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="sb-storage">
|
||
<div className="sb-storage-row"><span>저장 공간</span><b>{storage.used} GB</b></div>
|
||
<div className="sb-bar"><div className="sb-bar-fill" style={{ width:`${usedPct}%`, background:accent }}></div></div>
|
||
<div className="sb-storage-cap">{storage.used} / {storage.total} GB · {storage.remaining} GB 남음</div>
|
||
</div>
|
||
|
||
<a href="/admin.html" style={{ display:"flex", alignItems:"center", gap:8, padding:"7px 10px", borderRadius:6, fontSize:12.5, color:"var(--muted)", textDecoration:"none", margin:"4px 0 2px" }}
|
||
onMouseEnter={e => e.currentTarget.style.background="rgba(20,20,20,.05)"}
|
||
onMouseLeave={e => e.currentTarget.style.background="transparent"}>
|
||
<Icon name="settings" size={13} />관리자 설정
|
||
</a>
|
||
</aside>
|
||
);
|
||
};
|
||
|
||
const Header = ({ folder, category, onUpload, accent, categories, currentUser, onLogout, onMenuToggle }) => {
|
||
const folderObj = FOLDERS_DEF.find(f => f.id === folder);
|
||
const catObj = categories.find(c => c.id === category);
|
||
return (
|
||
<div className="hdr">
|
||
<div className="hdr-l">
|
||
<button className="hamburger" onClick={onMenuToggle} title="메뉴">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||
</button>
|
||
<div className="crumbs">
|
||
<a><Icon name="home" size={13} /></a>
|
||
<span className="sep">/</span>
|
||
<a>워크스페이스</a>
|
||
<span className="sep">/</span>
|
||
<span className="here">{catObj ? catObj.label : (folderObj ? folderObj.label : "모든 자료")}</span>
|
||
</div>
|
||
</div>
|
||
<div className="hdr-r">
|
||
<button className="btn btn-icon btn-ghost" title="알림"><Icon name="bell" size={16} /></button>
|
||
{currentUser && (
|
||
<>
|
||
<span style={{ fontSize:12.5, color:'var(--muted)', padding:'0 2px', display:'flex', alignItems:'center', gap:5 }}>
|
||
<span style={{ width:20, height:20, borderRadius:'50%', background:'var(--surface-2)', border:'1px solid var(--border-strong)', display:'grid', placeItems:'center', fontSize:10, fontWeight:700, color:'var(--ink)', flexShrink:0 }}>★</span>
|
||
관리자
|
||
</span>
|
||
<button className="btn btn-ghost" style={{ fontSize:12.5 }} onClick={onLogout}>로그아웃</button>
|
||
<button className="btn btn-accent" onClick={onUpload} style={{ background:accent, borderColor:accent }}>
|
||
<Icon name="upload" size={14} />업로드
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const TypeTabs = ({ categories, activeCat, onSelect }) => (
|
||
<div className="hdr-tabs">
|
||
<button className={`hdr-tab ${activeCat === null ? 'active' : ''}`} onClick={() => onSelect(null)}>전체</button>
|
||
{categories.map(c => (
|
||
<button key={c.id} className={`hdr-tab ${activeCat === c.id ? 'active' : ''}`}
|
||
onClick={() => onSelect(c.id)}>
|
||
{c.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
const Toolbar = ({ view, setView, sort, setSort, filters, setFilters, count, files, selected, onSelectAll, isLoggedIn }) => {
|
||
const [sortOpen, setSortOpen] = useState(false);
|
||
const sortLabels = { recent:"최근 수정순", name:"이름순", size:"크기순", downloads:"다운로드순" };
|
||
const allSelected = files.length > 0 && files.every(f => selected.has(f.id));
|
||
const someSelected = !allSelected && files.some(f => selected.has(f.id));
|
||
return (
|
||
<div className="toolbar">
|
||
<div className="toolbar-l">
|
||
{isLoggedIn && (
|
||
<div className={`check ${allSelected ? 'on' : someSelected ? 'indeterminate' : ''}`}
|
||
onClick={onSelectAll}
|
||
title={allSelected ? "전체 해제" : "전체 선택"}
|
||
style={{ cursor:'default', flexShrink:0 }}>
|
||
{allSelected && <Icon name="check" size={10} stroke={2.5} />}
|
||
{someSelected && <span style={{fontSize:12,lineHeight:1,fontWeight:700,color:'#fff'}}>–</span>}
|
||
</div>
|
||
)}
|
||
<button className={`pill ${filters.starredOnly ? "on" : ""}`} onClick={() => setFilters({...filters, starredOnly:!filters.starredOnly})}>
|
||
<Icon name="star" size={11} />즐겨찾기만
|
||
</button>
|
||
</div>
|
||
<div className="toolbar-r">
|
||
<span style={{ fontSize:12, color:"var(--faint)", fontFamily:"var(--font-mono)", marginRight:6 }}>{count}개</span>
|
||
<div className="sortmenu">
|
||
<button className="btn btn-ghost" onClick={() => setSortOpen(!sortOpen)}>
|
||
<Icon name="sort" size={13} />{sortLabels[sort]}<Icon name="chevron-down" size={12} />
|
||
</button>
|
||
{sortOpen && (
|
||
<div className="menu" onMouseLeave={() => setSortOpen(false)}>
|
||
{Object.entries(sortLabels).map(([k, l]) => (
|
||
<div key={k} className={`mi ${sort===k?"on":""}`} onClick={() => { setSort(k); setSortOpen(false); }}>
|
||
<span>{l}</span><Icon name="check" size={13} className="check" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="seg">
|
||
<button className={view==="grid"?"on":""} onClick={() => setView("grid")} title="그리드"><Icon name="grid" size={14} /></button>
|
||
<button className={view==="list"?"on":""} onClick={() => setView("list")} title="리스트"><Icon name="list" size={14} /></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const GridView = ({ files, selected, toggleSel, toggleStar, onOpen, isLoggedIn }) => (
|
||
<div className="grid">
|
||
{files.map(f => (
|
||
<div key={f.id} className={`card ${selected.has(f.id) ? "sel" : ""}`} onClick={() => onOpen(f)}>
|
||
{isLoggedIn && (
|
||
<div style={{ position:'absolute', top:8, left:8, zIndex:2 }}
|
||
onClick={e => { e.stopPropagation(); toggleSel(f.id); }}>
|
||
<div className={`check ${selected.has(f.id) ? 'on' : ''}`}>
|
||
<Icon name="check" size={10} stroke={2.5} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<Thumb file={f} />
|
||
<button className={`star ${f.starred ? "on" : ""}`} onClick={e => { e.stopPropagation(); toggleStar(f.id); }} title="즐겨찾기">
|
||
<Icon name="star" size={14} stroke={2} />
|
||
</button>
|
||
<div className="meta">
|
||
<div className="name">{f.name}</div>
|
||
<div className="sub"><span>{f.size_label}</span><span>{f.modified_at}</span></div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
|
||
const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories, isLoggedIn, currentUser, onDownload, onCopyLink, onDelete }) => {
|
||
const [menuFile, setMenuFile] = useState(null);
|
||
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
|
||
const menuRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!menuFile) return;
|
||
const close = e => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuFile(null); };
|
||
const onKey = e => { if (e.key === 'Escape') setMenuFile(null); };
|
||
document.addEventListener('mousedown', close);
|
||
document.addEventListener('keydown', onKey);
|
||
return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', onKey); };
|
||
}, [menuFile]);
|
||
|
||
const openMenu = (e, file) => {
|
||
e.stopPropagation();
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const x = Math.max(4, Math.min(rect.right - 164, window.innerWidth - 168));
|
||
const y = Math.min(rect.bottom + 4, window.innerHeight - 160);
|
||
setMenuPos({ x, y });
|
||
setMenuFile(f => f?.id === file.id ? null : file);
|
||
};
|
||
|
||
const cols = isLoggedIn
|
||
? "32px 1fr 100px 130px 130px 70px 52px"
|
||
: "1fr 100px 130px 130px 70px 52px";
|
||
|
||
return (
|
||
<div className="list">
|
||
<div className="list-head" style={{ gridTemplateColumns: cols }}>
|
||
{isLoggedIn && <div></div>}
|
||
<div>이름</div><div>크기</div><div>수정일</div><div>업로더</div><div>다운로드</div><div></div>
|
||
</div>
|
||
{files.map(f => (
|
||
<div key={f.id} className={`list-row ${selected.has(f.id) ? "sel" : ""}`}
|
||
style={{ gridTemplateColumns: cols }}
|
||
onClick={() => onOpen(f)}>
|
||
{isLoggedIn && (
|
||
<div className={`check ${selected.has(f.id) ? "on" : ""}`}
|
||
onClick={e => { e.stopPropagation(); toggleSel(f.id); }}>
|
||
<Icon name="check" size={11} stroke={2.5} />
|
||
</div>
|
||
)}
|
||
<div className="list-name">
|
||
<FIcon type={f.type} ext={f.ext} />
|
||
<div className="ftxt">
|
||
<span className="nm">{f.name}</span>
|
||
<span className="pth">{categories.find(c => c.id === f.category)?.label} · {f.author}</span>
|
||
</div>
|
||
</div>
|
||
<div className="list-meta">{f.size_label}</div>
|
||
<div className="list-meta">{f.modified_at}</div>
|
||
<div className="list-meta" style={{ fontFamily:"var(--font-sans)", fontSize:13 }}>{f.author}</div>
|
||
<div className="list-meta">↓ {f.downloads}</div>
|
||
<div style={{display:"flex",gap:2,justifyContent:"flex-end"}}>
|
||
<button className="iconbtn" onClick={e => { e.stopPropagation(); toggleStar(f.id); }}
|
||
style={{ color: f.starred ? "#e8a317" : "var(--faint)" }}>
|
||
<Icon name="star" size={14} stroke={f.starred ? 2.5 : 1.75} />
|
||
</button>
|
||
<button className="iconbtn" onClick={e => openMenu(e, f)}><Icon name="more" size={15} /></button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{menuFile && ReactDOM.createPortal(
|
||
<div ref={menuRef} className="ctx-menu" style={{ left: menuPos.x, top: menuPos.y }}>
|
||
<div className="ctx-mi" onClick={() => { onDownload(menuFile); setMenuFile(null); }}>
|
||
<Icon name="download" size={14} />다운로드
|
||
</div>
|
||
<div className="ctx-mi" onClick={() => { onCopyLink(menuFile); setMenuFile(null); }}>
|
||
<Icon name="link" size={14} />링크 복사
|
||
</div>
|
||
<div className="ctx-mi" onClick={() => { toggleStar(menuFile.id); setMenuFile(null); }}>
|
||
<Icon name="star" size={14} stroke={menuFile.starred ? 2.5 : 1.75}
|
||
style={{ color: menuFile.starred ? "#e8a317" : "currentColor" }} />
|
||
{menuFile.starred ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||
</div>
|
||
{currentUser?.isAdmin && (
|
||
<div className="ctx-mi danger" onClick={() => { const f = menuFile; setMenuFile(null); onDelete([f.id]); }}>
|
||
<Icon name="trash" size={14} />삭제
|
||
</div>
|
||
)}
|
||
</div>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const UploadModal = ({ onClose, onUploaded, accent, categories, users }) => {
|
||
const [dragOver, setDragOver] = useState(false);
|
||
const [uploads, setUploads] = useState([]);
|
||
const [meta, setMeta] = useState({ author: '', category: '', visibility: 'team' });
|
||
const inputRef = useRef(null);
|
||
|
||
const uploadFile = async file => {
|
||
const id = `up-${Date.now()}-${Math.random()}`;
|
||
const ext = (file.name.split('.').pop() || 'FILE').toUpperCase();
|
||
const type = file.type.startsWith('image/') ? 'img' : file.type.startsWith('video/') ? 'vid' : 'doc';
|
||
setUploads(u => [...u, { id, name: file.name, ext, type, progress: 10 }]);
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
if (meta.author) fd.append('author', meta.author);
|
||
if (meta.category) fd.append('category', meta.category);
|
||
fd.append('visibility', meta.visibility);
|
||
try {
|
||
setUploads(u => u.map(x => x.id === id ? { ...x, progress: 50 } : x));
|
||
const saved = await apiFetch('/api/files/upload', { method: 'POST', body: fd });
|
||
setUploads(u => u.map(x => x.id === id ? { ...x, progress: 100 } : x));
|
||
onUploaded(saved);
|
||
} catch {
|
||
setUploads(u => u.map(x => x.id === id ? { ...x, progress: -1 } : x));
|
||
}
|
||
};
|
||
|
||
const addFiles = fileList => Array.from(fileList || []).forEach(uploadFile);
|
||
const sel = { border:"1px solid var(--border-strong)", borderRadius:6, padding:"6px 8px", fontSize:13, background:"var(--surface)", color:"var(--ink)", outline:"none", width:"100%" };
|
||
|
||
return (
|
||
<div className="modal-bg" onClick={onClose}>
|
||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-h">
|
||
<h3>파일 업로드</h3>
|
||
<button className="iconbtn" onClick={onClose}><Icon name="x" size={16} /></button>
|
||
</div>
|
||
<div className="modal-b">
|
||
<div style={{ display:"grid", gridTemplateColumns:"1fr 1fr 1fr", gap:10, marginBottom:12 }}>
|
||
<div>
|
||
<div style={{ fontSize:11, fontWeight:600, color:"var(--muted)", marginBottom:4 }}>업로더</div>
|
||
<select style={sel} value={meta.author} onChange={e => setMeta(m => ({...m, author: e.target.value}))}>
|
||
<option value="">자동</option>
|
||
{users.map(u => <option key={u.id} value={u.name}>{u.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize:11, fontWeight:600, color:"var(--muted)", marginBottom:4 }}>카테고리</div>
|
||
<select style={sel} value={meta.category} onChange={e => setMeta(m => ({...m, category: e.target.value}))}>
|
||
<option value="">자동</option>
|
||
{categories.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div style={{ fontSize:11, fontWeight:600, color:"var(--muted)", marginBottom:4 }}>공개 범위</div>
|
||
<select style={sel} value={meta.visibility} onChange={e => setMeta(m => ({...m, visibility: e.target.value}))}>
|
||
<option value="team">팀 공유</option>
|
||
<option value="public">공개</option>
|
||
<option value="private">비공개</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className={`updz ${dragOver ? "on" : ""}`}
|
||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||
onDragLeave={() => setDragOver(false)}
|
||
onDrop={e => { e.preventDefault(); setDragOver(false); addFiles(e.dataTransfer.files); }}>
|
||
<Icon name="cloud" size={38} stroke={1.5} style={{color: dragOver ? accent : "var(--muted)"}}/>
|
||
<div className="t">파일을 여기로 드래그하세요</div>
|
||
<div className="s">또는 클릭하여 선택 · 최대 2GB · PDF, DOC, PPT, 이미지, 영상, ZIP</div>
|
||
<input ref={inputRef} type="file" multiple style={{display:"none"}} onChange={e => addFiles(e.target.files)} />
|
||
<button className="btn btn-primary" onClick={() => inputRef.current?.click()}>
|
||
<Icon name="upload" size={13} />파일 선택
|
||
</button>
|
||
</div>
|
||
{uploads.length > 0 && (
|
||
<div className="uplist">
|
||
{uploads.map(u => (
|
||
<div key={u.id} className="uprow">
|
||
<FIcon type={u.type} ext={u.ext} size={32} />
|
||
<div className="info">
|
||
<div className="nm">{u.name}</div>
|
||
<div className="progbar">
|
||
<div className="progfill" style={{ width:`${Math.max(0, u.progress)}%`, background: u.progress < 0 ? 'var(--warn)' : accent }}></div>
|
||
</div>
|
||
</div>
|
||
<div className="pct">{u.progress < 0 ? '오류' : u.progress >= 100 ? '✓' : `${Math.round(u.progress)}%`}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{uploads.length > 0 && uploads.every(u => u.progress === 100 || u.progress < 0) && (
|
||
<div style={{ display:'flex', justifyContent:'flex-end', marginTop:16 }}>
|
||
<button className="btn btn-accent" onClick={onClose} style={{ background:accent, borderColor:accent }}>
|
||
<Icon name="check" size={14} />확인
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const PreviewModal = ({ file, onClose, onDownload, onStar, onCopyLink, accent, categories }) => {
|
||
if (!file) return null;
|
||
return (
|
||
<div className="modal-bg" onClick={onClose}>
|
||
<div className="modal preview-modal" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-h">
|
||
<h3 style={{ display:"flex", alignItems:"center", gap:10 }}>
|
||
<FIcon type={file.type} ext={file.ext} size={26} />
|
||
<span style={{ fontWeight:500, fontSize:14 }}>{file.name}</span>
|
||
</h3>
|
||
<button className="iconbtn" onClick={onClose}><Icon name="x" size={16} /></button>
|
||
</div>
|
||
<div className="preview-body">
|
||
<div className="preview-stage">
|
||
{file.type === "doc" && (
|
||
<div className="ph">
|
||
<div className="h">{file.name.replace(/\.[^.]+$/, "")}</div>
|
||
<i className="short"></i><div style={{ height:12 }}></div>
|
||
<i/><i/><i/><i/><i/><div style={{ height:8 }}></div>
|
||
<i className="short"></i><i/><i/><i/><i/>
|
||
</div>
|
||
)}
|
||
{file.type === "img" && (
|
||
<div style={{ width:"80%", height:"80%", background:"repeating-linear-gradient(45deg,#e8e6dd 0 14px,#efede4 14px 28px)", borderRadius:6, display:"grid", placeItems:"center", color:"#999", fontFamily:"var(--font-mono)"}}>
|
||
{file.ext} 미리보기
|
||
</div>
|
||
)}
|
||
{file.type === "vid" && (
|
||
<div style={{ width:"100%", height:"100%", background:"#000", borderRadius:6, display:"grid", placeItems:"center"}}>
|
||
<div className="vid-play" style={{ width:64, height:64, borderWidth:2 }}></div>
|
||
</div>
|
||
)}
|
||
{(file.type === "zip" || file.type === "code") && (
|
||
<div style={{ color:"#fff", textAlign:"center", fontFamily:"var(--font-mono)" }}>
|
||
<div style={{ fontSize:48, opacity:.3, marginBottom:8 }}>{file.ext}</div>
|
||
<div style={{ fontSize:12, opacity:.5 }}>이 파일은 미리보기를 지원하지 않습니다</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="preview-info">
|
||
<h2>{file.name}</h2>
|
||
<VisTag v={file.visibility} />
|
||
<div className="infogrp">
|
||
<span className="lbl">파일 정보</span>
|
||
<div className="row"><span>크기</span><span>{file.size_label}</span></div>
|
||
<div className="row"><span>형식</span><span>{file.ext}</span></div>
|
||
<div className="row"><span>다운로드</span><span>{file.downloads}회</span></div>
|
||
</div>
|
||
<div className="infogrp">
|
||
<span className="lbl">업로드</span>
|
||
<div className="row"><span>업로더</span><span>{file.author}</span></div>
|
||
<div className="row"><span>수정</span><span>{file.modified_at}</span></div>
|
||
<div className="row"><span>분류</span><span>{categories.find(c=>c.id===file.category)?.label || "—"}</span></div>
|
||
</div>
|
||
<div className="actions">
|
||
<button className="btn btn-accent" onClick={() => onDownload(file)} style={{ background:accent, borderColor:accent, justifyContent:"center" }}>
|
||
<Icon name="download" size={14} />다운로드
|
||
</button>
|
||
<button className="btn" style={{ justifyContent:"center" }} onClick={() => onStar(file.id)}>
|
||
<Icon name="star" size={14} stroke={file.starred ? 2.5 : 1.75} style={{ color: file.starred ? "#e8a317" : "currentColor" }} />
|
||
{file.starred ? "즐겨찾기 해제" : "즐겨찾기 추가"}
|
||
</button>
|
||
<button className="btn btn-ghost" style={{ justifyContent:"center" }} onClick={() => onCopyLink(file)}><Icon name="link" size={14} />공유 링크 복사</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SelectionBar = ({ count, onClear, onDownload, onShare, onDelete, isAdmin }) => (
|
||
<div className="selbar">
|
||
<span className="selcount">{count}개 선택됨</span>
|
||
<div className="selbtns">
|
||
<button title="다운로드" onClick={onDownload}><Icon name="download" size={15} /></button>
|
||
<button title="링크 복사" onClick={onShare}><Icon name="share" size={15} /></button>
|
||
{isAdmin && <button title="삭제" onClick={onDelete}><Icon name="trash" size={15} /></button>}
|
||
<button className="x" title="선택 해제" onClick={onClear}><Icon name="x" size={15} /></button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const Toast = ({ msg, on }) => (
|
||
<div className={`toast ${on ? "on" : ""}`}>
|
||
<span className="ck"><Icon name="check" size={11} stroke={3} /></span>
|
||
{msg}
|
||
</div>
|
||
);
|
||
|
||
// ── 로그인 모달 ───────────────────────────────────────────────
|
||
|
||
const UserLoginModal = ({ onLogin, onClose, users, accent }) => {
|
||
const [tab, setTab] = useState('user');
|
||
const [name, setName] = useState('');
|
||
const [pw, setPw] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
const loginUser = () => {
|
||
if (!name.trim()) { setError('이름을 선택하세요'); return; }
|
||
onLogin({ name: name.trim(), isAdmin: false });
|
||
};
|
||
|
||
const loginAdmin = async () => {
|
||
if (!pw) { setError('비밀번호를 입력하세요'); return; }
|
||
setLoading(true); setError('');
|
||
try {
|
||
const res = await fetch('/api/admin/login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: pw }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || '로그인 실패');
|
||
sessionStorage.setItem('admin_token', data.token);
|
||
onLogin({ name: '관리자', isAdmin: true });
|
||
} catch (e) { setError(e.message); }
|
||
finally { setLoading(false); }
|
||
};
|
||
|
||
const sel = { width:'100%', border:'1px solid var(--border-strong)', borderRadius:6, padding:'8px 10px', fontSize:13, background:'var(--surface)', color:'var(--ink)', outline:'none', marginBottom:12 };
|
||
|
||
return (
|
||
<div className="modal-bg" onClick={onClose}>
|
||
<div className="modal" style={{ width:400, maxWidth:'92vw' }} onClick={e => e.stopPropagation()}>
|
||
<div className="modal-h">
|
||
<h3>로그인</h3>
|
||
<button className="iconbtn" onClick={onClose}><Icon name="x" size={16} /></button>
|
||
</div>
|
||
<div className="modal-b">
|
||
<div style={{ display:'flex', gap:3, marginBottom:20, background:'var(--surface-2)', padding:3, borderRadius:8, border:'1px solid var(--border)' }}>
|
||
{[['user','사용자'],['admin','관리자']].map(([t, l]) => (
|
||
<button key={t} onClick={() => { setTab(t); setError(''); setPw(''); setName(''); }}
|
||
style={{ flex:1, padding:'7px 0', border:'none', borderRadius:6, fontSize:13, fontWeight:500, cursor:'default', fontFamily:'inherit',
|
||
background: tab === t ? 'var(--surface)' : 'transparent',
|
||
color: tab === t ? 'var(--ink)' : 'var(--muted)',
|
||
boxShadow: tab === t ? 'var(--shadow-sm)' : 'none' }}>
|
||
{l}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tab === 'user' && (
|
||
<>
|
||
<div style={{ fontSize:11, fontWeight:600, color:'var(--muted)', marginBottom:5 }}>이름 선택</div>
|
||
<select style={sel} value={name} onChange={e => { setName(e.target.value); setError(''); }}>
|
||
<option value="">선택하세요</option>
|
||
{users.map(u => <option key={u.id} value={u.name}>{u.name}</option>)}
|
||
</select>
|
||
{error && <div style={{ fontSize:12, color:'var(--warn)', marginBottom:8 }}>{error}</div>}
|
||
<button className="btn btn-accent" style={{ width:'100%', justifyContent:'center', background:accent, borderColor:accent }} onClick={loginUser}>
|
||
로그인
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{tab === 'admin' && (
|
||
<>
|
||
<div style={{ fontSize:11, fontWeight:600, color:'var(--muted)', marginBottom:5 }}>관리자 비밀번호</div>
|
||
<input type="password" value={pw} onChange={e => { setPw(e.target.value); setError(''); }}
|
||
placeholder="비밀번호 입력" autoFocus
|
||
style={{ ...sel, marginBottom: error ? 4 : 12 }}
|
||
onKeyDown={e => e.key === 'Enter' && loginAdmin()} />
|
||
{error && <div style={{ fontSize:12, color:'var(--warn)', marginBottom:8 }}>{error}</div>}
|
||
<button className="btn btn-accent" style={{ width:'100%', justifyContent:'center', background:accent, borderColor:accent }} onClick={loginAdmin} disabled={loading}>
|
||
{loading ? '확인 중...' : '관리자 로그인'}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── main app ──────────────────────────────────────────────────
|
||
|
||
const TWEAK_DEFAULTS = {
|
||
accent: "#3b5bdb",
|
||
density: "regular",
|
||
cornerRadius: 8,
|
||
showRecent: true,
|
||
};
|
||
|
||
function App() {
|
||
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||
|
||
const [files, setFiles] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [stats, setStats] = useState({ folders: {}, categories: {}, types: {}, storage: { used: 0, total: 100, remaining: 100 } });
|
||
const [categories, setCategories] = useState(CATEGORIES_DEF);
|
||
|
||
const [view, setView] = useState("list");
|
||
const [sort, setSort] = useState("recent");
|
||
const [filters, setFilters] = useState({ starredOnly: false });
|
||
const [activeFolder, setActiveFolder] = useState("all");
|
||
const [activeCat, setActiveCat] = useState(null);
|
||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||
|
||
const [selected, setSelected] = useState(new Set());
|
||
const [previewing, setPreviewing] = useState(null);
|
||
const [uploadOpen, setUploadOpen] = useState(false);
|
||
const [globalDrag, setGlobalDrag] = useState(false);
|
||
const [toast, setToast] = useState({ on: false, msg: "" });
|
||
const [currentUser, setCurrentUser] = useState(() => {
|
||
try { return JSON.parse(sessionStorage.getItem('jaryo_user') || 'null'); } catch { return null; }
|
||
});
|
||
|
||
const dragCounter = useRef(0);
|
||
const currentUserRef = useRef(null);
|
||
|
||
// 액센트 컬러 적용
|
||
useEffect(() => {
|
||
document.documentElement.style.setProperty("--accent", t.accent);
|
||
const hex = t.accent.replace("#", "");
|
||
const r = parseInt(hex.slice(0,2),16), g = parseInt(hex.slice(2,4),16), b = parseInt(hex.slice(4,6),16);
|
||
document.documentElement.style.setProperty("--accent-soft", `rgba(${r},${g},${b},.10)`);
|
||
document.documentElement.style.setProperty("--radius", `${t.cornerRadius}px`);
|
||
document.documentElement.style.setProperty("--radius-lg", `${t.cornerRadius + 4}px`);
|
||
}, [t.accent, t.cornerRadius]);
|
||
|
||
useEffect(() => { currentUserRef.current = currentUser; }, [currentUser]);
|
||
|
||
const fetchStats = useCallback(() => {
|
||
apiFetch('/api/stats').then(setStats).catch(() => {});
|
||
}, []);
|
||
|
||
const fetchFiles = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const p = new URLSearchParams();
|
||
if (activeFolder) p.set('folder', activeFolder);
|
||
if (activeCat) p.set('category', activeCat);
|
||
if (filters.starredOnly) p.set('starred', '1');
|
||
p.set('sort', sort);
|
||
const data = await apiFetch(`/api/files?${p}`);
|
||
setFiles(data);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [activeFolder, activeCat, filters.starredOnly, sort]);
|
||
|
||
useEffect(() => { fetchFiles(); }, [fetchFiles]);
|
||
useEffect(() => { fetchStats(); }, [fetchStats]);
|
||
useEffect(() => {
|
||
apiFetch('/api/categories').then(setCategories).catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const token = sessionStorage.getItem('admin_token');
|
||
if (!token) return;
|
||
fetch('/api/admin/verify', { headers: { Authorization: `Bearer ${token}` } })
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.ok) {
|
||
const u = { name: '관리자', isAdmin: true };
|
||
setCurrentUser(u);
|
||
sessionStorage.setItem('jaryo_user', JSON.stringify(u));
|
||
} else {
|
||
sessionStorage.removeItem('admin_token');
|
||
sessionStorage.removeItem('jaryo_user');
|
||
setCurrentUser(null);
|
||
}
|
||
}).catch(() => {});
|
||
}, []);
|
||
|
||
const handleLogout = () => {
|
||
sessionStorage.removeItem('admin_token');
|
||
sessionStorage.removeItem('jaryo_user');
|
||
setCurrentUser(null);
|
||
setSelected(new Set());
|
||
};
|
||
|
||
const showToast = msg => { setToast({ on: true, msg }); setTimeout(() => setToast(s => ({...s, on: false})), 2200); };
|
||
|
||
// 전역 드래그
|
||
useEffect(() => {
|
||
const onDragEnter = e => { if (e.dataTransfer?.types?.includes("Files")) { dragCounter.current++; setGlobalDrag(true); } };
|
||
const onDragLeave = () => { dragCounter.current = Math.max(0, dragCounter.current - 1); if (!dragCounter.current) setGlobalDrag(false); };
|
||
const onDragOver = e => e.preventDefault();
|
||
const onDrop = e => {
|
||
e.preventDefault(); dragCounter.current = 0; setGlobalDrag(false);
|
||
if (currentUserRef.current) setUploadOpen(true);
|
||
else setToast({ on: true, msg: '업로드는 관리자만 가능합니다' });
|
||
};
|
||
window.addEventListener("dragenter", onDragEnter);
|
||
window.addEventListener("dragleave", onDragLeave);
|
||
window.addEventListener("dragover", onDragOver);
|
||
window.addEventListener("drop", onDrop);
|
||
return () => {
|
||
window.removeEventListener("dragenter", onDragEnter);
|
||
window.removeEventListener("dragleave", onDragLeave);
|
||
window.removeEventListener("dragover", onDragOver);
|
||
window.removeEventListener("drop", onDrop);
|
||
};
|
||
}, []);
|
||
|
||
const toggleSel = id => {
|
||
if (!currentUser) return;
|
||
setSelected(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; });
|
||
};
|
||
|
||
const handleSelectAll = () => {
|
||
if (files.length > 0 && files.every(f => selected.has(f.id))) {
|
||
setSelected(new Set());
|
||
} else {
|
||
setSelected(new Set(files.map(f => f.id)));
|
||
}
|
||
};
|
||
|
||
const toggleStar = async id => {
|
||
try {
|
||
const { starred } = await apiFetch(`/api/files/${id}/star`, { method: 'PATCH' });
|
||
setFiles(prev => prev.map(f => f.id === id ? { ...f, starred } : f));
|
||
if (previewing?.id === id) setPreviewing(p => ({ ...p, starred }));
|
||
showToast(starred ? "즐겨찾기에 추가되었습니다" : "즐겨찾기에서 제거되었습니다");
|
||
fetchStats();
|
||
} catch { showToast("오류가 발생했습니다"); }
|
||
};
|
||
|
||
const onDownload = async f => {
|
||
try {
|
||
if (f.file_path) {
|
||
const a = document.createElement('a');
|
||
a.href = `/api/files/${f.id}/download`;
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setFiles(prev => prev.map(x => x.id === f.id ? { ...x, downloads: x.downloads + 1 } : x));
|
||
if (previewing?.id === f.id) setPreviewing(p => ({ ...p, downloads: (p.downloads || 0) + 1 }));
|
||
showToast(`${f.name} 다운로드를 시작합니다`);
|
||
} else {
|
||
const { downloads } = await apiFetch(`/api/files/${f.id}/download`, { method: 'POST' });
|
||
setFiles(prev => prev.map(x => x.id === f.id ? { ...x, downloads } : x));
|
||
if (previewing?.id === f.id) setPreviewing(p => ({ ...p, downloads }));
|
||
showToast(`${f.name} (샘플 파일 — 실제 다운로드 파일이 없습니다)`);
|
||
}
|
||
} catch { showToast("오류가 발생했습니다"); }
|
||
};
|
||
|
||
const onCopyLink = async f => {
|
||
const url = `${window.location.origin}/api/files/${f.id}/download`;
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
showToast('링크가 클립보드에 복사되었습니다');
|
||
} catch {
|
||
showToast('복사에 실패했습니다');
|
||
}
|
||
};
|
||
|
||
const onBulkDownload = () => {
|
||
const selectedFiles = files.filter(f => selected.has(f.id));
|
||
let count = 0;
|
||
selectedFiles.forEach(f => {
|
||
if (f.file_path) {
|
||
const a = document.createElement('a');
|
||
a.href = `/api/files/${f.id}/download`;
|
||
a.style.display = 'none';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
count++;
|
||
}
|
||
});
|
||
showToast(count > 0 ? `${count}개 파일 다운로드 시작` : '다운로드 가능한 파일이 없습니다');
|
||
setSelected(new Set());
|
||
};
|
||
|
||
const onBulkShare = async () => {
|
||
const links = [...selected].map(id => `${window.location.origin}/api/files/${id}/download`);
|
||
try {
|
||
await navigator.clipboard.writeText(links.join('\n'));
|
||
showToast(`${selected.size}개 링크가 클립보드에 복사되었습니다`);
|
||
} catch { showToast('복사에 실패했습니다'); }
|
||
setSelected(new Set());
|
||
};
|
||
|
||
const deleteFiles = async (ids) => {
|
||
if (!currentUser?.isAdmin) { showToast('삭제는 관리자만 할 수 있습니다'); return; }
|
||
if (!window.confirm(`파일 ${ids.length}개를 삭제할까요?`)) return;
|
||
try {
|
||
await apiFetch('/api/files', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) });
|
||
showToast(`${ids.length}개 파일이 삭제되었습니다`);
|
||
setSelected(prev => { const next = new Set(prev); ids.forEach(id => next.delete(id)); return next; });
|
||
fetchFiles();
|
||
fetchStats();
|
||
} catch { showToast('삭제 중 오류가 발생했습니다'); }
|
||
};
|
||
|
||
const onBulkDelete = () => deleteFiles([...selected]);
|
||
|
||
const onUploaded = newFile => {
|
||
setFiles(prev => [newFile, ...prev]);
|
||
fetchStats();
|
||
showToast(`${newFile.name} 업로드 완료`);
|
||
};
|
||
|
||
return (
|
||
<div className="app" style={{ fontSize: t.density === "compact" ? 13 : t.density === "comfy" ? 15 : 14 }}>
|
||
{sidebarOpen && <div className="sb-overlay on" onClick={() => setSidebarOpen(false)} />}
|
||
<Sidebar
|
||
activeFolder={activeFolder}
|
||
setActiveFolder={id => { setActiveFolder(id); setActiveCat(null); }}
|
||
activeCat={activeCat}
|
||
setActiveCat={id => { setActiveCat(id); setActiveFolder("all"); }}
|
||
categories={categories}
|
||
accent={t.accent} stats={stats}
|
||
open={sidebarOpen} onClose={() => setSidebarOpen(false)}
|
||
/>
|
||
<div className="main">
|
||
<Header
|
||
folder={activeFolder} category={activeCat}
|
||
onUpload={() => setUploadOpen(true)} accent={t.accent} categories={categories}
|
||
currentUser={currentUser} onLogout={handleLogout}
|
||
onMenuToggle={() => setSidebarOpen(o => !o)}
|
||
/>
|
||
<TypeTabs
|
||
categories={categories}
|
||
activeCat={activeCat}
|
||
onSelect={id => { setActiveCat(id); setActiveFolder("all"); }}
|
||
/>
|
||
<Toolbar
|
||
view={view} setView={setView} sort={sort} setSort={setSort}
|
||
filters={filters} setFilters={setFilters} count={files.length}
|
||
files={files} selected={selected} onSelectAll={handleSelectAll} isLoggedIn={!!currentUser}
|
||
/>
|
||
<div className="scroll scrollbar" style={{ position:"relative" }}>
|
||
<div className="sec-h">
|
||
<h3>
|
||
{activeCat ? categories.find(c=>c.id===activeCat)?.label :
|
||
(FOLDERS_DEF.find(f=>f.id===activeFolder)?.label || "모든 자료")}
|
||
</h3>
|
||
<span className="right">{files.length}개 항목</span>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<SkeletonGrid />
|
||
) : files.length === 0 ? (
|
||
<div className="empty">
|
||
<Icon name="folder-open" size={42} stroke={1.4} />
|
||
<div className="t">표시할 자료가 없습니다</div>
|
||
<div className="s">필터를 조정하거나 새 파일을 업로드해보세요</div>
|
||
</div>
|
||
) : view === "grid" ? (
|
||
<GridView files={files} selected={selected} toggleSel={toggleSel} toggleStar={toggleStar} onOpen={setPreviewing} isLoggedIn={!!currentUser} />
|
||
) : (
|
||
<ListView files={files} selected={selected} toggleSel={toggleSel} toggleStar={toggleStar} onOpen={setPreviewing} categories={categories}
|
||
isLoggedIn={!!currentUser} currentUser={currentUser}
|
||
onDownload={onDownload} onCopyLink={onCopyLink} onDelete={deleteFiles} />
|
||
)}
|
||
|
||
<div className={`dropzone ${globalDrag ? "on" : ""}`}>
|
||
<Icon name="cloud" size={48} stroke={1.4} />
|
||
<div className="t">파일을 드롭하여 업로드</div>
|
||
<div className="s">최대 2GB · 다중 파일 지원</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{selected.size > 0 && (
|
||
<SelectionBar count={selected.size} onClear={() => setSelected(new Set())}
|
||
onDownload={onBulkDownload} onShare={onBulkShare} onDelete={onBulkDelete}
|
||
isAdmin={currentUser?.isAdmin} />
|
||
)}
|
||
|
||
{uploadOpen && <UploadModal onClose={() => setUploadOpen(false)} onUploaded={onUploaded} accent={t.accent} categories={categories} users={[]} />}
|
||
|
||
{previewing && (
|
||
<PreviewModal
|
||
file={previewing}
|
||
onClose={() => setPreviewing(null)}
|
||
onDownload={f => { onDownload(f); setPreviewing(null); }}
|
||
onStar={id => toggleStar(id)}
|
||
onCopyLink={onCopyLink}
|
||
accent={t.accent}
|
||
categories={categories}
|
||
/>
|
||
)}
|
||
|
||
<Toast msg={toast.msg} on={toast.on} />
|
||
|
||
<TweaksPanel title="Tweaks">
|
||
<TweakSection label="브랜드" />
|
||
<TweakColor label="액센트 컬러" value={t.accent}
|
||
options={['#3b5bdb', '#1a1a1a', '#1f8a5b', '#d97757', '#7c5cff', '#e91e63']}
|
||
onChange={v => setTweak('accent', v)} />
|
||
<TweakSection label="레이아웃" />
|
||
<TweakRadio label="밀도" value={t.density}
|
||
options={['compact', 'regular', 'comfy']}
|
||
onChange={v => setTweak('density', v)} />
|
||
<TweakSlider label="모서리 둥글기" value={t.cornerRadius} min={0} max={16} unit="px"
|
||
onChange={v => setTweak('cornerRadius', v)} />
|
||
<TweakSection label="콘텐츠" />
|
||
<TweakToggle label="최근 다운로드 표시" value={t.showRecent}
|
||
onChange={v => setTweak('showRecent', v)} />
|
||
</TweaksPanel>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||
</script>
|
||
</body>
|
||
</html>
|