Compare commits

..
2 Commits
Author SHA1 Message Date
horoli 20e0902adc Extend authenticated session activity 2026-09-09 16:58:08 +09:00
horoli 5f3ac952ed Refine team detail navigation UX 2026-09-09 16:51:52 +09:00
10 changed files with 260 additions and 63 deletions
+7 -1
View File
@@ -67,10 +67,14 @@ node .\server\bootstrap-admin.js --email=your-email@example.com
1. 최초 방문자는 로그인 화면에서 사용 신청으로 이동해 이메일·비밀번호·표시 이름을 등록한다. 1. 최초 방문자는 로그인 화면에서 사용 신청으로 이동해 이메일·비밀번호·표시 이름을 등록한다.
2. 서비스 운영자는 별도 `/admin` 페이지에서 승인 대기 사용자를 확인하고 이용을 승인한다. 개발 주소는 `http://localhost:5173/admin`이며, 운영 환경에서는 서비스 주소 뒤에 `/admin`을 붙인다. 2. 서비스 운영자는 별도 `/admin` 페이지에서 승인 대기 사용자를 확인하고 이용을 승인한다. 개발 주소는 `http://localhost:5173/admin`이며, 운영 환경에서는 서비스 주소 뒤에 `/admin`을 붙인다.
3. 승인된 사용자가 다시 로그인하면 내 팀에서 새 팀을 만들거나 기존 팀 이름을 검색할 수 있다. 서비스 이용 승인만으로 기존 팀에 자동 가입되지 않는다. 3. 승인된 사용자가 로그인하면 소속 팀이 있는 경우 **소속 팀**, 없는 경우 **팀 검색** 화면으로 진입한다. **소속 팀 | 팀 검색** 토글로 두 화면을 전환하며, 새 팀 만들기도 이용할 수 있다. 서비스 이용 승인만으로 기존 팀에 자동 가입되지 않는다.
4. 기존 팀을 검색해 가입을 신청하면 팀 소유자의 승인을 기다린다. 4. 기존 팀을 검색해 가입을 신청하면 팀 소유자의 승인을 기다린다.
5. 팀 소유자는 팀원 관리에서 가입 신청을 승인하고 열람자 또는 편집자 권한을 부여한다. 이후에도 팀원별 권한을 변경할 수 있다. 5. 팀 소유자는 팀원 관리에서 가입 신청을 승인하고 열람자 또는 편집자 권한을 부여한다. 이후에도 팀원별 권한을 변경할 수 있다.
운영자는 팀 선택·검색·팀원 관리·전술 목록 화면 우측 하단의 **운영자 승인 페이지** FAB로 가입 승인 페이지에 접근한다. 로그인 전과 승인 페이지, 전술 편집 화면에서는 FAB를 표시하지 않는다. 기존 `/admin` 직접 접근과 운영자 권한 검사는 유지한다.
2026-09-09 UX 검증: 운영 DB와 분리된 메모리 서버에서 PC 1280×900 및 모바일 390×844 화면을 확인했다. 소속 팀 유무에 따른 로그인·세션 복원 분기, 토글 클릭·방향키 전환, 팀 검색·가입 요청, 팀원 관리 복귀, 운영자 FAB의 승인 페이지 진입·새로고침·복귀, 로그아웃 및 일반 계정의 FAB 숨김과 `/admin` 접근 제한을 확인했다. 최종 전체 테스트 119개가 통과했다.
열람자는 팀 전술을 조회할 수 있고 서버에 저장할 수 없다. 편집자와 소유자는 팀 전술을 서버에 저장할 수 있다. 소유자 권한 이전은 지원하지 않는다. 서비스 운영자 권한과 팀 소유자 권한은 별개이며, 운영자도 다른 팀에 자동으로 참여하지 않는다. 열람자는 팀 전술을 조회할 수 있고 서버에 저장할 수 없다. 편집자와 소유자는 팀 전술을 서버에 저장할 수 있다. 소유자 권한 이전은 지원하지 않는다. 서비스 운영자 권한과 팀 소유자 권한은 별개이며, 운영자도 다른 팀에 자동으로 참여하지 않는다.
가입 신청은 MongoDB `team_join_requests`에 저장한다. 서버 시작 시 필요한 컬렉션 인덱스를 준비하며, 기존 계정·팀·전술을 삭제하거나 다시 만들 필요는 없다. 코드 반영 후에는 서버를 재시작하고 운영 화면은 다시 빌드한다. 가입 신청은 MongoDB `team_join_requests`에 저장한다. 서버 시작 시 필요한 컬렉션 인덱스를 준비하며, 기존 계정·팀·전술을 삭제하거나 다시 만들 필요는 없다. 코드 반영 후에는 서버를 재시작하고 운영 화면은 다시 빌드한다.
@@ -98,6 +102,8 @@ npm run server
Fastify가 `dist` 화면과 API를 제공한다. 실제 도메인을 허용 출처로 설정하고 HTTPS 프록시 뒤에서 실행한다. 운영 세션 쿠키는 Secure를 사용한다. Fastify가 `dist` 화면과 API를 제공한다. 실제 도메인을 허용 출처로 설정하고 HTTPS 프록시 뒤에서 실행한다. 운영 세션 쿠키는 Secure를 사용한다.
로그인 세션은 1시간 동안 유지되며, 인증된 조회·저장 요청 및 전술판의 클릭·드래그·키보드 등 사용자 입력에 따라 서버 만료 시각과 쿠키 유효기간이 연장된다. 로컬 입력의 활동 알림은 요청 수를 제한해 전송하고, 입력 없이 화면만 열어 두면 주기적으로 연장하지 않는다. 만료된 세션은 활동 알림으로 복구되지 않으며 다시 로그인해야 한다. 이전 7일 세션에 활동 기록이 없으면 로그인 시각을 기준으로 1시간 만료를 적용한다.
휴대폰의 HTTP LAN 화면 접속만으로 OS 파일 공유 조건이 충족되지는 않는다. 카카오톡으로 MP4 파일을 직접 공유하는 실기기 검증에는 지원 브라우저와 신뢰할 수 있는 HTTPS가 필요하다. 휴대폰의 HTTP LAN 화면 접속만으로 OS 파일 공유 조건이 충족되지는 않는다. 카카오톡으로 MP4 파일을 직접 공유하는 실기기 검증에는 지원 브라우저와 신뢰할 수 있는 HTTPS가 필요하다.
## 검증 ## 검증
+20 -3
View File
@@ -7,7 +7,7 @@ import fastifyStatic from '@fastify/static';
import { isValidPlay } from '../src/playRepository.js'; import { isValidPlay } from '../src/playRepository.js';
import { createMongoStore, DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI } from './db.js'; import { createMongoStore, DEFAULT_MONGODB_DB, DEFAULT_MONGODB_URI } from './db.js';
import { isDevelopmentOrigin, loadConfig } from './config.js'; import { isDevelopmentOrigin, loadConfig } from './config.js';
import { hashPassword, hashToken, newSessionToken, sessionExpiry, SESSION_COOKIE, verifyPassword, validEmail } from './security.js'; import { hashPassword, hashToken, newSessionToken, sessionExpiry, SESSION_COOKIE, SESSION_SECONDS, verifyPassword, validEmail } from './security.js';
const here = path.dirname(fileURLToPath(import.meta.url)); const here = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(here, '..'); const projectRoot = path.resolve(here, '..');
@@ -23,6 +23,13 @@ function errorPayload(code, message) { return { error: { code, message } }; }
function fail(reply, status, code, message) { return reply.code(status).send(errorPayload(code, message)); } function fail(reply, status, code, message) { return reply.code(status).send(errorPayload(code, message)); }
function parsePositiveId(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value); } function parsePositiveId(value) { return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value); }
function isProtectedConfigPath(url) { return /(?:^|\/)\.config\.json(?:\.[^/]*)?$/.test(String(url).split('?')[0]); } function isProtectedConfigPath(url) { return /(?:^|\/)\.config\.json(?:\.[^/]*)?$/.test(String(url).split('?')[0]); }
function isRenewableApiRequest(request, allowedOrigins, mode) {
const url = String(request.url).split('?')[0];
if (!url.startsWith('/api/') || url === '/api/health') return false;
if (url === '/api/auth/login' || url === '/api/auth/register' || url === '/api/auth/logout' || url === '/api/auth/me') return false;
if (request.headers.origin && !requestOriginAllowed(request, allowedOrigins, mode)) return false;
return true;
}
function requestOriginAllowed(request, allowedOrigins, mode) { function requestOriginAllowed(request, allowedOrigins, mode) {
const origin = request.headers.origin; const origin = request.headers.origin;
if (!origin) return true; if (!origin) return true;
@@ -57,9 +64,17 @@ export async function buildServer(options = {}) {
if (!token) return; if (!token) return;
const session = await store.sessions.findOne({ tokenHash: hashToken(token), expiresAt: { $gt: nowDate() } }); const session = await store.sessions.findOne({ tokenHash: hashToken(token), expiresAt: { $gt: nowDate() } });
if (!session) return; if (!session) return;
const now = nowDate();
const lastActivityAt = session.lastActivityAt || session.createdAt;
if (!lastActivityAt || now.getTime() - new Date(lastActivityAt).getTime() >= SESSION_SECONDS * 1000) return;
const row = await store.users.findOne({ id: session.userId }); const row = await store.users.findOne({ id: session.userId });
if (!row || row.status === 'suspended') return; if (!row || row.status === 'suspended') return;
request.user = row; request.user = row;
if (row.status === 'approved' && isRenewableApiRequest(request, allowedOrigins, mode)) {
const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000);
await store.sessions.updateOne({ tokenHash: session.tokenHash, expiresAt: { $gt: now } }, { $set: { expiresAt, lastActivityAt: now } });
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: SESSION_SECONDS });
}
}); });
app.addHook('onClose', async () => { await store.close(); }); app.addHook('onClose', async () => { await store.close(); });
@@ -129,8 +144,8 @@ export async function buildServer(options = {}) {
if (row.status === 'suspended') return fail(reply, 403, 'account_suspended', '사용이 중지된 계정입니다'); if (row.status === 'suspended') return fail(reply, 403, 'account_suspended', '사용이 중지된 계정입니다');
const token = newSessionToken(); const createdAt = nowDate(); const token = newSessionToken(); const createdAt = nowDate();
await store.sessions.deleteMany({ expiresAt: { $lte: createdAt } }); await store.sessions.deleteMany({ expiresAt: { $lte: createdAt } });
await store.sessions.insertOne({ tokenHash: hashToken(token), userId: row.id, expiresAt: new Date(sessionExpiry()), createdAt }); await store.sessions.insertOne({ tokenHash: hashToken(token), userId: row.id, expiresAt: new Date(sessionExpiry(createdAt.getTime())), createdAt, lastActivityAt: createdAt });
reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: 7 * 24 * 60 * 60 }); reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure: secureCookie, maxAge: SESSION_SECONDS });
return { user: publicUser(row) }; return { user: publicUser(row) };
}); });
@@ -139,6 +154,8 @@ export async function buildServer(options = {}) {
reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true }; reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true };
}); });
app.post('/api/auth/activity', { preHandler: requireUser, schema: { body: bodySchema({}, []) } }, async () => ({ ok: true }));
app.get('/api/auth/me', { preHandler: requireUser }, async (request) => ({ user: publicUser(request.user) })); app.get('/api/auth/me', { preHandler: requireUser }, async (request) => ({ user: publicUser(request.user) }));
app.get('/api/teams', { preHandler: requireUser }, async (request) => { app.get('/api/teams', { preHandler: requireUser }, async (request) => {
+32 -1
View File
@@ -1,7 +1,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { createInitialPlay } from '../src/domain.js'; import { createInitialPlay } from '../src/domain.js';
import { buildServer } from './app.js'; import { buildServer } from './app.js';
import { hashPassword } from './security.js'; import { hashPassword } from './security.js';
@@ -56,6 +56,37 @@ describe('Fastify Mongo contract', () => {
const temporaryRoots = []; const temporaryRoots = [];
afterEach(async () => { await app?.close(); await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); afterEach(async () => { await app?.close(); await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); });
it('uses a one hour sliding session and never revives an expired session', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
const store = memoryStore(); await seedUser(store, { id: 'clock', email: 'clock@example.com', displayName: 'Clock' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const start = new Date('2026-01-01T00:00:00.000Z'); vi.setSystemTime(start);
const result = await login(app, 'clock@example.com', 'correct horse battery staple');
expect(result.response.headers['set-cookie']).toContain('Max-Age=3600');
const session = store.sessions.documents[0]; expect(session.expiresAt).toEqual(new Date(start.getTime() + 3_600_000));
vi.setSystemTime(new Date(start.getTime() + 50 * 60_000));
const renewed = await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: result.cookie } });
expect(renewed.statusCode).toBe(200); expect(session.expiresAt).toEqual(new Date(start.getTime() + 110 * 60_000));
vi.setSystemTime(new Date(start.getTime() + 70 * 60_000)); const activity = await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie }, payload: {} }); expect(activity.statusCode).toBe(200); expect(activity.headers['set-cookie']).toContain('Max-Age=3600');
vi.setSystemTime(new Date(start.getTime() + 130 * 60_000)); const expired = await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie }, payload: {} }); expect(expired.statusCode).toBe(401); expect(session.expiresAt).toEqual(new Date(start.getTime() + 130 * 60_000));
} finally { vi.useRealTimers(); }
});
it('does not renew on health, auth/me, or rejected origins', async () => {
const store = memoryStore(); await seedUser(store, { id: 'origin', email: 'origin@example.com', displayName: 'Origin' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const result = await login(app, 'origin@example.com', 'correct horse battery staple'); const session = store.sessions.documents[0]; const original = session.expiresAt;
expect((await app.inject({ method: 'GET', url: '/api/health', headers: { cookie: result.cookie } })).statusCode).toBe(200); expect(session.expiresAt).toEqual(original);
expect((await app.inject({ method: 'GET', url: '/api/auth/me', headers: { cookie: result.cookie } })).statusCode).toBe(200); expect(session.expiresAt).toEqual(original);
expect((await app.inject({ method: 'POST', url: '/api/auth/activity', headers: { cookie: result.cookie, origin: 'https://evil.example' }, payload: {} })).statusCode).toBe(403); expect(session.expiresAt).toEqual(original);
});
it('rejects a legacy session that has already been idle for an hour', async () => {
const store = memoryStore(); await seedUser(store, { id: 'legacy', email: 'legacy@example.com', displayName: 'Legacy' }); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const result = await login(app, 'legacy@example.com', 'correct horse battery staple'); const session = store.sessions.documents[0]; delete session.lastActivityAt; session.createdAt = new Date(Date.now() - 3_600_000); session.expiresAt = new Date(Date.now() + 86_400_000);
expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: result.cookie } })).statusCode).toBe(401);
});
it('rejects unauthenticated and pending access', async () => { it('rejects unauthenticated and pending access', async () => {
const store = memoryStore(); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const store = memoryStore(); app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
expect((await app.inject({ method: 'GET', url: '/api/teams' })).statusCode).toBe(401); expect((await app.inject({ method: 'GET', url: '/api/teams' })).statusCode).toBe(401);
+2 -2
View File
@@ -7,7 +7,7 @@ const R = 8;
const P = 1; const P = 1;
export const SESSION_COOKIE = 'court_session'; export const SESSION_COOKIE = 'court_session';
export const SESSION_DAYS = 7; export const SESSION_SECONDS = 60 * 60;
export function hashToken(token) { export function hashToken(token) {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
@@ -40,7 +40,7 @@ export function newSessionToken() {
} }
export function sessionExpiry(now = Date.now()) { export function sessionExpiry(now = Date.now()) {
return new Date(now + SESSION_DAYS * 24 * 60 * 60 * 1000).toISOString(); return new Date(now + SESSION_SECONDS * 1000).toISOString();
} }
export function validEmail(email) { export function validEmail(email) {
+1
View File
@@ -36,6 +36,7 @@ export async function getCurrentUser() { return apiRequest('/api/auth/me'); }
export async function loginAccount(email, password) { return apiRequest('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }); } export async function loginAccount(email, password) { return apiRequest('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }); }
export async function registerAccount(email, password, displayName) { return apiRequest('/api/auth/register', { method: 'POST', body: JSON.stringify({ email, password, displayName }) }); } export async function registerAccount(email, password, displayName) { return apiRequest('/api/auth/register', { method: 'POST', body: JSON.stringify({ email, password, displayName }) }); }
export async function logoutAccount() { return apiRequest('/api/auth/logout', { method: 'POST', body: '{}' }); } export async function logoutAccount() { return apiRequest('/api/auth/logout', { method: 'POST', body: '{}' }); }
export async function renewSessionActivity() { return apiRequest('/api/auth/activity', { method: 'POST', body: '{}' }); }
export async function listTeams() { return apiRequest('/api/teams'); } export async function listTeams() { return apiRequest('/api/teams'); }
export async function createTeam(name) { return apiRequest('/api/teams', { method: 'POST', body: JSON.stringify({ name }) }); } export async function createTeam(name) { return apiRequest('/api/teams', { method: 'POST', body: JSON.stringify({ name }) }); }
export async function searchTeams(q) { return apiRequest(`/api/teams/search?q=${encodeURIComponent(q)}`); } export async function searchTeams(q) { return apiRequest(`/api/teams/search?q=${encodeURIComponent(q)}`); }
+101 -38
View File
@@ -9,6 +9,8 @@ import { createPlayOperationCoordinator } from './playOperations.js';
import { addPlaySequence, commitActionEdit, createAppState, createAppStateFromPlay, deletePlaySequence, serializePlay, setSelectedPlayerStart } from './state.js'; import { addPlaySequence, commitActionEdit, createAppState, createAppStateFromPlay, deletePlaySequence, serializePlay, setSelectedPlayerStart } from './state.js';
import { createVideoExportJob, downloadVideoFile, VideoExportCancelledError, VideoExportStaleError, shareVideoFile, validateVideoFile } from './videoExport.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 { 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'); const app = document.querySelector('#app');
app.innerHTML = accountLayout() + editorLayout(); app.innerHTML = accountLayout() + editorLayout();
@@ -16,6 +18,7 @@ document.querySelector('.shell').hidden = true;
const board = createBoard(document.querySelector('#board')); const board = createBoard(document.querySelector('#board'));
let storage = null; try { storage = window.localStorage; } catch { storage = null; } let storage = null; try { storage = window.localStorage; } catch { storage = null; }
const sessionActivity = createSessionActivityTracker({ onActivity: renewSessionActivity, isAuthenticated: () => Boolean(currentUser) });
const localPlayRepository = createLocalPlayRepository(storage); const localPlayRepository = createLocalPlayRepository(storage);
let playRepository = localPlayRepository; let playRepository = localPlayRepository;
let currentUser = null; let currentUser = null;
@@ -24,6 +27,10 @@ let accountGeneration = 0;
let searchGeneration = 0; let searchGeneration = 0;
let ownerGeneration = 0; let ownerGeneration = 0;
let activeOwnerTeamId = null; let activeOwnerTeamId = null;
let teamHubView = 'joined';
let teamHubViewUserSelected = false;
let selectedTeam = null;
let teamActionView = 'library';
const operationCoordinator = createPlayOperationCoordinator(); const operationCoordinator = createPlayOperationCoordinator();
let state = createAppState(); let state = createAppState();
const editorHistory = createEditorHistory(); const editorHistory = createEditorHistory();
@@ -51,6 +58,7 @@ let lastFrame = performance.now();
let savedListGeneration = 0; let savedListGeneration = 0;
let passiveListGeneration = 0; let passiveListGeneration = 0;
let loadGeneration = 0; let loadGeneration = 0;
let detailTacticsGeneration = 0;
const VIDEO_WIDTH = 1280; const VIDEO_WIDTH = 1280;
const VIDEO_HEIGHT = 720; const VIDEO_HEIGHT = 720;
const VIDEO_FPS = 30; const VIDEO_FPS = 30;
@@ -148,25 +156,70 @@ function showGate(view = 'auth', message = '') {
const gate = document.querySelector('#account-gate'); if (!gate) return; const gate = document.querySelector('#account-gate'); if (!gate) return;
gate.hidden = false; document.querySelector('.shell').hidden = view !== 'editor'; 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`; 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 === 'auth') document.querySelector('#auth-message').textContent = message;
if (view === 'register') document.querySelector('#register-message').textContent = message; if (view === 'register') document.querySelector('#register-message').textContent = message;
if (view === 'pending') document.querySelector('#pending-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 === '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 === '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 === 'admin') { document.querySelector('#admin-panel').hidden = false; document.querySelector('.shell').hidden = true; }
if (view === 'teams') renderTeamHubView();
if (view === 'editor') gate.hidden = true; 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 draftKey() { return currentUser && currentTeam ? `basket-utils:draft:v2:${encodeURIComponent(currentUser.id)}:${encodeURIComponent(currentTeam.id)}` : ''; }
function restoreTeamDraft() { function restoreTeamDraft() {
const key = draftKey(); if (!key || !storage) return '저장 전'; const key = draftKey(); if (!key || !storage) return '저장 전';
try { const raw = storage.getItem(key); if (!raw) return '저장 전'; state = createAppStateFromPlay(JSON.parse(raw)); return '임시 저장 복원됨'; } catch { return '임시 저장을 복원하지 못했습니다'; } try { const raw = storage.getItem(key); if (!raw) return '저장 전'; state = createAppStateFromPlay(JSON.parse(raw)); return '임시 저장 복원됨'; } catch { return '임시 저장을 복원하지 못했습니다'; }
} }
function renderTeamList(teams) { function renderTeamList(teams) {
const list = document.querySelector('#team-list'); list.replaceChildren(); const list = document.querySelector('#team-list'); list.replaceChildren(); document.querySelector('#retry-teams').hidden = true;
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; } 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 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); }
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); } } }
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) { async function renderPendingUsers(generation = accountGeneration) {
const target = document.querySelector('#pending-users'); target.replaceChildren(); 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; } 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 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; } } function setOwnerView(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; } } 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 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) { 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(); 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;
showGate('teams'); document.querySelector('#open-admin').hidden = !currentUser?.isOperator; document.querySelector('#team-welcome').textContent = `${currentUser?.displayName || currentUser?.email || ''}님, 사용할 팀을 선택하세요.`; document.querySelector('#team-message').textContent = message; if (teamHubView === 'detail') { teamHubView = 'joined'; teamHubViewUserSelected = false; }
try { const { teams } = await listTeams(); if (generation !== accountGeneration || !currentUser) return; renderTeamList(teams); const { requests } = await listJoinRequests(); if (generation === accountGeneration) renderJoinRequests(requests); } showGate('teams'); document.querySelector('#team-welcome').textContent = `${currentUser?.displayName || currentUser?.email || ''}님, 사용할 팀을 선택하세요.`; document.querySelector('#team-message').textContent = message;
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = `팀 목록을 불러오지 못했습니다: ${error.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) { function renderPlayLibrary(records, draftMessage = '', viewer = false) {
const list = document.querySelector('#play-library-list'); list.replaceChildren(); 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('#continue-draft').hidden = true;
document.querySelector('#new-play-form')?.reset(); 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; 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); } 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) { async function selectTeam(team) {
if (!team?.id || !currentUser) return; if (!team?.id || !currentUser) return;
await openTeamLibrary(team); selectedTeam = team; teamActionView = 'library'; teamHubView = 'detail'; teamHubViewUserSelected = true; renderTeamHubView(); loadTeamDetailTactics(team);
} }
function enterEditor(nextState, status = '이동 행동 편집 단계') { function enterEditor(nextState, status = '이동 행동 편집 단계') {
nextState = { ...nextState, mode: nextState.mode || 'move', selectedAction: -1, view: 'tactical', boardPreview: undefined }; nextState = { ...nextState, mode: nextState.mode || 'move', selectedAction: -1, view: 'tactical', boardPreview: undefined };
panel = 'court'; gazePick = null; 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(() => {}); 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; 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), '저장된 전술을 불러왔습니다'); } 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}`; } 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; } 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(); 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() { async function initializeAccount() {
sessionActivity.start();
const generation = accountGeneration; 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); } 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; if (!(error instanceof ApiError) || error.status !== 401) document.querySelector('#auth-message').textContent = '서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.'; showGate('auth', document.querySelector('#auth-message').textContent); } 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() { 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'); } try { await logoutAccount(); if (generation === accountGeneration) showGate('auth'); }
catch (error) { if (generation === accountGeneration) document.querySelector('#auth-message').textContent = `로그아웃 요청을 완료하지 못했습니다: ${error.message}`; } 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; }); } 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; event.preventDefault(); const form = event.target;
if (form.id === 'login-form') { if (form.id === 'login-form') {
const generation = ++accountGeneration; const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true; 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); } catch (error) { if (generation === accountGeneration) showGate(error.code === 'approval_required' ? 'pending' : 'auth', error.message); }
finally { button.disabled = false; } finally { button.disabled = false; }
} else if (form.id === 'register-form') { } 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-register')) { showGate('register'); return; }
if (event.target.closest('#show-login') || event.target.closest('#pending-login')) { showGate('auth'); 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('#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('#refresh-teams')) { setOwnerView(false); openTeamHub('', ++accountGeneration); return; } if (event.target.closest('#show-joined-teams')) { setTeamHubView('joined', { userInitiated: true }); 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('#show-team-search')) { setTeamHubView('search', { userInitiated: true }); 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('#show-team-create')) { setTeamHubView('create', { userInitiated: true }); 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('#show-team-library')) { setTeamActionView('library', { userInitiated: true }); 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('#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('#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('[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; } 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; } 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('#import-local-team')) { importLocalPlays(); 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('[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-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('#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('#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('#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('#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('#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; } 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('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) => { 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 === '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 === '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); if (event.target.id === 'playback-rate') controller.setRate(event.target.value);
+31
View File
@@ -0,0 +1,31 @@
const activityEvents = ['click', 'pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'touchmove'];
export function createSessionActivityTracker({ onActivity, isAuthenticated, throttleMs = 30_000, clock = () => Date.now(), target = globalThis }) {
const timerHost = target.setTimeout ? target : globalThis;
let active = false;
let inFlight = false;
let pending = false;
let timer = null;
let lastSent = -Infinity;
let generation = 0;
const listeners = new Map();
const send = async () => {
if (!active || inFlight || !isAuthenticated()) return;
inFlight = true; pending = false; lastSent = clock();
const sentGeneration = generation;
try { await onActivity(); }
catch (error) { if (sentGeneration === generation && (error?.status === 401 || error?.code === 'unauthenticated')) stop(); }
finally { inFlight = false; if (active && pending) schedule(); }
};
const schedule = () => {
if (!active || inFlight) return;
const wait = Math.max(0, throttleMs - (clock() - lastSent));
if (!wait) { void send(); return; }
if (timer === null) timer = timerHost.setTimeout(() => { timer = null; void send(); }, wait);
};
const activity = () => { if (!active || !isAuthenticated()) return; pending = true; schedule(); };
const start = () => { if (active) return; generation += 1; lastSent = -Infinity; active = true; activityEvents.forEach((event) => { listeners.set(event, activity); target.addEventListener(event, activity, { capture: true, passive: true }); }); };
const stop = () => { generation += 1; active = false; pending = false; if (timer !== null) { timerHost.clearTimeout(timer); timer = null; } listeners.forEach((listener, event) => target.removeEventListener(event, listener, { capture: true })); listeners.clear(); };
return { start, stop, activity, isActive: () => active };
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { createSessionActivityTracker } from './sessionActivity.js';
function harness() {
const target = new EventTarget(); const calls = []; let authenticated = true;
const tracker = createSessionActivityTracker({ target, throttleMs: 1000, isAuthenticated: () => authenticated, onActivity: () => { calls.push('request'); return Promise.resolve(); } });
return { target, calls, tracker, setAuthenticated: (value) => { authenticated = value; } };
}
describe('session activity tracker', () => {
it('sends one immediate request and one trailing request for a burst', async () => {
vi.useFakeTimers(); const { target, calls, tracker } = harness(); tracker.start(); target.dispatchEvent(new Event('pointerdown')); await vi.runOnlyPendingTimersAsync(); expect(calls).toHaveLength(1);
target.dispatchEvent(new Event('pointermove')); target.dispatchEvent(new Event('keydown')); await vi.advanceTimersByTimeAsync(999); expect(calls).toHaveLength(1); await vi.advanceTimersByTimeAsync(1); expect(calls).toHaveLength(2); await vi.advanceTimersByTimeAsync(60_000); expect(calls).toHaveLength(2); tracker.stop(); vi.useRealTimers();
});
it('stays idle, avoids overlap, then stops on the current generation 401', async () => {
vi.useFakeTimers(); const target = new EventTarget(); let resolveFirst; let rejectSecond; let authenticated = true; const calls = []; const onActivity = vi.fn(() => new Promise((resolve, reject) => { calls.push({ resolve, reject }); if (calls.length === 1) resolveFirst = resolve; else rejectSecond = reject; })); const tracker = createSessionActivityTracker({ target, throttleMs: 1000, isAuthenticated: () => authenticated, onActivity });
tracker.start(); await vi.advanceTimersByTimeAsync(3_600_000); expect(onActivity).not.toHaveBeenCalled();
target.dispatchEvent(new Event('click')); target.dispatchEvent(new Event('pointermove')); await vi.runOnlyPendingTimersAsync(); expect(onActivity).toHaveBeenCalledTimes(1); target.dispatchEvent(new Event('keydown')); await vi.advanceTimersByTimeAsync(1000); expect(onActivity).toHaveBeenCalledTimes(1); resolveFirst(); await Promise.resolve(); await vi.runOnlyPendingTimersAsync(); expect(onActivity).toHaveBeenCalledTimes(2); rejectSecond({ status: 401 }); await Promise.resolve(); await vi.runOnlyPendingTimersAsync(); expect(tracker.isActive()).toBe(false); target.dispatchEvent(new Event('click')); await vi.advanceTimersByTimeAsync(10_000); expect(onActivity).toHaveBeenCalledTimes(2); vi.useRealTimers();
});
it('removes listeners and makes no calls while unauthenticated', async () => {
vi.useFakeTimers(); const { target, calls, tracker, setAuthenticated } = harness(); setAuthenticated(false); tracker.start(); target.dispatchEvent(new Event('wheel')); await vi.runOnlyPendingTimersAsync(); expect(calls).toHaveLength(0); tracker.stop(); setAuthenticated(true); target.dispatchEvent(new Event('wheel')); await vi.runOnlyPendingTimersAsync(); expect(calls).toHaveLength(0); vi.useRealTimers();
});
});
+28 -10
View File
@@ -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; } .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-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-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-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-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; } .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-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; } .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-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; } .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; } #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. */ /* Team membership and library additions. */
.owner-team-name { margin:4px 0 16px; color:var(--ink); font-size:15px; font-weight:650; } .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-subheading { margin:18px 0 5px; font-size:11px; color:var(--ink); }
#owner-panel .pending-user { align-items:center; } #owner-members-panel .pending-user,#owner-approval-panel .pending-user { align-items:center; }
#owner-panel .pending-user > span { min-width:0; overflow-wrap:anywhere; flex:1 1 180px; } #owner-members-panel .pending-user > span,#owner-approval-panel .pending-user > span { min-width:0; overflow-wrap:anywhere; flex:1 1 180px; }
#owner-panel .pending-user select { min-width:86px; } #owner-members-panel .pending-user select,#owner-approval-panel .pending-user select { min-width:86px; }
#owner-panel .pending-user button { flex:0 0 auto; white-space:nowrap; } #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; } .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; } .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; } .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-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-new { padding-top:16px; border-top:1px solid var(--line); }
#play-library-status:empty,#play-library-message:empty { display:none; } #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; } #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) { @media(max-width:520px) {
#owner-panel .pending-user { align-items:stretch; } .account-gate.has-operator-fab .account-card { margin-bottom:72px; }
#owner-panel .pending-user select,#owner-panel .pending-user button { flex:1 1 86px; } .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; } .editor-role-banner { top:10px; font-size:9px; }
.pending-user { flex-wrap:wrap; align-items:center; } .pending-user { flex-wrap:wrap; align-items:center; }
.pending-user > :first-child { min-width:0; flex:1 1 100%; overflow-wrap:anywhere; } .pending-user > :first-child { min-width:0; flex:1 1 100%; overflow-wrap:anywhere; }
+13 -8
View File
@@ -15,13 +15,14 @@ const paths = {
plus: '<path d="M12 5v14M5 12h14"/>', plus: '<path d="M12 5v14M5 12h14"/>',
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>', download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
fullscreen: '<path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/>', fullscreen: '<path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/>',
back: '<path d="M19 12H5m7-7-7 7 7 7"/>',
}; };
export const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.court}</svg>`; export const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.court}</svg>`;
export function accountLayout() { export function accountLayout() {
return `<section id="account-gate" class="account-gate" aria-live="polite"> return `<section id="account-gate" class="account-gate" aria-live="polite">
<div class="account-card"> <div class="account-card">
<div class="account-brand"><span class="brand-mark">${icon('court')}</span><div><strong>basket<span>-utils</span></strong><small>BASKETBALL PLAY STUDIO</small></div></div> <div id="account-header" class="account-header"><button id="team-detail-back" class="detail-back" type="button" aria-label="소속 팀 목록으로 돌아가기" hidden>${icon('back')}</button><div class="account-brand"><span class="brand-mark">${icon('court')}</span><div><strong>basket<span>-utils</span></strong><small>BASKETBALL PLAY STUDIO</small></div></div></div>
<div id="auth-panel"> <div id="auth-panel">
<div class="account-heading"><span class="section-kicker">TEAM PLAYBOOK</span><h1>로그인</h1><p>승인된 계정으로 팀 전술을 안전하게 관리하세요.</p></div> <div class="account-heading"><span class="section-kicker">TEAM PLAYBOOK</span><h1>로그인</h1><p>승인된 계정으로 팀 전술을 안전하게 관리하세요.</p></div>
<form id="login-form" class="account-form"><label>이메일<input name="email" type="email" autocomplete="email" required maxlength="320" /></label><label>비밀번호<input name="password" type="password" autocomplete="current-password" required minlength="8" /></label><button class="primary" type="submit">로그인</button></form> <form id="login-form" class="account-form"><label>이메일<input name="email" type="email" autocomplete="email" required maxlength="320" /></label><label>비밀번호<input name="password" type="password" autocomplete="current-password" required minlength="8" /></label><button class="primary" type="submit">로그인</button></form>
@@ -40,12 +41,15 @@ export function accountLayout() {
</div> </div>
<div id="team-panel" hidden> <div id="team-panel" hidden>
<div class="account-heading"><span class="section-kicker">YOUR TEAMS</span><h1>팀 전술함</h1><p id="team-welcome"></p></div> <div class="account-heading"><span class="section-kicker">YOUR TEAMS</span><h1>팀 전술함</h1><p id="team-welcome"></p></div>
<div id="team-list" class="team-list"></div><button id="refresh-teams" class="account-secondary" type="button">팀 목록 새로고침</button> <div id="team-navigation" class="team-segmented" role="tablist" aria-label="팀 보기"><button id="show-joined-teams" type="button" role="tab" aria-selected="true" aria-pressed="true" aria-controls="joined-team-panel" tabindex="0">소속 팀</button><button id="show-team-search" type="button" role="tab" aria-selected="false" aria-pressed="false" aria-controls="search-team-panel" tabindex="-1">팀 검색</button><button id="show-team-create" type="button" role="tab" aria-selected="false" aria-pressed="false" aria-controls="create-team-panel" tabindex="-1">팀 생성</button></div>
<form id="team-form" class="team-form"><label>새 팀 만들기<input name="name" type="text" maxlength="100" placeholder="예: U18 남자부" required /></label><button class="primary" type="submit">팀 만들기</button></form> <section id="joined-team-panel" class="team-hub-pane" role="tabpanel" aria-labelledby="show-joined-teams">
<button id="open-admin" class="account-secondary" type="button" hidden>운영자 승인 페이지</button> <div class="team-table" role="table" aria-label="소속 팀 목록"><div class="team-table-header" role="row"><span role="columnheader">팀명</span><span role="columnheader">권한</span></div><div id="team-list" class="team-list" role="rowgroup"></div></div><button id="refresh-teams" class="account-secondary" type="button">팀 목록 새로고침</button><button id="retry-teams" class="account-secondary" type="button" hidden>팀 목록 다시 시도</button>
<section id="membership-panel" class="operator-panel"><div class="operator-heading"><span class="section-kicker">FIND A TEAM</span><strong>팀 검색 및 가입</strong></div><form id="team-search-form" class="team-form"><label>팀 이름 검색<input name="q" minlength="2" maxlength="60" placeholder="두 글자 이상 입력" /></label><button type="submit">검색</button></form><div id="team-search-results" class="team-list"></div><div id="join-status" class="account-empty"></div><div id="my-join-requests" class="account-empty"></div></section> </section>
<div class="local-import"><label>가져올 팀<select id="local-import-team" aria-label="기기 전술을 가져올 팀"><option value="">팀을 먼저 선택하세요</option></select></label><button id="import-local-team" class="account-secondary" type="button" disabled>이 기기의 전술 가져오기</button></div> <section id="search-team-panel" class="team-hub-pane" role="tabpanel" aria-labelledby="show-team-search" hidden>
<section id="owner-panel" class="operator-panel" hidden aria-labelledby="owner-team-title"><div class="operator-heading"><span class="section-kicker">TEAM OWNER</span><strong id="owner-team-title">팀원 관리</strong></div><p id="owner-team-name" class="owner-team-name"></p><h3 class="owner-subheading">가입 승인 대기</h3><div id="owner-requests"></div><h3 class="owner-subheading">현재 팀원</h3><div id="owner-members"></div><button id="owner-back" class="account-link" type="button">팀 목록으로</button></section> <div id="membership-panel" class="operator-panel"><div class="operator-heading"><span class="section-kicker">FIND A TEAM</span><strong>팀 검색 및 가입</strong></div><form id="team-search-form" class="team-form"><label>팀 이름 검색<input name="q" minlength="2" maxlength="60" placeholder="두 글자 이상 입력" /></label><button type="submit">검색</button></form><div id="team-search-results" class="team-list"></div><div id="join-status" class="account-empty"></div><div id="my-join-requests" class="account-empty"></div></div>
</section>
<section id="create-team-panel" class="team-hub-pane" role="tabpanel" aria-labelledby="show-team-create" hidden><form id="team-form" class="team-form team-create-panel"><label>새 팀 만들기<input name="name" type="text" maxlength="100" placeholder="예: U18 남자부" required /></label><button class="primary" type="submit">팀 만들기</button></form></section>
<section id="team-detail-panel" class="selected-team-panel" hidden aria-labelledby="selected-team-name"><div class="selected-team-heading"><strong id="selected-team-name"></strong><span>선택한 팀의 작업을 고르세요.</span></div><div id="team-action-navigation" class="team-segmented team-action-segmented" role="tablist" aria-label="선택한 팀 작업"><button id="show-team-library" type="button" role="tab" aria-selected="true" aria-pressed="true" aria-controls="team-library-action-panel" tabindex="0">전술 목록</button><button id="show-team-management" type="button" role="tab" aria-selected="false" aria-pressed="false" aria-controls="team-management-action-panel" tabindex="-1" hidden>팀원 관리</button><button id="show-team-approval" type="button" role="tab" aria-selected="false" aria-pressed="false" aria-controls="team-approval-action-panel" tabindex="-1" hidden>가입 승인</button></div><div id="team-library-action-panel" role="tabpanel" aria-labelledby="show-team-library"><p id="team-detail-tactics-status" class="account-message" role="status">전술 목록을 불러오는 중…</p><div id="team-detail-tactics-list" class="play-library-list"></div></div><div id="team-management-action-panel" role="tabpanel" aria-labelledby="show-team-management" hidden><section id="owner-members-panel" class="operator-panel" aria-labelledby="owner-team-title"><div class="operator-heading"><span class="section-kicker">TEAM OWNER</span><strong id="owner-team-title">팀원 관리</strong></div><p id="owner-team-name" class="owner-team-name"></p><h3 class="owner-subheading">현재 팀원</h3><div id="owner-members"></div></section></div><div id="team-approval-action-panel" role="tabpanel" aria-labelledby="show-team-approval" hidden><section id="owner-approval-panel" class="operator-panel" aria-labelledby="owner-approval-title"><div class="operator-heading"><span class="section-kicker">TEAM OWNER</span><strong id="owner-approval-title">가입 승인</strong></div><h3 class="owner-subheading">가입 승인 대기</h3><div id="owner-requests"></div></section></div><p id="owner-message" class="account-message" role="status"></p></section>
<button id="team-logout" class="account-link" type="button">로그아웃</button> <button id="team-logout" class="account-link" type="button">로그아웃</button>
<p id="team-message" class="account-message" role="status"></p> <p id="team-message" class="account-message" role="status"></p>
</div> </div>
@@ -53,13 +57,14 @@ export function accountLayout() {
<div class="account-heading"><span class="section-kicker">TEAM PLAY LIST</span><h1 id="play-library-title">전술 목록</h1><p id="play-library-welcome"></p></div> <div class="account-heading"><span class="section-kicker">TEAM PLAY LIST</span><h1 id="play-library-title">전술 목록</h1><p id="play-library-welcome"></p></div>
<div id="play-library-status" class="account-message" role="status"></div> <div id="play-library-status" class="account-message" role="status"></div>
<div id="play-library-list" class="play-library-list"></div> <div id="play-library-list" class="play-library-list"></div>
<section class="play-library-new"><div class="operator-heading"><span class="section-kicker">NEW PLAY</span><strong>새 전술 만들기</strong></div><form id="new-play-form" class="account-form"><label>전술 이름<input name="name" type="text" maxlength="100" placeholder="예: 1쿼터 기본 공격" required /></label><label>수비 형태<select name="defenseType"><option value="man-to-man">맨투맨</option><option value="2-3">23 지역 수비</option><option value="3-2">32 지역 수비</option></select></label><button id="library-new-play" class="primary" type="submit">새 전술 시작</button></form></section> <section class="play-library-new"><div class="operator-heading"><span class="section-kicker">NEW PLAY</span><strong>새 전술 만들기</strong></div><form id="new-play-form" class="account-form"><label>전술 이름<input name="name" type="text" maxlength="100" placeholder="예: 1쿼터 기본 공격" required /></label><label>수비 형태<select name="defenseType"><option value="man-to-man">맨투맨</option><option value="2-3">23 지역 수비</option><option value="32">32 지역 수비</option></select></label><button id="library-new-play" class="primary" type="submit">새 전술 시작</button></form></section>
<button id="continue-draft" class="account-secondary" type="button" hidden>임시 저장 계속하기</button> <button id="continue-draft" class="account-secondary" type="button" hidden>임시 저장 계속하기</button>
<button id="library-back-teams" class="account-link" type="button">팀 목록으로</button> <button id="library-back-teams" class="account-link" type="button">팀 목록으로</button>
<p id="play-library-message" class="account-message" role="status"></p> <p id="play-library-message" class="account-message" role="status"></p>
</div> </div>
<div id="admin-panel" hidden><div class="account-heading"><span class="section-kicker">OPERATOR ADMIN</span><h1>가입 승인</h1><p>승인 대기 계정을 검토하고 처리합니다.</p></div><div id="pending-users"></div><button id="admin-back" class="account-link" type="button">팀 전술함으로 돌아가기</button><p id="admin-message" class="account-message" role="status"></p></div> <div id="admin-panel" hidden><div class="account-heading"><span class="section-kicker">OPERATOR ADMIN</span><h1>가입 승인</h1><p>승인 대기 계정을 검토하고 처리합니다.</p></div><div id="pending-users"></div><button id="admin-back" class="account-link" type="button">팀 전술함으로 돌아가기</button><p id="admin-message" class="account-message" role="status"></p></div>
</div> </div>
<a id="operator-fab" class="operator-fab" href="/admin" hidden aria-label="운영자 승인 페이지">운영자 승인 <span aria-hidden="true">↗</span></a>
</section>`; </section>`;
} }