Improve membership flow, play library, and mobile tactics board

This commit is contained in:
2026-09-08 18:55:48 +09:00
parent aa5ce697ad
commit 680cb21c80
15 changed files with 1136 additions and 82 deletions
+11 -5
View File
@@ -9,18 +9,20 @@
- Fastify 인스턴스당 하나의 MongoClient 연결 풀을 유지하고 서버 종료 시 닫는다. 요청마다 연결·해제하지 않는다. - Fastify 인스턴스당 하나의 MongoClient 연결 풀을 유지하고 서버 종료 시 닫는다. 요청마다 연결·해제하지 않는다.
- 연결 URI와 DB 이름은 프로젝트 루트 `.config.json`으로 설정한다. `.config.json.sample`을 예시로 제공한다. 전용 DB `basket_utils` 사용은 사용자가 확인했으며 기존 다른 프로젝트 DB를 수정하지 않는다. - 연결 URI와 DB 이름은 프로젝트 루트 `.config.json`으로 설정한다. `.config.json.sample`을 예시로 제공한다. 전용 DB `basket_utils` 사용은 사용자가 확인했으며 기존 다른 프로젝트 DB를 수정하지 않는다.
- 실제 구현: GPT-5.6 Luna, xhigh. 주 에이전트는 요구사항 정리·검토·검증·문서 담당. - 실제 구현: GPT-5.6 Luna, xhigh. 주 에이전트는 요구사항 정리·검토·검증·문서 담당.
- 범위: 로그인, 운영자 사용 승인, 여러 팀 생성/전환, 팀별 전술 저장/불러오기, 기존 기기 전술의 명시적 가져오기. - 범위: 로그인, 별도 관리자 페이지의 운영자 사용 승인, 여러 팀 생성/검색/가입 신청, 팀 소유자의 가입 승인과 팀원별 권한 설정, 팀별 전술 저장/불러오기, 기존 기기 전술의 명시적 가져오기.
- 기존 Three.js 편집/재생과 MP4 파일 직접 공유는 유지. - 기존 Three.js 편집/재생과 MP4 파일 직접 공유는 유지.
- 외부 로그인 서비스 키가 없는 초기 구현은 이메일/비밀번호 로그인. 카카오톡 파일 공유와 서비스 로그인 수단은 독립적이다. - 외부 로그인 서비스 키가 없는 초기 구현은 이메일/비밀번호 로그인. 카카오톡 파일 공유와 서비스 로그인 수단은 독립적이다.
## 사용자 흐름 ## 사용자 흐름
1. 로그인 또는 회원가입. 1. 로그인 또는 회원가입.
2. 미승인 사용자는 승인 대기 화면. 승인된 사용자는 내 팀으로 진입. 2. 미승인 사용자는 승인 대기 화면. 운영자가 별도 관리자 페이지에서 서비스 이용을 승인하면 다시 로그인해 내 팀으로 진입.
3. 팀이 없으면 첫 팀 생성. 계정 하나로 여러 팀 생성 가능. 3. 새 팀 생성 또는 기존 팀 이름 검색을 선택. 기존 팀에는 가입을 신청하고 해당 팀 소유자의 승인을 기다림. 계정 하나로 여러 팀 생성/참여 가능.
4. 팀 선택 후 해당 팀의 전술 목록과 새 전술 작성. - 팀 소유자는 가입 신청을 승인하면서 열람자 또는 편집자 권한을 선택하고, 이후 팀원별 권한을 변경할 수 있음.
- 서비스 이용 승인과 팀 가입 승인은 별개. 팀 검색 결과만으로 전술이나 구성원 정보에 접근할 수 없음.
4. 팀 선택 후 해당 팀의 전술 목록 화면으로 이동. 저장된 전술을 선택하거나 새 전술의 이름·수비 형태를 정한 뒤에만 전술 캔버스로 진입.
5. 편집 화면에서 현재 팀 이름을 확인하며 저장·불러오기·MP4 생성/공유. 5. 편집 화면에서 현재 팀 이름을 확인하며 저장·불러오기·MP4 생성/공유.
6. 팀 전환 시 다른 팀의 목록/임시 저장과 섞이지 않음. 6. 캔버스에서 전술 목록으로 돌아갈 수 있고, 전술 목록에서 팀 선택으로 복귀. 팀 전환 시 다른 팀의 목록/임시 저장과 섞이지 않음.
7. 로그아웃 시 편집/공유 상태를 정리하고 로그인 화면으로 복귀. 7. 로그아웃 시 편집/공유 상태를 정리하고 로그인 화면으로 복귀.
## 검토 기준 ## 검토 기준
@@ -29,6 +31,7 @@
- 운영자는 로컬 설정 절차로 초기화하며 공개 기본 비밀번호를 제공하지 않는다. - 운영자는 로컬 설정 절차로 초기화하며 공개 기본 비밀번호를 제공하지 않는다.
- 비밀번호는 해시로 저장하고 세션은 HttpOnly 쿠키로 관리한다. - 비밀번호는 해시로 저장하고 세션은 HttpOnly 쿠키로 관리한다.
- 서버가 매 요청의 승인 상태와 팀 소속을 확인한다. - 서버가 매 요청의 승인 상태와 팀 소속을 확인한다.
- 운영자만 서비스 이용을 승인하고, 해당 팀 소유자만 팀 가입을 승인하거나 팀원 권한을 변경한다. 팀 소유자 역할은 일반 팀원 권한 변경 대상이 아니다.
- 팀 정보와 전술은 MongoDB에 저장되어 Fastify 재시작 후에도 남는다. - 팀 정보와 전술은 MongoDB에 저장되어 Fastify 재시작 후에도 남는다.
- 사용자·팀·전술명은 화면에서 데이터로 출력한다. - 사용자·팀·전술명은 화면에서 데이터로 출력한다.
- 팀 변경/로그아웃 후 이전 요청 응답이 새 화면을 덮어쓰지 않는다. - 팀 변경/로그아웃 후 이전 요청 응답이 새 화면을 덮어쓰지 않는다.
@@ -39,6 +42,9 @@
## 검증 시나리오 ## 검증 시나리오
- 비로그인 → 로그인 필요; 미승인 로그인 → 대기; 승인 후 → 팀 화면. - 비로그인 → 로그인 필요; 미승인 로그인 → 대기; 승인 후 → 팀 화면.
- 별도 관리자 페이지 직접 접속/새로고침/로그인 복귀와 일반 계정의 접근 제한 확인.
- 팀 검색 → 가입 신청 → 중복 신청 방지 → 소유자 승인 → 신청자의 내 팀에 표시.
- 열람자/편집자 권한 변경과 서버 저장 허용 여부, 소유자 이외 계정의 구성원 관리 제한 확인.
- 팀 A/B 생성 → 각각 다른 전술 저장 → 왕복 전환/새로고침 후 분리 확인. - 팀 A/B 생성 → 각각 다른 전술 저장 → 왕복 전환/새로고침 후 분리 확인.
- 승인된 별도 계정은 멤버가 아닌 팀의 목록/전술에 접근 불가. - 승인된 별도 계정은 멤버가 아닌 팀의 목록/전술에 접근 불가.
- 세션 로그아웃/승인 중단 후 서버 작업 거부. - 세션 로그아웃/승인 중단 후 서버 작업 거부.
+54 -1
View File
@@ -53,7 +53,7 @@ LAN 접속에는 Windows 방화벽과 공유기의 기기 간 통신 허용도
node .\server\bootstrap-admin.js --email=your-email@example.com --promote-existing node .\server\bootstrap-admin.js --email=your-email@example.com --promote-existing
``` ```
`your-email@example.com`을 실제 가입한 이메일로 바꾼다. 기존 비밀번호는 유지된다. 이후 로그인하여 다른 사용자의 가입 신청을 승인한다. `your-email@example.com`을 실제 가입한 이메일로 바꾼다. 기존 비밀번호는 유지된다. 이후 `/admin`에서 운영자 계정으로 로그인하여 다른 사용자의 가입 신청을 승인한다. 웹에서 최초 운영자 권한을 자동 부여하지 않는다.
PowerShell에서는 `npm.ps1`을 통해 실행할 때 옵션이 npm 자체 설정으로 해석되어 스크립트에 전달되지 않을 수 있으므로 위처럼 Node로 직접 실행한다. 명령 끝에 역슬래시(`\`)를 붙이지 않는다. PowerShell에서는 `npm.ps1`을 통해 실행할 때 옵션이 npm 자체 설정으로 해석되어 스크립트에 전달되지 않을 수 있으므로 위처럼 Node로 직접 실행한다. 명령 끝에 역슬래시(`\`)를 붙이지 않는다.
@@ -63,6 +63,30 @@ PowerShell에서는 `npm.ps1`을 통해 실행할 때 옵션이 npm 자체 설
node .\server\bootstrap-admin.js --email=your-email@example.com node .\server\bootstrap-admin.js --email=your-email@example.com
``` ```
## 사용자 승인과 팀 참여
1. 최초 방문자는 로그인 화면에서 사용 신청으로 이동해 이메일·비밀번호·표시 이름을 등록한다.
2. 서비스 운영자는 별도 `/admin` 페이지에서 승인 대기 사용자를 확인하고 이용을 승인한다. 개발 주소는 `http://localhost:5173/admin`이며, 운영 환경에서는 서비스 주소 뒤에 `/admin`을 붙인다.
3. 승인된 사용자가 다시 로그인하면 내 팀에서 새 팀을 만들거나 기존 팀 이름을 검색할 수 있다. 서비스 이용 승인만으로 기존 팀에 자동 가입되지 않는다.
4. 기존 팀을 검색해 가입을 신청하면 팀 소유자의 승인을 기다린다.
5. 팀 소유자는 팀원 관리에서 가입 신청을 승인하고 열람자 또는 편집자 권한을 부여한다. 이후에도 팀원별 권한을 변경할 수 있다.
열람자는 팀 전술을 조회할 수 있고 서버에 저장할 수 없다. 편집자와 소유자는 팀 전술을 서버에 저장할 수 있다. 소유자 권한 이전은 지원하지 않는다. 서비스 운영자 권한과 팀 소유자 권한은 별개이며, 운영자도 다른 팀에 자동으로 참여하지 않는다.
가입 신청은 MongoDB `team_join_requests`에 저장한다. 서버 시작 시 필요한 컬렉션 인덱스를 준비하며, 기존 계정·팀·전술을 삭제하거나 다시 만들 필요는 없다. 코드 반영 후에는 서버를 재시작하고 운영 화면은 다시 빌드한다.
팀 검색은 이름 두 글자 이상으로 검색하며 한 번에 최대 20개를 표시한다. 가입 대기/승인 상태는 내 가입 요청에서 확인하고, 팀 목록 새로고침으로 최신 상태를 불러온다. 팀 소유자는 내 팀 아래의 **팀원 관리**에서 승인하고, 기존 팀원의 역할을 선택한 뒤 **역할 저장**을 누른다.
## 팀 전술 목록
로그인 → 팀 선택 → 전술 목록 → 저장된 전술 열기 또는 새 전술 시작 → 전술 캔버스 순서로 사용한다. 새 팀을 만든 경우에도 먼저 비어 있는 전술 목록으로 이동한다.
전술 목록에는 해당 팀의 저장된 전술 이름과 수정 시각을 표시한다. 새 전술은 이름과 수비 형태를 정한 뒤 시작하고, 캔버스에서 저장하면 서버 목록에 추가된다. 열람자는 기존 전술을 열 수 있지만 새 전술 생성과 서버 저장은 할 수 없다.
캔버스의 **전술 목록**으로 돌아오면 다른 전술을 선택할 수 있고, 목록의 **팀 목록으로**에서 팀을 바꿀 수 있다. 이 기기에 남은 임시 저장본은 별도의 **임시 저장 계속하기**로 연다. 저장된 전술의 열기 버튼은 서버에 저장된 내용을 불러온다.
2026-09-08 전술 목록 UX 검증: 메모리 QA 서버에서 팀 선택 후 목록 진입, 기존 전술 열기, 임시본 이어서 편집, 서버 저장본과 임시본 구분, 이름·수비 형태를 지정한 새 전술 생성/저장/목록 반영, 새 팀의 빈 목록, 열람자의 생성·저장 제한을 확인했다. PC 및 모바일 화면 크기에서 목록 배치를 확인했고 전체 테스트 115개와 빌드가 통과했다.
## 운영 실행 ## 운영 실행
`.config.json``server.mode``production`, `server.allowedOrigins`를 실제 HTTPS 서비스 주소 배열, `server.secureCookie``true`로 설정한다. `.config.json``server.mode``production`, `server.allowedOrigins`를 실제 HTTPS 서비스 주소 배열, `server.secureCookie``true`로 설정한다.
@@ -90,3 +114,32 @@ npm run build
JSON 설정 전환 후에는 설정 로더 검증 5개와 정적 파일 보호 검증 1개를 추가해 총 112개 테스트와 빌드가 통과했다. 설정 파일이 없거나 JSON 구문·값 타입이 잘못되면 시작 시 오류를 안내하며, JSON 구문 오류에 설정 원문을 출력하지 않는다. 운영 모드에서는 Secure 쿠키가 강제되고 개발용 HTTP 출처는 허용하지 않는다. JSON 설정 전환 후에는 설정 로더 검증 5개와 정적 파일 보호 검증 1개를 추가해 총 112개 테스트와 빌드가 통과했다. 설정 파일이 없거나 JSON 구문·값 타입이 잘못되면 시작 시 오류를 안내하며, JSON 구문 오류에 설정 원문을 출력하지 않는다. 운영 모드에서는 Secure 쿠키가 강제되고 개발용 HTTP 출처는 허용하지 않는다.
QA seed 명령은 `basket_utils_qa` 또는 `basket_utils_qa_` 뒤에 영문·숫자·하이픈이 붙은 DB 이름만 허용한다. 이번 검증 DB는 `basket_utils_qa_20260908review`이며 테스트 데이터는 운영 DB와 분리되어 있다. QA 서버는 검증 후 종료했다. QA seed 명령은 `basket_utils_qa` 또는 `basket_utils_qa_` 뒤에 영문·숫자·하이픈이 붙은 DB 이름만 허용한다. 이번 검증 DB는 `basket_utils_qa_20260908review`이며 테스트 데이터는 운영 DB와 분리되어 있다. QA 서버는 검증 후 종료했다.
### 팀 가입·관리 화면 추가 검증 (2026-09-08)
- 전체 테스트 115개 통과, 배포 빌드 및 `git diff --check` 통과. 기존 번들 크기 경고는 유지된다.
- 이번 화면 검증은 운영 MongoDB와 연결하지 않는 로컬 메모리 QA 서버에서 진행했다.
- 사용 신청 → 별도 `/admin` 로그인/승인 → 신청자 로그인 → 팀 검색/가입 신청 → 소유자 승인 → 열람자/편집자 역할 저장을 브라우저에서 확인했다.
- 가입 대기 상태의 새로고침 유지, 일반 계정의 관리자 페이지 접근 제한, 관리자 직접 로그인/새로고침/뒤로가기, 계정 전환 후 화면 초기화를 확인했다.
- 새 팀 생성, 편집자의 서버 저장, 열람자의 저장 버튼 비활성화와 서버 가져오기 대상 제외를 확인했다. 서버 테스트에서도 같은 세션의 역할 변경 이후 저장 권한을 검증한다.
- PC 1280×900 및 모바일 390×844 화면에서 가입 양식·승인·팀원 관리 배치를 확인했다. 모바일은 브라우저 화면 크기 검증이며 실물 휴대폰 검증은 아니다.
### 기존 편집 화면과 모바일 전체화면
- 로그인 후 팀을 선택하면 전술 목록을 먼저 표시한다. 저장된 전술을 열거나 이름과 수비 형태를 정해 새 전술을 시작한다.
- 일반 화면은 기존 선수 목록·행동 도구·단계/재생 UI를 유지한다. 기본 이동 모드에서 선수 드래그는 이동 행동을 기록하며, 명시적으로 `배치`를 선택했을 때만 시작 위치를 변경한다.
- 모바일 전체화면은 화면 배치만 바꾸며 현재 단계와 기록된 행동을 유지한다. 도구와 재생 UI는 코트 크기를 줄이지 않는 접이식 오버레이로 표시한다.
- 브라우저 전체화면은 사용자가 버튼을 눌렀을 때만 요청하고, 성공하면 가로 방향 잠금을 요청한다. 종료 시 잠금을 해제한다. 방향 잠금은 브라우저 지원에 따라 실패할 수 있으므로, 전체화면 편집 중 세로 화면에서는 회전 안내와 종료 버튼을 표시하고 편집 입력을 차단한다. 가로 화면이 되면 안내를 닫는다. 브라우저 자체 종료 안내의 표시 시간은 앱에서 제어하지 않는다.
- 패스는 현재 단계의 최종 공 소유 선수를 선택했을 때만 표시한다. 패스 후 기존 소유자의 패스·슛은 숨기고, 공을 받은 선수를 선택하면 표시한다.
- 전체화면 왼쪽은 선수 구성 버튼만 표시한다. 오른쪽 상단에는 저장·전체화면·메뉴, 하단에는 단계·행동 버튼을 배치한다. 행동 도구 모음과 별도의 실행 취소·다시 실행 도구박스는 오른쪽 중앙에 배치한다. 단계 패널은 최대 160px 폭으로 목록, 진행도, 재생 버튼을 세로로 배치하며 코트 크기를 바꾸지 않는다.
- 세로 일반 화면은 코트와 도구 영역을 실제 레이아웃으로 분리한다. `visualViewport.height` 변화를 반영해 주소창을 제외한 표시 높이에 화면을 맞추며, 페이지 스크롤 대신 패널 내부 스크롤을 사용한다. 가로 전체화면은 코트 전체를 좌우 도구 사이의 중앙 영역에 맞춰 가로로 늘려 표시한다. 끝선의 작은 안전 여백을 제외하고 중앙 영역을 채우며, 선수·경로·드래그는 같은 카메라 좌표계를 사용한다. 일반 화면과 영상 출력의 비율, 행동 편집의 경로·좌표·시선 설정은 유지한다.
- 재생 바는 이전·다음 버튼 사이의 폭을 사용하고, 현재 시간 / 전체 시간은 바 아래에 작은 중앙 정렬 자막으로 표시한다.
- 모바일 전체화면 진입 버튼은 저장 버튼과 구별되는 진한 청록색과 1.8초 주기의 부드러운 확대·축소 및 퍼지는 테두리로 표시한다. 전체화면 진입 후에는 강조 애니메이션을 멈추며, 동작 줄이기 설정에서는 색상만 강조한다. 전체화면 SVG와 선수 구성 아이콘은 버튼 중앙에 정렬한다.
이전의 전체 화면 공통 작전판/움직임 모드 분리는 요구 범위와 달라 철회했다.
2026-09-08 추가 수정 검증: 전체 118개 테스트와 빌드 통과. 메모리 QA 서버에서 390×844 세로 전체화면의 회전 안내·종료·편집 영역 inert 처리, 가로 전환 시 안내 해제, 844×390 및 740×360의 단계/행동 패널 동시 표시와 코트 크기 유지, 단계 추가, 드래그 기록과 실행 취소/다시 실행, 패스 후 공 소유권에 따른 패스·슛 표시를 확인했다. 740px 행동 패널의 가로 넘침도 수정했다. 일반 PC 1280×900과 모바일 화면의 기존 UI를 유지한다. 이 검증은 브라우저 화면 크기 검증이며 실물 휴대폰의 방향 잠금 성공을 보증하지 않는다.
2026-09-08 수정 검증: 전체 118개 테스트, 빌드, `git diff --check` 통과. 사용하지 않는 이전 모드 전용 함수와 해당 테스트는 제거했다. 메모리 QA 서버에서 연속 드래그로 두 구간 추가 → 한 구간 실행 취소/다시 실행 → 서버 저장 → 목록 복귀 → 재열기 후 같은 경로 유지까지 확인했다. 새 전술도 이동 모드로 시작하며 첫 드래그가 경로를 만든다.
1280×900 일반 화면은 기존 3열 UI를 유지하고 전체화면 버튼을 숨긴다. 390×844 일반 화면의 가로 넘침과 버튼 클릭 영역을 확인했다. 844×390 및 740×360 가로 전체화면에서는 도구·단계 목록을 함께 열어도 코트 영역 크기가 유지되고 선수 10명이 가려지지 않는다. 두 패널을 연 상태의 선수 드래그와 단계 추가, 전체화면 종료 후 현재 단계 유지, 재진입 시 재생 바 닫힘을 확인했다. 브라우저 자체 전체화면 진입과 QA 전용 `Permissions-Policy: fullscreen=()` 환경의 대체 화면을 각각 확인했다. 실물 휴대폰의 터치 감도와 브라우저 안내 지속 시간은 별도 확인 대상이다. 기존 번들 크기 경고는 유지된다.
+71
View File
@@ -18,6 +18,7 @@ function iso(value) { return value instanceof Date ? value.toISOString() : new D
function safeText(value, fallback = '') { return String(value ?? '').trim() || fallback; } function safeText(value, fallback = '') { return String(value ?? '').trim() || fallback; }
function publicUser(row) { return { id: row.id, email: row.email, displayName: row.displayName, status: row.status, isOperator: Boolean(row.isOperator) }; } function publicUser(row) { return { id: row.id, email: row.email, displayName: row.displayName, status: row.status, isOperator: Boolean(row.isOperator) }; }
function publicTeam(row) { return { id: row.id, name: row.name, ownerUserId: row.ownerUserId, role: row.role, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; } function publicTeam(row) { return { id: row.id, name: row.name, ownerUserId: row.ownerUserId, role: row.role, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; }
function publicJoinRequest(row) { return { id: row.id, teamId: row.teamId, userId: row.userId, status: row.status, role: row.role || null, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) }; }
function errorPayload(code, message) { return { error: { code, message } }; } 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); }
@@ -159,6 +160,76 @@ export async function buildServer(options = {}) {
return reply.code(201).send({ team: publicTeam({ id, name, ownerUserId: request.user.id, role: 'owner', createdAt, updatedAt: createdAt }) }); return reply.code(201).send({ team: publicTeam({ id, name, ownerUserId: request.user.id, role: 'owner', createdAt, updatedAt: createdAt }) });
}); });
app.get('/api/teams/search', { preHandler: requireUser }, async (request) => {
const query = safeText(request.query?.q).slice(0, 60);
if (query.length < 2) return { teams: [] };
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const rows = await store.teams.find({ name: { $regex: escaped, $options: 'i' } }, { projection: { _id: 0, id: 1, name: 1 } }).sort({ name: 1 }).limit(20).toArray();
return { teams: rows.slice(0, 20).map((team) => ({ id: team.id, name: team.name })) };
});
app.get('/api/team-requests', { preHandler: requireUser }, async (request) => {
const rows = await store.joinRequests.find({ userId: request.user.id }).sort({ updatedAt: -1 }).toArray();
const teams = await store.teams.find({ id: { $in: rows.map((row) => row.teamId) } }, { projection: { _id: 0, id: 1, name: 1 } }).toArray();
const names = new Map(teams.map((team) => [team.id, team.name]));
return { requests: rows.map((row) => ({ ...publicJoinRequest(row), teamName: names.get(row.teamId) || '삭제된 팀' })) };
});
app.post('/api/teams/:teamId/join-requests', { preHandler: requireUser, schema: { body: bodySchema({}, []) } }, async (request, reply) => {
if (!parsePositiveId(request.params.teamId)) return fail(reply, 400, 'invalid_team', '팀 식별자가 올바르지 않습니다');
const team = await store.teams.findOne({ id: request.params.teamId }); if (!team) return fail(reply, 404, 'team_not_found', '팀을 찾을 수 없습니다');
if (await store.teamMembers.findOne({ teamId: team.id, userId: request.user.id })) return fail(reply, 409, 'already_member', '이미 팀에 속해 있습니다');
const existing = await store.joinRequests.findOne({ teamId: team.id, userId: request.user.id });
if (existing) {
if (existing.status === 'pending' || existing.status === 'approved') return { request: publicJoinRequest(existing), existing: true };
const at = nowDate();
await store.joinRequests.updateOne({ id: existing.id, teamId: team.id, userId: request.user.id }, { $set: { status: 'pending', role: null, updatedAt: at } });
return { request: publicJoinRequest({ ...existing, status: 'pending', role: null, updatedAt: at }), existing: true };
}
const at = nowDate(); const row = { id: randomUUID(), teamId: team.id, userId: request.user.id, status: 'pending', role: null, createdAt: at, updatedAt: at };
try { await store.joinRequests.insertOne(row); } catch (error) { if (error?.code === 11000) { const current = await store.joinRequests.findOne({ teamId: team.id, userId: request.user.id }); return { request: publicJoinRequest(current), existing: true }; } throw error; }
return reply.code(201).send({ request: publicJoinRequest(row), existing: false });
});
app.get('/api/teams/:teamId/members', { preHandler: requireTeam('owner') }, async (request) => {
const members = await store.teamMembers.find({ teamId: request.params.teamId }).toArray();
const users = await store.users.find({ id: { $in: members.map((member) => member.userId) } }).toArray();
const byId = new Map(users.map((user) => [user.id, user]));
return { members: members.map((member) => ({ user: publicUser(byId.get(member.userId) || { id: member.userId, email: '', displayName: member.userId, status: 'unknown', isOperator: false }), role: member.role, createdAt: iso(member.createdAt) })) };
});
app.get('/api/teams/:teamId/join-requests', { preHandler: requireTeam('owner') }, async (request) => {
const rows = await store.joinRequests.find({ teamId: request.params.teamId, status: 'pending' }).sort({ createdAt: 1 }).toArray();
const users = await store.users.find({ id: { $in: rows.map((row) => row.userId) } }).toArray(); const byId = new Map(users.map((user) => [user.id, user]));
return { requests: rows.map((row) => ({ ...publicJoinRequest(row), user: publicUser(byId.get(row.userId) || { id: row.userId, email: '', displayName: row.userId, status: 'unknown', isOperator: false }) })) };
});
app.post('/api/teams/:teamId/join-requests/:requestId/approve', { preHandler: requireTeam('owner'), schema: { body: bodySchema({ role: { type: 'string', enum: ['viewer', 'editor'] } }, []) } }, async (request, reply) => {
if (request.team.ownerUserId !== request.user.id) return fail(reply, 403, 'team_forbidden', '팀 소유자만 가입 요청을 승인할 수 있습니다');
const requestedRole = request.body?.role || 'viewer'; const requestRow = await store.joinRequests.findOne({ id: request.params.requestId, teamId: request.params.teamId });
if (requestRow?.status === 'approved') {
const existingMember = await store.teamMembers.findOne({ teamId: request.params.teamId, userId: requestRow.userId });
if (existingMember) return { ok: true, existing: true, member: { user: publicUser(await store.users.findOne({ id: requestRow.userId }) || { id: requestRow.userId, email: '', displayName: requestRow.userId, status: 'unknown', isOperator: false }), role: existingMember.role } };
}
if (requestRow?.status !== 'pending') return fail(reply, 404, 'request_not_found', '가입 요청을 찾을 수 없습니다');
const applicant = await store.users.findOne({ id: requestRow.userId, status: 'approved' });
if (!applicant) return fail(reply, 409, 'applicant_not_approved', '승인된 계정만 팀에 추가할 수 있습니다');
const at = nowDate();
let memberRole = requestedRole;
try { await store.teamMembers.insertOne({ teamId: request.params.teamId, userId: requestRow.userId, role: requestedRole, createdAt: at }); }
catch (error) { if (error?.code !== 11000) throw error; const existing = await store.teamMembers.findOne({ teamId: request.params.teamId, userId: requestRow.userId }); memberRole = existing?.role || requestedRole; }
await store.joinRequests.updateOne({ id: requestRow.id, status: 'pending' }, { $set: { status: 'approved', role: memberRole, updatedAt: at } });
return { ok: true, member: { user: publicUser(applicant), role: memberRole } };
});
app.patch('/api/teams/:teamId/members/:userId', { preHandler: requireTeam('owner'), schema: { body: bodySchema({ role: { type: 'string', enum: ['viewer', 'editor'] } }, ['role']) } }, async (request, reply) => {
if (request.team.ownerUserId !== request.user.id) return fail(reply, 403, 'team_forbidden', '팀 소유자만 멤버 권한을 변경할 수 있습니다');
const userId = request.params.userId; if (userId === request.user.id) return fail(reply, 400, 'owner_immutable', '소유자 권한은 변경할 수 없습니다');
const member = await store.teamMembers.findOne({ teamId: request.params.teamId, userId }); if (!member) return fail(reply, 404, 'member_not_found', '팀 멤버를 찾을 수 없습니다');
if (member.role === 'owner') return fail(reply, 400, 'owner_immutable', '소유자 권한은 변경할 수 없습니다');
await store.teamMembers.updateOne({ teamId: request.params.teamId, userId }, { $set: { role: request.body.role, updatedAt: nowDate() } }); return { ok: true, userId, role: request.body.role };
});
app.get('/api/teams/:teamId', { preHandler: requireTeam('viewer') }, async (request) => ({ team: publicTeam(request.team) })); app.get('/api/teams/:teamId', { preHandler: requireTeam('viewer') }, async (request) => ({ team: publicTeam(request.team) }));
app.get('/api/teams/:teamId/plays', { preHandler: requireTeam('viewer') }, async (request) => { app.get('/api/teams/:teamId/plays', { preHandler: requireTeam('viewer') }, async (request) => {
+51 -4
View File
@@ -14,6 +14,7 @@ function matches(document, filter) {
if ('$gt' in expected && !(actual > expected.$gt)) return false; if ('$gt' in expected && !(actual > expected.$gt)) return false;
if ('$lte' in expected && !(actual <= expected.$lte)) return false; if ('$lte' in expected && !(actual <= expected.$lte)) return false;
if ('$in' in expected && !expected.$in.includes(actual)) return false; if ('$in' in expected && !expected.$in.includes(actual)) return false;
if ('$regex' in expected && !(new RegExp(expected.$regex, expected.$options || '')).test(String(actual ?? ''))) return false;
return true; return true;
} }
return actual === expected; return actual === expected;
@@ -26,7 +27,7 @@ function project(document, projection = {}) {
if (projection._id === 0) delete result._id; if (projection._id === 0) delete result._id;
return result; return result;
} }
function collection() { function collection(uniqueKeys = []) {
const documents = []; const documents = [];
return { return {
documents, documents,
@@ -34,16 +35,16 @@ function collection() {
async findOne(filter, options) { const document = documents.find((item) => matches(item, filter)); return document ? project(document, options?.projection) : null; }, async findOne(filter, options) { const document = documents.find((item) => matches(item, filter)); return document ? project(document, options?.projection) : null; },
find(filter, options) { find(filter, options) {
let result = documents.filter((item) => matches(item, filter)).map((item) => project(item, options?.projection)); let result = documents.filter((item) => matches(item, filter)).map((item) => project(item, options?.projection));
return { sort(spec) { result.sort((left, right) => { for (const [key, direction] of Object.entries(spec)) { const a = left[key]; const b = right[key]; if (a === b) continue; return (a > b ? 1 : -1) * direction; } return 0; }); return this; }, toArray: async () => result }; return { sort(spec) { result.sort((left, right) => { for (const [key, direction] of Object.entries(spec)) { const a = left[key]; const b = right[key]; if (a === b) continue; return (a > b ? 1 : -1) * direction; } return 0; }); return this; }, limit(count) { result = result.slice(0, count); return this; }, toArray: async () => result };
}, },
async insertOne(document) { if (documents.some((item) => item.email && item.email === document.email)) { const error = new Error('duplicate'); error.code = 11000; throw error; } documents.push(clone(document)); return { acknowledged: true }; }, async insertOne(document) { if (documents.some((item) => uniqueKeys.some((keys) => keys.every((key) => item[key] !== undefined && item[key] === document[key])))) { const error = new Error('duplicate'); error.code = 11000; throw error; } documents.push(clone(document)); return { acknowledged: true }; },
async updateOne(filter, update) { const document = documents.find((item) => matches(item, filter)); if (!document) return { modifiedCount: 0 }; Object.assign(document, clone(update.$set || {})); return { modifiedCount: 1 }; }, async updateOne(filter, update) { const document = documents.find((item) => matches(item, filter)); if (!document) return { modifiedCount: 0 }; Object.assign(document, clone(update.$set || {})); return { modifiedCount: 1 }; },
async findOneAndUpdate(filter, update, options) { let document = documents.find((item) => matches(item, filter)); let upserted = false; if (!document && options?.upsert) { document = { ...clone(filter), ...clone(update.$setOnInsert || {}) }; documents.push(document); upserted = true; } if (!document) return null; Object.assign(document, clone(update.$set || {})); const value = clone(document); return options?.includeResultMetadata ? { value, lastErrorObject: upserted ? { upserted: document.id } : {} } : value; }, async findOneAndUpdate(filter, update, options) { let document = documents.find((item) => matches(item, filter)); let upserted = false; if (!document && options?.upsert) { document = { ...clone(filter), ...clone(update.$setOnInsert || {}) }; documents.push(document); upserted = true; } if (!document) return null; Object.assign(document, clone(update.$set || {})); const value = clone(document); return options?.includeResultMetadata ? { value, lastErrorObject: upserted ? { upserted: document.id } : {} } : value; },
async deleteOne(filter) { const index = documents.findIndex((item) => matches(item, filter)); if (index < 0) return { deletedCount: 0 }; documents.splice(index, 1); return { deletedCount: 1 }; }, async deleteOne(filter) { const index = documents.findIndex((item) => matches(item, filter)); if (index < 0) return { deletedCount: 0 }; documents.splice(index, 1); return { deletedCount: 1 }; },
async deleteMany(filter) { let deletedCount = 0; for (let index = documents.length - 1; index >= 0; index -= 1) if (matches(documents[index], filter)) { documents.splice(index, 1); deletedCount += 1; } return { deletedCount }; }, async deleteMany(filter) { let deletedCount = 0; for (let index = documents.length - 1; index >= 0; index -= 1) if (matches(documents[index], filter)) { documents.splice(index, 1); deletedCount += 1; } return { deletedCount }; },
}; };
} }
function memoryStore() { return { users: collection(), sessions: collection(), teams: collection(), teamMembers: collection(), plays: collection(), async close() {} }; } function memoryStore() { return { users: collection([['email']]), sessions: collection([['tokenHash']]), teams: collection(), teamMembers: collection([['teamId', 'userId']]), joinRequests: collection([['teamId', 'userId']]), plays: collection([['teamId', 'id']]), async close() {} }; }
function testConfig() { return { mongodb: { uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' }, server: { host: '0.0.0.0', port: 3000, mode: 'development', allowedOrigins: ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://192.168.5.10:5173'], secureCookie: false }, bootstrap: { password: '' }, qa: { db: 'basket_utils_qa', password: '' } }; } function testConfig() { return { mongodb: { uri: 'mongodb://172.16.0.7:27017', db: 'basket_utils' }, server: { host: '0.0.0.0', port: 3000, mode: 'development', allowedOrigins: ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://192.168.5.10:5173'], secureCookie: false }, bootstrap: { password: '' }, qa: { db: 'basket_utils_qa', password: '' } }; }
async function seedUser(store, { id, email, displayName, isOperator = false, status = 'approved', password = 'correct horse battery staple' }) { async function seedUser(store, { id, email, displayName, isOperator = false, status = 'approved', password = 'correct horse battery staple' }) {
const at = new Date(); await store.users.insertOne({ id, email, displayName, passwordHash: await hashPassword(password), status, isOperator, createdAt: at, updatedAt: at }); return { id, email, password }; const at = new Date(); await store.users.insertOne({ id, email, displayName, passwordHash: await hashPassword(password), status, isOperator, createdAt: at, updatedAt: at }); return { id, email, password };
@@ -87,6 +88,52 @@ describe('Fastify Mongo contract', () => {
expect((await app.inject({ method: 'POST', url: '/api/admin/users/viewer/suspend', headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200); expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: viewer.cookie } })).statusCode).toBe(401); expect((await app.inject({ method: 'POST', url: '/api/admin/users/viewer/suspend', headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200); expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: viewer.cookie } })).statusCode).toBe(401);
}); });
it('completes registration, operator approval, join approval, and role changes', async () => {
const store = memoryStore(); await seedUser(store, { id: 'owner', email: 'owner@example.com', displayName: 'Owner' }); await seedUser(store, { id: 'operator', email: 'operator@example.com', displayName: 'Operator', isOperator: true });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false });
const registered = await app.inject({ method: 'POST', url: '/api/auth/register', payload: { email: 'new@example.com', password: 'password123', displayName: 'New User' } }); expect(registered.statusCode).toBe(201); expect(registered.json().user.status).toBe('pending');
const operator = await login(app, 'operator@example.com', 'correct horse battery staple'); const userId = registered.json().user.id;
expect((await app.inject({ method: 'POST', url: `/api/admin/users/${userId}/approve`, headers: { cookie: operator.cookie }, payload: {} })).statusCode).toBe(200);
const owner = await login(app, 'owner@example.com', 'correct horse battery staple'); const team = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: owner.cookie }, payload: { name: 'North [A]' } })).json().team;
const applicant = await login(app, 'new@example.com', 'password123'); const search = await app.inject({ method: 'GET', url: '/api/teams/search?q=North%20%5BA%5D', headers: { cookie: applicant.cookie } }); expect(search.statusCode).toBe(200); expect(search.json().teams).toEqual([{ id: team.id, name: team.name }]);
const request = await app.inject({ method: 'POST', url: `/api/teams/${team.id}/join-requests`, headers: { cookie: applicant.cookie }, payload: {} }); expect(request.statusCode).toBe(201); const requestId = request.json().request.id;
const repeatedRequest = await app.inject({ method: 'POST', url: `/api/teams/${team.id}/join-requests`, headers: { cookie: applicant.cookie }, payload: {} }); expect(repeatedRequest.statusCode).toBe(200); expect(repeatedRequest.json().request.id).toBe(requestId);
const ownPendingRequests = (await app.inject({ method: 'GET', url: '/api/team-requests', headers: { cookie: applicant.cookie } })).json().requests; expect(ownPendingRequests[0].status).toBe('pending'); expect(ownPendingRequests[0].teamName).toBe('North [A]');
expect((await app.inject({ method: 'GET', url: `/api/teams/${team.id}/join-requests`, headers: { cookie: owner.cookie } })).json().requests).toHaveLength(1);
const approved = await app.inject({ method: 'POST', url: `/api/teams/${team.id}/join-requests/${requestId}/approve`, headers: { cookie: owner.cookie }, payload: { role: 'editor' } }); expect(approved.statusCode).toBe(200); expect(approved.json().member.role).toBe('editor');
const repeatedApproval = await app.inject({ method: 'POST', url: `/api/teams/${team.id}/join-requests/${requestId}/approve`, headers: { cookie: owner.cookie }, payload: { role: 'viewer' } }); expect(repeatedApproval.statusCode).toBe(200); expect(repeatedApproval.json().member.role).toBe('editor'); expect(store.joinRequests.documents.find((row) => row.id === requestId).role).toBe('editor');
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${team.id}/members/${userId}`, headers: { cookie: applicant.cookie }, payload: { role: 'editor' } })).statusCode).toBe(403);
const editorPlay = createInitialPlay('Editor play'); expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${editorPlay.id}`, headers: { cookie: applicant.cookie }, payload: { play: editorPlay } })).statusCode).toBe(201);
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${team.id}/members/${userId}`, headers: { cookie: owner.cookie }, payload: { role: 'viewer' } })).statusCode).toBe(200);
expect((await app.inject({ method: 'GET', url: '/api/teams', headers: { cookie: applicant.cookie } })).json().teams.map((entry) => entry.id)).toContain(team.id);
expect((await app.inject({ method: 'GET', url: `/api/teams/${team.id}/members`, headers: { cookie: owner.cookie } })).json().members.map((entry) => entry.user.id)).toContain(userId);
const ownApprovedRequests = (await app.inject({ method: 'GET', url: '/api/team-requests', headers: { cookie: applicant.cookie } })).json().requests; expect(ownApprovedRequests[0].status).toBe('approved'); expect(ownApprovedRequests[0].teamName).toBe('North [A]');
expect((await app.inject({ method: 'PUT', url: `/api/teams/${team.id}/plays/${editorPlay.id}`, headers: { cookie: applicant.cookie }, payload: { play: editorPlay } })).statusCode).toBe(403);
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${team.id}/members/${userId}`, headers: { cookie: owner.cookie }, payload: { role: 'owner' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${team.id}/members/${userId}`, headers: { cookie: owner.cookie }, payload: { role: 'bad' } })).statusCode).toBe(400);
});
it('keeps teams isolated and refuses suspended applicants and nonmembers', async () => {
const store = memoryStore(); await seedUser(store, { id: 'owner', email: 'owner@example.com', displayName: 'Owner' }); await seedUser(store, { id: 'applicant', email: 'applicant@example.com', displayName: 'Applicant' }); await seedUser(store, { id: 'viewer', email: 'viewer@example.com', displayName: 'Viewer' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const owner = await login(app, 'owner@example.com', 'correct horse battery staple'); const applicant = await login(app, 'applicant@example.com', 'correct horse battery staple'); const viewer = await login(app, 'viewer@example.com', 'correct horse battery staple');
const first = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: owner.cookie }, payload: { name: 'First Team' } })).json().team; const second = (await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: owner.cookie }, payload: { name: 'Second Team' } })).json().team;
const request = await app.inject({ method: 'POST', url: `/api/teams/${first.id}/join-requests`, headers: { cookie: applicant.cookie }, payload: {} }); const requestId = request.json().request.id;
await store.users.updateOne({ id: 'applicant' }, { $set: { status: 'suspended' } }); expect((await app.inject({ method: 'POST', url: `/api/teams/${first.id}/join-requests/${requestId}/approve`, headers: { cookie: owner.cookie }, payload: {} })).statusCode).toBe(409);
expect((await app.inject({ method: 'GET', url: `/api/teams/${first.id}`, headers: { cookie: viewer.cookie } })).statusCode).toBe(404);
expect((await app.inject({ method: 'PATCH', url: `/api/teams/${first.id}/members/owner`, headers: { cookie: owner.cookie }, payload: { role: 'viewer' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'GET', url: `/api/teams/${second.id}/members`, headers: { cookie: viewer.cookie } })).statusCode).toBe(404);
});
it('caps team search and preserves the inserted role on concurrent approval', async () => {
const store = memoryStore(); await seedUser(store, { id: 'owner', email: 'owner@example.com', displayName: 'Owner' }); await seedUser(store, { id: 'applicant', email: 'applicant@example.com', displayName: 'Applicant' });
app = await buildServer({ config: testConfig(), db: store, serveStatic: false, secureCookie: false }); const owner = await login(app, 'owner@example.com', 'correct horse battery staple'); const applicant = await login(app, 'applicant@example.com', 'correct horse battery staple');
const teams = []; for (let index = 0; index < 21; index += 1) teams.push((await app.inject({ method: 'POST', url: '/api/teams', headers: { cookie: owner.cookie }, payload: { name: `Cap Team ${String(index).padStart(2, '0')}` } })).json().team);
expect((await app.inject({ method: 'GET', url: '/api/teams/search?q=Cap', headers: { cookie: applicant.cookie } })).json().teams).toHaveLength(20);
const request = await app.inject({ method: 'POST', url: `/api/teams/${teams[0].id}/join-requests`, headers: { cookie: applicant.cookie }, payload: {} }); const requestId = request.json().request.id;
const approvals = await Promise.all(['viewer', 'editor'].map((role) => app.inject({ method: 'POST', url: `/api/teams/${teams[0].id}/join-requests/${requestId}/approve`, headers: { cookie: owner.cookie }, payload: { role } })));
expect(approvals.every((response) => response.statusCode === 200)).toBe(true); const member = store.teamMembers.documents.find((row) => row.teamId === teams[0].id && row.userId === 'applicant'); expect(['viewer', 'editor']).toContain(member.role); expect(store.joinRequests.documents.find((row) => row.id === requestId).role).toBe(member.role);
});
it('does not expose config files through the static handler', async () => { it('does not expose config files through the static handler', async () => {
const staticRoot = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-static-')); temporaryRoots.push(staticRoot); const staticRoot = await mkdtemp(path.join(os.tmpdir(), 'basket-utils-static-')); temporaryRoots.push(staticRoot);
await writeFile(path.join(staticRoot, 'index.html'), 'safe'); await writeFile(path.join(staticRoot, '.config.json'), '{"bootstrap":{"password":"static-secret"}}'); await writeFile(path.join(staticRoot, 'index.html'), 'safe'); await writeFile(path.join(staticRoot, '.config.json'), '{"bootstrap":{"password":"static-secret"}}');
+5 -1
View File
@@ -15,6 +15,7 @@ export async function createMongoStore(options = {}) {
const sessions = database.collection('sessions'); const sessions = database.collection('sessions');
const teams = database.collection('teams'); const teams = database.collection('teams');
const teamMembers = database.collection('team_members'); const teamMembers = database.collection('team_members');
const joinRequests = database.collection('team_join_requests');
const plays = database.collection('plays'); const plays = database.collection('plays');
try { try {
await Promise.all([ await Promise.all([
@@ -23,6 +24,9 @@ export async function createMongoStore(options = {}) {
sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }), sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
teamMembers.createIndex({ teamId: 1, userId: 1 }, { unique: true }), teamMembers.createIndex({ teamId: 1, userId: 1 }, { unique: true }),
teamMembers.createIndex({ userId: 1 }), teamMembers.createIndex({ userId: 1 }),
joinRequests.createIndex({ teamId: 1, userId: 1 }, { unique: true }),
joinRequests.createIndex({ teamId: 1, status: 1, createdAt: 1 }),
joinRequests.createIndex({ userId: 1, updatedAt: -1 }),
plays.createIndex({ teamId: 1, id: 1 }, { unique: true }), plays.createIndex({ teamId: 1, id: 1 }, { unique: true }),
plays.createIndex({ teamId: 1, updatedAt: -1 }), plays.createIndex({ teamId: 1, updatedAt: -1 }),
]); ]);
@@ -31,7 +35,7 @@ export async function createMongoStore(options = {}) {
throw error; throw error;
} }
return { return {
client, database, users, sessions, teams, teamMembers, plays, client, database, users, sessions, teams, teamMembers, joinRequests, plays,
async close() { if (!options.client) await client.close(); }, async close() { if (!options.client) await client.close(); },
}; };
} }
+7
View File
@@ -38,5 +38,12 @@ export async function registerAccount(email, password, displayName) { return api
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 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 listJoinRequests() { return apiRequest('/api/team-requests'); }
export async function requestTeamJoin(teamId) { return apiRequest(`/api/teams/${encodeURIComponent(teamId)}/join-requests`, { method: 'POST', body: '{}' }); }
export async function listTeamMembers(teamId) { return apiRequest(`/api/teams/${encodeURIComponent(teamId)}/members`); }
export async function listTeamJoinRequests(teamId) { return apiRequest(`/api/teams/${encodeURIComponent(teamId)}/join-requests`); }
export async function approveTeamJoin(teamId, requestId, role = 'viewer') { return apiRequest(`/api/teams/${encodeURIComponent(teamId)}/join-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: JSON.stringify({ role }) }); }
export async function changeTeamMemberRole(teamId, userId, role) { return apiRequest(`/api/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(userId)}`, { method: 'PATCH', body: JSON.stringify({ role }) }); }
export async function listPendingUsers() { return apiRequest('/api/admin/users?status=pending'); } export async function listPendingUsers() { return apiRequest('/api/admin/users?status=pending'); }
export async function approveUser(userId) { return apiRequest(`/api/admin/users/${encodeURIComponent(userId)}/approve`, { method: 'POST', body: '{}' }); } export async function approveUser(userId) { return apiRequest(`/api/admin/users/${encodeURIComponent(userId)}/approve`, { method: 'POST', body: '{}' }); }
+16 -1
View File
@@ -4,6 +4,8 @@ export const POV_EYE_HEIGHT = 1.75;
export const HORIZONTAL_FOV = 110; export const HORIZONTAL_FOV = 110;
export const POV_VERTICAL_FOV_MAX = 100; export const POV_VERTICAL_FOV_MAX = 100;
export const POV_VERTICAL_OFFSET = 0.08; export const POV_VERTICAL_OFFSET = 0.08;
export const FOCUS_CAMERA_WIDTH = 15.6;
export const FOCUS_CAMERA_HEIGHT = 14.6;
export function verticalFov(horizontalFov, aspect) { return THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(horizontalFov) / 2) / aspect)); } export function verticalFov(horizontalFov, aspect) { return THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(horizontalFov) / 2) / aspect)); }
export function povFov(aspect) { return Math.min(POV_VERTICAL_FOV_MAX, verticalFov(HORIZONTAL_FOV, Math.max(0.1, aspect))); } export function povFov(aspect) { return Math.min(POV_VERTICAL_FOV_MAX, verticalFov(HORIZONTAL_FOV, Math.max(0.1, aspect))); }
@@ -19,10 +21,13 @@ export function createCameras(aspect = 1) {
const tactical = new THREE.PerspectiveCamera(42, aspect, 0.1, 100); const tactical = new THREE.PerspectiveCamera(42, aspect, 0.1, 100);
tactical.position.set(0, 20, 8); tactical.position.set(0, 20, 8);
tactical.lookAt(0, 0, 7); tactical.lookAt(0, 0, 7);
const tacticalFocus = new THREE.OrthographicCamera(-FOCUS_CAMERA_WIDTH / 2, FOCUS_CAMERA_WIDTH / 2, FOCUS_CAMERA_HEIGHT / 2, -FOCUS_CAMERA_HEIGHT / 2, 0.1, 100);
tacticalFocus.position.set(0, 28, 7);
tacticalFocus.lookAt(0, 0, 7);
const pov = new THREE.PerspectiveCamera(povFov(aspect), aspect, 0.05, 100); const pov = new THREE.PerspectiveCamera(povFov(aspect), aspect, 0.05, 100);
pov.position.set(0, POV_EYE_HEIGHT, 0); pov.position.set(0, POV_EYE_HEIGHT, 0);
applyPovProjection(pov, aspect); applyPovProjection(pov, aspect);
return { tactical, pov }; return { tactical, tacticalFocus, pov };
} }
export function resizeCameras(cameras, aspect) { export function resizeCameras(cameras, aspect) {
@@ -31,6 +36,16 @@ export function resizeCameras(cameras, aspect) {
cameras.tactical.far = Math.max(100, cameras.tactical.position.y + 40); cameras.tactical.far = Math.max(100, cameras.tactical.position.y + 40);
cameras.tactical.lookAt(0, 0, 7); cameras.tactical.lookAt(0, 0, 7);
cameras.tactical.updateProjectionMatrix(); cameras.tactical.updateProjectionMatrix();
// Focus mode intentionally keeps a fixed world frustum. The fullscreen
// center viewport may stretch it horizontally to use all space between HUDs;
// regular tactical, POV, and export cameras keep their aspect-aware paths.
cameras.tacticalFocus.left = -FOCUS_CAMERA_WIDTH / 2;
cameras.tacticalFocus.right = FOCUS_CAMERA_WIDTH / 2;
cameras.tacticalFocus.top = FOCUS_CAMERA_HEIGHT / 2;
cameras.tacticalFocus.bottom = -FOCUS_CAMERA_HEIGHT / 2;
cameras.tacticalFocus.position.set(0, 28, 7);
cameras.tacticalFocus.lookAt(0, 0, 7);
cameras.tacticalFocus.updateProjectionMatrix();
applyPovProjection(cameras.pov, aspect); applyPovProjection(cameras.pov, aspect);
} }
+22 -2
View File
@@ -91,6 +91,17 @@ function relinkFollowingStarts(play, playerId, firstSequenceIndex) {
if (track) track.startLocation = copyLocation(previousTrack?.actions.at(-1)?.location || previousTrack?.startLocation || play.players.find((player) => player.id === playerId)?.location); if (track) track.startLocation = copyLocation(previousTrack?.actions.at(-1)?.location || previousTrack?.startLocation || play.players.find((player) => player.id === playerId)?.location);
} }
} }
function relinkBoardStarts(play, playerId) {
const first = play.sequences[0]?.tracks.find((candidate) => candidate.playerId === playerId);
if (first) for (const action of first.actions) { if (!['pass', 'shoot'].includes(action.type)) break; action.location = copyLocation(first.startLocation); }
for (let index = 1; index < play.sequences.length; index += 1) {
const previous = play.sequences[index - 1].tracks.find((candidate) => candidate.playerId === playerId);
const track = play.sequences[index].tracks.find((candidate) => candidate.playerId === playerId);
if (!track) continue;
track.startLocation = copyLocation(previous?.actions.at(-1)?.location || previous?.startLocation || play.players.find((player) => player.id === playerId)?.location);
for (const action of track.actions) { if (!['pass', 'shoot'].includes(action.type)) break; action.location = copyLocation(track.startLocation); }
}
}
export function trackActionSnapshot(play, sequenceId, playerId) { export function trackActionSnapshot(play, sequenceId, playerId) {
const sequence = play?.sequences?.find((candidate) => candidate.id === sequenceId); const track = sequence?.tracks?.find((candidate) => candidate.playerId === playerId); const sequence = play?.sequences?.find((candidate) => candidate.id === sequenceId); const track = sequence?.tracks?.find((candidate) => candidate.playerId === playerId);
@@ -131,14 +142,23 @@ export function restoreTrackActionSnapshot(play, snapshot) {
} }
export function setPlayerStartLocation(play, playerId, location) { export function setPlayerStartLocation(play, playerId, location) {
if (hasShoot(play)) return play;
const next = structuredClone(play); const next = structuredClone(play);
const player = next.players.find((candidate) => candidate.id === playerId); const player = next.players.find((candidate) => candidate.id === playerId);
if (!player) return next; if (!player) return next;
player.location = snapLocation(location); player.location = snapLocation(location);
const firstTrack = next.sequences[0]?.tracks.find((candidate) => candidate.playerId === playerId); const firstTrack = next.sequences[0]?.tracks.find((candidate) => candidate.playerId === playerId);
if (firstTrack) firstTrack.startLocation = copyLocation(player.location); if (firstTrack) firstTrack.startLocation = copyLocation(player.location);
relinkFollowingStarts(next, playerId, 1); // A board pose edit is allowed after a shot has been recorded. Keep the
// stationary shot (and any stationary event immediately after this pose)
// attached to the new start location so shoot invariants remain valid.
if (firstTrack) {
for (const action of firstTrack.actions) {
if (!['pass', 'shoot'].includes(action.type)) break;
action.location = copyLocation(player.location);
}
}
relinkBoardStarts(next, playerId);
if (!shootInvariantsValid(next)) return play;
return next; return next;
} }
+39 -3
View File
@@ -2,11 +2,35 @@ import { describe, expect, it } from 'vitest';
import { addAction, addPassAction, addScreenAction, addShootAction, addSequence, createInitialPlay, DEFENSE_REACTION_DELAY, distance, finalBallOwner, hasShoot, normalizeLookAt, offensePhaseDuration, PASS_DURATION, removeAction, resolveFacing, resolveLookAt, restoreTrackActionSnapshot, SCREEN_HOLD, scheduledTrackDuration, sequenceDuration, setPlayerStartLocation, setSequenceBallOwner, shortestAngleLerp, SHOOT_DURATION, snapLocation, trackActionSnapshot, trackNaturalDuration, trackDuration } from './domain.js'; import { addAction, addPassAction, addScreenAction, addShootAction, addSequence, createInitialPlay, DEFENSE_REACTION_DELAY, distance, finalBallOwner, hasShoot, normalizeLookAt, offensePhaseDuration, PASS_DURATION, removeAction, resolveFacing, resolveLookAt, restoreTrackActionSnapshot, SCREEN_HOLD, scheduledTrackDuration, sequenceDuration, setPlayerStartLocation, setSequenceBallOwner, shortestAngleLerp, SHOOT_DURATION, snapLocation, trackActionSnapshot, trackNaturalDuration, trackDuration } from './domain.js';
import { createPlaybackController, sampleEditingSequence, samplePlay, sampleSequence, selectRenderSample } from './playback.js'; import { createPlaybackController, sampleEditingSequence, samplePlay, sampleSequence, selectRenderSample } from './playback.js';
import { HORIZONTAL_FOV, POV_EYE_HEIGHT, createCameras, updatePovCamera } from './cameras.js'; import { HORIZONTAL_FOV, POV_EYE_HEIGHT, createCameras, updatePovCamera } from './cameras.js';
import { addMoveAction, addPlaySequence, canRedoActionEdit, canUndoActionEdit, commitActionEdit, createAppState, deletePlaySequence, deleteSelectedAction, redoActionEdit, setSelectedPlayerStart, undoActionEdit } from './state.js'; import { addMoveAction, addPlaySequence, canRedoActionEdit, canUndoActionEdit, commitActionEdit, createAppState, createAppStateFromPlay, deletePlaySequence, deleteSelectedAction, redoActionEdit, serializePlay, setSelectedPlayerStart, undoActionEdit } from './state.js';
import { passArrowPoints, passArrowVisualGeometry, pathSignature, possessionVisualState, screenMarkerState } from './scene.js'; import { passArrowPoints, passArrowVisualGeometry, pathSignature, possessionVisualState, screenMarkerState } from './scene.js';
import * as THREE from 'three'; import * as THREE from 'three';
import { assertValidPlay } from './playRepository.js';
describe('전술 데이터 모델', () => { describe('전술 데이터 모델', () => {
it('보드 시작 위치 변경은 슛이 있는 저장 전술에서도 행동과 시퀀스를 보존한다', () => {
let play = createInitialPlay();
play = addShootAction(play, 0, 'offense-1');
const beforeActions = JSON.stringify(play.sequences.map((sequence) => sequence.tracks.map((track) => track.actions)));
const next = setPlayerStartLocation(play, 'offense-1', { x: 2.24, z: 4.26 });
expect(next).not.toBe(play);
expect(next.sequences).toHaveLength(1);
expect(JSON.stringify(next.sequences.map((sequence) => sequence.tracks.map((track) => track.actions)))).not.toBe(beforeActions);
expect(next.sequences[0].tracks[0].actions.at(-1).type).toBe('shoot');
expect(next.sequences[0].tracks[0].actions.at(-1).location).toEqual({ x: 2, z: 4.5 });
});
it('다중 시퀀스의 명시 이동과 마지막 슛은 보드 포즈 변경 후에도 유효하다', () => {
let play = addAction(createInitialPlay(), 0, 'offense-1', { x: 1, z: 4 });
play = addSequence(play);
play = addAction(play, 1, 'offense-1', { x: 2, z: 6 });
play = addShootAction(play, 1, 'offense-1');
const next = setPlayerStartLocation(play, 'offense-1', { x: 3.1, z: 3.1 });
assertValidPlay(next);
expect(next.sequences[1].tracks[0].actions[0].location).toEqual({ x: 2, z: 6 });
expect(next.sequences[1].tracks[0].actions.at(-1).location).toEqual({ x: 2, z: 6 });
});
it.each(['man-to-man', '2-3', '3-2'])('수비 형태 %s가 10명과 Sequence 1을 만든다', (defenseType) => { it.each(['man-to-man', '2-3', '3-2'])('수비 형태 %s가 10명과 Sequence 1을 만든다', (defenseType) => {
const play = createInitialPlay('테스트', defenseType); const play = createInitialPlay('테스트', defenseType);
expect(play.players).toHaveLength(10); expect(play.players).toHaveLength(10);
@@ -22,9 +46,21 @@ describe('전술 데이터 모델', () => {
expect(play.sequences[0].tracks[0].actions[1].type).toBe('move'); expect(play.sequences[0].tracks[0].actions[1].type).toBe('move');
}); });
it('새 전술은 Starting 단계에서 시작 위치만 변경하고 Action을 만들지 않는다', () => { it('이동 모드의 연속 경로와 저장 후 재오픈을 보존한다', () => {
let state = createAppState(); let state = createAppState();
expect(state.mode).toBe('start'); expect(state.mode).toBe('move');
state = addMoveAction(state, { x: 1, z: 3 });
state = addMoveAction({ ...state, selectedAction: -1 }, { x: 2, z: 5 });
const actions = state.play.sequences[0].tracks[0].actions;
expect(actions).toHaveLength(2);
const reopened = createAppStateFromPlay(JSON.parse(serializePlay(state.play)));
expect(reopened.mode).toBe('move');
expect(reopened.play.sequences[0].tracks[0].actions.map((action) => action.location)).toEqual([{ x: 1, z: 3 }, { x: 2, z: 5 }]);
});
it('새 전술은 이동 모드로 열리고 명시적 배치 변경은 Action을 만들지 않는다', () => {
let state = createAppState();
expect(state.mode).toBe('move');
state = setSelectedPlayerStart(state, { x: 2.24, z: 4.26 }); state = setSelectedPlayerStart(state, { x: 2.24, z: 4.26 });
const player = state.play.players.find((candidate) => candidate.id === 'offense-1'); const player = state.play.players.find((candidate) => candidate.id === 'offense-1');
const track = state.play.sequences[0].tracks.find((candidate) => candidate.playerId === 'offense-1'); const track = state.play.sequences[0].tracks.find((candidate) => candidate.playerId === 'offense-1');
+11 -1
View File
@@ -5,7 +5,7 @@ import { createEditorHistory, editAction } from './editor.js';
import { createAppState } from './state.js'; import { createAppState } from './state.js';
import { isValidPlay } from './playRepository.js'; import { isValidPlay } from './playRepository.js';
import { createPlaybackController, sampleSequence, totalDuration } from './playback.js'; import { createPlaybackController, sampleSequence, totalDuration } from './playback.js';
import { createCameras, resizeCameras, updatePovCamera, POV_EYE_HEIGHT } from './cameras.js'; import { createCameras, resizeCameras, updatePovCamera, POV_EYE_HEIGHT, FOCUS_CAMERA_WIDTH, FOCUS_CAMERA_HEIGHT } from './cameras.js';
import { povLabelScaleY } from './scene.js'; import { povLabelScaleY } from './scene.js';
describe('action editor and recovery', () => { describe('action editor and recovery', () => {
@@ -83,6 +83,16 @@ describe('responsive POV and camera stability', () => {
const projected = new THREE.Vector3(7.5, 0, 7).project(cameras.tactical); const projected = new THREE.Vector3(7.5, 0, 7).project(cameras.tactical);
expect(Math.abs(projected.x)).toBeLessThan(1); expect(Math.abs(projected.x)).toBeLessThan(1);
}); });
it('keeps the fullscreen focus frustum fixed while regular tactical follows aspect', () => {
const portrait = createCameras(0.5); const wide = createCameras(2);
resizeCameras(portrait, 0.5); resizeCameras(wide, 2);
for (const camera of [portrait.tacticalFocus, wide.tacticalFocus]) {
expect(camera.right - camera.left).toBeCloseTo(FOCUS_CAMERA_WIDTH);
expect(camera.top - camera.bottom).toBeCloseTo(FOCUS_CAMERA_HEIGHT);
}
expect(portrait.tactical.aspect).toBeCloseTo(0.5);
expect(wide.tactical.aspect).toBeCloseTo(2);
});
it('POV keeps the eye height and action target inside the viewport on desktop and portrait layouts', () => { it('POV keeps the eye height and action target inside the viewport on desktop and portrait layouts', () => {
for (const aspect of [2, 0.5]) { for (const aspect of [2, 0.5]) {
const cameras = createCameras(aspect); const cameras = createCameras(aspect);
+287 -41
View File
@@ -8,7 +8,7 @@ import { createLocalPlayRepository } from './playRepository.js';
import { createPlayOperationCoordinator } from './playOperations.js'; 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, createServerPlayRepository, createTeam, getCurrentUser, listPendingUsers, listTeams, loginAccount, logoutAccount, registerAccount } from './api.js'; import { ApiError, approveUser, approveTeamJoin, changeTeamMemberRole, createServerPlayRepository, createTeam, getCurrentUser, listJoinRequests, listPendingUsers, listTeamJoinRequests, listTeamMembers, listTeams, loginAccount, logoutAccount, registerAccount, searchTeams, requestTeamJoin } from './api.js';
const app = document.querySelector('#app'); const app = document.querySelector('#app');
app.innerHTML = accountLayout() + editorLayout(); app.innerHTML = accountLayout() + editorLayout();
@@ -21,10 +21,26 @@ let playRepository = localPlayRepository;
let currentUser = null; let currentUser = null;
let currentTeam = null; let currentTeam = null;
let accountGeneration = 0; let accountGeneration = 0;
let searchGeneration = 0;
let ownerGeneration = 0;
let activeOwnerTeamId = null;
const operationCoordinator = createPlayOperationCoordinator(); const operationCoordinator = createPlayOperationCoordinator();
let state = createAppState(); let state = createAppState();
const editorHistory = createEditorHistory(); const editorHistory = createEditorHistory();
let panel = 'court'; let gazePick = null; let suppressClick = false; let drag = null; let panel = 'court'; let gazePick = null; let suppressClick = false; let drag = null; let mobileFocusActive = false; let focusPlaybackOpen = false;
let focusSession = 0; let focusOrientationToken = 0; let focusOrientationStatus = 'idle'; let fullscreenFocusRequest = 0; let focusEntryRequested = false;
let viewportSyncFrame = 0;
function syncAppViewportHeight() {
if (viewportSyncFrame) return;
viewportSyncFrame = requestAnimationFrame(() => {
viewportSyncFrame = 0;
const viewportHeight = Math.round(window.visualViewport?.height || window.innerHeight || 0);
if (!viewportHeight) return;
document.querySelector('.shell')?.style.setProperty('--app-viewport-height', `${viewportHeight}px`);
if (mobileFocusActive) board.resize();
});
}
function cancelActiveDrag() { const active = drag; drag = null; if (active && boardElement?.hasPointerCapture?.(active.pointerId)) boardElement.releasePointerCapture(active.pointerId); document.querySelector('.board-wrap')?.classList.remove('dragging'); if (state.boardPreview) state = { ...state, boardPreview: undefined }; }
document.querySelector('#play-name').value = state.play.name; document.querySelector('#play-name').value = state.play.name;
document.querySelector('#defense-type').value = state.play.defenseType; document.querySelector('#defense-type').value = state.play.defenseType;
document.querySelector('#save-status').textContent = '팀을 선택하세요'; document.querySelector('#save-status').textContent = '팀을 선택하세요';
@@ -128,13 +144,16 @@ async function sharePreparedVideo() {
} }
function showGate(view = 'auth', message = '') { function showGate(view = 'auth', message = '') {
cancelActiveDrag();
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']) document.querySelector(`#${id}`).hidden = id !== `${view}-panel`; for (const id of ['auth-panel', 'register-panel', 'pending-panel', 'team-panel', 'play-library-panel', 'admin-panel']) document.querySelector(`#${id}`).hidden = id !== `${view}-panel`;
if (view === 'auth') document.querySelector('#auth-message').textContent = message; if (view === '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 === 'admin') { document.querySelector('#admin-panel').hidden = false; document.querySelector('.shell').hidden = true; }
if (view === 'editor') gate.hidden = true; if (view === 'editor') gate.hidden = true;
} }
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)}` : ''; }
@@ -146,43 +165,91 @@ function renderTeamList(teams) {
const list = document.querySelector('#team-list'); list.replaceChildren(); const list = document.querySelector('#team-list'); list.replaceChildren();
const importSelect = document.querySelector('#local-import-team'); importSelect.replaceChildren(); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = teams.length ? '팀을 선택하세요' : '팀을 먼저 만드세요'; importSelect.append(placeholder); importSelect.value = ''; document.querySelector('#import-local-team').disabled = true; const 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) { const option = document.createElement('option'); option.value = team.id; option.textContent = team.name; importSelect.append(option); } for (const team of teams.filter((candidate) => candidate.role === 'owner' || candidate.role === 'editor')) { const option = document.createElement('option'); option.value = team.id; option.textContent = `${team.name} · ${team.role === 'owner' ? '소유자' : '편집자'}`; option.dataset.role = team.role; importSelect.append(option); }
for (const team of teams) { const button = document.createElement('button'); button.type = 'button'; button.className = 'team-card'; button.dataset.teamId = team.id; button.dataset.role = team.role; const name = document.createElement('strong'); name.textContent = team.name; const role = document.createElement('small'); role.textContent = team.role === 'owner' ? '소유자' : team.role === 'editor' ? '편집자' : '열람자'; button.append(name, role); list.append(button); } for (const team of teams) { const button = document.createElement('button'); button.type = 'button'; button.className = 'team-card'; button.dataset.teamId = team.id; button.dataset.role = team.role; const name = document.createElement('strong'); name.textContent = team.name; const role = document.createElement('small'); role.textContent = team.role === 'owner' ? '소유자' : team.role === 'editor' ? '편집자' : '열람자'; button.append(name, role); list.append(button); if (team.role === 'owner') { const manage = document.createElement('button'); manage.type = 'button'; manage.className = 'account-secondary'; manage.dataset.manageTeam = team.id; manage.dataset.teamName = team.name; manage.textContent = '팀원 관리'; list.append(manage); } }
} }
async function renderPendingUsers(generation = accountGeneration) { async function renderPendingUsers(generation = accountGeneration) {
const panel = document.querySelector('#operator-panel'); if (!currentUser?.isOperator) { panel.hidden = true; return; } const target = document.querySelector('#pending-users'); target.replaceChildren();
panel.hidden = false; const target = document.querySelector('#pending-users'); target.replaceChildren(); if (!currentUser?.isOperator) return;
try { try {
const { users } = await listPendingUsers(); if (generation !== accountGeneration || !currentUser) return; const { users } = await listPendingUsers(); if (generation !== accountGeneration || !currentUser) return;
if (!users.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '승인 대기 사용자가 없습니다.'; target.append(empty); return; } if (!users.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '승인 대기 사용자가 없습니다.'; target.append(empty); return; }
for (const user of users) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${user.displayName} · ${user.email}`; const button = document.createElement('button'); button.type = 'button'; button.dataset.approveUser = user.id; button.textContent = '승인'; row.append(label, button); target.append(row); } for (const user of users) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${user.displayName} · ${user.email}`; const button = document.createElement('button'); button.type = 'button'; button.dataset.approveUser = user.id; button.textContent = '승인'; row.append(label, button); target.append(row); }
} catch (error) { const message = document.createElement('p'); message.className = 'account-empty'; message.textContent = error.message; target.append(message); } } catch (error) { const message = document.createElement('p'); message.className = 'account-empty'; message.textContent = error.message; target.append(message); }
} }
async function openAdmin(generation = accountGeneration, navigate = true) {
if (!currentUser?.isOperator) { showGate('auth', '운영자 권한이 필요한 페이지입니다.'); if (window.location.pathname === '/admin') history.replaceState({ view: 'auth' }, '', '/'); return; }
operationCoordinator.beginIntent(); videoJob?.cancel(); controller.pause(); searchGeneration += 1; ownerGeneration += 1; activeOwnerTeamId = null;
showGate('admin'); if (navigate && window.location.pathname !== '/admin') history.pushState({ view: 'admin' }, '', '/admin');
await renderPendingUsers(generation);
}
async function searchAndRenderTeams(query) {
const target = document.querySelector('#team-search-results'); target.replaceChildren(); document.querySelector('#join-status').textContent = '';
const generation = ++searchGeneration;
if (query.trim().length < 2) return;
try { const [{ teams }, { requests: joinRequests }, { teams: memberships }] = await Promise.all([searchTeams(query), listJoinRequests(), listTeams()]); if (generation !== searchGeneration || !currentUser) return; const byTeam = new Map(joinRequests.map((request) => [request.teamId, request])); const memberByTeam = new Map(memberships.map((team) => [team.id, team])); for (const team of teams) { const row = document.createElement('div'); row.className = 'team-card search-team-row'; const name = document.createElement('strong'); name.textContent = team.name; const button = document.createElement('button'); button.type = 'button'; button.dataset.joinTeam = team.id; const membership = memberByTeam.get(team.id); const request = byTeam.get(team.id); if (membership) { button.textContent = '가입된 팀'; button.disabled = true; } else if (request?.status === 'pending') { button.textContent = '승인 대기 중'; button.disabled = true; } else if (request?.status === 'approved') { button.textContent = '가입 완료'; button.disabled = true; } else button.textContent = '가입 요청'; row.append(name, button); target.append(row); } renderJoinRequests(joinRequests); if (!teams.length) document.querySelector('#join-status').textContent = '검색된 팀이 없습니다.'; }
catch (error) { if (generation === searchGeneration) document.querySelector('#join-status').textContent = error.message; }
}
function renderJoinRequests(requests) { const target = document.querySelector('#my-join-requests'); target.replaceChildren(); if (!requests.length) return; const heading = document.createElement('strong'); heading.textContent = '내 가입 요청'; target.append(heading); for (const request of requests) { const row = document.createElement('p'); row.className = 'join-request-row'; row.textContent = `${request.teamName || '알 수 없는 팀'} · ${request.status === 'approved' ? '승인됨' : '승인 대기'}`; target.append(row); } }
function setOwnerView(active) { for (const selector of ['#refresh-teams', '#team-form', '.local-import', '#open-admin']) { const element = document.querySelector(selector); if (element) element.hidden = active; } }
async function openOwnerPanel(team) { const generation = ++ownerGeneration; activeOwnerTeamId = team.id; document.querySelector('#team-panel').hidden = false; document.querySelector('#owner-panel').hidden = false; document.querySelector('#membership-panel').hidden = true; document.querySelector('#team-list').hidden = true; document.querySelector('#owner-team-name').textContent = team.name ? `${team.name}` : '팀원 관리'; const requestsTarget = document.querySelector('#owner-requests'); const membersTarget = document.querySelector('#owner-members'); requestsTarget.replaceChildren(); membersTarget.replaceChildren(); document.querySelector('#team-message').textContent = '팀원 정보를 불러오는 중…'; try { const [{ requests }, { members }] = await Promise.all([listTeamJoinRequests(team.id), listTeamMembers(team.id)]); if (generation !== ownerGeneration || !currentUser || activeOwnerTeamId !== team.id) return; renderOwnerRows(team, requests, members); } catch (error) { if (generation === ownerGeneration) document.querySelector('#team-message').textContent = error.message; } }
function roleSelect(role, dataset) { const select = document.createElement('select'); select.setAttribute('aria-label', '팀원 역할'); for (const [value, label] of [['viewer', '열람자'], ['editor', '편집자']]) { const option = document.createElement('option'); option.value = value; option.textContent = label; select.append(option); } select.value = role; select.dataset.previousRole = role; Object.assign(select.dataset, dataset); return select; }
function renderOwnerRows(team, requests, members) { const requestsTarget = document.querySelector('#owner-requests'); const membersTarget = document.querySelector('#owner-members'); if (!requests.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '가입 승인 대기 요청이 없습니다.'; requestsTarget.append(empty); } for (const request of requests) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${request.user.displayName} · ${request.user.email}`; const select = roleSelect('viewer', {}); const approve = document.createElement('button'); approve.type = 'button'; approve.textContent = '승인'; approve.dataset.approveJoin = request.id; approve.dataset.teamId = team.id; row.append(label, select, approve); requestsTarget.append(row); } if (!members.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = '현재 팀원이 없습니다.'; membersTarget.append(empty); } for (const member of members) { const row = document.createElement('div'); row.className = 'pending-user'; const label = document.createElement('span'); label.textContent = `${member.user.displayName} · ${member.user.email}`; row.append(label); if (member.role === 'owner') { const owner = document.createElement('small'); owner.textContent = '소유자'; row.append(owner); } else { const select = roleSelect(member.role, { memberRole: member.user.id, teamId: team.id }); const save = document.createElement('button'); save.type = 'button'; save.textContent = '역할 저장'; save.dataset.saveMemberRole = 'true'; row.append(select, save); } membersTarget.append(row); } document.querySelector('#team-message').textContent = ''; }
async function openTeamHub(message = '', generation = accountGeneration) { async function openTeamHub(message = '', generation = accountGeneration) {
showGate('teams'); document.querySelector('#team-welcome').textContent = `${currentUser?.displayName || currentUser?.email || ''}님, 사용할 팀을 선택하세요.`; document.querySelector('#team-message').textContent = message; 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();
try { const { teams } = await listTeams(); if (generation !== accountGeneration || !currentUser) return; renderTeamList(teams); await renderPendingUsers(generation); } showGate('teams'); document.querySelector('#open-admin').hidden = !currentUser?.isOperator; document.querySelector('#team-welcome').textContent = `${currentUser?.displayName || currentUser?.email || ''}님, 사용할 팀을 선택하세요.`; document.querySelector('#team-message').textContent = message;
try { const { teams } = await listTeams(); if (generation !== accountGeneration || !currentUser) return; renderTeamList(teams); const { requests } = await listJoinRequests(); if (generation === accountGeneration) renderJoinRequests(requests); }
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = `팀 목록을 불러오지 못했습니다: ${error.message}`; } catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = `팀 목록을 불러오지 못했습니다: ${error.message}`; }
} }
function renderPlayLibrary(records, draftMessage = '', viewer = false) {
const list = document.querySelector('#play-library-list'); list.replaceChildren();
if (!records.length) { const empty = document.createElement('p'); empty.className = 'account-empty'; empty.textContent = viewer ? '저장된 전술이 없습니다.' : '저장된 전술이 없습니다. 아래에서 새 전술을 시작하세요.'; list.append(empty); }
for (const record of records) { const row = document.createElement('div'); row.className = 'play-library-row'; const copy = document.createElement('div'); const name = document.createElement('strong'); name.textContent = record.name; const updated = document.createElement('small'); updated.textContent = `수정 ${new Date(record.updatedAt).toLocaleString()}`; copy.append(name, updated); const button = document.createElement('button'); button.type = 'button'; button.className = 'primary'; button.dataset.openPlay = record.id; button.textContent = '열기'; row.append(copy, button); list.append(row); }
const continueButton = document.querySelector('#continue-draft');
const hasDraft = !viewer && draftMessage === '임시 저장 복원됨'; continueButton.hidden = !hasDraft; continueButton.textContent = hasDraft ? `임시 저장 계속하기 · ${state.play.name}` : '임시 저장 계속하기';
}
async function openTeamLibrary(team, message = '') {
if (!team?.id || !currentUser) return;
if (!document.querySelector('.shell')?.hidden && currentTeam?.id === team.id) saveDraft();
document.querySelector('#continue-draft').hidden = true;
document.querySelector('#new-play-form')?.reset();
const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentTeam = team; playRepository = createServerPlayRepository(team.id); editorHistory.clear(); state = createAppState(); const draftMessage = team.role === 'viewer' ? '저장 전' : restoreTeamDraft(); controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false;
document.querySelector('#play-library-title').textContent = `${team.name} · 전술 목록`; document.querySelector('#play-library-welcome').textContent = team.role === 'viewer' ? '저장된 전술을 열람할 수 있습니다.' : '저장된 전술을 열거나 새 전술을 시작하세요.'; document.querySelector('#play-library-status').textContent = '저장된 전술을 불러오는 중…'; document.querySelector('#play-library-message').textContent = message; document.querySelector('#library-new-play').disabled = team.role === 'viewer'; document.querySelector('#play-library-list').replaceChildren(); showGate('library');
try { const records = await playRepository.list(); if (generation !== accountGeneration || currentTeam?.id !== team.id || !currentUser) return; renderPlayLibrary(records, draftMessage, team.role === 'viewer'); document.querySelector('#play-library-status').textContent = ''; } catch (error) { if (generation !== accountGeneration || currentTeam?.id !== team.id) return; document.querySelector('#play-library-status').textContent = `저장 목록을 불러오지 못했습니다: ${error.message}`; const retry = document.createElement('button'); retry.type = 'button'; retry.className = 'account-secondary'; retry.id = 'retry-play-library'; retry.textContent = '다시 시도'; document.querySelector('#play-library-list').append(retry); }
}
async function selectTeam(team) { async function selectTeam(team) {
if (!team?.id || !currentUser) return; if (!team?.id || !currentUser) return;
const generation = ++accountGeneration; operationCoordinator.beginIntent(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentTeam = team; playRepository = createServerPlayRepository(team.id); editorHistory.clear(); state = createAppState(); const draftMessage = restoreTeamDraft(); await openTeamLibrary(team);
controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType; document.querySelector('#team-context').textContent = team.name; document.querySelector('#save-status').textContent = draftMessage; showGate('editor'); renderUi(); }
try { await refreshSavedPlays(); if (generation !== accountGeneration || currentTeam?.id !== team.id) return; document.querySelector('#status').textContent = draftMessage === '임시 저장 복원됨' ? '전술을 복원했습니다 · 행동을 선택해 편집하세요' : '팀 전술함을 열었습니다'; } catch (error) { if (generation === accountGeneration) document.querySelector('#status').textContent = `저장 목록을 불러오지 못했습니다: ${error.message}`; } function enterEditor(nextState, status = '이동 행동 편집 단계') {
nextState = { ...nextState, mode: nextState.mode || 'move', selectedAction: -1, view: 'tactical', boardPreview: undefined };
panel = 'court'; gazePick = null;
loadGeneration += 1; editorHistory.clear(); state = nextState; controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; const menu = document.querySelector('#project-menu'); if (menu) { menu.hidden = true; delete menu.dataset.view; } document.querySelector('#toggle-menu')?.setAttribute('aria-expanded', 'false'); document.querySelector('#play-name').value = state.play.name; document.querySelector('#defense-type').value = state.play.defenseType || 'man-to-man'; document.querySelector('#team-context').textContent = currentTeam?.name || ''; document.querySelector('#save-status').textContent = '전술 편집 중'; const roleBanner = document.querySelector('#editor-role-banner'); roleBanner.hidden = currentTeam?.role !== 'viewer'; roleBanner.textContent = currentTeam?.role === 'viewer' ? '열람자 권한: 팀 전술을 조회할 수 있으며, 변경 내용을 서버에 저장할 수 없습니다.' : ''; showGate('editor'); renderUi(); document.querySelector('#status').textContent = status; refreshSavedPlays(state.play.id).catch(() => {});
}
async function openLibraryPlay(id) {
if (!id || !currentTeam || !currentUser) return;
const generation = ++loadGeneration; const accountAtRequest = accountGeneration; const teamAtRequest = currentTeam.id; const userAtRequest = currentUser.id; const repository = playRepository; const status = document.querySelector('#play-library-status'); status.textContent = '전술을 불러오는 중…';
try { const play = await repository.get(id); if (generation !== loadGeneration || accountGeneration !== accountAtRequest || currentTeam?.id !== teamAtRequest || currentUser?.id !== userAtRequest || playRepository !== repository) return; if (!play) throw new Error('저장된 전술을 찾을 수 없습니다'); enterEditor(createAppStateFromPlay(play), '저장된 전술을 불러왔습니다'); }
catch (error) { if (generation === loadGeneration && accountGeneration === accountAtRequest && currentTeam?.id === teamAtRequest && currentUser?.id === userAtRequest && playRepository === repository) status.textContent = `전술을 열지 못했습니다: ${error.message}`; }
}
function startNewFromLibrary(name, defenseType) {
if (currentTeam?.role === 'viewer') { document.querySelector('#play-library-message').textContent = '열람자 권한에서는 새 전술을 만들 수 없습니다.'; return; }
const next = createAppState(String(name || '').trim() || '새 전술', defenseType || 'man-to-man'); enterEditor({ ...next, selectedPlayerId: 'offense-1' }); document.querySelector('#new-play-form')?.reset(); saveDraft();
} }
async function importLocalPlays() { async function importLocalPlays() {
const teamAtRequest = document.querySelector('#local-import-team').value; const teamName = document.querySelector('#local-import-team').selectedOptions[0]?.textContent || ''; if (!teamAtRequest) { document.querySelector('#team-message').textContent = '먼저 기기 전술 가져올 팀을 선택하세요.'; return; } const generation = accountGeneration; const userAtRequest = currentUser?.id; const repository = createServerPlayRepository(teamAtRequest); const button = document.querySelector('#import-local-team'); button.disabled = true; const importSelect = document.querySelector('#local-import-team'); const selectedOption = importSelect.selectedOptions[0]; const teamAtRequest = importSelect.value; const teamName = selectedOption?.textContent || ''; if (!teamAtRequest || !['owner', 'editor'].includes(selectedOption?.dataset.role)) { document.querySelector('#team-message').textContent = '전술 가져오기는 소유자 또는 편집자만 사용할 수 있습니다.'; return; } const generation = accountGeneration; const userAtRequest = currentUser?.id; const repository = createServerPlayRepository(teamAtRequest); const button = document.querySelector('#import-local-team'); button.disabled = true;
try { const existing = await repository.list(); const existingIds = new Set(existing.map((record) => record.id)); const records = await localPlayRepository.list(); const imports = []; for (const record of records) { const play = await localPlayRepository.get(record.id); if (play) imports.push(play); } try { const legacy = storage?.getItem('basket-utils:draft:v1'); if (legacy) { const play = JSON.parse(legacy); if (isValidPlayForImport(play) && !imports.some((candidate) => candidate.id === play.id)) imports.push(play); } } catch { /* malformed legacy draft stays untouched */ } let imported = 0; let skipped = 0; for (const play of imports) { if (generation !== accountGeneration || currentUser?.id !== userAtRequest) return; if (existingIds.has(play.id)) { skipped += 1; continue; } await repository.save(play); existingIds.add(play.id); imported += 1; } document.querySelector('#team-message').textContent = imports.length ? `${imported}개 전술을 ${teamName} 팀으로 가져왔습니다. ${skipped ? `${skipped}개는 이미 있어 건너뛰었습니다. ` : ''}이 기기의 원본은 유지됩니다.` : '이 기기에 가져올 전술이 없습니다.'; } 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}`; } catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = `기기 전술 가져오기 실패: ${error.message}`; }
finally { button.disabled = false; } finally { button.disabled = false; }
} }
async function initializeAccount() { async function initializeAccount() {
const generation = accountGeneration; const generation = accountGeneration;
try { const result = await getCurrentUser(); if (generation !== accountGeneration) return; currentUser = result.user; await openTeamHub('', generation); } try { const result = await getCurrentUser(); if (generation !== accountGeneration) return; currentUser = result.user; if (window.location.pathname === '/admin') { if (currentUser.isOperator) await openAdmin(generation); else { history.replaceState({ view: 'teams' }, '', '/'); await openTeamHub('운영자만 이용할 수 있는 페이지입니다.', ++accountGeneration); } } else await openTeamHub('', generation); }
catch (error) { if (generation !== accountGeneration) return; if (!(error instanceof ApiError) || error.status !== 401) document.querySelector('#auth-message').textContent = '서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.'; showGate('auth', document.querySelector('#auth-message').textContent); } catch (error) { if (generation !== accountGeneration) return; if (!(error instanceof ApiError) || error.status !== 401) document.querySelector('#auth-message').textContent = '서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.'; showGate('auth', document.querySelector('#auth-message').textContent); }
} }
function isValidPlayForImport(play) { try { return Boolean(play && Array.isArray(play.players) && Array.isArray(play.sequences) && createAppStateFromPlay(play)); } catch { return false; } } 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; videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentUser = null; currentTeam = null; playRepository = localPlayRepository; operationCoordinator.beginIntent(); document.querySelector('.shell').hidden = true; showGate('auth', '로그아웃 중…'); document.querySelectorAll('#login-form button,#show-register').forEach((control) => { control.disabled = true; }); const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; document.querySelector('#team-search-results').replaceChildren(); document.querySelector('#my-join-requests').replaceChildren(); videoJob?.cancel(); clearPreparedVideo('영상 준비 전'); controller.pause(); currentUser = null; currentTeam = null; playRepository = localPlayRepository; operationCoordinator.beginIntent(); document.querySelector('.shell').hidden = true; showGate('auth', '로그아웃 중…'); document.querySelectorAll('#login-form button,#show-register').forEach((control) => { control.disabled = true; });
try { await logoutAccount(); if (generation === accountGeneration) showGate('auth'); } 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; }); }
@@ -203,6 +270,7 @@ async function refreshSavedPlays(selectedId = '', canSelect = () => true, option
} catch (error) { if (!isCurrent() || nonInvasive) return false; select.replaceChildren(); const option = document.createElement('option'); option.value = ''; option.disabled = true; option.selected = true; option.textContent = '저장 목록을 사용할 수 없음'; select.append(option); load.disabled = true; throw error; } } catch (error) { if (!isCurrent() || nonInvasive) return false; select.replaceChildren(); const option = document.createElement('option'); option.value = ''; option.disabled = true; option.selected = true; option.textContent = '저장 목록을 사용할 수 없음'; select.append(option); load.disabled = true; throw error; }
} }
async function saveCurrentPlay() { async function saveCurrentPlay() {
if (currentTeam?.role === 'viewer') { document.querySelector('#status').textContent = '열람자 권한에서는 전술을 저장할 수 없습니다. 팀 소유자에게 편집자 권한을 요청하세요.'; return; }
const token = operationCoordinator.beginIntent(); const repository = playRepository; const teamAtRequest = currentTeam?.id; const token = operationCoordinator.beginIntent(); const repository = playRepository; const teamAtRequest = currentTeam?.id;
const playAtRequest = state.play; const nameAtRequest = String(document.querySelector('#play-name').value || '').trim() || '새 전술'; const nextPlay = structuredClone(playAtRequest); nextPlay.name = nameAtRequest; const playAtRequest = state.play; const nameAtRequest = String(document.querySelector('#play-name').value || '').trim() || '새 전술'; const nextPlay = structuredClone(playAtRequest); nextPlay.name = nameAtRequest;
const isSaveStillRelevant = () => operationCoordinator.isCurrent(token) && currentTeam?.id === teamAtRequest && state.play === playAtRequest && (String(document.querySelector('#play-name').value || '').trim() || '새 전술') === nameAtRequest; const isSaveStillRelevant = () => operationCoordinator.isCurrent(token) && currentTeam?.id === teamAtRequest && state.play === playAtRequest && (String(document.querySelector('#play-name').value || '').trim() || '새 전술') === nameAtRequest;
@@ -232,7 +300,9 @@ function setState(next, status = '', record = true) {
} }
function updatePlay(play, status, selectedAction = -1) { setState({ ...commitActionEdit(state, play), selectedAction }, status); } function updatePlay(play, status, selectedAction = -1) { setState({ ...commitActionEdit(state, play), selectedAction }, status); }
function addPassForTarget(targetId) { function addPassForTarget(targetId) {
const sequence = state.play.sequences[state.selectedSequence]; const ownerId = finalBallOwner(sequence); const play = addAction(state.play, state.selectedSequence, ownerId, null, { type: 'pass', targetPlayerId: targetId }); const sequence = state.play.sequences[state.selectedSequence]; const ownerId = finalBallOwner(sequence); const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); const target = state.play.players.find((player) => player.id === targetId);
if (!ownerId || selectedPlayer?.id !== ownerId || selectedPlayer.team !== 'offense' || !target || target.team !== 'offense' || target.id === ownerId) return;
const play = addAction(state.play, state.selectedSequence, ownerId, null, { type: 'pass', targetPlayerId: targetId });
if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '패스는 현재 공 소유 공격 선수 → 다른 공격 선수만 가능합니다'; if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '패스는 현재 공 소유 공격 선수 → 다른 공격 선수만 가능합니다';
else { state = { ...state, selectedPlayerId: ownerId, mode: 'move' }; const track = play.sequences[state.selectedSequence].tracks.find(track => track.playerId === ownerId); updatePlay(play, '패스 행동 추가', track.actions.length - 1); } else { state = { ...state, selectedPlayerId: ownerId, mode: 'move' }; const track = play.sequences[state.selectedSequence].tracks.find(track => track.playerId === ownerId); updatePlay(play, '패스 행동 추가', track.actions.length - 1); }
} }
@@ -253,6 +323,8 @@ function addShootForOwner() {
} }
function renderUi() { function renderUi() {
const readOnly = currentTeam?.role === 'viewer';
for (const id of ['save-play', 'import-play', 'import-file', 'new-play']) { const element = document.querySelector('#' + id); if (element) element.disabled = readOnly; }
const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId);
const sequence = state.play.sequences[state.selectedSequence] || state.play.sequences[0]; const sequence = state.play.sequences[state.selectedSequence] || state.play.sequences[0];
const playHasShoot = hasShoot(state.play); const playHasShoot = hasShoot(state.play);
@@ -260,7 +332,7 @@ function renderUi() {
const finalOwnerId = finalBallOwner(sequence); const finalOwner = state.play.players.find((player) => player.id === finalOwnerId); const hasPass = finalOwnerId && finalOwnerId !== sequence?.ballOwnerId; const finalOwnerId = finalBallOwner(sequence); const finalOwner = state.play.players.find((player) => player.id === finalOwnerId); const hasPass = finalOwnerId && finalOwnerId !== sequence?.ballOwnerId;
document.querySelector('#canvas-step').textContent = String(state.selectedSequence + 1).padStart(2, '0'); document.querySelector('#canvas-step').textContent = String(state.selectedSequence + 1).padStart(2, '0');
if (currentTeam) document.querySelector('#team-context').textContent = currentTeam.name; if (currentTeam) document.querySelector('#team-context').textContent = currentTeam.name;
document.querySelector('#open-team-hub').textContent = currentTeam?.name || '팀 전환'; document.querySelector('#open-team-hub').textContent = '전술 목록';
document.querySelector('#selected-label').textContent = selectedPlayer ? `${selectedPlayer.team === 'offense' ? 'O' : 'D'}${selectedPlayer.number}` : ''; document.querySelector('#selected-label').textContent = selectedPlayer ? `${selectedPlayer.team === 'offense' ? 'O' : 'D'}${selectedPlayer.number}` : '';
document.querySelector('#roster').innerHTML = ['offense', 'defense'].map((team) => `<div class="team-label">${team === 'offense' ? 'OFFENSE' : 'DEFENSE'}</div>${state.play.players.filter((player) => player.team === team).map((player) => { const startMark = hasPass && player.id === sequence?.ballOwnerId ? '<span class="ball-mark">START</span>' : ''; const finalMark = player.id === finalOwnerId ? '<span class="ball-mark">BALL</span>' : ''; return `<button class="roster-player ${player.id === state.selectedPlayerId ? 'selected' : ''} ${player.id === finalOwnerId ? 'owns-ball' : ''}" data-player="${player.id}"><span class="dot ${team}"></span>${team === 'offense' ? 'O' : 'D'}${player.number}${startMark}${finalMark}<small>${player.location.x.toFixed(1)}, ${player.location.z.toFixed(1)}</small></button>`; }).join('')}`).join(''); document.querySelector('#roster').innerHTML = ['offense', 'defense'].map((team) => `<div class="team-label">${team === 'offense' ? 'OFFENSE' : 'DEFENSE'}</div>${state.play.players.filter((player) => player.team === team).map((player) => { const startMark = hasPass && player.id === sequence?.ballOwnerId ? '<span class="ball-mark">START</span>' : ''; const finalMark = player.id === finalOwnerId ? '<span class="ball-mark">BALL</span>' : ''; return `<button class="roster-player ${player.id === state.selectedPlayerId ? 'selected' : ''} ${player.id === finalOwnerId ? 'owns-ball' : ''}" data-player="${player.id}"><span class="dot ${team}"></span>${team === 'offense' ? 'O' : 'D'}${player.number}${startMark}${finalMark}<small>${player.location.x.toFixed(1)}, ${player.location.z.toFixed(1)}</small></button>`; }).join('')}`).join('');
document.querySelector('#ball-owner-label').textContent = hasPass ? `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'} · 패스 후: ${finalOwner ? `O${finalOwner.number}` : '없음'}` : `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'}`; document.querySelector('#ball-owner-label').textContent = hasPass ? `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'} · 패스 후: ${finalOwner ? `O${finalOwner.number}` : '없음'}` : `시작 공 소유자: ${startingOwner ? `O${startingOwner.number}` : '없음'}`;
@@ -271,11 +343,11 @@ function renderUi() {
? '<strong>시작 위치 설정</strong><br />선수를 고른 뒤 코트를 클릭해 시작 위치를 배치하세요.<br />이 단계의 클릭은 행동으로 저장되지 않습니다.<br /><b>배치가 끝나면 이동를 누르세요.</b>' ? '<strong>시작 위치 설정</strong><br />선수를 고른 뒤 코트를 클릭해 시작 위치를 배치하세요.<br />이 단계의 클릭은 행동으로 저장되지 않습니다.<br /><b>배치가 끝나면 이동를 누르세요.</b>'
: state.mode === 'move' : state.mode === 'move'
? '<strong>이동 경로 설정</strong><br />선수를 고른 뒤 코트를 연속 클릭해 행동을 만드세요.<br />시작 위치를 바꾸려면 시작 배치로 돌아가세요.' ? '<strong>이동 경로 설정</strong><br />선수를 고른 뒤 코트를 연속 클릭해 행동을 만드세요.<br />시작 위치를 바꾸려면 시작 배치로 돌아가세요.'
: state.mode === 'pass' ? '<strong>패스</strong><br />현재 공 소유자는 자동 선택됩니다. 공격 선수를 클릭해 수신자를 고르세요.' : state.mode === 'pass' ? '<strong>패스</strong><br />공 소유 공격 선수를 선택한 뒤 다른 공격 선수를 클릭해 수신자를 고르세요.'
: state.mode === 'screen' ? '<strong>스크린</strong><br />볼을 소유하지 않은 공격 선수를 고른 뒤 수비 선수를 클릭하세요.' : state.mode === 'screen' ? '<strong>스크린</strong><br />볼을 소유하지 않은 공격 선수를 고른 뒤 수비 선수를 클릭하세요.'
: '<strong>시선 설정</strong><br />행동을 고른 뒤 선수나 코트를 클릭하면 시선이 저장됩니다.'; : '<strong>시선 설정</strong><br />행동을 고른 뒤 선수나 코트를 클릭하면 시선이 저장됩니다.';
document.querySelector('#help-copy').innerHTML = helpCopy; document.querySelector('#help-copy').innerHTML = helpCopy;
const sequenceNav = document.querySelector('#sequences'); sequenceNav.replaceChildren(); state.play.sequences.forEach((sequence, index) => { const button = document.createElement('button'); button.className = `sequence-tab ${index === state.selectedSequence ? 'selected' : ''}`; button.dataset.sequence = String(index); decorateSequenceButton(button, sequence, index, state.play.players); sequenceNav.append(button); }); const addSequenceButton = document.createElement('button'); addSequenceButton.id = 'add-sequence'; addSequenceButton.className = 'add-sequence'; addSequenceButton.textContent = ''; sequenceNav.append(addSequenceButton); const deleteSequenceButton = document.createElement('button'); deleteSequenceButton.id = 'delete-sequence'; deleteSequenceButton.className = 'danger'; deleteSequenceButton.textContent = '삭제'; sequenceNav.append(deleteSequenceButton); const sequenceNav = document.querySelector('#sequences'); sequenceNav.replaceChildren(); state.play.sequences.forEach((sequence, index) => { const button = document.createElement('button'); button.className = `sequence-tab ${index === state.selectedSequence ? 'selected' : ''}`; button.dataset.sequence = String(index); decorateSequenceButton(button, sequence, index, state.play.players); sequenceNav.append(button); }); const sequenceControls = document.querySelector('#sequence-controls'); sequenceControls.replaceChildren(); const addSequenceButton = document.createElement('button'); addSequenceButton.id = 'add-sequence'; addSequenceButton.className = 'add-sequence'; addSequenceButton.textContent = ''; addSequenceButton.setAttribute('aria-label', '단계 추가'); sequenceControls.append(addSequenceButton); const deleteSequenceButton = document.createElement('button'); deleteSequenceButton.id = 'delete-sequence'; deleteSequenceButton.className = 'danger'; deleteSequenceButton.textContent = '삭제'; deleteSequenceButton.setAttribute('aria-label', '현재 단계 삭제'); sequenceControls.append(deleteSequenceButton);
const track = selectedTrack(); const track = selectedTrack();
document.querySelector('#track-duration').textContent = track ? `${track.actions.length}개 · ${scheduledTrackDuration(sequence, track, 4.5, state.play.players).toFixed(1)}s` : ''; document.querySelector('#track-duration').textContent = track ? `${track.actions.length}개 · ${scheduledTrackDuration(sequence, track, 4.5, state.play.players).toFixed(1)}s` : '';
const emptyCopy = state.mode === 'start' ? '시작 위치를 설정 중입니다.<br />이 단계에서는 행동이 생성되지 않습니다.' : state.mode === 'screen' ? '아직 스크린 행동이 없습니다.<br />수비 선수를 선택해 Screen을 만드세요.' : '아직 행동이 없습니다.<br />코트 위를 클릭해 경로를 만드세요.'; const emptyCopy = state.mode === 'start' ? '시작 위치를 설정 중입니다.<br />이 단계에서는 행동이 생성되지 않습니다.' : state.mode === 'screen' ? '아직 스크린 행동이 없습니다.<br />수비 선수를 선택해 Screen을 만드세요.' : '아직 행동이 없습니다.<br />코트 위를 클릭해 경로를 만드세요.';
@@ -284,9 +356,11 @@ function renderUi() {
document.querySelector('#delete-action').disabled = state.mode === 'start' || state.selectedAction < 0; document.querySelector('#delete-action').disabled = state.mode === 'start' || state.selectedAction < 0;
const shootButton = document.querySelector('#shoot-action'); shootButton.hidden = selectedPlayer?.id !== finalOwnerId; const shootButton = document.querySelector('#shoot-action'); shootButton.hidden = selectedPlayer?.id !== finalOwnerId;
shootButton.disabled = playHasShoot || state.mode === 'start' || state.selectedSequence !== state.play.sequences.length - 1; shootButton.disabled = playHasShoot || state.mode === 'start' || state.selectedSequence !== state.play.sequences.length - 1;
const passButton = document.querySelector('[data-mode="pass"]'); const canPass = selectedPlayer?.team === 'offense' && selectedPlayer.id === finalOwnerId;
passButton.hidden = !canPass; passButton.disabled = playHasShoot || !canPass;
document.querySelector('#add-sequence').disabled = playHasShoot; document.querySelector('#add-sequence').disabled = playHasShoot;
document.querySelector('#delete-sequence').disabled = playHasShoot; document.querySelector('#delete-sequence').disabled = playHasShoot;
document.querySelectorAll('[data-mode]').forEach((button) => { button.disabled = playHasShoot; }); document.querySelectorAll('[data-mode]').forEach((button) => { button.disabled = playHasShoot || (button.dataset.mode === 'pass' && !canPass); });
document.querySelector('#undo').disabled = !canUndo; document.querySelector('#redo').disabled = !canRedo; document.querySelector('#undo').disabled = !canUndo; document.querySelector('#redo').disabled = !canRedo;
document.querySelectorAll('[data-mode]').forEach((button) => button.classList.toggle('active', button.dataset.mode === state.mode)); document.querySelectorAll('[data-mode]').forEach((button) => button.classList.toggle('active', button.dataset.mode === state.mode));
document.querySelectorAll('[data-sequence]').forEach((button) => button.classList.toggle('selected', Number(button.dataset.sequence) === state.selectedSequence)); document.querySelectorAll('[data-sequence]').forEach((button) => button.classList.toggle('selected', Number(button.dataset.sequence) === state.selectedSequence));
@@ -302,8 +376,9 @@ function renderUi() {
document.querySelector('#apply-position').disabled = action?.type !== 'move'; document.querySelector('#apply-position').disabled = action?.type !== 'move';
document.querySelector('#scrubber').max = totalDuration(state.play); document.querySelector('#scrubber').max = totalDuration(state.play);
document.querySelector('#data-preview').textContent = serializePlay(state.play); document.querySelector('#data-preview').textContent = serializePlay(state.play);
document.querySelector('.shell').dataset.panel = panel; const shell = document.querySelector('.shell'); shell.dataset.panel = panel; shell.dataset.mobileFocus = mobileFocusActive ? 'true' : 'false'; shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed'; updateOrientationGate(); board.setMobileFocus?.(mobileFocusActive);
document.querySelectorAll('button[data-panel]').forEach(button => { const active = button.dataset.panel === panel; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); }); document.querySelectorAll('button[data-panel]').forEach(button => { const active = button.dataset.panel === panel; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); });
document.querySelector('#toggle-focus-actions')?.setAttribute('aria-expanded', String(mobileFocusActive && panel === 'actions'));
ensureVideoFresh(); renderVideoControls(); ensureVideoFresh(); renderVideoControls();
} }
@@ -311,7 +386,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(); await openTeamHub('', generation); } try { const result = await loginAccount(data.get('email'), data.get('password')); if (generation !== accountGeneration) return; currentUser = result.user; form.reset(); if (window.location.pathname === '/admin') { if (currentUser.isOperator) await openAdmin(generation, false); else { history.replaceState({ view: 'teams' }, '', '/'); await openTeamHub('운영자만 이용할 수 있는 페이지입니다.', ++accountGeneration); } } else await openTeamHub('', generation); }
catch (error) { if (generation === accountGeneration) showGate(error.code === 'approval_required' ? 'pending' : 'auth', error.message); } 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') {
@@ -324,22 +399,49 @@ app.addEventListener('submit', async (event) => {
try { const result = await createTeam(data.get('name')); if (generation !== accountGeneration) return; form.reset(); await selectTeam(result.team); } try { const result = await createTeam(data.get('name')); if (generation !== accountGeneration) return; form.reset(); await selectTeam(result.team); }
catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = error.message; } catch (error) { if (generation === accountGeneration) document.querySelector('#team-message').textContent = error.message; }
finally { button.disabled = false; } finally { button.disabled = false; }
} } else if (form.id === 'team-search-form') { await searchAndRenderTeams(new FormData(form).get('q')); }
else if (form.id === 'new-play-form') { const data = new FormData(form); const button = form.querySelector('button[type=submit]'); button.disabled = true; try { startNewFromLibrary(data.get('name'), data.get('defenseType')); } finally { button.disabled = currentTeam?.role === 'viewer'; } }
}); });
app.addEventListener('click', (event) => { app.addEventListener('click', (event) => {
if (event.target.closest('#toggle-focus-playback')) {
if (!mobileFocusActive) return;
focusPlaybackOpen = !focusPlaybackOpen;
const shell = document.querySelector('.shell');
if (shell) shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed';
event.target.closest('#toggle-focus-playback').setAttribute('aria-expanded', String(focusPlaybackOpen));
return;
}
if (event.target.closest('#toggle-focus-actions')) {
if (!mobileFocusActive) return;
panel = panel === 'actions' ? 'court' : 'actions';
renderUi();
return;
}
if (event.target.closest('#show-register')) { showGate('register'); return; } if (event.target.closest('#show-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('#team-logout') || event.target.closest('#logout')) { logoutFromUi(); return; } if (event.target.closest('#open-admin')) { const generation = ++accountGeneration; openAdmin(generation); return; }
const teamCard = event.target.closest('[data-team-id]'); if (teamCard) { const team = { id: teamCard.dataset.teamId, name: teamCard.querySelector('strong')?.textContent || '', role: teamCard.dataset.role || 'viewer' }; selectTeam(team); return; } if (event.target.closest('#refresh-teams')) { setOwnerView(false); openTeamHub('', ++accountGeneration); return; }
if (event.target.closest('[data-manage-team]')) { const manage = event.target.closest('[data-manage-team]'); setOwnerView(true); openOwnerPanel({ id: manage.dataset.manageTeam, name: manage.dataset.teamName }); return; }
if (event.target.closest('#owner-back')) { ownerGeneration += 1; activeOwnerTeamId = null; setOwnerView(false); document.querySelector('#owner-panel').hidden = true; document.querySelector('#membership-panel').hidden = false; document.querySelector('#team-list').hidden = false; return; }
if (event.target.closest('[data-approve-join]')) { const button = event.target.closest('[data-approve-join]'); const ownerTeamId = button.dataset.teamId; const generation = ownerGeneration; const role = button.parentElement.querySelector('select')?.value || 'viewer'; button.disabled = true; approveTeamJoin(ownerTeamId, button.dataset.approveJoin, role).then(() => { if (generation === ownerGeneration && activeOwnerTeamId === ownerTeamId) return openOwnerPanel({ id: ownerTeamId, name: document.querySelector('#owner-team-name').textContent.replace(/ 팀$/, '') }); }).catch((error) => { if (generation === ownerGeneration) { button.disabled = false; document.querySelector('#team-message').textContent = error.message; } }); return; }
if (event.target.closest('[data-save-member-role]')) { const button = event.target.closest('[data-save-member-role]'); const select = button.parentElement.querySelector('select[data-member-role]'); const teamId = select?.dataset.teamId; const userId = select?.dataset.memberRole; const previous = select?.dataset.previousRole; const requestedRole = select?.value; const generation = ownerGeneration; if (!select || !teamId || !userId) return; button.disabled = true; select.disabled = true; changeTeamMemberRole(teamId, userId, requestedRole).then(() => { if (generation === ownerGeneration) { select.dataset.previousRole = requestedRole; document.querySelector('#team-message').textContent = '역할을 저장했습니다.'; } }).catch((error) => { if (generation === ownerGeneration) { select.value = previous; document.querySelector('#team-message').textContent = error.message; } }).finally(() => { if (generation === ownerGeneration) { button.disabled = false; select.disabled = false; } }); return; }
if (event.target.closest('#admin-back')) { accountGeneration += 1; searchGeneration += 1; setOwnerView(false); history.pushState({ view: 'teams' }, '', '/'); openTeamHub('', accountGeneration); return; }
if (event.target.closest('[data-join-team]')) { const button = event.target.closest('[data-join-team]'); const generation = searchGeneration; button.disabled = true; requestTeamJoin(button.dataset.joinTeam).then(() => { if (generation !== searchGeneration) return; button.textContent = '승인 대기 중'; document.querySelector('#join-status').textContent = '가입 요청을 보냈습니다. 팀 소유자의 승인을 기다려 주세요.'; return searchAndRenderTeams(document.querySelector('#team-search-form input[name="q"]').value); }).catch((error) => { if (generation === searchGeneration) { button.disabled = false; document.querySelector('#join-status').textContent = error.message; } }); return; }
if (event.target.closest('#team-logout') || event.target.closest('#logout')) { document.querySelector('#team-search-results').replaceChildren(); document.querySelector('#my-join-requests').replaceChildren(); document.querySelector('#join-status').textContent = ''; document.querySelector('#team-search-form input[name="q"]').value = ''; logoutFromUi(); return; }
const teamCard = event.target.closest('button.team-card[data-team-id]'); if (teamCard) { const team = { id: teamCard.dataset.teamId, name: teamCard.querySelector('strong')?.textContent || '', role: teamCard.dataset.role || 'viewer' }; selectTeam(team); return; }
if (event.target.closest('#import-local-team')) { importLocalPlays(); return; } if (event.target.closest('#import-local-team')) { importLocalPlays(); return; }
if (event.target.closest('[data-approve-user]')) { const generation = accountGeneration; approveUser(event.target.closest('[data-approve-user]').dataset.approveUser).then(() => { if (generation === accountGeneration) return renderPendingUsers(generation); }).catch((error) => { if (generation === accountGeneration) document.querySelector('#team-message').textContent = error.message; }); return; } if (event.target.closest('[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(); 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(); openTeamLibrary(currentTeam); } else { const generation = ++accountGeneration; document.querySelector('.shell').hidden = true; openTeamHub('', generation); } return; }
if (event.target.closest('[data-open-play]')) { openLibraryPlay(event.target.closest('[data-open-play]').dataset.openPlay); return; }
if (event.target.closest('#continue-draft')) { if (currentTeam?.role === 'viewer' || !currentTeam) return; enterEditor(state, '임시 저장 전술을 계속 편집합니다'); return; }
if (event.target.closest('#retry-play-library')) { openTeamLibrary(currentTeam); return; }
if (event.target.closest('#library-back-teams')) { operationCoordinator.beginIntent(); loadGeneration += 1; const generation = ++accountGeneration; currentTeam = null; playRepository = localPlayRepository; document.querySelector('.shell').hidden = true; openTeamHub('', generation); return; }
if (event.target.closest('#video-share-open')) { const menu = document.querySelector('#project-menu'); menu.dataset.view = 'video'; menu.hidden = false; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'true'); document.querySelector('#export-video').focus(); return; } if (event.target.closest('#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; }
if (event.target.closest('#cancel-gaze')) { gazePick = null; renderUi(); document.querySelector('#status').textContent = '시선 선택 취소'; return; } if (event.target.closest('#cancel-gaze')) { gazePick = null; renderUi(); document.querySelector('#status').textContent = '시선 선택 취소'; return; }
const panelButton = event.target.closest('button[data-panel]'); if (panelButton) { panel = panelButton.dataset.panel; renderUi(); return; } const panelButton = event.target.closest('button[data-panel]'); if (panelButton) { panel = panel === panelButton.dataset.panel ? 'court' : panelButton.dataset.panel; renderUi(); return; }
if (event.target.closest('#apply-position')) { const x = Number(document.querySelector('#action-x').value); const z = Number(document.querySelector('#action-z').value); updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: { x, z } }), '이동 위치 수정', state.selectedAction); return; } if (event.target.closest('#apply-position')) { const x = Number(document.querySelector('#action-x').value); const z = Number(document.querySelector('#action-z').value); updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: { x, z } }), '이동 위치 수정', state.selectedAction); return; }
if (event.target.closest('#loop')) { const button = document.querySelector('#loop'); const enabled = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(enabled)); controller.setLoop(enabled); return; } if (event.target.closest('#loop')) { const button = document.querySelector('#loop'); const enabled = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(enabled)); controller.setLoop(enabled); return; }
if (event.target.closest('#previous-step') || event.target.closest('#next-step')) { const current = controller.sample().sequenceIndex; const next = Math.max(0, Math.min(state.play.sequences.length - 1, current + (event.target.closest('#next-step') ? 1 : -1))); seekTo(state.play.sequences.slice(0, next).reduce((time, sequence) => time + sequenceDuration(sequence, 4.5, state.play.players), 0)); return; } if (event.target.closest('#previous-step') || event.target.closest('#next-step')) { const current = controller.sample().sequenceIndex; const next = Math.max(0, Math.min(state.play.sequences.length - 1, current + (event.target.closest('#next-step') ? 1 : -1))); seekTo(state.play.sequences.slice(0, next).reduce((time, sequence) => time + sequenceDuration(sequence, 4.5, state.play.players), 0)); return; }
@@ -354,14 +456,14 @@ app.addEventListener('click', (event) => {
if (event.target.closest('#shoot-action')) { addShootForOwner(); return; } if (event.target.closest('#shoot-action')) { addShootForOwner(); return; }
if (event.target.closest('#undo')) { const next = editorHistory.undo(state); if (next !== state) setState(next, '실행 취소', false); return; } if (event.target.closest('#undo')) { const next = editorHistory.undo(state); if (next !== state) setState(next, '실행 취소', false); return; }
if (event.target.closest('#redo')) { const next = editorHistory.redo(state); if (next !== state) setState(next, '다시 실행', false); return; } if (event.target.closest('#redo')) { const next = editorHistory.redo(state); if (next !== state) setState(next, '다시 실행', false); return; }
if (event.target.closest('#set-ball-owner')) { const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (selectedPlayer?.team === 'offense') { const play = setSequenceBallOwner(state.play, state.selectedSequence, selectedPlayer.id); if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '먼저 패스 행동을 삭제하세요'; else setState({ ...state, play }, `Sequence ${state.selectedSequence + 1} 공 소유자 O${selectedPlayer.number}`); } return; } if (event.target.closest('#set-ball-owner')) { const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (selectedPlayer?.team === 'offense') { const play = setSequenceBallOwner(state.play, state.selectedSequence, selectedPlayer.id); if (play === state.play) document.querySelector('#status').textContent = hasShoot(state.play) ? '슛 행동을 먼저 삭제하세요' : '먼저 패스 행동을 삭제하세요'; else setState({ ...state, play, mode: 'move', selectedAction: -1 }, `Sequence ${state.selectedSequence + 1} 공 소유자 O${selectedPlayer.number}`); } return; }
const playerButton = event.target.closest('[data-player]'); const playerButton = event.target.closest('[data-player]');
if (playerButton) { const playerId = playerButton.dataset.player; if (gazePick) { if (gazePick === 'player') applyGaze({ type: 'player', targetId: playerId }); return; } if (state.mode === 'pass') addPassForTarget(playerId); else if (state.mode === 'screen') addScreenForTarget(playerId); else if (state.mode === 'lookAt' && state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; const lookAt = normalizeLookAt({ type: 'player', targetId: playerId }, state.selectedPlayerId, play.players); if (action && lookAt) { action.lookAt = lookAt; updatePlay(play, '선수 대상 시선 저장', state.selectedAction); } } else { playbackSession = false; resetPreview = false; state = { ...state, selectedPlayerId: playerId, selectedAction: -1 }; renderUi(); } return; } if (playerButton) { const playerId = playerButton.dataset.player; if (gazePick) { if (gazePick === 'player') applyGaze({ type: 'player', targetId: playerId }); return; } if (state.mode === 'pass') addPassForTarget(playerId); else if (state.mode === 'screen') addScreenForTarget(playerId); else if (state.mode === 'lookAt' && state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; const lookAt = normalizeLookAt({ type: 'player', targetId: playerId }, state.selectedPlayerId, play.players); if (action && lookAt) { action.lookAt = lookAt; updatePlay(play, '선수 대상 시선 저장', state.selectedAction); } } else { playbackSession = false; resetPreview = false; state = { ...state, selectedPlayerId: playerId, selectedAction: -1 }; renderUi(); } return; }
const sequenceButton = event.target.closest('[data-sequence]'); const sequenceButton = event.target.closest('[data-sequence]');
if (sequenceButton) { gazePick = null; const selectedSequence = Number(sequenceButton.dataset.sequence); state = { ...state, selectedSequence, selectedAction: -1, mode: state.mode === 'start' && selectedSequence > 0 ? 'move' : state.mode }; controller.reset(); playbackSession = false; resetPreview = false; renderUi(); return; } if (sequenceButton) { gazePick = null; const selectedSequence = Number(sequenceButton.dataset.sequence); const mode = state.mode === 'pass' || (state.mode === 'start' && selectedSequence > 0) ? 'move' : state.mode; state = { ...state, selectedSequence, selectedAction: -1, mode }; controller.reset(); playbackSession = false; resetPreview = false; renderUi(); return; }
const actionButton = event.target.closest('[data-action]'); const actionButton = event.target.closest('[data-action]');
if (actionButton) { gazePick = null; playbackSession = false; resetPreview = false; state = { ...state, selectedAction: Number(actionButton.dataset.action) }; renderUi(); return; } if (actionButton) { gazePick = null; playbackSession = false; resetPreview = false; state = { ...state, selectedAction: Number(actionButton.dataset.action) }; renderUi(); return; }
const modeButton = event.target.closest('[data-mode]'); if (modeButton) { gazePick = null; const mode = modeButton.dataset.mode; if (mode === 'pass') { const ownerId = finalBallOwner(state.play.sequences[state.selectedSequence]); if (!ownerId) { document.querySelector('#status').textContent = '현재 단계의 공 소유 선수가 없습니다'; return; } state = { ...state, selectedPlayerId: ownerId }; } if (mode === 'screen') { const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!screener || screener.team !== 'offense' || finalBallOwner(state.play.sequences[state.selectedSequence]) === screener.id) { document.querySelector('#status').textContent = '먼저 공을 가지지 않은 공격 선수를 선택하세요'; return; } } playbackSession = false; resetPreview = false; state = { ...state, mode, selectedSequence: mode === 'start' ? 0 : state.selectedSequence, selectedAction: -1 }; document.querySelector('#status').textContent = mode === 'start' ? '시작 위치 설정 단계 · 행동은 생성되지 않습니다' : mode === 'move' ? '이동 행동 편집 단계' : mode === 'pass' ? '패스 대상 선택 단계 · 현재 공 소유자 자동 선택됨' : mode === 'screen' ? '스크린 수비 대상 선택 단계' : 'LookAt 편집 단계'; renderUi(); return; } const modeButton = event.target.closest('[data-mode]'); if (modeButton) { gazePick = null; const mode = modeButton.dataset.mode; if (mode === 'pass') { const ownerId = finalBallOwner(state.play.sequences[state.selectedSequence]); const selectedPlayer = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!ownerId || selectedPlayer?.id !== ownerId || selectedPlayer.team !== 'offense') return; } if (mode === 'screen') { const screener = state.play.players.find((player) => player.id === state.selectedPlayerId); if (!screener || screener.team !== 'offense' || finalBallOwner(state.play.sequences[state.selectedSequence]) === screener.id) { document.querySelector('#status').textContent = '먼저 공을 가지지 않은 공격 선수를 선택하세요'; return; } } playbackSession = false; resetPreview = false; state = { ...state, mode, selectedSequence: mode === 'start' ? 0 : state.selectedSequence, selectedAction: -1 }; document.querySelector('#status').textContent = mode === 'start' ? '시작 위치 설정 단계 · 행동은 생성되지 않습니다' : mode === 'move' ? '이동 행동 편집 단계' : mode === 'pass' ? '패스 대상 선택 단계 · 현재 공 소유자에게서 패스 대상을 고르세요' : mode === 'screen' ? '스크린 수비 대상 선택 단계' : 'LookAt 편집 단계'; renderUi(); return; }
if (event.target.closest('#add-sequence')) { if (state.mode === 'start') { document.querySelector('#status').textContent = '먼저 이동를 눌러 시작 위치를 확정하세요'; return; } const next = addPlaySequence(state); if (next.play === state.play) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '새 단계가 이전 마지막 위치를 상속했습니다'); return; } if (event.target.closest('#add-sequence')) { if (state.mode === 'start') { document.querySelector('#status').textContent = '먼저 이동를 눌러 시작 위치를 확정하세요'; return; } const next = addPlaySequence(state); if (next.play === state.play) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '새 단계가 이전 마지막 위치를 상속했습니다'); return; }
if (event.target.closest('#delete-sequence')) { const next = deletePlaySequence(state); if (next.play !== state.play) setState(next, '단계 삭제'); return; } if (event.target.closest('#delete-sequence')) { const next = deletePlaySequence(state); if (next.play !== state.play) setState(next, '단계 삭제'); return; }
if (event.target.closest('#delete-action')) { const play = removeAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction); if (play === state.play && hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 선택해 삭제하세요'; return; } updatePlay(play, '행동 삭제'); return; } if (event.target.closest('#delete-action')) { const play = removeAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction); if (play === state.play && hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 선택해 삭제하세요'; return; } updatePlay(play, '행동 삭제'); return; }
@@ -370,12 +472,13 @@ app.addEventListener('click', (event) => {
if (event.target.closest('#reset')) { controller.reset(); playbackSession = false; resetPreview = true; state = { ...state, playing: false, selectedSequence: 0, selectedAction: -1 }; document.querySelector('#status').textContent = '처음 위치로 복귀'; return; } if (event.target.closest('#reset')) { controller.reset(); playbackSession = false; resetPreview = true; state = { ...state, playing: false, selectedSequence: 0, selectedAction: -1 }; document.querySelector('#status').textContent = '처음 위치로 복귀'; return; }
if (event.target.closest('#tactical')) { state = { ...state, view: 'tactical' }; renderUi(); return; } if (event.target.closest('#tactical')) { state = { ...state, view: 'tactical' }; renderUi(); return; }
if (event.target.closest('#pov')) { panel = 'court'; gazePick = null; state = { ...state, view: 'pov' }; renderUi(); return; } if (event.target.closest('#pov')) { panel = 'court'; gazePick = null; state = { ...state, view: 'pov' }; renderUi(); return; }
if (event.target.closest('#new-play')) { operationCoordinator.beginIntent(); editorHistory.clear(); const name = document.querySelector('#play-name').value; const defenseType = document.querySelector('#defense-type').value; state = { ...createAppState(name, defenseType), selectedPlayerId: 'offense-1' }; controller = createPlaybackController(state.play); playbackSession = false; resetPreview = false; saveDraft(); document.querySelector('#status').textContent = '시작 배치 단계'; renderUi(); return; } if (event.target.closest('#new-play')) { if (currentTeam) openTeamLibrary(currentTeam, '새 전술 이름과 수비 형태를 선택하세요.'); return; }
}); });
app.addEventListener('change', (event) => { if (event.target.id === 'saved-plays') document.querySelector('#load-play').disabled = !event.target.value; if (event.target.id === 'local-import-team') document.querySelector('#import-local-team').disabled = !event.target.value; }); app.addEventListener('change', (event) => { if (event.target.id === 'saved-plays') document.querySelector('#load-play').disabled = !event.target.value; if (event.target.id === 'local-import-team') document.querySelector('#import-local-team').disabled = !event.target.value; });
app.addEventListener('input', (event) => { if (event.target.id === 'play-name') { operationCoordinator.beginIntent(); saveDraft(); ensureVideoFresh(); renderVideoControls(); } if (event.target.id === 'scrubber') seekTo(event.target.value); }); app.addEventListener('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.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);
@@ -397,6 +500,7 @@ function applyGaze(lookAt) {
function seekTo(time) { controller.pause(); controller.seek(time); playbackSession = true; resetPreview = false; state.mode = 'move'; state.playing = false; document.querySelector('#status').textContent = '재생 위치 미리보기'; renderUi(); } function seekTo(time) { controller.pause(); controller.seek(time); playbackSession = true; resetPreview = false; state.mode = 'move'; state.playing = false; document.querySelector('#status').textContent = '재생 위치 미리보기'; renderUi(); }
document.addEventListener('keydown', event => { document.addEventListener('keydown', event => {
if (document.querySelector('.shell')?.hidden) return; if (document.querySelector('.shell')?.hidden) return;
if (document.querySelector('.shell')?.dataset.orientationGate === 'true' && !event.target.closest('#orientation-gate')) { event.preventDefault(); return; }
if (event.key === 'Escape' && !document.querySelector('#project-menu').hidden) { const menu = document.querySelector('#project-menu'); menu.hidden = true; delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); document.querySelector('#video-share-open').focus(); return; } if (event.key === 'Escape' && !document.querySelector('#project-menu').hidden) { const menu = document.querySelector('#project-menu'); menu.hidden = true; delete menu.dataset.view; document.querySelector('#toggle-menu').setAttribute('aria-expanded', 'false'); document.querySelector('#video-share-open').focus(); return; }
if (event.target.closest('input,select,textarea,video,button,[contenteditable="true"]')) return; if (event.target.closest('input,select,textarea,video,button,[contenteditable="true"]')) return;
let id = null; let id = null;
@@ -423,20 +527,22 @@ document.querySelector('#board').addEventListener('click', (event) => {
if (hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; return; } if (hasShoot(state.play)) { document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; return; }
const location = snapLocation(clampLocation(hit.location)); const location = snapLocation(clampLocation(hit.location));
if (state.mode === 'start') { const next = setSelectedPlayerStart(state, location); if (next.play === state.play && hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '시작 위치 변경 · 행동 0개'); } if (state.mode === 'start') { const next = setSelectedPlayerStart(state, location); if (next.play === state.play && hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; else setState(next, '시작 위치 변경 · 행동 0개'); }
else if (state.mode === 'move') { const play = addAction(state.play, state.selectedSequence, state.selectedPlayerId, location); const track = play.sequences[state.selectedSequence]?.tracks.find((candidate) => candidate.playerId === state.selectedPlayerId); if (track && play !== state.play) updatePlay(play, '이동 행동 추가', track.actions.length - 1); else if (hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; } else if (state.mode === 'move') { const play = addAction(state.play, state.selectedSequence, state.selectedPlayerId, location); if (play !== state.play) updatePlay(play, '이동 행동 추가', -1); else if (hasShoot(state.play)) document.querySelector('#status').textContent = '슛 행동을 먼저 삭제하세요'; }
else if (state.mode === 'screen') { document.querySelector('#status').textContent = '수비 선수를 선택하세요'; } else if (state.mode === 'screen') { document.querySelector('#status').textContent = '수비 선수를 선택하세요'; }
else if (state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; if (action) { action.lookAt = { type: 'location', ...location }; updatePlay(play, '코트 위치 시선 저장', state.selectedAction); } } else if (state.selectedAction >= 0) { const play = structuredClone(state.play); const action = play.sequences[state.selectedSequence]?.tracks.find((track) => track.playerId === state.selectedPlayerId)?.actions[state.selectedAction]; if (action) { action.lookAt = { type: 'location', ...location }; updatePlay(play, '코트 위치 시선 저장', state.selectedAction); } }
}); });
const boardElement = document.querySelector('#board'); const boardElement = document.querySelector('#board');
boardElement.addEventListener('pointerdown', event => { boardElement.addEventListener('pointerdown', event => {
if (event.button !== 0 || state.view !== 'tactical' || state.playing || gazePick || (state.mode === 'start' && hasShoot(state.play))) return; if (event.button !== 0 || state.view !== 'tactical' || state.playing || gazePick || drag || (event.pointerType !== 'mouse' && event.isPrimary === false)) return;
if (state.mode !== 'start' && state.mode !== 'move') return; if (!['start', 'move'].includes(state.mode)) return;
const hit = board.pick(event, state); const hit = board.pick(event, state);
if (hit?.type !== 'player' || hit.playerId !== state.selectedPlayerId) return; if (hit?.type !== 'player') return;
const action = selectedTrack()?.actions[state.selectedAction]; const changedPlayer = hit.playerId !== state.selectedPlayerId;
if (state.mode !== 'start' && action?.type !== 'move') return; const selectedAction = selectedTrack()?.actions[state.selectedAction];
drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, moved: false, location: null }; if (!changedPlayer && state.mode === 'move' && state.selectedAction >= 0 && selectedAction?.type !== 'move') return;
drag = { pointerId: event.pointerId, playerId: hit.playerId, x: event.clientX, y: event.clientY, moved: false, location: null };
state = { ...state, selectedPlayerId: hit.playerId, selectedAction: changedPlayer ? -1 : state.selectedAction };
boardElement.setPointerCapture(event.pointerId); boardElement.setPointerCapture(event.pointerId);
}); });
boardElement.addEventListener('pointermove', event => { boardElement.addEventListener('pointermove', event => {
@@ -444,6 +550,7 @@ boardElement.addEventListener('pointermove', event => {
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 7 && !drag.moved) return; if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 7 && !drag.moved) return;
const location = board.courtLocation(event); if (!location) return; const location = board.courtLocation(event); if (!location) return;
drag.moved = true; drag.location = snapLocation(location); drag.moved = true; drag.location = snapLocation(location);
state = { ...state, boardPreview: { ...(state.boardPreview || {}), [drag.playerId]: drag.location } };
document.querySelector('.board-wrap').classList.add('dragging'); document.querySelector('.board-wrap').classList.add('dragging');
document.querySelector('#status').textContent = `놓으면 위치 변경 · ${drag.location.x.toFixed(1)}, ${drag.location.z.toFixed(1)}`; document.querySelector('#status').textContent = `놓으면 위치 변경 · ${drag.location.x.toFixed(1)}, ${drag.location.z.toFixed(1)}`;
}); });
@@ -454,14 +561,153 @@ function finishDrag(event) {
if (boardElement.hasPointerCapture(event.pointerId)) boardElement.releasePointerCapture(event.pointerId); if (boardElement.hasPointerCapture(event.pointerId)) boardElement.releasePointerCapture(event.pointerId);
if (!completed.moved) return; if (!completed.moved) return;
suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0);
if (event.type !== 'pointerup') return; if (event.type !== 'pointerup') { cancelActiveDrag(); return; }
state = { ...state, boardPreview: undefined };
if (state.mode === 'start') setState(setSelectedPlayerStart(state, completed.location), '시작 위치 변경'); if (state.mode === 'start') setState(setSelectedPlayerStart(state, completed.location), '시작 위치 변경');
else updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: completed.location }), '이동 위치 변경', state.selectedAction); else if (state.selectedAction < 0) { const play = addAction(state.play, state.selectedSequence, completed.playerId, completed.location); if (play !== state.play) updatePlay(play, '이동 행동 추가', -1); }
else updatePlay(editAction(state.play, state.selectedSequence, completed.playerId, state.selectedAction, { location: completed.location }), '이동 위치 변경', state.selectedAction);
} }
boardElement.addEventListener('pointerup', finishDrag); boardElement.addEventListener('pointerup', finishDrag);
boardElement.addEventListener('pointercancel', finishDrag); boardElement.addEventListener('pointercancel', finishDrag);
boardElement.addEventListener('lostpointercapture', event => { if (drag?.pointerId === event.pointerId) finishDrag({ type: 'pointercancel', pointerId: event.pointerId }); });
window.addEventListener('blur', () => { if (drag) finishDrag({ type: 'pointercancel', pointerId: drag.pointerId }); });
function focusViewportEligible() {
const shortSide = Math.min(window.innerWidth || 0, window.innerHeight || 0);
const coarse = window.matchMedia?.('(pointer: coarse)').matches;
return window.innerWidth <= 800 || (coarse && shortSide <= 800) || (window.innerWidth <= 900 && window.innerHeight <= 550);
}
function focusIsPortrait() {
return (window.innerHeight || 0) > (window.innerWidth || 0);
}
function updateOrientationGate(session = focusSession) {
if (session !== focusSession) return;
const blocked = mobileFocusActive && focusIsPortrait() && ['pending', 'unsupported', 'rejected'].includes(focusOrientationStatus);
const gate = document.querySelector('#orientation-gate');
if (gate) {
gate.hidden = !blocked;
gate.setAttribute('aria-hidden', String(!blocked));
if (blocked && !gate.contains(document.activeElement)) gate.querySelector('#orientation-gate-exit')?.focus();
}
for (const selector of ['.topbar', '.mobile-tabs', '.workspace', '.playback-deck']) {
const element = document.querySelector(selector);
if (element) element.inert = blocked;
}
const shell = document.querySelector('.shell');
if (shell) shell.dataset.orientationGate = blocked ? 'true' : 'false';
}
function requestLandscapeLock(session) {
const orientation = window.screen?.orientation;
const token = ++focusOrientationToken;
if (!orientation || typeof orientation.lock !== 'function') { focusOrientationStatus = 'unsupported'; updateOrientationGate(session); return; }
focusOrientationStatus = 'pending'; updateOrientationGate(session);
Promise.resolve().then(() => orientation.lock('landscape')).then(() => {
if (token !== focusOrientationToken || session !== focusSession || !mobileFocusActive || !document.fullscreenElement) {
if (!mobileFocusActive || !document.fullscreenElement) { try { orientation.unlock?.(); } catch { /* best effort */ } }
return;
}
focusOrientationStatus = 'locked'; updateOrientationGate(session);
}).catch(() => {
if (token !== focusOrientationToken || session !== focusSession || !mobileFocusActive) return;
focusOrientationStatus = 'rejected'; updateOrientationGate(session);
});
}
function unlockOrientation(session) {
const orientation = window.screen?.orientation;
const token = ++focusOrientationToken;
if (!orientation || typeof orientation.unlock !== 'function') return;
Promise.resolve().then(() => orientation.unlock()).catch(() => {}).then(() => {
if (token !== focusOrientationToken || session !== focusSession || mobileFocusActive) return;
focusOrientationStatus = 'idle'; updateOrientationGate(session);
});
}
function updateFullscreenButton(button, active) {
if (!button) return;
const label = active ? '전체화면 종료' : '전체화면';
const visualLabel = button.querySelector('.fullscreen-label');
if (visualLabel) visualLabel.textContent = label;
else button.textContent = label;
button.setAttribute('aria-label', label);
button.title = label;
button.setAttribute('aria-pressed', String(active));
}
function setMobileFocus(active, options = {}) {
cancelActiveDrag();
if (active) {
if (!mobileFocusActive) focusSession += 1;
mobileFocusActive = true;
focusOrientationStatus = options.orientationStatus || 'idle';
} else {
const endingSession = focusSession;
const shouldUnlock = mobileFocusActive || focusOrientationStatus !== 'idle';
focusEntryRequested = false;
focusSession += 1;
mobileFocusActive = false;
focusPlaybackOpen = false;
focusOrientationStatus = 'idle';
if (shouldUnlock) unlockOrientation(endingSession);
}
const shell = document.querySelector('.shell');
if (shell) {
shell.dataset.mobileFocus = mobileFocusActive ? 'true' : 'false';
shell.dataset.focusPlayback = focusPlaybackOpen ? 'open' : 'closed';
}
board.setMobileFocus?.(mobileFocusActive);
const button = document.querySelector('#toggle-fullscreen');
updateFullscreenButton(button, mobileFocusActive || Boolean(document.fullscreenElement));
document.querySelector('#toggle-focus-playback')?.setAttribute('aria-expanded', String(focusPlaybackOpen));
updateOrientationGate();
}
document.addEventListener('click', async event => {
if (event.target.closest('#orientation-gate-exit')) {
++fullscreenFocusRequest;
if (document.fullscreenElement) { try { await document.exitFullscreen?.(); } catch { /* best effort */ } }
else setMobileFocus(false);
return;
}
if (!event.target.closest('#toggle-fullscreen')) return;
const status = document.querySelector('#fullscreen-status');
if (document.fullscreenElement) { ++fullscreenFocusRequest; try { await document.exitFullscreen?.(); } catch { /* best effort */ } return; }
if (mobileFocusActive) { ++fullscreenFocusRequest; setMobileFocus(false); if (status) status.textContent = ''; return; }
const wantsFocus = focusViewportEligible();
const request = ++fullscreenFocusRequest;
focusEntryRequested = wantsFocus;
try {
if (document.documentElement.requestFullscreen) {
await document.documentElement.requestFullscreen();
if (request !== fullscreenFocusRequest) return;
if (wantsFocus && !mobileFocusActive) setMobileFocus(true);
if (wantsFocus && focusOrientationStatus === 'idle') requestLandscapeLock(focusSession);
focusEntryRequested = false;
} else if (wantsFocus) {
setMobileFocus(true, { orientationStatus: 'unsupported' });
if (status) status.textContent = '전체화면을 지원하지 않아 가로 회전 후 편집할 수 있습니다';
} else if (status) status.textContent = '전체화면을 지원하지 않아 현재 화면에 맞춰 표시합니다';
} catch {
if (request !== fullscreenFocusRequest) return;
focusEntryRequested = false;
if (wantsFocus) setMobileFocus(true, { orientationStatus: 'rejected' });
if (status) status.textContent = wantsFocus ? '브라우저 전체화면을 사용할 수 없어 가로 회전 후 편집할 수 있습니다' : '전체화면을 사용할 수 없습니다';
}
});
document.addEventListener('fullscreenchange', () => {
const status = document.querySelector('#fullscreen-status');
const button = document.querySelector('#toggle-fullscreen');
if (!document.fullscreenElement) { focusEntryRequested = false; setMobileFocus(false); }
else if (focusEntryRequested && !mobileFocusActive && focusViewportEligible()) { setMobileFocus(true); requestLandscapeLock(focusSession); focusEntryRequested = false; }
updateFullscreenButton(button, Boolean(document.fullscreenElement) || mobileFocusActive);
if (status && !document.fullscreenElement && !mobileFocusActive) status.textContent = '';
board.resize();
syncAppViewportHeight();
});
window.addEventListener('resize', () => { syncAppViewportHeight(); cancelActiveDrag(); updateOrientationGate(); board.resize(); });
window.addEventListener('orientationchange', () => { syncAppViewportHeight(); cancelActiveDrag(); updateOrientationGate(); board.resize(); });
window.visualViewport?.addEventListener('resize', syncAppViewportHeight, { passive: true });
window.visualViewport?.addEventListener('scroll', syncAppViewportHeight, { passive: true });
renderUi(); renderUi();
syncAppViewportHeight();
initializeAccount(); initializeAccount();
window.addEventListener('popstate', () => { const generation = ++accountGeneration; ownerGeneration += 1; searchGeneration += 1; activeOwnerTeamId = null; if (currentUser?.isOperator && window.location.pathname === '/admin') openAdmin(generation, false); else if (currentUser && window.location.pathname !== '/admin') openTeamHub('', generation); else if (window.location.pathname === '/admin') { showGate('auth', '로그인 후 운영자 페이지를 이용할 수 있습니다.'); history.replaceState({ view: 'auth' }, '', '/'); } });
function frame(now) { const delta = Math.min(0.25, (now - lastFrame) / 1000); lastFrame = now; if (videoExportActive) { requestAnimationFrame(frame); return; } const sample = selectRenderSample(state.play, { playbackSession, resetPreview: resetPreview || state.mode === 'start', selectedSequence: state.selectedSequence, selectedPlayerId: state.selectedPlayerId, selectedAction: state.selectedAction }, controller, delta); state.playing = sample.playing; if (playbackSession && !sample.playing && sample.elapsed >= sample.totalDuration && document.querySelector('#status').textContent === '재생 중') document.querySelector('#status').textContent = '재생 완료'; document.querySelector('#clock').textContent = `${sample.elapsed.toFixed(1)}s`; document.querySelector('#scrubber').value = playbackSession ? sample.elapsed : 0; document.querySelector('#time-display').textContent = (playbackSession ? sample.elapsed : 0).toFixed(1) + ' / ' + totalDuration(state.play).toFixed(1) + '초'; board.render(state.play, state, sample, delta); requestAnimationFrame(frame); } function frame(now) { const delta = Math.min(0.25, (now - lastFrame) / 1000); lastFrame = now; if (videoExportActive) { requestAnimationFrame(frame); return; } const sample = selectRenderSample(state.play, { playbackSession, resetPreview: resetPreview || state.mode === 'start', selectedSequence: state.selectedSequence, selectedPlayerId: state.selectedPlayerId, selectedAction: state.selectedAction }, controller, delta); state.playing = sample.playing; if (playbackSession && !sample.playing && sample.elapsed >= sample.totalDuration && document.querySelector('#status').textContent === '재생 중') document.querySelector('#status').textContent = '재생 완료'; document.querySelector('#clock').textContent = `${sample.elapsed.toFixed(1)}s`; document.querySelector('#scrubber').value = playbackSession ? sample.elapsed : 0; document.querySelector('#time-display').textContent = (playbackSession ? sample.elapsed : 0).toFixed(1) + ' / ' + totalDuration(state.play).toFixed(1) + '초'; board.render(state.play, state, sample, delta); requestAnimationFrame(frame); }
requestAnimationFrame(frame); requestAnimationFrame(frame);
+6 -5
View File
@@ -99,7 +99,7 @@ export function createBoard(container) {
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x192922); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x192922);
scene.add(new THREE.HemisphereLight(0xe9f6ff, 0x15202c, 2)); scene.add(new THREE.HemisphereLight(0xe9f6ff, 0x15202c, 2));
const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; container.appendChild(renderer.domElement); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; container.appendChild(renderer.domElement);
const cameras = createCameras(1); const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const cameras = createCameras(1); let mobileFocus = false; const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2();
const courtPlane = new THREE.Mesh(new THREE.PlaneGeometry(COURT.width, COURT.length), new THREE.MeshBasicMaterial({ visible: false })); courtPlane.rotation.x = -Math.PI / 2; courtPlane.position.set(0, 0.1, COURT.length / 2); scene.add(courtPlane); const courtPlane = new THREE.Mesh(new THREE.PlaneGeometry(COURT.width, COURT.length), new THREE.MeshBasicMaterial({ visible: false })); courtPlane.rotation.x = -Math.PI / 2; courtPlane.position.set(0, 0.1, COURT.length / 2); scene.add(courtPlane);
addCourt(scene); addCourt(scene);
const playerObjects = new Map(); const pathObjects = new Map(); const screenMarkers = new Map(); const passArrowObjects = new Map(); const arrow = new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(), 1.6, palette.selected, 0.28, 0.2); arrow.visible = false; scene.add(arrow); const playerObjects = new Map(); const pathObjects = new Map(); const screenMarkers = new Map(); const passArrowObjects = new Map(); const arrow = new THREE.ArrowHelper(new THREE.Vector3(0, 0, 1), new THREE.Vector3(), 1.6, palette.selected, 0.28, 0.2); arrow.visible = false; scene.add(arrow);
@@ -108,13 +108,14 @@ export function createBoard(container) {
const possessionHalo = new THREE.Mesh(new THREE.RingGeometry(0.62, 0.76, 32), new THREE.MeshBasicMaterial({ color: 0xb6c8a3, transparent: true, opacity: 0.8, side: THREE.DoubleSide })); possessionHalo.rotation.x = -Math.PI / 2; possessionHalo.position.y = 0.18; possessionHalo.visible = false; scene.add(possessionHalo); const possessionHalo = new THREE.Mesh(new THREE.RingGeometry(0.62, 0.76, 32), new THREE.MeshBasicMaterial({ color: 0xb6c8a3, transparent: true, opacity: 0.8, side: THREE.DoubleSide })); possessionHalo.rotation.x = -Math.PI / 2; possessionHalo.position.y = 0.18; possessionHalo.visible = false; scene.add(possessionHalo);
function resize() { const width = container.clientWidth || 800; const height = container.clientHeight || 600; renderer.setSize(width, height, false); resizeCameras(cameras, width / height); } function resize() { const width = container.clientWidth || 800; const height = container.clientHeight || 600; renderer.setSize(width, height, false); resizeCameras(cameras, width / height); }
function tacticalCamera(targetRenderer = renderer) { return mobileFocus && targetRenderer === renderer ? cameras.tacticalFocus : cameras.tactical; }
let previousPovPlayer = null; let previousElapsed = null; let previousPovPlayer = null; let previousElapsed = null;
function render(play, appState, playbackSample, delta = 0, targetRenderer = renderer, targetViewportHeight = container.clientHeight || 600) { function render(play, appState, playbackSample, delta = 0, targetRenderer = renderer, targetViewportHeight = container.clientHeight || 600) {
scene.getObjectByName('physicalHoop').visible = appState.view === 'pov'; scene.getObjectByName('physicalHoop').visible = appState.view === 'pov';
scene.getObjectByName('physicalBackboard').visible = appState.view === 'pov'; scene.getObjectByName('physicalBackboard').visible = appState.view === 'pov';
scene.getObjectByName('diagramHoop').visible = appState.view !== 'pov'; scene.getObjectByName('diagramHoop').visible = appState.view !== 'pov';
const sequence = play.sequences[playbackSample?.sequenceIndex ?? appState.selectedSequence] || play.sequences[0]; const sequence = play.sequences[playbackSample?.sequenceIndex ?? appState.selectedSequence] || play.sequences[0];
const positions = playbackSample?.locations || Object.fromEntries(play.players.map((player) => [player.id, player.location])); const positions = { ...(playbackSample?.locations || Object.fromEntries(play.players.map((player) => [player.id, player.location]))), ...(appState.boardPreview || {}) };
const activePathKeys = new Set(sequence.tracks.map((track) => `${sequence.id}:${track.playerId}`)); const activeMarkerKeys = new Set(); const activePathKeys = new Set(sequence.tracks.map((track) => `${sequence.id}:${track.playerId}`)); const activeMarkerKeys = new Set();
const activePassArrowKeys = new Set(); const activePassArrowKeys = new Set();
for (const [key, path] of pathObjects) if (!activePathKeys.has(key)) { scene.remove(path); path.geometry.dispose(); path.material.dispose(); pathObjects.delete(key); } for (const [key, path] of pathObjects) if (!activePathKeys.has(key)) { scene.remove(path); path.geometry.dispose(); path.material.dispose(); pathObjects.delete(key); }
@@ -165,9 +166,9 @@ export function createBoard(container) {
} }
previousPovPlayer = appState.selectedPlayerId; previousElapsed = elapsed; previousPovPlayer = appState.selectedPlayerId; previousElapsed = elapsed;
} else { previousPovPlayer = null; previousElapsed = null; } } else { previousPovPlayer = null; previousElapsed = null; }
targetRenderer.render(scene, appState.view === 'pov' ? cameras.pov : cameras.tactical); targetRenderer.render(scene, appState.view === 'pov' ? cameras.pov : tacticalCamera(targetRenderer));
} }
function pick(event, appState) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; const camera = appState.view === 'pov' ? cameras.pov : cameras.tactical; raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects([...playerObjects.values()], true); const playerHit = hits.find((hit) => hit.object.parent?.userData.playerId || hit.object.userData.playerId); if (playerHit) return { type: 'player', playerId: playerHit.object.parent?.userData.playerId || playerHit.object.userData.playerId }; const courtHit = raycaster.intersectObject(courtPlane)[0]; if (courtHit) return { type: 'location', location: { x: courtHit.point.x, z: courtHit.point.z } }; return null; } function pick(event, appState) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; const camera = appState.view === 'pov' ? cameras.pov : tacticalCamera(); raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects([...playerObjects.values()], true); const playerHit = hits.find((hit) => hit.object.parent?.userData.playerId || hit.object.userData.playerId); if (playerHit) return { type: 'player', playerId: playerHit.object.parent?.userData.playerId || playerHit.object.userData.playerId }; if (event.pointerType === 'touch' || event.pointerType === 'pen') { let nearest = null; let best = 24 * 24; for (const [playerId, object] of playerObjects) { if (!object.visible) continue; const projected = object.position.clone().project(camera); const x = rect.left + (projected.x + 1) * rect.width / 2; const y = rect.top + (1 - projected.y) * rect.height / 2; const distance = (x - event.clientX) ** 2 + (y - event.clientY) ** 2; if (distance <= best) { best = distance; nearest = playerId; } } if (nearest) return { type: 'player', playerId: nearest }; } const courtHit = raycaster.intersectObject(courtPlane)[0]; if (courtHit) return { type: 'location', location: { x: courtHit.point.x, z: courtHit.point.z } }; return null; }
function createExportSurface(width = 1280, height = 720) { function createExportSurface(width = 1280, height = 720) {
const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
const exportRenderer = new THREE.WebGLRenderer({ canvas, antialias: true }); exportRenderer.setPixelRatio(1); exportRenderer.setSize(width, height, false); exportRenderer.outputColorSpace = THREE.SRGBColorSpace; const exportRenderer = new THREE.WebGLRenderer({ canvas, antialias: true }); exportRenderer.setPixelRatio(1); exportRenderer.setSize(width, height, false); exportRenderer.outputColorSpace = THREE.SRGBColorSpace;
@@ -180,7 +181,7 @@ export function createBoard(container) {
dispose() { for (const [name, original] of Object.entries(cameraState)) { const camera = cameras[name]; camera.position.copy(original.position); camera.quaternion.copy(original.quaternion); camera.fov = original.fov; camera.aspect = original.aspect; camera.near = original.near; camera.far = original.far; camera.zoom = original.zoom; if (original.view?.enabled) camera.setViewOffset(original.view.fullWidth, original.view.fullHeight, original.view.offsetX, original.view.offsetY, original.view.width, original.view.height); else camera.clearViewOffset(); camera.updateProjectionMatrix(); camera.updateMatrixWorld(); } previousPovPlayer = previousCameraHistory.player; previousElapsed = previousCameraHistory.elapsed; exportRenderer.dispose(); exportRenderer.forceContextLoss?.(); canvas.width = 1; canvas.height = 1; }, dispose() { for (const [name, original] of Object.entries(cameraState)) { const camera = cameras[name]; camera.position.copy(original.position); camera.quaternion.copy(original.quaternion); camera.fov = original.fov; camera.aspect = original.aspect; camera.near = original.near; camera.far = original.far; camera.zoom = original.zoom; if (original.view?.enabled) camera.setViewOffset(original.view.fullWidth, original.view.fullHeight, original.view.offsetX, original.view.offsetY, original.view.width, original.view.height); else camera.clearViewOffset(); camera.updateProjectionMatrix(); camera.updateMatrixWorld(); } previousPovPlayer = previousCameraHistory.player; previousElapsed = previousCameraHistory.elapsed; exportRenderer.dispose(); exportRenderer.forceContextLoss?.(); canvas.width = 1; canvas.height = 1; },
}; };
} }
resize(); new ResizeObserver(resize).observe(container); return { scene, renderer, cameras, render, createExportSurface, pick, resize, courtLocation(event) { const rect = renderer.domElement.getBoundingClientRect(); pointer.set((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1); raycaster.setFromCamera(pointer, cameras.tactical); const hit = raycaster.intersectObject(courtPlane)[0]; return hit ? { x: hit.point.x, z: hit.point.z } : null; } }; resize(); new ResizeObserver(resize).observe(container); return { scene, renderer, cameras, render, createExportSurface, pick, resize, setMobileFocus(active) { const next = Boolean(active); if (next === mobileFocus) return; mobileFocus = next; resize(); }, courtLocation(event) { const rect = renderer.domElement.getBoundingClientRect(); pointer.set((event.clientX - rect.left) / rect.width * 2 - 1, -(event.clientY - rect.top) / rect.height * 2 + 1); raycaster.setFromCamera(pointer, tacticalCamera()); const hit = raycaster.intersectObject(courtPlane)[0]; return hit ? { x: hit.point.x, z: hit.point.z } : null; } };
} }
function trackPointsForPath(track) { return [track.startLocation, ...track.actions.map((action) => action.location)]; } function trackPointsForPath(track) { return [track.startLocation, ...track.actions.map((action) => action.location)]; }
+1 -1
View File
@@ -3,7 +3,7 @@ import { assertValidPlay } from './playRepository.js';
export function createAppState(name = '새 전술', defenseType = 'man-to-man') { export function createAppState(name = '새 전술', defenseType = 'man-to-man') {
const play = createInitialPlay(name, defenseType); const play = createInitialPlay(name, defenseType);
return { play, selectedPlayerId: 'offense-1', selectedSequence: 0, selectedAction: -1, mode: 'start', view: 'tactical', playing: false, history: createSequenceHistory(play) }; return { play, selectedPlayerId: 'offense-1', selectedSequence: 0, selectedAction: -1, mode: 'move', view: 'tactical', playing: false, history: createSequenceHistory(play) };
} }
export function createAppStateFromPlay(sourcePlay) { export function createAppStateFromPlay(sourcePlay) {
assertValidPlay(sourcePlay); const play = cloneValue(sourcePlay); const selectedPlayerId = play.players.some((player) => player.id === 'offense-1') ? 'offense-1' : play.players[0]?.id || null; assertValidPlay(sourcePlay); const play = cloneValue(sourcePlay); const selectedPlayerId = play.players.some((player) => player.id === 'offense-1') ? 'offense-1' : play.players[0]?.id || null;
+531 -8
View File
@@ -33,17 +33,24 @@ h1 { display:flex; align-items:center; gap:10px; color:#f4f2e8; font-size:17px;
.view-group { display:flex; background:#111f19; padding:4px; border-radius:8px; pointer-events:auto; } .view-group button { background:transparent; color:#92a095; border-color:transparent; font-size:10px; min-height:30px; padding:5px 8px; border-radius:5px; } .view-group .icon { width:14px; height:14px; } .view-group button.active { background:#34443a; color:#f3f3e9; box-shadow:0 2px 5px #0002; } .view-group button:hover:not(:disabled) { background:#3b4e42; border-color:transparent; } .view-group { display:flex; background:#111f19; padding:4px; border-radius:8px; pointer-events:auto; } .view-group button { background:transparent; color:#92a095; border-color:transparent; font-size:10px; min-height:30px; padding:5px 8px; border-radius:5px; } .view-group .icon { width:14px; height:14px; } .view-group button.active { background:#34443a; color:#f3f3e9; box-shadow:0 2px 5px #0002; } .view-group button:hover:not(:disabled) { background:#3b4e42; border-color:transparent; }
.canvas-meta { position:absolute; top:85px; left:24px; right:24px; display:flex; justify-content:space-between; color:#829187; font-size:8px; letter-spacing:.09em; pointer-events:none; z-index:1; } .canvas-meta b { margin:0 7px; font-weight:normal; } .canvas-meta { position:absolute; top:85px; left:24px; right:24px; display:flex; justify-content:space-between; color:#829187; font-size:8px; letter-spacing:.09em; pointer-events:none; z-index:1; } .canvas-meta b { margin:0 7px; font-weight:normal; }
.board { position:absolute; inset:108px 10px 110px; } .board canvas { display:block; width:100%; height:100%; touch-action:none; } .board { position:absolute; inset:108px 10px 110px; } .board canvas { display:block; width:100%; height:100%; touch-action:none; }
.mode-group { position:absolute; bottom:43px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:3px; background:#fdfdf7; padding:5px; border-radius:12px; box-shadow:0 7px 20px #0003; max-width:calc(100% - 28px); z-index:2; } .mode-group button { display:flex; flex-direction:column; gap:4px; background:transparent; border-color:transparent; border-radius:8px; color:#6b7970; min-width:49px; padding:7px 10px; font-size:9px; } .mode-group button.active { color:#bf5c35; background:#f8e7da; border-color:#f0d6c4; } .mode-group button:hover:not(:disabled) { background:#f0f0e7; border-color:transparent; } .tool-divider { width:1px; height:28px; background:#e4e7df; margin:0 3px; } .board-tools { position:absolute; bottom:43px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:6px; max-width:calc(100% - 28px); z-index:2; }
.mode-group { position:static; transform:none; display:flex; align-items:center; gap:3px; background:#fdfdf7; padding:5px; border-radius:12px; box-shadow:0 7px 20px #0003; max-width:none; } .mode-group button { display:flex; flex-direction:column; gap:4px; background:transparent; border-color:transparent; border-radius:8px; color:#6b7970; min-width:49px; padding:7px 10px; font-size:9px; } .mode-group button.active { color:#bf5c35; background:#f8e7da; border-color:#f0d6c4; } .mode-group button:hover:not(:disabled) { background:#f0f0e7; border-color:transparent; }
.history-tools { display:flex; align-items:center; gap:3px; flex:0 0 auto; background:#fdfdf7; padding:5px; border-radius:12px; box-shadow:0 7px 20px #0003; } .history-tools button { display:flex; flex-direction:column; gap:4px; min-width:58px; padding:7px; background:transparent; border-color:transparent; border-radius:8px; color:#6b7970; font-size:9px; } .history-tools button:hover:not(:disabled) { background:#f0f0e7; border-color:transparent; } .history-glyph { display:block; font-size:19px; line-height:16px; font-family:ui-monospace,monospace; } .tool-divider { width:1px; height:28px; background:#e4e7df; margin:0 3px; }
.board-tools .mode-group,
.board-tools .history-tools,
.board-tools button { align-items:center; justify-content:center; }
.board-tools button { text-align:center; }
.history-glyph { width:19px; height:19px; display:grid; place-items:center; line-height:1; }
.board-status { position:absolute; bottom:14px; left:24px; right:24px; display:flex; justify-content:space-between; gap:12px; pointer-events:none; color:#a6b3a7; font-size:9px; line-height:1.5; } #status { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } #clock { color:#d8dfd3; font-family:ui-monospace,monospace; flex-shrink:0; } #cancel-gaze { position:absolute; z-index:3; top:105px; left:50%; transform:translateX(-50%); background:#f8e7da; border-color:#e6c8b4; font-size:10px; } .board-status { position:absolute; bottom:14px; left:24px; right:24px; display:flex; justify-content:space-between; gap:12px; pointer-events:none; color:#a6b3a7; font-size:9px; line-height:1.5; } #status { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } #clock { color:#d8dfd3; font-family:ui-monospace,monospace; flex-shrink:0; } #cancel-gaze { position:absolute; z-index:3; top:105px; left:50%; transform:translateX(-50%); background:#f8e7da; border-color:#e6c8b4; font-size:10px; }
.action-row { width:100%; margin:0 0 7px; padding:12px; display:grid; grid-template-columns:26px minmax(0,1fr); text-align:left; justify-content:stretch; gap:5px 9px; background:white; border-color:var(--line); border-radius:9px; } .action-row>span { grid-row:span 2; display:grid; place-items:center; width:25px; height:28px; background:#f0f3eb; color:#83917f; font-family:ui-monospace,monospace; font-size:10px; border-radius:5px; } .action-row b { font-size:11px; font-weight:600; } .action-row small { font-size:9px; color:#8a988d; } .action-row.selected { border-color:#e5ba9f; background:#fffaf5; box-shadow:0 2px 6px #d96c4308; } .action-row.selected>span { background:#f4e0d0; color:#bb7144; } .action-row { width:100%; margin:0 0 7px; padding:12px; display:grid; grid-template-columns:26px minmax(0,1fr); text-align:left; justify-content:stretch; gap:5px 9px; background:white; border-color:var(--line); border-radius:9px; } .action-row>span { grid-row:span 2; display:grid; place-items:center; width:25px; height:28px; background:#f0f3eb; color:#83917f; font-family:ui-monospace,monospace; font-size:10px; border-radius:5px; } .action-row b { font-size:11px; font-weight:600; } .action-row small { font-size:9px; color:#8a988d; } .action-row.selected { border-color:#e5ba9f; background:#fffaf5; box-shadow:0 2px 6px #d96c4308; } .action-row.selected>span { background:#f4e0d0; color:#bb7144; }
.empty { position:relative; padding:50px 18px 24px; margin-bottom:18px; border:1px dashed #dbe2d8; border-radius:12px; color:#8b978e; font-size:11px; text-align:center; line-height:1.9; } .empty::before { content:'↗'; position:absolute; top:12px; left:calc(50% - 14px); width:28px; height:28px; border-radius:8px; display:grid; place-items:center; background:#eaf0e7; color:#85917c; font-size:19px; } .empty { position:relative; padding:50px 18px 24px; margin-bottom:18px; border:1px dashed #dbe2d8; border-radius:12px; color:#8b978e; font-size:11px; text-align:center; line-height:1.9; } .empty::before { content:'↗'; position:absolute; top:12px; left:calc(50% - 14px); width:28px; height:28px; border-radius:8px; display:grid; place-items:center; background:#eaf0e7; color:#85917c; font-size:19px; }
#action-properties { margin:24px 0 0; padding:17px 14px 14px; border:1px solid var(--line); border-radius:12px; min-width:0; background:white; } #action-properties legend { display:flex; align-items:center; gap:7px; padding:0 5px; font-size:11px; font-weight:600; } #action-properties legend .icon { width:14px; height:14px; color:var(--muted); } #action-properties label { display:grid; gap:8px; font-size:10px; font-weight:550; color:#647368; } #action-properties p { margin:10px 0 18px; color:#8a968b; font-size:9px; line-height:1.8; } #action-properties:disabled { opacity:.55; } #action-properties { margin:24px 0 0; padding:17px 14px 14px; border:1px solid var(--line); border-radius:12px; min-width:0; background:white; } #action-properties legend { display:flex; align-items:center; gap:7px; padding:0 5px; font-size:11px; font-weight:600; } #action-properties legend .icon { width:14px; height:14px; color:var(--muted); } #action-properties label { display:grid; gap:8px; font-size:10px; font-weight:550; color:#647368; } #action-properties p { margin:10px 0 18px; color:#8a968b; font-size:9px; line-height:1.8; } #action-properties:disabled { opacity:.55; }
.coordinates { display:grid; grid-template-columns:1fr 1fr; gap:10px; } .coordinates label { display:block!important; color:#415347!important; } .coordinates label span { font-weight:normal; font-size:8px; color:#9aa396; margin-left:4px; } .coordinates input { margin-top:8px; } #apply-position { width:100%; margin-top:13px; background:#edf2e9; border-color:transparent; color:#496448; font-size:10px; justify-content:space-between; } .coordinates { display:grid; grid-template-columns:1fr 1fr; gap:10px; } .coordinates label { display:block!important; color:#415347!important; } .coordinates label span { font-weight:normal; font-size:8px; color:#9aa396; margin-left:4px; } .coordinates input { margin-top:8px; } #apply-position { width:100%; margin-top:13px; background:#edf2e9; border-color:transparent; color:#496448; font-size:10px; justify-content:space-between; }
.danger { color:#a97969; border-color:transparent; background:transparent; font-size:10px; } .full { width:100%; margin-top:9px; } .history-actions { display:flex; gap:7px; padding-top:12px; border-top:1px solid var(--line); margin-top:9px; } .history-actions button { flex:1; background:transparent; color:#7a897d; font-size:10px; min-height:33px; } .shortcut-note { display:flex; align-items:center; justify-content:center; gap:5px; font-size:8px; color:#99a297; margin-top:18px; } kbd { font-family:inherit; padding:3px 5px; border:1px solid #e0e5da; border-radius:4px; font-size:8px; background:#f1f4ed; } .danger { color:#a97969; border-color:transparent; background:transparent; font-size:10px; } .full { width:100%; margin-top:9px; } .shortcut-note { display:flex; align-items:center; justify-content:center; gap:5px; font-size:8px; color:#99a297; margin-top:18px; } kbd { font-family:inherit; padding:3px 5px; border:1px solid #e0e5da; border-radius:4px; font-size:8px; background:#f1f4ed; }
.playback-deck { display:grid; grid-template-columns:175px minmax(0,1fr); grid-template-rows:minmax(0,1fr) 60px; padding:0 26px; border-top:1px solid var(--line); background:white; } .sequence-heading { align-self:center; } .sequence-heading strong { display:block; font-size:13px; margin-top:7px; font-weight:600; } .sequences { display:flex; align-items:center; gap:8px; overflow:auto; min-width:0; scrollbar-width:thin; } .playback-deck { display:grid; grid-template-columns:175px minmax(0,1fr); grid-template-rows:minmax(0,1fr) 60px; padding:0 26px; border-top:1px solid var(--line); background:white; } .sequence-heading { align-self:center; } .sequence-heading strong { display:block; font-size:13px; margin-top:7px; font-weight:600; } .sequences-drawer { display:flex; flex-direction:row; align-items:center; gap:8px; min-width:0; flex:1; grid-column:2; } .sequences { display:flex; flex:1; align-items:center; gap:8px; overflow:auto; min-width:0; scrollbar-width:thin; } .sequence-controls { display:flex; flex-direction:row; align-items:center; gap:8px; flex:0 0 auto; }
.sequence-tab { flex:0 0 auto; min-width:118px; height:64px; justify-content:flex-start; border-radius:8px; background:#f6f8f2; color:#7c8b7c; font-size:10px; } .sequence-tab.selected { background:#eef2e7; color:#456744; border-color:#9db58c; box-shadow:0 0 0 2px #a6be9718; } .sequence-tab svg { width:48px; height:48px; } .sequence-tab .sequence-copy { display:grid; gap:4px; text-align:left; } .sequence-copy small { font-size:8px; color:#9aa58f; } .add-sequence { min-width:45px; height:64px; border-style:dashed; border-color:#d6decf; background:transparent; font-size:20px; font-weight:300; color:#8ea181; } #delete-sequence { margin-left:auto; min-width:40px; } .sequence-tab { flex:0 0 auto; min-width:118px; height:64px; justify-content:flex-start; border-radius:8px; background:#f6f8f2; color:#7c8b7c; font-size:10px; } .sequence-tab.selected { background:#eef2e7; color:#456744; border-color:#9db58c; box-shadow:0 0 0 2px #a6be9718; } .sequence-tab svg { width:48px; height:48px; } .sequence-tab .sequence-copy { display:grid; gap:4px; text-align:left; } .sequence-copy small { font-size:8px; color:#9aa58f; } .add-sequence { min-width:45px; height:64px; border-style:dashed; border-color:#d6decf; background:transparent; font-size:20px; font-weight:300; color:#8ea181; } #delete-sequence { margin-left:auto; min-width:40px; }
.playback-row { grid-column:1 / -1; display:flex; gap:25px; align-items:center; border-top:1px solid var(--line); } .transport { display:flex; gap:9px; align-items:center; width:150px; justify-content:center; flex-shrink:0; } .transport .icon-button { border:0; background:transparent; color:#85927f; } .play-button { border-radius:50%; width:38px; min-height:38px; border-color:var(--ink); background:var(--ink); color:white; padding:8px; } .play-button:hover:not(:disabled) { background:var(--green); border-color:var(--green); } .play-button .icon { width:16px; height:16px; margin-left:2px; fill:currentColor; stroke-width:1; } .playback-row { grid-column:1 / -1; display:flex; gap:25px; align-items:center; border-top:1px solid var(--line); } .transport { display:flex; gap:9px; align-items:center; width:150px; justify-content:center; flex-shrink:0; } .transport .icon-button { border:0; background:transparent; color:#85927f; } .play-button { border-radius:50%; width:38px; min-height:38px; border-color:var(--ink); background:var(--ink); color:white; padding:8px; } .play-button:hover:not(:disabled) { background:var(--green); border-color:var(--green); } .play-button .icon { width:16px; height:16px; margin-left:2px; fill:currentColor; stroke-width:1; }
.timeline { display:flex; align-items:center; gap:12px; min-width:0; flex:1; } .timeline .icon-button { border-color:transparent; width:24px; padding:0; font-size:22px; color:#8e9e88; } .timeline input[type=range] { flex:1; min-width:30px; height:20px; min-height:20px; padding:0; accent-color:var(--accent); background:transparent; border:none; } .timeline output { font-family:ui-monospace,monospace; font-size:10px; color:#7f8c7b; white-space:nowrap; } .timeline select { width:64px; min-height:30px; padding:4px 6px; border-color:transparent; background:transparent; font-size:11px; } #loop { background:transparent; border-color:transparent; color:#889680; font-size:11px; } #loop[aria-pressed=true] { color:var(--green); background:#e7efdf; } .timeline { display:flex; align-items:center; gap:12px; min-width:0; flex:1; } .timeline .icon-button { border-color:transparent; width:24px; padding:0; font-size:22px; color:#8e9e88; } .timeline input[type=range] { flex:1; min-width:30px; height:20px; min-height:20px; padding:0; accent-color:var(--accent); background:transparent; border:none; } .timeline output { font-family:ui-monospace,monospace; font-size:10px; color:#7f8c7b; white-space:nowrap; } .playback-options { display:flex; align-items:center; gap:6px; flex:0 0 auto; } .playback-options select { width:64px; min-height:30px; padding:4px 6px; border-color:transparent; background:transparent; font-size:11px; } #loop { background:transparent; border-color:transparent; color:#889680; font-size:11px; } #loop[aria-pressed=true] { color:var(--green); background:#e7efdf; }
@media(max-width:1180px) { .workspace { grid-template-columns:180px minmax(0,1fr) 230px; gap:12px; padding:14px; } .topbar { padding:0 18px; gap:18px; } .brand { width:170px; } .breadcrumb { display:none; } #save-status { max-width:120px; } .canvas-header { left:17px; right:17px; } .canvas-header h1 { font-size:15px; } .view-group button { gap:4px; padding:5px 6px; } .view-group .icon { display:none; } .mode-group button { min-width:43px; padding:6px 7px; } } @media(max-width:1180px) { .workspace { grid-template-columns:180px minmax(0,1fr) 230px; gap:12px; padding:14px; } .topbar { padding:0 18px; gap:18px; } .brand { width:170px; } .breadcrumb { display:none; } #save-status { max-width:120px; } .canvas-header { left:17px; right:17px; } .canvas-header h1 { font-size:15px; } .view-group button { gap:4px; padding:5px 6px; } .view-group .icon { display:none; } .mode-group button { min-width:43px; padding:6px 7px; } }
@media(max-width:900px) { @media(max-width:900px) {
.shell { grid-template-rows:68px 47px minmax(0,1fr) 136px; min-height:660px; } .topbar { gap:14px; padding:0 16px; } .brand { width:auto; } .brand strong { font-size:24px; } .brand small,#save-status { display:none; } .shell { grid-template-rows:68px 47px minmax(0,1fr) 136px; min-height:660px; } .topbar { gap:14px; padding:0 16px; } .brand { width:auto; } .brand strong { font-size:24px; } .brand small,#save-status { display:none; }
@@ -52,15 +59,15 @@ h1 { display:flex; align-items:center; gap:10px; color:#f4f2e8; font-size:17px;
.playback-deck { padding:0 16px; grid-template-columns:110px minmax(0,1fr); grid-template-rows:minmax(0,1fr) 56px; } .sequence-heading .section-kicker { font-size:7px; } .sequence-heading strong { font-size:11px; } .sequence-tab { height:56px; min-width:105px; padding:5px; } .sequence-tab svg { width:42px; height:42px; } .add-sequence { height:56px; min-width:35px; } .transport { width:110px; gap:3px; } .playback-row { gap:8px; } .timeline { gap:5px; } .timeline output { font-size:9px; } .playback-deck { padding:0 16px; grid-template-columns:110px minmax(0,1fr); grid-template-rows:minmax(0,1fr) 56px; } .sequence-heading .section-kicker { font-size:7px; } .sequence-heading strong { font-size:11px; } .sequence-tab { height:56px; min-width:105px; padding:5px; } .sequence-tab svg { width:42px; height:42px; } .add-sequence { height:56px; min-width:35px; } .transport { width:110px; gap:3px; } .playback-row { gap:8px; } .timeline { gap:5px; } .timeline output { font-size:9px; }
} }
@media(max-width:520px) { @media(max-width:520px) {
.shell { grid-template-rows:92px 44px minmax(0,1fr) 134px; min-height:650px; } .topbar { align-items:flex-start; padding:14px 16px 0; gap:6px; } .brand { gap:6px; } .brand-mark { width:27px; height:27px; border-radius:8px; } .brand-mark .icon { width:20px; height:20px; } .brand strong { font-size:20px; white-space:nowrap; } .document-title { position:absolute; bottom:7px; left:16px; right:16px; } #play-name { width:100%; min-height:28px; font-size:12px; padding-block:4px; } .top-actions { margin-left:auto; gap:4px; } .top-actions button { min-height:32px; padding:6px 9px; font-size:10px; } .top-actions .icon { width:15px; height:15px; } #toggle-menu { width:32px; } .project-menu { top:54px; right:12px; width:min(330px,calc(100vw - 24px)); } .shell { grid-template-rows:92px 44px minmax(0,1fr) 134px; min-height:650px; } .topbar { align-items:flex-start; padding:14px 16px 0; gap:6px; } .brand { gap:6px; } .brand-mark { width:27px; height:27px; border-radius:8px; } .brand-mark .icon { width:20px; height:20px; } .brand strong { font-size:20px; white-space:nowrap; } .document-title { position:absolute; bottom:7px; left:16px; right:16px; } #play-name { width:100%; min-height:28px; font-size:12px; padding-block:4px; } .top-actions { margin-left:auto; gap:4px; } .top-actions button { min-height:32px; padding:6px 9px; font-size:10px; } .top-actions .icon { width:15px; height:15px; } #toggle-fullscreen { width:32px; min-width:32px; padding:6px; font-size:0; } #toggle-fullscreen::after { content:'⛶'; font-size:17px; line-height:1; } #toggle-menu { width:32px; } .project-menu { top:54px; right:12px; width:min(330px,calc(100vw - 24px)); }
.mobile-tabs { padding:3px 14px; } .mobile-tabs button { flex:1; min-width:0; } .workspace { padding:0 10px 10px; } .workspace .panel { left:10px; right:10px; bottom:10px; padding:18px; } .board-wrap { border-radius:13px; } .canvas-header { top:17px; left:16px; right:16px; } .canvas-header h1 { font-size:14px; } .canvas-header .section-kicker { font-size:7px; } .view-group { padding:3px; } .view-group button { font-size:9px; padding:4px 6px; } .canvas-meta { top:74px; left:16px; right:16px; font-size:7px; } .board { inset:91px 0 98px; } .mode-group { bottom:37px; padding:4px; border-radius:10px; } .mode-group button { min-width:52px; padding:6px 8px; min-height:47px; } .board-status { left:16px; right:16px; bottom:12px; font-size:8px; } .mobile-tabs { padding:3px 14px; } .mobile-tabs button { flex:1; min-width:0; } .workspace { padding:0 10px 10px; } .workspace .panel { left:10px; right:10px; bottom:10px; padding:18px; } .board-wrap { border-radius:13px; } .canvas-header { top:17px; left:16px; right:16px; } .canvas-header h1 { font-size:14px; } .canvas-header .section-kicker { font-size:7px; } .view-group { padding:3px; } .view-group button { font-size:9px; padding:4px 6px; } .canvas-meta { top:74px; left:16px; right:16px; font-size:7px; } .board { inset:91px 0 98px; } .mode-group { bottom:37px; padding:4px; border-radius:10px; flex-wrap:wrap; justify-content:center; } .mode-group button { min-width:52px; padding:6px 8px; min-height:47px; } .mode-group .history-button { min-width:58px; } .board-status { left:16px; right:16px; bottom:12px; font-size:8px; }
.playback-deck { grid-template-columns:74px minmax(0,1fr); padding:0 12px; grid-template-rows:minmax(0,1fr) 58px; } .sequence-heading .section-kicker { font-size:6px; letter-spacing:.07em; } .sequence-heading strong { font-size:10px; } .sequence-tab { min-width:98px; } .transport { width:98px; gap:1px; } .transport .icon-button { width:28px; padding:5px; } .play-button { width:34px; min-height:34px; } .timeline { gap:4px; flex-wrap:wrap; position:relative; padding-top:12px; } .timeline input[type=range] { position:absolute; left:0; right:0; top:1px; width:100%; height:10px; min-height:10px; } .timeline .icon-button { min-height:27px; width:19px; } .timeline output { margin-left:auto; font-size:8px; } .timeline select { width:45px; font-size:9px; min-height:27px; padding:1px; } #loop { min-height:27px; padding:2px 4px; font-size:15px; } #loop span,.shortcut-note { display:none; } .playback-deck { grid-template-columns:74px minmax(0,1fr); padding:0 12px; grid-template-rows:minmax(0,1fr) 58px; } .sequence-heading .section-kicker { font-size:6px; letter-spacing:.07em; } .sequence-heading strong { font-size:10px; } .sequence-tab { min-width:98px; } .transport { width:98px; gap:1px; } .transport .icon-button { width:28px; padding:5px; } .play-button { width:34px; min-height:34px; } .timeline { gap:4px; flex-wrap:wrap; position:relative; padding-top:12px; } .timeline input[type=range] { position:absolute; left:0; right:0; top:1px; width:100%; height:10px; min-height:10px; } .timeline .icon-button { min-height:27px; width:19px; } .timeline output { margin-left:auto; font-size:8px; } .playback-options select { width:45px; font-size:9px; min-height:27px; padding:1px; } #loop { min-height:27px; padding:2px 4px; font-size:15px; } #loop span,.shortcut-note { display:none; }
} }
@media(max-height:550px) and (min-width:521px) and (max-width:1000px) { @media(max-height:550px) and (min-width:521px) and (max-width:1000px) {
.shell { min-height:350px; grid-template-rows:52px minmax(0,1fr) 64px; } .topbar { padding-inline:14px; } .brand-mark { height:30px; width:30px; } .brand small { display:none; } .brand strong { font-size:23px; } .shell { min-height:350px; grid-template-rows:52px minmax(0,1fr) 64px; } .topbar { padding-inline:14px; } .brand-mark { height:30px; width:30px; } .brand small { display:none; } .brand strong { font-size:23px; }
.mobile-tabs { position:absolute; left:12px; top:67px; width:110px; z-index:5; flex-direction:column; padding:0; } .mobile-tabs button { width:100%; background:var(--paper); justify-content:flex-start; min-height:38px; } .workspace { position:relative; display:block; padding:8px 12px 8px 132px; } .board-wrap { height:100%; } .workspace .panel { display:none; position:absolute; inset:8px 12px 8px 132px; z-index:5; background:var(--paper); padding:15px; overflow:auto; border-radius:12px; } .shell[data-panel=players] .left-panel,.shell[data-panel=actions] .right-panel { display:block; } .mobile-tabs { position:absolute; left:12px; top:67px; width:110px; z-index:5; flex-direction:column; padding:0; } .mobile-tabs button { width:100%; background:var(--paper); justify-content:flex-start; min-height:38px; } .workspace { position:relative; display:block; padding:8px 12px 8px 132px; } .board-wrap { height:100%; } .workspace .panel { display:none; position:absolute; inset:8px 12px 8px 132px; z-index:5; background:var(--paper); padding:15px; overflow:auto; border-radius:12px; } .shell[data-panel=players] .left-panel,.shell[data-panel=actions] .right-panel { display:block; }
.canvas-header { top:13px; left:15px; right:15px; } .canvas-header .section-kicker,.canvas-meta { display:none; } .canvas-header h1 { font-size:12px; margin:0; } .board { inset:45px 110px 18px 0; } .mode-group { top:57px; bottom:auto; left:auto; right:14px; transform:none; display:grid; grid-template-columns:1fr 1fr; gap:2px; padding:4px; } .mode-group button { min-width:35px; padding:4px 6px; font-size:8px; } .mode-group .icon { width:16px; height:16px; } .tool-divider { display:none; } .board-status { left:14px; right:14px; bottom:5px; font-size:8px; } .canvas-header { top:13px; left:15px; right:15px; } .canvas-header .section-kicker,.canvas-meta { display:none; } .canvas-header h1 { font-size:12px; margin:0; } .board { inset:45px 110px 18px 0; } .mode-group { top:57px; bottom:auto; left:auto; right:14px; transform:none; display:grid; grid-template-columns:1fr 1fr; gap:2px; padding:4px; } .mode-group button { min-width:35px; padding:4px 6px; font-size:8px; } .mode-group .icon { width:16px; height:16px; } .tool-divider { display:none; } .board-status { left:14px; right:14px; bottom:5px; font-size:8px; }
.playback-deck { display:flex; align-items:center; gap:16px; padding:0 14px; } .sequence-heading { display:none; } .sequences { max-width:220px; flex:0 1 auto; } .sequence-tab,.add-sequence { height:43px; min-width:34px; } .sequence-tab svg { width:27px; height:31px; } .sequence-copy small,#delete-sequence { display:none; } .playback-row { flex:1; min-width:0; border:0; gap:6px; } .transport { width:100px; gap:2px; } .timeline { gap:4px; } .timeline output { font-size:8px; } .timeline select { width:47px; } .playback-deck { display:flex; align-items:center; gap:16px; padding:0 14px; } .sequence-heading { display:none; } .sequences-drawer { max-width:220px; flex:0 1 auto; } .sequences { flex:0 1 auto; } .sequence-tab,.add-sequence { height:43px; min-width:34px; } .sequence-tab svg { width:27px; height:31px; } .sequence-copy small,#delete-sequence { display:none; } .playback-row { flex:1; min-width:0; border:0; gap:6px; } .transport { width:100px; gap:2px; } .timeline { gap:4px; } .timeline output { font-size:8px; } .playback-options select { width:47px; }
} }
@media(prefers-reduced-motion:reduce) { *,*::before,*::after { transition:none!important; } } @media(prefers-reduced-motion:reduce) { *,*::before,*::after { transition:none!important; } }
@@ -76,3 +83,519 @@ h1 { display:flex; align-items:center; gap:10px; color:#f4f2e8; font-size:17px;
.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-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; } }
/* Team membership and library additions. */
.owner-team-name { margin:4px 0 16px; color:var(--ink); font-size:15px; font-weight:650; }
.owner-subheading { margin:18px 0 5px; font-size:11px; color:var(--ink); }
#owner-panel .pending-user { align-items:center; }
#owner-panel .pending-user > span { min-width:0; overflow-wrap:anywhere; flex:1 1 180px; }
#owner-panel .pending-user select { min-width:86px; }
#owner-panel .pending-user button { flex:0 0 auto; white-space:nowrap; }
.editor-role-banner { position:absolute; z-index:3; top:15px; left:50%; transform:translateX(-50%); max-width:calc(100% - 32px); padding:7px 12px; border:1px solid #e8cdbb; border-radius:8px; background:#fff7f0; color:#8b5f49; font-size:10px; text-align:center; box-shadow:0 3px 10px #00000012; }
.join-request-row { margin:7px 0 0; color:var(--muted); font-size:10px; }
.play-library-list { display:grid; gap:8px; margin:12px 0 18px; max-height:300px; overflow:auto; }
.play-library-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px; border:1px solid var(--line); border-radius:9px; background:#fbfcf9; }
.play-library-row > div { min-width:0; display:grid; gap:3px; }
.play-library-row strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.play-library-row small { color:var(--muted); 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-status:empty,#play-library-message:empty { display:none; }
#fullscreen-status { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; }
@media(max-width:520px) {
#owner-panel .pending-user { align-items:stretch; }
#owner-panel .pending-user select,#owner-panel .pending-user button { flex:1 1 86px; }
.editor-role-banner { top:10px; font-size:9px; }
.pending-user { flex-wrap:wrap; align-items:center; }
.pending-user > :first-child { min-width:0; flex:1 1 100%; overflow-wrap:anywhere; }
.pending-user select,.pending-user button { flex:0 0 auto; min-width:86px; }
.play-library-row { align-items:flex-start; }
}
/* Explicit mobile/tablet focus: the board keeps its rectangle while tools float above it. */
.shell[data-mobile-focus="true"] { position:fixed; inset:0; z-index:40; min-height:0; height:100dvh; display:block; overflow:hidden; background:var(--court); }
.shell[data-mobile-focus="true"] .topbar { position:absolute; inset:0 0 auto; z-index:10; height:48px; padding:6px 10px; background:#10221bdd; border:0; }
.shell[data-mobile-focus="true"] .brand,
.shell[data-mobile-focus="true"] .document-title,
.shell[data-mobile-focus="true"] #save-status { display:none; }
.shell[data-mobile-focus="true"] .top-actions { width:100%; justify-content:flex-end; gap:5px; margin:0; }
.shell[data-mobile-focus="true"] .top-actions button { min-height:34px; padding:6px 9px; background:#fdfdf7ef; border-color:#dce4d8; color:#415b4b; font-size:10px; box-shadow:0 3px 10px #0003; }
.shell[data-mobile-focus="true"] .workspace { position:absolute; inset:0; display:block; padding:0; }
.shell[data-mobile-focus="true"] .board-wrap { position:absolute; inset:0; height:auto; border-radius:0; box-shadow:none; }
.shell[data-mobile-focus="true"] .canvas-header,
.shell[data-mobile-focus="true"] .canvas-meta { display:none; }
.shell[data-mobile-focus="true"] .board { inset:0; }
.shell[data-mobile-focus="true"] .board-status { left:10px; right:10px; bottom:56px; font-size:9px; }
.shell[data-mobile-focus="true"] .mode-group { bottom:62px; }
.shell[data-mobile-focus="true"] .mobile-tabs { position:absolute; z-index:8; top:54px; left:8px; display:flex; justify-content:flex-start; gap:4px; padding:0; pointer-events:none; }
.shell[data-mobile-focus="true"] .mobile-tabs button { pointer-events:auto; min-width:36px; width:36px; min-height:34px; padding:6px; font-size:0; background:#fdfdf7ef; border-color:#dce4d8; box-shadow:0 3px 10px #0003; }
.shell[data-mobile-focus="true"] .mobile-tabs button .icon { width:17px; height:17px; }
.shell[data-mobile-focus="true"] .workspace .panel { position:absolute; z-index:7; display:none; padding:16px; background:var(--paper); border:1px solid var(--line); border-radius:12px; box-shadow:var(--shadow); overflow:auto; }
.shell[data-mobile-focus="true"][data-panel="players"] .left-panel,
.shell[data-mobile-focus="true"][data-panel="actions"] .right-panel { display:block; }
.shell[data-mobile-focus="true"] .playback-deck { position:absolute; z-index:8; inset:auto 0 0; height:52px; display:flex; align-items:center; gap:10px; padding:0 8px; background:#10221bdd; border-top:1px solid #ffffff22; }
.shell[data-mobile-focus="true"] .sequence-heading { display:none; }
.shell[data-mobile-focus="true"] .sequences { min-width:0; flex:1; }
.shell[data-mobile-focus="true"] .sequence-tab,
.shell[data-mobile-focus="true"] .add-sequence { height:40px; min-width:70px; }
.shell[data-mobile-focus="true"] .sequence-tab svg { width:30px; height:30px; }
.shell[data-mobile-focus="true"] .sequence-copy small,
.shell[data-mobile-focus="true"] #delete-sequence { display:none; }
.shell[data-mobile-focus="true"] .playback-row { flex:0 0 190px; border:0; gap:4px; }
.shell[data-mobile-focus="true"] .transport { width:68px; gap:1px; }
.shell[data-mobile-focus="true"] .transport .icon-button { width:26px; min-height:30px; }
.shell[data-mobile-focus="true"] .play-button { width:32px; min-height:32px; }
.shell[data-mobile-focus="true"] .timeline { gap:3px; }
.shell[data-mobile-focus="true"] .timeline input[type=range] { min-width:32px; }
.shell[data-mobile-focus="true"] .timeline output,
.shell[data-mobile-focus="true"] .timeline select,
.shell[data-mobile-focus="true"] #loop { display:none; }
@media (orientation:landscape) {
.shell[data-mobile-focus="true"] {
--focus-gutter-width:clamp(160px,calc((100dvw - 101.3514dvh) / 2 - 16px),270px);
}
.shell[data-mobile-focus="true"] .topbar {
left:auto;
right:0;
width:var(--focus-gutter-width);
height:auto;
min-height:0;
padding:6px 8px;
background:transparent;
}
.shell[data-mobile-focus="true"] .top-actions {
width:100%;
flex-wrap:wrap;
justify-content:flex-end;
}
.shell[data-mobile-focus="true"] #open-team-hub,
.shell[data-mobile-focus="true"] #video-share-open { display:none; }
.shell[data-mobile-focus="true"] .top-actions > #save-play,
.shell[data-mobile-focus="true"] .top-actions > #toggle-fullscreen,
.shell[data-mobile-focus="true"] .top-actions > #toggle-focus-playback,
.shell[data-mobile-focus="true"] .top-actions > #toggle-menu {
width:36px;
min-width:36px;
padding:6px;
font-size:0;
}
.shell[data-mobile-focus="true"] #save-play span { display:none; }
.shell[data-mobile-focus="true"] #toggle-fullscreen::after { content:'⛶'; font-size:17px; }
.shell[data-mobile-focus="true"] #toggle-focus-playback::after { content:'단계'; font-size:9px; line-height:1; white-space:nowrap; }
.shell[data-mobile-focus="true"] .workspace .panel { top:8px; bottom:8px; width:var(--focus-gutter-width); }
.shell[data-mobile-focus="true"] .mobile-tabs {
top:8px;
left:8px;
right:auto;
width:var(--focus-gutter-width);
flex-direction:row;
}
.shell[data-mobile-focus="true"][data-panel="players"] .left-panel { left:8px; right:auto; top:52px; }
.shell[data-mobile-focus="true"][data-panel="actions"] .right-panel { left:auto; right:8px; top:92px; }
.shell[data-mobile-focus="true"][data-panel="actions"] .mode-group { display:none; }
}
@media (orientation:portrait) {
.shell[data-mobile-focus="true"] .workspace .panel { top:52px; right:8px; bottom:52px; left:8px; }
}
@media(max-width:560px) {
.shell[data-mobile-focus="true"] .top-actions button { font-size:0; width:36px; padding:6px; }
.shell[data-mobile-focus="true"] .top-actions #toggle-fullscreen::after { content:'⛶'; font-size:17px; }
.shell[data-mobile-focus="true"] .playback-row { flex-basis:120px; }
.shell[data-mobile-focus="true"] .transport { display:none; }
}
/* Focus controls are mobile-only; desktop keeps the standard top bar. */
#toggle-fullscreen,#toggle-focus-playback { display:none; }
.shell[data-mobile-focus="true"] #toggle-fullscreen,
.shell[data-mobile-focus="true"] #toggle-focus-playback { display:inline-flex; }
@media (max-width:800px), (pointer:coarse) and (max-height:800px), (max-width:900px) and (max-height:550px) {
#toggle-fullscreen { display:inline-flex; }
}
.shell[data-mobile-focus="true"] .topbar { background:transparent; }
.shell[data-mobile-focus="true"] .playback-deck { display:none; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-deck { display:flex; top:auto; left:8px; right:8px; bottom:52px; height:52px; }
@media (orientation:landscape) {
.shell[data-mobile-focus="true"] .mode-group { left:auto; right:8px; transform:none; top:92px; bottom:auto; }
.shell[data-mobile-focus="true"] .mode-group button { min-width:36px; min-height:36px; padding:5px 6px; font-size:8px; }
.shell[data-mobile-focus="true"] .mode-group .icon { width:16px; height:16px; }
.shell[data-mobile-focus="true"] .mode-group .tool-divider { display:none; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-deck {
left:8px;
right:auto;
top:52px;
bottom:8px;
width:var(--focus-gutter-width);
height:auto;
flex-direction:column;
align-items:stretch;
gap:8px;
padding:8px;
overflow:hidden;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequences {
flex-direction:column;
align-items:stretch;
overflow:auto;
flex:1 1 auto;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequence-tab,
.shell[data-mobile-focus="true"][data-focus-playback="open"] .add-sequence {
width:100%;
min-width:0;
flex:0 0 auto;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-row {
flex:0 0 auto;
width:100%;
flex-wrap:wrap;
gap:4px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline { min-width:0; flex:1 1 90px; }
}
/* Focus-only controls stay out of the normal desktop and portrait navigation. */
#toggle-focus-actions { display:none; }
.shell[data-mobile-focus="true"] #toggle-focus-actions { display:inline-flex; }
.shell[data-mobile-focus="true"] .mobile-tabs button[data-panel="court"],
.shell[data-mobile-focus="true"] .mobile-tabs button[data-panel="actions"] { display:none; }
.shell[data-mobile-focus="true"] .mobile-tabs button[data-panel="players"] { display:inline-flex; }
#orientation-gate { display:grid; place-items:center; gap:9px; position:fixed; inset:0; z-index:60; padding:24px; background:#10221bd9; color:#fff; text-align:center; }
#orientation-gate strong { font-size:20px; }
#orientation-gate span { max-width:260px; font-size:12px; line-height:1.6; }
.shell[data-orientation-gate="false"] #orientation-gate { display:none; }
@media (orientation:landscape) {
.shell[data-mobile-focus="true"] {
--focus-gutter-width:clamp(150px,calc((100dvw - 101.3514dvh) / 2 - 12px),210px);
}
.shell[data-mobile-focus="true"] .top-actions > #toggle-focus-actions {
width:36px; min-width:36px; padding:6px; font-size:0;
}
.shell[data-mobile-focus="true"] #toggle-focus-actions::after { content:'행동'; font-size:9px; line-height:1; }
.shell[data-mobile-focus="true"] #toggle-focus-actions { white-space:nowrap; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-deck {
display:flex; flex-direction:column; align-items:stretch; gap:8px;
left:8px; right:auto; top:8px; bottom:8px; width:var(--focus-gutter-width); height:auto;
padding:8px; overflow:hidden;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequences {
display:flex; flex-direction:column; align-items:stretch; gap:5px;
min-height:0; max-width:none; flex:1 1 auto; overflow:auto; padding:1px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequence-tab,
.shell[data-mobile-focus="true"][data-focus-playback="open"] .add-sequence {
width:100%; min-width:0; height:40px; flex:0 0 auto;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-row {
display:flex; flex-direction:column; align-items:stretch; gap:8px;
width:100%; flex:0 0 auto; min-height:0;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline {
order:1; min-width:0; width:100%; flex:0 0 auto; flex-wrap:wrap;
border-top:1px solid #ffffff2e; padding-top:8px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline input[type="range"] { min-width:30px; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .transport {
order:2; width:100%; justify-content:center; gap:4px;
border-top:1px solid #ffffff2e; padding-top:8px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .transport .icon-button { width:30px; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline output,
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline select,
.shell[data-mobile-focus="true"][data-focus-playback="open"] #loop { display:inline-flex; }
}
@media (orientation:portrait) {
.shell[data-mobile-focus="true"] #orientation-gate { display:grid; }
.shell[data-mobile-focus="true"][data-orientation-gate="false"] #orientation-gate { display:none; }
}
/* The focused landscape drawer lives in the side gutter without changing the court viewport. */
@media (orientation:landscape) {
.shell[data-mobile-focus="true"] {
--focus-gutter-width:clamp(140px,calc((100dvw - 101.3514dvh) / 2 - 12px),160px);
--focus-panel-width:clamp(160px,calc((100dvw - 101.3514dvh) / 2 - 12px),270px);
}
.shell[data-mobile-focus="true"] .workspace .right-panel { width:var(--focus-panel-width); }
.shell[data-mobile-focus="true"] .workspace .right-panel { min-width:0; overflow-x:hidden; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-deck {
left:8px; right:auto; top:52px; bottom:8px; width:var(--focus-gutter-width); height:auto;
transform:none; padding:8px; overflow:hidden;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequences-drawer {
display:flex; flex:1 1 auto; min-height:0; width:100%; flex-direction:column; gap:8px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequences {
display:flex; flex:1 1 auto; min-height:0; max-width:none; width:100%;
flex-direction:column; align-items:stretch; gap:5px; overflow:auto; padding:1px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequence-tab {
width:100%; min-width:0; height:40px; flex:0 0 auto;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequence-controls {
display:flex; flex:0 0 auto; flex-direction:row; gap:5px;
padding-top:8px; border-top:1px solid #ffffff2e;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequence-controls .add-sequence,
.shell[data-mobile-focus="true"][data-focus-playback="open"] .sequence-controls #delete-sequence {
display:flex; flex:1; width:auto; min-width:0; height:34px; margin:0;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-row {
display:flex; flex:0 0 auto; width:100%; min-height:0; flex-direction:column; align-items:stretch; gap:8px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline {
order:1; display:flex; flex:0 0 auto; min-width:0; width:100%; flex-wrap:wrap;
gap:3px; padding-top:8px; border-top:1px solid #ffffff2e;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline input[type="range"] { min-width:30px; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline output { display:inline-flex; font-size:8px; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .transport {
order:2; width:100%; justify-content:center; gap:4px; padding-top:8px; border-top:1px solid #ffffff2e;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .transport .icon-button { width:30px; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-options {
order:3; display:flex; width:100%; align-items:center; justify-content:center; gap:4px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-options select {
width:58px; min-height:28px; padding:3px 4px; font-size:9px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .playback-options #loop {
display:inline-flex; min-height:28px; padding:3px 4px; font-size:14px;
}
.shell[data-mobile-focus="true"][data-panel="actions"] .empty { padding:24px 12px 16px; margin-bottom:10px; }
}
#orientation-gate { align-content:center; }
#orientation-gate-exit { min-height:36px; margin-top:7px; border-color:#ffffff44; background:#ffffff14; color:#fff; }
/* Keep the history box separate from the mode tools in the regular editor. */
.focus-bottom-actions { display:none; }
@media (max-width:520px) {
.board-tools { bottom:37px; width:calc(100% - 28px); gap:4px; flex-direction:column; justify-content:center; }
.mode-group { width:100%; justify-content:center; }
.history-tools { flex:0 0 auto; }
}
/* Focus mode reserves the right gutter for tools and leaves the court viewport unchanged. */
@media (orientation:landscape) {
.shell[data-mobile-focus="true"] .top-actions { flex-wrap:nowrap; }
.shell[data-mobile-focus="true"] .top-actions > #save-play,
.shell[data-mobile-focus="true"] .top-actions > #toggle-fullscreen,
.shell[data-mobile-focus="true"] .top-actions > #toggle-menu {
width:36px; min-width:36px; padding:6px; font-size:0;
}
.shell[data-mobile-focus="true"] .board-tools {
top:50%; right:8px; bottom:auto; left:auto; width:var(--focus-gutter-width); max-width:none;
transform:translateY(-50%); flex-direction:column; align-items:stretch; gap:8px;
}
.shell[data-mobile-focus="true"] .mode-group {
width:100%; max-width:none; position:static; transform:none; display:grid;
grid-template-columns:1fr 1fr; gap:2px; padding:4px;
}
.shell[data-mobile-focus="true"] .mode-group button {
min-width:0; min-height:36px; padding:5px 6px; font-size:8px;
}
.shell[data-mobile-focus="true"] .mode-group .icon { width:16px; height:16px; }
.shell[data-mobile-focus="true"] .history-tools {
width:100%; justify-content:center; padding:4px; gap:2px;
}
.shell[data-mobile-focus="true"] .history-tools button {
flex:1 1 0; min-width:0; min-height:36px; padding:5px 4px; font-size:8px;
}
.shell[data-mobile-focus="true"] .focus-bottom-actions {
position:absolute; right:8px; bottom:8px; z-index:10; display:flex; gap:5px;
}
.shell[data-mobile-focus="true"] .focus-bottom-actions button {
min-width:52px; min-height:34px; padding:6px 8px; background:#fdfdf7ef;
border-color:#dce4d8; color:#415b4b; font-size:10px; box-shadow:0 3px 10px #0003;
white-space:nowrap; align-items:center; justify-content:center;
}
.shell[data-mobile-focus="true"] .focus-bottom-actions #toggle-focus-playback::after,
.shell[data-mobile-focus="true"] .focus-bottom-actions #toggle-focus-actions::after { content:none; }
.shell[data-mobile-focus="true"][data-panel="actions"] .board-tools { display:none; }
.shell[data-mobile-focus="true"][data-panel="actions"] .right-panel { bottom:56px; }
}
/* In portrait editor mode, reserve the tool boxes in normal document flow. */
@media (orientation:portrait) and (max-width:900px) {
.shell:not([data-mobile-focus="true"]) {
height:auto; min-height:100dvh; grid-template-rows:68px 47px auto 136px;
}
.shell:not([data-mobile-focus="true"]) .workspace { min-height:0; }
.shell:not([data-mobile-focus="true"]) .board-wrap { display:flex; flex-direction:column; height:auto; }
.shell:not([data-mobile-focus="true"]) .board {
position:relative; inset:auto; flex:1 1 auto; min-height:clamp(280px,85vw,420px); width:auto; margin:108px 10px 12px;
}
.shell:not([data-mobile-focus="true"]) .board-tools {
position:static; transform:none; width:calc(100% - 28px); max-width:none; margin:0 auto 37px;
flex:0 0 auto; flex-direction:column; align-items:center;
}
}
@media (orientation:portrait) and (max-width:520px) {
.shell:not([data-mobile-focus="true"]) { grid-template-rows:92px 44px auto 134px; }
.shell:not([data-mobile-focus="true"]) .board { margin:91px 0 12px; }
}
@media (orientation:portrait) and (max-width:900px) {
.shell:not([data-mobile-focus="true"]),
.shell:not([data-mobile-focus="true"]) .workspace,
.shell:not([data-mobile-focus="true"]) .board-wrap {
width:100%; max-width:100%; min-width:0;
}
}
/* Keep the compact editor header on narrow portrait tablets as well as phones. */
@media (orientation:portrait) and (max-width:600px) {
.shell:not([data-mobile-focus="true"]) { grid-template-rows:92px 44px auto 134px; }
.shell:not([data-mobile-focus="true"]) .topbar {
min-width:0; gap:6px; padding:14px 16px 0; align-items:flex-start;
}
.shell:not([data-mobile-focus="true"]) .brand { width:auto; gap:6px; min-width:0; }
.shell:not([data-mobile-focus="true"]) .brand strong { font-size:20px; }
.shell:not([data-mobile-focus="true"]) .brand small,
.shell:not([data-mobile-focus="true"]) #save-status { display:none; }
.shell:not([data-mobile-focus="true"]) .document-title {
position:absolute; bottom:7px; left:16px; right:16px;
}
.shell:not([data-mobile-focus="true"]) #play-name { width:100%; min-height:28px; font-size:12px; padding-block:4px; }
.shell:not([data-mobile-focus="true"]) .top-actions {
min-width:0; max-width:100%; margin-left:auto; gap:4px; flex-shrink:1;
}
.shell:not([data-mobile-focus="true"]) .top-actions button { min-width:0; flex-shrink:1; min-height:32px; padding:6px 9px; white-space:nowrap; }
.shell:not([data-mobile-focus="true"]) #open-team-hub { width:64px; max-width:64px; overflow:hidden; text-overflow:ellipsis; padding-inline:4px; }
.shell:not([data-mobile-focus="true"]) #video-share-open span,
.shell:not([data-mobile-focus="true"]) #save-play span { display:none; }
.shell:not([data-mobile-focus="true"]) #video-share-open,
.shell:not([data-mobile-focus="true"]) #save-play { width:34px; min-width:34px; padding:6px; }
.shell:not([data-mobile-focus="true"]) #toggle-fullscreen { width:32px; min-width:32px; padding:6px; font-size:0; }
.shell:not([data-mobile-focus="true"]) #toggle-fullscreen::after { content:'⛶'; font-size:17px; line-height:1; }
.shell:not([data-mobile-focus="true"]) #toggle-menu { width:32px; min-width:32px; }
}
/* The focused board occupies only the space between the left and right gutters. */
@media (orientation:landscape) {
.shell[data-mobile-focus="true"] { --focus-panel-width:var(--focus-gutter-width); }
.shell[data-mobile-focus="true"] .board {
inset:0 calc(var(--focus-gutter-width) + 8px);
}
.shell[data-mobile-focus="true"] .board-status {
left:10px;
right:10px;
}
.shell[data-mobile-focus="true"] .mode-group:not(:has([data-mode="pass"]:not([hidden]))):has(#shoot-action[hidden]) [data-mode="screen"],
.shell[data-mobile-focus="true"] .mode-group:not(:has([data-mode="pass"][hidden])):not(:has(#shoot-action[hidden])) #shoot-action {
grid-column:1 / -1; justify-self:center;
}
}
/* Keep compact focus controls centered even when their text labels are hidden. */
.mobile-tabs button { align-items:center; justify-content:center; }
.shell[data-mobile-focus="true"] .mobile-tabs button {
align-items:center; justify-content:center; gap:0; line-height:1; text-align:center;
}
.shell[data-mobile-focus="true"] .mobile-tabs button .icon { flex:0 0 auto; margin:0; }
/* The fullscreen control uses a real icon so its hit area and visual glyph share a center. */
#toggle-fullscreen {
align-items:center; justify-content:center; gap:0; line-height:1;
}
#toggle-fullscreen::after { content:none !important; }
#toggle-fullscreen .fullscreen-label { display:none; }
#toggle-fullscreen .fullscreen-icon {
display:grid; place-items:center; flex:0 0 auto; width:17px; height:17px; line-height:1;
}
#toggle-fullscreen .fullscreen-icon .icon { display:block; width:100%; height:100%; margin:0; }
@keyframes fullscreen-prompt {
0%, 100% { transform:scale(1); box-shadow:0 0 0 0 #087f8c66, 0 3px 8px #087f8c44; }
50% { transform:scale(1.04); box-shadow:0 0 0 5px #087f8c00, 0 6px 16px #087f8c66; }
}
@media (max-width:800px), (pointer:coarse) and (max-height:800px), (max-width:900px) and (max-height:550px) {
.shell:not([data-mobile-focus="true"]) #toggle-fullscreen {
color:#fff; border-color:#087f8c; background:#087f8c;
animation:fullscreen-prompt 1800ms ease-in-out infinite; transform-origin:center;
}
.shell:not([data-mobile-focus="true"]) #toggle-fullscreen[aria-pressed="true"],
.shell[data-mobile-focus="true"] #toggle-fullscreen { animation:none; }
}
@media (prefers-reduced-motion:reduce) {
.shell:not([data-mobile-focus="true"]) #toggle-fullscreen { animation:none !important; }
}
/* Keep playback navigation at the edges of a full-width scrubber, with time below. */
.timeline {
display:grid; grid-template-columns:auto minmax(0,1fr) auto; grid-template-rows:auto auto;
align-items:center; column-gap:5px; row-gap:1px; min-width:0; padding:0; position:static; flex-wrap:initial;
}
.timeline #previous-step { grid-column:1; grid-row:1; width:24px; min-width:24px; }
.timeline input[type="range"] {
position:static; grid-column:2; grid-row:1; width:100%; min-width:0; height:20px; min-height:20px;
}
.timeline #next-step { grid-column:3; grid-row:1; width:24px; min-width:24px; }
.timeline output {
grid-column:1 / -1; grid-row:2; justify-self:center; margin:0; line-height:1.2; font-size:9px;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline {
display:grid; grid-template-columns:auto minmax(0,1fr) auto; grid-template-rows:auto auto;
align-items:center; min-width:0; width:100%; flex:0 0 auto;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline #previous-step { grid-column:1; grid-row:1; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline input[type="range"] {
position:static; grid-column:2; grid-row:1; width:100%; min-width:0;
}
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline #next-step { grid-column:3; grid-row:1; }
.shell[data-mobile-focus="true"][data-focus-playback="open"] .timeline output {
display:block; grid-column:1 / -1; grid-row:2; justify-self:center; margin:0; white-space:nowrap;
}
/* The editor shell follows the visual viewport so mobile browser chrome does not create page scroll. */
.shell[data-mobile-focus="true"] { height:var(--app-viewport-height,100dvh); }
@media (orientation:portrait) and (max-width:900px) {
.shell:not([data-mobile-focus="true"]) {
height:var(--app-viewport-height,100dvh); min-height:0; overflow:hidden;
grid-template-rows:84px 36px minmax(0,1fr) 100px;
}
.shell:not([data-mobile-focus="true"]) .workspace {
height:100%; min-height:0; overflow:hidden;
}
.shell:not([data-mobile-focus="true"]) .board-wrap {
height:100%; min-height:0; overflow:hidden;
}
.shell:not([data-mobile-focus="true"]) .board {
flex:1 1 auto; min-height:0; max-height:420px; margin:96px 10px 8px;
}
.shell:not([data-mobile-focus="true"]) .board-status { display:none; }
.shell:not([data-mobile-focus="true"]) .board-tools {
flex:0 0 auto; width:calc(100% - 20px); gap:4px; margin:0 auto 8px; overflow:visible;
}
.shell:not([data-mobile-focus="true"]) .mode-group {
gap:1px; padding:3px; width:100%;
}
.shell:not([data-mobile-focus="true"]) .mode-group button {
min-width:44px; min-height:38px; padding:4px 5px; gap:2px; font-size:8px;
}
.shell:not([data-mobile-focus="true"]) .mode-group .icon { width:15px; height:15px; }
.shell:not([data-mobile-focus="true"]) .history-tools {
width:100%; padding:3px; gap:3px;
}
.shell:not([data-mobile-focus="true"]) .history-tools button {
flex:1 1 0; min-width:0; min-height:34px; padding:4px 6px; flex-direction:row; gap:3px; font-size:8px;
}
.shell:not([data-mobile-focus="true"]) .history-glyph { width:16px; height:16px; font-size:16px; }
.shell:not([data-mobile-focus="true"]) .playback-deck {
min-height:0; padding-inline:10px; grid-template-rows:minmax(0,1fr) 56px;
}
.shell:not([data-mobile-focus="true"]) .sequence-tab,
.shell:not([data-mobile-focus="true"]) .add-sequence { height:40px; min-width:42px; }
.shell:not([data-mobile-focus="true"]) .sequence-tab svg { width:30px; height:30px; }
.shell:not([data-mobile-focus="true"]) .playback-row { min-width:0; gap:8px; }
}
@media (orientation:portrait) and (max-width:600px) {
.shell:not([data-mobile-focus="true"]) { grid-template-rows:92px 36px minmax(0,1fr) 100px; }
.shell:not([data-mobile-focus="true"]) .canvas-meta { display:none; }
.shell:not([data-mobile-focus="true"]) .board { margin:72px 0 8px; }
}
@media (orientation:portrait) and (max-width:520px) {
.shell:not([data-mobile-focus="true"]) { grid-template-rows:84px 36px minmax(0,1fr) 100px; }
.shell:not([data-mobile-focus="true"]) .board { margin:72px 0 8px; }
}
+24 -9
View File
@@ -14,6 +14,7 @@ const paths = {
sliders: '<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3"/><circle cx="15" cy="17" r="3"/>', sliders: '<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3"/><circle cx="15" cy="17" r="3"/>',
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"/>',
}; };
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>`;
@@ -39,13 +40,25 @@ 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> <div id="team-list" class="team-list"></div><button id="refresh-teams" class="account-secondary" type="button">팀 목록 새로고침</button>
<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> <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>
<button id="open-admin" 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>
<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> <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="operator-panel" class="operator-panel" hidden><div class="operator-heading"><span class="section-kicker">OPERATOR</span><strong>승인 대기 사용자</strong></div><div id="pending-users"></div></section> <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>
<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>
<div id="play-library-panel" hidden>
<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-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>
<button id="continue-draft" class="account-secondary" type="button" hidden>임시 저장 계속하기</button>
<button id="library-back-teams" class="account-link" type="button">팀 목록으로</button>
<p id="play-library-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>
</section>`; </section>`;
} }
@@ -70,7 +83,7 @@ export function editorLayout() {
<header class="topbar"> <header class="topbar">
<div class="brand"><span class="brand-mark">${icon('court')}</span><div><strong>basket<span>-utils</span></strong><small>BASKETBALL PLAY STUDIO</small></div></div> <div class="brand"><span class="brand-mark">${icon('court')}</span><div><strong>basket<span>-utils</span></strong><small>BASKETBALL PLAY STUDIO</small></div></div>
<div class="document-title"><span class="breadcrumb"><span id="team-context">내 팀</span> <span>/</span></span><input id="play-name" value="새 전술" aria-label="전술 이름" /></div> <div class="document-title"><span class="breadcrumb"><span id="team-context">내 팀</span> <span>/</span></span><input id="play-name" value="새 전술" aria-label="전술 이름" /></div>
<div class="top-actions"><span id="save-status" role="status">저장 전</span><button id="open-team-hub" type="button" aria-label="팀 전환">팀 전환</button><button id="video-share-open" aria-label="영상 공유">${icon('download')}<span>영상 공유</span></button><button id="save-play" class="primary" aria-label="저장">${icon('save')}<span>저장</span></button><button id="toggle-menu" class="icon-button" aria-expanded="false" aria-controls="project-menu" aria-label="전술 메뉴">${icon('menu')}</button></div> <div class="top-actions"><span id="save-status" role="status">저장 전</span><button id="open-team-hub" type="button" aria-label="전술 목록">전술 목록</button><button id="video-share-open" aria-label="영상 공유">${icon('download')}<span>영상 공유</span></button><button id="save-play" class="primary" aria-label="저장">${icon('save')}<span>저장</span></button><button id="toggle-fullscreen" type="button" aria-pressed="false" aria-label="전체화면" title="전체화면"><span class="fullscreen-icon" aria-hidden="true">${icon('fullscreen')}</span><span class="fullscreen-label">전체화면</span></button><button id="toggle-menu" class="icon-button" aria-expanded="false" aria-controls="project-menu" aria-label="전술 메뉴">${icon('menu')}</button></div><span id="fullscreen-status" role="status" aria-live="polite"></span>
<div id="project-menu" class="project-menu" hidden> <div id="project-menu" class="project-menu" hidden>
<div class="menu-heading">플레이북 관리</div> <div class="menu-heading">플레이북 관리</div>
<div class="setup"><label for="defense-type">새 전술의 수비 형태</label><select id="defense-type" aria-label="수비 형태"><option value="man-to-man">맨투맨</option><option value="2-3">23 지역 수비</option><option value="3-2">32 지역 수비</option></select><button id="new-play">${icon('plus')}새 전술 만들기</button></div> <div class="setup"><label for="defense-type">새 전술의 수비 형태</label><select id="defense-type" aria-label="수비 형태"><option value="man-to-man">맨투맨</option><option value="2-3">23 지역 수비</option><option value="3-2">32 지역 수비</option></select><button id="new-play">${icon('plus')}새 전술 만들기</button></div>
@@ -90,13 +103,15 @@ export function editorLayout() {
<div class="panel-footnote"><span class="live-dot"></span>모든 움직임은 여기서 시작됩니다.</div> <div class="panel-footnote"><span class="live-dot"></span>모든 움직임은 여기서 시작됩니다.</div>
</aside> </aside>
<section class="board-wrap"> <section class="board-wrap">
<div id="editor-role-banner" class="editor-role-banner" hidden role="status"></div>
<div class="canvas-header"><div><span class="section-kicker">THE PLAYGROUND</span><h1>전술 캔버스 <span id="canvas-step">01</span></h1></div><div class="view-group"><button id="tactical" class="view active">${icon('court')}작전판</button><button id="pov" class="view">${icon('eye')}선수 시점</button></div></div> <div class="canvas-header"><div><span class="section-kicker">THE PLAYGROUND</span><h1>전술 캔버스 <span id="canvas-step">01</span></h1></div><div class="view-group"><button id="tactical" class="view active">${icon('court')}작전판</button><button id="pov" class="view">${icon('eye')}선수 시점</button></div></div>
<div class="canvas-meta"><span><i class="live-dot"></i> HALF COURT</span><span>15 × 14 M <b>·</b> 5 ON 5</span></div> <div class="canvas-meta"><span><i class="live-dot"></i> HALF COURT</span><span>15 × 14 M <b>·</b> 5 ON 5</span></div>
<div id="board" class="board" aria-label="농구 작전 코트"></div><button id="cancel-gaze" hidden>시선 선택 취소</button> <div id="board" class="board" aria-label="농구 작전 코트"></div><button id="cancel-gaze" hidden>시선 선택 취소</button>
<div class="mode-group" aria-label="행동 도구"><button data-mode="start" class="mode start active">${icon('people')}<span>배치</span></button><span class="tool-divider"></span><button data-mode="move" class="mode">${icon('move')}<span>이동</span></button><button data-mode="pass" class="mode">${icon('pass')}<span>패스</span></button><button data-mode="screen" class="mode">${icon('screen')}<span>스크린</span></button><button id="shoot-action" class="mode">${icon('shoot')}<span>슛</span></button></div> <div class="board-tools"><div class="mode-group" aria-label="행동 도구"><button data-mode="start" class="mode start active">${icon('people')}<span>배치</span></button><span class="tool-divider"></span><button data-mode="move" class="mode">${icon('move')}<span>이동</span></button><button data-mode="pass" class="mode">${icon('pass')}<span>패스</span></button><button data-mode="screen" class="mode">${icon('screen')}<span>스크린</span></button><button id="shoot-action" class="mode">${icon('shoot')}<span>슛</span></button></div><div class="history-tools" aria-label="편집 기록"><button id="undo" class="history-button" type="button" aria-label="실행 취소" title="실행 취소 (Ctrl+Z)"><span class="history-glyph" aria-hidden="true">↶</span><span>실행 취소</span></button><button id="redo" class="history-button" type="button" aria-label="다시 실행" title="다시 실행 (Ctrl+Shift+Z)"><span class="history-glyph" aria-hidden="true">↷</span><span>다시 실행</span></button></div></div>
<div class="focus-bottom-actions" aria-label="집중 편집 도구"><button id="toggle-focus-playback" type="button" aria-expanded="false" aria-controls="playback-deck" aria-label="단계·재생">단계</button><button id="toggle-focus-actions" type="button" aria-expanded="false" aria-controls="action-panel" aria-label="행동 편집">행동</button></div>
<div class="board-status"><span id="status" role="status">선수를 선택하고 첫 움직임을 만들어보세요.</span><span id="clock">00.0s</span></div> <div class="board-status"><span id="status" role="status">선수를 선택하고 첫 움직임을 만들어보세요.</span><span id="clock">00.0s</span></div>
</section> </section>
<aside class="panel right-panel"> <aside id="action-panel" class="panel right-panel">
<div class="panel-heading"><span class="section-kicker">MAKE YOUR MOVE</span><h2>행동 편집</h2></div> <div class="panel-heading"><span class="section-kicker">MAKE YOUR MOVE</span><h2>행동 편집</h2></div>
<div class="panel-title">행동 목록 <span id="track-duration"></span></div><div id="actions"></div> <div class="panel-title">행동 목록 <span id="track-duration"></span></div><div id="actions"></div>
<fieldset id="action-properties" disabled><legend>${icon('sliders')} 행동 속성</legend> <fieldset id="action-properties" disabled><legend>${icon('sliders')} 행동 속성</legend>
@@ -104,13 +119,13 @@ export function editorLayout() {
<p id="gaze-description">행동을 선택하면 시선을 설정할 수 있습니다.</p> <p id="gaze-description">행동을 선택하면 시선을 설정할 수 있습니다.</p>
<div class="coordinates"><label>X <span>가로 위치</span><input id="action-x" aria-label="X" type="number" step="0.5" min="-7.5" max="7.5" /></label><label>Z <span>세로 위치</span><input id="action-z" aria-label="Z" type="number" step="0.5" min="0" max="14" /></label></div><button id="apply-position">위치 적용 <span>↗</span></button> <div class="coordinates"><label>X <span>가로 위치</span><input id="action-x" aria-label="X" type="number" step="0.5" min="-7.5" max="7.5" /></label><label>Z <span>세로 위치</span><input id="action-z" aria-label="Z" type="number" step="0.5" min="0" max="14" /></label></div><button id="apply-position">위치 적용 <span>↗</span></button>
</fieldset><button id="delete-action" class="danger full">선택 행동 삭제</button> </fieldset><button id="delete-action" class="danger full">선택 행동 삭제</button>
<div class="history-actions"><button id="undo" title="Ctrl+Z">↶ 실행 취소</button><button id="redo" title="Ctrl+Shift+Z">↷ 다시 실행</button></div>
<div class="shortcut-note"><kbd>Space</kbd> 재생 <span>·</span> <kbd>Esc</kbd> 선택 취소</div> <div class="shortcut-note"><kbd>Space</kbd> 재생 <span>·</span> <kbd>Esc</kbd> 선택 취소</div>
</aside> </aside>
</div> </div>
<footer class="playback-deck"> <footer id="playback-deck" class="playback-deck">
<div class="sequence-heading"><span class="section-kicker">PLAY SEQUENCE</span><strong>플레이 흐름</strong></div><nav class="sequences" id="sequences" aria-label="플레이 단계"></nav> <div class="sequence-heading"><span class="section-kicker">PLAY SEQUENCE</span><strong>플레이 흐름</strong></div><div class="sequences-drawer"><nav class="sequences" id="sequences" aria-label="플레이 단계"></nav><div id="sequence-controls" class="sequence-controls"></div></div>
<div class="playback-row"><div class="transport"><button id="reset" class="icon-button" aria-label="처음으로" title="처음으로">${icon('reset')}</button><button id="play" class="play-button" aria-label="재생" title="재생 · Space">${icon('play')}</button><button id="pause" class="icon-button" aria-label="일시정지" title="일시정지">${icon('pause')}</button></div><div class="timeline"><button id="previous-step" class="icon-button" aria-label="이전 단계"></button><input id="scrubber" type="range" min="0" max="0" value="0" step="0.01" aria-label="재생 위치" /><button id="next-step" class="icon-button" aria-label="다음 단계"></button><output id="time-display">0.0 / 0.0초</output><select id="playback-rate" aria-label="재생 속도"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="1.5">1.5×</option><option value="2">2×</option></select><button id="loop" aria-pressed="false">↻ <span>반복</span></button></div></div> <div class="playback-row"><div class="transport"><button id="reset" class="icon-button" aria-label="처음으로" title="처음으로">${icon('reset')}</button><button id="play" class="play-button" aria-label="재생" title="재생 · Space">${icon('play')}</button><button id="pause" class="icon-button" aria-label="일시정지" title="일시정지">${icon('pause')}</button></div><div class="timeline"><button id="previous-step" class="icon-button" aria-label="이전 단계"></button><input id="scrubber" type="range" min="0" max="0" value="0" step="0.01" aria-label="재생 위치" /><button id="next-step" class="icon-button" aria-label="다음 단계"></button><output id="time-display">0.0 / 0.0초</output></div><div class="playback-options"><select id="playback-rate" aria-label="재생 속도"><option value="0.5">0.5×</option><option value="1" selected>1×</option><option value="1.5">1.5×</option><option value="2">2×</option></select><button id="loop" aria-pressed="false">↻ <span>반복</span></button></div></div>
</footer> </footer>
<div id="orientation-gate" hidden role="dialog" aria-modal="true" aria-live="polite" aria-labelledby="orientation-gate-title"><strong id="orientation-gate-title">가로로 돌려주세요</strong><span>편집을 계속하려면 화면을 가로로 회전해 주세요.</span><button id="orientation-gate-exit" type="button">전체화면 종료</button></div>
</section>`; </section>`;
} }