feat(화면): 화면 배치를 계정에 저장 — PC 를 바꿔도 따라오게

사용자 승인(2026-09-07). 앞서 취향을 localStorage 로 옮겨 **탭·재시작** 문제는 풀었고,
이 변경은 그 위에 **다른 PC 에서도 같은 배치**를 얹음. 사용자가 노트북·데스크톱 두 대를 오감.

- `db_management/018_user_ui_prefs.sql` — 사용자당 한 줄, `prefs` JSON 한 칸.
  칸을 나누면 취향이 늘 때마다 마이그레이션이 또 필요해 한 칸에 담음.
- `GET/PUT /api/dashboard/me/ui-prefs` — 배치 값만. **설계값은 안 담음**(문자열만 받음).
- 로그인이 확인된 첫 순간에 한 번 받아 로컬 위에 얹고, 취향이 바뀌면 1.5초 모아 올림.
  서버가 없거나 못 읽으면 **로컬 값으로 그대로 돔**(계획서 원문).

⚠ 같은 함정을 또 밟을 뻔했음 — 키만 아는 자리가 저장소를 직접 고르면 **올려보내기도 안 걸림**.
그래서 `storageOf` 를 없애고 **읽기·쓰기 창구 하나**(`readByKey`/`writeByKey`)로 모음.
저장소 선택과 서버 올려보내기가 그 한 곳에만 있음. 그물도 그 이름으로 갱신.

**DB 는 아직 적용하지 않았음** — 표가 없으면 API 가 실패하고 화면은 로컬 값으로 도는 것이
정상 동작임. 적용 시점은 다른 창들과 맞춘 뒤.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 14:50:31 +09:00
co-authored by Claude Opus 5
parent 159e2a5226
commit 8a0a8f18e3
11 changed files with 198 additions and 22 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ import { makePanelDraggable } from "./ui_template_overlay_drag";
// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav";
import { storageOf } from "../A00_Common/b_page_state";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
@@ -85,7 +85,7 @@ export function createWorkflowPanelHandle(
}
function readOpenState(key: string): boolean {
return storageOf(key).getItem(key) !== "false";
return readByKey(key) !== "false";
}
/**
@@ -192,7 +192,7 @@ function createPanel(
toggle.setAttribute("aria-label", toggle.title);
toggle.setAttribute("aria-expanded", String(isOpen));
}
storageOf(storageKey).setItem(storageKey, String(isOpen));
writeByKey(storageKey, String(isOpen));
// 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다.
if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh());
onOpenChange?.(isOpen);
+3 -3
View File
@@ -11,7 +11,7 @@
* 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫).
*/
import { storageOf } from "../A00_Common/b_page_state";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
const DRAG_THRESHOLD_PX = 4;
/** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */
@@ -30,7 +30,7 @@ interface PanelPosition {
}
function readPosition(key: string): PanelPosition | null {
const raw = storageOf(key).getItem(key);
const raw = readByKey(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<PanelPosition>;
@@ -94,7 +94,7 @@ export function makePanelDraggable(
root.style.bottom = "auto";
root.style.top = `${next.top}px`;
}
storageOf(storageKey).setItem(storageKey, JSON.stringify(next));
writeByKey(storageKey, JSON.stringify(next));
}
function swallowNextClick(): void {
+4 -4
View File
@@ -1,5 +1,5 @@
import "./ui_template_resizer.css";
import { storageOf } from "../A00_Common/b_page_state";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
/* =============================================================================
* ui_template_resizer.ts
@@ -62,20 +62,20 @@ export function createPanelResizer(options: PanelResizerOptions): PanelResizer {
function apply(size: number, persist: boolean): void {
const next = clamp(size);
target.style.setProperty(cssVar, `${Math.round(next)}px`);
if (persist && storageKey) storageOf(storageKey).setItem(storageKey, String(Math.round(next)));
if (persist && storageKey) writeByKey(storageKey, String(Math.round(next)));
onResize?.(next);
}
function restore(): void {
if (!storageKey) return;
const saved = Number(storageOf(storageKey).getItem(storageKey));
const saved = Number(readByKey(storageKey));
// 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다.
if (Number.isFinite(saved) && saved > 0) apply(saved, false);
}
function reset(): void {
target.style.removeProperty(cssVar);
if (storageKey) storageOf(storageKey).removeItem(storageKey);
if (storageKey) writeByKey(storageKey, null);
onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth);
}