<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<title>拍攝順序表</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Noto+Serif+TC:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.2/babel.min.js"></script>
<script type="module">
import{initializeApp}from"https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js";
import{getFirestore,collection,onSnapshot,doc,setDoc,deleteDoc,getDoc}from"https://www.gstatic.com/firebasejs/10.12.0/firebase-firestore.js";
import{getStorage,ref,uploadBytes,getDownloadURL}from"https://www.gstatic.com/firebasejs/10.12.0/firebase-storage.js";
const cfg={apiKey:"AIzaSyD6w6kUV_19R9JAwXAn3AiD3o60lkrs9aI",authDomain:"shooting-schedule-44f99.firebaseapp.com",projectId:"shooting-schedule-44f99",storageBucket:"shooting-schedule-44f99.firebasestorage.app",messagingSenderId:"186340238913",appId:"1:186340238913:web:89d8858db47a81e24888c0"};
const app=initializeApp(cfg);
window.__fb={db:getFirestore(app),storage:getStorage(app),collection,onSnapshot,doc,setDoc,deleteDoc,getDoc,ref,uploadBytes,getDownloadURL};
</script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html{font-size:18px}
body{background:#f2efe9;color:#1a1612;font-family:'Noto Serif TC',serif;min-height:100vh}
input,select,textarea,button{font-family:'Noto Serif TC',serif;font-size:16px}
input::placeholder,textarea::placeholder{color:#bbb}
/* ── TABS ── */
.tabs{display:flex;background:#1a1612;padding:0 32px}
.tab{padding:16px 28px;font-size:17px;font-weight:700;color:#a09070;cursor:pointer;border-bottom:3px solid transparent;transition:all .15s;letter-spacing:.04em}
.tab.active{color:#f4f1ec;border-bottom-color:#c8a96e}
.tab:hover:not(.active){color:#d0c0a0}
/* ── ANNOUNCEMENT ── */
.ann-bar{background:#1e0a3c;padding:20px 32px;display:flex;align-items:center;gap:16px;border-bottom:3px solid #7c3aed;animation:slideDown .3s ease}
@keyframes slideDown{from{opacity:0;transform:translateY(-100%)}to{opacity:1;transform:translateY(0)}}
.ann-msg{font-size:22px;font-weight:700;color:#ede9fe;flex:1}
.ann-close{background:#7c3aed;border:none;color:#fff;border-radius:8px;padding:10px 20px;font-size:16px;cursor:pointer;font-family:inherit;font-weight:700}
/* ── PROGRESS ── */
.prog-hero{background:#fff;border-bottom:2px solid #e5e0d8;padding:28px 36px}
.prog-inner{max-width:1400px;margin:0 auto;display:flex;align-items:center;gap:48px;flex-wrap:wrap}
.prog-pct{font-family:‘DM Mono’,monospace;font-size:80px;font-weight:500;color:#1a1612;line-height:1;letter-spacing:-.04em}
.prog-pct-label{font-size:14px;color:#888;letter-spacing:.15em;text-transform:uppercase;font-family:‘DM Mono’,monospace;margin-top:4px}
.prog-bar-area{flex:1;min-width:260px}
.prog-bar-title{font-size:14px;color:#666;letter-spacing:.15em;text-transform:uppercase;font-family:‘DM Mono’,monospace;margin-bottom:12px}
.prog-track{background:#e5e0d8;border-radius:8px;height:16px;overflow:hidden}
.prog-fill{height:100%;border-radius:8px;background:linear-gradient(90deg,#1a1612,#8b6a40);transition:width 1s cubic-bezier(.16,1,.3,1)}
.prog-nums{display:flex;gap:32px;margin-top:16px;flex-wrap:wrap}
.pn-val{font-family:‘DM Mono’,monospace;font-size:26px;font-weight:500;color:#1a1612}
.pn-label{font-size:13px;color:#888;letter-spacing:.1em;text-transform:uppercase;margin-top:2px}
.now-card{background:#fffbeb;border:2px solid #f59e0b;border-radius:14px;padding:18px 28px;text-align:center}
.now-label{font-size:13px;color:#92400e;letter-spacing:.2em;text-transform:uppercase;font-family:‘DM Mono’,monospace;margin-bottom:8px}
.now-val{font-family:‘DM Mono’,monospace;font-size:28px;font-weight:500;color:#92400e}
/* ── ANN PANEL ── */
.ann-panel{background:#f9f7f3;border-bottom:2px solid #e5e0d8;padding:16px 36px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.ann-toggle{background:#1e0a3c;border:none;color:#ede9fe;border-radius:8px;padding:12px 22px;font-size:16px;font-weight:700;cursor:pointer;white-space:nowrap}
.ann-input{flex:1;min-width:240px;background:#fff;border:2px solid #d1d5db;border-radius:8px;color:#1a1612;padding:11px 16px;font-size:17px;outline:none;transition:border-color .2s}
.ann-input:focus{border-color:#7c3aed}
.ann-send{background:#7c3aed;border:none;color:#fff;border-radius:8px;padding:12px 26px;font-size:16px;font-weight:700;cursor:pointer;white-space:nowrap}
.ann-clear{background:#fff;border:2px solid #e5e0d8;color:#888;border-radius:8px;padding:11px 18px;font-size:15px;cursor:pointer;white-space:nowrap}
/* ── HEADER ── */
.header{background:#fff;border-bottom:2px solid #e5e0d8;position:sticky;top:0;z-index:100;box-shadow:0 2px 16px rgba(0,0,0,.07)}
.header-inner{max-width:1400px;margin:0 auto;padding:16px 36px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}
.site-title{font-size:24px;font-weight:700;color:#1a1612;cursor:pointer;border-bottom:2px solid transparent;transition:border-color .2s}
.site-title:hover{border-bottom-color:#1a1612}
.title-edit{background:transparent;border:none;border-bottom:2px solid #1a1612;color:#1a1612;font-family:inherit;font-size:24px;font-weight:700;outline:none;width:360px}
.live-badge{display:flex;align-items:center;gap:8px;background:#dcfce7;border:2px solid #86efac;border-radius:20px;padding:6px 16px}
.live-dot{width:8px;height:8px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.live-txt{font-size:14px;color:#15803d;font-family:‘DM Mono’,monospace;font-weight:500}
/* ── CONTENT ── */
.content{max-width:1400px;margin:0 auto;padding:28px 36px 60px}
/* ── PHOTO UPLOAD ── */
.photo-section{margin-bottom:32px}
.section-title{font-size:20px;font-weight:700;color:#1a1612;margin-bottom:14px;letter-spacing:.02em}
.photo-drop{
border:3px dashed #d1c9be;border-radius:16px;
background:#fff;padding:36px;
text-align:center;cursor:pointer;
transition:all .2s;position:relative;overflow:hidden;
}
.photo-drop:hover,.photo-drop.drag-over{border-color:#1a1612;background:#faf8f5}
.photo-drop-icon{font-size:40px;margin-bottom:12px}
.photo-drop-text{font-size:18px;font-weight:700;color:#555;margin-bottom:4px}
.photo-drop-sub{font-size:15px;color:#aaa}
.photo-input{position:absolute;inset:0;opacity:0;cursor:pointer}
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;margin-top:16px}
.photo-item{position:relative;border-radius:10px;overflow:hidden;aspect-ratio:4/3;border:2px solid #e5e0d8}
.photo-item img{width:100%;height:100%;object-fit:cover}
.photo-del{position:absolute;top:6px;right:6px;background:rgba(0,0,0,.7);color:#fff;border:none;border-radius:50%;width:28px;height:28px;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center}
.photo-uploading{display:flex;align-items:center;justify-content:center;height:100%;background:#f4f1ec;font-size:13px;color:#888;font-family:‘DM Mono’,monospace}
/* ── CREW ── */
.crew-section{margin-bottom:32px}
.crew-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}
.crew-card{background:#fff;border:2px solid #e5e0d8;border-radius:14px;padding:20px 22px}
.crew-role{font-size:13px;font-weight:700;color:#888;letter-spacing:.2em;text-transform:uppercase;font-family:‘DM Mono’,monospace;margin-bottom:8px}
.crew-name{font-size:20px;font-weight:700;color:#1a1612;margin-bottom:6px}
.crew-phone{font-size:18px;color:#555;font-family:‘DM Mono’,monospace;margin-bottom:4px}
.crew-note{font-size:15px;color:#888}
.crew-edit-btn{background:transparent;border:2px solid #e5e0d8;color:#888;border-radius:8px;padding:6px 14px;font-size:14px;cursor:pointer;margin-top:10px;transition:all .15s}
.crew-edit-btn:hover{border-color:#1a1612;color:#1a1612}
.crew-add-card{background:#faf8f5;border:2px dashed #d1c9be;border-radius:14px;padding:20px 22px;display:flex;align-items:center;justify-content:center;cursor:pointer;min-height:120px;transition:all .2s;font-size:17px;color:#bbb;font-weight:700}
.crew-add-card:hover{border-color:#1a1612;color:#1a1612;background:#fff}
.crew-ei{background:#f9f7f3;border:2px solid #e5e0d8;border-radius:8px;color:#1a1612;padding:8px 12px;font-size:16px;outline:none;width:100%;margin-bottom:8px;transition:border-color .2s}
.crew-ei:focus{border-color:#1a1612}
/* ── DAYS ── */
.day-section{margin-bottom:24px}
.day-header{
display:flex;align-items:center;gap:16px;
background:#1a1612;color:#f4f1ec;
border-radius:12px;padding:16px 24px;
margin-bottom:0;cursor:pointer;
transition:background .15s;
}
.day-header:hover{background:#2d2520}
.day-num{font-family:‘DM Mono’,monospace;font-size:22px;font-weight:500;letter-spacing:.05em;flex-shrink:0}
.day-date-edit{background:transparent;border:none;border-bottom:1px solid #666;color:#c8a96e;font-family:‘DM Mono’,monospace;font-size:16px;outline:none;width:120px;letter-spacing:.04em}
.day-date-text{font-family:‘DM Mono’,monospace;font-size:16px;color:#c8a96e;letter-spacing:.04em;cursor:text}
.day-location{font-size:17px;color:#c8a96e;flex:1}
.day-stats{font-family:‘DM Mono’,monospace;font-size:14px;color:#888;margin-left:auto;white-space:nowrap}
.day-collapse{font-size:18px;color:#888;margin-left:8px;flex-shrink:0}
.day-add-scene{
background:#fff;border:2px dashed #d1c9be;
border-radius:0 0 12px 12px;
padding:12px 24px;text-align:center;
cursor:pointer;font-size:16px;color:#aaa;
transition:all .15s;margin-top:0;
}
.day-add-scene:hover{border-color:#1a1612;color:#1a1612;background:#faf8f5}
.add-day-btn{
background:#fff;border:3px dashed #d1c9be;
border-radius:14px;padding:20px;
text-align:center;cursor:pointer;
font-size:18px;font-weight:700;color:#bbb;
transition:all .2s;width:100%;
}
.add-day-btn:hover{border-color:#1a1612;color:#1a1612;background:#faf8f5}
/* ── SCENE TABLE ── */
.scene-table{background:#fff;border:2px solid #e5e0d8;border-radius:0 0 12px 12px;overflow:hidden;margin-top:0}
.scene-table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
.s-head{display:grid;grid-template-columns:var(–g);background:#f4f1ec;border-bottom:2px solid #e5e0d8;padding:0 20px;min-width:1000px}
.s-th{padding:12px 10px;font-family:‘DM Mono’,monospace;font-size:11px;letter-spacing:.18em;color:#666;text-transform:uppercase;font-weight:500}
.s-th-sub{font-size:10px;color:#aaa;letter-spacing:.1em;margin-top:1px}
.s-row{display:grid;grid-template-columns:var(–g);padding:0 20px;border-bottom:2px solid #f0ebe3;min-width:1000px;transition:background .12s;animation:rowIn .2s ease forwards;opacity:0}
@keyframes rowIn{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}
.s-row:last-child{border-bottom:none}
.s-row:hover{background:#faf8f5}
.s-row.shooting{background:#fffbeb;border-left:4px solid #f59e0b}
.s-row.done{opacity:.5}
.s-row.dragging{opacity:.2}
.s-row.dragover{outline:2px solid #1a1612;background:#f4f1ec}
.s-td{padding:16px 10px;display:flex;align-items:center;min-width:0}
.s-tdc{flex-direction:column;align-items:flex-start;justify-content:center;gap:5px}
/* scene cell text */
.c-order{font-family:‘DM Mono’,monospace;font-size:13px;color:#ccc;user-select:none}
.c-scene{font-family:‘DM Mono’,monospace;font-size:18px;font-weight:500;color:#1a1612;letter-spacing:.06em}
.c-loc{font-size:17px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.c-cast{font-size:15px;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.c-pages{font-family:‘DM Mono’,monospace;font-size:16px;color:#888}
.c-time{display:inline-flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:8px;font-size:16px;font-weight:700;border:2px solid transparent}
.c-shot-a{font-size:16px;color:#1d4ed8;font-weight:500}
.c-shot-b{font-family:‘DM Mono’,monospace;font-size:13px;color:#888}
.c-cam-a{font-size:16px;color:#15803d;font-weight:500}
.c-cam-b{font-size:13px;color:#888}
.c-vfx{display:inline-block;padding:4px 12px;border-radius:20px;font-size:13px;font-weight:700;letter-spacing:.04em;font-family:‘DM Mono’,monospace;border:2px solid transparent}
.c-vfx-note{font-size:12px;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px}
.c-stat{display:inline-flex;align-items:center;gap:4px;padding:5px 14px;border-radius:20px;font-size:13px;font-weight:700;letter-spacing:.06em;font-family:‘DM Mono’,monospace;white-space:nowrap;border:2px solid transparent}
.c-qbtns{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}
.c-qbtn{font-size:12px;padding:3px 9px;border-radius:10px;border:2px solid currentColor;background:transparent;cursor:pointer;font-family:‘DM Mono’,monospace;opacity:.55;transition:opacity .12s;letter-spacing:.04em;white-space:nowrap}
.c-qbtn:hover{opacity:1}
.c-acts{display:flex;flex-direction:column;gap:5px}
.c-abtn{font-size:13px;font-family:‘DM Mono’,monospace;padding:6px 10px;border-radius:6px;cursor:pointer;letter-spacing:.04em;border:2px solid currentColor;background:transparent;transition:opacity .12s;white-space:nowrap}
.c-abtn:hover{opacity:.7}
.c-ae{color:#92400e}.c-an{color:#1d4ed8}.c-ad{color:#b91c1c}.c-as{color:#15803d}.c-ac{color:#888}
/* edit inputs */
.ei{background:#fff;border:2px solid #d1d5db;border-radius:6px;color:#1a1612;padding:7px 10px;font-family:inherit;font-size:16px;outline:none;width:100%;transition:border-color .2s}
.ei:focus{border-color:#1a1612}
.es{background:#fff;border:2px solid #d1d5db;border-radius:6px;color:#1a1612;padding:7px 8px;font-family:inherit;font-size:16px;outline:none;width:100%;cursor:pointer}
/* notes */
.s-notes{background:#faf8f5;border-bottom:2px solid #f0ebe3;padding:12px 56px 16px;display:flex;gap:32px;flex-wrap:wrap;min-width:1000px}
.nc-label{font-family:‘DM Mono’,monospace;font-size:10px;letter-spacing:.2em;color:#aaa;text-transform:uppercase;margin-bottom:4px}
.nc-val{font-size:16px;color:#555}
/* new scene row */
.s-new{display:grid;grid-template-columns:var(–g);padding:0 20px;background:#fffbeb;border-top:2px solid #f59e0b44;min-width:1000px}
/* controls */
.controls{display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:14px 36px;background:#f9f7f3;border-bottom:2px solid #e5e0d8}
.search{flex:1;min-width:200px;background:#fff;border:2px solid #d1d5db;border-radius:8px;color:#1a1612;padding:11px 16px;font-size:17px;font-family:inherit;outline:none;transition:border-color .2s}
.search:focus{border-color:#1a1612}
.fbtn{font-family:‘DM Mono’,monospace;font-size:14px;background:#fff;border:2px solid #d1d5db;color:#666;border-radius:7px;padding:9px 16px;cursor:pointer;transition:all .12s;white-space:nowrap}
.fbtn:hover{border-color:#1a1612;color:#1a1612}
.fbtn.fat{background:#1a1612;border-color:#1a1612;color:#fff;font-weight:600}
.fbtn.fas{background:#dcfce7;border-color:#16a34a;color:#15803d}
/* modal */
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:200;display:flex;align-items:center;justify-content:center;padding:24px}
.modal{background:#fff;border-radius:18px;padding:32px;width:100%;max-width:480px;box-shadow:0 24px 80px rgba(0,0,0,.2)}
.modal-title{font-size:22px;font-weight:700;margin-bottom:24px}
.modal-row{margin-bottom:16px}
.modal-label{font-size:14px;color:#888;letter-spacing:.1em;text-transform:uppercase;font-family:‘DM Mono’,monospace;margin-bottom:6px}
.modal-btns{display:flex;gap:10px;margin-top:24px}
.mbtn-save{flex:1;background:#1a1612;border:none;color:#f4f1ec;border-radius:8px;padding:13px;font-size:17px;font-weight:700;cursor:pointer}
.mbtn-cancel{background:#fff;border:2px solid #e5e0d8;color:#888;border-radius:8px;padding:13px 20px;font-size:17px;cursor:pointer}
.mbtn-del{background:#fee2e2;border:2px solid #fca5a5;color:#b91c1c;border-radius:8px;padding:13px 16px;font-size:17px;cursor:pointer}
/* legend */
.legend{display:flex;justify-content:space-between;align-items:flex-start;margin-top:20px;flex-wrap:wrap;gap:14px}
.lg{display:flex;gap:24px;flex-wrap:wrap}
.ls-label{font-family:‘DM Mono’,monospace;font-size:11px;letter-spacing:.2em;color:#aaa;text-transform:uppercase;margin-bottom:6px}
.litems{display:flex;gap:14px;flex-wrap:wrap}
.li{display:flex;align-items:center;gap:6px;font-family:‘DM Mono’,monospace;font-size:13px;color:#666}
.ldot{width:9px;height:9px;border-radius:2px}
.tip{font-family:‘DM Mono’,monospace;font-size:13px;color:#bbb;align-self:flex-end}
.empty{text-align:center;padding:40px;color:#bbb;font-size:16px;font-family:‘DM Mono’,monospace;letter-spacing:.08em}
.loading{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:16px;color:#666;font-family:‘DM Mono’,monospace;font-size:14px;letter-spacing:.3em;text-transform:uppercase}
</style>
</head>
<body>
<div id="root"><div class="loading"><div class="live-dot" style="width:14px;height:14px;background:#1a1612"></div>連線中</div></div>
<script type="text/babel" data-type="module">
const{useState,useEffect,useRef,useCallback}=React;
const TL=["日","夜","晨","昏"];
const TC={"日":{bg:"#fef9c3",bd:"#fbbf24",tx:"#92400e"},"夜":{bg:"#dbeafe",bd:"#60a5fa",tx:"#1e40af"},"晨":{bg:"#ffedd5",bd:"#fb923c",tx:"#9a3412"},"昏":{bg:"#fee2e2",bd:"#f87171",tx:"#991b1b"}};
const SHOTS=["特寫","大特寫","中近景","中景","中全景","全景","大全景","過肩","主觀鏡","空拍"];
const LENS=["14mm","24mm","35mm","50mm","85mm","100mm","135mm","變焦"];
const CAMS=["A機","B機","A機+B機","手持A機","手持B機","空拍機","特殊攝影"];
const MOVS=["固定","跟拍","推軌","升降","手持","搖鏡","Steadicam","空拍"];
const VFX=["無","合成背景","螢幕合成","去背/摳像","CG生物","爆炸特效","天氣特效","時間加速","其他"];
const VS={"無":{bg:"#dcfce7",bd:"#86efac",tx:"#15803d"},"合成背景":{bg:"#ede9fe",bd:"#a78bfa",tx:"#5b21b6"},"螢幕合成":{bg:"#dbeafe",bd:"#60a5fa",tx:"#1e40af"},"去背/摳像":{bg:"#ffedd5",bd:"#fb923c",tx:"#9a3412"},"CG生物":{bg:"#fee2e2",bd:"#f87171",tx:"#991b1b"},"爆炸特效":{bg:"#fff7ed",bd:"#f97316",tx:"#9a3412"},"天氣特效":{bg:"#ecfeff",bd:"#22d3ee",tx:"#155e75"},"時間加速":{bg:"#fdf4ff",bd:"#e879f9",tx:"#86198f"},"其他":{bg:"#f1f5f9",bd:"#94a3b8",tx:"#475569"}};
const ST=["待拍","拍攝中","已完成","跳過"];
const SS={"待拍":{bg:"#f9fafb",bd:"#d1d5db",tx:"#6b7280"},"拍攝中":{bg:"#fffbeb",bd:"#fbbf24",tx:"#92400e"},"已完成":{bg:"#dcfce7",bd:"#86efac",tx:"#15803d"},"跳過":{bg:"#f9fafb",bd:"#d1d5db",tx:"#9ca3af"}};
const SGRID="40px 88px 1fr 56px 1fr 52px 114px 114px 120px 140px 96px";
const CREW_ROLES=["導演","副導演","攝影師","製片","場記","燈光師","收音師","美術","化妝","服裝","演員統籌","其他"];
let _id=Date.now();
const uid=()=>"x"+(++_id);
const empScene=(dayId,order)=>({id:uid(),dayId,scene:"",location:"",time:"日",cast:"",pages:1.0,notes:"",shotType:"中景",lens:"50mm",camera:"A機",movement:"固定",vfx:"無",vfxNote:"",status:"待拍",order});
const empDay=(order)=>({id:uid(),order,date:"",location:"",collapsed:false});
const empCrew=()=>({id:uid(),role:"導演",name:"",phone:"",note:""});
function waitFB(){return new Promise(r=>{const c=()=>window.__fb?r(window.__fb):setTimeout(c,50);c();})}
function Sel({opts,val,set}){return<select className="es" value={val} onChange={e=>set(e.target.value)}>{opts.map(o=><option key={o}>{o}</option>)}</select>}
function In({v,set,ph,type="text",step,min,w,cls="ei"}){return<input className={cls} type={type} step={step} min={min} value={v} onChange={e=>set(e.target.value)} placeholder={ph} style={w?{width:w}:{}}/>}
/* ── CREW MODAL ── */
function CrewModal({crew,onSave,onDelete,onClose}){
const[form,setForm]=useState(crew||empCrew());
const s=fn=>setForm(f=>fn(f));
return(
<div className="modal-bg" onClick={e=>{if(e.target===e.currentTarget)onClose()}}>
<div className="modal">
<div className="modal-title">{crew?"編輯組員":"新增組員"}</div>
<div className="modal-row"><div className="modal-label">職稱</div><Sel opts={CREW_ROLES} val={form.role} set={v=>s(f=>({...f,role:v}))}/></div>
<div className="modal-row"><div className="modal-label">姓名</div><In cls="ei" v={form.name} set={v=>s(f=>({...f,name:v}))} ph="姓名"/></div>
<div className="modal-row"><div className="modal-label">電話</div><In cls="ei" v={form.phone} set={v=>s(f=>({...f,phone:v}))} ph="手機號碼" type="tel"/></div>
<div className="modal-row"><div className="modal-label">備註</div><In cls="ei" v={form.note} set={v=>s(f=>({...f,note:v}))} ph="備註(可留空)"/></div>
<div className="modal-btns">
{crew&&<button className="mbtn-del" onClick={()=>onDelete(crew.id)}>刪除</button>}
<button className="mbtn-cancel" onClick={onClose}>取消</button>
<button className="mbtn-save" onClick={()=>onSave(form)}>儲存</button>
</div>
</div>
</div>
);
}
/* ── DAY MODAL ── */
function DayModal({day,onSave,onDelete,onClose}){
const[form,setForm]=useState(day||empDay(1));
const s=fn=>setForm(f=>fn(f));
return(
<div className="modal-bg" onClick={e=>{if(e.target===e.currentTarget)onClose()}}>
<div className="modal">
<div className="modal-title">{day?"編輯拍攝日":"新增拍攝日"}</div>
<div className="modal-row"><div className="modal-label">日期</div><In cls="ei" v={form.date} set={v=>s(f=>({...f,date:v}))} ph="例如:2026/05/20(週三)"/></div>
<div className="modal-row"><div className="modal-label">拍攝地點概述</div><In cls="ei" v={form.location} set={v=>s(f=>({...f,location:v}))} ph="例如:台北內湖攝影棚"/></div>
<div className="modal-btns">
{day&&<button className="mbtn-del" onClick={()=>onDelete(day.id)}>刪除此拍攝日</button>}
<button className="mbtn-cancel" onClick={onClose}>取消</button>
<button className="mbtn-save" onClick={()=>onSave(form)}>儲存</button>
</div>
</div>
</div>
);
}
function App(){
const[days,setDays]=useState([]);
const[scenes,setScenes]=useState([]);
const[crew,setCrew]=useState([]);
const[photos,setPhotos]=useState([]);
const[meta,setMeta]=useState({projectName:"《無名之城》拍攝順序表",banner:"",bannerActive:false,currentScene:""});
const[ready,setReady]=useState(false);
const[tab,setTab]=useState("schedule");
const[eId,setEId]=useState(null);
const[eForm,setEForm]=useState(null);
const[exp,setExp]=useState(null);
const[drag,setDrag]=useState(null);
const[over,setOver]=useState(null);
const[sFilter,setSFilter]=useState("全部");
const[annTxt,setAnnTxt]=useState("");
const[showAnn,setShowAnn]=useState(false);
const[editT,setEditT]=useState(false);
const[crewModal,setCrewModal]=useState(null);// null | 'new' | crew obj
const[dayModal,setDayModal]=useState(null);
const[photoDrag,setPhotoDrag]=useState(false);
const[uploading,setUploading]=useState(false);
const[collapsed,setCollapsed]=useState({});
const fb=useRef(null);
const seeded=useRef(false);
const fileRef=useRef();
useEffect(()=>{
waitFB().then(f=>{
fb.current=f;setReady(true);
const{db,collection,onSnapshot,doc}=f;
onSnapshot(collection(db,"days"),snap=>{setDays(snap.docs.map(d=>d.data()).sort((a,b)=>a.order-b.order));});
onSnapshot(collection(db,"scenes"),snap=>{
if(snap.empty&&!seeded.current){
seeded.current=true;
const d1={id:"d1",order:1,date:"2026/05/20(週三)",location:"台北內湖攝影棚"};
const d2={id:"d2",order:2,date:"2026/05/21(週四)",location:"信義區外景"};
f.setDoc(doc(db,"days","d1"),d1);f.setDoc(doc(db,"days","d2"),d2);
[{id:"s1",dayId:"d1",scene:"A-01",location:"咖啡廳內景",time:"日",cast:"主角、女配角",pages:2.5,notes:"需要特調咖啡道具",shotType:"中景",lens:"50mm",camera:"A機",movement:"固定",vfx:"無",vfxNote:"",status:"待拍",order:1},
{id:"s2",dayId:"d1",scene:"A-02",location:"吧台特寫",time:"日",cast:"主角",pages:1.0,notes:"",shotType:"特寫",lens:"85mm",camera:"A機",movement:"固定",vfx:"無",vfxNote:"",status:"待拍",order:2},
{id:"s3",dayId:"d2",scene:"B-03",location:"街道外景",time:"夜",cast:"主角",pages:1.0,notes:"需備雨衣",shotType:"全景",lens:"35mm",camera:"A機+B機",movement:"跟拍",vfx:"合成背景",vfxNote:"城市燈光",status:"待拍",order:1}
].forEach(s=>f.setDoc(doc(db,"scenes",s.id),s));
return;
}
setScenes(snap.docs.map(d=>d.data()));
});
onSnapshot(collection(db,"crew"),snap=>{setCrew(snap.docs.map(d=>d.data()));});
onSnapshot(collection(db,"photos"),snap=>{setPhotos(snap.docs.map(d=>d.data()).sort((a,b)=>a.order-b.order));});
onSnapshot(doc(db,"meta","main"),snap=>{if(snap.exists())setMeta(snap.data());});
});
},[]);
const fset=(col,id,data)=>fb.current.setDoc(fb.current.doc(fb.current.db,col,id),data);
const fdel=(col,id)=>fb.current.deleteDoc(fb.current.doc(fb.current.db,col,id));
const fm=p=>{const n={...meta,...p};setMeta(n);fset("meta","main",n);};
const send=()=>{if(!annTxt.trim())return;fm({banner:annTxt,bannerActive:true});setAnnTxt("");setShowAnn(false);};
// scenes for a day
const dayScenes=dayId=>scenes.filter(s=>s.dayId===dayId).sort((a,b)=>a.order-b.order);
const startEdit=s=>{setEId(s.id);setEForm({...s});};
const saveEdit=()=>{fset("scenes",eForm.id,eForm);setEId(null);setEForm(null);};
const cancelEdit=()=>{setEId(null);setEForm(null);};
const delScene=id=>{if(!confirm("確定刪除?"))return;fdel("scenes",id);};
const addScene=dayId=>{
const ds=dayScenes(dayId);
const max=ds.length?Math.max(...ds.map(s=>s.order)):0;
const s=empScene(dayId,max+1);
fset("scenes",s.id,s);
setEId(s.id);setEForm({...s});
};
const setStatus=async(sc,status)=>{await fset("scenes",sc.id,{...sc,status});if(status==="拍攝中")fm({currentScene:sc.scene});};
const moveScene=async(fId,tId,dayId)=>{
if(fId===tId)return;
const arr=dayScenes(dayId);
const fi=arr.findIndex(s=>s.id===fId),ti=arr.findIndex(s=>s.id===tId);
if(fi<0||ti<0)return;
const[m]=arr.splice(fi,1);arr.splice(ti,0,m);
await Promise.all(arr.map((s,i)=>fset("scenes",s.id,{...s,order:i+1})));
};
// crew
const saveCrew=form=>{fset("crew",form.id,form);setCrewModal(null);};
const delCrew=id=>{if(!confirm("確定刪除?"))return;fdel("crew",id);setCrewModal(null);};
// days
const saveDay=form=>{fset("days",form.id,form);setDayModal(null);};
const delDay=id=>{if(!confirm("確定刪除此拍攝日?所有場次不受影響。"))return;fdel("days",id);setDayModal(null);};
const addDay=()=>{
const max=days.length?Math.max(...days.map(d=>d.order)):0;
const d=empDay(max+1);
setDayModal({mode:"new",day:d});
};
const saveDayModal=form=>{fset("days",form.id,form);setDayModal(null);};
// photos
const handleFiles=async files=>{
if(!files||!files.length)return;
setUploading(true);
const{storage,ref,uploadBytes,getDownloadURL}=fb.current;
for(const file of Array.from(files)){
if(!file.type.startsWith("image/"))continue;
const r=ref(storage,`photos/${Date.now()}_${file.name}`);
await uploadBytes(r,file);
const url=await getDownloadURL(r);
const max=photos.length?Math.max(...photos.map(p=>p.order||0)):0;
const ph={id:uid(),url,name:file.name,order:max+1};
fset("photos",ph.id,ph);
}
setUploading(false);
};
const delPhoto=id=>{if(!confirm("確定刪除照片?"))return;fdel("photos",id);};
const tot=scenes.reduce((s,r)=>s+Number(r.pages),0);
const done=scenes.filter(s=>s.status==="已完成").length;
const vfxN=scenes.filter(s=>s.vfx!=="無").length;
const pct=scenes.length?Math.round(done/scenes.length*100):0;
if(!ready)return<div className="loading"><div className="live-dot" style={{width:"14px",height:"14px",background:"#1a1612"}}/><span>連線中</span></div>;
const ef=eForm||{};
const se=fn=>setEForm(f=>fn(f));
return(
<div>
{/* ── TABS ── */}
<div className="tabs">
{[["schedule","📋 拍攝順序表"],["crew","👥 組員聯絡"],["photos","📷 現場照片"]].map(([k,l])=>(
<div key={k} className={`tab${tab===k?" active":""}`} onClick={()=>setTab(k)}>{l}</div>
))}
</div>
{/* ── ANNOUNCEMENT ── */}
{meta.bannerActive&&meta.banner&&(
<div className="ann-bar">
<span style={{fontSize:"28px"}}>📢</span>
<span className="ann-msg">{meta.banner}</span>
<button className="ann-close" onClick={()=>fm({bannerActive:false})}>✕ 關閉</button>
</div>
)}
{/* ── PROGRESS ── */}
<div className="prog-hero">
<div className="prog-inner">
<div style={{textAlign:"center"}}>
<div className="prog-pct">{pct}%</div>
<div className="prog-pct-label">完成進度</div>
</div>
<div className="prog-bar-area">
<div className="prog-bar-title">拍攝完成進度</div>
<div className="prog-track"><div className="prog-fill" style={{width:`${pct}%`}}/></div>
<div className="prog-nums">
<div><div className="pn-val">{done}/{scenes.length}</div><div className="pn-label">已完成</div></div>
<div><div className="pn-val">{days.length}</div><div className="pn-label">拍攝天數</div></div>
<div><div className="pn-val">{tot.toFixed(1)}</div><div className="pn-label">總頁數</div></div>
<div><div className="pn-val">{vfxN}</div><div className="pn-label">特效場</div></div>
</div>
</div>
{meta.currentScene&&(
<div className="now-card">
<div className="now-label">NOW SHOOTING</div>
<div className="now-val">🎬 {meta.currentScene}</div>
</div>
)}
</div>
</div>
{/* ── ANN PANEL ── */}
<div className="ann-panel">
<button className="ann-toggle" onClick={()=>setShowAnn(p=>!p)}>📢 {showAnn?"收合":"發送全體公告"}</button>
{meta.bannerActive&&<button className="ann-clear" onClick={()=>fm({bannerActive:false})}>✕ 關閉公告</button>}
{showAnn&&<>
<input className="ann-input" value={annTxt} onChange={e=>setAnnTxt(e.target.value)}
placeholder="輸入公告… 例如:全體放飯!30 分鐘後集合 🍱"
onKeyDown={e=>{if(e.key==="Enter")send();}} autoFocus/>
<button className="ann-send" onClick={send}>立即發送</button>
</>}
</div>
{/* ── HEADER ── */}
<div className="header">
<div className="header-inner">
<div>
{editT
?<input className="title-edit" autoFocus value={meta.projectName} onChange={e=>fm({projectName:e.target.value})} onBlur={()=>setEditT(false)} onKeyDown={e=>{if(e.key==="Enter")setEditT(false);}}/>
:<div className="site-title" onClick={()=>setEditT(true)}>{meta.projectName}</div>
}
</div>
<div className="live-badge"><span className="live-dot"/><span className="live-txt">即時同步</span></div>
</div>
</div>
{/* ══════ TAB: SCHEDULE ══════ */}
{tab==="schedule"&&(
<>
<div className="controls">
<div style={{display:"flex",gap:"4px",flexWrap:"wrap"}}>
{["全部",...ST].map(t=>(
<button key={t} className={`fbtn${sFilter===t?" fas":""}`} onClick={()=>setSFilter(t)}>{t==="全部"?"狀態:全":t}</button>
))}
</div>
</div>
<div className="content">
{days.map(day=>{
const ds=dayScenes(day.id).filter(s=>sFilter==="全部"||s.status===sFilter);
const isCollapsed=collapsed[day.id];
const dayDone=dayScenes(day.id).filter(s=>s.status==="已完成").length;
return(
<div className="day-section" key={day.id}>
{/* Day Header */}
<div className="day-header">
<div className="day-num" onClick={()=>setDayModal({mode:"edit",day})}>第 {day.order} 天</div>
<div style={{display:"flex",flexDirection:"column",gap:"2px",flex:1}}>
<div className="day-date-text" onClick={()=>setDayModal({mode:"edit",day})}>{day.date||"(點擊設定日期)"}</div>
{day.location&&<div style={{fontSize:"15px",color:"#888"}}>{day.location}</div>}
</div>
<div className="day-stats">{dayDone}/{dayScenes(day.id).length} 場完成</div>
<button style={{background:"transparent",border:"1px solid #444",color:"#888",borderRadius:"6px",padding:"6px 12px",cursor:"pointer",fontSize:"13px",fontFamily:"'DM Mono',monospace"}} onClick={()=>setDayModal({mode:"edit",day})}>✎ 編輯</button>
<div className="day-collapse" onClick={()=>setCollapsed(c=>({...c,[day.id]:!c[day.id]}))}>
{isCollapsed?"▼":"▲"}
</div>
</div>
{!isCollapsed&&(
<div className="scene-table">
<div className="scene-table-scroll">
{/* Head */}
<div className="s-head" style={{"--g":SGRID}}>
{[["#",""],["場號",""],["地點",""],["時",""],["演員",""],["頁",""],["景別","鏡頭"],["攝影機","運動"],["視效","VFX"],["狀態",""],["操作",""]].map(([h,s],i)=>(
<div key={i} className="s-th">{h}{s&&<div className="s-th-sub">{s}</div>}</div>
))}
</div>
{ds.length===0&&<div className="empty">— 尚無場景,請點下方「+ 新增場景」—</div>}
{ds.map((sc,idx)=>{
const isE=eId===sc.id,isX=exp===sc.id;
const rc=["s-row",sc.status==="拍攝中"?"shooting":"",sc.status==="已完成"?"done":"",drag===sc.id?"dragging":"",over===sc.id?"dragover":""].filter(Boolean).join(" ");
const tc=TC[sc.time]||TC["日"],vs=VS[sc.vfx]||VS["其他"],ss=SS[sc.status]||SS["待拍"];
return(
<div key={sc.id}>
<div className={rc} style={{"--g":SGRID,animationDelay:`${idx*.03}s`}}
draggable={!isE}
onDragStart={e=>{setDrag(sc.id);e.dataTransfer.effectAllowed="move";}}
onDragOver={e=>{e.preventDefault();setOver(sc.id);}}
onDrop={e=>{e.preventDefault();if(drag)moveScene(drag,sc.id,day.id);setDrag(null);setOver(null);}}
onDragEnd={()=>{setDrag(null);setOver(null);}}>
<div className="s-td"><span className="c-order">⠿{sc.order}</span></div>
<div className="s-td">{isE?<input className="ei" value={ef.scene} onChange={e=>se(f=>({...f,scene:e.target.value}))} placeholder="場號"/>:<span className="c-scene">{sc.scene}</span>}</div>
<div className="s-td">{isE?<input className="ei" value={ef.location} onChange={e=>se(f=>({...f,location:e.target.value}))} placeholder="地點"/>:<span className="c-loc">{sc.location}</span>}</div>
<div className="s-td">{isE?<Sel opts={TL} val={ef.time} set={v=>se(f=>({...f,time:v}))}/>:<span className="c-time" style={{background:tc.bg,borderColor:tc.bd,color:tc.tx}}>{sc.time}</span>}</div>
<div className="s-td">{isE?<input className="ei" value={ef.cast} onChange={e=>se(f=>({...f,cast:e.target.value}))} placeholder="演員"/>:<span className="c-cast">{sc.cast}</span>}</div>
<div className="s-td">{isE?<input className="ei" type="number" step="0.5" min="0" value={ef.pages} onChange={e=>se(f=>({...f,pages:e.target.value}))} style={{width:"52px"}}/>:<span className="c-pages">{Number(sc.pages).toFixed(1)}</span>}</div>
<div className="s-td s-tdc">{isE?(<><Sel opts={SHOTS} val={ef.shotType} set={v=>se(f=>({...f,shotType:v}))}/><Sel opts={LENS} val={ef.lens} set={v=>se(f=>({...f,lens:v}))}/></>):(<><span className="c-shot-a">{sc.shotType}</span><span className="c-shot-b">{sc.lens}</span></>)}</div>
<div className="s-td s-tdc">{isE?(<><Sel opts={CAMS} val={ef.camera} set={v=>se(f=>({...f,camera:v}))}/><Sel opts={MOVS} val={ef.movement} set={v=>se(f=>({...f,movement:v}))}/></>):(<><span className="c-cam-a">{sc.camera}</span><span className="c-cam-b">{sc.movement}</span></>)}</div>
<div className="s-td s-tdc">{isE?(<><Sel opts={VFX} val={ef.vfx} set={v=>se(f=>({...f,vfx:v}))}/><input className="ei" value={ef.vfxNote} onChange={e=>se(f=>({...f,vfxNote:e.target.value}))} placeholder="說明"/></>):(<><span className="c-vfx" style={{background:vs.bg,borderColor:vs.bd,color:vs.tx}}>{sc.vfx}</span>{sc.vfxNote&&<span className="c-vfx-note">{sc.vfxNote}</span>}</>)}</div>
<div className="s-td s-tdc">{isE?<Sel opts={ST} val={ef.status} set={v=>se(f=>({...f,status:v}))}/>:(<><span className="c-stat" style={{background:ss.bg,borderColor:ss.bd,color:ss.tx}}>{sc.status==="拍攝中"&&"🎬 "}{sc.status}</span><div className="c-qbtns">{ST.filter(s=>s!==sc.status).map(s=><button key={s} className="c-qbtn" style={{color:SS[s].tx,borderColor:SS[s].bd}} onClick={()=>setStatus(sc,s)}>→{s}</button>)}</div></>)}</div>
<div className="s-td c-acts">{isE?(<><button className="c-abtn c-as" onClick={saveEdit}>✓ 儲存</button><button className="c-abtn c-ac" onClick={cancelEdit}>✕ 取消</button></>):(<><button className="c-abtn c-ae" onClick={()=>startEdit(sc)}>✎ 編輯</button><button className="c-abtn c-an" onClick={()=>setExp(isX?null:sc.id)}>{isX?"▲":"▼"} 備註</button><button className="c-abtn c-ad" onClick={()=>delScene(sc.id)}>✕ 刪除</button></>)}</div>
</div>
{isX&&!isE&&(
<div className="s-notes">
{sc.notes&&<div><div className="nc-label">拍攝備註</div><div className="nc-val">{sc.notes}</div></div>}
{sc.vfxNote&&<div><div className="nc-label">特效說明</div><div className="nc-val">{sc.vfxNote}</div></div>}
{!sc.notes&&!sc.vfxNote&&<span style={{color:"#bbb",fontFamily:"'DM Mono',monospace",fontSize:"14px"}}>— 無備註 —</span>}
</div>
)}
</div>
);
})}
</div>
<div className="day-add-scene" onClick={()=>addScene(day.id)}>+ 新增場景到第 {day.order} 天</div>
</div>
)}
</div>
);
})}
<button className="add-day-btn" onClick={addDay}>+ 新增拍攝日</button>
<div className="legend" style={{marginTop:"28px"}}>
<div className="lg">
<div><div className="ls-label">時段</div><div className="litems">{TL.map(t=><div key={t} className="li"><div className="ldot" style={{background:TC[t].bd}}/>{t}</div>)}</div></div>
<div><div className="ls-label">狀態</div><div className="litems">{ST.map(s=><div key={s} className="li"><div className="ldot" style={{background:SS[s].bd}}/>{s}</div>)}</div></div>
<div><div className="ls-label">視效</div><div className="litems">{Object.entries(VS).filter(([k])=>k!=="無").map(([k,v])=><div key={k} className="li"><div className="ldot" style={{background:v.bd}}/>{k}</div>)}</div></div>
</div>
<div className="tip">⠿ 拖曳排序 · ▼ 展開備註 · 🔴 Firebase 即時同步</div>
</div>
</div>
</>
)}
{/* ══════ TAB: CREW ══════ */}
{tab==="crew"&&(
<div className="content">
<div className="crew-section">
<div className="section-title">組員聯絡資訊</div>
<div className="crew-grid">
{crew.sort((a,b)=>CREW_ROLES.indexOf(a.role)-CREW_ROLES.indexOf(b.role)).map(c=>(
<div className="crew-card" key={c.id}>
<div className="crew-role">{c.role}</div>
<div className="crew-name">{c.name||"(未填寫)"}</div>
{c.phone&&<div className="crew-phone">📞 {c.phone}</div>}
{c.note&&<div className="crew-note">{c.note}</div>}
<button className="crew-edit-btn" onClick={()=>setCrewModal({mode:"edit",crew:c})}>✎ 編輯</button>
</div>
))}
<div className="crew-add-card" onClick={()=>setCrewModal({mode:"new",crew:null})}>+ 新增組員</div>
</div>
</div>
</div>
)}
{/* ══════ TAB: PHOTOS ══════ */}
{tab==="photos"&&(
<div className="content">
<div className="photo-section">
<div className="section-title">現場照片</div>
<div
className={`photo-drop${photoDrag?" drag-over":""}`}
onDragOver={e=>{e.preventDefault();setPhotoDrag(true);}}
onDragLeave={()=>setPhotoDrag(false)}
onDrop={e=>{e.preventDefault();setPhotoDrag(false);handleFiles(e.dataTransfer.files);}}
onClick={()=>fileRef.current&&fileRef.current.click()}
>
<input ref={fileRef} type="file" accept="image/*" multiple className="photo-input"
onChange={e=>handleFiles(e.target.files)}/>
<div className="photo-drop-icon">📸</div>
<div className="photo-drop-text">{uploading?"上傳中…":"拖放照片到這裡,或點擊選擇"}</div>
<div className="photo-drop-sub">{uploading?"請稍候":"支援 JPG、PNG、HEIC 等格式,可多選"}</div>
</div>
{photos.length>0&&(
<div className="photo-grid">
{photos.map(p=>(
<div className="photo-item" key={p.id}>
<img src={p.url} alt={p.name} loading="lazy"/>
<button className="photo-del" onClick={()=>delPhoto(p.id)}>✕</button>
</div>
))}
</div>
)}
{photos.length===0&&!uploading&&(
<div style={{textAlign:"center",padding:"40px",color:"#bbb",fontFamily:"'DM Mono',monospace",fontSize:"15px"}}>— 尚未上傳任何照片 —</div>
)}
</div>
</div>
)}
{/* ── MODALS ── */}
{crewModal&&(
<CrewModal
crew={crewModal.mode==="edit"?crewModal.crew:null}
onSave={saveCrew}
onDelete={delCrew}
onClose={()=>setCrewModal(null)}
/>
)}
{dayModal&&(
<DayModal
day={dayModal.mode==="edit"?dayModal.day:null}
onSave={form=>{if(dayModal.mode==="new"){fset("days",form.id,form);}else{fset("days",form.id,form);}setDayModal(null);}}
onDelete={delDay}
onClose={()=>setDayModal(null)}
/>
)}
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
</script>
</body>
</html>