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
+80 -6
View File
@@ -214,12 +214,19 @@ function storageFor(key: string): Storage {
return key.startsWith("aislo:pref:") ? window.localStorage : window.sessionStorage;
}
/** 위와 같은 판단을 **키만 들고 저장소를 직접 만지는 화면 공용 부품**에 내준다
* (`ui_template_resizer`·`ui_template_overlay`·`ui_template_overlay_drag`).
* 그 부품들은 등록표에서 받은 키로 높이·열림·자리를 저장하는데, 여기를 안 거치면
* 취향만 세션에 남아 탭을 새로 열 때마다 초기화된다. */
export function storageOf(key: string): Storage {
return storageFor(key);
/**
* **키만 아는 자리**를 위한 읽기·쓰기 창구 — 등록표 이름 대신 완성된 키를 든 곳이 쓴다
* (`ui_template_resizer`·`_overlay`·`_overlay_drag`, 조정창 저장소).
*
* 저장소를 직접 고르게 두면 두 가지가 샜다 — 취향이 세션에 남아 탭마다 초기화됐고
* (2026-09-07), 계정 올려보내기도 안 걸렸다. **저장소 선택과 올려보내기를 여기 한 곳**에 둔다.
*/
export function readByKey(key: string): string | null {
return readRaw(key);
}
export function writeByKey(key: string, value: string | null): void {
writeRaw(key, value);
}
/**
@@ -247,6 +254,72 @@ function liftPrefsToLocalStorage(): void {
liftPrefsToLocalStorage();
/* ── 계정에 붙는 화면 취향 (2026-09-07 사용자 승인) ─────────────────────────────
* 브라우저에 남긴 값 위에 **계정 값을 얹는다** — PC 를 바꿔도 같은 배치가 되게.
* 규칙 셋:
* · 서버가 없거나 못 읽으면 **로컬 값으로 그대로 돈다**(계획서 원문).
* · 서버에 있는 키만 덮는다 — 서버가 비었다고 로컬을 지우지 않는다.
* · 취향이 바뀌면 잠시 모아 한 번에 올린다. **설계값은 여기 오지 않는다** —
* 자동저장 금지(CLAUDE.md 5장)는 설계값 규칙이고, 화면 배치는 [저장] 단추가 없다.
* ------------------------------------------------------------------------- */
const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs";
const PREFS_PUSH_DELAY_MS = 1500;
let applyingServerPrefs = false;
let prefsPushTimer: ReturnType<typeof setTimeout> | null = null;
function localPrefs(): Record<string, string> {
const out: Record<string, string> = {};
try {
for (const key of Object.keys(window.localStorage)) {
if (!key.startsWith("aislo:pref:")) continue;
const value = window.localStorage.getItem(key);
if (value !== null) out[key] = value;
}
} catch {
/* 저장소가 막힌 브라우저 — 올릴 것이 없다. */
}
return out;
}
/** 취향이 바뀌면 잠시 모아 한 번에 올린다. 실패는 조용히 넘긴다(다음 변경 때 다시 올라간다). */
function pushUiPrefs(): void {
if (applyingServerPrefs) return;
if (prefsPushTimer) clearTimeout(prefsPushTimer);
prefsPushTimer = setTimeout(() => {
prefsPushTimer = null;
void fetch(PREFS_ENDPOINT, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prefs: localPrefs() }),
}).catch(() => undefined);
}, PREFS_PUSH_DELAY_MS);
}
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */
export async function syncUiPrefs(): Promise<void> {
let stored: Record<string, unknown> = {};
try {
const response = await fetch(PREFS_ENDPOINT, { credentials: "include" });
if (!response.ok) return;
const body = (await response.json()) as { prefs?: Record<string, unknown> };
stored = body.prefs ?? {};
} catch {
return; // 서버가 없으면 로컬 값으로 그대로 돈다.
}
applyingServerPrefs = true;
try {
for (const [key, value] of Object.entries(stored)) {
if (!key.startsWith("aislo:pref:") || typeof value !== "string") continue;
window.localStorage.setItem(key, value);
}
} catch {
/* 저장소가 막힌 브라우저 — 이번 세션은 화면 메모리로 돈다. */
} finally {
applyingServerPrefs = false;
}
}
/** 저장소 접근은 막힌 브라우저(사생활 보호)에서 던진다 — 전부 조용히 넘긴다. */
function readRaw(key: string): string | null {
try {
@@ -264,6 +337,7 @@ function writeRaw(key: string, value: string | null): void {
} catch {
/* 무시 — 값은 화면 메모리에 남는다. */
}
if (key.startsWith("aislo:pref:")) pushUiPrefs();
}
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */