/* 악당프롬프터 화면. 빌드 없이 Vue 전역판 + Vuetify UMD 로 돈다. * 주소는 전부 상대 경로다(Gateboard 가 /akdang-prompter/ 아래에서 서빙). */ const { createApp, reactive, ref, computed, watch, onMounted, onBeforeUnmount, nextTick } = Vue; // ---- 공용 ---------------------------------------------------------------------- async function api(path, opts = {}) { const res = await fetch("api/" + path, { method: opts.method || "GET", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-Requested-With": "akdang-prompter" }, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }); let data = null; try { data = await res.json(); } catch (_) { /* 본문 없음 */ } if (res.status === 401 && path !== "login") { store.me = null; go("login"); } if (!res.ok) { const msg = data && data.detail ? (typeof data.detail === "string" ? data.detail : "입력값을 확인 필요") : `오류 ${res.status}`; const err = new Error(msg); err.status = res.status; throw err; } return data; } const SETTINGS_KEY = "akdang-prompter.settings"; const settings = reactive(Object.assign( { fontSize: 20, focusBy: {}, dim: false, theme: "system", plain: false }, JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}"))); watch(settings, (v) => localStorage.setItem(SETTINGS_KEY, JSON.stringify(v)), { deep: true }); // chromeHidden: 폰 가사 화면에서 탭으로 위쪽 막대(메뉴·곡 이동)를 숨긴 상태 const store = reactive({ me: null, ready: false, chromeHidden: false, toast: { show: false, text: "", color: "" } }); function toast(text, color = "") { Object.assign(store.toast, { show: true, text, color }); } function openSlides(songId) { const el = document.documentElement; if (el.requestFullscreen && !document.fullscreenElement) el.requestFullscreen().catch(() => {}); go("slides", songId); } // 해시 주소: #/ #/song/3 #/admin #/edit/3 #/login #/slides/3 const route = reactive({ name: "list", id: null }); function parseHash() { const parts = location.hash.replace(/^#\/?/, "").split("/"); route.name = parts[0] || "list"; route.id = parts[1] ? Number(parts[1]) : null; } function go(name, id) { location.hash = "#/" + (name === "list" ? "" : name + (id != null ? "/" + id : "")); } window.addEventListener("hashchange", parseHash); parseHash(); // 파트 표기: "1,3" / "ALL" / ""(불명) / "?남"(남·여·같이 까지만 아는 미정) function partNums(parts) { if (!parts || parts === "ALL" || parts.startsWith("?")) return []; return parts.split(",").map(Number); } function isAll(parts, members) { const nums = partNums(parts); return parts === "ALL" || (members.length > 1 && members.every((m) => nums.includes(m.num))); } // 파트 칸 글자. 이름마다 무대 색을 붙여 둔다(보기 화면은 색을 안 씀). function labelPieces(parts, members, colors) { if (!parts) return [{ text: "—", color: colors.unknown }]; if (parts.startsWith("?")) return [{ text: parts.slice(1) + "?", color: colors.unknown }]; if (isAll(parts, members)) return [{ text: "전체", color: colors.group }]; const out = []; partNums(parts).forEach((n, i) => { const m = members.find((x) => x.num === n); if (i) out.push({ text: "·", color: colors.unknown }); out.push({ text: m ? m.short : n + "번", color: m ? m.color : colors.unknown }); }); return out; } function partLabel(parts, members) { return labelPieces(parts, members, { unknown: "", group: "" }).map((p) => p.text).join(""); } // 무대 색: 1명 = 그 사람 색, 2명 이상·전체 = '같이', 불명·미정 = '불명'(7월 프롬프트 규칙). // 서버 stage.color_for(PPT 가 씀)와 같은 규칙이어야 한다. 2명 그라데이션은 2026-10-05 에 뺐다. function colorFor(parts, members, colors) { if (parts === "ALL") return colors.group; const nums = partNums(parts); if (nums.length >= 2) return colors.group; const m = nums.length === 1 && members.find((x) => x.num === nums[0]); return m ? m.color : colors.unknown; } function sings(parts, num) { if (num == null || !parts || parts.startsWith("?")) return false; return parts === "ALL" || partNums(parts).includes(num); } const NO_COLORS = { group: "#00B050", unknown: "#FFFFFF" }; // 강조할 사람들(번호 배열). 기기에 계정별로 기억한다 — 한 폰을 여럿이 쓰거나 guest 로 바꿔 들어와도 // 앞 사람 선택이 남지 않게. 고른 적이 없으면 기본: 단원은 자기 번호, 번호 없는 계정(guest·관리자)은 없음. // (2026-10-05: '나'·'강조 안 함' 항목을 없애고 이렇게 바꿈 — guest 에게 '나' 는 뜻이 없었다) function focusList() { const key = store.me ? store.me.login : ""; const saved = settings.focusBy && settings.focusBy[key]; if (Array.isArray(saved)) return saved; return store.me && store.me.num != null ? [store.me.num] : []; } function setFocus(nums) { const key = store.me ? store.me.login : ""; settings.focusBy = { ...(settings.focusBy || {}), [key]: [...nums].sort((a, b) => a - b) }; } function singsAny(parts, nums) { return nums.some((n) => sings(parts, n)); } // ---- 가사 블록 (보기·무대·편집 미리보기 공용) --------------------------------- const LyricsBlock = { props: { lines: Array, members: Array, colors: Object, stage: Boolean, focus: { type: Array, default: () => [] }, dim: Boolean, fontSize: { type: Number, default: 16 }, }, setup(props) { // 파트 칸에 이름을 적는 것은 절의 첫 줄과 파트가 바뀌는 줄만. 나머지는 칸만 비워 둔다. const rows = computed(() => { const out = []; let prev = null; let start = true; const cols = props.colors || NO_COLORS; const members = props.members || []; (props.lines || []).forEach((ln, idx) => { if (!ln.text || ln.text === "---") { out.push({ gap: true }); start = true; return; } // 줄 안의 파트를 처음 나온 순서대로, 한 번씩. 앞 줄과 같으면 칸을 비운다. // 한 칸에 '1·3번→전체→1·3번' 처럼 이어 쓰면 칸이 276px 까지 넓어져 폰에서 가사가 // 두세 글자씩 접혔다(2026-10-05 393px 실측). 그래서 파트마다 칸 안의 한 줄로 쌓는다. const seq = []; for (const sg of ln.segs || [{ parts: ln.parts }]) if (!seq.includes(sg.parts)) seq.push(sg.parts); const key = seq.join("|"); const show = start || key !== prev; let at = 0; // data-l / data-s: 색칠 편집에서 드래그한 범위를 (줄, 글자 위치)로 되찾는 데 쓴다 const segs = (ln.segs || [{ parts: ln.parts, text: ln.text }]).map((sg) => { const o = { text: sg.text, start: at, mine: singsAny(sg.parts, props.focus), style: { color: colorFor(sg.parts, members, cols) } }; at += sg.text.length; return o; }); out.push({ idx, labels: show ? seq.map((x) => partLabel(x, members)) : [], groups: show ? seq.map((x) => labelPieces(x, members, cols)) : [], segs, mine: segs.some((sg) => sg.mine), }); prev = key; start = false; }); return out; }); // 파트 칸 폭은 이 곡에서 가장 긴 표시에 맞춘다. 곡 안에서는 모든 줄이 같은 자리에서 // 시작하고, 이름이 짧은 곡은 좁은 화면에서 가사 칸을 덜 빼앗긴다. const labelWidth = computed(() => { const longest = Math.max(2, ...rows.value.flatMap((r) => (r.labels || []).map((t) => [...t].length))); return (longest * 0.8 + 0.2).toFixed(1) + "em"; }); return { rows, labelWidth }; }, template: `
가사 없음
`, }; const pctOf = (a, b) => (b ? Math.round((a / b) * 100) : 0); function sumShares(list) { const t = { chars: 0, unknown: 0, members: {} }; for (const sh of list) { t.chars += sh.chars; t.unknown += sh.unknown; for (const [n, v] of Object.entries(sh.members)) { const m = (t.members[n] = t.members[n] || { lines: 0, chars: 0, solo: 0 }); m.lines += v.lines; m.chars += v.chars; m.solo += v.solo; } } return t; } // 곡목록 한 줄 안의 분량. 칸 폭을 고정해 숫자가 줄마다 같은 자리에 온다. const ShareChips = { props: { share: Object, members: Array }, setup() { return { pctOf }; }, template: `
`, }; const ColorLegend = { props: { members: Array, colors: Object, share: Object }, setup() { return { store, go, pctOf }; }, template: `
{{ m.name }} {{ pctOf(share.members[m.num].chars, share.chars) }}%(단독 {{ pctOf(share.members[m.num].solo, share.chars) }}%) 2명 이상 불명 색 바꾸기
`, }; // ---- 로그인 -------------------------------------------------------------------- const LoginView = { setup() { const form = reactive({ login: "", password: "" }); const busy = ref(false); const error = ref(""); async function submit() { busy.value = true; error.value = ""; try { store.me = await api("login", { method: "POST", body: form }); go("list"); } catch (e) { error.value = e.message; } finally { busy.value = false; } } return { form, busy, error, submit }; }, template: `
악당프롬프터
공연 가사 보기
{{ error }} 로그인
`, }; // ---- 곡목록 -------------------------------------------------------------------- const SongList = { components: { ShareChips }, setup() { const songs = ref([]); const members = ref([]); const loading = ref(true); onMounted(async () => { try { const d = await api("songs"); songs.value = d.songs; members.value = d.members; } finally { loading.value = false; } }); const nums = computed(() => focusList()); const num = computed(() => (nums.value.length === 1 ? nums.value[0] : null)); const who = computed(() => members.value.find((m) => m.num === num.value)); const total = computed(() => sumShares(songs.value.map((s) => s.share).filter(Boolean))); const isAdmin = computed(() => !!(store.me && store.me.is_admin)); // 분량은 관리자만 return { songs, members, loading, num, who, go, openSlides, total, isAdmin }; }, template: `
11월 본공연 · {{ songs.length }}곡
{{ who.name }} 기준 줄 수
슬라이드 PPT
분량
전체 {{ songs.length }}곡 참여율
자세히
곡 없음
`, }; // ---- 가사 보기 ----------------------------------------------------------------- const SongView = { components: { LyricsBlock, ColorLegend }, props: { id: Number }, setup(props) { const data = ref(null); const error = ref(""); let lock = null; async function load() { error.value = ""; try { data.value = await api("songs/" + props.id); window.scrollTo(0, 0); } catch (e) { error.value = e.message; } } watch(() => props.id, load, { immediate: true }); // 공연 중에 화면이 꺼지지 않게. https 에서만 된다(Gateboard 경유는 https). async function wake() { try { if ("wakeLock" in navigator && document.visibilityState === "visible") { lock = await navigator.wakeLock.request("screen"); } } catch (_) { /* 지원 안 함 또는 거부 — 그냥 둔다 */ } } function onVisible() { if (document.visibilityState === "visible") wake(); } function onKey(e) { if (!data.value || e.target.closest("input, textarea")) return; if (e.key === "ArrowLeft" && data.value.prev) go("song", data.value.prev); if (e.key === "ArrowRight" && data.value.next) go("song", data.value.next); } // 폰: 좌우로 밀면 이전·다음 곡. DC 의 글 넘기기와 같은 기준 — "가로로 분명히 민 것만". // 느슨하면 살짝 비스듬한 세로 스크롤이 곡을 넘겼다(DC 2026-09-26). 그래서 빠르게(0.7초 안), // 가로 80px 이상, 세로는 가로의 절반 이하. 두 손가락·확대 중·글자 고르는 중·입력칸 위는 안 함. let touch = null; function onTouchStart(e) { const zoomed = window.visualViewport && window.visualViewport.scale > 1.01; if (e.touches.length !== 1 || zoomed || e.target.closest?.("input, textarea, .v-select, .v-overlay")) { touch = null; return; } touch = { x: e.touches[0].clientX, y: e.touches[0].clientY, at: Date.now() }; } function onTouchEnd(e) { if (!touch || !data.value) return; const dx = e.changedTouches[0].clientX - touch.x; const dy = e.changedTouches[0].clientY - touch.y; const quick = Date.now() - touch.at < 700; touch = null; if (!quick || Math.abs(dx) < 80 || Math.abs(dy) > Math.abs(dx) / 2) return; if (String(window.getSelection?.() || "").trim()) return; // 글자 고르는 중 const target = dx < 0 ? data.value.next : data.value.prev; if (target) go("song", target); } // 폰: 가사를 한 번 누르면 위쪽 막대(메뉴·곡 이동)를 숨기고, 다시 누르면 보인다. // ABOX 읽기 화면과 같은 판정 — 단추·고르기 위, 글자를 고른 상태, 6px 넘게 움직인 누름(밀기·끌기)은 // 막대를 건드리지 않는다. 마우스는 하지 않는다(PC 에서 누를 때마다 막대가 사라지면 불편). let press = null; function onPointerDown(e) { press = e.pointerType === "touch" ? { x: e.clientX, y: e.clientY } : null; } function onClick(e) { const p = press; press = null; if (!p || !e.target?.closest?.(".song-body")) return; if (e.target.closest("button, a, input, textarea, select, .v-overlay, .v-select")) return; if (String(window.getSelection?.() || "").trim()) return; if (Math.hypot(e.clientX - p.x, e.clientY - p.y) > 6) return; // 막대가 접히거나 펴지면 가사가 위아래로 밀린다. 누른 줄이 화면에서 같은 자리에 남게 스크롤을 // 맞춘다(ABOX: 탭할 때 글이 다시 흐르면 읽던 자리를 잃는다). 메뉴 막대가 접히는 동안 여러 번 맞춘다. const anchor = e.target.closest(".text, .label, .legend") || e.target; const before = anchor.getBoundingClientRect().top; store.chromeHidden = !store.chromeHidden; const fix = () => window.scrollBy(0, anchor.getBoundingClientRect().top - before); requestAnimationFrame(fix); setTimeout(fix, 120); setTimeout(fix, 260); } onMounted(() => { wake(); document.addEventListener("visibilitychange", onVisible); document.addEventListener("keydown", onKey); document.addEventListener("touchstart", onTouchStart, { passive: true }); document.addEventListener("touchend", onTouchEnd); window.addEventListener("pointerdown", onPointerDown, true); window.addEventListener("click", onClick); }); onBeforeUnmount(() => { document.removeEventListener("visibilitychange", onVisible); document.removeEventListener("keydown", onKey); document.removeEventListener("touchstart", onTouchStart); document.removeEventListener("touchend", onTouchEnd); window.removeEventListener("pointerdown", onPointerDown, true); window.removeEventListener("click", onClick); store.chromeHidden = false; // 다른 화면에서는 늘 보인다 if (lock) lock.release().catch(() => {}); }); const focus = computed({ get: () => focusList(), set: (v) => setFocus(v || []) }); const focusMenu = ref(false); function toggleFocus(n) { focus.value = focus.value.includes(n) ? focus.value.filter((x) => x !== n) : [...focus.value, n]; } const focusItems = computed(() => (data.value ? data.value.members : []).map( (m) => ({ title: `${m.num} ${m.name}`, value: m.num, short: m.short }))); const focusText = computed(() => focusItems.value.filter((x) => focus.value.includes(x.value)) .map((x) => x.short).join("·")); const mineCount = computed(() => !data.value ? 0 : data.value.lines.filter( (ln) => (ln.segs || []).some((sg) => singsAny(sg.parts, focus.value))).length); function size(d) { settings.fontSize = Math.min(48, Math.max(14, settings.fontSize + d)); } const songItems = computed(() => (data.value ? data.value.songs : []).map( (x) => ({ title: `${x.position}. ${x.title}`, value: x.id }))); return { data, error, settings, focus, focusMenu, toggleFocus, focusItems, focusText, mineCount, size, go, store, openSlides, songItems }; }, template: `
{{ error }}
`, }; // ---- 슬라이드 보기 (PPT 처럼 한 장씩) ------------------------------------------ /* 장은 서버 slides.build 가 준다 — PPT 와 같은 장, 같은 색. * 글자 크기는 PPT 비율을 따른다: 80pt / 슬라이드 높이 538pt, 한 줄에 한글 약 12자. * 그래서 어떤 화면에서도 PPT 와 같은 자리에서 줄이 접히고 5줄 장이 들어간다. * 그래도 넘치면(글꼴이 아직 안 왔거나 대체 글꼴이 더 넓을 때) 들어갈 때까지 줄인다. */ const SlideView = { props: { id: Number }, setup(props) { const slides = ref([]); const at = ref(0); const hud = ref(true); const stage = ref(null); const body = ref(null); const size = ref(40); let hudTimer = null; let lock = null; let touchX = null; function showHud() { hud.value = true; clearTimeout(hudTimer); hudTimer = setTimeout(() => { hud.value = false; }, 2500); } async function fit() { await nextTick(); const box = stage.value, el = body.value; if (!box || !el) return; const w = box.clientWidth * 0.94, h = box.clientHeight * 0.92; let f = Math.min(h * 80 / 538, w / 12.6); el.style.fontSize = f + "px"; for (let i = 0; i < 30 && (el.scrollHeight > h || el.scrollWidth > w); i++) { f *= 0.95; el.style.fontSize = f + "px"; } size.value = f; } function move(d) { const n = Math.max(0, Math.min(slides.value.length - 1, at.value + d)); if (n !== at.value) { at.value = n; fit(); } } function exit() { if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); const cur = slides.value[at.value]; go(cur ? "song" : "list", cur ? cur.song_id : undefined); } function onKey(e) { if (["ArrowRight", "PageDown", " ", "Enter", "ArrowDown"].includes(e.key)) { e.preventDefault(); move(1); } else if (["ArrowLeft", "PageUp", "ArrowUp", "Backspace"].includes(e.key)) { e.preventDefault(); move(-1); } else if (e.key === "Home") { at.value = 0; fit(); } else if (e.key === "End") { at.value = slides.value.length - 1; fit(); } else if (e.key === "Escape") exit(); } // 화면 왼쪽 1/3 을 누르면 이전, 나머지는 다음. 밀기도 된다. function onTap(e) { showHud(); move(e.clientX < window.innerWidth / 3 ? -1 : 1); } function onTouchStart(e) { touchX = e.touches[0].clientX; } function onTouchEnd(e) { if (touchX == null) return; const dx = e.changedTouches[0].clientX - touchX; touchX = null; if (Math.abs(dx) > 50) { e.preventDefault(); move(dx < 0 ? 1 : -1); showHud(); } } function toggleFull() { if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); else document.documentElement.requestFullscreen?.().catch(() => {}); } async function wake() { try { if ("wakeLock" in navigator && document.visibilityState === "visible") { lock = await navigator.wakeLock.request("screen"); } } catch (_) { /* 지원 안 함 */ } } function onVisible() { if (document.visibilityState === "visible") wake(); } onMounted(async () => { document.addEventListener("keydown", onKey); document.addEventListener("visibilitychange", onVisible); window.addEventListener("resize", fit); document.addEventListener("fullscreenchange", fit); // 글꼴(CDN)이 늦게 오면 그때 다시 맞춘다 document.fonts?.ready.then(fit); wake(); showHud(); slides.value = (await api("slides")).slides; const i = slides.value.findIndex((x) => x.song_id === props.id); at.value = i >= 0 ? i : 0; fit(); }); onBeforeUnmount(() => { document.removeEventListener("keydown", onKey); document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("resize", fit); document.removeEventListener("fullscreenchange", fit); clearTimeout(hudTimer); if (lock) lock.release().catch(() => {}); }); const cur = computed(() => slides.value[at.value]); const where = computed(() => { const c = cur.value; if (!c) return ""; const same = slides.value.filter((x) => x.song_id === c.song_id); return `${c.position}. ${c.song_title} · ${same.indexOf(c) + 1}/${same.length} · 전체 ${at.value + 1}/${slides.value.length}`; }); return { slides, at, cur, hud, stage, body, where, onTap, onTouchStart, onTouchEnd, exit, toggleFull, move, showHud }; }, template: `
{{ sg.text }}
{{ where }}
이전 다음 전체 화면 닫기
`, }; // ---- 가사 편집 ----------------------------------------------------------------- const LyricsEditor = { components: { LyricsBlock, ColorLegend }, props: { id: Number }, setup(props) { const song = ref(null); const members = ref([]); const colors = ref(NO_COLORS); const text = ref(""); const saved = ref(""); const version = ref(0); const preview = ref({ lines: [], warnings: [] }); const busy = ref(false); const tab = ref("paint"); let timer = null; // 색칠: 붓(고른 사람들)과 드래그한 범위 const brush = ref([]); // 사람 번호들. 비면 고르지 않음 const picked = ref(null); // { l1, o1, l2, o2 } — 줄 번호와 그 줄 글자 위치 const canvas = ref(null); onMounted(async () => { const d = await api("songs/" + props.id); song.value = d.song; members.value = d.members; colors.value = d.colors; text.value = saved.value = d.text; version.value = d.song.version; }); watch(text, () => { clearTimeout(timer); timer = setTimeout(async () => { try { preview.value = await api("lyrics/preview", { method: "POST", body: { text: text.value } }); } catch (_) { /* 미리보기 실패는 저장 때 다시 드러난다 */ } }, 300); }); const dirty = computed(() => text.value !== saved.value); function guard(e) { if (dirty.value) { e.preventDefault(); e.returnValue = ""; } } onMounted(() => window.addEventListener("beforeunload", guard)); onBeforeUnmount(() => window.removeEventListener("beforeunload", guard)); async function refresh() { preview.value = await api("lyrics/preview", { method: "POST", body: { text: text.value } }); } // 드래그한 범위를 (줄, 글자 위치)로. 가사 글자에 붙은 data-l/data-s 로 되찾는다. function where(node, offset) { const el = (node.nodeType === 3 ? node.parentElement : node)?.closest?.("[data-l]"); if (!el || !canvas.value || !canvas.value.contains(el)) return null; const inText = node.nodeType === 3 ? offset : (offset ? el.textContent.length : 0); return { l: Number(el.dataset.l), o: Number(el.dataset.s) + inText }; } function onSelect() { const sel = window.getSelection(); if (!sel || !sel.rangeCount || sel.isCollapsed) return; const r = sel.getRangeAt(0); const a = where(r.startContainer, r.startOffset); const b = where(r.endContainer, r.endOffset); if (!a || !b) return; picked.value = { l1: a.l, o1: a.o, l2: b.l, o2: b.o }; } onMounted(() => document.addEventListener("selectionchange", onSelect)); onBeforeUnmount(() => document.removeEventListener("selectionchange", onSelect)); const pickedText = computed(() => { const pk = picked.value; if (!pk) return ""; const lines = preview.value.lines; const out = []; for (let l = pk.l1; l <= pk.l2; l++) { const t = (lines[l]?.segs || []).map((x) => x.text).join(""); out.push(t.slice(l === pk.l1 ? pk.o1 : 0, l === pk.l2 ? pk.o2 : t.length)); } return out.join(" / "); }); function brushParts() { const nums = [...brush.value].sort((a, b) => a - b); if (brush.value.includes("ALL")) return "ALL"; if (brush.value.includes("-")) return ""; return nums.join(","); } function toggle(v) { // 전체·불명은 혼자만. 사람은 여러 명 고를 수 있다(2명 이상 = 초록) if (v === "ALL" || v === "-") { brush.value = brush.value[0] === v ? [] : [v]; return; } const b = brush.value.filter((x) => x !== "ALL" && x !== "-"); brush.value = b.includes(v) ? b.filter((x) => x !== v) : [...b, v]; } const brushStyle = computed(() => brush.value.length ? { color: colorFor(brushParts(), members.value, colors.value) } : {}); async function paint() { const pk = picked.value; if (!pk || !brush.value.length) return; const parts = brushParts(); const lines = preview.value.lines.map((ln) => { if (!ln.segs || !ln.segs.length) return { text: ln.text, segs: [] }; return { text: ln.text, segs: ln.segs.map((x) => ({ ...x })) }; }); for (let l = pk.l1; l <= pk.l2; l++) { const ln = lines[l]; if (!ln || !ln.segs.length) continue; // 글자마다 파트를 펼쳐 칠하고 다시 묶는다 const chars = []; ln.segs.forEach((x) => [...x.text].forEach((ch) => chars.push([x.parts, ch]))); const from = l === pk.l1 ? pk.o1 : 0; const to = l === pk.l2 ? pk.o2 : chars.length; for (let i = from; i < to && i < chars.length; i++) chars[i][0] = parts; ln.segs = []; for (const [pp, ch] of chars) { const last = ln.segs[ln.segs.length - 1]; if (last && last.parts === pp) last.text += ch; else ln.segs.push({ parts: pp, text: ch }); } } try { const r = await api("lyrics/format", { method: "POST", body: { lines } }); text.value = r.text; await refresh(); window.getSelection()?.removeAllRanges(); picked.value = null; } catch (e) { toast(e.message, "error"); } } async function save() { busy.value = true; try { const r = await api(`songs/${props.id}/lyrics`, { method: "PUT", body: { text: text.value, version: version.value } }); version.value = r.version; saved.value = text.value; toast(`저장됨 · ${r.lines}줄` + (r.warnings.length ? ` · 경고 ${r.warnings.length}` : ""), r.warnings.length ? "warning" : "success"); } catch (e) { toast(e.message, "error"); } finally { busy.value = false; } } function leave() { if (dirty.value && !confirm("저장하지 않은 변경이 있음. 나갈지?")) return; saved.value = text.value; go("song", props.id); } return { song, members, colors, text, preview, busy, dirty, save, leave, tab, brush, picked, pickedText, toggle, paint, brushStyle, canvas, partLabel, brushParts }; }, template: `
{{ song.title }} · 가사 편집
저장
한 줄에 파트 | 가사. 파트를 비우면 앞 줄과 같음. 빈 줄은 절 구분(PPT 한 장). 여러 명은 1,2(2명 이상 초록), 전원은 전체, 불명은 -, 아직 사람을 모르면 남·여·같이(흰색).
줄 안에서 바뀌면 1 | 가사 {2: (가사)}. PPT 를 절 중간에서 넘기려면 --- 한 줄.
{{ m.num }} {{ m.name }}
{{ w }} 색칠 글 (붙여넣기·오타)
`, }; // ---- 관리 ---------------------------------------------------------------------- const SongAdmin = { setup() { const songs = ref([]); const dialog = reactive({ show: false, id: null, form: {} }); const blank = () => ({ title: "", artist: "", kind: "", tj: "", tj_mr: "", note: "" }); const KINDS = ["단체곡", "개인곡", "남여 듀엣", "남남 듀엣", "여여 듀엣"]; async function load() { songs.value = (await api("songs")).songs; } onMounted(load); function open(s) { dialog.id = s ? s.id : null; dialog.form = s ? { title: s.title, artist: s.artist, kind: s.kind, tj: s.tj, tj_mr: s.tj_mr, note: s.note } : blank(); dialog.show = true; } async function submit() { try { if (dialog.id) await api("songs/" + dialog.id, { method: "PUT", body: dialog.form }); else { const r = await api("songs", { method: "POST", body: dialog.form }); dialog.show = false; go("edit", r.id); return; } dialog.show = false; await load(); } catch (e) { toast(e.message, "error"); } } async function remove(s) { if (!confirm(`'${s.title}' 을(를) 지울지? 가사도 같이 지워짐`)) return; try { await api("songs/" + s.id, { method: "DELETE" }); await load(); } catch (e) { toast(e.message, "error"); } } async function move(i, d) { const ids = songs.value.map((s) => s.id); const j = i + d; [ids[i], ids[j]] = [ids[j], ids[i]]; try { await api("songs/order", { method: "POST", body: { ids } }); await load(); } catch (e) { toast(e.message, "error"); await load(); } } return { songs, dialog, open, submit, remove, move, go, KINDS }; }, template: `
위아래 화살표로 공연 순서 변경
곡 추가
{{ dialog.id ? '곡 정보' : '곡 추가' }}
취소 {{ dialog.id ? '저장' : '추가하고 가사 입력' }}
`, }; const PeopleAdmin = { setup() { const people = ref([]); const dialog = reactive({ show: false, id: null, form: {} }); const issued = reactive({ show: false, login: "", password: "" }); const colors = ref({ ...NO_COLORS, palette: [] }); const stageForm = reactive({ group: "", unknown: "" }); async function load() { const d = await api("people"); people.value = d.people; colors.value = d.colors; Object.assign(stageForm, { group: d.colors.group, unknown: d.colors.unknown }); } onMounted(load); function open(p) { dialog.id = p ? p.id : null; dialog.form = p ? { num: p.num, name: p.name, short: p.short, login: p.login || "", is_admin: p.is_admin, color: p.color_shown, shared: p.shared } : { num: null, name: "", short: "", login: "", is_admin: false, color: "", shared: false }; dialog.show = true; } // 번호의 기본색(palette 순서). 사람 색을 비우면 이것이 쓰인다. function defaultColor(num) { const n = Number(num); const pal = colors.value.palette || []; return n >= 1 && n <= pal.length ? pal[n - 1].color : colors.value.unknown; } const HEX = /^#[0-9A-Fa-f]{6}$/; async function saveStage() { try { await api("settings/colors", { method: "PUT", body: { ...stageForm } }); toast("무대 색 저장됨", "success"); await load(); } catch (e) { toast(e.message, "error"); } } async function submit() { const body = { ...dialog.form, color: dialog.form.color || "", num: dialog.form.num === "" || dialog.form.num == null ? null : Number(dialog.form.num) }; try { if (dialog.id) await api("people/" + dialog.id, { method: "PUT", body }); else await api("people", { method: "POST", body }); dialog.show = false; await load(); } catch (e) { toast(e.message, "error"); } } async function remove(p) { if (!confirm(`'${p.name}' 을(를) 지울지? 가사의 ${p.num ?? ''}번 표기는 남음`)) return; try { await api("people/" + p.id, { method: "DELETE" }); await load(); } catch (e) { toast(e.message, "error"); } } async function reset(p) { if (p.has_password && !confirm(`'${p.name}' 의 비밀번호를 새로 만들지? 기존 비밀번호와 로그인이 끊김`)) return; try { const r = await api(`people/${p.id}/password`, { method: "POST" }); Object.assign(issued, { show: true, login: p.login, password: r.password }); await load(); } catch (e) { toast(e.message, "error"); } } function copy() { navigator.clipboard?.writeText(`아이디 ${issued.login} / 비밀번호 ${issued.password}`) .then(() => toast("복사됨"), () => toast("복사 실패. 직접 옮겨 적기 필요", "warning")); } return { people, dialog, issued, open, submit, remove, reset, copy, store, colors, stageForm, defaultColor, saveStage, HEX }; }, template: `
번호가 가사의 파트 번호. 무대에 안 서는 관리자는 번호 비움
사람 추가
무대 색 (검은 배경 · PPT 와 같음)
가
가
저장
{{ dialog.id ? '사람 수정' : '사람 추가' }}
무대 색 (비우면 번호 순 기본색)
가
취소 저장
새 비밀번호
아이디 {{ issued.login }}
{{ issued.password }}
이 창을 닫으면 다시 볼 수 없음
복사 닫기
`, }; // 곡마다 사람별 분량. 참여 = 혼자든 같이든 부르는 글자 비율, 단독 = 혼자 부르는 글자 비율(띄어쓰기 제외). const SharesAdmin = { setup() { const d = ref(null); onMounted(async () => { d.value = await api("shares"); }); const pct = (a, b) => (b ? Math.round((a / b) * 100) : 0); const total = computed(() => { if (!d.value) return null; const t = { chars: 0, unknown: 0, members: {} }; for (const s of d.value.songs) { t.chars += s.chars; t.unknown += s.unknown; for (const [n, v] of Object.entries(s.members)) { const m = (t.members[n] = t.members[n] || { lines: 0, chars: 0, solo: 0 }); m.lines += v.lines; m.chars += v.chars; m.solo += v.solo; } } return t; }); // 관리자는 눌러서 가사 편집으로, 단원은 가사 화면으로 const open = (id) => go(store.me && store.me.is_admin ? "edit" : "song", id); return { d, pct, total, open }; }, template: `
참여 = 혼자든 같이든 부르는 글자 비율, 단독 = 혼자 부르는 글자 비율(띄어쓰기 제외). 같이 부르는 줄은 모두에게 들어가서 참여의 합은 100% 를 넘음
{{ m.short }} 불명 전체 {{ d.songs.length }}곡
{{ pct(total.members[m.num].chars, total.chars) }}%
단독 {{ pct(total.members[m.num].solo, total.chars) }}%
{{ total.members[m.num].lines }}줄
{{ pct(total.unknown, total.chars) }}%
`, }; const SharesView = { components: { SharesAdmin }, template: `
곡별 분량
`, }; const AdminView = { components: { SongAdmin, PeopleAdmin, SharesAdmin }, setup() { return { tab: ref(route.name === "people" ? "people" : "songs") }; }, template: ` 곡목록 사람 분량 `, }; // ---- 바탕 ---------------------------------------------------------------------- const PasswordDialog = { props: { modelValue: Boolean }, emits: ["update:modelValue"], setup(props, { emit }) { const form = reactive({ current: "", new: "", again: "" }); const busy = ref(false); async function submit() { busy.value = true; try { await api("me/password", { method: "POST", body: { current: form.current, new: form.new } }); toast("비밀번호 바뀜", "success"); Object.assign(form, { current: "", new: "", again: "" }); emit("update:modelValue", false); } catch (e) { toast(e.message, "error"); } finally { busy.value = false; } } return { form, busy, submit, emit }; }, template: ` 비밀번호 변경 취소 변경 `, }; const App = { components: { LoginView, SongList, SongView, SlideView, SharesView, LyricsEditor, AdminView, PasswordDialog }, setup() { const theme = Vuetify.useTheme(); const pwDialog = ref(false); function applyTheme() { const dark = settings.theme === "dark" || (settings.theme === "system" && matchMedia("(prefers-color-scheme: dark)").matches); theme.change(dark ? "dark" : "light"); } watch(() => settings.theme, applyTheme, { immediate: true }); matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyTheme); function cycleTheme() { settings.theme = { system: "dark", dark: "light", light: "system" }[settings.theme] || "system"; } const themeIcon = computed(() => ({ dark: "$appDark", light: "$appLight" }[settings.theme] || "$appDark")); onMounted(async () => { try { store.me = await api("me"); } catch (_) { store.me = null; } store.ready = true; if (!store.me) go("login"); }); // 로그인 안 한 채로 다른 주소를 열면 로그인으로, 관리자 화면은 관리자만. const view = computed(() => { if (!store.ready) return null; if (!store.me) return "login-view"; if (route.name === "login") return "song-list"; if (route.name === "song" && route.id) return "song-view"; if (route.name === "slides") return "slide-view"; if (route.name === "shares") return store.me.is_admin ? "shares-view" : "song-list"; if (["edit", "admin", "people"].includes(route.name) && !store.me.is_admin) return "song-list"; if (route.name === "people") return "admin-view"; if (route.name === "edit" && route.id) return "lyrics-editor"; if (route.name === "admin") return "admin-view"; return "song-list"; }); async function logout() { try { await api("logout", { method: "POST" }); } catch (_) { /* 이미 끊김 */ } store.me = null; go("login"); } return { store, route, view, go, logout, pwDialog, cycleTheme, themeIcon, settings }; }, template: ` 악당프롬프터 {{ store.toast.text }} `, }; const vuetify = Vuetify.createVuetify({ icons: { aliases: window.APP_ICONS }, theme: { defaultTheme: "light", themes: window.APP_THEME.themes }, defaults: { ...window.APP_THEME.defaults, VAlert: { variant: "tonal", density: "compact" }, }, }); createApp(App).use(vuetify).mount("#app");