Rename app to basket-utils
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Court Lab — UI redesign
|
||||
# basket-utils — UI redesign
|
||||
|
||||
2026-09-07
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Court Lab 개선 기록
|
||||
# basket-utils 개선 기록
|
||||
|
||||
2026-09-07
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Court Lab · 농구 전술보드</title>
|
||||
<title>basket-utils · 농구 전술보드</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ const { port, host } = config.server;
|
||||
const app = await buildServer({ config });
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
console.log(`court.lab server listening on http://${host}:${port}`);
|
||||
console.log(`basket-utils server listening on http://${host}:${port}`);
|
||||
const shutdown = async () => { try { await app.close(); } finally { process.exit(0); } };
|
||||
process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown);
|
||||
} catch (error) {
|
||||
|
||||
+3
-3
@@ -70,7 +70,7 @@ function ensureVideoFresh() {
|
||||
if (videoJob && videoJob.key !== videoKey()) videoJob.cancel();
|
||||
}
|
||||
function videoFileName(name) {
|
||||
const safe = String(name || 'court-lab-play').trim().replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-').slice(0, 70) || 'court-lab-play';
|
||||
const safe = String(name || 'basket-utils-play').trim().replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-').slice(0, 70) || 'basket-utils-play';
|
||||
return `${safe}.mp4`;
|
||||
}
|
||||
async function startVideoExport() {
|
||||
@@ -123,7 +123,7 @@ async function startVideoExport() {
|
||||
async function sharePreparedVideo() {
|
||||
ensureVideoFresh();
|
||||
if (!preparedVideo) { setVideoStatus('먼저 MP4 영상을 만들어 준비하세요'); return; }
|
||||
try { await shareVideoFile(preparedVideo.file, { navigatorObject: window.navigator, title: state.play.name || 'court.lab 전술 영상', text: '농구 전술 MP4 영상' }); setVideoStatus('공유창을 열었습니다 · 카카오톡 대화방을 선택해 전송하세요'); }
|
||||
try { await shareVideoFile(preparedVideo.file, { navigatorObject: window.navigator, title: state.play.name || 'basket-utils 전술 영상', text: '농구 전술 MP4 영상' }); setVideoStatus('공유창을 열었습니다 · 카카오톡 대화방을 선택해 전송하세요'); }
|
||||
catch (error) { if (error?.name === 'AbortError') setVideoStatus('영상 파일 공유를 취소했습니다'); else setVideoStatus(error.message || '영상 파일 공유를 지원하지 않습니다'); }
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ app.addEventListener('click', (event) => {
|
||||
if (event.target.closest('#apply-position')) { const x = Number(document.querySelector('#action-x').value); const z = Number(document.querySelector('#action-z').value); updatePlay(editAction(state.play, state.selectedSequence, state.selectedPlayerId, state.selectedAction, { location: { x, z } }), '이동 위치 수정', state.selectedAction); return; }
|
||||
if (event.target.closest('#loop')) { const button = document.querySelector('#loop'); const enabled = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(enabled)); controller.setLoop(enabled); return; }
|
||||
if (event.target.closest('#previous-step') || event.target.closest('#next-step')) { const current = controller.sample().sequenceIndex; const next = Math.max(0, Math.min(state.play.sequences.length - 1, current + (event.target.closest('#next-step') ? 1 : -1))); seekTo(state.play.sequences.slice(0, next).reduce((time, sequence) => time + sequenceDuration(sequence, 4.5, state.play.players), 0)); return; }
|
||||
if (event.target.closest('#export-play')) { const play = structuredClone(state.play); play.name = document.querySelector('#play-name').value.trim() || '새 전술'; const url = URL.createObjectURL(new Blob([JSON.stringify(play, null, 2)], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = 'court-lab.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return; }
|
||||
if (event.target.closest('#export-play')) { const play = structuredClone(state.play); play.name = document.querySelector('#play-name').value.trim() || '새 전술'; const url = URL.createObjectURL(new Blob([JSON.stringify(play, null, 2)], { type: 'application/json' })); const a = document.createElement('a'); a.href = url; a.download = 'basket-utils.json'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); return; }
|
||||
if (event.target.closest('#export-video')) { startVideoExport(); return; }
|
||||
if (event.target.closest('#cancel-video')) { videoJob?.cancel(); return; }
|
||||
if (event.target.closest('#share-video')) { sharePreparedVideo(); return; }
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ input,select { width:100%; min-width:0; min-height:39px; padding:9px 11px; borde
|
||||
.shell { width:100%; height:100dvh; min-height:620px; display:grid; grid-template-rows:76px minmax(0,1fr) 150px; }
|
||||
.topbar { position:relative; z-index:10; display:flex; align-items:center; gap:30px; padding:0 26px; background:white; border-bottom:1px solid var(--line); }
|
||||
.brand { display:flex; align-items:center; gap:10px; flex-shrink:0; width:185px; } .brand-mark { width:37px; height:37px; display:grid; place-items:center; color:#fff4e9; background:var(--accent); border-radius:10px; transform:rotate(-6deg); } .brand-mark .icon { width:26px; height:26px; }
|
||||
.brand strong { font-size:26px; letter-spacing:-.06em; font-weight:850; line-height:1; } .brand strong span { font-weight:400; } .brand i { font-size:10px; font-style:normal; vertical-align:super; margin-left:3px; } .brand small { display:block; margin-top:5px; font-size:7px; letter-spacing:.14em; color:var(--muted); }
|
||||
.brand strong { font-size:26px; letter-spacing:-.06em; font-weight:850; line-height:1; white-space:nowrap; } .brand strong span { font-weight:400; } .brand > div { flex-shrink:0; } .brand i { font-size:10px; font-style:normal; vertical-align:super; margin-left:3px; } .brand small { display:block; margin-top:5px; font-size:7px; letter-spacing:.14em; color:var(--muted); }
|
||||
.document-title { display:flex; align-items:center; gap:18px; min-width:0; flex:1; } .breadcrumb { white-space:nowrap; font-size:11px; color:var(--muted); } .breadcrumb span { margin-left:20px; color:#c0c9c1; }
|
||||
#play-name { width:min(250px,100%); background:transparent; border-color:transparent; font-size:14px; font-weight:650; padding-left:0; } #play-name:hover,#play-name:focus { border-color:var(--line); padding-left:10px; }
|
||||
.top-actions { display:flex; align-items:center; gap:10px; } #save-status { max-width:175px; font-size:10px; color:#748579; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-right:7px; } #save-status::before { content:''; display:inline-block; width:5px; height:5px; border-radius:50%; background:#78a085; margin-right:7px; vertical-align:1px; }
|
||||
@@ -52,7 +52,7 @@ 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; }
|
||||
}
|
||||
@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-mark { width:30px; height:30px; border-radius:8px; } .brand-mark .icon { width:22px; height:22px; } .brand strong { font-size:24px; } .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:6px; } .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-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; }
|
||||
.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; }
|
||||
}
|
||||
@@ -75,4 +75,4 @@ h1 { display:flex; align-items:center; gap:10px; color:#f4f2e8; font-size:17px;
|
||||
.team-form { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:9px; padding-top:18px; border-top:1px solid var(--line); } .team-form button { min-height:39px; } .local-import { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:9px; margin-top:13px; } .local-import label { display:grid; gap:7px; color:#627268; font-size:11px; font-weight:600; } .account-secondary { width:100%; margin-top:0; background:#edf2e9; border-color:transparent; color:#496448; font-size:11px; } .account-empty { margin:4px 0; color:var(--muted); font-size:11px; line-height:1.6; }
|
||||
.operator-panel { margin-top:21px; padding-top:17px; border-top:1px solid var(--line); } .operator-heading { display:flex; justify-content:space-between; align-items:center; margin-bottom:11px; } .operator-heading strong { font-size:11px; } .pending-user { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 0; border-bottom:1px solid #eef1eb; color:var(--muted); font-size:10px; } .pending-user button { min-height:29px; padding:5px 9px; font-size:10px; }
|
||||
#open-team-hub { min-height:32px; padding:6px 10px; color:#587160; border-color:#dbe4d8; background:#f5f8f1; font-size:10px; } #team-context { color:#65766b; }
|
||||
@media(max-width:520px) { .account-gate { align-items:start; padding:14px; } .account-card { margin-top:5vh; padding:24px 20px; border-radius:16px; } .account-brand { margin-bottom:26px; } .team-form,.local-import { grid-template-columns:1fr; } .team-form button,.local-import button { width:100%; } #open-team-hub { width:78px; max-width:78px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .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; } }
|
||||
|
||||
@@ -20,7 +20,7 @@ export const icon = name => `<svg class="icon" viewBox="0 0 24 24" fill="none" s
|
||||
export function accountLayout() {
|
||||
return `<section id="account-gate" class="account-gate" aria-live="polite">
|
||||
<div class="account-card">
|
||||
<div class="account-brand"><span class="brand-mark">${icon('court')}</span><div><strong>court<span>lab</span></strong><small>BASKETBALL PLAY STUDIO</small></div></div>
|
||||
<div class="account-brand"><span class="brand-mark">${icon('court')}</span><div><strong>basket<span>-utils</span></strong><small>BASKETBALL PLAY STUDIO</small></div></div>
|
||||
<div id="auth-panel">
|
||||
<div class="account-heading"><span class="section-kicker">TEAM PLAYBOOK</span><h1>로그인</h1><p>승인된 계정으로 팀 전술을 안전하게 관리하세요.</p></div>
|
||||
<form id="login-form" class="account-form"><label>이메일<input name="email" type="email" autocomplete="email" required maxlength="320" /></label><label>비밀번호<input name="password" type="password" autocomplete="current-password" required minlength="8" /></label><button class="primary" type="submit">로그인</button></form>
|
||||
@@ -68,7 +68,7 @@ export function decorateSequenceButton(button, sequence, index, players) {
|
||||
export function editorLayout() {
|
||||
return `<section class="shell">
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="brand-mark">${icon('court')}</span><div><strong>court<span>lab</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="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 id="project-menu" class="project-menu" hidden>
|
||||
|
||||
+3
-3
@@ -79,7 +79,7 @@ async function runVideoExport(options, signal) {
|
||||
sleepImpl = (delay) => new Promise((resolve) => setTimeout(resolve, Math.max(0, delay))),
|
||||
isVisible = () => globalThis.document?.hidden !== true,
|
||||
FileConstructor = globalThis.File,
|
||||
fileName = 'court-lab-play.mp4',
|
||||
fileName = 'basket-utils-play.mp4',
|
||||
} = options;
|
||||
if (!canvas?.captureStream) throw new VideoExportError('이 브라우저는 캔버스 영상 생성을 지원하지 않습니다', 'capture-stream-unsupported');
|
||||
if (typeof MediaRecorderConstructor !== 'function') throw new VideoExportError('이 브라우저는 MP4 영상 생성을 지원하지 않습니다', 'media-recorder-unsupported');
|
||||
@@ -237,7 +237,7 @@ export async function shareVideoFile(file, options = {}) {
|
||||
let supported = false;
|
||||
try { supported = Boolean(navigatorObject.canShare({ files: [file] })); } catch { supported = false; }
|
||||
if (!supported) throw new VideoShareError('이 브라우저에서 MP4 파일 공유를 지원하지 않습니다. MP4를 다운로드해 카카오톡에 직접 첨부하세요.', 'file-share-unsupported');
|
||||
return navigatorObject.share({ files: [file], title: options.title || 'court.lab 전술 영상', text: options.text || '농구 전술 MP4 영상' });
|
||||
return navigatorObject.share({ files: [file], title: options.title || 'basket-utils 전술 영상', text: options.text || '농구 전술 MP4 영상' });
|
||||
}
|
||||
|
||||
export function downloadVideoFile(file, options = {}) {
|
||||
@@ -247,7 +247,7 @@ export function downloadVideoFile(file, options = {}) {
|
||||
const url = urlObject.createObjectURL(file);
|
||||
const anchor = documentObject.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = options.fileName || file.name || 'court-lab-play.mp4';
|
||||
anchor.download = options.fileName || file.name || 'basket-utils-play.mp4';
|
||||
anchor.click();
|
||||
setTimeout(() => urlObject.revokeObjectURL?.(url), 1000);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('MP4 video export', () => {
|
||||
const result = await job.promise;
|
||||
expect(elapsed).toEqual([0, 0.5]);
|
||||
expect(result.file.type).toBe('video/mp4');
|
||||
expect(result.file.name).toBe('court-lab-play.mp4');
|
||||
expect(result.file.name).toBe('basket-utils-play.mp4');
|
||||
expect(result.file.size).toBeGreaterThan(0);
|
||||
expect(canvas.stopped).toHaveLength(1);
|
||||
expect(timing.time).toBe(1000);
|
||||
|
||||
Reference in New Issue
Block a user