Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
Empty file removed src/App.css
Empty file.
22 changes: 0 additions & 22 deletions src/App.tsx

This file was deleted.

13 changes: 13 additions & 0 deletions src/assets/logo-full.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/logo-full@4x.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/logo-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/logo-icon@4x.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions src/components/common/LoginButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Link } from 'react-router-dom';

export function LoginButton() {
return (
<Link to="/login" className="flex items-center gap-1 text-body-s-bold text-gray-800">
로그인
</Link>
);
}
15 changes: 15 additions & 0 deletions src/components/common/Logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Link, useLocation } from 'react-router-dom';

import logoFull from '@/assets/logo-full@4x.webp';
import logoIcon from '@/assets/logo-icon@4x.webp';

export function Logo() {
const { pathname } = useLocation();
const isHome = pathname === '/';

return (
<Link to="/">
<img src={isHome ? logoFull : logoIcon} alt="또랑" className="h-8" />
</Link>
);
}
2 changes: 2 additions & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { LoginButton } from './LoginButton';
export { Logo } from './Logo';
16 changes: 8 additions & 8 deletions src/components/layout/Gnb.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import { Link } from 'react-router-dom';
import { Link, useLocation, useParams } from 'react-router-dom';

import clsx from 'clsx';

import { DEFAULT_TAB, TABS, type Tab } from '../../constants/navigation';
import { TABS, getTabFromPathname, getTabPath } from '@/constants/navigation';

