Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[11팀 김상연][Chapter1-2] 프레임워크 없이 SPA 만들기 #106

Open
wants to merge 5 commits into
base: chapter1-2
Choose a base branch
from
Open
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
34 changes: 32 additions & 2 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,34 @@
/** @jsx createVNode */
import{ createVNode } from "./lib";
import { createVNode } from './lib';
import { globalStore } from './stores';
import { NotFoundPage } from './pages';

export const App = () => ({});
export const App = ({ targetPage }) => {
const PageComponent = targetPage ?? <NotFoundPage />;
const error = globalStore.getState().error;

return (
<div>
<PageComponent />
{error ? (
<div
id='error-boundary'
className='fixed bottom-4 left-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg shadow-lg transition-opacity duration-300 hover:opacity-75'
role='alert'
>
<div className='flex justify-between items-center'>
<div>
<strong className='font-bold'>오류 발생!</strong>
<span className='block sm:inline ml-1'>
{error.message || '알 수 없는 오류가 발생했습니다.'}
</span>
</div>
<button className='text-red-700 hover:text-red-900 font-semibold'>&times;</button>
</div>
</div>
) : (
''
)}
</div>
);
};
24 changes: 22 additions & 2 deletions src/components/posts/Post.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
/** @jsx createVNode */
import{ createVNode } from "../../lib";
import { createVNode } from '../../lib';

export const Post = () => ({});
export const Post = ({ author, time, content, id }) => {
return (
<div className='bg-white rounded-lg shadow p-4 mb-4'>
<div className='flex items-center mb-2'>
<img src='https://via.placeholder.com/40' alt='프로필' className='rounded-full mr-2' />
<div>
<div className='font-bold'>{author}</div>
<div className='text-gray-500 text-sm'>{time}</div>
</div>
</div>
<p>{content}</p>
<div className='mt-2 flex justify-between text-gray-500'>
<span className='like-button' data-post-id={id}>
좋아요
</span>
<span>댓글</span>
<span>공유</span>
</div>
</div>
);
};
17 changes: 15 additions & 2 deletions src/components/posts/PostForm.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
/** @jsx createVNode */
import{ createVNode } from "../../lib";
import { createVNode } from '../../lib';

export const PostForm = () => ({});
export const PostForm = () => {
return (
<div className='mb-4 bg-white rounded-lg shadow p-4'>
<textarea
id='post-content'
placeholder='무슨 생각을 하고 계신가요?'
className='w-full p-2 border rounded'
></textarea>
<button id='post-submit' className='mt-2 bg-blue-600 text-white px-4 py-2 rounded'>
게시
</button>
</div>
);
};
10 changes: 8 additions & 2 deletions src/components/templates/Footer.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
/** @jsx createVNode */
import{ createVNode } from "../../lib";
import { createVNode } from '../../lib';

export const Footer = () => ({});
export const Footer = () => {
return (
<footer className='bg-gray-200 p-4 text-center'>
<p>&copy; 2024 항해플러스. All rights reserved.</p>
</footer>
);
};
10 changes: 8 additions & 2 deletions src/components/templates/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
/** @jsx createVNode */
import{ createVNode } from "../../lib";
import { createVNode } from '../../lib';

export const Header = () => ({});
export const Header = () => {
return (
<header className='bg-blue-600 text-white p-4 sticky top-0'>
<h1 className='text-2xl font-bold'>항해플러스</h1>
</header>
);
};
48 changes: 46 additions & 2 deletions src/components/templates/Navigation.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,48 @@
/** @jsx createVNode */
import{ createVNode } from "../../lib";
import { createVNode } from '../../lib';

export const Navigation = () => ({});
const getNavItemClass = (path) => {
const currentPath = window.location.pathname;
return currentPath === path ? 'text-blue-600 font-bold' : 'text-gray-600';
};

export const Navigation = ({ loggedIn }) => {
return (
<nav className='bg-white shadow-md p-2 sticky top-14'>
<ul className='flex justify-around'>
<li>
<a href='/' className={getNavItemClass('/')} data-link>
</a>
</li>
{!loggedIn ? (
<li>
<a href='/login' className={getNavItemClass('/login')} data-link>
로그인
</a>
</li>
) : (
''
)}
{loggedIn ? (
<li>
<a href='/profile' className={getNavItemClass('/profile')} data-link>
프로필
</a>
</li>
) : (
''
)}
{loggedIn ? (
<li>
<a href='#' id='logout' className='text-gray-600'>
로그아웃
</a>
</li>
) : (
''
)}
</ul>
</nav>
);
};
42 changes: 40 additions & 2 deletions src/lib/createElement.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,44 @@
// - vNode.children의 각 자식에 대해 createElement를 재귀 호출하여 추가

export function createElement(vNode) {
// 여기에 구현하세요
return {}
// 1번
if (!vNode) return document.createTextNode('');

// 2번
if (typeof vNode === 'string' || typeof vNode === 'number') return document.createTextNode(vNode);

// 3번
if (Array.isArray(vNode)) {
const fragment = document.createDocumentFragment();
vNode.forEach((node) => fragment.appendChild(createElement(node)));
return fragment;
}

// 4번
if (typeof vNode.type === 'function') {
return createElement(vNode.type(vNode.props));
}

// + @ - router에 함수 실행이 아닌, 컴포넌트 자체를 넣어줄 때 실행되는 코드.
if (typeof vNode.type === 'object' && typeof vNode.type.type === 'function') {
return createElement(vNode.type);
}

// 5번
const node = document.createElement(vNode.type);

for (const key in vNode.props) {
if (key === 'className') {
node.setAttribute('class', vNode.props['className']);
} else if (key.startsWith('on')) {
const eventType = key.replace('on', '').toLowerCase();
node.addEventListener(eventType, vNode.props[key]);
} else node.setAttribute(key, vNode.props[key]);
}

if (vNode.children.length > 0) {
vNode.children.forEach((child) => node.appendChild(createElement(child)));
}

return node;
}
2 changes: 1 addition & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@

