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; return key.startsWith("aislo:pref:") ? window.localStorage : window.sessionStorage;
} }
/** 위와 같은 판단을 **키만 들고 저장소를 직접 만지는 화면 공용 부품**에 내준다 /**
* (`ui_template_resizer`·`ui_template_overlay`·`ui_template_overlay_drag`). * **키만 아는 자리**를 위한 읽기·쓰기 창구 — 등록표 이름 대신 완성된 키를 든 곳이 쓴다
* 그 부품들은 등록표에서 받은 키로 높이·열림·자리를 저장하는데, 여기를 안 거치면 * (`ui_template_resizer`·`_overlay`·`_overlay_drag`, 조정창 저장소).
* 취향만 세션에 남아 탭을 새로 열 때마다 초기화된다. */ *
export function storageOf(key: string): Storage { * 저장소를 직접 고르게 두면 두 가지가 샜다 — 취향이 세션에 남아 탭마다 초기화됐고
return storageFor(key); * (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(); 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 { function readRaw(key: string): string | null {
try { try {
@@ -264,6 +337,7 @@ function writeRaw(key: string, value: string | null): void {
} catch { } catch {
/* 무시 — 값은 화면 메모리에 남는다. */ /* 무시 — 값은 화면 메모리에 남는다. */
} }
if (key.startsWith("aislo:pref:")) pushUiPrefs();
} }
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */ /** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */
+11
View File
@@ -1,4 +1,5 @@
import { API_BASE_URL } from "@config/config_frontend"; import { API_BASE_URL } from "@config/config_frontend";
import { syncUiPrefs } from "../A00_Common/b_page_state";
export interface ApiResult { export interface ApiResult {
status: string; status: string;
@@ -42,10 +43,14 @@ export function verifyLogin(email: string, otpCode: string): Promise<ApiResult>
에서는 즉시 버려 다음 조회가 서버를 다시 본다. */ 에서는 즉시 버려 다음 조회가 서버를 다시 본다. */
const SESSION_CACHE_MS = 5000; const SESSION_CACHE_MS = 5000;
let sessionCache: { at: number; value: Promise<SessionUser | null> } | null = null; let sessionCache: { at: number; value: Promise<SessionUser | null> } | null = null;
/** 계정 취향 받아오기는 한 번만 — 세션 조회는 화면마다 여러 번 불린다. */
let uiPrefsSynced = false;
/** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */ /** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */
export function clearSessionCache(): void { export function clearSessionCache(): void {
sessionCache = null; sessionCache = null;
// 로그인·로그아웃이면 다음 세션에서 취향을 다시 받아 온다(다른 계정일 수 있다).
uiPrefsSynced = false;
} }
function sessionOnce(): Promise<SessionUser | null> { function sessionOnce(): Promise<SessionUser | null> {
@@ -56,6 +61,12 @@ function sessionOnce(): Promise<SessionUser | null> {
const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" });
if (!response.ok) return null; if (!response.ok) return null;
const data = (await response.json()) as { status: string; user?: SessionUser }; const data = (await response.json()) as { status: string; user?: SessionUser };
// 로그인이 확인된 첫 순간에 계정 취향을 한 번 받아 온다 — PC 를 바꿔도 같은 배치가
// 되게(2026-09-07). 실패해도 화면은 브라우저에 남은 값으로 그대로 돈다.
if (data.user && !uiPrefsSynced) {
uiPrefsSynced = true;
void syncUiPrefs();
}
return data.user ?? null; return data.user ?? null;
} catch { } catch {
return null; return null;
+35
View File
@@ -520,3 +520,38 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]:
(bucket_seconds, bucket_seconds, since, bucket_seconds), (bucket_seconds, bucket_seconds, since, bucket_seconds),
) )
return {"current": current, "history": list(await cursor.fetchall()), "stats": current} return {"current": current, "history": list(await cursor.fetchall()), "stats": current}
async def get_user_ui_prefs(user_id: int) -> dict[str, Any]:
"""계정에 붙은 화면 취향(배치·표시). 없으면 빈 묶음.
설계값이 아니라 **패널 높이·접힘 같은 배치 값**만 담는다(표 `user_ui_prefs` 주석 참조).
읽기 실패는 비치명 — 화면은 브라우저에 남은 값으로 그대로 돈다.
"""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute("SELECT prefs FROM user_ui_prefs WHERE user_id = %s", (user_id,))
row = await cursor.fetchone()
if not row or not row[0]:
return {}
raw = row[0]
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8")
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return {}
return raw if isinstance(raw, dict) else {}
async def save_user_ui_prefs(user_id: int, prefs: dict[str, Any]) -> None:
"""화면 취향을 통째로 덮어쓴다 — 사용자당 한 줄."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""INSERT INTO user_ui_prefs (user_id, prefs) VALUES (%s, %s)
ON DUPLICATE KEY UPDATE prefs = VALUES(prefs)""",
(user_id, json.dumps(prefs, ensure_ascii=False)),
)
await connection.commit()
+21
View File
@@ -34,11 +34,13 @@ from .B01_Dashboard_Repository import (
get_project, get_project,
get_system_resources, get_system_resources,
get_user_admin_target, get_user_admin_target,
get_user_ui_prefs,
list_all_projects, list_all_projects,
list_all_users, list_all_users,
list_audit_logs, list_audit_logs,
list_company_projects, list_company_projects,
list_user_projects, list_user_projects,
save_user_ui_prefs,
soft_delete_project, soft_delete_project,
soft_delete_user, soft_delete_user,
update_admin_user, update_admin_user,
@@ -85,6 +87,7 @@ from .B01_Dashboard_Schema import (
InviteMemberRequest, InviteMemberRequest,
JoinCompanyRequest, JoinCompanyRequest,
ProcessJoinRequest, ProcessJoinRequest,
UiPrefsRequest,
UpdateCompanyAssetRequest, UpdateCompanyAssetRequest,
UpdateCompanyLogoRequest, UpdateCompanyLogoRequest,
UpdateProjectRequest, UpdateProjectRequest,
@@ -169,6 +172,24 @@ async def patch_dashboard_me(
return {"status": "success", "user": user} return {"status": "success", "user": user}
# ── 화면 취향 (2026-09-07 사용자 승인) ───────────────────────────────────────
# 패널 높이·접힘 같은 **배치 값**만 계정에 붙인다. 설계값은 여기 오지 않는다 — 초안·결과는
# 브라우저에 두고 [저장]·[확정]에서만 정본으로 간다(CLAUDE.md 5장).
# 브라우저는 이 값을 **로컬 사본 위에 병합**하고, 서버가 없거나 못 읽으면 로컬 값으로 돈다.
@router.get("/me/ui-prefs")
async def read_ui_prefs(session: dict[str, Any] = Depends(verify_session)):
return {"status": "success", "prefs": await get_user_ui_prefs(int(session["user_id"]))}
@router.put("/me/ui-prefs")
async def write_ui_prefs(
payload: UiPrefsRequest,
session: dict[str, Any] = Depends(verify_session),
):
await save_user_ui_prefs(int(session["user_id"]), payload.prefs)
return {"status": "success"}
@router.get("/user/projects") @router.get("/user/projects")
async def user_projects(session: dict[str, Any] = Depends(verify_session)): async def user_projects(session: dict[str, Any] = Depends(verify_session)):
# 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다. # 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다.
+10
View File
@@ -12,6 +12,16 @@ class UpdateUserRequest(BaseModel):
phone: str | None = Field(default=None, max_length=50) phone: str | None = Field(default=None, max_length=50)
class UiPrefsRequest(BaseModel):
"""화면 취향 묶음 — 배치·표시 값만. 키는 브라우저 등록표(`b_page_state.ts`)가 정본이다.
값은 문자열 하나(높이는 `"438"`, 접힘은 `"true"`)라 저장소에 넣던 모양 그대로다.
설계값이 섞여 들어오지 않게 **문자열만** 받는다.
"""
prefs: dict[str, str] = Field(default_factory=dict)
class CreateCompanyRequest(BaseModel): class CreateCompanyRequest(BaseModel):
name: str = Field(min_length=1, max_length=255) name: str = Field(min_length=1, max_length=255)
business_registration_number: str = Field(min_length=1, max_length=20) business_registration_number: str = Field(min_length=1, max_length=20)
+3 -3
View File
@@ -1,7 +1,7 @@
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { stateKey, storageOf } from "../A00_Common/b_page_state"; import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
import { navigateTo } from "../A00_Common/router"; import { navigateTo } from "../A00_Common/router";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
@@ -529,7 +529,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const width = crossHalfWidth(); const width = crossHalfWidth();
if (!key || width === undefined) return; if (!key || width === undefined) return;
try { try {
storageOf(key).setItem(key, String(width)); writeByKey(key, String(width));
} catch { } catch {
/* 무시 */ /* 무시 */
} }
@@ -708,7 +708,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값). // 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값).
const sessionDisplayKey = displaySessionKey(); const sessionDisplayKey = displaySessionKey();
if (sessionDisplayKey) { if (sessionDisplayKey) {
const sessionDisplay = Number(storageOf(sessionDisplayKey).getItem(sessionDisplayKey)); const sessionDisplay = Number(readByKey(sessionDisplayKey));
if (Number.isFinite(sessionDisplay) && sessionDisplay > 0) if (Number.isFinite(sessionDisplay) && sessionDisplay > 0)
crossHalfWidthField.input.value = sessionDisplay.toFixed(1); crossHalfWidthField.input.value = sessionDisplay.toFixed(1);
} }
+3 -3
View File
@@ -21,7 +21,7 @@ import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
import { readState, storageOf } from "../A00_Common/b_page_state"; import { readByKey, readState, writeByKey } from "../A00_Common/b_page_state";
import { import {
applyStructureAreaRows, applyStructureAreaRows,
STRUCTURE_AREA_KEYS, STRUCTURE_AREA_KEYS,
@@ -92,7 +92,7 @@ export function createRockBoundaryStore(options: {
const storageKey = sessionKey(); const storageKey = sessionKey();
if (!storageKey) return; if (!storageKey) return;
try { try {
storageOf(storageKey).setItem(storageKey, JSON.stringify(Object.fromEntries(offsets))); writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets)));
} catch { } catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
} }
@@ -105,7 +105,7 @@ export function createRockBoundaryStore(options: {
const storageKey = sessionKey(); const storageKey = sessionKey();
if (!storageKey) return; if (!storageKey) return;
try { try {
const raw = storageOf(storageKey).getItem(storageKey); const raw = readByKey(storageKey);
if (!raw) return; if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>; const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, offset]) => { Object.entries(parsed).forEach(([chainage, offset]) => {
+25
View File
@@ -0,0 +1,25 @@
-- 018_user_ui_prefs.sql
-- 화면 취향을 계정에 붙인다 (2026-09-07 사용자 승인)
--
-- 패널 높이·접힘, 유토곡선 열림·범례 같은 **배치 값**이다. 설계값은 담지 않는다 —
-- 설계 초안·계산 결과는 지금처럼 브라우저 세션에 두고 [저장]·[확정]에서만 정본으로 간다.
--
-- 왜 필요한가 — 취향이 브라우저에만 있어 **PC 를 바꾸면 처음부터 다시 맞춰야 했다**.
-- 사용자가 노트북·데스크톱 두 대를 오가며 쓴다. (탭을 새로 열 때마다 초기화되던 것은
-- 앞서 `localStorage` 로 옮겨 해결했고, 이 표는 그 위에 「다른 PC 에서도 같은 배치」를 얹는다.)
--
-- 왜 칸을 나누지 않고 JSON 한 칸인가 — 취향은 화면이 늘 때마다 늘어난다. 칸으로 나누면
-- 그때마다 마이그레이션이 또 필요하다. 값이 작고(수십 바이트) 검색 대상이 아니라
-- 한 칸에 담는 편이 값싸다. 키 이름은 브라우저 등록표(`A00_Common/b_page_state.ts`)가 정본이다.
USE aislo_db;
CREATE TABLE IF NOT EXISTS user_ui_prefs (
user_id INT NOT NULL COMMENT 'users.id — 사용자당 한 줄',
prefs JSON NOT NULL COMMENT '화면 취향 묶음 {키: 값}. 키는 브라우저 등록표의 pref 이름',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id),
CONSTRAINT fk_user_ui_prefs_user FOREIGN KEY (user_id)
REFERENCES users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='사용자별 화면 취향 (배치·표시). 설계값은 담지 않는다';
+3 -3
View File
@@ -5,7 +5,7 @@ import { makePanelDraggable } from "./ui_template_overlay_drag";
// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 // 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. // 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; 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 TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
@@ -85,7 +85,7 @@ export function createWorkflowPanelHandle(
} }
function readOpenState(key: string): boolean { 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-label", toggle.title);
toggle.setAttribute("aria-expanded", String(isOpen)); toggle.setAttribute("aria-expanded", String(isOpen));
} }
storageOf(storageKey).setItem(storageKey, String(isOpen)); writeByKey(storageKey, String(isOpen));
// 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다. // 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다.
if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh()); if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh());
onOpenChange?.(isOpen); onOpenChange?.(isOpen);
+3 -3
View File
@@ -11,7 +11,7 @@
* 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫). * 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫).
*/ */
import { storageOf } from "../A00_Common/b_page_state"; import { readByKey, writeByKey } from "../A00_Common/b_page_state";
const DRAG_THRESHOLD_PX = 4; const DRAG_THRESHOLD_PX = 4;
/** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ /** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */
@@ -30,7 +30,7 @@ interface PanelPosition {
} }
function readPosition(key: string): PanelPosition | null { function readPosition(key: string): PanelPosition | null {
const raw = storageOf(key).getItem(key); const raw = readByKey(key);
if (!raw) return null; if (!raw) return null;
try { try {
const parsed = JSON.parse(raw) as Partial<PanelPosition>; const parsed = JSON.parse(raw) as Partial<PanelPosition>;
@@ -94,7 +94,7 @@ export function makePanelDraggable(
root.style.bottom = "auto"; root.style.bottom = "auto";
root.style.top = `${next.top}px`; root.style.top = `${next.top}px`;
} }
storageOf(storageKey).setItem(storageKey, JSON.stringify(next)); writeByKey(storageKey, JSON.stringify(next));
} }
function swallowNextClick(): void { function swallowNextClick(): void {
+4 -4
View File
@@ -1,5 +1,5 @@
import "./ui_template_resizer.css"; 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 * ui_template_resizer.ts
@@ -62,20 +62,20 @@ export function createPanelResizer(options: PanelResizerOptions): PanelResizer {
function apply(size: number, persist: boolean): void { function apply(size: number, persist: boolean): void {
const next = clamp(size); const next = clamp(size);
target.style.setProperty(cssVar, `${Math.round(next)}px`); 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); onResize?.(next);
} }
function restore(): void { function restore(): void {
if (!storageKey) return; if (!storageKey) return;
const saved = Number(storageOf(storageKey).getItem(storageKey)); const saved = Number(readByKey(storageKey));
// 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다. // 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다.
if (Number.isFinite(saved) && saved > 0) apply(saved, false); if (Number.isFinite(saved) && saved > 0) apply(saved, false);
} }
function reset(): void { function reset(): void {
target.style.removeProperty(cssVar); target.style.removeProperty(cssVar);
if (storageKey) storageOf(storageKey).removeItem(storageKey); if (storageKey) writeByKey(storageKey, null);
onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth); onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth);
} }