Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/executeJS/src/features/playground/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './store';
145 changes: 145 additions & 0 deletions apps/executeJS/src/features/playground/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { JsExecutionResult } from '@/shared';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface Tab {
id: string;
playgroundId: Playground['id'];
title: string;
}

export interface Playground {
id: string;
result: JsExecutionResult | null;
isExecuting: boolean;
// setExecuting: (executing: boolean) => void;
// executeCode: (code: string) => void;
// clearResult: () => void;
}

interface PlaygroundState {
tabs: Tab[];
activeTabId: Tab['id'];
tabHistory: Tab['id'][];
playgrounds: Map<Playground['id'], Playground>;
addTab: () => void;
closeTab: (tabId: Tab['id']) => void;
setActiveTab: (tabId: Tab['id']) => void;
}

const INITIAL_TAB_TITLE = '✨New Playground';
const INITIAL_TAB_ID = 'first-playground-tab';
const INITIAL_PLAYGROUND_ID = 'first-playground';

const initialTab: Tab = {
id: INITIAL_TAB_ID,
playgroundId: INITIAL_PLAYGROUND_ID,
title: INITIAL_TAB_TITLE,
};

export const usePlaygroundStore = create<PlaygroundState>()(
persist(
(set) => ({
tabs: [initialTab],
activeTabId: INITIAL_TAB_ID,
tabHistory: [INITIAL_TAB_ID],
playgrounds: new Map([
[
INITIAL_PLAYGROUND_ID,
{ id: INITIAL_PLAYGROUND_ID, result: null, isExecuting: false },
],
]),

// 탭 추가
addTab: () => {
set((state) => {
const date = new Date().valueOf();

const newTabId = `playground-tab-${date}`;
const newPlaygroundId = `playground-${date}`;

const newTab: Tab = {
id: newTabId,
playgroundId: newPlaygroundId,
title: INITIAL_TAB_TITLE,
};

const newPlaygrounds = new Map(state.playgrounds);
newPlaygrounds.set(newPlaygroundId, {
id: newPlaygroundId,
result: null,
isExecuting: false,
});

return {
tabs: [...state.tabs, newTab],
activeTabId: newTabId,
tabHistory: [...state.tabHistory, newTabId],
playgrounds: newPlaygrounds,
};
});
},

// 탭 닫기
closeTab: (tabId: Tab['id']) => {
set((state) => {
const closingTab = state.tabs.find((tab) => tab.id === tabId);
const tabsLength = state.tabs.length;

if (!closingTab || tabsLength === 1) return state;

const tabs = state.tabs.filter((tab) => tab.id !== tabId);
const tabHistory = state.tabHistory.filter((id) => id !== tabId);
const lastActiveTabId =
tabHistory[tabHistory.length - 1] || tabs[0].id;
const playgrounds = new Map(state.playgrounds);

playgrounds.delete(closingTab.playgroundId);

return {
tabs,
activeTabId: lastActiveTabId,
tabHistory,
playgrounds,
};
});
},

// 탭 활성화
setActiveTab: (tabId: Tab['id']) => {
set((state) => {
const lastTabId = state.tabHistory[state.tabHistory.length - 1];

if (lastTabId === tabId) {
return state;
}

return {
activeTabId: tabId,
tabHistory: [...state.tabHistory, tabId],
};
});
},
}),
{
name: 'executejs-playground-store',
storage: createJSONStorage(() => localStorage, {
replacer: (_, value) => {
if (value instanceof Map) {
return {
_type: 'map',
value: Array.from(value.entries()),
};
}
return value;
},
reviver: (_, value: any) => {
if (value && value._type === 'map') {
return new Map(value.value);
}
return value;
},
}),
}
)
);
65 changes: 24 additions & 41 deletions apps/executeJS/src/pages/playground/playground-groups.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,34 @@
import { useState } from 'react';
import { PlaygroundPage } from './playground-page';
import { Cross2Icon, PlusIcon } from '@radix-ui/react-icons';

interface Tab {
id: string;
title: string;
}

const INITIAL_TAB_TITLE = '✨New Playground';

const initialTabs: Tab[] = [{ id: 'playground-1', title: INITIAL_TAB_TITLE }];
import { usePlaygroundStore } from '@/features/playground';
import { PlaygroundWidget } from '@/widgets/playground';

