Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,847 @@
.layout-playground__hero {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 24px;
border: 1px solid rgba(8, 145, 178, 0.14);
border-radius: 24px;
background:
linear-gradient(135deg, rgba(8, 145, 178, 0.12), rgba(59, 130, 246, 0.08)),
rgba(255, 255, 255, 0.92);
}
.layout-playground__title.ant-typography {
margin-top: 14px;
margin-bottom: 8px;
}
.layout-playground__meta {
display: grid;
gap: 10px;
min-width: 220px;
}
.layout-playground__meta span {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
color: #155e75;
}
.layout-playground__controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.layout-playground__control-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
background: rgba(255, 255, 255, 0.88);
}
.layout-playground__control-card .ant-input-number,
.layout-playground__control-card .ant-radio-group {
width: 100%;
}
.layout-playground__control-card .ant-radio-button-wrapper {
text-align: center;
}
.layout-playground__preview-wrap {
display: flex;
flex-direction: column;
gap: 12px;
}
.layout-playground__actions {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 22px;
background: rgba(255, 255, 255, 0.9);
}
.layout-playground__storage-card {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
padding: 20px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)),
rgba(255, 255, 255, 0.92);
}
.layout-playground__storage-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.layout-playground__storage-copy.ant-typography {
margin-top: 6px;
margin-bottom: 0;
}
.layout-playground__storage-input {
width: 100%;
}
.layout-playground__bottom-menu {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 20px 22px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.92)),
rgba(255, 255, 255, 0.94);
}
.layout-playground__bottom-menu-copy {
flex: 1;
min-width: 0;
}
.layout-playground__bottom-menu-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
flex: 1;
min-width: 0;
}
.layout-playground__saved-item {
padding-inline: 14px !important;
border: 1px solid transparent;
border-radius: 18px;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease;
}
.layout-playground__saved-item:hover {
background: rgba(248, 250, 252, 0.86);
border-color: rgba(148, 163, 184, 0.18);
}
.layout-playground__saved-item--active {
border-color: rgba(59, 130, 246, 0.28);
background:
linear-gradient(180deg, rgba(239, 246, 255, 0.92), rgba(255, 255, 255, 0.98)),
#fff;
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.08);
}
.layout-playground__saved-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.layout-playground__preview-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.layout-playground__fullscreen-shell {
display: flex;
width: 100%;
height: calc(100dvh - 92px);
min-height: 0;
min-width: 0;
overflow: hidden;
}
.layout-playground__fullscreen-shell--embedded {
height: 100%;
}
.layout-playground__fullscreen-shell--saved-fit {
min-height: 0;
overflow: hidden;
}
.layout-playground__preview-frame {
display: flex;
flex-direction: column;
min-height: 460px;
width: 100%;
padding: 18px;
border: 1px solid rgba(14, 116, 144, 0.14);
border-radius: 28px;
background:
radial-gradient(circle at top left, rgba(14, 165, 233, 0.12), transparent 28%),
linear-gradient(180deg, rgba(240, 249, 255, 0.96), rgba(255, 255, 255, 0.98));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.layout-playground__preview-frame--fullscreen {
min-height: 0;
height: 100%;
}
.layout-playground__preview-frame--saved-detail {
flex: 1;
min-height: 0;
overflow: hidden;
}
.layout-playground__preview-frame--gallery {
flex: 1;
min-height: 0;
padding: 10px;
border-radius: 22px;
}
.layout-playground__saved-browser {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
width: 100%;
min-height: 0;
overflow: hidden;
}
.layout-playground__saved-detail {
display: flex;
flex-direction: column;
flex: 1;
gap: 12px;
height: 100%;
width: 100%;
min-height: 0;
}
.layout-playground__saved-fit-viewport {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.layout-playground__saved-fit-stage {
display: flex;
align-items: flex-start;
justify-content: flex-start;
flex: 0 0 auto;
max-width: 100%;
max-height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.layout-playground__saved-detail--fit {
display: flex;
flex: 0 0 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
transform-origin: top left;
will-change: transform;
}
.layout-playground__saved-detail--fit,
.layout-playground__saved-detail--fit > * {
max-width: none;
}
.layout-playground__saved-detail-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 4px;
}
.layout-playground__saved-browser-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.layout-playground__saved-browser-layout {
min-height: calc(100vh - 220px);
}
.layout-playground__saved-browser-preview {
min-width: 0;
}
.layout-playground__saved-gallery {
--layout-gallery-columns: 1;
--layout-gallery-rows: 1;
display: grid;
grid-template-columns: repeat(var(--layout-gallery-columns), minmax(0, 1fr));
grid-template-rows: repeat(var(--layout-gallery-rows), minmax(0, 1fr));
gap: 12px;
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
align-items: stretch;
}
.layout-playground__saved-card {
display: flex;
flex-direction: column;
justify-content: stretch;
flex: 1;
gap: 10px;
min-width: 0;
min-height: 0;
height: 100%;
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.94)),
rgba(255, 255, 255, 0.94);
overflow: hidden;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.layout-playground__saved-card:hover {
border-color: rgba(59, 130, 246, 0.22);
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.08);
}
.layout-playground__saved-card--active {
border-color: rgba(59, 130, 246, 0.36);
box-shadow: 0 16px 30px rgba(37, 99, 235, 0.1);
}
.layout-playground__saved-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex: none;
}
.layout-playground__empty-state {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 14px;
min-height: 420px;
padding: 28px;
border: 1px dashed rgba(14, 116, 144, 0.28);
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(125, 211, 252, 0.28), transparent 30%),
rgba(255, 255, 255, 0.76);
}
.layout-playground__empty-state--fullscreen {
flex: 1;
min-height: 0;
height: 100%;
}
.layout-playground__saved-browser-loading {
display: flex;
flex: 1;
min-height: 0;
width: 100%;
}
.layout-playground__saved-browser-loading .ant-list {
flex: 1;
min-height: 0;
}
.layout-playground__empty-state .ant-typography {
margin-bottom: 0;
}
.layout-playground__editor-card.ant-card,
.layout-playground__editor-card.ant-card .ant-card-body,
.layout-playground__editor-stack.ant-space,
.layout-playground__editor-stack.ant-space .ant-space-item {
min-height: 0;
}
.layout-playground__editor-card.ant-card {
min-height: 100%;
}
.layout-playground__editor-card.ant-card .ant-card-body {
height: 100%;
}
.layout-playground__editor-stack.ant-space {
display: flex;
height: 100%;
}
.layout-playground__splitter {
position: relative;
height: 100%;
min-height: 420px;
min-width: 0;
border-radius: 20px;
overflow: hidden;
background: rgba(148, 163, 184, 0.08);
}
.layout-playground__splitter--preview {
min-height: 0;
}
.layout-playground__splitter-frame {
height: 100%;
min-height: inherit;
}
.layout-playground__splitter-frame .ant-splitter-panel {
min-height: 0;
overflow: hidden;
}
.layout-playground__splitter--collapsed {
display: flex;
flex: 1;
width: 100%;
min-height: inherit;
}
.layout-playground__splitter--collapsed.layout-playground__splitter--preview {
min-height: 0;
}
.layout-playground__splitter--collapsed-horizontal {
flex-direction: row;
}
.layout-playground__splitter--collapsed-vertical {
flex-direction: column;
}
.layout-playground__splitter--collapsed > :first-child {
flex: 1;
min-width: 0;
min-height: 0;
}
.layout-playground__splitter-toggle-dock {
position: absolute;
z-index: 2;
display: flex;
gap: 8px;
pointer-events: none;
}
.layout-playground__splitter-toggle-dock .ant-btn {
pointer-events: auto;
box-shadow: 0 12px 26px rgba(15, 23, 42, 0.16);
}
.layout-playground__splitter-toggle-dock--horizontal {
top: 50%;
left: 50%;
flex-direction: column;
transform: translate(-50%, -50%);
}
.layout-playground__splitter-toggle-dock--vertical {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.layout-playground__splitter--depth-2 {
background: rgba(186, 230, 253, 0.2);
}
.layout-playground__splitter--depth-3 {
background: rgba(224, 231, 255, 0.24);
}
.layout-playground__splitter--depth-4 {
background: rgba(254, 240, 138, 0.18);
}
.layout-playground__pane {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
min-height: 0;
padding: 12px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 0;
cursor: pointer;
transition:
border-color 0.18s ease,
background-color 0.18s ease;
}
.layout-playground__pane:hover {
background: rgba(255, 255, 255, 0.48);
}
.layout-playground__pane--readonly {
cursor: default;
}
.layout-playground__pane--readonly:hover {
transform: none;
box-shadow: none;
}
.layout-playground__pane--preview {
gap: 0;
padding: 0;
}
.layout-playground__pane--surface {
gap: 0;
padding: 0;
border-color: transparent;
background: transparent;
}
.layout-playground__pane:focus-visible {
outline: 3px solid rgba(37, 99, 235, 0.24);
outline-offset: 2px;
}
.layout-playground__pane-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 32px;
position: relative;
z-index: 1;
}
.layout-playground__pane-toolbar--overlay {
position: absolute;
top: 12px;
right: 12px;
left: 12px;
min-height: 0;
}
.layout-playground__pane-toolbar--compact {
justify-content: flex-end;
}
.layout-playground__pane--selected .layout-playground__pane-toolbar {
position: absolute;
top: 12px;
right: 12px;
left: 12px;
justify-content: flex-end;
}
.layout-playground__pane--primary {
background: rgba(207, 250, 254, 0.42);
}
.layout-playground__pane--secondary {
background: rgba(224, 231, 255, 0.4);
}
.layout-playground__pane--accent {
background: rgba(254, 249, 195, 0.42);
}
.layout-playground__pane--neutral {
background: rgba(226, 232, 240, 0.48);
}
.layout-playground__pane--surface.layout-playground__pane--primary,
.layout-playground__pane--surface.layout-playground__pane--secondary,
.layout-playground__pane--surface.layout-playground__pane--accent,
.layout-playground__pane--surface.layout-playground__pane--neutral,
.layout-playground__pane--hidden {
background: transparent;
}
.layout-playground__pane-label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #0f766e;
}
.layout-playground__pane-placeholder {
display: flex;
flex: 1;
min-height: 0;
align-items: flex-start;
justify-content: center;
padding: 40px 0 4px;
}
.layout-playground__pane--preview .layout-playground__pane-placeholder {
padding: 0;
}
.layout-playground__pane--surface .layout-playground__pane-placeholder {
padding: 0;
}
.layout-playground__pane-component-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.84);
}
.layout-playground__pane-component-body > * {
flex: 1;
min-height: 0;
min-width: 0;
width: 100%;
}
/* Saved-layout thumbnails must clamp nested sample scroll areas to the scaled preview box. */
.layout-playground__saved-detail--fit .layout-playground__preview-frame,
.layout-playground__saved-detail--fit .layout-playground__splitter,
.layout-playground__saved-detail--fit .layout-playground__splitter-frame,
.layout-playground__saved-detail--fit .layout-playground__splitter-frame .ant-splitter-panel,
.layout-playground__saved-detail--fit .layout-playground__pane,
.layout-playground__saved-detail--fit .layout-playground__pane-component-body,
.layout-playground__saved-detail--fit .layout-playground__pane-component-body > *,
.layout-playground__saved-detail--fit .ant-card,
.layout-playground__saved-detail--fit .ant-card-body,
.layout-playground__saved-detail--fit .ant-list,
.layout-playground__saved-detail--fit .ant-list-items,
.layout-playground__saved-detail--fit .ant-space,
.layout-playground__saved-detail--fit .ant-space-item {
overflow: hidden;
}
.layout-playground__saved-detail--fit .layout-playground__saved-detail-bar {
flex: 0 0 auto;
}
.layout-playground__saved-detail--fit .layout-playground__preview-frame--saved-detail {
min-height: 0;
}
.layout-playground__saved-detail--fit .layout-playground__splitter,
.layout-playground__saved-detail--fit .layout-playground__splitter-frame,
.layout-playground__saved-detail--fit .layout-playground__splitter-frame .ant-splitter-panel,
.layout-playground__saved-detail--fit .layout-playground__pane,
.layout-playground__saved-detail--fit .layout-playground__pane-component-body,
.layout-playground__saved-detail--fit .layout-playground__pane-component-body > *,
.layout-playground__saved-detail--fit .ant-card,
.layout-playground__saved-detail--fit .ant-card-body,
.layout-playground__saved-detail--fit .ant-space,
.layout-playground__saved-detail--fit .ant-space-item {
min-height: 0 !important;
min-width: 0;
max-height: none;
}
.layout-playground__saved-detail--fit .layout-playground__pane-component-body > * {
height: 100%;
}
.layout-playground__saved-detail--fit * {
scrollbar-width: none;
}
.layout-playground__saved-detail--fit *::-webkit-scrollbar {
width: 0;
height: 0;
}
.layout-playground__saved-detail--fit .previewer-ui__scroll,
.layout-playground__saved-detail--fit .previewer-ui__pre,
.layout-playground__saved-detail--fit .previewer-ui__markdown pre,
.layout-playground__saved-detail--fit .codex-diff-previewer__diff-body,
.layout-playground__saved-detail--fit .codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body {
overflow: hidden !important;
}
.layout-playground__saved-detail--fit .previewer-ui,
.layout-playground__saved-detail--fit .previewer-ui__body,
.layout-playground__saved-detail--fit .previewer-ui__editor,
.layout-playground__saved-detail--fit .previewer-ui__editor-body,
.layout-playground__saved-detail--fit .codex-diff-previewer,
.layout-playground__saved-detail--fit .codex-diff-previewer__diff-list,
.layout-playground__saved-detail--fit .codex-diff-previewer__diff-section,
.layout-playground__saved-detail--fit .embedded-map-ui,
.layout-playground__saved-detail--fit .embedded-map-ui__frame,
.layout-playground__saved-detail--fit .embedded-map-ui__slot {
min-height: 0 !important;
height: 100%;
}
.layout-playground__saved-detail--fit .embedded-map-ui__canvas {
min-height: 0;
}
.layout-playground__pane--selected {
border-color: rgba(37, 99, 235, 0.75);
background: rgba(219, 234, 254, 0.5);
}
.layout-playground__pane--surface.layout-playground__pane--selected {
background: transparent;
}
.layout-playground__pane--hidden {
border-color: transparent;
}
.layout-playground__divider.ant-divider {
margin: 0;
}
.layout-playground__code {
display: flex;
flex-direction: column;
gap: 12px;
}
.layout-playground__code-block {
margin: 0;
padding: 18px 20px;
overflow: auto;
border-radius: 20px;
background: #0f172a;
color: #dbeafe;
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 768px) {
.layout-playground__hero {
flex-direction: column;
}
.layout-playground__meta {
min-width: 0;
}
.layout-playground__preview-head {
flex-direction: column;
align-items: flex-start;
}
.layout-playground__bottom-menu {
flex-direction: column;
align-items: stretch;
}
.layout-playground__bottom-menu-actions {
flex-direction: column;
align-items: stretch;
}
.layout-playground__saved-browser-head {
flex-direction: column;
}
.layout-playground__saved-detail-bar {
flex-direction: column;
align-items: flex-start;
}
.layout-playground__saved-fit-viewport {
align-items: flex-start;
}
.layout-playground__saved-browser-layout {
min-height: auto;
}
.layout-playground__saved-gallery {
grid-template-columns: 1fr;
grid-template-rows: repeat(var(--layout-gallery-rows), minmax(0, 1fr));
min-height: auto;
}
.layout-playground__saved-card-head {
flex-direction: column;
}
.layout-playground__storage-head {
flex-direction: column;
}
.layout-playground__pane-toolbar {
flex-direction: column;
align-items: flex-start;
}
.layout-playground__preview-frame {
min-height: 520px;
padding: 12px;
}
.layout-playground__preview-frame--fullscreen {
min-height: 0;
}
.layout-playground__preview-frame--gallery {
min-height: 320px;
}
.layout-playground__splitter {
min-height: 480px;
}
.layout-playground__splitter-toggle-dock {
width: calc(100% - 24px);
justify-content: center;
}
.layout-playground__splitter-toggle-dock--horizontal,
.layout-playground__splitter-toggle-dock--vertical {
flex-direction: column;
}
.layout-playground__splitter-toggle-dock .ant-btn {
width: 100%;
}
}

