Improve membership flow, play library, and mobile tactics board
This commit is contained in:
+287
-41
@@ -8,7 +8,7 @@ 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, createServerPlayRepository, createTeam, getCurrentUser, listPendingUsers, listTeams, loginAccount, logoutAccount, registerAccount } from './api.js';
|
||||
import { ApiError, approveUser, approveTeamJoin, changeTeamMemberRole, createServerPlayRepository, createTeam, getCurrentUser, listJoinRequests, listPendingUsers, listTeamJoinRequests, listTeamMembers, listTeams, loginAccount, logoutAccount, registerAccount, searchTeams, requestTeamJoin } from './api.js';
|
||||
|
||||
const app = document.querySelector('#app');
|
||||
app.innerHTML = accountLayout() + editorLayout();
|
||||
@@ -21,10 +21,26 @@ let playRepository = localPlayRepository;
|
||||
let currentUser = null;
|
||||
let currentTeam = null;
|
||||
let accountGeneration = 0;
|
||||
let searchGeneration = 0;
|
||||
let ownerGeneration = 0;
|
||||
let activeOwnerTeamId = null;
|
||||
const operationCoordinator = createPlayOperationCoordinator();
|
||||
let state = createAppState();
|
||||
const editorHistory = createEditorHistory();
|
||||
let panel = 'court'; let gazePick = null; let suppressClick = false; let drag = null;
|
||||
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 = '팀을 선택하세요';
|
||||
@@ -128,13 +144,16 @@ async function sharePreparedVideo() {
|
||||
}
|
||||
|
||||
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']) document.querySelector(`#${id}`).hidden = id !== `${view}-panel`;
|
||||
for (const id of ['auth-panel', 'register-panel', 'pending-panel', 'team-panel', 'play-library-panel', 'admin-panel']) document.querySelector(`#${id}`).hidden = id !== `${view}-panel`;
|
||||
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 === 'editor') gate.hidden = true;
|
||||
}
|
||||
function draftKey() { return currentUser && currentTeam ? `basket-utils:draft:v2:${encodeURIComponent(currentUser.id)}:${encodeURIComponent(currentTeam.id)}` : ''; }
|
||||
@@ -146,43 +165,91 @@ function renderTeamList(teams) {
|
||||
const list = document.querySelector('#team-list'); list.replaceChildren();
|
||||
const importSelect = document.querySelector('#local-import-team'); importSelect.replaceChildren(); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = teams.length ? '팀을 선택하세요' : '팀을 먼저 만드세요'; importSelect.append(placeholder); importSelect.value = ''; document.querySelector('#import-local-team').disabled = 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 option = document.createElement('option'); option.value = team.id; option.textContent = team.name; importSelect.append(option); }
|
||||
for (const team of teams) { const button = document.createElement('button'); button.type = 'button'; button.className = 'team-card'; button.dataset.teamId = team.id; button.dataset.role = team.role; const name = document.createElement('strong'); name.textContent = team.name; const role = document.createElement('small'); role.textContent = team.role === 'owner' ? '소유자' : team.role === 'editor' ? '편집자' : '열람자'; button.append(name, role); list.append(button); }
|
||||
for (const team of teams.filter((candidate) => candidate.role === 'owner' || candidate.role === 'editor')) { const option = document.createElement('option'); option.value = team.id; option.textContent = `${team.name} · ${team.role === 'owner' ? '소유자' : '편집자'}`; option.dataset.role = team.role; importSelect.append(option); }
|
||||
for (const team of teams) { const button = document.createElement('button'); button.type = 'button'; button.className = 'team-card'; button.dataset.teamId = team.id; button.dataset.role = team.role; const name = document.createElement('strong'); name.textContent = team.name; const role = document.createElement('small'); role.textContent = team.role === 'owner' ? '소유자' : team.role === 'editor' ? '편집자' : '열람자'; button.append(name, role); list.append(button); if (team.role === 'owner') { const manage = document.createElement('button'); manage.type = 'button'; manage.className = 'account-secondary'; manage.dataset.manageTeam = team.id; manage.dataset.teamName = team.name; manage.textContent = '팀원 관리'; list.append(manage); } }
|
||||
}
|
||||
async function renderPendingUsers(generation = accountGeneration) {
|
||||
const panel = document.querySelector('#operator-panel'); if (!currentUser?.isOperator) { panel.hidden = true; return; }
|
||||
panel.hidden = false; const target = document.querySelector('#pending-users'); target.replaceChildren();
|
||||
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 ['#refresh-teams', '#team-form', '.local-import', '#open-admin']) { const element = document.querySelector(selector); if (element) element.hidden = active; } }
|
||||
async function openOwnerPanel(team) { const generation = ++ownerGeneration; activeOwnerTeamId = team.id; document.querySelector('#team-panel').hidden = false; document.querySelector('#owner-panel').hidden = false; document.querySelector('#membership-panel').hidden = true; document.querySelector('#team-list').hidden = true; document.querySelector('#owner-team-name').textContent = team.name ? `${team.name} 팀` : '팀원 관리'; const requestsTarget = document.querySelector('#owner-requests'); const membersTarget = document.querySelector('#owner-members'); requestsTarget.replaceChildren(); membersTarget.replaceChildren(); document.querySelector('#team-message').textContent = '팀원 정보를 불러오는 중…'; try { const [{ requests }, { members }] = await Promise.all([listTeamJoinRequests(team.id), listTeamMembers(team.id)]); if (generation !== ownerGeneration || !currentUser || activeOwnerTeamId !== team.id) return; renderOwnerRows(team, requests, members); } catch (error) { if (generation === ownerGeneration) document.querySelector('#team-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('#team-message').textContent = ''; }
|
||||
async function openTeamHub(message = '', generation = accountGeneration) {
|
||||
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); await renderPendingUsers(generation); }
|
||||
ownerGeneration += 1; activeOwnerTeamId = null; searchGeneration += 1; document.querySelector('#owner-panel').hidden = true; document.querySelector('#team-list').hidden = false; document.querySelector('#membership-panel').hidden = false; document.querySelector('#team-search-results').replaceChildren();
|
||||
showGate('teams'); document.querySelector('#open-admin').hidden = !currentUser?.isOperator; 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); const { requests } = await listJoinRequests(); if (generation === accountGeneration) renderJoinRequests(requests); }
|
||||
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = `팀 목록을 불러오지 못했습니다: ${error.message}`; }
|
||||
}
|
||||
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;
|
||||
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;
|
||||
const generation = ++accountGeneration; operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentTeam = team; playRepository = createServerPlayRepository(team.id); editorHistory.clear(); state = createAppState(); const draftMessage = restoreTeamDraft();
|
||||
controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType; document.querySelector('#team-context').textContent = team.name; document.querySelector('#save-status').textContent = draftMessage; showGate('editor'); renderUi();
|
||||
try { await refreshSavedPlays(); if (generation !== accountGeneration || currentTeam?.id !== team.id) return; document.querySelector('#status').textContent = draftMessage === '임시 저장 복원됨' ? '전술을 복원했습니다 · 행동을 선택해 편집하세요' : '팀 전술함을 열었습니다'; } catch (error) { if (generation === accountGeneration) document.querySelector('#status').textContent = `저장 목록을 불러오지 못했습니다: ${error.message}`; }
|
||||
await openTeamLibrary(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) {
|
||||
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('#play-library-status'); 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 importLocalPlays() {
|
||||
const teamAtRequest = document.querySelector('#local-import-team').value; const teamName = document.querySelector('#local-import-team').selectedOptions[0]?.textContent || ''; if (!teamAtRequest) { document.querySelector('#team-message').textContent = '먼저 기기 전술을 가져올 팀을 선택하세요.'; return; } const generation = accountGeneration; const userAtRequest = currentUser?.id; const repository = createServerPlayRepository(teamAtRequest); const button = document.querySelector('#import-local-team'); button.disabled = true;
|
||||
const importSelect = document.querySelector('#local-import-team'); const selectedOption = importSelect.selectedOptions[0]; const teamAtRequest = importSelect.value; const teamName = selectedOption?.textContent || ''; if (!teamAtRequest || !['owner', 'editor'].includes(selectedOption?.dataset.role)) { document.querySelector('#team-message').textContent = '전술 가져오기는 소유자 또는 편집자만 사용할 수 있습니다.'; return; } const generation = accountGeneration; const userAtRequest = currentUser?.id; const repository = createServerPlayRepository(teamAtRequest); const button = document.querySelector('#import-local-team'); button.disabled = true;
|
||||
try { const existing = await repository.list(); const existingIds = new Set(existing.map((record) => record.id)); const records = await localPlayRepository.list(); const imports = []; for (const record of records) { const play = await localPlayRepository.get(record.id); if (play) imports.push(play); } try { const legacy = storage?.getItem('basket-utils:draft:v1'); if (legacy) { const play = JSON.parse(legacy); if (isValidPlayForImport(play) && !imports.some((candidate) => candidate.id === play.id)) imports.push(play); } } catch { /* malformed legacy draft stays untouched */ } let imported = 0; let skipped = 0; for (const play of imports) { if (generation !== accountGeneration || currentUser?.id !== userAtRequest) return; if (existingIds.has(play.id)) { skipped += 1; continue; } await repository.save(play); existingIds.add(play.id); imported += 1; } document.querySelector('#team-message').textContent = imports.length ? `${imported}개 전술을 ${teamName} 팀으로 가져왔습니다. ${skipped ? `${skipped}개는 이미 있어 건너뛰었습니다. ` : ''}이 기기의 원본은 유지됩니다.` : '이 기기에 가져올 전술이 없습니다.'; }
|
||||
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = `기기 전술 가져오기 실패: ${error.message}`; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
async function initializeAccount() {
|
||||
const generation = accountGeneration;
|
||||
try { const result = await getCurrentUser(); if (generation !== accountGeneration) return; currentUser = result.user; await openTeamHub('', generation); }
|
||||
try { const result = await getCurrentUser(); if (generation !== accountGeneration) return; 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; if (!(error instanceof ApiError) || error.status !== 401) document.querySelector('#auth-message').textContent = '서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.'; showGate('auth', document.querySelector('#auth-message').textContent); }
|
||||
}
|
||||
function isValidPlayForImport(play) { try { return Boolean(play && Array.isArray(play.players) && Array.isArray(play.sequences) && createAppStateFromPlay(play)); } catch { return false; } }
|
||||
async function logoutFromUi() {
|
||||
const generation = ++accountGeneration; 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; });
|
||||
const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; document.querySelector('#team-search-results').replaceChildren(); document.querySelector('#my-join-requests').replaceChildren(); 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; }); }
|
||||
@@ -203,6 +270,7 @@ async function refreshSavedPlays(selectedId = '', canSelect = () => true, option
|
||||
} 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;
|
||||
@@ -232,7 +300,9 @@ function setState(next, status = '', record = true) {
|
||||
}
|
||||
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 play = addAction(state.play, state.selectedSequence, ownerId, null, { type: 'pass', targetPlayerId: 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); }
|
||||
}
|
||||
@@ -253,6 +323,8 @@ function addShootForOwner() {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -260,7 +332,7 @@ function renderUi() {
|
||||
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 = 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) => `<div class="team-label">${team === 'offense' ? 'OFFENSE' : 'DEFENSE'}</div>${state.play.players.filter((player) => player.team === team).map((player) => { const startMark = hasPass && player.id === sequence?.ballOwnerId ? '<span class="ball-mark">START</span>' : ''; const finalMark = player.id === finalOwnerId ? '<span class="ball-mark">BALL</span>' : ''; return `<button class="roster-player ${player.id === state.selectedPlayerId ? 'selected' : ''} ${player.id === finalOwnerId ? 'owns-ball' : ''}" data-player="${player.id}"><span class="dot ${team}"></span>${team === 'offense' ? 'O' : 'D'}${player.number}${startMark}${finalMark}<small>${player.location.x.toFixed(1)}, ${player.location.z.toFixed(1)}</small></button>`; }).join('')}`).join('');
|
||||
document.querySelector('#ball-owner-label').textContent = hasPass ? `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'} · 패스 후: ${finalOwner ? `O${finalOwner.number}` : '없음'}` : `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'}`;
|
||||
@@ -271,11 +343,11 @@ function renderUi() {
|
||||
? '<strong>시작 위치 설정</strong><br />선수를 고른 뒤 코트를 클릭해 시작 위치를 배치하세요.<br />이 단계의 클릭은 행동으로 저장되지 않습니다.<br /><b>배치가 끝나면 이동를 누르세요.</b>'
|
||||
: state.mode === 'move'
|
||||
? '<strong>이동 경로 설정</strong><br />선수를 고른 뒤 코트를 연속 클릭해 행동을 만드세요.<br />시작 위치를 바꾸려면 시작 배치로 돌아가세요.'
|
||||
: state.mode === 'pass' ? '<strong>패스</strong><br />현재 공 소유자는 자동 선택됩니다. 공격 선수를 클릭해 수신자를 고르세요.'
|
||||
: state.mode === 'pass' ? '<strong>패스</strong><br />공 소유 공격 선수를 선택한 뒤 다른 공격 선수를 클릭해 수신자를 고르세요.'
|
||||
: state.mode === 'screen' ? '<strong>스크린</strong><br />볼을 소유하지 않은 공격 선수를 고른 뒤 수비 선수를 클릭하세요.'
|
||||
: '<strong>시선 설정</strong><br />행동을 고른 뒤 선수나 코트를 클릭하면 시선이 저장됩니다.';
|
||||
document.querySelector('#help-copy').innerHTML = helpCopy;
|
||||
const sequenceNav = document.querySelector('#sequences'); sequenceNav.replaceChildren(); state.play.sequences.forEach((sequence, index) => { const button = document.createElement('button'); button.className = `sequence-tab ${index === state.selectedSequence ? 'selected' : ''}`; button.dataset.sequence = String(index); decorateSequenceButton(button, sequence, index, state.play.players); sequenceNav.append(button); }); const addSequenceButton = document.createElement('button'); addSequenceButton.id = 'add-sequence'; addSequenceButton.className = 'add-sequence'; addSequenceButton.textContent = '+'; sequenceNav.append(addSequenceButton); const deleteSequenceButton = document.createElement('button'); deleteSequenceButton.id = 'delete-sequence'; deleteSequenceButton.className = 'danger'; deleteSequenceButton.textContent = '삭제'; sequenceNav.append(deleteSequenceButton);
|
||||
const sequenceNav = document.querySelector('#sequences'); sequenceNav.replaceChildren(); state.play.sequences.forEach((sequence, index) => { const button = document.createElement('button'); button.className = `sequence-tab ${index === state.selectedSequence ? 'selected' : ''}`; button.dataset.sequence = String(index); decorateSequenceButton(button, sequence, index, state.play.players); sequenceNav.append(button); }); const sequenceControls = document.querySelector('#sequence-controls'); sequenceControls.replaceChildren(); const addSequenceButton = document.createElement('button'); addSequenceButton.id = 'add-sequence'; addSequenceButton.className = 'add-sequence'; addSequenceButton.textContent = '+'; addSequenceButton.setAttribute('aria-label', '단계 추가'); sequenceControls.append(addSequenceButton); const deleteSequenceButton = document.createElement('button'); deleteSequenceButton.id = 'delete-sequence'; deleteSequenceButton.className = 'danger'; deleteSequenceButton.textContent = '삭제'; deleteSequenceButton.setAttribute('aria-label', '현재 단계 삭제'); sequenceControls.append(deleteSequenceButton);
|
||||
const track = selectedTrack();
|
||||
document.querySelector('#track-duration').textContent = track ? `${track.actions.length}개 · ${scheduledTrackDuration(sequence, track, 4.5, state.play.players).toFixed(1)}s` : '';
|
||||
const emptyCopy = state.mode === 'start' ? '시작 위치를 설정 중입니다.<br />이 단계에서는 행동이 생성되지 않습니다.' : state.mode === 'screen' ? '아직 스크린 행동이 없습니다.<br />수비 선수를 선택해 Screen을 만드세요.' : '아직 행동이 없습니다.<br />코트 위를 클릭해 경로를 만드세요.';
|
||||
@@ -284,9 +356,11 @@ function renderUi() {
|
||||
document.querySelector('#delete-action').disabled = state.mode === 'start' || state.selectedAction < 0;
|
||||
const shootButton = document.querySelector('#shoot-action'); shootButton.hidden = selectedPlayer?.id !== finalOwnerId;
|
||||
shootButton.disabled = playHasShoot || state.mode === 'start' || state.selectedSequence !== state.play.sequences.length - 1;
|
||||
const passButton = document.querySelector('[data-mode="pass"]'); const canPass = selectedPlayer?.team === 'offense' && selectedPlayer.id === finalOwnerId;
|
||||
passButton.hidden = !canPass; passButton.disabled = playHasShoot || !canPass;
|
||||
document.querySelector('#add-sequence').disabled = playHasShoot;
|
||||
document.querySelector('#delete-sequence').disabled = playHasShoot;
|
||||
document.querySelectorAll('[data-mode]').forEach((button) => { button.disabled = playHasShoot; });
|
||||
document.querySelectorAll('[data-mode]').forEach((button) => { button.disabled = playHasShoot || (button.dataset.mode === 'pass' && !canPass); });
|
||||
document.querySelector('#undo').disabled = !canUndo; document.querySelector('#redo').disabled = !canRedo;
|
||||
document.querySelectorAll('[data-mode]').forEach((button) => button.classList.toggle('active', button.dataset.mode === state.mode));
|
||||
document.querySelectorAll('[data-sequence]').forEach((button) => button.classList.toggle('selected', Number(button.dataset.sequence) === state.selectedSequence));
|
||||
@@ -302,8 +376,9 @@ function renderUi() {
|
||||
document.querySelector('#apply-position').disabled = action?.type !== 'move';
|
||||
document.querySelector('#scrubber').max = totalDuration(state.play);
|
||||
document.querySelector('#data-preview').textContent = serializePlay(state.play);
|
||||
document.querySelector('.shell').dataset.panel = panel;
|
||||
const shell = document.querySelector('.shell'); shell.dataset.panel = panel; shell.dataset.mobileFocus = mobileFocusActive ? 'true' : 'false'; shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed'; updateOrientationGate(); board.setMobileFocus?.(mobileFocusActive);
|
||||
document.querySelectorAll('button[data-panel]').forEach(button => { const active = button.dataset.panel === panel; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); });
|
||||
document.querySelector('#toggle-focus-actions')?.setAttribute('aria-expanded', String(mobileFocusActive && panel === 'actions'));
|
||||
ensureVideoFresh(); renderVideoControls();
|
||||
}
|
||||
|
||||
@@ -311,7 +386,7 @@ app.addEventListener('submit', async (event) => {
|
||||
event.preventDefault(); const form = event.target;
|
||||
if (form.id === 'login-form') {
|
||||
const generation = ++accountGeneration; const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true;
|
||||
try { const result = await loginAccount(data.get('email'), data.get('password')); if (generation !== accountGeneration) return; currentUser = result.user; form.reset(); await openTeamHub('', generation); }
|
||||
try { const result = await loginAccount(data.get('email'), data.get('password')); if (generation !== accountGeneration) return; currentUser = result.user; form.reset(); if (window.location.pathname === '/admin') { if (currentUser.isOperator) await openAdmin(generation, false); else { history.replaceState({ view: 'teams' }, '', '/'); await openTeamHub('운영자만 이용할 수 있는 페이지입니다.', ++accountGeneration); } } else await openTeamHub('', generation); }
|
||||
catch (error) { if (generation === accountGeneration) showGate(error.code === 'approval_required' ? 'pending' : 'auth', error.message); }
|
||||
finally { button.disabled = false; }
|
||||
} else if (form.id === 'register-form') {
|
||||
@@ -324,22 +399,49 @@ app.addEventListener('submit', async (event) => {
|
||||
try { const result = await createTeam(data.get('name')); if (generation !== accountGeneration) return; form.reset(); await selectTeam(result.team); }
|
||||
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = error.message; }
|
||||
finally { button.disabled = false; }
|
||||
}
|
||||
} else if (form.id === 'team-search-form') { await searchAndRenderTeams(new FormData(form).get('q')); }
|
||||
else if (form.id === 'new-play-form') { const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true; try { startNewFromLibrary(data.get('name'), data.get('defenseType')); } finally { button.disabled = currentTeam?.role === 'viewer'; } }
|
||||
});
|
||||
|
||||
app.addEventListener('click', (event) => {
|
||||
if (event.target.closest('#toggle-focus-playback')) {
|
||||
if (!mobileFocusActive) return;
|
||||
focusPlaybackOpen = !focusPlaybackOpen;
|
||||
const shell = document.querySelector('.shell');
|
||||
if (shell) shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed';
|
||||
event.target.closest('#toggle-focus-playback').setAttribute('aria-expanded', String(focusPlaybackOpen));
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('#toggle-focus-actions')) {
|
||||
if (!mobileFocusActive) return;
|
||||
panel = panel === 'actions' ? 'court' : 'actions';
|
||||
renderUi();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('#show-register')) { showGate('register'); return; }
|
||||
if (event.target.closest('#show-login') || event.target.closest('#pending-login')) { showGate('auth'); return; }
|
||||
if (event.target.closest('#team-logout') || event.target.closest('#logout')) { logoutFromUi(); return; }
|
||||
const teamCard = event.target.closest('[data-team-id]'); if (teamCard) { const team = { id: teamCard.dataset.teamId, name: teamCard.querySelector('strong')?.textContent || '', role: teamCard.dataset.role || 'viewer' }; selectTeam(team); return; }
|
||||
if (event.target.closest('#open-admin')) { const generation = ++accountGeneration; openAdmin(generation); return; }
|
||||
if (event.target.closest('#refresh-teams')) { setOwnerView(false); openTeamHub('', ++accountGeneration); return; }
|
||||
if (event.target.closest('[data-manage-team]')) { const manage = event.target.closest('[data-manage-team]'); setOwnerView(true); openOwnerPanel({ id: manage.dataset.manageTeam, name: manage.dataset.teamName }); return; }
|
||||
if (event.target.closest('#owner-back')) { ownerGeneration += 1; activeOwnerTeamId = null; setOwnerView(false); document.querySelector('#owner-panel').hidden = true; document.querySelector('#membership-panel').hidden = false; document.querySelector('#team-list').hidden = false; return; }
|
||||
if (event.target.closest('[data-approve-join]')) { const button = event.target.closest('[data-approve-join]'); const ownerTeamId = button.dataset.teamId; const generation = ownerGeneration; const role = button.parentElement.querySelector('select')?.value || 'viewer'; button.disabled = true; approveTeamJoin(ownerTeamId, button.dataset.approveJoin, role).then(() => { if (generation === ownerGeneration && activeOwnerTeamId === ownerTeamId) return openOwnerPanel({ id: ownerTeamId, name: document.querySelector('#owner-team-name').textContent.replace(/ 팀$/, '') }); }).catch((error) => { if (generation === ownerGeneration) { button.disabled = false; document.querySelector('#team-message').textContent = error.message; } }); return; }
|
||||
if (event.target.closest('[data-save-member-role]')) { const button = event.target.closest('[data-save-member-role]'); const select = button.parentElement.querySelector('select[data-member-role]'); const teamId = select?.dataset.teamId; const userId = select?.dataset.memberRole; const previous = select?.dataset.previousRole; const requestedRole = select?.value; const generation = ownerGeneration; if (!select || !teamId || !userId) return; button.disabled = true; select.disabled = true; changeTeamMemberRole(teamId, userId, requestedRole).then(() => { if (generation === ownerGeneration) { select.dataset.previousRole = requestedRole; document.querySelector('#team-message').textContent = '역할을 저장했습니다.'; } }).catch((error) => { if (generation === ownerGeneration) { select.value = previous; document.querySelector('#team-message').textContent = error.message; } }).finally(() => { if (generation === ownerGeneration) { button.disabled = false; select.disabled = false; } }); return; }
|
||||
if (event.target.closest('#admin-back')) { accountGeneration += 1; searchGeneration += 1; setOwnerView(false); history.pushState({ view: 'teams' }, '', '/'); openTeamHub('', accountGeneration); return; }
|
||||
if (event.target.closest('[data-join-team]')) { const button = event.target.closest('[data-join-team]'); const generation = searchGeneration; button.disabled = true; requestTeamJoin(button.dataset.joinTeam).then(() => { if (generation !== searchGeneration) return; button.textContent = '승인 대기 중'; document.querySelector('#join-status').textContent = '가입 요청을 보냈습니다. 팀 소유자의 승인을 기다려 주세요.'; return searchAndRenderTeams(document.querySelector('#team-search-form input[name="q"]').value); }).catch((error) => { if (generation === searchGeneration) { button.disabled = false; document.querySelector('#join-status').textContent = error.message; } }); return; }
|
||||
if (event.target.closest('#team-logout') || event.target.closest('#logout')) { document.querySelector('#team-search-results').replaceChildren(); document.querySelector('#my-join-requests').replaceChildren(); document.querySelector('#join-status').textContent = ''; document.querySelector('#team-search-form input[name="q"]').value = ''; logoutFromUi(); return; }
|
||||
const teamCard = event.target.closest('button.team-card[data-team-id]'); if (teamCard) { const team = { id: teamCard.dataset.teamId, name: teamCard.querySelector('strong')?.textContent || '', role: teamCard.dataset.role || 'viewer' }; selectTeam(team); return; }
|
||||
if (event.target.closest('#import-local-team')) { importLocalPlays(); return; }
|
||||
if (event.target.closest('[data-approve-user]')) { const generation = accountGeneration; approveUser(event.target.closest('[data-approve-user]').dataset.approveUser).then(() => { if (generation === accountGeneration) return renderPendingUsers(generation); }).catch((error) => { if (generation === accountGeneration) document.querySelector('#team-message').textContent = error.message; }); return; }
|
||||
if (event.target.closest('#open-team-hub')) { operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); const generation = ++accountGeneration; document.querySelector('.shell').hidden = true; openTeamHub('', generation); return; }
|
||||
if (event.target.closest('[data-approve-user]')) { const generation = accountGeneration; const button = event.target.closest('[data-approve-user]'); button.disabled = true; approveUser(button.dataset.approveUser).then(() => { if (generation === accountGeneration) return renderPendingUsers(generation); }).catch((error) => { if (generation === accountGeneration) (document.querySelector('#admin-message') || document.querySelector('#team-message')).textContent = error.message; }).finally(() => { if (generation === accountGeneration) button.disabled = false; }); return; }
|
||||
if (event.target.closest('#open-team-hub')) { operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); if (currentTeam) { if (!document.querySelector('.shell')?.hidden) saveDraft(); openTeamLibrary(currentTeam); } else { const generation = ++accountGeneration; document.querySelector('.shell').hidden = true; openTeamHub('', generation); } return; }
|
||||
if (event.target.closest('[data-open-play]')) { openLibraryPlay(event.target.closest('[data-open-play]').dataset.openPlay); return; }
|
||||
if (event.target.closest('#continue-draft')) { if (currentTeam?.role === 'viewer' || !currentTeam) return; enterEditor(state, '임시 저장 전술을 계속 편집합니다'); return; }
|
||||
if (event.target.closest('#retry-play-library')) { openTeamLibrary(currentTeam); return; }
|
||||
if (event.target.closest('#library-back-teams')) { operationCoordinator.beginIntent(); loadGeneration += 1; const generation = ++accountGeneration; currentTeam = null; playRepository = localPlayRepository; document.querySelector('.shell').hidden = true; openTeamHub('', generation); return; }
|
||||
if (event.target.closest('#video-share-open')) { const menu = document.querySelector('#project-menu'); menu.dataset.view = 'video'; menu.hidden = false; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'true'); document.querySelector('#export-video').focus(); return; }
|
||||
if (event.target.closest('#close-video-tools')) { const menu = document.querySelector('#project-menu'); menu.hidden = true; delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); document.querySelector('#video-share-open').focus(); return; }
|
||||
if (event.target.closest('#toggle-menu')) { const menu = document.querySelector('#project-menu'); const open = menu.hidden; menu.hidden = !open; if (open) delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', String(open)); return; }
|
||||
if (event.target.closest('#cancel-gaze')) { gazePick = null; renderUi(); document.querySelector('#status').textContent = '시선 선택 취소'; return; }
|
||||
const panelButton = event.target.closest('button[data-panel]'); if (panelButton) { panel = panelButton.dataset.panel; renderUi(); return; }
|
||||
const panelButton = event.target.closest('button[data-panel]'); if (panelButton) { panel = panel === panelButton.dataset.panel ? 'court' : panelButton.dataset.panel; renderUi(); return; }
|
||||
if (event.target.closest('#apply-position')) { const x = Number(document.querySelector('#action-x').value); const z = Number(document.querySelector('#action-z').value); updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: { x, z } }), '이동 위치 수정', state.selectedAction); return; }
|
||||
if (event.target.closest('#loop')) { const button = document.querySelector('#loop'); const enabled = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(enabled)); controller.setLoop(enabled); return; }
|
||||
if (event.target.closest('#previous-step') || event.target.closest('#next-step')) { const current = controller.sample().sequenceIndex; const next = Math.max(0, Math.min(state.play.sequences.length - 1, current + (event.target.closest('#next-step') ? 1 : -1))); seekTo(state.play.sequences.slice(0, next).reduce((time, sequence) => time + sequenceDuration(sequence, 4.5, state.play.players), 0)); return; }
|
||||
@@ -354,14 +456,14 @@ app.addEventListener('click', (event) => {
|
||||
if (event.target.closest('#shoot-action')) { addShootForOwner(); return; }
|
||||
if (event.target.closest('#undo')) { const next = editorHistory.undo(state); if (next !== state) setState(next, '실행 취소', false); return; }
|
||||
if (event.target.closest('#redo')) { const next = editorHistory.redo(state); if (next !== state) setState(next, '다시 실행', false); return; }
|
||||
if (event.target.closest('#set-ball-owner')) { const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (selectedPlayer?.team === 'offense') { const play = setSequenceBallOwner(state.play, state.selectedSequence, selectedPlayer.id); if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '먼저 패스 행동을 삭제하세요'; else setState({ ...state, play }, `Sequence ${state.selectedSequence + 1} 공 소유자 O${selectedPlayer.number}`); } return; }
|
||||
if (event.target.closest('#set-ball-owner')) { const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (selectedPlayer?.team === 'offense') { const play = setSequenceBallOwner(state.play, state.selectedSequence, selectedPlayer.id); if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '먼저 패스 행동을 삭제하세요'; else setState({ ...state, play, mode: 'move', selectedAction: -1 }, `Sequence ${state.selectedSequence + 1} 공 소유자 O${selectedPlayer.number}`); } return; }
|
||||
const playerButton = event.target.closest('[data-player]');
|
||||
if (playerButton) { const playerId = playerButton.dataset.player; if (gazePick) { if (gazePick === 'player') applyGaze({ type: 'player', targetId: playerId }); return; } if (state.mode === 'pass') addPassForTarget(playerId); else if (state.mode === 'screen') addScreenForTarget(playerId); else if (state.mode === 'lookAt' && state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; const lookAt = normalizeLookAt({ type: 'player', targetId: playerId }, state.selectedPlayerId, play.players); if (action && lookAt) { action.lookAt = lookAt; updatePlay(play, '선수 대상 시선 저장', state.selectedAction); } } else { playbackSession = false; resetPreview = false; state = { ...state, selectedPlayerId: playerId, selectedAction: -1 }; renderUi(); } return; }
|
||||
const sequenceButton = event.target.closest('[data-sequence]');
|
||||
if (sequenceButton) { gazePick = null; const selectedSequence = Number(sequenceButton.dataset.sequence); state = { ...state, selectedSequence, selectedAction: -1, mode: state.mode === 'start' && selectedSequence > 0 ? 'move' : state.mode }; controller.reset(); playbackSession = false; resetPreview = false; renderUi(); return; }
|
||||
if (sequenceButton) { gazePick = null; const selectedSequence = Number(sequenceButton.dataset.sequence); const mode = state.mode === 'pass' || (state.mode === 'start' && selectedSequence > 0) ? 'move' : state.mode; state = { ...state, selectedSequence, selectedAction: -1, mode }; controller.reset(); playbackSession = false; resetPreview = false; renderUi(); return; }
|
||||
const actionButton = event.target.closest('[data-action]');
|
||||
if (actionButton) { gazePick = null; playbackSession = false; resetPreview = false; state = { ...state, selectedAction: Number(actionButton.dataset.action) }; renderUi(); return; }
|
||||
const modeButton = event.target.closest('[data-mode]'); if (modeButton) { gazePick = null; const mode = modeButton.dataset.mode; if (mode === 'pass') { const ownerId = finalBallOwner(state.play.sequences[state.selectedSequence]); if (!ownerId) { document.querySelector('#status').textContent = '현재 단계의 공 소유 선수가 없습니다'; return; } state = { ...state, selectedPlayerId: ownerId }; } if (mode === 'screen') { const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!screener || screener.team !== 'offense' || finalBallOwner(state.play.sequences[state.selectedSequence]) === screener.id) { document.querySelector('#status').textContent = '먼저 공을 가지지 않은 공격 선수를 선택하세요'; return; } } playbackSession = false; resetPreview = false; state = { ...state, mode, selectedSequence: mode === 'start' ? 0 : state.selectedSequence, selectedAction: -1 }; document.querySelector('#status').textContent = mode === 'start' ? '시작 위치 설정 단계 · 행동은 생성되지 않습니다' : mode === 'move' ? '이동 행동 편집 단계' : mode === 'pass' ? '패스 대상 선택 단계 · 현재 공 소유자 자동 선택됨' : mode === 'screen' ? '스크린 수비 대상 선택 단계' : 'LookAt 편집 단계'; renderUi(); return; }
|
||||
const modeButton = event.target.closest('[data-mode]'); if (modeButton) { gazePick = null; const mode = modeButton.dataset.mode; if (mode === 'pass') { const ownerId = finalBallOwner(state.play.sequences[state.selectedSequence]); const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!ownerId || selectedPlayer?.id !== ownerId || selectedPlayer.team !== 'offense') return; } if (mode === 'screen') { const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!screener || screener.team !== 'offense' || finalBallOwner(state.play.sequences[state.selectedSequence]) === screener.id) { document.querySelector('#status').textContent = '먼저 공을 가지지 않은 공격 선수를 선택하세요'; return; } } playbackSession = false; resetPreview = false; state = { ...state, mode, selectedSequence: mode === 'start' ? 0 : state.selectedSequence, selectedAction: -1 }; document.querySelector('#status').textContent = mode === 'start' ? '시작 위치 설정 단계 · 행동은 생성되지 않습니다' : mode === 'move' ? '이동 행동 편집 단계' : mode === 'pass' ? '패스 대상 선택 단계 · 현재 공 소유자에게서 패스 대상을 고르세요' : mode === 'screen' ? '스크린 수비 대상 선택 단계' : 'LookAt 편집 단계'; renderUi(); return; }
|
||||
if (event.target.closest('#add-sequence')) { if (state.mode === 'start') { document.querySelector('#status').textContent = '먼저 이동를 눌러 시작 위치를 확정하세요'; return; } const next = addPlaySequence(state); if (next.play === state.play) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '새 단계가 이전 마지막 위치를 상속했습니다'); return; }
|
||||
if (event.target.closest('#delete-sequence')) { const next = deletePlaySequence(state); if (next.play !== state.play) setState(next, '단계 삭제'); return; }
|
||||
if (event.target.closest('#delete-action')) { const play = removeAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction); if (play === state.play && hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 선택해 삭제하세요'; return; } updatePlay(play, '행동 삭제'); return; }
|
||||
@@ -370,12 +472,13 @@ app.addEventListener('click', (event) => {
|
||||
if (event.target.closest('#reset')) { controller.reset(); playbackSession = false; resetPreview = true; state = { ...state, playing: false, selectedSequence: 0, selectedAction: -1 }; document.querySelector('#status').textContent = '처음 위치로 복귀'; return; }
|
||||
if (event.target.closest('#tactical')) { state = { ...state, view: 'tactical' }; renderUi(); return; }
|
||||
if (event.target.closest('#pov')) { panel = 'court'; gazePick = null; state = { ...state, view: 'pov' }; renderUi(); return; }
|
||||
if (event.target.closest('#new-play')) { operationCoordinator.beginIntent(); editorHistory.clear(); const name = document.querySelector('#play-name').value; const defenseType = document.querySelector('#defense-type').value; state = { ...createAppState(name, defenseType), selectedPlayerId: 'offense-1' }; controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; saveDraft(); document.querySelector('#status').textContent = '시작 배치 단계'; renderUi(); return; }
|
||||
if (event.target.closest('#new-play')) { if (currentTeam) openTeamLibrary(currentTeam, '새 전술 이름과 수비 형태를 선택하세요.'); return; }
|
||||
});
|
||||
|
||||
app.addEventListener('change', (event) => { if (event.target.id === 'saved-plays') document.querySelector('#load-play').disabled = !event.target.value; if (event.target.id === 'local-import-team') document.querySelector('#import-local-team').disabled = !event.target.value; });
|
||||
app.addEventListener('input', (event) => { if (event.target.id === 'play-name') { operationCoordinator.beginIntent(); saveDraft(); ensureVideoFresh(); renderVideoControls(); } if (event.target.id === 'scrubber') seekTo(event.target.value); });
|
||||
app.addEventListener('change', async (event) => {
|
||||
if (event.target.dataset.memberRole) { event.target.closest('.pending-user')?.querySelector('[data-save-member-role]')?.focus(); document.querySelector('#team-message').textContent = '변경한 역할을 확인하려면 역할 저장을 누르세요.'; return; }
|
||||
if (event.target.id === 'video-view') { ensureVideoFresh(); renderVideoControls(); return; }
|
||||
if (event.target.id === 'gaze') { const type = event.target.value; if (type === 'player' || type === 'location') { gazePick = type; state.mode = 'move'; panel = 'court'; renderUi(); document.querySelector('#status').textContent = type === 'player' ? '시선으로 따라갈 선수를 선택하세요' : '바라볼 코트 지점을 선택하세요'; } else applyGaze(type === 'auto' ? null : { type }); }
|
||||
if (event.target.id === 'playback-rate') controller.setRate(event.target.value);
|
||||
@@ -397,6 +500,7 @@ function applyGaze(lookAt) {
|
||||
function seekTo(time) { controller.pause(); controller.seek(time); playbackSession = true; resetPreview = false; state.mode = 'move'; state.playing = false; document.querySelector('#status').textContent = '재생 위치 미리보기'; renderUi(); }
|
||||
document.addEventListener('keydown', event => {
|
||||
if (document.querySelector('.shell')?.hidden) return;
|
||||
if (document.querySelector('.shell')?.dataset.orientationGate === 'true' && !event.target.closest('#orientation-gate')) { event.preventDefault(); return; }
|
||||
if (event.key === 'Escape' && !document.querySelector('#project-menu').hidden) { const menu = document.querySelector('#project-menu'); menu.hidden = true; delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); document.querySelector('#video-share-open').focus(); return; }
|
||||
if (event.target.closest('input,select,textarea,video,button,[contenteditable="true"]')) return;
|
||||
let id = null;
|
||||
@@ -423,20 +527,22 @@ document.querySelector('#board').addEventListener('click', (event) => {
|
||||
if (hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; return; }
|
||||
const location = snapLocation(clampLocation(hit.location));
|
||||
if (state.mode === 'start') { const next = setSelectedPlayerStart(state, location); if (next.play === state.play && hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '시작 위치 변경 · 행동 0개'); }
|
||||
else if (state.mode === 'move') { const play = addAction(state.play, state.selectedSequence, state.selectedPlayerId, location); const track = play.sequences[state.selectedSequence]?.tracks.find((candidate) => candidate.playerId === state.selectedPlayerId); if (track && play !== state.play) updatePlay(play, '이동 행동 추가', track.actions.length - 1); else if (hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; }
|
||||
else if (state.mode === 'move') { const play = addAction(state.play, state.selectedSequence, state.selectedPlayerId, location); if (play !== state.play) updatePlay(play, '이동 행동 추가', -1); else if (hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; }
|
||||
else if (state.mode === 'screen') { document.querySelector('#status').textContent = '수비 선수를 선택하세요'; }
|
||||
else if (state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; if (action) { action.lookAt = { type: 'location', ...location }; updatePlay(play, '코트 위치 시선 저장', state.selectedAction); } }
|
||||
});
|
||||
|
||||
const boardElement = document.querySelector('#board');
|
||||
boardElement.addEventListener('pointerdown', event => {
|
||||
if (event.button !== 0 || state.view !== 'tactical' || state.playing || gazePick || (state.mode === 'start' && hasShoot(state.play))) return;
|
||||
if (state.mode !== 'start' && state.mode !== 'move') return;
|
||||
if (event.button !== 0 || state.view !== 'tactical' || state.playing || gazePick || drag || (event.pointerType !== 'mouse' && event.isPrimary === false)) return;
|
||||
if (!['start', 'move'].includes(state.mode)) return;
|
||||
const hit = board.pick(event, state);
|
||||
if (hit?.type !== 'player' || hit.playerId !== state.selectedPlayerId) return;
|
||||
const action = selectedTrack()?.actions[state.selectedAction];
|
||||
if (state.mode !== 'start' && action?.type !== 'move') return;
|
||||
drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, moved: false, location: null };
|
||||
if (hit?.type !== 'player') return;
|
||||
const changedPlayer = hit.playerId !== state.selectedPlayerId;
|
||||
const selectedAction = selectedTrack()?.actions[state.selectedAction];
|
||||
if (!changedPlayer && state.mode === 'move' && state.selectedAction >= 0 && selectedAction?.type !== 'move') return;
|
||||
drag = { pointerId: event.pointerId, playerId: hit.playerId, x: event.clientX, y: event.clientY, moved: false, location: null };
|
||||
state = { ...state, selectedPlayerId: hit.playerId, selectedAction: changedPlayer ? -1 : state.selectedAction };
|
||||
boardElement.setPointerCapture(event.pointerId);
|
||||
});
|
||||
boardElement.addEventListener('pointermove', event => {
|
||||
@@ -444,6 +550,7 @@ boardElement.addEventListener('pointermove', event => {
|
||||
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 7 && !drag.moved) return;
|
||||
const location = board.courtLocation(event); if (!location) return;
|
||||
drag.moved = true; drag.location = snapLocation(location);
|
||||
state = { ...state, boardPreview: { ...(state.boardPreview || {}), [drag.playerId]: drag.location } };
|
||||
document.querySelector('.board-wrap').classList.add('dragging');
|
||||
document.querySelector('#status').textContent = `놓으면 위치 변경 · ${drag.location.x.toFixed(1)}, ${drag.location.z.toFixed(1)}`;
|
||||
});
|
||||
@@ -454,14 +561,153 @@ function finishDrag(event) {
|
||||
if (boardElement.hasPointerCapture(event.pointerId)) boardElement.releasePointerCapture(event.pointerId);
|
||||
if (!completed.moved) return;
|
||||
suppressClick = true; setTimeout(() => { suppressClick = false; }, 0);
|
||||
if (event.type !== 'pointerup') return;
|
||||
if (event.type !== 'pointerup') { cancelActiveDrag(); return; }
|
||||
state = { ...state, boardPreview: undefined };
|
||||
if (state.mode === 'start') setState(setSelectedPlayerStart(state, completed.location), '시작 위치 변경');
|
||||
else updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: completed.location }), '이동 위치 변경', state.selectedAction);
|
||||
else if (state.selectedAction < 0) { const play = addAction(state.play, state.selectedSequence, completed.playerId, completed.location); if (play !== state.play) updatePlay(play, '이동 행동 추가', -1); }
|
||||
else updatePlay(editAction(state.play, state.selectedSequence, completed.playerId, state.selectedAction, { location: completed.location }), '이동 위치 변경', state.selectedAction);
|
||||
}
|
||||
boardElement.addEventListener('pointerup', finishDrag);
|
||||
boardElement.addEventListener('pointercancel', finishDrag);
|
||||
boardElement.addEventListener('lostpointercapture', event => { if (drag?.pointerId === event.pointerId) finishDrag({ type: 'pointercancel', pointerId: event.pointerId }); });
|
||||
window.addEventListener('blur', () => { if (drag) finishDrag({ type: 'pointercancel', pointerId: drag.pointerId }); });
|
||||
|
||||
function focusViewportEligible() {
|
||||
const shortSide = Math.min(window.innerWidth || 0, window.innerHeight || 0);
|
||||
const coarse = window.matchMedia?.('(pointer: coarse)').matches;
|
||||
return window.innerWidth <= 800 || (coarse && shortSide <= 800) || (window.innerWidth <= 900 && window.innerHeight <= 550);
|
||||
}
|
||||
function focusIsPortrait() {
|
||||
return (window.innerHeight || 0) > (window.innerWidth || 0);
|
||||
}
|
||||
function updateOrientationGate(session = focusSession) {
|
||||
if (session !== focusSession) return;
|
||||
const blocked = mobileFocusActive && focusIsPortrait() && ['pending', 'unsupported', 'rejected'].includes(focusOrientationStatus);
|
||||
const gate = document.querySelector('#orientation-gate');
|
||||
if (gate) {
|
||||
gate.hidden = !blocked;
|
||||
gate.setAttribute('aria-hidden', String(!blocked));
|
||||
if (blocked && !gate.contains(document.activeElement)) gate.querySelector('#orientation-gate-exit')?.focus();
|
||||
}
|
||||
for (const selector of ['.topbar', '.mobile-tabs', '.workspace', '.playback-deck']) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) element.inert = blocked;
|
||||
}
|
||||
const shell = document.querySelector('.shell');
|
||||
if (shell) shell.dataset.orientationGate = blocked ? 'true' : 'false';
|
||||
}
|
||||
function requestLandscapeLock(session) {
|
||||
const orientation = window.screen?.orientation;
|
||||
const token = ++focusOrientationToken;
|
||||
if (!orientation || typeof orientation.lock !== 'function') { focusOrientationStatus = 'unsupported'; updateOrientationGate(session); return; }
|
||||
focusOrientationStatus = 'pending'; updateOrientationGate(session);
|
||||
Promise.resolve().then(() => orientation.lock('landscape')).then(() => {
|
||||
if (token !== focusOrientationToken || session !== focusSession || !mobileFocusActive || !document.fullscreenElement) {
|
||||
if (!mobileFocusActive || !document.fullscreenElement) { try { orientation.unlock?.(); } catch { /* best effort */ } }
|
||||
return;
|
||||
}
|
||||
focusOrientationStatus = 'locked'; updateOrientationGate(session);
|
||||
}).catch(() => {
|
||||
if (token !== focusOrientationToken || session !== focusSession || !mobileFocusActive) return;
|
||||
focusOrientationStatus = 'rejected'; updateOrientationGate(session);
|
||||
});
|
||||
}
|
||||
function unlockOrientation(session) {
|
||||
const orientation = window.screen?.orientation;
|
||||
const token = ++focusOrientationToken;
|
||||
if (!orientation || typeof orientation.unlock !== 'function') return;
|
||||
Promise.resolve().then(() => orientation.unlock()).catch(() => {}).then(() => {
|
||||
if (token !== focusOrientationToken || session !== focusSession || mobileFocusActive) return;
|
||||
focusOrientationStatus = 'idle'; updateOrientationGate(session);
|
||||
});
|
||||
}
|
||||
function updateFullscreenButton(button, active) {
|
||||
if (!button) return;
|
||||
const label = active ? '전체화면 종료' : '전체화면';
|
||||
const visualLabel = button.querySelector('.fullscreen-label');
|
||||
if (visualLabel) visualLabel.textContent = label;
|
||||
else button.textContent = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.title = label;
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
}
|
||||
function setMobileFocus(active, options = {}) {
|
||||
cancelActiveDrag();
|
||||
if (active) {
|
||||
if (!mobileFocusActive) focusSession += 1;
|
||||
mobileFocusActive = true;
|
||||
focusOrientationStatus = options.orientationStatus || 'idle';
|
||||
} else {
|
||||
const endingSession = focusSession;
|
||||
const shouldUnlock = mobileFocusActive || focusOrientationStatus !== 'idle';
|
||||
focusEntryRequested = false;
|
||||
focusSession += 1;
|
||||
mobileFocusActive = false;
|
||||
focusPlaybackOpen = false;
|
||||
focusOrientationStatus = 'idle';
|
||||
if (shouldUnlock) unlockOrientation(endingSession);
|
||||
}
|
||||
const shell = document.querySelector('.shell');
|
||||
if (shell) {
|
||||
shell.dataset.mobileFocus = mobileFocusActive ? 'true' : 'false';
|
||||
shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed';
|
||||
}
|
||||
board.setMobileFocus?.(mobileFocusActive);
|
||||
const button = document.querySelector('#toggle-fullscreen');
|
||||
updateFullscreenButton(button, mobileFocusActive || Boolean(document.fullscreenElement));
|
||||
document.querySelector('#toggle-focus-playback')?.setAttribute('aria-expanded', String(focusPlaybackOpen));
|
||||
updateOrientationGate();
|
||||
}
|
||||
document.addEventListener('click', async event => {
|
||||
if (event.target.closest('#orientation-gate-exit')) {
|
||||
++fullscreenFocusRequest;
|
||||
if (document.fullscreenElement) { try { await document.exitFullscreen?.(); } catch { /* best effort */ } }
|
||||
else setMobileFocus(false);
|
||||
return;
|
||||
}
|
||||
if (!event.target.closest('#toggle-fullscreen')) return;
|
||||
const status = document.querySelector('#fullscreen-status');
|
||||
if (document.fullscreenElement) { ++fullscreenFocusRequest; try { await document.exitFullscreen?.(); } catch { /* best effort */ } return; }
|
||||
if (mobileFocusActive) { ++fullscreenFocusRequest; setMobileFocus(false); if (status) status.textContent = ''; return; }
|
||||
const wantsFocus = focusViewportEligible();
|
||||
const request = ++fullscreenFocusRequest;
|
||||
focusEntryRequested = wantsFocus;
|
||||
try {
|
||||
if (document.documentElement.requestFullscreen) {
|
||||
await document.documentElement.requestFullscreen();
|
||||
if (request !== fullscreenFocusRequest) return;
|
||||
if (wantsFocus && !mobileFocusActive) setMobileFocus(true);
|
||||
if (wantsFocus && focusOrientationStatus === 'idle') requestLandscapeLock(focusSession);
|
||||
focusEntryRequested = false;
|
||||
} else if (wantsFocus) {
|
||||
setMobileFocus(true, { orientationStatus: 'unsupported' });
|
||||
if (status) status.textContent = '전체화면을 지원하지 않아 가로 회전 후 편집할 수 있습니다';
|
||||
} else if (status) status.textContent = '전체화면을 지원하지 않아 현재 화면에 맞춰 표시합니다';
|
||||
} catch {
|
||||
if (request !== fullscreenFocusRequest) return;
|
||||
focusEntryRequested = false;
|
||||
if (wantsFocus) setMobileFocus(true, { orientationStatus: 'rejected' });
|
||||
if (status) status.textContent = wantsFocus ? '브라우저 전체화면을 사용할 수 없어 가로 회전 후 편집할 수 있습니다' : '전체화면을 사용할 수 없습니다';
|
||||
}
|
||||
});
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
const status = document.querySelector('#fullscreen-status');
|
||||
const button = document.querySelector('#toggle-fullscreen');
|
||||
if (!document.fullscreenElement) { focusEntryRequested = false; setMobileFocus(false); }
|
||||
else if (focusEntryRequested && !mobileFocusActive && focusViewportEligible()) { setMobileFocus(true); requestLandscapeLock(focusSession); focusEntryRequested = false; }
|
||||
updateFullscreenButton(button, Boolean(document.fullscreenElement) || mobileFocusActive);
|
||||
if (status && !document.fullscreenElement && !mobileFocusActive) status.textContent = '';
|
||||
board.resize();
|
||||
syncAppViewportHeight();
|
||||
});
|
||||
window.addEventListener('resize', () => { syncAppViewportHeight(); cancelActiveDrag(); updateOrientationGate(); board.resize(); });
|
||||
window.addEventListener('orientationchange', () => { syncAppViewportHeight(); cancelActiveDrag(); updateOrientationGate(); board.resize(); });
|
||||
window.visualViewport?.addEventListener('resize', syncAppViewportHeight, { passive: true });
|
||||
window.visualViewport?.addEventListener('scroll', syncAppViewportHeight, { passive: true });
|
||||
|
||||
renderUi();
|
||||
syncAppViewportHeight();
|
||||
initializeAccount();
|
||||
window.addEventListener('popstate', () => { const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; if (currentUser?.isOperator && window.location.pathname === '/admin') openAdmin(generation, false); else if (currentUser && window.location.pathname !== '/admin') openTeamHub('', generation); else if (window.location.pathname === '/admin') { showGate('auth', '로그인 후 운영자 페이지를 이용할 수 있습니다.'); history.replaceState({ view: 'auth' }, '', '/'); } });
|
||||
function frame(now) { const delta = Math.min(0.25, (now - lastFrame) / 1000); lastFrame = now; if (videoExportActive) { requestAnimationFrame(frame); return; } const sample = selectRenderSample(state.play, { playbackSession, resetPreview: resetPreview || state.mode === 'start', selectedSequence: state.selectedSequence, selectedPlayerId: state.selectedPlayerId, selectedAction: state.selectedAction }, controller, delta); state.playing = sample.playing; if (playbackSession && !sample.playing && sample.elapsed >= sample.totalDuration && document.querySelector('#status').textContent === '재생 중') document.querySelector('#status').textContent = '재생 완료'; document.querySelector('#clock').textContent = `${sample.elapsed.toFixed(1)}s`; document.querySelector('#scrubber').value = playbackSession ? sample.elapsed : 0; document.querySelector('#time-display').textContent = (playbackSession ? sample.elapsed : 0).toFixed(1) + ' / ' + totalDuration(state.play).toFixed(1) + '초'; board.render(state.play, state, sample, delta); requestAnimationFrame(frame); }
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
Reference in New Issue
Block a user