멀티 이슈 윈도우
여러 이슈를 OS 바탕화면처럼 동시에 띄워 비교하고 작업합니다.
이슈 한 건이 한 윈도우입니다. 대시보드 위에 떠있는 작은 창을 여러 개 띄워 두고, OS 바탕화면을 다루듯 옮기고 겹치고 접습니다. 두 이슈의 스크린샷을 나란히 놓고 비교하거나, 작업 중인 이슈를 최소화한 채 다른 이슈를 빠르게 훑는 흐름이 자연스럽게 만들어집니다.
OS 바탕화면 메타포
윈도우 모델은 의도적으로 macOS / Windows 의 윈도우 매니저를 흉내냅니다. 사용자가 이미 알고 있는 인터랙션 — 타이틀바를 잡고 드래그, 모서리를 잡고 리사이즈, 더블클릭으로 최대화 — 을 그대로 재사용해, 학습 비용 없이 멀티 이슈 작업이 가능합니다.
| OS 동작 | QA Note 멀티 윈도우 |
|---|---|
| 타이틀바 드래그 | 윈도우 위치 이동 |
| 모서리 드래그 | 윈도우 크기 조정 |
| 타이틀바 더블클릭 | 최대화 ↔ 복원 |
| 창 접기 | 하단 탭 바로 접기 (Cmd/Ctrl+M) |
윈도우끼리 z-order 가 있어, 클릭한 윈도우가 자동으로 맨 위로 올라옵니다.
타이틀바 액션
타이틀바는 높이 36px 이고, 좌에서 우로 다음 순서로 배치됩니다.
| 순서 | 항목 | 아이콘 | 단축키 | 동작 |
|---|---|---|---|---|
| 1 | 이전 이슈 | ‹ | — | 목록의 이전 이슈로 교체 (첫 이슈면 흐림) |
| 2 | 다음 이슈 | › | — | 목록의 다음 이슈로 교체 (마지막 이슈면 흐림) |
| 3 | 위치 표기 | — | — | QANOTE-16 · 3 / 37 (목록이 없으면 이슈 키만) |
| 4 | 제목 | — | — | 말줄임 표시. 원격 수정이 들어오면 2.2초 하이라이트 |
| 5 | 전용 페이지로 이동 | ↗ | — | /issues/[issueNumber] 풀스크린으로 전환 |
| 6 | 창 접기 | − | Cmd/Ctrl+M | 하단 탭 바로 접음 |
| 7 | 전체화면 ↔ 크기 복원 | ⤢ | — | 타이틀바 더블클릭으로도 토글 |
| 8 | 윈도우 닫기 | × | Esc | 윈도우 제거 (이슈 자체는 보존) |
Cmd/Ctrl+M 과 Esc 는 포커스(최상위) 윈도우에만 적용되고, 입력란·에디터에 커서가 있으면 가로채지 않습니다.
전용 페이지 이동
윈도우는 작은 창이라 데이터가 압축되어 표시됩니다. 깊게 파고들어야 할 때는 타이틀바의 ↗ 로 풀스크린 페이지(/issues/[issueNumber])로 전환합니다. URL 이 바뀌므로 그대로 복사해 동료에게 공유 가능합니다.
열려 있는 윈도우 목록은 주소창에도 ?issues=16,12 로 따라붙습니다. 그 URL 을 복사해 열면 같은 조합이 그대로 복원됩니다.
접힌 창 탭 바
창을 접으면(− 버튼 또는 Cmd/Ctrl+M) 윈도우는 사라지지 않고 화면 하단 가운데의 알약 모양 탭 바로 들어갑니다. 탭에는 이슈 키와 제목이 남고, 오른쪽 끝에 {접힌 수} / 8 카운터가 붙습니다.
| 조작 | 결과 |
|---|---|
| 탭 클릭 | 창을 다시 펼치고 최상위로 포커스 |
탭의 × | 창 닫기 (접힌 상태에서 바로 정리) |
- 접힌 창은 위치·크기를 그대로 기억합니다. 다시 펼치면 접기 전 자리로 돌아옵니다.
- 접힘 상태도
localStorage에 저장되어 새로고침 후에도 유지됩니다. - 이슈를 여러 건 선택해 벌크 액션 바가 떠 있으면 탭 바가 그 위로 한 단 올라갑니다.
Hydration mismatch 해결
윈도우의 위치·크기·열림 상태는 localStorage 에 저장됩니다. Next.js App Router 의 SSR 단계에서는 localStorage 에 접근할 수 없기 때문에, 서버 렌더 결과와 클라이언트 첫 렌더가 어긋나면 hydration mismatch 가 발생합니다.
해결 패턴은 첫 렌더는 항상 빈 상태로, useEffect 안에서 복원 입니다.
"use client";
import { useEffect, useState } from "react";
type WindowState = { id: string; x: number; y: number; w: number; h: number };
export function MultiWindowShell() {
const [windows, setWindows] = useState<WindowState[]>([]);
// 첫 client render 후에만 localStorage 에서 복원 → hydration mismatch 방지
useEffect(() => {
const stored = localStorage.getItem("qanote:windows");
if (stored) setWindows(JSON.parse(stored));
}, []);
// 변경 시 저장
useEffect(() => {
localStorage.setItem("qanote:windows", JSON.stringify(windows));
}, [windows]);
return (
<div className="multi-window-shell">
{windows.map((w) => (
<IssueWindow key={w.id} state={w} />
))}
</div>
);
}
서버 렌더 결과는 windows = [] 로 고정되고, 클라이언트가 hydration 직후 useEffect 가 한 번 돌면서 실제 상태를 채웁니다. 사용자는 "처음 들어왔을 때 윈도우가 한 박자 늦게 나타난다" 정도의 미세한 깜빡임을 보지만, hydration 경고는 발생하지 않습니다.
같은 이유로 next/dynamic 의 ssr: false 옵션으로 윈도우 셸 자체를 클라이언트 전용 컴포넌트로 만들어도 됩니다. 어느 쪽이든 핵심은 서버와 클라이언트 첫 렌더가 동일 하게 만드는 것입니다.
제한 사항
- 최대 동시 열림 수: 8 개. 8 개가 열린 상태에서 새 이슈를 열면 창이 추가되지 않고 "윈도우는 최대 8개까지 열 수 있어요" 토스트만 뜹니다. 이미 열려 있는 이슈를 다시 누르는 것은 상한과 무관하게 포커스로 처리됩니다.
- 작은 화면: 윈도우는 뷰포트 안으로 자동 보정됩니다. 화면을 줄이면 열려 있던 창이 화면 안쪽으로 밀려 들어오고, 폭·높이도 뷰포트에 맞춰 줄어듭니다.
- 세션 간 복원 범위: localStorage 에 저장되므로 같은 브라우저 · 같은 도메인에서만 복원됩니다. 시크릿 모드 · 다른 브라우저에서는 빈 상태로 시작합니다.
- 윈도우 크기 하한: 320×240. 그 아래로는 리사이즈되지 않습니다.
- 모달이 아닙니다: 뒤 화면을 어둡게 덮지 않고 포커스도 가두지 않습니다. 윈도우를 열어둔 채 뒤의 목록·보드를 그대로 조작할 수 있습니다.