diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 5332496e..6be81d42 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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 | null = null; + +function localPrefs(): Record { + const out: Record = {}; + 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 { + let stored: Record = {}; + try { + const response = await fetch(PREFS_ENDPOINT, { credentials: "include" }); + if (!response.ok) return; + const body = (await response.json()) as { prefs?: Record }; + 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(); } /** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */ diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index c2565d99..157cb129 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -1,4 +1,5 @@ import { API_BASE_URL } from "@config/config_frontend"; +import { syncUiPrefs } from "../A00_Common/b_page_state"; export interface ApiResult { status: string; @@ -42,10 +43,14 @@ export function verifyLogin(email: string, otpCode: string): Promise 에서는 즉시 버려 다음 조회가 서버를 다시 본다. */ const SESSION_CACHE_MS = 5000; let sessionCache: { at: number; value: Promise } | null = null; +/** 계정 취향 받아오기는 한 번만 — 세션 조회는 화면마다 여러 번 불린다. */ +let uiPrefsSynced = false; /** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */ export function clearSessionCache(): void { sessionCache = null; + // 로그인·로그아웃이면 다음 세션에서 취향을 다시 받아 온다(다른 계정일 수 있다). + uiPrefsSynced = false; } function sessionOnce(): Promise { @@ -56,6 +61,12 @@ function sessionOnce(): Promise { const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); if (!response.ok) return null; 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; } catch { return null; diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 9bed97a0..b7fe4ac8 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -520,3 +520,38 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]: (bucket_seconds, bucket_seconds, since, bucket_seconds), ) 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() diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 1f4740cf..0635b8dc 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -34,11 +34,13 @@ from .B01_Dashboard_Repository import ( get_project, get_system_resources, get_user_admin_target, + get_user_ui_prefs, list_all_projects, list_all_users, list_audit_logs, list_company_projects, list_user_projects, + save_user_ui_prefs, soft_delete_project, soft_delete_user, update_admin_user, @@ -85,6 +87,7 @@ from .B01_Dashboard_Schema import ( InviteMemberRequest, JoinCompanyRequest, ProcessJoinRequest, + UiPrefsRequest, UpdateCompanyAssetRequest, UpdateCompanyLogoRequest, UpdateProjectRequest, @@ -169,6 +172,24 @@ async def patch_dashboard_me( 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") async def user_projects(session: dict[str, Any] = Depends(verify_session)): # 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다. diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index ceab68a7..9218ab0d 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -12,6 +12,16 @@ class UpdateUserRequest(BaseModel): 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): name: str = Field(min_length=1, max_length=255) business_registration_number: str = Field(min_length=1, max_length=20) diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 0a3f68db..30ba8b6d 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,7 +1,7 @@ import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; 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 { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; @@ -529,7 +529,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const width = crossHalfWidth(); if (!key || width === undefined) return; try { - storageOf(key).setItem(key, String(width)); + writeByKey(key, String(width)); } catch { /* 무시 */ } @@ -708,7 +708,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값). const sessionDisplayKey = displaySessionKey(); if (sessionDisplayKey) { - const sessionDisplay = Number(storageOf(sessionDisplayKey).getItem(sessionDisplayKey)); + const sessionDisplay = Number(readByKey(sessionDisplayKey)); if (Number.isFinite(sessionDisplay) && sessionDisplay > 0) crossHalfWidthField.input.value = sessionDisplay.toFixed(1); } diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index ba03afb1..05947b77 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -21,7 +21,7 @@ import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; 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 { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -92,7 +92,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - storageOf(storageKey).setItem(storageKey, JSON.stringify(Object.fromEntries(offsets))); + writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -105,7 +105,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = storageOf(storageKey).getItem(storageKey); + const raw = readByKey(storageKey); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, offset]) => { diff --git a/db_management/018_user_ui_prefs.sql b/db_management/018_user_ui_prefs.sql new file mode 100644 index 00000000..d5eb0530 --- /dev/null +++ b/db_management/018_user_ui_prefs.sql @@ -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='사용자별 화면 취향 (배치·표시). 설계값은 담지 않는다'; diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index fe396242..3fe0881b 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -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); diff --git a/ui_template/ui_template_overlay_drag.ts b/ui_template/ui_template_overlay_drag.ts index 3dc6cdbb..35e205cc 100644 --- a/ui_template/ui_template_overlay_drag.ts +++ b/ui_template/ui_template_overlay_drag.ts @@ -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; @@ -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 { diff --git a/ui_template/ui_template_resizer.ts b/ui_template/ui_template_resizer.ts index 717aec37..a25a8436 100644 --- a/ui_template/ui_template_resizer.ts +++ b/ui_template/ui_template_resizer.ts @@ -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); }