File diff suppressed because it is too large Load Diff

313
src/views/play/layoutStorage.ts Executable file
View File

@@ -0,0 +1,313 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
export type LayoutAxis = 'horizontal' | 'vertical';
export type SizeUnit = 'px' | '%';
export type SavedLayoutRecord = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
axis: LayoutAxis;
sizeUnit: SizeUnit;
primarySize: number;
primaryMin: number;
secondaryMin: number;
resizable: boolean;
selectedLeafId: string | null;
totalPanes: number;
summary: string;
tree: unknown;
};
type SavedLayoutRow = {
id: string;
name: string;
created_at: string;
updated_at: string;
axis: LayoutAxis;
size_unit: SizeUnit;
primary_size: number;
primary_min: number;
secondary_min: number;
resizable: boolean;
selected_leaf_id: string | null;
total_panes: number;
summary: string;
tree: unknown;
};
const WORK_SERVER_TIMEOUT_MS = 8000;
const PLAY_LAYOUTS_TABLE = 'play_layouts';
let setupPromise: Promise<void> | null = null;
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveWorkServerFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
const isLocalWorkServerHost =
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalWorkServerHost) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
const WORK_SERVER_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
class LayoutStorageError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'LayoutStorageError';
this.status = status;
}
}
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null;
const method = init?.method?.toUpperCase() ?? 'GET';
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, WORK_SERVER_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
let response: Response;
try {
response = await fetch(`${baseUrl}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
});
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof DOMException && error.name === 'AbortError') {
throw new LayoutStorageError('work-db 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error;
}
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text();
let message = text || '레이아웃 저장소 요청에 실패했습니다.';
try {
const payload = JSON.parse(text) as { message?: string };
message = payload.message || message;
} catch {
// Keep the raw response text when JSON parsing is not available.
}
throw new LayoutStorageError(message, response.status);
}
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text();
throw new LayoutStorageError(text ? 'work-db 응답이 JSON이 아닙니다.' : 'work-db 응답을 확인할 수 없습니다.', 502);
}
return response.json() as Promise<T>;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
return await requestOnce<T>(WORK_SERVER_BASE_URL, path, init);
} catch (error) {
const shouldRetryWithFallback =
WORK_SERVER_FALLBACK_BASE_URL &&
WORK_SERVER_FALLBACK_BASE_URL !== WORK_SERVER_BASE_URL &&
(error instanceof LayoutStorageError
? error.status === 404 || error.status === 408 || error.status === 502
: error instanceof Error && (/not found/i.test(error.message) || /404/.test(error.message)));
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(WORK_SERVER_FALLBACK_BASE_URL, path, init);
}
}
function toRecord(row: SavedLayoutRow): SavedLayoutRecord {
return {
id: row.id,
name: row.name,
createdAt: row.created_at,
updatedAt: row.updated_at,
axis: row.axis,
sizeUnit: row.size_unit,
primarySize: Number(row.primary_size),
primaryMin: Number(row.primary_min),
secondaryMin: Number(row.secondary_min),
resizable: Boolean(row.resizable),
selectedLeafId: row.selected_leaf_id,
totalPanes: Number(row.total_panes),
summary: row.summary,
tree: row.tree,
};
}
function toRow(record: SavedLayoutRecord): SavedLayoutRow {
return {
id: record.id,
name: record.name,
created_at: record.createdAt,
updated_at: record.updatedAt,
axis: record.axis,
size_unit: record.sizeUnit,
primary_size: record.primarySize,
primary_min: record.primaryMin,
secondary_min: record.secondaryMin,
resizable: record.resizable,
selected_leaf_id: record.selectedLeafId,
total_panes: record.totalPanes,
summary: record.summary,
tree: record.tree,
};
}
async function ensureLayoutStorageTable() {
if (!setupPromise) {
setupPromise = (async () => {
const schemaResponse = await request<{ items: Array<{ table_name: string }> }>('/schema/tables');
const tableExists = schemaResponse.items.some((item) => item.table_name === PLAY_LAYOUTS_TABLE);
if (tableExists) {
return;
}
try {
await request<{ ok: boolean; tableName: string }>('/ddl/create-table', {
method: 'POST',
body: JSON.stringify({
tableName: PLAY_LAYOUTS_TABLE,
columns: [
{ name: 'id', type: 'text', nullable: false, primary: true },
{ name: 'name', type: 'text', nullable: false },
{ name: 'created_at', type: 'timestamp with time zone', nullable: false },
{ name: 'updated_at', type: 'timestamp with time zone', nullable: false },
{ name: 'axis', type: 'text', nullable: false },
{ name: 'size_unit', type: 'text', nullable: false },
{ name: 'primary_size', type: 'integer', nullable: false },
{ name: 'primary_min', type: 'integer', nullable: false },
{ name: 'secondary_min', type: 'integer', nullable: false },
{ name: 'resizable', type: 'boolean', nullable: false, defaultTo: true },
{ name: 'selected_leaf_id', type: 'text', nullable: true },
{ name: 'total_panes', type: 'integer', nullable: false },
{ name: 'summary', type: 'text', nullable: false },
{ name: 'tree', type: 'jsonb', nullable: false },
],
}),
});
} catch (error) {
if (!(error instanceof LayoutStorageError) || !/already exists/i.test(error.message)) {
throw error;
}
}
})().catch((error) => {
setupPromise = null;
throw error;
});
}
return setupPromise;
}
export async function listSavedLayouts() {
await ensureLayoutStorageTable();
const response = await request<{ rows: SavedLayoutRow[] }>(`/crud/${PLAY_LAYOUTS_TABLE}/select`, {
method: 'POST',
body: JSON.stringify({
orderBy: [{ field: 'updated_at', direction: 'desc' }],
limit: 200,
}),
});
return response.rows.map(toRecord);
}
export async function saveLayout(record: SavedLayoutRecord) {
await ensureLayoutStorageTable();
const row = toRow(record);
const existing = await request<{ rows: SavedLayoutRow[] }>(`/crud/${PLAY_LAYOUTS_TABLE}/select`, {
method: 'POST',
body: JSON.stringify({
where: [{ field: 'id', operator: 'eq', value: record.id }],
limit: 1,
}),
});
if (existing.rows.length > 0) {
await request<{ ok: boolean }>(`/crud/${PLAY_LAYOUTS_TABLE}/update`, {
method: 'PATCH',
body: JSON.stringify({
data: row,
where: [{ field: 'id', operator: 'eq', value: record.id }],
}),
});
return;
}
await request<{ ok: boolean }>(`/crud/${PLAY_LAYOUTS_TABLE}/insert`, {
method: 'POST',
body: JSON.stringify({
data: row,
}),
});
}
export async function deleteLayout(id: string) {
await ensureLayoutStorageTable();
await request<{ ok: boolean }>(`/crud/${PLAY_LAYOUTS_TABLE}/delete`, {
method: 'DELETE',
body: JSON.stringify({
where: [{ field: 'id', operator: 'eq', value: id }],
}),
});
}