}
{file.kind === 'image' ? (
setLoaded(true)} onError={()=>setErrored(true)}
style={{width:'100%',height:'100%',objectFit:'cover',
opacity: loaded?1:0, transition:'opacity .25s ease-out'}}/>
) : (
setLoaded(true)} onError={()=>setErrored(true)}
style={{width:'100%',height:'100%',objectFit:'cover',
opacity: loaded?1:0, transition:'opacity .25s ease-out'}}/>
)}
{file.kind === 'video' && loaded && (
)}
);
};
const V2DataStage = ({palette, project, user, onUpload, onAnalyzeFile, onAnalyzeAll, onUpdateProject})=>{
const [uploads, setUploads] = React.useState({}); // {tempId: {name, progress, error}}
const [pendingAnalyze, setPendingAnalyze] = React.useState({}); // {fileId: true}
// Folder list is the source of truth on `project.folders` so empty folders
// still show up in the tree (otherwise users can't drop files into them).
const folders = {};
(project?.folders || []).forEach(f => { folders[f.name] = []; });
(palette.files || []).forEach(f => { (folders[f.folder] ||= []).push(f); });
const folderNames = Object.keys(folders);
const [activeFolder, setActiveFolder] = React.useState(folderNames[0] || '');
const [editing, setEditing] = React.useState(null);
const [name, setName] = React.useState(palette.project?.name || '');
const [desc, setDesc] = React.useState(palette.project?.brief || '');
React.useEffect(() => { setName(palette.project?.name || ''); setDesc(palette.project?.brief || ''); }, [palette.project?.id]);
const stats = (palette.files || []).reduce((s,f) => {
s.total++;
s[f.status] = (s[f.status]||0) + 1;
return s;
}, {total:0});
const visibleFiles = activeFolder ? (folders[activeFolder] || []) : (palette.files || []);
const fileInputRef = React.useRef(null);
const onDropFiles = async (files) => {
if (!files?.length) return;
const folderId = (project?.folders || []).find(f => f.name === activeFolder)?.id || (project?.folders || [])[0]?.id;
if (!folderId) return;
for (const file of files) {
const tid = 'up-' + Math.random().toString(36).slice(2,9);
setUploads(u => ({...u, [tid]: {name: file.name, progress: 0}}));
try {
await onUpload(folderId, file, (p) => {
setUploads(u => ({...u, [tid]: {...u[tid], progress: p}}));
});
setUploads(u => { const {[tid]:_, ...rest} = u; return rest; });
} catch(e) {
setUploads(u => ({...u, [tid]: {...u[tid], error: e.message}}));
setTimeout(() => setUploads(u => { const {[tid]:_, ...rest} = u; return rest; }), 4000);
}
}
};
const triggerAnalyze = async (fileId) => {
setPendingAnalyze(p => ({...p, [fileId]: true}));
try { await onAnalyzeFile?.(fileId); }
finally { setPendingAnalyze(p => { const {[fileId]:_, ...rest} = p; return rest; }); }
};
return (
{/* LEFT — folder tree + project header */}
PROJECT
{editing==='name' ? (
setName(e.target.value)}
onBlur={()=>{ setEditing(null); if (name !== palette.project?.name) onUpdateProject?.({name}); }}
onKeyDown={e=>{if(e.key==='Enter')e.target.blur();}}
style={{width:'100%',padding:'2px 4px',margin:'0 -4px',fontSize:13,fontWeight:700,
border:'1px solid var(--accent)',borderRadius:4,outline:'none',fontFamily:'inherit'}}/>
) : (
setEditing('name')} style={{fontSize:13,fontWeight:700,cursor:'text',padding:'2px 0',marginBottom:6,display:'flex',alignItems:'center',gap:6}}>
{name || 'Untitled'}
)}
{editing==='desc' ? (
FOLDERS
setActiveFolder('')} style={{display:'flex',alignItems:'center',gap:8,padding:'8px 10px',borderRadius:6,fontSize:12.5,marginBottom:2,
background: activeFolder==='' ? 'var(--accent-3)' : 'transparent', cursor:'pointer'}}>
모든 파일
{stats.total}
{folderNames.map(n => (
setActiveFolder(n)} style={{display:'flex',alignItems:'center',gap:8,padding:'8px 10px',borderRadius:6,fontSize:12.5,marginBottom:2,
background: activeFolder===n ? 'var(--accent-3)' : 'transparent', cursor:'pointer'}}>
{n}
{folders[n].length}
))}
{/* CENTER — dropzone + grid */}
{e.preventDefault();}}
onDrop={e=>{e.preventDefault(); onDropFiles(Array.from(e.dataTransfer?.files||[]));}}>
{[
{bg:'#006747',n:'doc'},{bg:'#004D35',n:'image'},{bg:'#1B2A24',n:'video'},
{bg:'#FFB600',n:'audio',fg:'#1B2A24'},{bg:'#4D5A53',n:'url'}
].map((b,i) => (
))}
자료를 끌어다 놓으세요
PDF · DOCX · PPTX · 이미지 · 영상 · 오디오 · URL — 폴더째 업로드 가능합니다.
업로드되는 즉시 AI가 페이지/씬/영역 단위로 분석을 시작합니다.
fileInputRef.current?.click()}>
파일 선택
onDropFiles(Array.from(e.target.files || []))}/>
{Object.keys(uploads).length > 0 && (
UPLOADING · {Object.keys(uploads).length}
{Object.entries(uploads).map(([tid, u]) => (
{u.error ?
:
}
{u.name}
{u.error &&
업로드 실패: {u.error}
}
{u.error ? 'FAIL' : `${Math.round((u.progress||0)*100)}%`}
))}
)}
업로드된 자료
{visibleFiles.length} ITEMS
{stats.analyzed ? ` · ${stats.analyzed} ANALYZED` : ''}
{stats.analyzing ? ` · ${stats.analyzing} ANALYZING` : ''}
{stats.not_analyzed ? ` · ${stats.not_analyzed} NOT ANALYZED` : ''}
Analyze all
{visibleFiles.map(f => {
const meta = v2FileMeta(f.kind);
const isNew = f.status==='not_analyzed';
const canAnalyze = f.status==='not_analyzed' || f.status==='pending' || f.status==='failed' || f.status==='idle';
const hasMedia = f.kind === 'image' || f.kind === 'video';
return (
{isNew && (
NEW · 방금 업로드
)}
{hasMedia &&
}
{!hasMedia && (
)}
{f.name}
{meta.label} · {f.size||''}
{f.updated}
{canAnalyze && (
triggerAnalyze(f.id)}
disabled={!!pendingAnalyze[f.id]}
style={{marginTop:10,width:'100%',justifyContent:'center',fontSize:11}}>
{pendingAnalyze[f.id] ? <>
분석 중…
> : <>
{isNew ? '지금 분석 시작' : 'Analyze'}
>}
)}
{f.status === 'analyzing' && !pendingAnalyze[f.id] && (
백그라운드 분석 중…
)}
);
})}
{visibleFiles.length === 0 && (
이 폴더에는 아직 파일이 없습니다. 위 영역으로 끌어다 놓아주세요.
)}
{/* RIGHT — Team pane */}
);
};
const v2Initial = (s) => (s || '·').trim().slice(0,1).toUpperCase();
const v2Color = (seed) => {
const palette = ['#006747','#004D35','#7B6A2A','#857E70','#1B2A24','#FFB600'];
let h = 0; for (const c of (seed||'')) h = (h*31 + c.charCodeAt(0)) >>> 0;
return palette[h % palette.length];
};
const V2TeamPane = ({projectId, currentUserId})=>{
const [members, setMembers] = React.useState([]);
const [pending, setPending] = React.useState([]);
const [isOwner, setIsOwner] = React.useState(false);
const [canManage, setCanManage] = React.useState(false);
const [loadingMembers, setLoadingMembers] = React.useState(true);
const [invite, setInvite] = React.useState('');
const [inviteRole, setInviteRole] = React.useState('editor');
const [busy, setBusy] = React.useState(false);
const [shareLink, setShareLink] = React.useState(null);
const [linkCopied, setLinkCopied] = React.useState(false);
const [feedback, setFeedback] = React.useState(null);
const reload = React.useCallback(async () => {
if (!projectId) return;
setLoadingMembers(true);
try {
const r = await window.API.listMembers(projectId);
setMembers(r.members || []);
setPending(r.pending_invites || []);
setIsOwner(!!r.is_owner);
setCanManage(!!r.can_manage);
} catch(e) {
console.error('listMembers', e);
} finally { setLoadingMembers(false); }
}, [projectId]);
React.useEffect(() => { reload(); }, [reload]);
const sendInvite = async () => {
const v = invite.trim();
if (!projectId) return;
setBusy(true); setFeedback(null); setShareLink(null); setLinkCopied(false);
try {
const res = await window.API.inviteToProject(projectId, {email: v || null, role: inviteRole});
const url = window.location.origin + res.share_path;
setShareLink(url);
setFeedback({ok:true, text: v ? `${v} 에게 보낼 초대 링크가 생성됐어요` : '초대 링크가 생성됐어요'});
setInvite('');
reload();
} catch(e) {
setFeedback({ok:false, text: '초대 실패 — 권한을 확인해주세요'});
} finally { setBusy(false); }
};
const copyLink = async () => {
if (!shareLink) return;
try { await navigator.clipboard.writeText(shareLink); setLinkCopied(true); setTimeout(()=>setLinkCopied(false), 2000); }
catch(e) { /* ignore */ }
};
const removeMember = async (uid) => {
if (!projectId || !uid) return;
if (!window.confirm('이 멤버를 프로젝트에서 제거할까요?')) return;
try { await window.API.removeMember(projectId, uid); reload(); }
catch(e) { setFeedback({ok:false, text:'제거 실패'}); }
};
const changeRole = async (uid, role) => {
if (!projectId || !uid) return;
try { await window.API.updateMemberRole(projectId, uid, role); reload(); }
catch(e) { setFeedback({ok:false, text:'권한 변경 실패'}); }
};
return (
TEAM · {members.length} MEMBER{members.length===1?'':'S'}
프로젝트를 함께 보거나 편집할 팀원을 초대하세요. 권한은 언제든 바꿀 수 있습니다.
{canManage && (
INVITE BY EMAIL
setInvite(e.target.value)}
onKeyDown={e=>{if(e.key==='Enter')sendInvite();}}
placeholder="name@palette.co · (선택)"
style={{flex:1,padding:'8px 10px',border:'1px solid var(--rule-2)',borderRadius:6,
background:'var(--paper-2)',fontSize:12,fontFamily:'inherit',outline:'none',minWidth:0}}/>
setInviteRole(e.target.value)}
style={{padding:'8px 8px',border:'1px solid var(--rule-2)',borderRadius:6,
background:'var(--paper-2)',fontSize:11.5,fontFamily:'inherit',cursor:'pointer'}}>
Editor
Viewer
{busy ? <> 생성 중… >
: <> 초대 링크 만들기>}
{feedback && (
{feedback.text}
)}
{shareLink && (
)}
)}
MEMBERS
{loadingMembers && members.length === 0 && (
)}
{members.map(m => {
const isMe = m.user_id === currentUserId;
const display = m.name || (m.email||'').split('@')[0] || '—';
const isOwnerRow = m.role === 'owner';
return (
{v2Initial(m.name || m.email)}
{display}
{isMe && · YOU }
{m.email||'—'}
{isOwnerRow ? (
OWNER
) : canManage ? (
<>
changeRole(m.user_id, e.target.value)}
style={{padding:'3px 6px',border:'1px solid var(--rule)',borderRadius:5,
background:'var(--paper)',fontSize:10.5,fontFamily:'inherit',cursor:'pointer'}}>
Editor
Viewer
removeMember(m.user_id)} className="btn sm ghost"
title="멤버 제거" style={{padding:'2px 6px'}}>
>
) : (
{m.role||'member'}
)}
);
})}
{members.length === 0 && (
아직 멤버 정보 없음 — 새로고침해주세요.
)}
{pending.length > 0 && canManage && (
<>
PENDING INVITES · {pending.length}
{pending.map(inv => (
{inv.email || '(open invite)'}
· {inv.role} · {(inv.token||'').slice(0,12)}…
))}
>
)}
);
};
window.V2DataStage = V2DataStage;