App.tsx
import { useEffect, useState } from 'react'
import { createBrowserRouter, RouterProvider} from 'react-router-dom';
import Layout from './components/layout';
import Home from './routes/home';
import Profile from './routes/profile';
import Login from './routes/login';
import CreateAccount from './routes/create-account';
import styled, { createGlobalStyle } from 'styled-components';
import reset from "styled-reset";
import LoadingScreen from './components/loading-screen';
import { auth } from './routes/firebase';
import ProtectedRoute from './routes/protected-route';
const Wrapper = styled.div`
height: 100vh; /* 화면 전체 높이를 기준으로 중앙 정렬 */
display: flex;
justify-content: center; /* 수평 중앙 */
align-items: center; /* 수직 중앙 */
flex-direction: column;
margin: 0 auto; /* 화면 가운데로 */
padding: 50px 0px;
`;
const router = createBrowserRouter([
{
path:"/",
element:
/*
<Layout />,
*/
<Wrapper>
<ProtectedRoute>
<Layout />
</ProtectedRoute>
</Wrapper>,
children: [
{
path: "",
element:
/*
<ProtectedRoute>
<Home />
</ProtectedRoute>
*/
<Home />
},
{
path: "profile",
element:
/*
<ProtectedRoute>
<Profile />
</ProtectedRoute>
*/
<Profile />
}
]
},
{
path: "/login",
element: <Login />
},
{
path:"/create-account",
element: <CreateAccount />
}
]);
const GlobalStyles = createGlobalStyle`
${reset};
* {
box-sizing: border-box;
}
body {
background-color: black;
color:white;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
`;
function App() {
const [isLoading, setLoading] = useState(true);
const init = async() => {
await auth.authStateReady();
// Firebase가 쿠키와 토큰을 읽고 백엔드와 소통해서 로그인여부를 확인하는 동안 기다림
setLoading(false);
};
useEffect(()=>{
init();
},[]);
return <>
<GlobalStyles />
{isLoading ? <LoadingScreen /> : <RouterProvider router = {router} />}
</>;
}
export default App
protected-route.tsx
import React from "react";
import { auth } from "./firebase";
import { Navigate } from "react-router-dom";
export default function ProtectedRoute({children} : {
children : React.ReactNode;
}){
const user = auth.currentUser;
if(user === null){
return <Navigate to="/login" />
}
return children;
}
layout.tsx
import { Link, Outlet, useNavigate } from "react-router-dom";
import styled from "styled-components";
import { auth } from "../routes/firebase";
/*
SVG vector icon 찾을 수 있는 웹사이트(리액트 프로젝트에서 바로 쓸 수 있는 아이콘 찾기 가능)
https://heroicons.dev/
*/
const Wrapper = styled.div`
display: grid;
gap:20px;
grid-template-columns: 1fr 4fr;
height: 100%;
padding: 50px 0px;
width: 100%;
max-width: 860px;
`;
const Menu = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
`;
const MenuItem = styled.div`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid white;
height: 50px;
width: 50px;
border-radius: 50%;
svg{
width : 30px;
fill: white;
}
&.log-out{
border-color : tomato;
svg{
fill: tomato;
}
}
`;
export default function Layout(){
const navigate = useNavigate();
const onLogOut = async() => {
const ok = confirm("Are you sure you want to log out?");
if(ok){
await auth.signOut();
navigate("/login");
}
};
return(
<Wrapper>
<Menu>
<Link to="/">
<MenuItem>
<svg data-slot="icon" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path clip-rule="evenodd" fill-rule="evenodd" d="M9.293 2.293a1 1 0 0 1 1.414 0l7 7A1 1 0 0 1 17 11h-1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6H3a1 1 0 0 1-.707-1.707l7-7Z"></path>
</svg>
</MenuItem>
</Link>
<Link to="/profile">
<MenuItem>
<svg data-slot="icon" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z" />
</svg>
</MenuItem>
</Link>
<MenuItem onClick={onLogOut} className="log-out">
<svg data-slot="icon" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path clip-rule="evenodd" fill-rule="evenodd" d="M3 4.25A2.25 2.25 0 0 1 5.25 2h5.5A2.25 2.25 0 0 1 13 4.25v2a.75.75 0 0 1-1.5 0v-2a.75.75 0 0 0-.75-.75h-5.5a.75.75 0 0 0-.75.75v11.5c0 .414.336.75.75.75h5.5a.75.75 0 0 0 .75-.75v-2a.75.75 0 0 1 1.5 0v2A2.25 2.25 0 0 1 10.75 18h-5.5A2.25 2.25 0 0 1 3 15.75V4.25Z"></path>
<path clip-rule="evenodd" fill-rule="evenodd" d="M19 10a.75.75 0 0 0-.75-.75H8.704l1.048-.943a.75.75 0 1 0-1.004-1.114l-2.5 2.25a.75.75 0 0 0 0 1.114l2.5 2.25a.75.75 0 1 0 1.004-1.114l-1.048-.943h9.546A.75.75 0 0 0 19 10Z"></path>
</svg>
</MenuItem>
</Menu>
<Outlet />
</Wrapper>
)
}
home.tsx
import styled from "styled-components";
import PostTweetForm from "../components/post-tweet-form";
const Wraaper = styled.div`
`;
export default function Home(){
return <Wraaper>
<PostTweetForm />
</Wraaper>
}
post-tweet-form.tsx
import { useState } from "react";
import styled from "styled-components"
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 10px;
`;
const TextArea = styled.textarea`
border: 2px solid white;
padding: 20px;
border-radius: 20px;
font-size: 16px;
color: white;
background-color: black;
width: 100%;
resize: none;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
&::placeholder {
font-size: 16px;
}
&:focus {
outline: none;
border-color: #1d9bf0;
}
`;
const AttachFileButton = styled.label`
padding: 10px 0px;
color: #1d9bf0;
text-align: center;
border-radius: 20px;
border: 1px solid #1d9bf0;
font-size: 14px;
font-weight: 600;
cursor: pointer;
`;
const AttachFileInput = styled.input`
display: none;
`;
const SubmitBtn = styled.input`
background-color: #1d9bf0;
color: white;
border: none;
padding: 10px 0px;
border-radius: 20px;
font-size: 16px;
cursor: pointer;
&:hover,
&:active {
opacity: 0.9;
}
`;
export default function PostTweetForm(){
const [isLoading, setLoading] = useState(false);
const [tweet, setTweet] = useState("");
const [file, setFile] = useState<File|null>(null);
const onChange = (e : React.ChangeEvent<HTMLTextAreaElement>) => {
setTweet(e.target.value);
};
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const {files} = e.target;
if(files && files.length === 1){
setFile(files[0]);
}
};
return <Form>
<TextArea onChange={onChange} value={tweet} placeholder="What is happening?"/>
<AttachFileButton htmlFor="file">{file ? "Photo added" : "Add photo"}</AttachFileButton>
<AttachFileInput onChange={onFileChange} type="file" id="file" accept="image/*"/>
<SubmitBtn type="submit" value={isLoading ? "Posting..." : "Post Tweet"}/>
</Form>
};
nwitter-reloaded.z01
19.53MB
nwitter-reloaded.z02
19.53MB
nwitter-reloaded.z03
19.53MB
nwitter-reloaded.z04
19.53MB
nwitter-reloaded.z05
19.53MB
nwitter-reloaded.zip
14.61MB
'트위터(React, TypeScript, Firebase, Vite)' 카테고리의 다른 글
15. TWEETING - Uploading Images (0) | 2025.04.17 |
---|---|
14. Tweeting to Firestore (0) | 2025.04.16 |
12. TWEETING - Navigation Bar (0) | 2025.04.15 |
11. Authentication - Github Login (0) | 2025.04.15 |
10. Authentication - Log In (0) | 2025.04.15 |