export const PlaygroundGroups: React.FC = () => {
const [tabs, setTabs] = useState<Tab[]>(initialTabs);

const handleCloseTab = (tabId: string) => {
setTabs((prevTabs) => {
if (prevTabs.length === 1) {
return prevTabs; // 최소 하나의 탭은 유지
}

return prevTabs.filter((tab) => tab.id !== tabId);
});
};

const handleAddTab = () => {
setTabs((prevTabs) => {
const date = new Date().valueOf();
const newTabId = `playground-${date}`;
const newTab: Tab = {
id: newTabId,
title: INITIAL_TAB_TITLE,
};

return [...prevTabs, newTab];
});
};
const { tabs, activeTabId, addTab, closeTab, setActiveTab, playgrounds } =
usePlaygroundStore();

return (
<div className="overflow-hidden w-screen h-screen">
<div className="overflow-x-auto flex items-center border-b border-slate-800">
<div className="flex shrink-0">
{tabs.map(({ id, title }) => {
const active = id === activeTabId;

return (
<div
key={id}
className="shrink-0 flex items-center p-2 border-r border-slate-800"
// TODO: 활성화된 탭 스타일링 개선 @bori
style={{
backgroundColor: active
? 'rgba(255, 255, 255, 0.1)'
: 'transparent',
}}
>
{/* TODO: 탭 최대 너비에 따른 제목 ellipsis 처리 @bori */}
<button
type="button"
onClick={() => {
// TODO: 탭 전환 로직 @bori
}}
onClick={() => setActiveTab(id)}
onContextMenu={(event) => {
event.preventDefault();
// TODO: 탭 우클릭 메뉴 로직 @bori
Expand All @@ -64,7 +40,7 @@ export const PlaygroundGroups: React.FC = () => {
</button>
<button
type="button"
onClick={() => handleCloseTab(id)}
onClick={() => closeTab(id)}
className="p-1 rounded-sm hover:bg-[rgba(255,255,255,0.2)] transition-colors cursor-pointer"
>
<Cross2Icon />
Expand All @@ -76,15 +52,22 @@ export const PlaygroundGroups: React.FC = () => {

<button
type="button"
onClick={handleAddTab}
onClick={addTab}
className="shrink-0 p-2 ml-1 rounded-sm hover:bg-[rgba(255,255,255,0.2)] transition-colors cursor-pointer"
>
<PlusIcon />
</button>
</div>

{/* TODO: 활성화된 탭에 따른 플레이그라운드 렌더링 @bori */}
<PlaygroundPage />
{tabs.map((tab) => {
const { playgroundId, id } = tab;
const active = tab.id === activeTabId;
const playground = playgrounds.get(playgroundId);

if (!active || !playground) return null;

return <PlaygroundWidget key={id} playground={playground} />;
})}
</div>
);
};
1 change: 1 addition & 0 deletions apps/executeJS/src/widgets/playground/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './playground-widget';
138 changes: 138 additions & 0 deletions apps/executeJS/src/widgets/playground/playground-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useState } from 'react';

import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { PlayIcon, StopIcon } from '@radix-ui/react-icons';

import { CodeEditor } from '@/widgets/code-editor';
import { OutputPanel } from '@/widgets/output-panel';
import { useExecutionStore } from '@/features/execute-code';
import { Playground } from '@/features/playground';

const getInitialCode = (): string => {
try {
const executionStorage = localStorage.getItem(
'executejs-execution-storage'
);

if (executionStorage) {
const parsed = JSON.parse(executionStorage);
const code = parsed?.state?.result?.code;

if (code) {
console.log('result from executionStorage:', code);

return code;
}
}
} catch (error) {
console.error('error from executionStorage:', error);
}

return 'console.log("Hello, ExecuteJS!");';
};

interface PlaygroundProps {
playground: Playground;
}

export const PlaygroundWidget: React.FC<PlaygroundProps> = ({ playground }) => {
// TODO: playground prop 로직 추가 예정 @bori
console.log('PlaygroundWidget', playground);

// FIXME: tab이 여러개 생기거나 global store로 상태가 이동되면 수정되어야함
const [code, setCode] = useState(getInitialCode);
const {
result: executionResult,
isExecuting,
executeCode,
} = useExecutionStore();

// 코드 실행 핸들러
const handleExecuteCode = (codeToExecute?: string) => {
const codeToRun = codeToExecute || code;
if (codeToRun.trim()) {
executeCode(codeToRun);
}
};

// 코드 변경 핸들러
const handleCodeChange = (newCode: string) => {
setCode(newCode);
};

return (
<div className="h-screen w-screen flex flex-col bg-slate-950 text-white">
{/* 헤더 */}
<div className="flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800">
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-slate-300">ExecuteJS</div>
</div>

<div className="flex items-center gap-2">
<button
onClick={() => handleExecuteCode()}
disabled={isExecuting || !code.trim()}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-700 disabled:cursor-not-allowed text-white text-sm font-medium rounded-md transition-colors"
>
{isExecuting ? (
<>
<StopIcon className="w-4 h-4" />
실행 중...
</>
) : (
<>
<PlayIcon className="w-4 h-4" />
실행 (Cmd+Enter)
</>
)}
</button>
</div>
</div>

{/* 메인 컨텐츠 영역 */}
<div className="flex-1 flex">
<PanelGroup direction="horizontal" className="flex-1">
{/* 왼쪽 패널 - 코드 에디터 */}
<Panel defaultSize={50} minSize={30}>
<div className="h-full bg-slate-900 border-r border-slate-800">
<div className="h-8 bg-slate-800 border-b border-slate-700 flex items-center px-4">
<span className="text-xs font-medium text-slate-400 uppercase tracking-wide">
Editor
</span>
</div>
<div className="h-[calc(100%-2rem)]">
<CodeEditor
value={code}
onChange={handleCodeChange}
onExecute={handleExecuteCode}
language="javascript"
theme="vs-dark"
/>
</div>
</div>
</Panel>

{/* 리사이즈 핸들 */}
<PanelResizeHandle className="w-1 bg-slate-800 hover:bg-slate-700 transition-colors" />

{/* 오른쪽 패널 - 출력 결과 */}
<Panel defaultSize={50} minSize={30}>
<div className="h-full bg-slate-900">
<div className="h-8 bg-slate-800 border-b border-slate-700 flex items-center px-4">
<span className="text-xs font-medium text-slate-400 uppercase tracking-wide">
Output
</span>
</div>
<div className="h-[calc(100%-2rem)]">
<OutputPanel
result={executionResult}
isExecuting={isExecuting}
/>
</div>
</div>
</Panel>
</PanelGroup>
</div>
</div>
);
};