Files
jaryo/index.html
T
vibsin9322 c6cb9eeaf6 feat: 첨부파일 다중 업로드, 관리자 전용 업로드, 파일유형별 다운로드 아이콘
- 업로드시 제목 입력 + 첨부파일 최대 3개 등록 가능 (posts/attachments 구조 도입)
- 개별 첨부파일 다운로드 API 및 UI 추가 (일반/관리자 페이지 모두)
- 파일 업로드는 관리자만 가능하도록 서버 인증 적용, 일반 페이지 업로드 버튼 제거
- 다운로드 아이콘을 파일 유형(문서/이미지/동영상/압축/코드)별로 색상·아이콘 구분
- 관리자 페이지 강조 버튼 hover 시 배경색 사라지던 CSS 버그 수정

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 18:10:51 +09:00

1458 lines
78 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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:32px;height:32px;border-radius:7px;background:#fff;display:grid;place-items:center;flex-shrink:0;overflow:hidden}
.sb-brand-mark img{width:100%;height:100%;object-fit:contain}
.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 118px 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}
.list-dl{display:flex;align-items:center;gap:4px}
.dlbtn{width:22px;height:22px;border:1px solid var(--border-strong);border-radius:5px;color:var(--muted);background:var(--surface);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
.dlbtn:hover{filter:brightness(0.96)}
.dlbtn.dlbtn-doc{background:#fef4e6;color:#b56b18;border-color:transparent}
.dlbtn.dlbtn-img{background:#e8f4ee;color:#1f7a52;border-color:transparent}
.dlbtn.dlbtn-vid{background:#1a1a1a;color:#fff;border-color:transparent}
.dlbtn.dlbtn-zip{background:#f3eed9;color:#7a6418;border-color:transparent}
.dlbtn.dlbtn-code{background:#0f1115;color:#a3b8ff;border-color:transparent}
.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)}
/* 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}
/* 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 .infogrp .row.att{padding:6px 8px;margin:0 -8px;border-radius:6px;gap:10px}
.preview-info .infogrp .row.att:hover{background:var(--surface-2);color:var(--accent)}
.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"/></>,
file: <><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></>,
user: <><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></>,
"log-out": <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></>,
edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></>,
};
const fallback = paths['folder'];
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" {...rest}>
{paths[name] || fallback}
</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 TYPE_ICON = { doc: 'file', img: 'image', vid: 'video', zip: 'folder', code: 'code' };
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> () =&gt; {'{'}</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"><img src="/DH.png" alt="로고" /></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 }}>
<div
className={`sb-item ${activeCat === null ? "active" : ""}`}
onClick={() => { setActiveCat(null); onClose?.(); }}>
<Icon name="folder-open" size={15} className="ico" />
<span className="lbl">전체</span>
<span className="cnt">{folders.all ?? 0}</span>
</div>
{categories.map(c => (
<div key={c.id}
className={`sb-item ${activeCat === c.id ? "active" : ""}`}
onClick={() => { setActiveCat(c.id); 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, 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>
</>
)}
</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, onDownloadAttachment, 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 118px 52px"
: "1fr 100px 130px 130px 118px 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-dl" onClick={e => e.stopPropagation()}>
{(f.attachments || []).map(a => (
<button key={a.id} className={`dlbtn dlbtn-${a.type}`} title={`${a.name} 다운로드`}
onClick={() => onDownloadAttachment(a)}>
<Icon name={TYPE_ICON[a.type] || 'file'} size={11} />
</button>
))}
{(!f.attachments || f.attachments.length === 0) && <span className="pth"> {f.downloads}</span>}
</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 PreviewModal = ({ file, onClose, onDownload, onDownloadAttachment, 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>
{file.attachments && file.attachments.length > 0 && (
<div className="infogrp">
<span className="lbl">첨부파일 ({file.attachments.length})</span>
{file.attachments.map(a => (
<div key={a.id} className="row att" onClick={() => onDownloadAttachment(a)}>
<span style={{ display:"flex", alignItems:"center", gap:8, minWidth:0 }}>
<span className={`dlbtn dlbtn-${a.type}`} style={{ flexShrink:0 }}><Icon name={TYPE_ICON[a.type] || 'file'} size={11} /></span>
<span style={{ overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{a.name}</span>
</span>
<span style={{ display:"flex", alignItems:"center", gap:6, flexShrink:0 }}>
{a.size_label}<Icon name="download" size={12} />
</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 [toast, setToast] = useState({ on: false, msg: "" });
const [currentUser, setCurrentUser] = useState(() => {
try { return JSON.parse(sessionStorage.getItem('jaryo_user') || 'null'); } catch { return 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]);
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); };
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 onDownloadAttachment = a => {
const el = document.createElement('a');
el.href = `/api/attachments/${a.id}/download`;
el.style.display = 'none';
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
setFiles(prev => prev.map(x => x.id === a.file_id ? { ...x, downloads: x.downloads + 1 } : x));
if (previewing?.id === a.file_id) setPreviewing(p => ({ ...p, downloads: (p.downloads || 0) + 1 }));
showToast(`${a.name} 다운로드를 시작합니다`);
};
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]);
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}
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} onDownloadAttachment={onDownloadAttachment} onCopyLink={onCopyLink} onDelete={deleteFiles} />
)}
</div>
</div>
{selected.size > 0 && (
<SelectionBar count={selected.size} onClear={() => setSelected(new Set())}
onDownload={onBulkDownload} onShare={onBulkShare} onDelete={onBulkDelete}
isAdmin={currentUser?.isAdmin} />
)}
{previewing && (
<PreviewModal
file={previewing}
onClose={() => setPreviewing(null)}
onDownload={f => { onDownload(f); setPreviewing(null); }}
onDownloadAttachment={onDownloadAttachment}
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>