export function createVNode(type, props, ...children) {
// 여기에 구현하세요
return {}
return { type, props, children: children.flat(Infinity).filter((child) => !!child) };
}
34 changes: 17 additions & 17 deletions src/main.jsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
/** @jsx createVNode */
import { createElement, createRouter, createVNode, renderElement } from "./lib";
import { HomePage, LoginPage, ProfilePage } from "./pages";
import { globalStore } from "./stores";
import { ForbiddenError, UnauthorizedError } from "./errors";
import { userStorage } from "./storages";
import { addEvent, registerGlobalEvents } from "./utils";
import { App } from "./App";
import { createElement, createRouter, createVNode, renderElement } from './lib';
import { HomePage, LoginPage, ProfilePage } from './pages';
import { globalStore } from './stores';
import { ForbiddenError, UnauthorizedError } from './errors';
import { userStorage } from './storages';
import { addEvent, registerGlobalEvents } from './utils';
import { App } from './App';

const router = createRouter({
"/": HomePage,
"/login": () => {
'/': <HomePage />,
'/login': () => {
const { loggedIn } = globalStore.getState();
if (loggedIn) {
throw new ForbiddenError();
}
return <LoginPage/>;
return <LoginPage />;
},
"/profile": () => {
'/profile': () => {
const { loggedIn } = globalStore.getState();
if (!loggedIn) {
throw new UnauthorizedError();
}
return <ProfilePage/>;
return <ProfilePage />;
},
});

Expand All @@ -40,19 +40,19 @@ function render() {
const $root = document.querySelector('#root');

try {
const $app = createElement(<App targetPage={router.getTarget()}/>);
const $app = createElement(<App targetPage={router.getTarget()} />);
if ($root.hasChildNodes()) {
$root.firstChild.replaceWith($app)
} else{
$root.firstChild.replaceWith($app);
} else {
$root.appendChild($app);
}
} catch (error) {
if (error instanceof ForbiddenError) {
router.push("/");
router.push('/');
return;
}
if (error instanceof UnauthorizedError) {
router.push("/login");
router.push('/login');
return;
}

Expand Down
32 changes: 30 additions & 2 deletions src/pages/HomePage.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,32 @@
/** @jsx createVNode */
import{ createVNode } from "../lib";
import { createVNode } from '../lib';
import { globalStore } from '../stores';
import { Header, Navigation, Post, Footer, PostForm } from '../components';

export const HomePage = () => ({});
export const HomePage = () => {
const { loggedIn, posts } = globalStore.getState();

return (
<div className='bg-gray-100 min-h-screen flex justify-center'>
<div className='max-w-md w-full'>
<Header />
<Navigation loggedIn={loggedIn} />
<main className='p-4'>
{loggedIn ? <PostForm /> : ''}
<div id='posts-container' className='space-y-4'>
{posts.map((post) => (
<Post
author={post.author}
time={post.time}
content={post.content}
id={post.id}
key={post.id}
/>
))}
</div>
</main>
<Footer />
</div>
</div>
);
};
39 changes: 37 additions & 2 deletions src/pages/LoginPage.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,39 @@
/** @jsx createVNode */
import{ createVNode } from "../lib";
import { createVNode } from '../lib';

export const LoginPage = () => ({});
export const LoginPage = () => {
return (
<div className='bg-gray-100 flex items-center justify-center min-h-screen'>
<div className='bg-white p-8 rounded-lg shadow-md w-full max-w-md'>
<h1 className='text-2xl font-bold text-center text-blue-600 mb-8'>항해플러스</h1>
<form id='login-form'>
<input
type='text'
id='username'
placeholder='사용자 이름'
className='w-full p-2 mb-4 border rounded'
required
/>
<input
type='password'
placeholder='비밀번호'
className='w-full p-2 mb-6 border rounded'
required
/>
<button type='submit' className='w-full bg-blue-600 text-white p-2 rounded'>
로그인
</button>
</form>
<div className='mt-4 text-center'>
<a href='#' className='text-blue-600 text-sm'>
비밀번호를 잊으셨나요?
</a>
</div>
<hr className='my-6' />
<div className='text-center'>
<button className='bg-green-500 text-white px-4 py-2 rounded'>새 계정 만들기</button>
</div>
</div>
</div>
);
};
23 changes: 21 additions & 2 deletions src/pages/NotFoundPage.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
/** @jsx createVNode */
import{ createVNode } from "../lib";
import { createVNode } from '../lib';

export const NotFoundPage = () => ({});
export const NotFoundPage = () => {
return (
<main className='bg-gray-100 flex items-center justify-center min-h-screen'>
<div
className='bg-white p-8 rounded-lg shadow-md w-full text-center'
style={{ maxWidth: '480px' }}
>
<h1 className='text-2xl font-bold text-blue-600 mb-4'>항해플러스</h1>
<p className='text-4xl font-bold text-gray-800 mb-4'>404</p>
<p className='text-xl text-gray-600 mb-8'>페이지를 찾을 수 없습니다</p>
<p className='text-gray-600 mb-8'>
요청하신 페이지가 존재하지 않거나 이동되었을 수 있습니다.
</p>
<a href='/' data-link className='bg-blue-600 text-white px-4 py-2 rounded font-bold'>
홈으로 돌아가기
</a>
</div>
</main>
);
};
Loading
Loading