feat: init premium random group selector with custom sizes and scroll prevention
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
/* ==========================================================================
|
||||
PREMIUM RANDOM GROUP SELECTOR - APPLICATION LOGIC
|
||||
========================================================================== */
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// ------------------------------------------------------------------------
|
||||
// State Variables
|
||||
// ------------------------------------------------------------------------
|
||||
let allMembers = [];
|
||||
let remainingMembers = [];
|
||||
let completedGroups = [];
|
||||
let isDrawing = false;
|
||||
|
||||
// Member Table Array State
|
||||
let memberTableData = [];
|
||||
|
||||
// Custom Groups State
|
||||
let customGroups = [
|
||||
{ id: 1, name: '그룹 1', count: 5 },
|
||||
{ id: 2, name: '그룹 2', count: 4 },
|
||||
{ id: 3, name: '그룹 3', count: 3 }
|
||||
];
|
||||
let customIdCounter = 4;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// DOM Elements
|
||||
// ------------------------------------------------------------------------
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const btnOpenModal = document.getElementById('btn-open-modal');
|
||||
const btnOpenModalSub = document.getElementById('btn-open-modal-sub');
|
||||
const btnCloseModal = document.getElementById('btn-close-modal');
|
||||
const btnSaveModal = document.getElementById('btn-save-modal');
|
||||
const modalTabBtns = document.querySelectorAll('.modal-tab-btn');
|
||||
const tabPanes = document.querySelectorAll('.tab-pane');
|
||||
|
||||
const currentModeIndicator = document.getElementById('current-mode-indicator');
|
||||
|
||||
const btnModeTable = document.getElementById('btn-mode-table');
|
||||
const btnModeText = document.getElementById('btn-mode-text');
|
||||
const tableViewContainer = document.getElementById('table-view-container');
|
||||
const textViewContainer = document.getElementById('text-view-container');
|
||||
const memberTableBody = document.getElementById('member-table-body');
|
||||
const tableCountBadge = document.getElementById('table-count-badge');
|
||||
const btnAddRow = document.getElementById('btn-add-row');
|
||||
const btnClearTable = document.getElementById('btn-clear-table');
|
||||
|
||||
const memberInput = document.getElementById('member-input');
|
||||
const btnPreset49num = document.getElementById('btn-preset-49num');
|
||||
const btnPreset49names = document.getElementById('btn-preset-49names');
|
||||
|
||||
const statTotal = document.getElementById('stat-total');
|
||||
const statRemaining = document.getElementById('stat-remaining');
|
||||
const statGroups = document.getElementById('stat-groups');
|
||||
|
||||
const modePerGroup = document.getElementById('mode-per-group');
|
||||
const modeTotalGroups = document.getElementById('mode-total-groups');
|
||||
const modeCustomGroups = document.getElementById('mode-custom-groups');
|
||||
|
||||
const uniformModeControls = document.getElementById('uniform-mode-controls');
|
||||
const modeSizeLabel = document.getElementById('mode-size-label');
|
||||
const groupSizeVal = document.getElementById('group-size-val');
|
||||
const btnSizeMinus = document.getElementById('btn-size-minus');
|
||||
const btnSizePlus = document.getElementById('btn-size-plus');
|
||||
|
||||
const customBuilderContainer = document.getElementById('custom-builder-container');
|
||||
const customGroupsList = document.getElementById('custom-groups-list');
|
||||
const btnAddCustomGroup = document.getElementById('btn-add-custom-group');
|
||||
const customTotalBadge = document.getElementById('custom-total-badge');
|
||||
|
||||
const toggleSound = document.getElementById('toggle-sound');
|
||||
const btnDrawGroup = document.getElementById('btn-draw-group');
|
||||
const btnDrawAll = document.getElementById('btn-draw-all');
|
||||
const drawBtnText = document.getElementById('draw-btn-text');
|
||||
|
||||
const drawingGroupTitle = document.getElementById('drawing-group-title');
|
||||
const drawingStatusText = document.getElementById('drawing-status-text');
|
||||
const drawingCardsContainer = document.getElementById('drawing-cards-container');
|
||||
|
||||
const groupsContainer = document.getElementById('groups-container');
|
||||
const emptyResults = document.getElementById('empty-results');
|
||||
const btnCopyResults = document.getElementById('btn-copy-results');
|
||||
const btnResetAll = document.getElementById('btn-reset-all');
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Sample Data (49 Names)
|
||||
// ------------------------------------------------------------------------
|
||||
const SAMPLE_49_NAMES = [
|
||||
'김민준', '이서준', '박도윤', '최예준', '정시우', '강하준', '조주원', '윤지호',
|
||||
'장지후', '임준서', '한건우', '오우진', '서선우', '신연우', '권유준', '황은우',
|
||||
'안민재', '송현우', '류유찬', '홍지훈', '고지석', '문이준', '양정우', '손유진',
|
||||
'배승현', '백승민', '허시안', '유지원', '남지원', '심현준', '노이도', '하도현',
|
||||
'곽태경', '성하진', '차윤성', '주원준', '우재원', '구지한', '신유현', '임서진',
|
||||
'민서안', '진율', '지성현', '엄태윤', '채하민', '원도윤', '천서우', '방지호', '공유준'
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Member Table Logic & Synchronization
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
// Toggle Table vs Text View Mode
|
||||
btnModeTable.addEventListener('click', () => {
|
||||
syncTextToTableData();
|
||||
btnModeTable.classList.add('active');
|
||||
btnModeText.classList.remove('active');
|
||||
tableViewContainer.classList.remove('hidden');
|
||||
textViewContainer.classList.add('hidden');
|
||||
renderMemberTable();
|
||||
});
|
||||
|
||||
btnModeText.addEventListener('click', () => {
|
||||
syncTableDataToText();
|
||||
btnModeText.classList.add('active');
|
||||
btnModeTable.classList.remove('active');
|
||||
textViewContainer.classList.remove('hidden');
|
||||
tableViewContainer.classList.add('hidden');
|
||||
});
|
||||
|
||||
function renderMemberTable() {
|
||||
memberTableBody.innerHTML = '';
|
||||
tableCountBadge.textContent = `총 ${memberTableData.length}명`;
|
||||
|
||||
memberTableData.forEach((name, idx) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const tdIdx = document.createElement('td');
|
||||
tdIdx.className = 'table-idx';
|
||||
tdIdx.textContent = idx + 1;
|
||||
|
||||
const tdName = document.createElement('td');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'table-row-input';
|
||||
input.value = name;
|
||||
input.addEventListener('change', (e) => {
|
||||
memberTableData[idx] = e.target.value.trim();
|
||||
syncTableDataToText();
|
||||
});
|
||||
tdName.appendChild(input);
|
||||
|
||||
const tdAction = document.createElement('td');
|
||||
tdAction.style.textAlign = 'center';
|
||||
const btnRemove = document.createElement('button');
|
||||
btnRemove.type = 'button';
|
||||
btnRemove.className = 'btn-remove-row';
|
||||
btnRemove.textContent = '✕';
|
||||
btnRemove.title = '삭제';
|
||||
btnRemove.addEventListener('click', () => {
|
||||
memberTableData.splice(idx, 1);
|
||||
renderMemberTable();
|
||||
syncTableDataToText();
|
||||
});
|
||||
tdAction.appendChild(btnRemove);
|
||||
|
||||
tr.appendChild(tdIdx);
|
||||
tr.appendChild(tdName);
|
||||
tr.appendChild(tdAction);
|
||||
memberTableBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function syncTextToTableData() {
|
||||
const rawText = memberInput.value;
|
||||
const list = rawText
|
||||
.split(/[\n,]/)
|
||||
.map(name => name.trim())
|
||||
.filter(name => name.length > 0);
|
||||
memberTableData = [...list];
|
||||
}
|
||||
|
||||
function syncTableDataToText() {
|
||||
const cleaned = memberTableData.filter(n => n && n.trim().length > 0);
|
||||
memberInput.value = cleaned.join('\n');
|
||||
}
|
||||
|
||||
// Add Row in Table
|
||||
btnAddRow.addEventListener('click', () => {
|
||||
memberTableData.push(`참가자 ${memberTableData.length + 1}`);
|
||||
renderMemberTable();
|
||||
syncTableDataToText();
|
||||
|
||||
const wrapper = document.querySelector('.member-table-wrapper');
|
||||
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
|
||||
});
|
||||
|
||||
// Clear Table
|
||||
btnClearTable.addEventListener('click', () => {
|
||||
memberTableData = [];
|
||||
memberInput.value = '';
|
||||
renderMemberTable();
|
||||
});
|
||||
|
||||
// Presets in Modal
|
||||
btnPreset49num.addEventListener('click', () => {
|
||||
const numbers = Array.from({ length: 49 }, (_, i) => `${i + 1}번`);
|
||||
memberTableData = [...numbers];
|
||||
syncTableDataToText();
|
||||
renderMemberTable();
|
||||
});
|
||||
|
||||
btnPreset49names.addEventListener('click', () => {
|
||||
memberTableData = [...SAMPLE_49_NAMES];
|
||||
syncTableDataToText();
|
||||
renderMemberTable();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Modal Control Logic
|
||||
// ------------------------------------------------------------------------
|
||||
function openModal() {
|
||||
settingsModal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Save/Apply
|
||||
btnSaveModal.addEventListener('click', () => {
|
||||
if (!textViewContainer.classList.contains('hidden')) {
|
||||
syncTextToTableData();
|
||||
} else {
|
||||
syncTableDataToText();
|
||||
}
|
||||
loadMembersFromTableOrInput();
|
||||
updateModeUI();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
settingsModal.classList.add('hidden');
|
||||
}
|
||||
|
||||
btnOpenModal.addEventListener('click', openModal);
|
||||
if (btnOpenModalSub) btnOpenModalSub.addEventListener('click', openModal);
|
||||
btnCloseModal.addEventListener('click', closeModal);
|
||||
|
||||
settingsModal.addEventListener('click', (e) => {
|
||||
if (e.target === settingsModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !settingsModal.classList.contains('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Modal Tabs
|
||||
modalTabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const targetTab = btn.getAttribute('data-tab');
|
||||
|
||||
modalTabBtns.forEach(b => b.classList.remove('active'));
|
||||
tabPanes.forEach(p => p.classList.remove('active'));
|
||||
|
||||
btn.classList.add('active');
|
||||
document.getElementById(targetTab).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Mode Toggle & Settings
|
||||
// ------------------------------------------------------------------------
|
||||
modePerGroup.addEventListener('change', updateModeUI);
|
||||
modeTotalGroups.addEventListener('change', updateModeUI);
|
||||
modeCustomGroups.addEventListener('change', updateModeUI);
|
||||
|
||||
function updateModeUI() {
|
||||
let modeText = '';
|
||||
if (modeCustomGroups.checked) {
|
||||
uniformModeControls.classList.add('hidden');
|
||||
customBuilderContainer.classList.remove('hidden');
|
||||
modeText = '추첨 방식: 🛠️ 그룹별 명수 개별 지정 (커스텀)';
|
||||
} else {
|
||||
uniformModeControls.classList.remove('hidden');
|
||||
customBuilderContainer.classList.add('hidden');
|
||||
|
||||
if (modePerGroup.checked) {
|
||||
modeSizeLabel.textContent = '한 그룹당 인원수';
|
||||
modeText = `추첨 방식: 그룹 당 ${groupSizeVal.value}명`;
|
||||
} else {
|
||||
modeSizeLabel.textContent = '생성할 총 그룹 수';
|
||||
modeText = `추첨 방식: 총 ${groupSizeVal.value}개 그룹 균등`;
|
||||
}
|
||||
}
|
||||
if (currentModeIndicator) {
|
||||
currentModeIndicator.textContent = modeText;
|
||||
}
|
||||
}
|
||||
|
||||
// Stepper Buttons
|
||||
btnSizeMinus.addEventListener('click', () => {
|
||||
let val = parseInt(groupSizeVal.value) || 1;
|
||||
if (val > 1) {
|
||||
groupSizeVal.value = val - 1;
|
||||
updateModeUI();
|
||||
}
|
||||
});
|
||||
|
||||
btnSizePlus.addEventListener('click', () => {
|
||||
let val = parseInt(groupSizeVal.value) || 1;
|
||||
if (val < 100) {
|
||||
groupSizeVal.value = val + 1;
|
||||
updateModeUI();
|
||||
}
|
||||
});
|
||||
|
||||
groupSizeVal.addEventListener('change', updateModeUI);
|
||||
|
||||
// Custom Groups Builder UI
|
||||
btnAddCustomGroup.addEventListener('click', () => {
|
||||
customGroups.push({
|
||||
id: customIdCounter++,
|
||||
name: `그룹 ${customGroups.length + 1}`,
|
||||
count: 3
|
||||
});
|
||||
renderCustomGroupsBuilder();
|
||||
});
|
||||
|
||||
function renderCustomGroupsBuilder() {
|
||||
customGroupsList.innerHTML = '';
|
||||
|
||||
customGroups.forEach((cg, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'custom-group-row';
|
||||
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.className = 'custom-group-name';
|
||||
nameInput.value = cg.name;
|
||||
nameInput.addEventListener('change', (e) => {
|
||||
cg.name = e.target.value || `그룹 ${idx + 1}`;
|
||||
});
|
||||
|
||||
const stepper = document.createElement('div');
|
||||
stepper.className = 'number-stepper sm-stepper';
|
||||
stepper.innerHTML = `
|
||||
<button type="button" class="btn-cg-minus">-</button>
|
||||
<input type="number" value="${cg.count}" min="1" max="50" class="input-cg-val">
|
||||
<button type="button" class="btn-cg-plus">+</button>
|
||||
`;
|
||||
|
||||
const inputVal = stepper.querySelector('.input-cg-val');
|
||||
stepper.querySelector('.btn-cg-minus').addEventListener('click', () => {
|
||||
if (cg.count > 1) {
|
||||
cg.count--;
|
||||
inputVal.value = cg.count;
|
||||
updateCustomTotalBadge();
|
||||
}
|
||||
});
|
||||
stepper.querySelector('.btn-cg-plus').addEventListener('click', () => {
|
||||
cg.count++;
|
||||
inputVal.value = cg.count;
|
||||
updateCustomTotalBadge();
|
||||
});
|
||||
inputVal.addEventListener('change', (e) => {
|
||||
let v = parseInt(e.target.value) || 1;
|
||||
cg.count = Math.max(1, v);
|
||||
inputVal.value = cg.count;
|
||||
updateCustomTotalBadge();
|
||||
});
|
||||
|
||||
const btnRemove = document.createElement('button');
|
||||
btnRemove.type = 'button';
|
||||
btnRemove.className = 'btn-remove-custom-group';
|
||||
btnRemove.textContent = '✕';
|
||||
btnRemove.title = '삭제';
|
||||
btnRemove.addEventListener('click', () => {
|
||||
if (customGroups.length <= 1) {
|
||||
alert('최소 1개 이상의 그룹 설정이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
customGroups = customGroups.filter(item => item.id !== cg.id);
|
||||
renderCustomGroupsBuilder();
|
||||
});
|
||||
|
||||
row.appendChild(nameInput);
|
||||
row.appendChild(stepper);
|
||||
row.appendChild(btnRemove);
|
||||
customGroupsList.appendChild(row);
|
||||
});
|
||||
|
||||
updateCustomTotalBadge();
|
||||
}
|
||||
|
||||
function updateCustomTotalBadge() {
|
||||
const totalCount = customGroups.reduce((sum, item) => sum + item.count, 0);
|
||||
customTotalBadge.textContent = `총 설정 인원: ${totalCount}명`;
|
||||
}
|
||||
|
||||
// Sound Toggle
|
||||
toggleSound.addEventListener('change', (e) => {
|
||||
window.soundEffects.enabled = e.target.checked;
|
||||
});
|
||||
|
||||
// Action Buttons
|
||||
btnDrawGroup.addEventListener('click', () => drawNextGroup(false));
|
||||
btnDrawAll.addEventListener('click', () => drawNextGroup(true));
|
||||
btnResetAll.addEventListener('click', resetAllData);
|
||||
btnCopyResults.addEventListener('click', copyResultsToClipboard);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Core Functions
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
function loadMembersFromTableOrInput() {
|
||||
const list = memberTableData.filter(name => name && name.trim().length > 0);
|
||||
|
||||
if (list.length === 0) {
|
||||
const numbers = Array.from({ length: 49 }, (_, i) => `${i + 1}번`);
|
||||
memberTableData = [...numbers];
|
||||
syncTableDataToText();
|
||||
renderMemberTable();
|
||||
allMembers = [...numbers];
|
||||
remainingMembers = [...numbers];
|
||||
} else {
|
||||
allMembers = [...list];
|
||||
remainingMembers = [...list];
|
||||
}
|
||||
|
||||
completedGroups = [];
|
||||
|
||||
updateUIStats();
|
||||
renderGroups();
|
||||
}
|
||||
|
||||
function resetAllData() {
|
||||
allMembers = [];
|
||||
remainingMembers = [];
|
||||
completedGroups = [];
|
||||
isDrawing = false;
|
||||
|
||||
updateUIStats();
|
||||
renderGroups();
|
||||
|
||||
// Reset Stage to Initial Placeholder
|
||||
drawingGroupTitle.textContent = 'READY';
|
||||
if (drawingStatusText) drawingStatusText.textContent = '추첨 대기 중';
|
||||
drawingCardsContainer.className = 'cards-grid grid-size-lg';
|
||||
drawingCardsContainer.innerHTML = `
|
||||
<div class="initial-placeholder">
|
||||
<span class="placeholder-icon">✨</span>
|
||||
<p>상단의 <strong>[다음 1개 그룹 추첨]</strong> 버튼을 눌러 그룹 편성을 시작하세요.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateUIStats() {
|
||||
statTotal.textContent = allMembers.length;
|
||||
statRemaining.textContent = remainingMembers.length;
|
||||
statGroups.textContent = completedGroups.length;
|
||||
|
||||
if (remainingMembers.length === 0 && allMembers.length > 0) {
|
||||
drawBtnText.textContent = '모든 추첨 완료';
|
||||
btnDrawGroup.disabled = true;
|
||||
btnDrawAll.disabled = true;
|
||||
} else {
|
||||
drawBtnText.textContent = '다음 1개 그룹 추첨';
|
||||
btnDrawGroup.disabled = false;
|
||||
btnDrawAll.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shuffleArray(array) {
|
||||
const arr = [...array];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function getNextGroupConfig() {
|
||||
const totalRemaining = remainingMembers.length;
|
||||
|
||||
if (modeCustomGroups.checked) {
|
||||
const nextCustomIndex = completedGroups.length;
|
||||
if (nextCustomIndex < customGroups.length) {
|
||||
const cg = customGroups[nextCustomIndex];
|
||||
const targetCount = Math.min(totalRemaining, cg.count);
|
||||
return { name: cg.name, count: targetCount };
|
||||
} else {
|
||||
const groupNum = completedGroups.length + 1;
|
||||
const targetCount = Math.min(totalRemaining, 4);
|
||||
return { name: `그룹 ${groupNum}`, count: targetCount };
|
||||
}
|
||||
} else {
|
||||
const groupNum = completedGroups.length + 1;
|
||||
const groupName = `GROUP ${groupNum}`;
|
||||
const inputValue = parseInt(groupSizeVal.value) || 1;
|
||||
|
||||
if (modePerGroup.checked) {
|
||||
const targetCount = Math.min(totalRemaining, Math.max(1, inputValue));
|
||||
return { name: groupName, count: targetCount };
|
||||
} else {
|
||||
const desiredTotalGroups = Math.max(1, inputValue);
|
||||
const remainingGroupCount = Math.max(1, desiredTotalGroups - completedGroups.length);
|
||||
const memberCountPerGroup = Math.ceil(totalRemaining / remainingGroupCount);
|
||||
const targetCount = Math.min(totalRemaining, memberCountPerGroup);
|
||||
return { name: groupName, count: targetCount };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Group Draw Execution
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
async function drawNextGroup(drawAll = false) {
|
||||
if (isDrawing) return;
|
||||
if (remainingMembers.length === 0) {
|
||||
if (allMembers.length === 0) {
|
||||
openModal();
|
||||
alert('먼저 ⚙️ 설정 & 명단 모달에서 참가자 명단을 입력하세요.');
|
||||
} else {
|
||||
alert('모든 인원의 그룹 배치가 완료되었습니다.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawing = true;
|
||||
btnDrawGroup.disabled = true;
|
||||
btnDrawAll.disabled = true;
|
||||
|
||||
const speedMode = document.querySelector('input[name="anim-speed"]:checked').value;
|
||||
|
||||
if (drawAll) {
|
||||
while (remainingMembers.length > 0) {
|
||||
const config = getNextGroupConfig();
|
||||
await performSingleGroupDraw(config.name, config.count, speedMode);
|
||||
}
|
||||
} else {
|
||||
const config = getNextGroupConfig();
|
||||
await performSingleGroupDraw(config.name, config.count, speedMode);
|
||||
}
|
||||
|
||||
isDrawing = false;
|
||||
updateUIStats();
|
||||
}
|
||||
|
||||
function performSingleGroupDraw(groupName, count, speedMode) {
|
||||
return new Promise((resolve) => {
|
||||
const shuffled = shuffleArray(remainingMembers);
|
||||
const pickedMembers = shuffled.slice(0, count);
|
||||
|
||||
remainingMembers = remainingMembers.filter(m => !pickedMembers.includes(m));
|
||||
|
||||
// Header status text update
|
||||
drawingGroupTitle.textContent = groupName;
|
||||
if (drawingStatusText) drawingStatusText.textContent = '무작위 추출 중...';
|
||||
|
||||
// Reset cards container and assign grid scaling class
|
||||
drawingCardsContainer.innerHTML = '';
|
||||
drawingCardsContainer.className = 'cards-grid';
|
||||
|
||||
if (count <= 4) {
|
||||
drawingCardsContainer.classList.add('grid-size-xl');
|
||||
} else if (count <= 12) {
|
||||
drawingCardsContainer.classList.add('grid-size-lg');
|
||||
} else if (count <= 24) {
|
||||
drawingCardsContainer.classList.add('grid-size-md');
|
||||
} else {
|
||||
drawingCardsContainer.classList.add('grid-size-sm');
|
||||
}
|
||||
|
||||
if (speedMode === 'instant') {
|
||||
pickedMembers.forEach((member, i) => {
|
||||
createCardElement(i + 1, member, true);
|
||||
});
|
||||
|
||||
if (drawingStatusText) drawingStatusText.textContent = '추첨 완료!';
|
||||
finishGroupDraw(groupName, pickedMembers);
|
||||
setTimeout(resolve, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
const cardElements = pickedMembers.map((member, i) => {
|
||||
return createCardElement(i + 1, member, false);
|
||||
});
|
||||
|
||||
let spinInterval = null;
|
||||
let tickCount = 0;
|
||||
const spinDuration = speedMode === 'fast' ? 500 : 1200;
|
||||
|
||||
spinInterval = setInterval(() => {
|
||||
tickCount++;
|
||||
if (tickCount % 2 === 0) {
|
||||
window.soundEffects.playTick();
|
||||
}
|
||||
cardElements.forEach(cardObj => {
|
||||
if (!cardObj.revealed) {
|
||||
const randIdx = Math.floor(Math.random() * allMembers.length);
|
||||
cardObj.reelElement.textContent = allMembers[randIdx];
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(spinInterval);
|
||||
|
||||
const flipStagger = speedMode === 'fast' ? 80 : 180;
|
||||
|
||||
pickedMembers.forEach((member, idx) => {
|
||||
setTimeout(() => {
|
||||
const cardObj = cardElements[idx];
|
||||
cardObj.revealed = true;
|
||||
cardObj.element.classList.add('revealed');
|
||||
window.soundEffects.playReveal();
|
||||
|
||||
if (idx === pickedMembers.length - 1) {
|
||||
setTimeout(() => {
|
||||
window.soundEffects.playFanfare();
|
||||
triggerConfetti();
|
||||
if (drawingStatusText) drawingStatusText.textContent = '추첨 완료!';
|
||||
finishGroupDraw(groupName, pickedMembers);
|
||||
resolve();
|
||||
}, 300);
|
||||
}
|
||||
}, idx * flipStagger);
|
||||
});
|
||||
|
||||
}, spinDuration);
|
||||
});
|
||||
}
|
||||
|
||||
function createCardElement(index, finalName, instantRevealed) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `draw-card ${instantRevealed ? 'revealed' : ''}`;
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'card-inner';
|
||||
|
||||
const front = document.createElement('div');
|
||||
front.className = 'card-front';
|
||||
const reel = document.createElement('span');
|
||||
reel.className = 'slot-reel spinning';
|
||||
reel.textContent = '???';
|
||||
front.appendChild(reel);
|
||||
|
||||
const back = document.createElement('div');
|
||||
back.className = 'card-back';
|
||||
|
||||
const numSpan = document.createElement('span');
|
||||
numSpan.className = 'member-num';
|
||||
numSpan.textContent = `MEMBER ${index}`;
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'member-name';
|
||||
nameSpan.textContent = finalName;
|
||||
|
||||
back.appendChild(numSpan);
|
||||
back.appendChild(nameSpan);
|
||||
|
||||
inner.appendChild(front);
|
||||
inner.appendChild(back);
|
||||
card.appendChild(inner);
|
||||
|
||||
drawingCardsContainer.appendChild(card);
|
||||
|
||||
return {
|
||||
element: card,
|
||||
reelElement: reel,
|
||||
revealed: instantRevealed
|
||||
};
|
||||
}
|
||||
|
||||
function finishGroupDraw(groupName, members) {
|
||||
completedGroups.push({
|
||||
name: groupName,
|
||||
members: members
|
||||
});
|
||||
renderGroups();
|
||||
updateUIStats();
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
groupsContainer.innerHTML = '';
|
||||
if (completedGroups.length === 0) {
|
||||
groupsContainer.appendChild(emptyResults);
|
||||
emptyResults.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
emptyResults.style.display = 'none';
|
||||
|
||||
completedGroups.forEach(group => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'group-card';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'group-card-header';
|
||||
header.innerHTML = `
|
||||
<span class="title">${group.name}</span>
|
||||
<span class="count">${group.members.length}명</span>
|
||||
`;
|
||||
|
||||
const membersList = document.createElement('div');
|
||||
membersList.className = 'group-members-list';
|
||||
|
||||
group.members.forEach((m, idx) => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'member-badge';
|
||||
badge.innerHTML = `<span class="num">${idx + 1}</span> ${m}`;
|
||||
membersList.appendChild(badge);
|
||||
});
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(membersList);
|
||||
groupsContainer.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function copyResultsToClipboard() {
|
||||
if (completedGroups.length === 0) {
|
||||
alert('복사할 그룹 추첨 결과가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
let text = `[ 무작위 그룹 추첨 결과 ]\n\n`;
|
||||
completedGroups.forEach(g => {
|
||||
text += `■ ${g.name} (${g.members.length}명)\n`;
|
||||
text += `${g.members.join(', ')}\n\n`;
|
||||
});
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('추첨 결과가 클립보드에 복사되었습니다!');
|
||||
}).catch(() => {
|
||||
alert('복사에 실패했습니다. 직접 선택하여 복사해주세요.');
|
||||
});
|
||||
}
|
||||
|
||||
// Confetti Animation (Canvas)
|
||||
function triggerConfetti() {
|
||||
const canvas = document.getElementById('confetti-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
|
||||
const particles = [];
|
||||
const colors = ['#06b6d4', '#8b5cf6', '#10b981', '#f43f5e', '#f59e0b', '#ffffff'];
|
||||
|
||||
for (let i = 0; i < 70; i++) {
|
||||
particles.push({
|
||||
x: canvas.width / 2,
|
||||
y: canvas.height / 2 + 50,
|
||||
vx: (Math.random() - 0.5) * 18,
|
||||
vy: (Math.random() - 0.7) * 18,
|
||||
size: Math.random() * 8 + 4,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rotation: Math.random() * 360,
|
||||
rSpeed: (Math.random() - 0.5) * 10,
|
||||
opacity: 1
|
||||
});
|
||||
}
|
||||
|
||||
let startTime = Date.now();
|
||||
|
||||
function animate() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed > 1600) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
particles.forEach(p => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.4;
|
||||
p.rotation += p.rSpeed;
|
||||
p.opacity -= 0.015;
|
||||
|
||||
if (p.opacity > 0) {
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate((p.rotation * Math.PI) / 180);
|
||||
ctx.globalAlpha = Math.max(0, p.opacity);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size);
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
// Window Resize handle
|
||||
window.addEventListener('resize', () => {
|
||||
const canvas = document.getElementById('confetti-canvas');
|
||||
if (canvas) {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize Default State
|
||||
renderCustomGroupsBuilder();
|
||||
updateModeUI();
|
||||
|
||||
// Load default 49 numbers
|
||||
const defaultNumbers = Array.from({ length: 49 }, (_, i) => `${i + 1}번`);
|
||||
memberTableData = [...defaultNumbers];
|
||||
syncTableDataToText();
|
||||
renderMemberTable();
|
||||
loadMembersFromTableOrInput();
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/* ==========================================================================
|
||||
ELEGANT AUDIO SYNTHESIZER (WEB AUDIO API)
|
||||
========================================================================== */
|
||||
|
||||
class SoundEffects {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.enabled = true;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
this.ctx = new AudioCtx();
|
||||
}
|
||||
}
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
}
|
||||
}
|
||||
|
||||
playTick() {
|
||||
if (!this.enabled) return;
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
|
||||
try {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc.type = 'sine';
|
||||
// Low subtle tick
|
||||
osc.frequency.setValueAtTime(600, this.ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(1200, this.ctx.currentTime + 0.03);
|
||||
|
||||
gain.gain.setValueAtTime(0.04, this.ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.03);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start();
|
||||
osc.stop(this.ctx.currentTime + 0.035);
|
||||
} catch (e) {
|
||||
// Audio context edge cases
|
||||
}
|
||||
}
|
||||
|
||||
playReveal() {
|
||||
if (!this.enabled) return;
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
|
||||
try {
|
||||
const now = this.ctx.currentTime;
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(440, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(880, now + 0.12);
|
||||
|
||||
gain.gain.setValueAtTime(0.08, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.13);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
playFanfare() {
|
||||
if (!this.enabled) return;
|
||||
this.init();
|
||||
if (!this.ctx) return;
|
||||
|
||||
try {
|
||||
const now = this.ctx.currentTime;
|
||||
// Elegant Glass Chimes chord: C5, E5, G5, C6
|
||||
const freqs = [523.25, 659.25, 783.99, 1046.50];
|
||||
|
||||
freqs.forEach((f, idx) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(f, now + idx * 0.04);
|
||||
|
||||
gain.gain.setValueAtTime(0.06, now + idx * 0.04);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.04 + 0.5);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + idx * 0.04);
|
||||
osc.stop(now + idx * 0.04 + 0.55);
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
window.soundEffects = new SoundEffects();
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Premium Random Group Selector | 무작위 인원 그룹 추첨기</title>
|
||||
<!-- Google Fonts: Inter & Outfit & Noto Sans KR -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="logo-area">
|
||||
<div class="logo-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5"></rect>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5"></rect>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5"></rect>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5"></rect>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">
|
||||
<h1>RANDOM SELECTOR</h1>
|
||||
<span class="badge">PRO GROUPING</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="status-summary">
|
||||
<div class="stat-pill">
|
||||
<span class="label">전체 인원</span>
|
||||
<span class="value" id="stat-total">0</span>
|
||||
</div>
|
||||
<div class="stat-pill highlight-pill">
|
||||
<span class="label">남은 대기</span>
|
||||
<span class="value" id="stat-remaining">0</span>
|
||||
</div>
|
||||
<div class="stat-pill">
|
||||
<span class="label">완료 그룹</span>
|
||||
<span class="value" id="stat-groups">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal Trigger Icon Button -->
|
||||
<button type="button" class="btn btn-secondary btn-icon-setting" id="btn-open-modal" title="명단 및 설정 변경">
|
||||
<span class="icon">⚙️</span>
|
||||
<span class="btn-text">설정 & 명단</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Single Column Layout -->
|
||||
<main class="main-single-layout">
|
||||
<!-- Top Main Action Controls Bar -->
|
||||
<section class="main-action-bar">
|
||||
<div class="action-info">
|
||||
<span class="mode-indicator" id="current-mode-indicator">추첨 방식: 그룹 당 4명</span>
|
||||
</div>
|
||||
<div class="action-buttons-group">
|
||||
<button type="button" class="btn btn-outline btn-md" id="btn-open-modal-sub">
|
||||
⚙️ 명단/설정 변경
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-md" id="btn-draw-group">
|
||||
<span class="btn-sparkle">✨</span>
|
||||
<span class="btn-text" id="draw-btn-text">다음 1개 그룹 추첨</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-accent btn-md" id="btn-draw-all">
|
||||
⚡ 남은 전체 한 번에 배치
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Full-Width Live Draw Stage -->
|
||||
<section class="stage-section">
|
||||
<div class="draw-stage" id="draw-stage">
|
||||
<!-- Active Drawing / Result Display (Always Visible) -->
|
||||
<div class="active-draw-display" id="active-draw-display">
|
||||
<div class="drawing-header">
|
||||
<span class="group-title-badge" id="drawing-group-title">READY</span>
|
||||
<span class="drawing-status-text" id="drawing-status-text">추첨 대기 중</span>
|
||||
</div>
|
||||
|
||||
<div class="cards-grid grid-size-lg" id="drawing-cards-container">
|
||||
<div class="initial-placeholder">
|
||||
<span class="placeholder-icon">✨</span>
|
||||
<p>상단의 <strong>[다음 1개 그룹 추첨]</strong> 버튼을 눌러 그룹 편성을 시작하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Groups Results Area -->
|
||||
<div class="results-area">
|
||||
<div class="results-header">
|
||||
<h3><span class="icon">🏆</span> 생성된 그룹 목록</h3>
|
||||
<div class="results-actions">
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="btn-copy-results">📋 결과 복사</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" id="btn-reset-all">🔄 전체 리셋</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="groups-grid" id="groups-container">
|
||||
<div class="empty-results" id="empty-results">
|
||||
아직 추첨된 그룹이 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Canvas for Particle / Confetti -->
|
||||
<canvas id="confetti-canvas"></canvas>
|
||||
|
||||
<!-- ==========================================================================
|
||||
SETTINGS & MEMBER MANAGEMENT MODAL WINDOW
|
||||
========================================================================== -->
|
||||
<div class="modal-overlay hidden" id="settings-modal">
|
||||
<div class="modal-content">
|
||||
<!-- Modal Header -->
|
||||
<div class="modal-header">
|
||||
<div class="modal-tabs">
|
||||
<button type="button" class="modal-tab-btn active" data-tab="tab-members">📊 명단 관리 (표)</button>
|
||||
<button type="button" class="modal-tab-btn" data-tab="tab-settings">⚙️ 그룹 & 연출 설정</button>
|
||||
</div>
|
||||
<button type="button" class="modal-close-btn" id="btn-close-modal" title="닫기">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="modal-body">
|
||||
<!-- TAB 1: Member Table Management -->
|
||||
<div class="tab-pane active" id="tab-members">
|
||||
<div class="panel-header-sub">
|
||||
<div class="preset-group">
|
||||
<span class="sub-title">빠른 49명 생성:</span>
|
||||
<button type="button" class="btn btn-xs btn-outline" id="btn-preset-49num">1~49번</button>
|
||||
<button type="button" class="btn btn-xs btn-outline" id="btn-preset-49names">샘플이름</button>
|
||||
</div>
|
||||
<div class="input-mode-toggle">
|
||||
<button type="button" class="btn btn-xs btn-ghost active" id="btn-mode-table">📊 표 모드</button>
|
||||
<button type="button" class="btn btn-xs btn-ghost" id="btn-mode-text">📝 텍스트 일괄입력</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table View Container -->
|
||||
<div class="member-view-box" id="table-view-container">
|
||||
<div class="table-actions-bar">
|
||||
<button type="button" class="btn btn-xs btn-secondary" id="btn-add-row">➕ 인원 추가</button>
|
||||
<button type="button" class="btn btn-xs btn-ghost" id="btn-clear-table">🗑️ 전체 비우기</button>
|
||||
<span class="table-count-badge" id="table-count-badge">총 0명</span>
|
||||
</div>
|
||||
|
||||
<div class="member-table-wrapper">
|
||||
<table class="member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 55px;">#</th>
|
||||
<th>이름</th>
|
||||
<th style="width: 60px; text-align: center;">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="member-table-body">
|
||||
<!-- Dynamic Table Rows -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Area View Container (Hidden by default) -->
|
||||
<div class="member-view-box hidden" id="text-view-container">
|
||||
<div class="input-group">
|
||||
<textarea id="member-input" placeholder="이름을 줄바꿈이나 콤마로 구분하여 대량 입력하세요. 예: 홍길동 김철수 이영희..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 2: Group & Animation Settings -->
|
||||
<div class="tab-pane" id="tab-settings">
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">추첨 방식 선택</label>
|
||||
<div class="radio-toggle stacked-toggle">
|
||||
<input type="radio" name="group-mode" id="mode-per-group" value="per-group" checked>
|
||||
<label for="mode-per-group">그룹 당 동일 N명</label>
|
||||
|
||||
<input type="radio" name="group-mode" id="mode-total-groups" value="total-groups">
|
||||
<label for="mode-total-groups">총 N개 그룹 균등</label>
|
||||
|
||||
<input type="radio" name="group-mode" id="mode-custom-groups" value="custom-groups">
|
||||
<label for="mode-custom-groups">🛠️ 그룹별 명수 개별 지정 (커스텀)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uniform Mode Controls -->
|
||||
<div class="setting-row inline-input" id="uniform-mode-controls">
|
||||
<label for="group-size-val" id="mode-size-label">한 그룹당 인원수</label>
|
||||
<div class="number-stepper">
|
||||
<button type="button" id="btn-size-minus">-</button>
|
||||
<input type="number" id="group-size-val" value="4" min="1" max="50">
|
||||
<button type="button" id="btn-size-plus">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Mode Builder Controls -->
|
||||
<div class="custom-builder-container hidden" id="custom-builder-container">
|
||||
<div class="custom-builder-header">
|
||||
<span>개별 그룹 구성 목록</span>
|
||||
<span class="total-custom-badge" id="custom-total-badge">설정: 0명</span>
|
||||
</div>
|
||||
|
||||
<div class="custom-groups-list" id="custom-groups-list">
|
||||
<!-- Dynamic Custom Group Rows -->
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-xs btn-outline btn-full" id="btn-add-custom-group">
|
||||
➕ 그룹 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="modal-divider">
|
||||
|
||||
<div class="setting-group">
|
||||
<label class="setting-label">애니메이션 연출 속도</label>
|
||||
<div class="radio-toggle speed-toggle">
|
||||
<input type="radio" name="anim-speed" id="speed-fast" value="fast" checked>
|
||||
<label for="speed-fast">🚀 터보 (0.6초)</label>
|
||||
|
||||
<input type="radio" name="anim-speed" id="speed-normal" value="normal">
|
||||
<label for="speed-normal">⚡ 보통 (1.4초)</label>
|
||||
|
||||
<input type="radio" name="anim-speed" id="speed-instant" value="instant">
|
||||
<label for="speed-instant">⚡ 즉시</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row toggle-row">
|
||||
<span>사운드 효과음</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="toggle-sound" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary btn-md btn-full" id="btn-save-modal">
|
||||
✨ 저장 및 적용하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="audio.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
김우진
|
||||
임민경
|
||||
최선아
|
||||
이미영
|
||||
국혜림
|
||||
조태희
|
||||
최근혜
|
||||
류원준
|
||||
주완기
|
||||
정성호
|
||||
김원기
|
||||
홍아름
|
||||
이경민
|
||||
김윤재
|
||||
한승민
|
||||
김신지
|
||||
김용연
|
||||
성형일
|
||||
박주한
|
||||
황동환
|
||||
최현호
|
||||
조찬영
|
||||
한치영
|
||||
문형석
|
||||
김혜인
|
||||
송대일
|
||||
이태훈
|
||||
신혜영
|
||||
정은혜
|
||||
채선영
|
||||
허유나
|
||||
김수현
|
||||
김정석
|
||||
김태식
|
||||
최영환
|
||||
박지영
|
||||
권순호
|
||||
정두휘
|
||||
양숙영
|
||||
최혜은
|
||||
윤봄이
|
||||
마희연
|
||||
정지윤
|
||||
권혁진
|
||||
염승호
|
||||
윤준수
|
||||
김지영
|
||||
Reference in New Issue
Block a user