interface GnbProps {
activeTab?: Tab;
}
export function Gnb() {
const { projectId = '' } = useParams<{ projectId: string }>();
const location = useLocation();
const activeTab = getTabFromPathname(location.pathname);

export function Gnb({ activeTab = DEFAULT_TAB }: GnbProps) {
return (
<nav
className="flex h-15 items-center justify-center"
role="tablist"
aria-label="네비게이션 메뉴"
>
{TABS.map(({ key, label, path }) => {
{TABS.map(({ key, label }) => {
const isActive = activeTab === key;
return (
<Link
key={key}
to={path}
to={getTabPath(projectId, key)}
role="tab"
id={`tab-${key}`}
aria-selected={isActive}
Expand Down
17 changes: 0 additions & 17 deletions src/components/layout/Header.tsx

This file was deleted.

22 changes: 14 additions & 8 deletions src/components/layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import type { ReactNode } from 'react';
import { Outlet } from 'react-router-dom';

import { Header } from './Header';
import { Logo } from '@/components/common';

interface LayoutProps {
headerLeft?: ReactNode;
headerCenter?: ReactNode;
headerRight?: ReactNode;
children?: ReactNode;
left?: ReactNode;
center?: ReactNode;
right?: ReactNode;
}

export function Layout({ headerLeft, headerCenter, headerRight, children }: LayoutProps) {
export function Layout({ left, center, right }: LayoutProps) {
return (
<div className="min-h-screen bg-gray-100">
<Header left={headerLeft} center={headerCenter} right={headerRight} />
<main className="pt-15">{children}</main>
<header className="fixed top-0 right-0 left-0 z-50 flex h-15 items-center justify-between border-b border-gray-200 bg-white px-18">
<div className="flex items-center gap-6">{left ?? <Logo />}</div>
<div className="absolute left-1/2 -translate-x-1/2">{center}</div>
<div className="flex items-center gap-8">{right}</div>
</header>
<main className="pt-15">
<Outlet />
</main>
</div>
);
}
3 changes: 0 additions & 3 deletions src/components/layout/index.ts

This file was deleted.

43 changes: 36 additions & 7 deletions src/constants/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,43 @@
export const TABS = [
{ key: 'slide', label: '슬라이드', path: '/slide' },
{ key: 'video', label: '영상', path: '/video' },
{ key: 'insight', label: '인사이트', path: '/insight' },
{ key: 'slide', label: '슬라이드' },
{ key: 'video', label: '영상' },
{ key: 'insight', label: '인사이트' },
] as const;

export type Tab = (typeof TABS)[number]['key'];

export const DEFAULT_TAB: Tab = 'slide';
export const DEFAULT_SLIDE_ID = '1';

export const PATH_TO_TAB: Record<string, Tab> = {
'/': DEFAULT_TAB,
...Object.fromEntries(TABS.map((tab) => [tab.path, tab.key])),
const LAST_SLIDE_KEY_PREFIX = 'lastSlideId:';

/** 마지막으로 본 슬라이드 ID 저장 */
export const setLastSlideId = (projectId: string, slideId: string): void => {
localStorage.setItem(`${LAST_SLIDE_KEY_PREFIX}${projectId}`, slideId);
};

/** 마지막으로 본 슬라이드 ID 조회 */
export const getLastSlideId = (projectId: string): string => {
return localStorage.getItem(`${LAST_SLIDE_KEY_PREFIX}${projectId}`) ?? DEFAULT_SLIDE_ID;
};

/** 탭별 경로 생성 */
export const getTabPath = (projectId: string, tab: Tab, slideId?: string): string => {
switch (tab) {
case 'slide':
return `/${projectId}/slide/${slideId ?? getLastSlideId(projectId)}`;
case 'video':
return `/${projectId}/video`;
case 'insight':
return `/${projectId}/insight`;
}
};

/** pathname에서 탭 추출 (/:projectId/:tab/...) */
export const getTabFromPathname = (pathname: string): Tab => {
const segments = pathname.split('/').filter(Boolean);
const tabSegment = segments[1];

if (tabSegment === 'video') return 'video';
if (tabSegment === 'insight') return 'insight';
return 'slide';
};
34 changes: 25 additions & 9 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom';

import App from './App';
import InsightPage from './pages/InsightPage';
import SlidePage from './pages/SlidePage';
import VideoPage from './pages/VideoPage';
import './styles/index.css';
import { LoginButton, Logo } from '@/components/common';
import { Gnb } from '@/components/layout/Gnb';
import { Layout } from '@/components/layout/Layout';
import { HomePage, InsightPage, SlidePage, VideoPage } from '@/pages';
import '@/styles/index.css';

const router = createBrowserRouter([
{
path: '/',
element: <App />,
element: <Layout right={<LoginButton />} />,
children: [{ index: true, element: <HomePage /> }],
},
{
path: '/:projectId',
element: (
<Layout
left={
<>
<Logo />
<span className="text-body-m-bold text-gray-800">내 발표</span>
</>
Comment on lines +23 to +26
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragment(<>...</>)를 사용하면 Layout 컴포넌트의 gap-6 스타일이 자식 요소들 사이에 적용되지 않습니다. Fragment는 DOM에 렌더링되지 않기 때문입니다. div로 감싸거나, 배열로 전달하되 각 요소에 key를 추가해주세요.

Suggested change
<>
<Logo />
<span className="text-body-m-bold text-gray-800">내 발표</span>
</>
<div>
<Logo />
<span className="text-body-m-bold text-gray-800">내 발표</span>
</div>

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

@AndyH0ng AndyH0ng Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fragment는 DOM에 렌더링되지 않고 풀어지기 때문에, <Logo /><span>이 Layout의 gap-6이 적용된 div의 직접적인 자식이 됩니다. 따라서 gap이 정상 적용됩니다.

오히려 div로 감싸면 Logo와 span 사이에 gap이 적용되지 않게 됩니다.

}
center={<Gnb />}
right={<LoginButton />}
/>
),
children: [
{ index: true, element: <SlidePage /> }, // DEFAULT_TAB 변경 시 동기화 필요
{ path: 'slide', element: <SlidePage /> },
{ index: true, element: <Navigate to="slide/1" replace /> },
{ path: 'slide/:slideId?', element: <SlidePage /> },
Copy link

Copilot AI Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

슬라이드 경로의 slideId 파라미터가 optional(?)로 정의되어 있습니다. 그러나 SlidePage.tsx에서는 기본값으로 DEFAULT_SLIDE_ID를 사용하고, router 설정에서도 명시적으로 'slide/1'로 리다이렉트하고 있습니다. slideId를 필수 파라미터로 만들어 타입 안정성을 높이는 것이 좋습니다.

Suggested change
{ path: 'slide/:slideId?', element: <SlidePage /> },
{ path: 'slide/:slideId', element: <SlidePage /> },

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

@AndyH0ng AndyH0ng Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slideId를 optional로 둔 이유는 /:projectId 접근 시 /:projectId/slide/1로 리다이렉트되기 때문입니다. 실제로 SlidePage가 렌더링될 때는 항상 slideId가 존재합니다.

다만 타입 안정성을 위해 필수로 변경하는 것도 좋은 방법이라 생각하여 반영하겠습니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다 ✅

{ path: 'video', element: <VideoPage /> },
{ path: 'insight', element: <InsightPage /> },
],
Expand Down
7 changes: 7 additions & 0 deletions src/pages/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function HomePage() {
return (
<div className="p-8">
<h1 className="text-body-m-bold">홈</h1>
</div>
);
}
18 changes: 17 additions & 1 deletion src/pages/SlidePage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';

import { DEFAULT_SLIDE_ID, setLastSlideId } from '@/constants/navigation';

export default function SlidePage() {
const { projectId = '', slideId = DEFAULT_SLIDE_ID } = useParams<{
projectId: string;
slideId: string;
}>();

useEffect(() => {
if (projectId && slideId) {
setLastSlideId(projectId, slideId);
}
}, [projectId, slideId]);

return (
<div role="tabpanel" id="tabpanel-slide" aria-labelledby="tab-slide" className="p-8">
<h1 className="text-body-m-bold">슬라이드</h1>
<h1 className="text-body-m-bold">슬라이드 {slideId}</h1>
</div>
);
}
4 changes: 4 additions & 0 deletions src/pages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { default as HomePage } from './HomePage';
export { default as InsightPage } from './InsightPage';
export { default as SlidePage } from './SlidePage';
export { default as VideoPage } from './VideoPage';
8 changes: 7 additions & 1 deletion tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,

/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
7 changes: 6 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';

import path from 'path';
import { defineConfig } from 'vite';

// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});