diff --git a/index.html b/index.html index e0d1c84..d20ea0f 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,18 @@ + - - Vite + React + TS + + + + Reddit Client +
+ diff --git a/src/AddBoardDialog.tsx b/src/AddBoardDialog.tsx new file mode 100644 index 0000000..520c191 --- /dev/null +++ b/src/AddBoardDialog.tsx @@ -0,0 +1,80 @@ +import { FormEvent, useRef, useState } from "react"; +import { useBoardContext } from "./BoardContext"; +import { useBoardExists } from "./useBoardExists"; + +export const ADD_BOARD_DIALOG_HEIGHT = "40px"; + +export function AddBoardDialog() { + const [board, setBoard] = useState(""); + const inputRef = useRef(null); + const dialogRef = useRef(null); + const { checkBoardExistance, isLoading } = useBoardExists(); + const { boardContext } = useBoardContext(); + + async function handleFormSubmit(event: FormEvent) { + event.preventDefault(); + + const doesBoardExist = await checkBoardExistance(board); + + if (!doesBoardExist) { + return alert("This board does not exist"); + } + + boardContext.addBoard(board); + closeDialog(); + setBoard(""); + } + + function openDialog() { + dialogRef.current?.showModal(); + inputRef.current?.focus(); + } + + function closeDialog() { + dialogRef.current?.close(); + } + + return ( +
+ + + + + +
+

Enter the name of the board

+ setBoard(event.target.value)} + /> + +
+
+
+ ); +} diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/App.tsx b/src/App.tsx index 52ea980..bee3be9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,35 +1,11 @@ -import { useState } from "react"; -import reactLogo from "./assets/react.svg"; -import viteLogo from "/vite.svg"; -import "./App.css"; - -function App() { - const [count, setCount] = useState(0); +import { AddBoardDialog } from "./AddBoardDialog"; +import { LanesContainer } from "./LanesContainer"; +export function App() { return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- +
+ + +
); } - -export default App; diff --git a/src/BoardContext.tsx b/src/BoardContext.tsx new file mode 100644 index 0000000..380cf6f --- /dev/null +++ b/src/BoardContext.tsx @@ -0,0 +1,91 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +const LOCAL_STORAGE_KEY = "@felipeog/rm-reddit-client/boards"; + +type TBoardContext = { + boards: string[]; + addBoard: (board: string) => void; + removeBoard: (board: string) => void; + reorderBoard: (from: string, to: string) => void; +}; + +type TBoardContextProviderProps = { + children: ReactNode; +}; + +const BoardContext = createContext(null! as TBoardContext); + +export function BoardContextProvider(props: TBoardContextProviderProps) { + const [boards, setBoards] = useState(() => { + const boardsString = localStorage.getItem(LOCAL_STORAGE_KEY) || "[]"; + return JSON.parse(boardsString); + }); + + const addBoard = useCallback( + (board: string) => + setBoards((prevBoards) => { + if (prevBoards.includes(board)) return prevBoards; + return [...prevBoards, board]; + }), + [] + ); + + const removeBoard = useCallback( + (board: string) => + setBoards((prevBoards) => + prevBoards.filter((prevBoard) => prevBoard !== board) + ), + [] + ); + + const reorderBoard = useCallback( + (from: string, to: string) => + setBoards((prevBoards) => { + const boards = [...prevBoards]; + const fromIndex = boards.indexOf(from); + const toIndex = boards.indexOf(to); + + boards.splice(fromIndex, 1); + boards.splice(toIndex, 0, from); + + return boards; + }), + [] + ); + + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(boards)); + }, [boards]); + + return ( + + {props.children} + + ); +} + +export function useBoardContext() { + const boardContext = useContext(BoardContext); + + if (!boardContext) { + throw new Error( + "useBoardContext has to be used within BoardContextProvider." + ); + } + + return { boardContext }; +} diff --git a/src/LanesContainer.tsx b/src/LanesContainer.tsx new file mode 100644 index 0000000..9b295b6 --- /dev/null +++ b/src/LanesContainer.tsx @@ -0,0 +1,117 @@ +import { ADD_BOARD_DIALOG_HEIGHT } from "./AddBoardDialog"; +import { useBoardContent } from "./useBoardContent"; +import { useBoardContext } from "./BoardContext"; +import { formatDate } from "./formatDate"; +import { DragEvent } from "react"; + +export function LanesContainer() { + const { boardContext } = useBoardContext(); + + return ( +
+ {boardContext.boards.map((board) => ( + + ))} +
+ ); +} + +type TLaneProps = { + board: string; +}; + +function Lane(props: TLaneProps) { + const { boardContext } = useBoardContext(); + const { boardContent, isLoading, loadMore } = useBoardContent(props.board); + + function onDragStart(event: DragEvent) { + event.dataTransfer.setData("text/plain", props.board); + } + + function onDragEnter(event: DragEvent) { + event.preventDefault(); + } + + function onDragOver(event: DragEvent) { + event.preventDefault(); + } + + function onDrop(event: DragEvent) { + const board = event.dataTransfer.getData("text/plain"); + if (board !== props.board) { + boardContext.reorderBoard(board, props.board); + } + } + + return ( +
+
+

+ + {props.board} + +

+ +
+ +
+ {boardContent.length > 0 + ? boardContent.map((item) => ( +
+

+ Title:{" "} + + {item.title} + +

+

Author: {item.author}

+

Created: {formatDate(item.created * 1000)}

+

+ Ups/Downs: {item.ups}/{item.downs} +

+
+
+ )) + : null} +
+ + +
+ ); +} diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 348c670..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1,6 +0,0 @@ - \ No newline at end of file diff --git a/src/formatDate.ts b/src/formatDate.ts new file mode 100644 index 0000000..da2f93b --- /dev/null +++ b/src/formatDate.ts @@ -0,0 +1,13 @@ +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: false, +}); + +export function formatDate(date: number) { + return dateTimeFormatter.format(date); +} diff --git a/src/index.css b/src/index.css index 6119ad9..93bfeb2 100644 --- a/src/index.css +++ b/src/index.css @@ -1,68 +1,52 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; +*, +*:before, +*:after { + margin: 0; + padding: 0; + box-sizing: border-box; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; +:root { + --neutral-10: #000000; + --neutral-20: #1c1c1c; + --neutral-30: #383838; + --neutral-40: #555555; + --neutral-50: #717171; + --neutral-60: #8d8d8d; + --neutral-70: #aaaaaa; + --neutral-80: #c6c6c6; + --neutral-90: #ffffff; } -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; +@media (prefers-color-scheme: dark) { + :root { + --neutral-10: #ffffff; + --neutral-20: #c6c6c6; + --neutral-30: #aaaaaa; + --neutral-40: #8d8d8d; + --neutral-50: #717171; + --neutral-60: #555555; + --neutral-70: #383838; + --neutral-80: #1c1c1c; + --neutral-90: #000000; + } } -h1 { - font-size: 3.2em; - line-height: 1.1; +html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + color: var(--neutral-10); + background-color: var(--neutral-90); } -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; +body { + scrollbar-color: var(--neutral-60) transparent; } -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + +::-webkit-scrollbar-track { + background: transparent; } -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } +::-webkit-scrollbar-thumb { + background: var(--neutral-10); } diff --git a/src/main.tsx b/src/main.tsx index 966f17a..076225f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,16 @@ +import "./index.css"; +import { App } from "./App.tsx"; +import { BoardContextProvider } from "./BoardContext.tsx"; import React from "react"; import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; -import "./index.css"; -ReactDOM.createRoot(document.getElementById("root")!).render( +const rootElement = document.getElementById("root")!; +const root = ReactDOM.createRoot(rootElement); + +root.render( - + + + ); diff --git a/src/useBoardContent.ts b/src/useBoardContent.ts new file mode 100644 index 0000000..0f421e6 --- /dev/null +++ b/src/useBoardContent.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +type TData = { + name: string; + title: string; + ups: number; + downs: number; + author: string; + url: string; + created: number; +}; + +type TResponse = { + data: { + after: string; + dist: number; + children: { data: TData }[]; + }; +}; + +type TPagination = { + after?: string; + count?: number; +}; + +export function useBoardContent(board: string) { + const [isLoading, setIsLoading] = useState(false); + const [data, setData] = useState([]); + const fetchRef = useRef(false); + const paginationRef = useRef({}); + + const getBoardContent = useCallback(async () => { + if (fetchRef.current) return; + + fetchRef.current = true; + setIsLoading(true); + + try { + let url = + `https://www.reddit.com/r/${board}/hot.json` + + `?after=${paginationRef.current.after || ""}` + + `&count=${paginationRef.current.count || ""}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error("Error loading board content."); + } + + const json: TResponse = await response.json(); + if (!json?.data?.children) { + throw new Error("Error loading board content."); + } + + paginationRef.current.after = json.data.after; + paginationRef.current.count = json.data.dist; + + const newData = json.data.children.map((children) => children.data); + setData((prevData) => [...prevData, ...newData]); + } catch (error) { + alert("Error loading board content."); + console.error(error); + } finally { + fetchRef.current = false; + setIsLoading(false); + } + }, [board]); + + useEffect(() => { + getBoardContent(); + }, [getBoardContent]); + + return { + isLoading, + loadMore: getBoardContent, + boardContent: data, + }; +} diff --git a/src/useBoardExists.ts b/src/useBoardExists.ts new file mode 100644 index 0000000..6175681 --- /dev/null +++ b/src/useBoardExists.ts @@ -0,0 +1,24 @@ +import { useCallback, useState } from "react"; + +export function useBoardExists() { + const [isLoading, setIsLoading] = useState(false); + + const checkBoardExistance = useCallback(async (board: string) => { + setIsLoading(true); + + try { + const response = await fetch( + `https://www.reddit.com/r/${board}/about.json` + ); + + return response.ok && response.status === 200; + } catch (error) { + alert("Error checking board existance."); + console.error(error); + } finally { + setIsLoading(false); + } + }, []); + + return { isLoading, checkBoardExistance }; +}