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:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */
|
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 사용자 지시) — 수정 권한은 따로다.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]) => {
|
||||||
|
|||||||
@@ -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='사용자별 화면 취향 (배치·표시). 설계값은 담지 않는다';
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user