diff --git a/components/boards/BestArticleList.tsx b/components/boards/BestArticleList.tsx
index 0accd18e..186d8d51 100644
--- a/components/boards/BestArticleList.tsx
+++ b/components/boards/BestArticleList.tsx
@@ -5,15 +5,15 @@ import { getArticle } from "@/lib/articleApi";
import { Article, ArticleApiData } from "@/types/articleTypes";
import Link from "next/link";
import useDeviceType from "@/hooks/useDeviceType";
-import { DeviceTypePageSize } from "@/types/articleTypes";
+import { DeviceTypePageSize } from "@/constants/deviceSizesConstants";
+import { ORDER_TYPE_ENUM } from "@/constants/orderConstants";
-const ORDERBY = "like";
+const { MOBILE_PAGE_SIZE, TABLET_PAGE_SIZE, DESKTOP_PAGE_SIZE } =
+ DeviceTypePageSize;
export default function BestArticleList() {
const [articles, setArticles] = useState
([]);
const { isMobile, isTablet } = useDeviceType();
- const { MOBILE_PAGE_SIZE, TABLET_PAGE_SIZE, DESKTOP_PAGE_SIZE } =
- DeviceTypePageSize;
const pageSize = isTablet
? TABLET_PAGE_SIZE
@@ -32,7 +32,7 @@ export default function BestArticleList() {
};
useEffect(() => {
- fetchData({ orderBy: ORDERBY, pageSize: pageSize });
+ fetchData({ orderBy: ORDER_TYPE_ENUM.LIKE, pageSize: pageSize });
}, [pageSize]);
return (
diff --git a/components/layout/Button.tsx b/components/layout/Button.tsx
index 53375c97..d2a98aa7 100644
--- a/components/layout/Button.tsx
+++ b/components/layout/Button.tsx
@@ -1,11 +1,25 @@
-import { ReactNode } from "react";
import styles from "./Button.module.scss";
import Link from "next/link";
+import { ButtonProps } from "@/types/commonTypes";
+
+export default function Button({
+ href = "",
+ children,
+ disabled = false,
+}: ButtonProps) {
+ if (!href) {
+ return (
+
+ );
+ }
-export default function Button({ children }: { children: ReactNode }) {
return (
-
-
+
+
);
}
diff --git a/components/layout/Comment.tsx/Comment.tsx b/components/layout/Comment.tsx/Comment.tsx
new file mode 100644
index 00000000..19e5720a
--- /dev/null
+++ b/components/layout/Comment.tsx/Comment.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+import kebabImg from "@/public/images/icons/ic_kebab.svg";
+import { getFormatTime, getElapsedTime } from "@/utils/Utils";
+import { CommentType } from "@/types/articleTypes";
+import styles from "./Commtent.module.scss";
+import defaultProfileImg from "@/public/images/icons/ic_user.svg";
+
+export default function Comment({
+ comment: {
+ content,
+ writer: { image, nickname },
+ createdAt,
+ },
+}: {
+ comment: CommentType;
+}) {
+ const elapsedTime = getElapsedTime(createdAt);
+ const formattedTime = getFormatTime(createdAt);
+ const profileImg = image ? image : defaultProfileImg.src;
+
+ return (
+
+
+
{content}
+
+
+
+
+
+
{nickname}
+
+ {formattedTime} {`(${elapsedTime})`}
+
+
+
+
+
+ );
+}
diff --git a/components/layout/Comment.tsx/CommentsSection.tsx b/components/layout/Comment.tsx/CommentsSection.tsx
new file mode 100644
index 00000000..eece4a9c
--- /dev/null
+++ b/components/layout/Comment.tsx/CommentsSection.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import Comment from "./Comment";
+import { CommentsSectionProp } from "@/types/articleTypes";
+import styles from "./Commtent.module.scss";
+
+export default function CommentsSection({
+ comments: {
+ comments,
+ imgUrl: { src, alt },
+ content,
+ },
+ className,
+}: CommentsSectionProp) {
+ const isCommentEmpty = !comments.length;
+
+ return (
+
+ {isCommentEmpty && (
+
+
+
{content}
+
+ )}
+ {comments.map((comment) => (
+
+ ))}
+
+ );
+}
diff --git a/components/layout/Comment.tsx/Commtent.module.scss b/components/layout/Comment.tsx/Commtent.module.scss
new file mode 100644
index 00000000..e0343825
--- /dev/null
+++ b/components/layout/Comment.tsx/Commtent.module.scss
@@ -0,0 +1,114 @@
+@mixin font-style($size, $weight, $lineHeight, $color) {
+ font-size: $size;
+ font-weight: $weight;
+ color: $color;
+ line-height: $lineHeight;
+}
+
+.kebab-image {
+ cursor: pointer;
+ width: 24px;
+ height: 24px;
+}
+
+.go-back-button {
+ margin: 40px auto 40px;
+ background-color: var(--activate-button-blue);
+ padding: 12px 71px 12px 71px;
+ border-radius: 40px;
+ @include font-style(18px, 600, 24px, white);
+ font-family: inherit;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+
+ img {
+ width: 24px;
+ height: 24px;
+ }
+}
+
+.comments-section {
+ p {
+ @include font-style(16px, 400, 22.4px, var(--gray800));
+ }
+
+ img {
+ width: 40px;
+ height: 40px;
+ }
+
+ .comment {
+ margin-bottom: 24px;
+
+ .comment-content-Wrapper {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+
+ img {
+ width: 24px;
+ height: 24px;
+ }
+ }
+ .writer-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ img {
+ width: 40px;
+ height: 40px;
+ }
+
+ .nickname-wrapper {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 4px;
+
+ h3 {
+ @include font-style(14px, 400, 16.71px, var(--gray600));
+ }
+
+ h4 {
+ @include font-style(12px, 400, 14.32px, var(--gray400));
+ }
+ }
+ }
+ }
+
+ .empty-comment {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ img {
+ width: 200px;
+ height: 200px;
+ }
+
+ h3 {
+ @include font-style(16px, 400, 24px, var(--gray400));
+ text-align: center;
+ }
+ }
+
+ .horizontal-divider {
+ border-top: 1px solid var(--gray200);
+ width: 100%;
+ height: 0px;
+ margin: 16px 0 24px;
+ }
+}
+
+/* Tablet Styles */
+@media screen and (min-width: 768px) and (max-width: 1199px) {
+}
+
+/* Mobile Styles */
+@media screen and (max-width: 767px) {
+}
diff --git a/components/layout/Comment.tsx/GoBackToListButton.tsx b/components/layout/Comment.tsx/GoBackToListButton.tsx
new file mode 100644
index 00000000..1f576cf1
--- /dev/null
+++ b/components/layout/Comment.tsx/GoBackToListButton.tsx
@@ -0,0 +1,15 @@
+import React from "react";
+import backImg from "@/public/images/icons/ic_back.svg";
+import Link from "next/link";
+import styles from "./Commtent.module.scss";
+
+export default function GoBackToListButton({ href = "" }: { href?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/components/layout/Dropdown.tsx b/components/layout/Dropdown.tsx
deleted file mode 100644
index a8ce1cba..00000000
--- a/components/layout/Dropdown.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import arrowDownImg from "@/public/images/icons/ic_arrow_down.svg";
-import { MouseEvent, useState } from "react";
-import styles from "./Dropdown.module.scss";
-import smallDropdownImg from "@/public/images/icons/ic_sort.svg";
-import useDeviceType from "@/hooks/useDeviceType";
-
-export default function Dropdown({
- onOrderChange,
-}: {
- onOrderChange: (newOption: string) => void;
-}) {
- const [seletedOption, setSeletedOption] = useState("최신순");
- const [isOpen, setIsOpen] = useState(false);
- const { isMobile } = useDeviceType();
-
- const handleToggleDropdown = () => {
- setIsOpen(!isOpen);
- };
-
- const handleOptionClick = (event: MouseEvent) => {
- const option = event.currentTarget.innerText;
- setSeletedOption(option);
- // 왜 textContent를 사용하면 계속 타입 오류가 발생할까
- // event.target.innerText 으로 할 때도 왜 타입 오류가 발생할까
- onOrderChange(option === "최신순" ? "recent" : "like");
- setIsOpen(false);
- };
-
-
-
- return (
-
-
- {isOpen && (
-
- )}
-
- );
-}
diff --git a/components/layout/Dropdown/Dropdown.module.scss b/components/layout/Dropdown/Dropdown.module.scss
new file mode 100644
index 00000000..d748da2f
--- /dev/null
+++ b/components/layout/Dropdown/Dropdown.module.scss
@@ -0,0 +1,35 @@
+.dropdown {
+ position: relative;
+ display: inline-block;
+}
+
+.trigger {
+ font: inherit;
+ cursor: pointer;
+ outline: none;
+ box-shadow: none;
+ background-color: var(--white);
+ display: flex;
+ align-items: center;
+}
+
+.menu {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ background: var(--white);
+ z-index: 20;
+ cursor: pointer;
+}
+
+.menu-item {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.divider {
+ margin: 0;
+ border-top: 1px solid var(--gray200);
+ width: 100%;
+}
diff --git a/components/layout/Dropdown/Dropdown.tsx b/components/layout/Dropdown/Dropdown.tsx
new file mode 100644
index 00000000..fb8ce50f
--- /dev/null
+++ b/components/layout/Dropdown/Dropdown.tsx
@@ -0,0 +1,82 @@
+import React, { ReactElement, useEffect, useRef, useState } from "react";
+import styles from "./Dropdown.module.scss";
+import useClickOutside from "@/hooks/useClickOutside";
+import { DropdownProps } from "@/types/uiTypes";
+
+export default function Dropdown({
+ trigger = <>>,
+ items = [],
+ children = null,
+ onSelect = () => {},
+ textDrop = true,
+ triggerClassName = "",
+ menuClassName = "",
+ itemClassName = "",
+ onToggle = () => {},
+ ...rest
+}: DropdownProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+ const [selectedItem, setSelectedItem] = useState(
+ typeof trigger === "string" ? trigger : ""
+ );
+
+ const handleToggle = () => {
+ setIsOpen(!isOpen);
+ onToggle(!isOpen);
+ };
+
+ const handleItemClick = (item: string) => {
+ setSelectedItem(item);
+ setIsOpen(false);
+ onSelect(item);
+ onToggle(false);
+ };
+
+ useClickOutside(dropdownRef, () => {
+ setIsOpen(false);
+ onToggle(false);
+ });
+
+ const renderTrigger = () => {
+ if (typeof trigger === "string") {
+ return <>{selectedItem}>;
+ } else if (React.isValidElement(trigger))
+ return React.cloneElement(trigger);
+ };
+
+ useEffect(() => {}, [trigger]);
+
+ return (
+
+
+ {renderTrigger()}
+
+
+ {isOpen && (
+ <>
+ {items
+ ? items.map((item, index) => (
+ <>
+ - handleItemClick(item)}
+ >
+ {item}
+
+ {index < items.length - 1 && (
+
+ )}
+ >
+ ))
+ : children}
+ >
+ )}
+
+
+ );
+}
diff --git a/components/layout/Dropdown.module.scss b/components/layout/Dropdown/SortDropdown.module.scss
similarity index 82%
rename from components/layout/Dropdown.module.scss
rename to components/layout/Dropdown/SortDropdown.module.scss
index fc3ba94e..ad3f1eb9 100644
--- a/components/layout/Dropdown.module.scss
+++ b/components/layout/Dropdown/SortDropdown.module.scss
@@ -18,12 +18,10 @@
@include font-style(16px, 400, 24px, var(--gray800));
font-family: inherit;
- display: flex;
- align-items: center;
justify-content: space-between;
}
-.menu-wrapper {
+.menu {
background-color: var(--white);
position: absolute;
transform: translateY(10px);
@@ -34,19 +32,21 @@
border-radius: 12px;
}
-.menu-line {
- border: 1px solid var(--gray200);
-}
-
-.menu {
+.menu-item {
width: 132px;
height: 42px;
- display: flex;
- align-items: center;
- justify-content: center;
+
+ &:first-child {
+ border-radius: 12px 12px 0 0;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ border-radius: 0 0 12px 12px;
+ }
&:hover {
- cursor: pointer;
+ background-color: #f1f1f1;
}
}
diff --git a/components/layout/Dropdown/SortDropdown.tsx b/components/layout/Dropdown/SortDropdown.tsx
new file mode 100644
index 00000000..1fcd3e1a
--- /dev/null
+++ b/components/layout/Dropdown/SortDropdown.tsx
@@ -0,0 +1,61 @@
+import arrowDownImg from "@/public/images/icons/ic_arrow_down.svg";
+import { useEffect, useState } from "react";
+import styles from "./SortDropdown.module.scss";
+import smallDropdownImg from "@/public/images/icons/ic_sort.svg";
+import useDeviceType from "@/hooks/useDeviceType";
+import Dropdown from "./Dropdown";
+import { SortDropdownProps } from "@/types/uiTypes";
+
+export default function SortDropdown({
+ onOrderChange,
+ items = [],
+ defaultOrderType,
+}: SortDropdownProps) {
+ const { isMobile } = useDeviceType();
+ const [isMounted, setIsMounted] = useState(false);
+ const [selectedItem, setSelectedItem] = useState(defaultOrderType);
+
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ if (!isMounted) {
+ return null;
+ }
+
+ const handleOptionClick = (option: string) => {
+ setSelectedItem(option);
+ onOrderChange(option);
+ };
+
+ const triggerContent = isMobile ? (
+
+ ) : (
+ <>
+ {selectedItem}
+
+ >
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/components/layout/FileInput/FileInput.module.scss b/components/layout/FileInput/FileInput.module.scss
new file mode 100644
index 00000000..91ac37f0
--- /dev/null
+++ b/components/layout/FileInput/FileInput.module.scss
@@ -0,0 +1,138 @@
+@mixin font-style($size, $weight, $lineHeight, $color) {
+ font-size: $size;
+ font-weight: $weight;
+ color: $color;
+ line-height: $lineHeight;
+}
+
+.file-input-section {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ margin: 0 0 var(--additem-gap-24);
+}
+
+.file-input-wrapper {
+ width: 282px;
+ height: 282px;
+ border-radius: 12px;
+ background-color: #f3f4f6;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+
+ gap: var(--additem-gap-init);
+
+ position: relative;
+
+ span {
+ @include font-style(16px, 400, 26px, var(--gray400));
+ }
+}
+
+.file-input-wrapper:hover {
+ background-color: #e5e6e8;
+}
+
+.file-input {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ margin: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.add-image-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+}
+
+.add-img {
+ width: 48px;
+ height: 48px;
+}
+
+.preview-image-wrapper {
+ display: flex;
+ position: relative;
+}
+
+.preview-image {
+ width: 282px;
+ height: 282px;
+ object-fit: contain;
+ border-radius: 12px;
+}
+
+.cancel-button {
+ margin-left: 8px;
+ padding: 0;
+ width: 20px;
+ height: 20px;
+ background-color: var(--gray400);
+ border-radius: 9999px;
+
+ position: absolute;
+ right: 14px;
+ top: 14px;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ &:hover {
+ background-color: var(--activate-button-blue);
+ }
+}
+
+/* Tablet Styles */
+@media screen and (min-width: 768px) and (max-width: 1199px) {
+ .file-input-section {
+ width: 100%;
+ gap: 16px;
+ }
+
+ .file-input-wrapper,
+ .preview-image-wrapper {
+ width: 162px;
+ height: 162px;
+ }
+
+ .preview-image {
+ width: 100%;
+ height: 100%;
+ }
+}
+
+/* Mobile Styles */
+@media screen and (max-width: 767px) {
+ .file-input-section {
+ width: 100%;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 9px;
+ }
+
+ .file-input-wrapper,
+ .preview-image-wrapper {
+ flex: 0 0 calc(50% - 8px);
+ width: auto;
+ }
+
+ .preview-image {
+ width: 100%;
+ }
+
+ .file-input {
+ width: 100%;
+ }
+}
diff --git a/components/layout/FileInput/FileInput.tsx b/components/layout/FileInput/FileInput.tsx
new file mode 100644
index 00000000..f76ad508
--- /dev/null
+++ b/components/layout/FileInput/FileInput.tsx
@@ -0,0 +1,69 @@
+import React, { ChangeEvent } from "react";
+import { useEffect, useRef, useState } from "react";
+import logoImg from "@/public/images/icons/ic_plus.svg";
+import xIcon from "@/public/images/icons/ic_x.svg";
+import { FileInputType } from "../../../types/commonTypes";
+import styles from "./FileInput.module.scss";
+
+export default function FileInput({
+ value,
+ onChange,
+ className,
+}: FileInputType) {
+ const [preview, setPreview] = useState("");
+ const inputRef = useRef(null);
+
+ const handleChange = (e: ChangeEvent) => {
+ const nextValue = e.target?.files?.[0];
+ if (nextValue) {
+ onChange(nextValue);
+ }
+ };
+
+ const handleClearClick = () => {
+ const inputNode = inputRef.current;
+ if (!inputNode) return;
+
+ inputNode.value = "";
+ onChange(null);
+ };
+
+ useEffect(() => {
+ if (!value) return;
+ const nextPreview = URL.createObjectURL(value);
+ setPreview(nextPreview);
+ }, [value]);
+
+ return (
+
+
+
+
+
+
이미지 등록
+
+
+ {value && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/components/layout/Header/Header.tsx b/components/layout/Header/Header.tsx
index 00a83ea1..35c4121e 100644
--- a/components/layout/Header/Header.tsx
+++ b/components/layout/Header/Header.tsx
@@ -39,7 +39,7 @@ const Header: React.FC = () => {
{!isLogin && (
-
+
)}
{isLogin &&
}
diff --git a/components/layout/RegisterForm/InputField.module.scss b/components/layout/RegisterForm/InputField.module.scss
new file mode 100644
index 00000000..12f43614
--- /dev/null
+++ b/components/layout/RegisterForm/InputField.module.scss
@@ -0,0 +1,24 @@
+@mixin font-style($size, $weight, $lineHeight, $color) {
+ font-size: $size;
+ font-weight: $weight;
+ color: $color;
+ line-height: $lineHeight;
+}
+
+.input {
+ width: 100%;
+ font-family: inherit;
+ background-color: var(--gray100);
+ @include font-style(16px, 400, 26px, var(--gray800));
+ padding: 16px 24px;
+ height: 56px;
+ border-radius: 12px;
+
+ &::placeholder {
+ @include font-style(16px, 400, 26px, var(--gray400));
+ }
+
+ &.textarea {
+ height: 282px;
+ }
+}
diff --git a/components/layout/RegisterForm/InputField.tsx b/components/layout/RegisterForm/InputField.tsx
new file mode 100644
index 00000000..ea3d15f1
--- /dev/null
+++ b/components/layout/RegisterForm/InputField.tsx
@@ -0,0 +1,47 @@
+import { InputFieldProps } from "@/types/registerTypes";
+import React from "react";
+import FileInput from "../FileInput/FileInput";
+import styles from "./InputField.module.scss";
+
+export default function InputField({
+ field,
+ value,
+ onChange,
+}: InputFieldProps) {
+ switch (field.type) {
+ case "textarea":
+ return (
+
+ );
+
+ case "file":
+ return (
+
+ );
+
+ case "input":
+ return (
+
+ );
+
+ default:
+ return null;
+ }
+}
diff --git a/components/layout/RegisterForm/RegisterForm.module.scss b/components/layout/RegisterForm/RegisterForm.module.scss
new file mode 100644
index 00000000..3993ef0e
--- /dev/null
+++ b/components/layout/RegisterForm/RegisterForm.module.scss
@@ -0,0 +1,43 @@
+@mixin font-style($size, $weight, $lineHeight, $color) {
+ font-size: $size;
+ font-weight: $weight;
+ color: $color;
+ line-height: $lineHeight;
+}
+
+.form {
+ width: 1200px;
+ margin: 0 auto;
+ padding: 24px 0;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ &.bottom-button {
+ gap: 0;
+ padding: 0;
+
+ .bottom-button {
+ margin-top: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ }
+ }
+}
+
+.title-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+
+.title {
+ @include font-style(20px, 700, 32px, var(--gray800));
+}
+
+.input-label {
+ margin-bottom: 12px;
+ @include font-style(18px, 700, 26px, var(--gray800));
+}
diff --git a/components/layout/RegisterForm/RegisterForm.tsx b/components/layout/RegisterForm/RegisterForm.tsx
new file mode 100644
index 00000000..63262f12
--- /dev/null
+++ b/components/layout/RegisterForm/RegisterForm.tsx
@@ -0,0 +1,104 @@
+import React, { FormEvent, useEffect, useState } from "react";
+import InputField from "./InputField";
+import Button from "../Button";
+import { RegisterFormProps, HandleChange } from "@/types/registerTypes";
+import { FIELDTYPE, FormValues } from "./registerConfig";
+import styles from "./RegisterForm.module.scss";
+
+export default function RegisterForm({
+ titleText = "",
+ buttonText = "등록",
+ fields,
+ bottomButon = false,
+ href = "",
+}: RegisterFormProps) {
+ const initializeFormValues = () => {
+ const initialValues: FormValues = {};
+ Object.values(fields).forEach((field) => {
+ initialValues[field.id] = field.type === "file" ? null : "";
+ });
+ return initialValues;
+ };
+
+ const [formValues, setFormValues] = useState
(
+ initializeFormValues()
+ );
+ const [isValid, setIsValid] = useState(false);
+
+ const handleChange: HandleChange = (e) => {
+ if (e instanceof File || e === null) {
+ setFormValues((prevValues) => ({
+ ...prevValues,
+ [FIELDTYPE.IMAGE]: e,
+ }));
+ } else {
+ const target = e.target;
+ const { name, value } = target;
+ if (name in formValues) {
+ setFormValues((prevValues) => ({
+ ...prevValues,
+ [name]: value,
+ }));
+ }
+ }
+ };
+
+ const handleBlur = () => {
+ const filteredFields = Object.entries(formValues).filter(
+ ([key]) => key !== FIELDTYPE.IMAGE
+ );
+
+ const result = filteredFields.every(
+ ([_, value]) => value !== null && value !== ""
+ );
+
+ setIsValid(result);
+ };
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ };
+
+ useEffect(() => {
+ setFormValues(initializeFormValues());
+ }, [fields]);
+
+ const formClassName = `${styles.form} ${
+ bottomButon ? styles["bottom-button"] : ""
+ }`;
+
+ return (
+
+ );
+}
diff --git a/components/layout/RegisterForm/registerConfig.ts b/components/layout/RegisterForm/registerConfig.ts
new file mode 100644
index 00000000..166740a7
--- /dev/null
+++ b/components/layout/RegisterForm/registerConfig.ts
@@ -0,0 +1,32 @@
+import { FieldInfo } from "../../../types/registerTypes";
+
+export enum FIELDTYPE {
+ TITLE = "*제목",
+ CONTENT = "*내용",
+ IMAGE = "이미지",
+}
+
+export const fields: { [id: string]: FieldInfo } = {
+ [FIELDTYPE.TITLE]: {
+ id: FIELDTYPE.TITLE,
+ name: FIELDTYPE.TITLE,
+ type: "input",
+ placeholder: "제목을 입력해주세요",
+ },
+ [FIELDTYPE.CONTENT]: {
+ id: FIELDTYPE.CONTENT,
+ name: FIELDTYPE.CONTENT,
+ type: "textarea",
+ placeholder: "내용을 입력해주세요",
+ },
+ [FIELDTYPE.IMAGE]: {
+ id: FIELDTYPE.IMAGE,
+ name: FIELDTYPE.IMAGE,
+ type: "file",
+ },
+};
+
+export type FormValues = {
+ [key in string]: string | File | null;
+};
+// key in FIELDTYPE으로 하고 싶었으나 map으로 돌리면 타입 불일치가 생겨서 string으로 함
diff --git a/constants/deviceSizesConstants.ts b/constants/deviceSizesConstants.ts
new file mode 100644
index 00000000..4ec44708
--- /dev/null
+++ b/constants/deviceSizesConstants.ts
@@ -0,0 +1,12 @@
+export enum DeviceSizes {
+ TABLET_MIN_WIDTH = 768,
+ MOBILE_MAX_WIDTH = DeviceSizes.TABLET_MIN_WIDTH - 1,
+ DESKTOP_MIN_WIDTH = 1200,
+ TABLET_MAX_WIDTH = DeviceSizes.DESKTOP_MIN_WIDTH - 1,
+}
+
+export enum DeviceTypePageSize {
+ MOBILE_PAGE_SIZE = 1,
+ TABLET_PAGE_SIZE,
+ DESKTOP_PAGE_SIZE,
+}
diff --git a/constants/orderConstants.ts b/constants/orderConstants.ts
new file mode 100644
index 00000000..1a4c438f
--- /dev/null
+++ b/constants/orderConstants.ts
@@ -0,0 +1,20 @@
+export enum ORDER_TYPE_ENUM {
+ RECENT = "recent",
+ LIKE = "like",
+}
+
+export const orderTypeKR = {
+ [ORDER_TYPE_ENUM.RECENT]: "최신순",
+ [ORDER_TYPE_ENUM.LIKE]: "추천순",
+} as const;
+
+export const orderTypeUS = {
+ [orderTypeKR[ORDER_TYPE_ENUM.RECENT]]: ORDER_TYPE_ENUM.RECENT,
+ [orderTypeKR[ORDER_TYPE_ENUM.LIKE]]: ORDER_TYPE_ENUM.LIKE,
+} as const;
+
+export const orderTypeKeys = Object.values(ORDER_TYPE_ENUM);
+export const orderTypeKeysKR = Object.values(orderTypeKR);
+export const defaultOrderType = ORDER_TYPE_ENUM.RECENT;
+
+export type OrderTypeKR = (typeof orderTypeKR)[keyof typeof orderTypeKR];
diff --git a/hooks/useClickOutside.ts b/hooks/useClickOutside.ts
new file mode 100644
index 00000000..31448cea
--- /dev/null
+++ b/hooks/useClickOutside.ts
@@ -0,0 +1,18 @@
+import { useEffect, RefObject } from "react";
+
+const useClickOutside = (ref: RefObject, handler: () => void) => {
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (ref.current && !ref.current.contains(event.target as Node)) {
+ handler();
+ }
+ };
+
+ document.addEventListener("click", handleClickOutside);
+ return () => {
+ document.removeEventListener("click", handleClickOutside);
+ };
+ }, [ref, handler]);
+};
+
+export default useClickOutside;
diff --git a/hooks/useDeviceType.ts b/hooks/useDeviceType.ts
index 8ab39523..dd09f03d 100644
--- a/hooks/useDeviceType.ts
+++ b/hooks/useDeviceType.ts
@@ -1,21 +1,34 @@
+import { DeviceSizes } from "@/constants/deviceSizesConstants";
import { useState, useEffect } from "react";
+const {
+ MOBILE_MAX_WIDTH,
+ TABLET_MIN_WIDTH,
+ TABLET_MAX_WIDTH,
+ DESKTOP_MIN_WIDTH,
+} = DeviceSizes;
+// 767, 768, 1199, 1200
+
const useDeviceType = () => {
const [deviceType, setDeviceType] = useState({
- isMobile: typeof window !== "undefined" && window.innerWidth <= 767,
+ isMobile:
+ typeof window !== "undefined" && window.innerWidth <= MOBILE_MAX_WIDTH,
isTablet:
typeof window !== "undefined" &&
- window.innerWidth >= 768 &&
- window.innerWidth <= 1199,
- isDesktop: typeof window !== "undefined" && window.innerWidth >= 1200,
+ window.innerWidth >= TABLET_MIN_WIDTH &&
+ window.innerWidth <= TABLET_MAX_WIDTH,
+ isDesktop:
+ typeof window !== "undefined" && window.innerWidth >= DESKTOP_MIN_WIDTH,
});
useEffect(() => {
const handleResize = () => {
setDeviceType({
- isMobile: window.innerWidth <= 767,
- isTablet: window.innerWidth >= 768 && window.innerWidth <= 1199,
- isDesktop: window.innerWidth >= 1200,
+ isMobile: window.innerWidth <= MOBILE_MAX_WIDTH,
+ isTablet:
+ window.innerWidth >= TABLET_MIN_WIDTH &&
+ window.innerWidth <= TABLET_MAX_WIDTH,
+ isDesktop: window.innerWidth >= DESKTOP_MIN_WIDTH,
});
};
diff --git a/lib/articleApi.ts b/lib/articleApi.ts
index 674f3421..1b1a9ec9 100644
--- a/lib/articleApi.ts
+++ b/lib/articleApi.ts
@@ -1,3 +1,5 @@
+import { ORDER_TYPE_ENUM } from "@/constants/orderConstants";
+import { ArticleApiData, ArticleCommentApiData } from "@/types/articleTypes";
import axios, { AxiosError } from "axios";
const instance = axios.create({
@@ -5,14 +7,18 @@ const instance = axios.create({
});
export const getArticle = async ({
+ articleId = "",
page = 1,
pageSize = 10,
- orderBy = "recent",
+ orderBy = ORDER_TYPE_ENUM.RECENT,
keyword = "",
-}) => {
+ detail = false,
+}: ArticleApiData = {}) => {
try {
- const response = await instance.get(`/articles`, {
- params: { page, pageSize, orderBy, keyword },
+ const params = detail ? undefined : { page, pageSize, orderBy, keyword };
+
+ const response = await instance.get(`/articles/${articleId}`, {
+ params,
});
if (response.status !== 200) {
@@ -24,3 +30,22 @@ export const getArticle = async ({
throw new Error(`API Error: ${error.message}`);
}
};
+
+export const getArticleComment = async ({
+ articleId = "",
+ limit = 10,
+ cursor = 0,
+}: ArticleCommentApiData) => {
+ try {
+ const response = await instance.get(`/articles/${articleId}/comments`, {
+ params: { limit, cursor },
+ });
+ if (response.status !== 200) {
+ throw new Error("데이터를 불러오는데 실패했습니다");
+ }
+
+ return response.data;
+ } catch (error: any) {
+ throw new Error(`API Error: ${error.message}`);
+ }
+};
diff --git a/pages/addboard/index.tsx b/pages/addboard/index.tsx
new file mode 100644
index 00000000..7ef61913
--- /dev/null
+++ b/pages/addboard/index.tsx
@@ -0,0 +1,16 @@
+import RegisterForm from "@/components/layout/RegisterForm/RegisterForm";
+import { fields } from "@/components/layout/RegisterForm/registerConfig";
+
+export default function AddBoard() {
+ const formFields = fields;
+
+ return (
+
+
+
+ );
+}
diff --git a/pages/board/[id].tsx b/pages/board/[id].tsx
new file mode 100644
index 00000000..899f8a26
--- /dev/null
+++ b/pages/board/[id].tsx
@@ -0,0 +1,77 @@
+import { getArticle, getArticleComment } from "@/lib/articleApi";
+import styles from "@/components/board/BoardDetailArticle.module.scss";
+import CommentsSection from "@/components/layout/Comment.tsx/CommentsSection";
+import RegisterForm from "@/components/layout/RegisterForm/RegisterForm";
+import GoBackToListButton from "@/components/layout/Comment.tsx/GoBackToListButton";
+import { useEffect, useState } from "react";
+import {
+ ArticleApiData,
+ ArticleCommentApiData,
+ CommentObject,
+} from "@/types/articleTypes";
+import { useRouter } from "next/router";
+import { commentInfo, fields } from "@/components/board/BoardDetailConfig";
+import BoardDetailArticle from "@/components/board/BoardDetailArticle";
+
+export default function FreeBoardDetail() {
+ const router = useRouter();
+ const { id } = router.query;
+ const [comments, setComments] = useState(commentInfo);
+ const formFields = fields;
+ const [article, setArticle] = useState({
+ id: 0,
+ title: "",
+ content: "",
+ image: null,
+ createdAt: "",
+ updatedAt: "",
+ isLiked: false,
+ likeCount: 0,
+ writer: {
+ id: 0,
+ nickname: "",
+ },
+ });
+
+ const fetchDataArticle = async ({ articleId }: ArticleApiData) => {
+ try {
+ const result = await getArticle({
+ articleId,
+ detail: true,
+ });
+
+ setArticle(() => result);
+ } catch (error) {
+ console.log(error);
+ }
+ };
+
+ const fetchDataComment = async ({ articleId }: ArticleCommentApiData) => {
+ try {
+ const result = await getArticleComment({ articleId });
+
+ setComments({
+ ...comments,
+ comments: result.list,
+ });
+ } catch (error) {
+ console.log(error);
+ }
+ };
+
+ useEffect(() => {
+ if (id) {
+ fetchDataArticle({ articleId: id });
+ fetchDataComment({ articleId: id });
+ }
+ }, [id]);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/pages/boards/index.tsx b/pages/boards/index.tsx
index 74779f1e..a185201e 100644
--- a/pages/boards/index.tsx
+++ b/pages/boards/index.tsx
@@ -4,13 +4,11 @@ import styles from "@/components/boards/Freeboard.module.scss";
import { ArticleProps } from "@/types/articleTypes";
import { getArticle } from "@/lib/articleApi";
-const ORDERBY = "recent";
-const PAGESIZE = 10;
const REVALIDATE = 10;
export async function getStaticProps() {
try {
- const bestRes = await getArticle({ orderBy: ORDERBY, pageSize: PAGESIZE });
+ const bestRes = await getArticle();
const initialArticles = bestRes.list ?? [];
return {
@@ -30,7 +28,7 @@ export async function getStaticProps() {
}
}
-export default function Freeboard({ initialArticles }: ArticleProps) {
+export default function FreeBoard({ initialArticles }: ArticleProps) {
return (
diff --git a/public/images/icons/Img_reply_empty.svg b/public/images/icons/Img_reply_empty.svg
new file mode 100644
index 00000000..dbac5014
--- /dev/null
+++ b/public/images/icons/Img_reply_empty.svg
@@ -0,0 +1,10 @@
+
diff --git a/public/images/icons/no_img.svg b/public/images/icons/no_img.svg
new file mode 100644
index 00000000..59f30fde
--- /dev/null
+++ b/public/images/icons/no_img.svg
@@ -0,0 +1,4 @@
+
diff --git a/styles/reset.css b/styles/reset.css
index bb4d0547..88a50656 100644
--- a/styles/reset.css
+++ b/styles/reset.css
@@ -133,5 +133,10 @@ a {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+input,
+textarea {
+ outline: none;
+ border: none;
+}
/*# sourceMappingURL=styles.css.map */
diff --git a/types/articleTypes.ts b/types/articleTypes.ts
index e170b723..c379eae1 100644
--- a/types/articleTypes.ts
+++ b/types/articleTypes.ts
@@ -1,3 +1,5 @@
+import { ORDER_TYPE_ENUM } from "@/constants/orderConstants";
+
export interface Article {
content: string;
createdAt: string;
@@ -13,10 +15,12 @@ export interface Article {
}
export interface ArticleApiData {
+ articleId?: string | string[];
page?: number;
pageSize?: number;
- orderBy?: "recent" | "like";
+ orderBy?: ORDER_TYPE_ENUM.RECENT | ORDER_TYPE_ENUM.LIKE;
keyword?: string | string[] | undefined;
+ detail?: boolean;
}
export interface ArticleProps {
@@ -27,8 +31,32 @@ export interface ArticleProp {
article: Article;
}
-export enum DeviceTypePageSize {
- MOBILE_PAGE_SIZE = 1,
- TABLET_PAGE_SIZE = 2,
- DESKTOP_PAGE_SIZE = 3,
+// Comment
+export type CommentType = {
+ id: number;
+ content: string;
+ createdAt: string;
+ updatedAt: string;
+ writer: {
+ id: number;
+ image: string;
+ nickname: string;
+ };
+};
+
+export interface ArticleCommentApiData {
+ articleId?: string | string[];
+ limit?: number;
+ cursor?: number;
+}
+
+export interface CommentObject {
+ comments: CommentType[];
+ content: string;
+ imgUrl: { src: string; alt?: string };
+}
+
+export interface CommentsSectionProp {
+ comments: CommentObject;
+ className?: string;
}
diff --git a/types/commonTypes.ts b/types/commonTypes.ts
new file mode 100644
index 00000000..9398a049
--- /dev/null
+++ b/types/commonTypes.ts
@@ -0,0 +1,15 @@
+import { Url } from "next/dist/shared/lib/router/router";
+import { ReactNode } from "react";
+
+export interface ButtonProps {
+ href?: Url;
+ children: ReactNode;
+ disabled?: boolean;
+}
+
+export interface FileInputType {
+ name: string;
+ value: File | null;
+ onChange: (file: File | null) => void;
+ className?: string;
+}
diff --git a/types/orderTypes.ts b/types/orderTypes.ts
new file mode 100644
index 00000000..fe8adf2c
--- /dev/null
+++ b/types/orderTypes.ts
@@ -0,0 +1,3 @@
+import { ORDER_TYPE_ENUM } from "@/constants/orderConstants";
+
+export type Order = keyof typeof ORDER_TYPE_ENUM;
diff --git a/types/registerTypes.ts b/types/registerTypes.ts
new file mode 100644
index 00000000..14efaf81
--- /dev/null
+++ b/types/registerTypes.ts
@@ -0,0 +1,33 @@
+export interface FieldInfo {
+ id: string;
+ name: string;
+ type: "input" | "textarea" | "file";
+ placeholder?: string;
+}
+
+export interface FieldValues {
+ [key: string]: string | File | null;
+}
+
+export interface RegisterFormProps {
+ fields: { [key: string]: FieldInfo };
+ titleText?: string;
+ buttonText?: string;
+ bottomButon?: boolean;
+ href?: string;
+}
+
+export interface InputFieldProps {
+ field: FieldInfo;
+ value?: string | File | null;
+ onChange: (
+ e: React.ChangeEvent
| File | null
+ ) => void;
+}
+
+export type HandleChangeEvent =
+ | React.ChangeEvent
+ | File
+ | null;
+
+export type HandleChange = (e: HandleChangeEvent) => void;
diff --git a/types/uiTypes.ts b/types/uiTypes.ts
new file mode 100644
index 00000000..d4d5dac3
--- /dev/null
+++ b/types/uiTypes.ts
@@ -0,0 +1,27 @@
+import { ORDER_TYPE_ENUM, orderTypeKeysKR } from "@/constants/orderConstants";
+import { ReactElement } from "react";
+
+// Dropdown
+
+export interface TriggerType {
+ trigger: ReactElement | string;
+}
+
+export interface DropdownProps extends TriggerType {
+ items?: string[];
+ children?: React.ReactNode;
+ onSelect?: (item: string) => void;
+ textDrop?: boolean;
+ triggerClassName?: string;
+ menuClassName?: string;
+ itemClassName?: string;
+ onToggle?: (isOpen: boolean) => void;
+}
+
+// SortDropdown
+
+export interface SortDropdownProps {
+ onOrderChange: (newOption: string) => void;
+ items: typeof orderTypeKeysKR;
+ defaultOrderType: string;
+}
diff --git a/utils/Utils.ts b/utils/Utils.ts
index 2c8dcca1..a6e4092c 100644
--- a/utils/Utils.ts
+++ b/utils/Utils.ts
@@ -1,8 +1,3 @@
-// 숫자를 쉼표로 구분하여 반환
-export function getCommasToNumber(number: number) {
- return number.toLocaleString();
-}
-
// 숫자만 입력 및 숫자 쉼표로 구분하여 반환
export function getFormatNumber(number: string) {
return number.replace(/\D/g, "").replace(/\B(?=(\d{3})+(?!\d))/g, ",");