From 5f3ac952ed6dde257e61caf10a6f889d54ca46ce Mon Sep 17 00:00:00 2001 From: Horoli Date: Wed, 9 Sep 2026 16:51:52 +0900 Subject: [PATCH] Refine team detail navigation UX --- src/main.js | 139 ++++++++++++++++++++++++++++++++++++-------------- src/style.css | 38 ++++++++++---- src/ui.js | 21 +++++--- 3 files changed, 142 insertions(+), 56 deletions(-) diff --git a/src/main.js b/src/main.js index e56b52e..97b0016 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,8 @@ 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(); @@ -16,6 +18,7 @@ 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; @@ -24,6 +27,10 @@ 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(); @@ -51,6 +58,7 @@ 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; @@ -148,25 +156,70 @@ function showGate(view = 'auth', message = '') { 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(); - 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; + 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.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); } } + 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(); @@ -191,15 +244,19 @@ async function searchAndRenderTeams(query) { 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 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('#team-message').textContent = ''; } +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; 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}`; } + 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(); @@ -214,21 +271,21 @@ async function openTeamLibrary(team, message = '') { 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'); + 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; - await openTeamLibrary(team); + 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) { +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('#play-library-status'); status.textContent = '전술을 불러오는 중…'; + 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}`; } } @@ -236,20 +293,15 @@ 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 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() { + sessionActivity.start(); const generation = accountGeneration; - 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); } + 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); } } -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; 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; }); + 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; }); } @@ -394,7 +446,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(); if (window.location.pathname === '/admin') { if (currentUser.isOperator) await openAdmin(generation, false); else { history.replaceState({ view: 'teams' }, '', '/'); await openTeamHub('운영자만 이용할 수 있는 페이지입니다.', ++accountGeneration); } } else await openTeamHub('', generation); } + try { const result = await loginAccount(data.get('email'), data.get('password')); if (generation !== accountGeneration) return; if (currentUser?.id !== result.user.id) resetTeamHubState(); currentUser = result.user; sessionActivity.stop(); sessionActivity.start(); 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') { @@ -428,23 +480,27 @@ app.addEventListener('click', (event) => { } 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('#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('#team-detail-back')) { detailTacticsGeneration += 1; ownerGeneration += 1; activeOwnerTeamId = null; currentTeam = null; playRepository = localPlayRepository; teamActionView = 'library'; setOwnerView(false); setTeamHubView('joined', { userInitiated: true }); return; } + if (event.target.closest('#show-joined-teams')) { setTeamHubView('joined', { userInitiated: true }); return; } + if (event.target.closest('#show-team-search')) { setTeamHubView('search', { userInitiated: true }); return; } + if (event.target.closest('#show-team-create')) { setTeamHubView('create', { userInitiated: true }); return; } + if (event.target.closest('#show-team-library')) { setTeamActionView('library', { userInitiated: true }); return; } + if (event.target.closest('#show-team-management')) { if (selectedTeam?.role === 'owner') openOwnerPanel(selectedTeam); return; } + if (event.target.closest('#show-team-approval')) { if (selectedTeam?.role === 'owner') openOwnerPanel(selectedTeam, 'approval'); return; } + if (event.target.closest('#refresh-teams') || event.target.closest('#retry-teams')) { setOwnerView(false); openTeamHub('', ++accountGeneration); 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(selectedTeam, teamActionView); }).catch((error) => { if (generation === ownerGeneration) { button.disabled = false; document.querySelector('#owner-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('#owner-message').textContent = '역할을 저장했습니다.'; } }).catch((error) => { if (generation === ownerGeneration) { select.value = previous; document.querySelector('#owner-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; } + const teamCard = event.target.closest('button.team-select[data-team-id]'); if (teamCard) { const team = { id: teamCard.dataset.teamId, name: teamCard.querySelector('.team-name')?.textContent || teamCard.querySelector('strong')?.textContent || '', role: teamCard.dataset.role || 'viewer' }; selectTeam(team); 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('#open-team-hub')) { operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); if (currentTeam) { if (!document.querySelector('.shell')?.hidden) saveDraft(); const team = currentTeam; accountGeneration += 1; loadGeneration += 1; document.querySelector('.shell').hidden = true; showGate('teams'); selectTeam(team); } 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('[data-open-detail-play]')) { const playId = event.target.closest('[data-open-detail-play]').dataset.openDetailPlay; const team = selectedTeam; if (team) { currentTeam = team; playRepository = createServerPlayRepository(team.id); openLibraryPlay(playId, '#team-detail-tactics-status'); } 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('#library-back-teams')) { operationCoordinator.beginIntent(); loadGeneration += 1; const team = selectedTeam || currentTeam; const generation = ++accountGeneration; currentTeam = null; activeOwnerTeamId = null; teamActionView = 'library'; playRepository = localPlayRepository; document.querySelector('.shell').hidden = true; if (team) { selectedTeam = team; teamHubView = 'detail'; teamHubViewUserSelected = true; showGate('teams'); renderTeamHubView(); } else { selectedTeam = null; 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; } @@ -484,10 +540,17 @@ app.addEventListener('click', (event) => { 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('change', (event) => { if (event.target.id === 'saved-plays') document.querySelector('#load-play').disabled = !event.target.value; }); +app.addEventListener('keydown', event => { + const current = event.target.closest('#team-navigation button, #team-action-navigation button'); + if (!current || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const navigation = current.closest('[role="tablist"]'); const buttons = [...navigation.querySelectorAll('button:not([hidden]):not(:disabled)')]; const index = buttons.indexOf(current); const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length; const next = buttons[nextIndex]; + next.focus(); if (navigation.id === 'team-navigation') setTeamHubView(next.id === 'show-team-search' ? 'search' : next.id === 'show-team-create' ? 'create' : 'joined', { userInitiated: true }); else if (next.id === 'show-team-management') { setTeamActionView('management', { userInitiated: true }); openOwnerPanel(selectedTeam); } else if (next.id === 'show-team-approval') { setTeamActionView('approval', { userInitiated: true }); openOwnerPanel(selectedTeam, 'approval'); } else { setTeamActionView('library', { userInitiated: true }); } +}); 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.dataset.memberRole) { event.target.closest('.pending-user')?.querySelector('[data-save-member-role]')?.focus(); document.querySelector('#owner-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); diff --git a/src/style.css b/src/style.css index 1334b50..c7d2e76 100644 --- a/src/style.css +++ b/src/style.css @@ -76,22 +76,22 @@ h1 { display:flex; align-items:center; gap:10px; color:#f4f2e8; font-size:17px; .mode-group #remove-shoot-action span { white-space:normal; line-height:1.1; } .account-gate { position:fixed; inset:0; z-index:50; display:grid; place-items:center; padding:24px; background:linear-gradient(145deg,#edf2eb,#f8f9f6 55%,#f5e8df); overflow:auto; } .account-card { width:min(470px,100%); padding:32px; border:1px solid #dfe7dc; border-radius:20px; background:#fff; box-shadow:0 24px 70px #23342e18; } -.account-brand { display:flex; align-items:center; gap:10px; margin-bottom:34px; } .account-brand .brand-mark { width:40px; height:40px; } .account-brand strong { font-size:28px; letter-spacing:-.06em; line-height:1; } .account-brand strong span { font-weight:400; } .account-brand small { display:block; margin-top:5px; color:var(--muted); font-size:7px; letter-spacing:.14em; } +.account-header { display:flex; align-items:center; gap:12px; min-height:40px; margin-bottom:34px; } .account-brand { display:flex; align-items:center; gap:10px; margin-right:auto; } .account-brand .brand-mark { width:40px; height:40px; } .account-brand strong { font-size:28px; letter-spacing:-.06em; line-height:1; } .account-brand strong span { font-weight:400; } .account-brand small { display:block; margin-top:5px; color:var(--muted); font-size:7px; letter-spacing:.14em; } .account-header.is-team-detail .account-brand { margin-right:0; margin-left:auto; } .detail-back { display:inline-flex; align-items:center; justify-content:center; flex:0 0 38px; width:38px; min-height:38px; padding:8px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--muted); } .detail-back:hover:not(:disabled) { background:#f0f3ec; border-color:#c5d0c4; } .account-heading .section-kicker { margin-bottom:10px; } .account-heading h1 { margin:0; color:var(--ink); font-size:27px; font-weight:700; } .account-heading p { margin:11px 0 24px; color:var(--muted); font-size:12px; line-height:1.7; } .account-form { display:grid; gap:14px; } .account-form label,.team-form label { display:grid; gap:7px; color:#627268; font-size:11px; font-weight:600; } .account-form button { width:100%; margin-top:4px; } .account-link { width:100%; margin-top:17px; border-color:transparent; background:transparent; color:#6d8173; font-size:11px; } .account-link:hover:not(:disabled) { background:#f1f4ed; } .account-message { min-height:18px; margin:16px 0 0; color:#a26a55; font-size:11px; line-height:1.6; } -.team-list { display:grid; gap:9px; margin-bottom:22px; } .team-card { width:100%; min-height:60px; padding:12px 15px; justify-content:space-between; text-align:left; background:#f7f9f4; border-color:#e0e7dc; } .team-card strong { font-size:13px; } .team-card small { color:var(--muted); font-size:10px; } .team-card:hover:not(:disabled) { border-color:#b6c8ae; background:#edf4e9; } -.team-form { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:9px; padding-top:18px; border-top:1px solid var(--line); } .team-form button { min-height:39px; } .local-import { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:9px; margin-top:13px; } .local-import label { display:grid; gap:7px; color:#627268; font-size:11px; font-weight:600; } .account-secondary { width:100%; margin-top:0; background:#edf2e9; border-color:transparent; color:#496448; font-size:11px; } .account-empty { margin:4px 0; color:var(--muted); font-size:11px; line-height:1.6; } + .team-table { margin-bottom:22px; border:1px solid #e0e7dc; border-radius:9px; overflow:hidden; background:#fbfcf9; } .team-table-header,.team-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(78px,28%); gap:12px; align-items:center; } .team-table-header { padding:9px 13px; background:#f2f5ef; color:#627268; font-size:10px; font-weight:700; } .team-list { display:grid; } .team-card,.team-select { width:100%; min-height:48px; padding:11px 13px; border:0; border-top:1px solid #e7ece4; border-radius:0; text-align:left; background:#fbfcf9; } .team-row { color:var(--ink); } .team-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; font-weight:650; } .team-role { color:var(--muted); font-size:10px; } .team-card:hover:not(:disabled),.team-select:hover:not(:disabled) { background:#edf4e9; } .search-team-row { display:flex; align-items:center; justify-content:space-between; gap:12px; } .search-team-row button { flex:0 0 auto; min-height:32px; } +.team-form { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:9px; padding-top:18px; border-top:1px solid var(--line); } .team-form button { min-height:39px; } .account-secondary { width:100%; margin-top:0; background:#edf2e9; border-color:transparent; color:#496448; font-size:11px; } .account-empty { margin:4px 0; color:var(--muted); font-size:11px; line-height:1.6; } .operator-panel { margin-top:21px; padding-top:17px; border-top:1px solid var(--line); } .operator-heading { display:flex; justify-content:space-between; align-items:center; margin-bottom:11px; } .operator-heading strong { font-size:11px; } .pending-user { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 0; border-bottom:1px solid #eef1eb; color:var(--muted); font-size:10px; } .pending-user button { min-height:29px; padding:5px 9px; font-size:10px; } #open-team-hub { min-height:32px; padding:6px 10px; color:#587160; border-color:#dbe4d8; background:#f5f8f1; font-size:10px; } #team-context { color:#65766b; } -@media(max-width:520px) { .account-gate { align-items:start; padding:14px; } .account-card { margin-top:5vh; padding:24px 20px; border-radius:16px; } .account-brand { margin-bottom:26px; } .team-form,.local-import { grid-template-columns:1fr; } .team-form button,.local-import button { width:100%; } #open-team-hub { width:64px; max-width:64px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-inline:4px; } .top-actions button { white-space:nowrap; } #video-share-open span,#save-play span { display:none; } #video-share-open,#save-play { width:34px; min-width:34px; padding:6px; } } +@media(max-width:520px) { .account-gate { align-items:start; padding:14px; } .account-card { margin-top:5vh; padding:24px 20px; border-radius:16px; } .account-header { margin-bottom:26px; } .team-form { grid-template-columns:1fr; } .team-form button { width:100%; } #open-team-hub { width:64px; max-width:64px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-inline:4px; } .top-actions button { white-space:nowrap; } #video-share-open span,#save-play span { display:none; } #video-share-open,#save-play { width:34px; min-width:34px; padding:6px; } } /* Team membership and library additions. */ .owner-team-name { margin:4px 0 16px; color:var(--ink); font-size:15px; font-weight:650; } .owner-subheading { margin:18px 0 5px; font-size:11px; color:var(--ink); } -#owner-panel .pending-user { align-items:center; } -#owner-panel .pending-user > span { min-width:0; overflow-wrap:anywhere; flex:1 1 180px; } -#owner-panel .pending-user select { min-width:86px; } -#owner-panel .pending-user button { flex:0 0 auto; white-space:nowrap; } +#owner-members-panel .pending-user,#owner-approval-panel .pending-user { align-items:center; } +#owner-members-panel .pending-user > span,#owner-approval-panel .pending-user > span { min-width:0; overflow-wrap:anywhere; flex:1 1 180px; } +#owner-members-panel .pending-user select,#owner-approval-panel .pending-user select { min-width:86px; } +#owner-members-panel .pending-user button,#owner-approval-panel .pending-user button { flex:0 0 auto; white-space:nowrap; } .editor-role-banner { position:absolute; z-index:3; top:15px; left:50%; transform:translateX(-50%); max-width:calc(100% - 32px); padding:7px 12px; border:1px solid #e8cdbb; border-radius:8px; background:#fff7f0; color:#8b5f49; font-size:10px; text-align:center; box-shadow:0 3px 10px #00000012; } .join-request-row { margin:7px 0 0; color:var(--muted); font-size:10px; } .play-library-list { display:grid; gap:8px; margin:12px 0 18px; max-height:300px; overflow:auto; } @@ -102,10 +102,28 @@ h1 { display:flex; align-items:center; gap:10px; color:#f4f2e8; font-size:17px; .play-library-row button { flex:0 0 auto; min-width:60px; padding:7px 10px; font-size:10px; } .play-library-new { padding-top:16px; border-top:1px solid var(--line); } #play-library-status:empty,#play-library-message:empty { display:none; } +.account-gate.has-operator-fab .account-card { margin-bottom:74px; } +.team-segmented { display:grid; grid-template-columns:repeat(3,1fr); gap:4px; margin:0 0 18px; padding:4px; border:1px solid #dfe7dc; border-radius:10px; background:#f2f5ef; } +.team-segmented button { min-height:38px; border:0; border-radius:7px; background:transparent; color:var(--muted); font-size:11px; } +.team-segmented button[aria-selected="true"] { background:#fff; color:var(--ink); box-shadow:0 2px 8px #23342e12; } +.team-action-segmented { grid-template-columns:repeat(3,1fr); margin:13px 0 0; } +.team-hub-pane[hidden] { display:none; } +.team-create-panel { margin-top:21px; } +.selected-team-panel { margin-top:20px; padding-top:17px; border-top:1px solid var(--line); } +.selected-team-heading { display:flex; align-items:baseline; justify-content:space-between; gap:12px; } +.selected-team-heading strong { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; } +.selected-team-heading span { color:var(--muted); font-size:10px; } +.operator-fab { position:fixed; right:calc(18px + env(safe-area-inset-right)); bottom:calc(18px + env(safe-area-inset-bottom)); z-index:60; display:inline-flex; align-items:center; justify-content:center; gap:6px; min-width:56px; min-height:56px; padding:12px 15px; border:1px solid #b9cbb0; border-radius:999px; background:#345441; color:#fff; box-shadow:0 8px 22px #23342e2e; font-size:11px; font-weight:700; text-decoration:none; touch-action:manipulation; } +.operator-fab:hover { background:#263f31; color:#fff; } #fullscreen-status { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; } @media(max-width:520px) { - #owner-panel .pending-user { align-items:stretch; } - #owner-panel .pending-user select,#owner-panel .pending-user button { flex:1 1 86px; } + .account-gate.has-operator-fab .account-card { margin-bottom:72px; } + .account-card { padding-bottom:calc(24px + env(safe-area-inset-bottom)); } + .operator-fab { right:calc(12px + env(safe-area-inset-right)); bottom:calc(12px + env(safe-area-inset-bottom)); min-width:58px; min-height:58px; padding-inline:13px; } + .team-table-header,.team-row { grid-template-columns:minmax(0,1fr) minmax(70px,30%); } + .selected-team-heading { align-items:flex-start; flex-direction:column; gap:3px; } + #owner-members-panel .pending-user,#owner-approval-panel .pending-user { align-items:stretch; } + #owner-members-panel .pending-user select,#owner-members-panel .pending-user button,#owner-approval-panel .pending-user select,#owner-approval-panel .pending-user button { flex:1 1 86px; } .editor-role-banner { top:10px; font-size:9px; } .pending-user { flex-wrap:wrap; align-items:center; } .pending-user > :first-child { min-width:0; flex:1 1 100%; overflow-wrap:anywhere; } diff --git a/src/ui.js b/src/ui.js index 43ac222..7847f17 100644 --- a/src/ui.js +++ b/src/ui.js @@ -15,13 +15,14 @@ const paths = { plus: '', download: '', fullscreen: '', + back: '', }; export const icon = name => ``; export function accountLayout() { return ``; }