import { accountLayout, editorLayout, decorateSequenceButton } from './ui.js'; import './style.css'; import { createEditorHistory, editAction } from './editor.js'; import { addAction, addScreenAction, addShootAction, clampLocation, finalBallOwner, hasShoot, normalizeLookAt, removeAction, scheduledTrackDuration, sequenceDuration, setSequenceBallOwner, snapLocation } from './domain.js'; import { createPlaybackController, samplePlay, selectRenderSample, totalDuration } from './playback.js'; import { createBoard } from './scene.js'; import { createLocalPlayRepository } from './playRepository.js'; import { createPlayOperationCoordinator } from './playOperations.js'; import { addPlaySequence, commitActionEdit, createAppState, createAppStateFromPlay, deletePlaySequence, serializePlay, setSelectedPlayerStart } from './state.js'; import { createVideoExportJob, downloadVideoFile, VideoExportCancelledError, VideoExportStaleError, shareVideoFile, validateVideoFile } from './videoExport.js'; import { ApiError, approveUser, approveTeamJoin, changeTeamMemberRole, createServerPlayRepository, createTeam, getCurrentUser, listJoinRequests, listPendingUsers, listTeamJoinRequests, listTeamMembers, listTeams, loginAccount, logoutAccount, registerAccount, searchTeams, requestTeamJoin } from './api.js'; import { renewSessionActivity } from './api.js'; import { createSessionActivityTracker } from './sessionActivity.js'; const app = document.querySelector('#app'); app.innerHTML = accountLayout() + editorLayout(); document.querySelector('.shell').hidden = true; const board = createBoard(document.querySelector('#board')); let storage = null; try { storage = window.localStorage; } catch { storage = null; } const sessionActivity = createSessionActivityTracker({ onActivity: renewSessionActivity, isAuthenticated: () => Boolean(currentUser) }); const localPlayRepository = createLocalPlayRepository(storage); let playRepository = localPlayRepository; let currentUser = null; let currentTeam = null; let accountGeneration = 0; let searchGeneration = 0; let ownerGeneration = 0; let activeOwnerTeamId = null; let teamHubView = 'joined'; let teamHubViewUserSelected = false; let selectedTeam = null; let teamActionView = 'library'; const operationCoordinator = createPlayOperationCoordinator(); let state = createAppState(); const editorHistory = createEditorHistory(); let panel = 'court'; let gazePick = null; let suppressClick = false; let drag = null; let mobileFocusActive = false; let focusPlaybackOpen = false; let focusSession = 0; let focusOrientationToken = 0; let focusOrientationStatus = 'idle'; let fullscreenFocusRequest = 0; let focusEntryRequested = false; let viewportSyncFrame = 0; function syncAppViewportHeight() { if (viewportSyncFrame) return; viewportSyncFrame = requestAnimationFrame(() => { viewportSyncFrame = 0; const viewportHeight = Math.round(window.visualViewport?.height || window.innerHeight || 0); if (!viewportHeight) return; document.querySelector('.shell')?.style.setProperty('--app-viewport-height', `${viewportHeight}px`); if (mobileFocusActive) board.resize(); }); } function cancelActiveDrag() { const active = drag; drag = null; if (active && boardElement?.hasPointerCapture?.(active.pointerId)) boardElement.releasePointerCapture(active.pointerId); document.querySelector('.board-wrap')?.classList.remove('dragging'); if (state.boardPreview) state = { ...state, boardPreview: undefined }; } document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType; document.querySelector('#save-status').textContent = '팀을 선택하세요'; let controller = createPlaybackController(state.play); let playbackSession = false; let resetPreview = false; let lastFrame = performance.now(); let savedListGeneration = 0; let passiveListGeneration = 0; let loadGeneration = 0; let detailTacticsGeneration = 0; const VIDEO_WIDTH = 1280; const VIDEO_HEIGHT = 720; const VIDEO_FPS = 30; let videoExportActive = false; let videoJob = null; let preparedVideo = null; let videoPreviewUrl = null; function videoSettings() { const view = document.querySelector('#video-view')?.value || 'tactical'; return { view, selectedPlayerId: view === 'pov' ? state.selectedPlayerId : null }; } function videoKey() { const name = String(document.querySelector('#play-name')?.value || '').trim() || '새 전술'; return JSON.stringify({ play: state.play, name, ...videoSettings() }); } function setVideoStatus(message) { const element = document.querySelector('#video-status'); if (element) element.textContent = message; } function renderVideoControls() { const exportButton = document.querySelector('#export-video'); const cancelButton = document.querySelector('#cancel-video'); const progress = document.querySelector('#video-progress'); const shareButton = document.querySelector('#share-video'); const downloadButton = document.querySelector('#download-video'); if (!exportButton) return; const povOption = document.querySelector('#video-view option[value="pov"]'); const povPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (povOption) povOption.textContent = `선수 시점 · ${povPlayer ? `${povPlayer.team === 'offense' ? 'O' : 'D'}${povPlayer.number}` : '선택 선수'}`; exportButton.disabled = videoExportActive; cancelButton.hidden = !videoExportActive; progress.hidden = !videoExportActive; shareButton.disabled = !preparedVideo || videoExportActive || preparedVideo.key !== videoKey(); downloadButton.disabled = !preparedVideo || videoExportActive || preparedVideo.key !== videoKey(); } function clearPreparedVideo(message = '영상 준비 전') { preparedVideo = null; if (videoPreviewUrl) { URL.revokeObjectURL(videoPreviewUrl); videoPreviewUrl = null; } const preview = document.querySelector('#video-preview'); if (preview) { preview.pause?.(); preview.removeAttribute('src'); preview.load?.(); preview.hidden = true; } const progress = document.querySelector('#video-progress'); if (progress) progress.value = 0; setVideoStatus(message); renderVideoControls(); } function ensureVideoFresh() { if (preparedVideo && preparedVideo.key !== videoKey()) clearPreparedVideo('전술이 변경되어 MP4를 다시 만들어야 합니다'); if (videoJob && videoJob.key !== videoKey()) videoJob.cancel(); } function videoFileName(name) { const safe = String(name || 'basket-utils-play').trim().replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-').slice(0, 70) || 'basket-utils-play'; return `${safe}.mp4`; } async function startVideoExport() { if (videoJob) return; ensureVideoFresh(); const play = structuredClone(state.play); play.name = String(document.querySelector('#play-name').value || '').trim() || '새 전술'; const settings = videoSettings(); const key = videoKey(); const duration = totalDuration(play); if (duration <= 0) { setVideoStatus('먼저 재생할 이동 행동을 추가하세요'); return; } clearPreparedVideo('MP4 생성 준비 중…'); const exportState = { ...state, play, view: settings.view, selectedPlayerId: settings.selectedPlayerId || state.selectedPlayerId, selectedAction: -1, playing: true, mode: 'move' }; let surface = null; // Freeze the editor animation loop while the dedicated export surface is // sampled. This keeps the user's current play/pause state and scrubber // position intact when the export finishes. videoExportActive = true; renderVideoControls(); setVideoStatus('MP4 생성 중… 0%'); try { surface = board.createExportSurface(VIDEO_WIDTH, VIDEO_HEIGHT); const job = createVideoExportJob({ canvas: surface.canvas, duration, fps: VIDEO_FPS, fileName: videoFileName(play.name), isCurrent: () => videoKey() === key, renderFrame: (elapsed) => { if (videoKey() !== key) throw new VideoExportStaleError(); surface.render(play, exportState, { ...samplePlay(play, elapsed), playing: true }, 1 / VIDEO_FPS); }, onProgress: (progress) => { const element = document.querySelector('#video-progress'); if (element) element.value = progress; setVideoStatus(`MP4 생성 중… ${Math.round(progress * 100)}%`); }, }); job.key = key; videoJob = job; const result = await job.promise; if (videoKey() !== key) throw new VideoExportStaleError(); setVideoStatus('MP4 재생 정보 확인 중…'); if (job.signal.aborted) throw new VideoExportCancelledError(); const metadata = await validateVideoFile(result.file, { signal: job.signal }); if (job.signal.aborted) throw new VideoExportCancelledError(); if (videoKey() !== key) throw new VideoExportStaleError(); preparedVideo = { file: result.file, key, metadata }; videoPreviewUrl = URL.createObjectURL(result.file); const preview = document.querySelector('#video-preview'); preview.src = videoPreviewUrl; preview.hidden = false; setVideoStatus(`MP4 준비 완료 · ${(result.file.size / 1024 / 1024).toFixed(1)}MB · ${metadata.duration ? metadata.duration.toFixed(1) + '초' : duration.toFixed(1) + '초'}`); } catch (error) { if (error instanceof VideoExportCancelledError || error?.code === 'cancelled') setVideoStatus('영상 생성을 취소했습니다'); else if (error instanceof VideoExportStaleError || error?.code === 'stale') clearPreparedVideo('전술이 변경되어 영상 생성을 취소했습니다'); else { clearPreparedVideo(`영상 생성 실패: ${error.message || error}`); } } finally { surface?.dispose(); board.resize(); videoExportActive = false; videoJob = null; renderVideoControls(); } } async function sharePreparedVideo() { ensureVideoFresh(); if (!preparedVideo) { setVideoStatus('먼저 MP4 영상을 만들어 준비하세요'); return; } try { await shareVideoFile(preparedVideo.file, { navigatorObject: window.navigator, title: state.play.name || 'basket-utils 전술 영상', text: '농구 전술 MP4 영상' }); setVideoStatus('공유창을 열었습니다 · 카카오톡 대화방을 선택해 전송하세요'); } catch (error) { if (error?.name === 'AbortError') setVideoStatus('영상 파일 공유를 취소했습니다'); else setVideoStatus(error.message || '영상 파일 공유를 지원하지 않습니다'); } } function showGate(view = 'auth', message = '') { cancelActiveDrag(); const gate = document.querySelector('#account-gate'); if (!gate) return; gate.hidden = false; document.querySelector('.shell').hidden = view !== 'editor'; for (const id of ['auth-panel', 'register-panel', 'pending-panel', 'team-panel', 'play-library-panel', 'admin-panel']) document.querySelector(`#${id}`).hidden = id !== `${view}-panel`; const operatorFab = document.querySelector('#operator-fab'); const showOperatorFab = Boolean(currentUser?.isOperator && ['teams', 'library'].includes(view)); if (operatorFab) operatorFab.hidden = !showOperatorFab; gate.classList.toggle('has-operator-fab', showOperatorFab); if (view === 'auth') document.querySelector('#auth-message').textContent = message; if (view === 'register') document.querySelector('#register-message').textContent = message; if (view === 'pending') document.querySelector('#pending-message').textContent = message || '운영자 승인이 완료되면 다시 로그인해 주세요.'; if (view === 'teams') { document.querySelector('#team-panel').hidden = false; document.querySelector('#auth-panel').hidden = true; document.querySelector('#register-panel').hidden = true; document.querySelector('#pending-panel').hidden = true; } if (view === 'library') { document.querySelector('#play-library-panel').hidden = false; document.querySelector('.shell').hidden = true; } if (view === 'admin') { document.querySelector('#admin-panel').hidden = false; document.querySelector('.shell').hidden = true; } if (view === 'teams') renderTeamHubView(); if (view === 'editor') gate.hidden = true; } function resetTeamHubState() { teamHubView = 'joined'; teamHubViewUserSelected = false; selectedTeam = null; teamActionView = 'library'; activeOwnerTeamId = null; ownerGeneration += 1; searchGeneration += 1; document.querySelector('#team-search-form')?.reset(); document.querySelector('#team-search-results')?.replaceChildren(); document.querySelector('#my-join-requests')?.replaceChildren(); document.querySelector('#team-list')?.replaceChildren(); document.querySelector('#team-message')?.replaceChildren(); document.querySelector('#owner-message')?.replaceChildren(); document.querySelector('#retry-teams')?.setAttribute('hidden', ''); renderTeamHubView(); } function renderTeamHubView() { const ownerOpen = Boolean(activeOwnerTeamId); const joined = document.querySelector('#joined-team-panel'); const search = document.querySelector('#search-team-panel'); const create = document.querySelector('#create-team-panel'); const navigation = document.querySelector('#team-navigation'); if (!joined || !search || !create || !navigation) return; const teamHeading = document.querySelector('#team-panel > .account-heading'); if (teamHeading) teamHeading.hidden = teamHubView === 'detail'; const accountHeader = document.querySelector('#account-header'); const detailBack = document.querySelector('#team-detail-back'); if (accountHeader) accountHeader.classList.toggle('is-team-detail', teamHubView === 'detail'); if (detailBack) detailBack.hidden = teamHubView !== 'detail'; navigation.hidden = teamHubView === 'detail'; joined.hidden = ownerOpen || teamHubView !== 'joined'; search.hidden = ownerOpen || teamHubView !== 'search'; create.hidden = ownerOpen || teamHubView !== 'create'; for (const [id, selected] of [['show-joined-teams', teamHubView === 'joined' || teamHubView === 'detail'], ['show-team-search', teamHubView === 'search'], ['show-team-create', teamHubView === 'create']]) { const button = document.querySelector(`#${id}`); if (!button) continue; button.setAttribute('aria-selected', String(selected)); button.setAttribute('aria-pressed', String(selected)); button.tabIndex = selected ? 0 : -1; } const selectedPanel = document.querySelector('#team-detail-panel'); const selectedName = document.querySelector('#selected-team-name'); const actionNavigation = document.querySelector('#team-action-navigation'); const management = document.querySelector('#show-team-management'); const approval = document.querySelector('#show-team-approval'); if (selectedPanel) selectedPanel.hidden = teamHubView !== 'detail' || !selectedTeam; if (selectedName) selectedName.textContent = selectedTeam?.name || ''; if (actionNavigation) actionNavigation.hidden = teamHubView !== 'detail' || !selectedTeam; if (management || approval) { const canManage = selectedTeam?.role === 'owner'; if (management) { management.hidden = !canManage; management.disabled = !canManage; } if (approval) { approval.hidden = !canManage; approval.disabled = !canManage; } } for (const [id, selected] of [['show-team-library', teamActionView === 'library'], ['show-team-management', teamActionView === 'management'], ['show-team-approval', teamActionView === 'approval']]) { const button = document.querySelector(`#${id}`); if (!button || button.hidden) continue; button.setAttribute('aria-selected', String(selected)); button.setAttribute('aria-pressed', String(selected)); button.tabIndex = selected ? 0 : -1; } const libraryAction = document.querySelector('#team-library-action-panel'); const managementAction = document.querySelector('#team-management-action-panel'); const approvalAction = document.querySelector('#team-approval-action-panel'); if (libraryAction) libraryAction.hidden = teamActionView !== 'library'; if (managementAction) managementAction.hidden = teamActionView !== 'management' || selectedTeam?.role !== 'owner'; if (approvalAction) approvalAction.hidden = teamActionView !== 'approval' || selectedTeam?.role !== 'owner'; const ownerMembersPanel = document.querySelector('#owner-members-panel'); const ownerApprovalPanel = document.querySelector('#owner-approval-panel'); if (ownerMembersPanel) ownerMembersPanel.hidden = !ownerOpen; if (ownerApprovalPanel) ownerApprovalPanel.hidden = !ownerOpen; document.querySelectorAll('#team-list button[data-team-id]').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.teamId === selectedTeam?.id))); } function setTeamHubView(view, { userInitiated = false } = {}) { if (!['joined', 'search', 'create'].includes(view)) return; if (activeOwnerTeamId) { ownerGeneration += 1; activeOwnerTeamId = null; setOwnerView(false); } teamHubView = view; if (view !== 'detail') selectedTeam = null; if (userInitiated) teamHubViewUserSelected = true; renderTeamHubView(); } function setTeamActionView(view, { userInitiated = false } = {}) { if (!['library', 'management', 'approval'].includes(view) || !selectedTeam || (['management', 'approval'].includes(view) && selectedTeam.role !== 'owner')) return; teamActionView = view; if (userInitiated) renderTeamHubView(); } function draftKey() { return currentUser && currentTeam ? `basket-utils:draft:v2:${encodeURIComponent(currentUser.id)}:${encodeURIComponent(currentTeam.id)}` : ''; } function restoreTeamDraft() { const key = draftKey(); if (!key || !storage) return '저장 전'; try { const raw = storage.getItem(key); if (!raw) return '저장 전'; state = createAppStateFromPlay(JSON.parse(raw)); return '임시 저장 복원됨'; } catch { return '임시 저장을 복원하지 못했습니다'; } } function renderTeamList(teams) { const list = document.querySelector('#team-list'); list.replaceChildren(); document.querySelector('#retry-teams').hidden = true; if (!teams.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '아직 팀이 없습니다. 첫 팀을 만들어 시작하세요.'; list.append(empty); return; } for (const team of teams) { const roleLabel = team.role === 'owner' ? '소유자' : team.role === 'editor' ? '편집자' : '열람자'; const button = document.createElement('button'); button.type = 'button'; button.className = 'team-card team-select team-row'; button.dataset.teamId = team.id; button.dataset.role = team.role; button.setAttribute('aria-label', `${team.name}, 권한 ${roleLabel}`); button.setAttribute('aria-pressed', 'false'); const name = document.createElement('span'); name.className = 'team-name'; name.textContent = team.name; const role = document.createElement('span'); role.className = 'team-role'; role.textContent = roleLabel; button.append(name, role); list.append(button); } } function renderTeamDetailTactics(records) { const list = document.querySelector('#team-detail-tactics-list'); if (!list) return; list.replaceChildren(); if (!records.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '저장된 전술이 없습니다.'; list.append(empty); return; } for (const record of records) { const row = document.createElement('div'); row.className = 'play-library-row'; const copy = document.createElement('div'); const name = document.createElement('strong'); name.textContent = record.name; const updated = document.createElement('small'); updated.textContent = `수정 ${new Date(record.updatedAt).toLocaleString()}`; copy.append(name, updated); const button = document.createElement('button'); button.type = 'button'; button.className = 'primary'; button.dataset.openDetailPlay = record.id; button.textContent = '열기'; row.append(copy, button); list.append(row); } } async function loadTeamDetailTactics(team) { const generation = ++detailTacticsGeneration; const status = document.querySelector('#team-detail-tactics-status'); const list = document.querySelector('#team-detail-tactics-list'); if (status) status.textContent = '전술 목록을 불러오는 중…'; if (list) list.replaceChildren(); try { const records = await createServerPlayRepository(team.id).list(); if (generation !== detailTacticsGeneration || selectedTeam?.id !== team.id || !currentUser) return; renderTeamDetailTactics(records); if (status) status.textContent = ''; } catch (error) { if (generation !== detailTacticsGeneration || selectedTeam?.id !== team.id) return; if (status) status.textContent = `전술 목록을 불러오지 못했습니다: ${error.message}`; } } async function renderPendingUsers(generation = accountGeneration) { const target = document.querySelector('#pending-users'); target.replaceChildren(); if (!currentUser?.isOperator) return; try { const { users } = await listPendingUsers(); if (generation !== accountGeneration || !currentUser) return; if (!users.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '승인 대기 사용자가 없습니다.'; target.append(empty); return; } for (const user of users) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${user.displayName} · ${user.email}`; const button = document.createElement('button'); button.type = 'button'; button.dataset.approveUser = user.id; button.textContent = '승인'; row.append(label, button); target.append(row); } } catch (error) { const message = document.createElement('p'); message.className = 'account-empty'; message.textContent = error.message; target.append(message); } } async function openAdmin(generation = accountGeneration, navigate = true) { if (!currentUser?.isOperator) { showGate('auth', '운영자 권한이 필요한 페이지입니다.'); if (window.location.pathname === '/admin') history.replaceState({ view: 'auth' }, '', '/'); return; } operationCoordinator.beginIntent(); videoJob?.cancel(); controller.pause(); searchGeneration += 1; ownerGeneration += 1; activeOwnerTeamId = null; showGate('admin'); if (navigate && window.location.pathname !== '/admin') history.pushState({ view: 'admin' }, '', '/admin'); await renderPendingUsers(generation); } async function searchAndRenderTeams(query) { const target = document.querySelector('#team-search-results'); target.replaceChildren(); document.querySelector('#join-status').textContent = ''; const generation = ++searchGeneration; if (query.trim().length < 2) return; try { const [{ teams }, { requests: joinRequests }, { teams: memberships }] = await Promise.all([searchTeams(query), listJoinRequests(), listTeams()]); if (generation !== searchGeneration || !currentUser) return; const byTeam = new Map(joinRequests.map((request) => [request.teamId, request])); const memberByTeam = new Map(memberships.map((team) => [team.id, team])); for (const team of teams) { const row = document.createElement('div'); row.className = 'team-card search-team-row'; const name = document.createElement('strong'); name.textContent = team.name; const button = document.createElement('button'); button.type = 'button'; button.dataset.joinTeam = team.id; const membership = memberByTeam.get(team.id); const request = byTeam.get(team.id); if (membership) { button.textContent = '가입된 팀'; button.disabled = true; } else if (request?.status === 'pending') { button.textContent = '승인 대기 중'; button.disabled = true; } else if (request?.status === 'approved') { button.textContent = '가입 완료'; button.disabled = true; } else button.textContent = '가입 요청'; row.append(name, button); target.append(row); } renderJoinRequests(joinRequests); if (!teams.length) document.querySelector('#join-status').textContent = '검색된 팀이 없습니다.'; } catch (error) { if (generation === searchGeneration) document.querySelector('#join-status').textContent = error.message; } } function renderJoinRequests(requests) { const target = document.querySelector('#my-join-requests'); target.replaceChildren(); if (!requests.length) return; const heading = document.createElement('strong'); heading.textContent = '내 가입 요청'; target.append(heading); for (const request of requests) { const row = document.createElement('p'); row.className = 'join-request-row'; row.textContent = `${request.teamName || '알 수 없는 팀'} · ${request.status === 'approved' ? '승인됨' : '승인 대기'}`; target.append(row); } } function setOwnerView(active) { for (const selector of ['#owner-members-panel', '#owner-approval-panel']) { const panel = document.querySelector(selector); if (panel) panel.hidden = !active; } if (!active) renderTeamHubView(); } async function openOwnerPanel(team, action = 'management') { if (team?.role && team.role !== 'owner') return; const ownerTeam = team || selectedTeam; if (!ownerTeam?.id || ownerTeam.role !== 'owner') return; const generation = ++ownerGeneration; selectedTeam = ownerTeam; teamActionView = action; activeOwnerTeamId = ownerTeam.id; document.querySelector('#team-panel').hidden = false; setOwnerView(true); renderTeamHubView(); document.querySelector('#owner-team-name').textContent = ownerTeam.name ? `${ownerTeam.name} 팀` : '팀원 관리'; const requestsTarget = document.querySelector('#owner-requests'); const membersTarget = document.querySelector('#owner-members'); requestsTarget.replaceChildren(); membersTarget.replaceChildren(); document.querySelector('#owner-message').textContent = '팀원 정보를 불러오는 중…'; try { const [{ requests }, { members }] = await Promise.all([listTeamJoinRequests(ownerTeam.id), listTeamMembers(ownerTeam.id)]); if (generation !== ownerGeneration || !currentUser || activeOwnerTeamId !== ownerTeam.id) return; renderOwnerRows(ownerTeam, requests, members); } catch (error) { if (generation === ownerGeneration) document.querySelector('#owner-message').textContent = error.message; } } function roleSelect(role, dataset) { const select = document.createElement('select'); select.setAttribute('aria-label', '팀원 역할'); for (const [value, label] of [['viewer', '열람자'], ['editor', '편집자']]) { const option = document.createElement('option'); option.value = value; option.textContent = label; select.append(option); } select.value = role; select.dataset.previousRole = role; Object.assign(select.dataset, dataset); return select; } function renderOwnerRows(team, requests, members) { const requestsTarget = document.querySelector('#owner-requests'); const membersTarget = document.querySelector('#owner-members'); if (!requests.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '가입 승인 대기 요청이 없습니다.'; requestsTarget.append(empty); } for (const request of requests) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${request.user.displayName} · ${request.user.email}`; const select = roleSelect('viewer', {}); const approve = document.createElement('button'); approve.type = 'button'; approve.textContent = '승인'; approve.dataset.approveJoin = request.id; approve.dataset.teamId = team.id; row.append(label, select, approve); requestsTarget.append(row); } if (!members.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '현재 팀원이 없습니다.'; membersTarget.append(empty); } for (const member of members) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${member.user.displayName} · ${member.user.email}`; row.append(label); if (member.role === 'owner') { const owner = document.createElement('small'); owner.textContent = '소유자'; row.append(owner); } else { const select = roleSelect(member.role, { memberRole: member.user.id, teamId: team.id }); const save = document.createElement('button'); save.type = 'button'; save.textContent = '역할 저장'; save.dataset.saveMemberRole = 'true'; row.append(select, save); } membersTarget.append(row); } document.querySelector('#owner-message').textContent = ''; } async function openTeamHub(message = '', generation = accountGeneration) { ownerGeneration += 1; detailTacticsGeneration += 1; activeOwnerTeamId = null; searchGeneration += 1; currentTeam = null; selectedTeam = null; teamActionView = 'library'; playRepository = localPlayRepository; document.querySelector('#owner-members-panel').hidden = true; document.querySelector('#owner-approval-panel').hidden = true; document.querySelector('#owner-message').textContent = ''; setOwnerView(false); document.querySelector('#team-message').textContent = message; document.querySelector('#retry-teams').hidden = true; if (teamHubView === 'detail') { teamHubView = 'joined'; teamHubViewUserSelected = false; } showGate('teams'); document.querySelector('#team-welcome').textContent = `${currentUser?.displayName || currentUser?.email || ''}님, 사용할 팀을 선택하세요.`; document.querySelector('#team-message').textContent = message; try { const { teams } = await listTeams(); if (generation !== accountGeneration || !currentUser) return; renderTeamList(teams); if (!teamHubViewUserSelected) setTeamHubView(teams.length ? 'joined' : 'search'); const { requests } = await listJoinRequests(); if (generation === accountGeneration) renderJoinRequests(requests); } catch (error) { if (generation === accountGeneration) { document.querySelector('#team-message').textContent = `팀 목록을 불러오지 못했습니다: ${error.message}`; document.querySelector('#retry-teams').hidden = false; renderTeamHubView(); } } } function renderPlayLibrary(records, draftMessage = '', viewer = false) { const list = document.querySelector('#play-library-list'); list.replaceChildren(); if (!records.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = viewer ? '저장된 전술이 없습니다.' : '저장된 전술이 없습니다. 아래에서 새 전술을 시작하세요.'; list.append(empty); } for (const record of records) { const row = document.createElement('div'); row.className = 'play-library-row'; const copy = document.createElement('div'); const name = document.createElement('strong'); name.textContent = record.name; const updated = document.createElement('small'); updated.textContent = `수정 ${new Date(record.updatedAt).toLocaleString()}`; copy.append(name, updated); const button = document.createElement('button'); button.type = 'button'; button.className = 'primary'; button.dataset.openPlay = record.id; button.textContent = '열기'; row.append(copy, button); list.append(row); } const continueButton = document.querySelector('#continue-draft'); const hasDraft = !viewer && draftMessage === '임시 저장 복원됨'; continueButton.hidden = !hasDraft; continueButton.textContent = hasDraft ? `임시 저장 계속하기 · ${state.play.name}` : '임시 저장 계속하기'; } async function openTeamLibrary(team, message = '') { if (!team?.id || !currentUser) return; if (!document.querySelector('.shell')?.hidden && currentTeam?.id === team.id) saveDraft(); document.querySelector('#continue-draft').hidden = true; document.querySelector('#new-play-form')?.reset(); const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentTeam = team; playRepository = createServerPlayRepository(team.id); editorHistory.clear(); state = createAppState(); const draftMessage = team.role === 'viewer' ? '저장 전' : restoreTeamDraft(); controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; selectedTeam = team; teamActionView = 'library'; document.querySelector('#play-library-title').textContent = `${team.name} · 전술 목록`; document.querySelector('#play-library-welcome').textContent = team.role === 'viewer' ? '저장된 전술을 열람할 수 있습니다.' : '저장된 전술을 열거나 새 전술을 시작하세요.'; document.querySelector('#play-library-status').textContent = '저장된 전술을 불러오는 중…'; document.querySelector('#play-library-message').textContent = message; document.querySelector('#library-new-play').disabled = team.role === 'viewer'; document.querySelector('#play-library-list').replaceChildren(); showGate('library'); try { const records = await playRepository.list(); if (generation !== accountGeneration || currentTeam?.id !== team.id || !currentUser) return; renderPlayLibrary(records, draftMessage, team.role === 'viewer'); document.querySelector('#play-library-status').textContent = ''; } catch (error) { if (generation !== accountGeneration || currentTeam?.id !== team.id) return; document.querySelector('#play-library-status').textContent = `저장 목록을 불러오지 못했습니다: ${error.message}`; const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'account-secondary'; retry.id = 'retry-play-library'; retry.textContent = '다시 시도'; document.querySelector('#play-library-list').append(retry); } } async function selectTeam(team) { if (!team?.id || !currentUser) return; selectedTeam = team; teamActionView = 'library'; teamHubView = 'detail'; teamHubViewUserSelected = true; renderTeamHubView(); loadTeamDetailTactics(team); } function enterEditor(nextState, status = '이동 행동 편집 단계') { nextState = { ...nextState, mode: nextState.mode || 'move', selectedAction: -1, view: 'tactical', boardPreview: undefined }; panel = 'court'; gazePick = null; loadGeneration += 1; editorHistory.clear(); state = nextState; controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; const menu = document.querySelector('#project-menu'); if (menu) { menu.hidden = true; delete menu.dataset.view; } document.querySelector('#toggle-menu')?.setAttribute('aria-expanded', 'false'); document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType || 'man-to-man'; document.querySelector('#team-context').textContent = currentTeam?.name || ''; document.querySelector('#save-status').textContent = '전술 편집 중'; const roleBanner = document.querySelector('#editor-role-banner'); roleBanner.hidden = currentTeam?.role !== 'viewer'; roleBanner.textContent = currentTeam?.role === 'viewer' ? '열람자 권한: 팀 전술을 조회할 수 있으며, 변경 내용을 서버에 저장할 수 없습니다.' : ''; showGate('editor'); renderUi(); document.querySelector('#status').textContent = status; refreshSavedPlays(state.play.id).catch(() => {}); } async function openLibraryPlay(id, statusSelector = '#play-library-status') { if (!id || !currentTeam || !currentUser) return; const generation = ++loadGeneration; const accountAtRequest = accountGeneration; const teamAtRequest = currentTeam.id; const userAtRequest = currentUser.id; const repository = playRepository; const status = document.querySelector(statusSelector); status.textContent = '전술을 불러오는 중…'; try { const play = await repository.get(id); if (generation !== loadGeneration || accountGeneration !== accountAtRequest || currentTeam?.id !== teamAtRequest || currentUser?.id !== userAtRequest || playRepository !== repository) return; if (!play) throw new Error('저장된 전술을 찾을 수 없습니다'); enterEditor(createAppStateFromPlay(play), '저장된 전술을 불러왔습니다'); } catch (error) { if (generation === loadGeneration && accountGeneration === accountAtRequest && currentTeam?.id === teamAtRequest && currentUser?.id === userAtRequest && playRepository === repository) status.textContent = `전술을 열지 못했습니다: ${error.message}`; } } function startNewFromLibrary(name, defenseType) { if (currentTeam?.role === 'viewer') { document.querySelector('#play-library-message').textContent = '열람자 권한에서는 새 전술을 만들 수 없습니다.'; return; } const next = createAppState(String(name || '').trim() || '새 전술', defenseType || 'man-to-man'); enterEditor({ ...next, selectedPlayerId: 'offense-1' }); document.querySelector('#new-play-form')?.reset(); saveDraft(); } async function initializeAccount() { sessionActivity.start(); const generation = accountGeneration; try { const result = await getCurrentUser(); if (generation !== accountGeneration) return; if (currentUser?.id !== result.user.id) resetTeamHubState(); currentUser = result.user; if (window.location.pathname === '/admin') { if (currentUser.isOperator) await openAdmin(generation); else { history.replaceState({ view: 'teams' }, '', '/'); await openTeamHub('운영자만 이용할 수 있는 페이지입니다.', ++accountGeneration); } } else await openTeamHub('', generation); } catch (error) { if (generation !== accountGeneration) return; sessionActivity.stop(); if (!(error instanceof ApiError) || error.status !== 401) document.querySelector('#auth-message').textContent = '서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.'; showGate('auth', document.querySelector('#auth-message').textContent); } } async function logoutFromUi() { sessionActivity.stop(); const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; resetTeamHubState(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentUser = null; currentTeam = null; playRepository = localPlayRepository; operationCoordinator.beginIntent(); document.querySelector('.shell').hidden = true; showGate('auth', '로그아웃 중…'); document.querySelectorAll('#login-form button,#show-register').forEach((control) => { control.disabled = true; }); try { await logoutAccount(); if (generation === accountGeneration) showGate('auth'); } catch (error) { if (generation === accountGeneration) document.querySelector('#auth-message').textContent = `로그아웃 요청을 완료하지 못했습니다: ${error.message}`; } finally { if (generation === accountGeneration) document.querySelectorAll('#login-form button,#show-register').forEach((control) => { control.disabled = false; }); } } function downloadPreparedVideo() { ensureVideoFresh(); if (!preparedVideo) { setVideoStatus('먼저 MP4 영상을 만들어 준비하세요'); return; } try { downloadVideoFile(preparedVideo.file, { fileName: preparedVideo.file.name }); setVideoStatus('MP4 다운로드를 시작했습니다 · 카카오톡에 직접 첨부할 수 있습니다'); } catch (error) { setVideoStatus(error.message || '영상 다운로드를 준비하지 못했습니다'); } } async function refreshSavedPlays(selectedId = '', canSelect = () => true, options = {}) { const repository = playRepository; const userAtRequest = currentUser?.id; const teamAtRequest = currentTeam?.id; const nonInvasive = options.nonInvasive === true; const generation = nonInvasive ? ++passiveListGeneration : ++savedListGeneration; const activeGenerationAtStart = savedListGeneration; const isCurrent = () => nonInvasive ? generation === passiveListGeneration && activeGenerationAtStart === savedListGeneration && currentUser?.id === userAtRequest && currentTeam?.id === teamAtRequest && playRepository === repository : generation === savedListGeneration && currentUser?.id === userAtRequest && currentTeam?.id === teamAtRequest && playRepository === repository; const select = document.querySelector('#saved-plays'); const load = document.querySelector('#load-play'); try { const records = await repository.list(); if (!isCurrent()) return false; const preservedSelection = nonInvasive ? select.value : ''; const preservedLoadDisabled = nonInvasive ? load.disabled : false; select.replaceChildren(); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.selected = nonInvasive ? !preservedSelection : !selectedId; placeholder.textContent = records.length ? '저장된 전술 선택' : '저장된 전술 없음'; select.append(placeholder); for (const record of records) { const option = document.createElement('option'); option.value = record.id; option.textContent = `${record.name} · ${new Date(record.updatedAt).toLocaleString()}`; select.append(option); } if (nonInvasive) { if (preservedSelection && records.some((record) => record.id === preservedSelection)) select.value = preservedSelection; load.disabled = preservedLoadDisabled; } else { if (selectedId && canSelect() && records.some((record) => record.id === selectedId)) select.value = selectedId; load.disabled = !select.value; } return true; } catch (error) { if (!isCurrent() || nonInvasive) return false; select.replaceChildren(); const option = document.createElement('option'); option.value = ''; option.disabled = true; option.selected = true; option.textContent = '저장 목록을 사용할 수 없음'; select.append(option); load.disabled = true; throw error; } } async function saveCurrentPlay() { if (currentTeam?.role === 'viewer') { document.querySelector('#status').textContent = '열람자 권한에서는 전술을 저장할 수 없습니다. 팀 소유자에게 편집자 권한을 요청하세요.'; return; } const token = operationCoordinator.beginIntent(); const repository = playRepository; const teamAtRequest = currentTeam?.id; const playAtRequest = state.play; const nameAtRequest = String(document.querySelector('#play-name').value || '').trim() || '새 전술'; const nextPlay = structuredClone(playAtRequest); nextPlay.name = nameAtRequest; const isSaveStillRelevant = () => operationCoordinator.isCurrent(token) && currentTeam?.id === teamAtRequest && state.play === playAtRequest && (String(document.querySelector('#play-name').value || '').trim() || '새 전술') === nameAtRequest; try { await operationCoordinator.enqueueSave(nextPlay.id, () => repository.save(nextPlay)); } catch (error) { if (isSaveStillRelevant() && currentTeam?.id === teamAtRequest) document.querySelector('#status').textContent = `전술 저장 실패: ${error.message}`; return; } if (!isSaveStillRelevant()) { try { await refreshSavedPlays('', () => true, { nonInvasive: true }); } catch { /* stale save must not alter current UI */ } return; } state.play.name = nextPlay.name; document.querySelector('#play-name').value = state.play.name; renderUi(); try { await refreshSavedPlays(isSaveStillRelevant() ? nextPlay.id : '', isSaveStillRelevant); if (isSaveStillRelevant()) document.querySelector('#status').textContent = '전술을 저장했습니다'; } catch (error) { if (isSaveStillRelevant()) document.querySelector('#status').textContent = `전술은 저장했지만 목록 갱신에 실패했습니다: ${error.message}`; } } async function loadSelectedPlay() { const id = document.querySelector('#saved-plays').value; if (!id) return; const repository = playRepository; const teamAtRequest = currentTeam?.id; const token = operationCoordinator.beginIntent(); const generation = ++loadGeneration; const playAtRequest = state.play; const nameAtRequest = String(document.querySelector('#play-name').value || '').trim() || '새 전술'; const isLoadStillRelevant = () => operationCoordinator.isCurrent(token) && generation === loadGeneration && state.play === playAtRequest && (String(document.querySelector('#play-name').value || '').trim() || '새 전술') === nameAtRequest; try { const play = await repository.get(id); if (!isLoadStillRelevant() || currentTeam?.id !== teamAtRequest) return; if (!play) { document.querySelector('#status').textContent = '저장된 전술을 찾을 수 없습니다'; return; } const nextState = createAppStateFromPlay(play); if (!isLoadStillRelevant()) return; editorHistory.clear(); state = nextState; document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType || 'man-to-man'; saveDraft(); controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; renderUi(); document.querySelector('#status').textContent = '저장된 전술을 불러왔습니다'; } catch (error) { if (isLoadStillRelevant() && currentTeam?.id === teamAtRequest) document.querySelector('#status').textContent = `전술 불러오기 실패: ${error.message}`; } } function selectedTrack() { return state.play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId); } function setState(next, status = '', record = true) { gazePick = null; if (record) editorHistory.record(state, next); if (next.play !== state.play) operationCoordinator.beginIntent(); state = next; saveDraft(); controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; if (status) document.querySelector('#status').textContent = status; renderUi(); } function updatePlay(play, status, selectedAction = -1) { setState({ ...commitActionEdit(state, play), selectedAction }, status); } function addPassForTarget(targetId) { const sequence = state.play.sequences[state.selectedSequence]; const ownerId = finalBallOwner(sequence); const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); const target = state.play.players.find((player) => player.id === targetId); if (!ownerId || selectedPlayer?.id !== ownerId || selectedPlayer.team !== 'offense' || !target || target.team !== 'offense' || target.id === ownerId) return; const play = addAction(state.play, state.selectedSequence, ownerId, null, { type: 'pass', targetPlayerId: targetId }); if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '패스는 현재 공 소유 공격 선수 → 다른 공격 선수만 가능합니다'; else { state = { ...state, selectedPlayerId: ownerId, mode: 'move' }; const track = play.sequences[state.selectedSequence].tracks.find(track => track.playerId === ownerId); updatePlay(play, '패스 행동 추가', track.actions.length - 1); } } function addScreenForTarget(targetId) { const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); const target = state.play.players.find((player) => player.id === targetId); const sequence = state.play.sequences[state.selectedSequence]; if (!screener || screener.team !== 'offense' || finalBallOwner(sequence) === screener.id) { document.querySelector('#status').textContent = '스크린은 공을 가지지 않은 공격 선수만 가능합니다'; return; } if (!target || target.team !== 'defense') { document.querySelector('#status').textContent = '스크린 대상은 수비 선수여야 합니다'; return; } const play = addScreenAction(state.play, state.selectedSequence, screener.id, target.id); if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '스크린 대상과 너무 가까워 배치할 수 없습니다'; else { state = { ...state, mode: 'move' }; const track = play.sequences[state.selectedSequence].tracks.find(track => track.playerId === screener.id); updatePlay(play, '스크린 행동 추가', track.actions.length - 1); } } function addShootForOwner() { const sequenceIndex = state.selectedSequence; if (state.mode === 'start' || sequenceIndex !== state.play.sequences.length - 1) { document.querySelector('#status').textContent = '슛은 마지막 단계에서만 가능합니다'; return; } const ownerId = finalBallOwner(state.play.sequences[sequenceIndex]); const owner = state.play.players.find((player) => player.id === ownerId); if (!owner || owner.team !== 'offense') { document.querySelector('#status').textContent = '슛을 시도할 공 소유 공격 선수가 없습니다'; return; } const workingState = { ...state, mode: 'move', selectedSequence: sequenceIndex, selectedPlayerId: ownerId, selectedAction: -1 }; const play = addShootAction(state.play, sequenceIndex, ownerId); if (play === state.play) { state = workingState; renderUi(); document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동은 전술당 1개만 가능합니다' : '슛은 마지막 단계의 공 소유 선수만 가능합니다'; return; } state = workingState; const track = play.sequences[sequenceIndex].tracks.find((candidate) => candidate.playerId === ownerId); updatePlay(play, '슛 행동 추가', track.actions.length - 1); } function removeShootAction() { const shot = state.play.sequences.flatMap((sequence, sequenceIndex) => sequence.tracks.flatMap((track) => track.actions.map((action, actionIndex) => ({ action, actionIndex, playerId: track.playerId, sequenceIndex })))).find(({ action }) => action.type === 'shoot'); if (!shot) return; const play = removeAction(state.play, shot.sequenceIndex, shot.playerId, shot.actionIndex); if (play === state.play) return; setState({ ...commitActionEdit(state, play, shot.sequenceIndex, shot.playerId), selectedAction: -1 }, '슛 행동 삭제'); } function renderUi() { const readOnly = currentTeam?.role === 'viewer'; for (const id of ['save-play', 'import-play', 'import-file', 'new-play']) { const element = document.querySelector('#' + id); if (element) element.disabled = readOnly; } const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); const sequence = state.play.sequences[state.selectedSequence] || state.play.sequences[0]; const playHasShoot = hasShoot(state.play); const startingOwner = state.play.players.find((player) => player.id === sequence?.ballOwnerId); const finalOwnerId = finalBallOwner(sequence); const finalOwner = state.play.players.find((player) => player.id === finalOwnerId); const hasPass = finalOwnerId && finalOwnerId !== sequence?.ballOwnerId; document.querySelector('#canvas-step').textContent = String(state.selectedSequence + 1).padStart(2, '0'); if (currentTeam) document.querySelector('#team-context').textContent = currentTeam.name; document.querySelector('#open-team-hub').textContent = '전술 목록'; document.querySelector('#selected-label').textContent = selectedPlayer ? `${selectedPlayer.team === 'offense' ? 'O' : 'D'}${selectedPlayer.number}` : ''; document.querySelector('#roster').innerHTML = ['offense', 'defense'].map((team) => `