/* 악당프롬프터 화면. 빌드 없이 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: `
{{ m.short }}
{{ share && share.members[m.num] ? pctOf(share.members[m.num].chars, share.chars) : 0 }}
`,
};
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 }}곡 참여율
자세히
{{ s.position }}
{{ s.title }}
{{ [s.artist, s.kind, s.tj && ('TJ ' + s.tj)].filter(Boolean).join(' · ') || ' ' }}
{{ s.counts[num] || 0 }}줄
{{ s.lines }}줄
곡 없음
`,
};
// ---- 가사 보기 -----------------------------------------------------------------
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 }}
{{ [data.song.artist, data.song.kind, data.song.tj && ('TJ ' + data.song.tj),
data.song.tj_mr && ('MR ' + data.song.tj_mr)].filter(Boolean).join(' · ') || ' ' }}
슬라이드
{{ focusText || '강조 없음' }}
{{ m.title }}
색
{{ mineCount }}줄
이전 곡
목록
다음 곡
`,
};
// ---- 슬라이드 보기 (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: `
{{ 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 }}
색칠
글 (붙여넣기·오타)
칠하기
붓:
{{ partLabel(brushParts(), members) }}
사람을 고르기(여러 명이면 여럿)
· 고른 가사: {{ pickedText }}
가사를 드래그해서 고르기
`,
};
// ---- 관리 ----------------------------------------------------------------------
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: `
{{ s.position }}
{{ s.title }}
{{ s.lines }}줄 · 파트 미지정 {{ s.unassigned }}줄
{{ 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 와 같음)
{{ p.num ?? '—' }}
{{ p.num != null ? '가' : '' }}
{{ p.name }} · {{ p.short }}
관리자
공용
{{ p.login ? '아이디 ' + p.login + (p.has_password ? '' : ' · 비밀번호 없음') : '로그인 없음' }}
{{ 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 }}
|
불명 |
|
{{ s.position }}. {{ s.title }} |
|
{{ pct(s.members[m.num].chars, s.chars) }}%
단독 {{ pct(s.members[m.num].solo, s.chars) }}%
{{ s.members[m.num].lines }}줄
|
{{ pct(s.unknown, s.chars) }}% |
| 전체 {{ 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.me.name }}
{{ 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");