KIM-JINO5 2 ヶ月 前
コミット
8d5c7d5aab

+ 106 - 0
app/(main)/(forum)/latest/_component/LatestListLayout.tsx

@@ -0,0 +1,106 @@
+'use client';
+
+import './style.scss';
+import Link from 'next/link';
+import { useSearchParams } from 'next/navigation';
+import { useMemo } from 'react';
+import Post from '@/types/forum/post';
+import { formatDate } from '@/lib/utils/client';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faComment, faThumbsUp, faEye } from '@fortawesome/free-regular-svg-icons';
+import { faFloppyDisk, faImage, faVideo } from '@fortawesome/free-solid-svg-icons';
+
+interface Props {
+	list: Post[];
+	startIndex?: number;
+}
+
+export default function LatestListLayout({list, startIndex}: Props)
+{
+	const searchParams = useSearchParams();
+	const query = useMemo(() => Object.fromEntries(searchParams.entries()), [searchParams]);
+
+	return (
+		<section className="latest-list-layout" aria-label='최근 게시글'>
+			<article>
+				<ul>
+					<li>번호</li>
+					<li>게시판</li>
+					<li>제목</li>
+					<li>작성자</li>
+					<li>작성일</li>
+					<li>조회수</li>
+					<li>좋아요</li>
+				</ul>
+			</article>
+			<article>
+				{list.length > 0 && (
+					list.map((row, i) => {
+						const createdAt = formatDate(row.createdAt);
+						const postViewUrl = {pathname: '/post/' + row.id, query};
+
+						return (
+							<section key={row.id}>
+								{/* PC */}
+								<ol>
+									<li>
+										<small>{startIndex !== undefined ? startIndex - i : i + 1}</small>
+									</li>
+									<li>{row.boardName}</li>
+									<li>
+										{row.boardPrefix && row.boardPrefixID && (
+											<span className='prefix'>[{row.boardPrefix.name}]</span>
+										)}
+										<Link href={postViewUrl}>
+											<em>{row.subject} {row.comments > 0 && <span>[{row.comments}]</span>}</em>
+											{row.files > 0 && <FontAwesomeIcon icon={faFloppyDisk} />}
+											{row.images > 0 && <FontAwesomeIcon icon={faImage} />}
+											{row.videos > 0 && <FontAwesomeIcon icon={faVideo} />}
+										</Link>
+									</li>
+									<li>{row.name || row.sid}</li>
+									<li>{createdAt}</li>
+									<li>{row.views}</li>
+									<li>{row.likes}</li>
+								</ol>
+
+								{/* Mobile */}
+								<dl hidden>
+									<dt>
+										<span className='board-name'>{row.boardName}</span>
+										{row.boardPrefix && row.boardPrefixID && (
+											<span className='prefix'>[{row.boardPrefix.name}]</span>
+										)}
+										<Link href={postViewUrl}>
+											<em>{row.subject} {row.comments > 0 && <span>[{row.comments}]</span>}</em>
+											{row.files > 0 && <FontAwesomeIcon icon={faFloppyDisk} />}
+											{row.images > 0 && <FontAwesomeIcon icon={faImage} />}
+											{row.videos > 0 && <FontAwesomeIcon icon={faVideo} />}
+										</Link>
+									</dt>
+									<dd>
+										<ul>
+											<li>{row.name || row.sid}</li>
+											<li><FontAwesomeIcon icon={faComment} /> {row.comments}</li>
+											<li><FontAwesomeIcon icon={faEye} /> {row.views}</li>
+											<li><FontAwesomeIcon icon={faThumbsUp} /> {row.likes}</li>
+											<li>{createdAt}</li>
+										</ul>
+									</dd>
+								</dl>
+							</section>
+						);
+					})
+				)}
+
+				{list.length <= 0 && (
+					<section>
+						<p className="text-center p-10">
+							등록된 글이 없습니다.
+						</p>
+					</section>
+				)}
+			</article>
+		</section>
+	);
+}

+ 171 - 0
app/(main)/(forum)/latest/_component/style.scss

@@ -0,0 +1,171 @@
+section.latest-list-layout {
+	border-top: 1px solid #eaeaea;
+	margin: 0.75rem 0;
+
+	article:nth-of-type(1) {
+		background-color: #f9fafb;
+		border-bottom: 1px solid #eaeaea;
+		padding: 0.5rem 0;
+
+		ul {
+			display: grid;
+			grid-template-columns:
+				clamp(50px, 5%, 80px)
+				clamp(80px, 10%, 120px)
+				1fr
+				clamp(100px, 14%, 190px)
+				clamp(80px, 10%, 100px)
+				clamp(50px, 9%, 100px)
+				clamp(50px, 9%, 100px);
+			column-gap: 0.75rem;
+
+			li {
+				text-align: center;
+
+				&:nth-child(2),
+				&:nth-child(3),
+				&:nth-child(4) {
+					text-align: left;
+				}
+			}
+		}
+
+		@media (max-width: 1024px) {
+			display: none;
+		}
+	}
+
+	article:nth-of-type(2) {
+		box-sizing: border-box;
+
+		section {
+			padding: 0.5rem 0;
+			box-sizing: inherit;
+			border-bottom: 1px solid #eaeaea;
+
+			&.active,
+			&:hover {
+				background-color: #faffd1;
+			}
+
+			.prefix {
+				color: #333;
+				padding-right: 0.5rem;
+			}
+
+			.board-name {
+				color: #666;
+				font-size: 0.813rem;
+				padding-right: 0.5rem;
+			}
+
+			a {
+				color: #0060a9;
+				text-decoration: none;
+
+				&:hover {
+					text-decoration: underline;
+					color: #c7511f;
+				}
+
+				> em {
+					display: inherit;
+					font-style: normal;
+
+					> span {
+						color: #d13232;
+						font-size: 0.813rem;
+						vertical-align: text-top;
+						padding-right: 4px;
+					}
+				}
+
+				> svg {
+					padding-right: 4px;
+					color: #9c9898;
+				}
+
+				> span {
+					display: inline-block;
+					vertical-align: middle;
+				}
+			}
+
+			// PC
+			ol {
+				display: grid;
+				grid-template-columns:
+					clamp(50px, 5%, 80px)
+					clamp(80px, 10%, 120px)
+					1fr
+					clamp(100px, 14%, 190px)
+					clamp(80px, 10%, 100px)
+					clamp(50px, 9%, 100px)
+					clamp(50px, 9%, 100px);
+				column-gap: 0.75rem;
+				align-items: center;
+
+				li {
+					text-align: center;
+
+					&:nth-child(2) {
+						text-align: left;
+						color: #666;
+						font-size: 0.875rem;
+					}
+
+					&:nth-child(3) {
+						min-width: 0;
+						text-align: left;
+						word-break: keep-all;
+						overflow-wrap: break-word;
+						text-overflow: ellipsis;
+					}
+
+					&:nth-child(4) {
+						text-align: left;
+					}
+				}
+			}
+
+			// Mobile
+			dl {
+				dt {
+					font-size: 1.063rem;
+					word-break: keep-all;
+					overflow-wrap: break-word;
+					text-overflow: ellipsis;
+				}
+
+				dd {
+					ul {
+						display: flex;
+						flex-direction: row;
+						flex-wrap: nowrap;
+						justify-content: start;
+						padding-top: 0.5rem;
+						column-gap: 1.063rem;
+
+						li {
+							font-size: 0.875rem;
+
+							&:last-child {
+								flex-grow: 1;
+								text-align: right;
+							}
+						}
+					}
+				}
+			}
+
+			@media (max-width: 1024px) {
+				ol {
+					display: none;
+				}
+				dl {
+					display: block;
+				}
+			}
+		}
+	}
+}

+ 44 - 0
app/(main)/(forum)/latest/page.tsx

@@ -0,0 +1,44 @@
+'use server';
+
+import './style.scss';
+import View from './view';
+import { BoardSort, PostSearchType } from '@/constants/forum';
+import { fetchAllPosts } from '@/lib/api/forum/board';
+import { throwError } from '@/lib/utils/server';
+
+type Props = {
+	searchParams: Promise<{
+		page: number;
+		perPage: number;
+		sort?: BoardSort;
+		search: PostSearchType;
+		keyword?: string;
+	}>;
+}
+
+export default async function Latest({ searchParams }: Props)
+{
+	const query = await searchParams;
+
+	query.page = Math.max(Number(query.page) || 1) as number;
+	query.perPage = Math.max(Number(query.perPage) || 20) as number;
+	query.sort = (Number(query.sort) || BoardSort.CreatedAt) as BoardSort|undefined;
+	query.search = (Number(query.search) || PostSearchType.Subject) as PostSearchType;
+	query.keyword = (query.keyword || '') as string|undefined;
+
+	const posts = await fetchAllPosts({
+		page: query.page as number,
+		perPage: query.perPage as number,
+		sort: query.sort as BoardSort|null|undefined,
+		search: query.search as PostSearchType,
+		keyword: query.keyword as string|null|undefined
+	});
+
+	if (!posts.success) {
+		throwError(posts);
+	}
+
+	return (
+		<View _query={query} _postList={posts.data!} />
+	);
+}

+ 96 - 0
app/(main)/(forum)/latest/style.scss

@@ -0,0 +1,96 @@
+#latest {
+	position: relative;
+	padding: 25px 32px 32px 32px;
+	min-width: 270px;
+	max-width: 1920px;
+	margin: 0 auto;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 20px;
+	}
+
+	// 검색 조건
+	.list-header, .list-footer {
+		select, input[type="search"], input[type="radio"] {
+			height: 34px;
+		}
+
+		input[type="radio"] {
+			position: absolute;
+			opacity: 0;
+			pointer-events: none;
+		}
+
+		a, button {
+			line-height: 34px;
+			padding: 0 15px;
+		}
+	}
+
+	// 상단 제어 버튼
+	.list-header {
+		display: grid;
+		grid-template-columns: 1fr auto auto;
+		gap: 7px;
+		align-items: end;
+
+		section:first-child {
+			flex: 1 1 auto;
+			min-width: 0;
+		}
+	}
+
+	// 하단 제어 버튼
+	.list-footer {
+		display: grid;
+		grid-template-columns: 1fr auto;
+
+		.search-toggle {
+			display: none;
+		}
+
+		> section[aria-label='게시글 검색'] > form {
+			display: flex;
+			align-items: center;
+			gap: 7px;
+		}
+	}
+
+    @media (max-width: 576px) {
+		.list-footer {
+			grid-template-columns: auto 1fr;
+			align-items: center;
+
+			.search-toggle {
+				grid-column: 1;
+				display: inline-flex;
+				align-items: center;
+				justify-content: center;
+				height: 100%;
+			}
+
+			> section[aria-label='게시글 검색'] {
+				visibility: hidden;
+			}
+		}
+    }
+
+	@media (max-width: 768px) {
+		.list-footer {
+			> section[aria-label='게시글 검색'] {
+				form {
+					padding-right: 7px;
+
+					select {
+						flex-grow: 0;
+					}
+
+					input {
+						flex-grow: 1;
+					}
+				}
+			}
+		}
+	}
+}

+ 234 - 0
app/(main)/(forum)/latest/view.tsx

@@ -0,0 +1,234 @@
+'use client';
+
+import './style.scss';
+import { usePathname, useSearchParams } from 'next/navigation';
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import { BoardSort, PostSearchType } from '@/constants/forum';
+import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { fetchApi } from '@/lib/utils/client';
+import useErrorAlert from '@/hooks/useErrorAlert';
+import Loading from '@/app/component/Loading';
+import Pagination from '@/app/component/Pagination';
+import { BoardPostsResponse } from '@/types/response/forum/board';
+import Post from '@/types/forum/post';
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import LatestListLayout from './_component/LatestListLayout';
+
+type ViewProps = {
+	_query: {
+		page: number;
+		perPage: number;
+		sort?: BoardSort;
+		search: PostSearchType;
+		keyword?: string;
+	},
+	_postList: BoardPostsResponse
+};
+
+export default function View({ _query, _postList }: ViewProps)
+{
+	const pathname = usePathname();
+	const searchParams = useSearchParams();
+	const { setError } = useErrorAlert();
+	const [loading, setLoading] = useState<boolean>(false);
+	const [total, setTotal] = useState<number>(_postList.total);
+	const [list, setList] = useState<Post[]>(_postList.list);
+	const [page, setPage] = useState<number>(_query.page);
+	const [perPage, setPerPage] = useState<number>(_query.perPage);
+	const [sort, setSort] = useState<BoardSort|undefined>(_query.sort);
+	const [search, setSearch] = useState<PostSearchType>(_query.search);
+	const [keyword, setKeyword] = useState<string|undefined>(_query.keyword);
+	const [params, setParams] = useState<Record<string, string>>({});
+	const [searchDialogOpen, setSearchDialogOpen] = useState(false);
+	const isMounted = useRef(false);
+	const searchRef = useRef(search);
+	const keywordRef = useRef(keyword);
+	searchRef.current = search;
+	keywordRef.current = keyword;
+	const startIndex = useMemo(() => total - ((page - 1) * perPage), [total, page, perPage]);
+
+	// 상태 => URL 동기화
+	useEffect(() => {
+		const alreadyParams = new URLSearchParams(searchParams.toString());
+
+		Object.entries(params).forEach(([k, v]) => {
+			if (v) {
+				alreadyParams.set(k, v);
+			} else {
+				alreadyParams.delete(k);
+			}
+		});
+
+		const queryString = `?${alreadyParams.toString()}`;
+		if (window.location.search !== queryString) {
+			window.history.replaceState(null, '', `${pathname}${queryString}`);
+		}
+
+	}, [page, perPage, sort, search, keyword, params, pathname, searchParams]);
+
+	const handleFetchPosts = useCallback(async () => {
+		try {
+			setLoading(true);
+
+			const queryParams = new URLSearchParams();
+
+			queryParams.set('page', String(page));
+			queryParams.set('perPage', String(perPage));
+
+			if (sort !== undefined && sort !== null) {
+				queryParams.set('sort', String(sort));
+			}
+			if (searchRef.current !== undefined && searchRef.current !== null) {
+				queryParams.set('search', String(searchRef.current));
+			}
+			if (keywordRef.current) {
+				queryParams.set('keyword', keywordRef.current);
+			}
+
+			const res = await fetchApi<BoardPostsResponse>(`/api/forum/posts?${queryParams.toString()}`);
+
+			if (!res.data) {
+				setError('게시글을 불러올 수 없습니다.');
+			} else {
+				setTotal(res.data.total);
+				setList(res.data.list);
+			}
+		} catch (err) {
+			if (err instanceof Error) {
+				setError(err.message || '알 수 없는 오류가 발생했습니다.');
+			}
+		} finally {
+			setLoading(false);
+		}
+	}, [page, perPage, sort]);
+
+	const handlePageChange = useCallback((page: number) => {
+		setPage(page);
+		setParams((prev) => ({ ...prev, page: String(page) }));
+	}, []);
+
+	const handleChange = useCallback((e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement>) => {
+		const { name, value } = e.target;
+
+		switch (name) {
+			case 'sort':
+				setSort(Number(value) as BoardSort);
+				break;
+			case 'perPage':
+				setPerPage(Number(value));
+				break;
+			case 'search':
+				setSearch(Number(value) as PostSearchType);
+				break;
+			case 'keyword':
+				setKeyword(value);
+				break;
+		}
+
+		if (['perPage', 'search', 'keyword'].includes(name)) {
+			handlePageChange(1);
+		}
+
+		setParams((prev) => ({ ...prev, [name]: value }));
+	}, [handlePageChange]);
+
+	const handleSearch = useCallback((e: React.FormEvent) => {
+		e.preventDefault();
+		handleFetchPosts();
+	}, [handleFetchPosts]);
+
+	const handleSearchDialog = useCallback((e: React.FormEvent) => {
+		e.preventDefault();
+		handleFetchPosts();
+		setSearchDialogOpen(false);
+	}, [handleFetchPosts]);
+
+	useEffect(() => {
+		if (!isMounted.current) {
+			isMounted.current = true;
+			return;
+		}
+
+		handleFetchPosts();
+	}, [page, perPage, sort, handleFetchPosts]);
+
+	return (
+		<div id='latest'>
+			{loading && <Loading />}
+
+			<div className='list-header'>
+				<section>
+					<h1>토론</h1>
+				</section>
+
+				{/* 정렬 */}
+				<section aria-label='게시글 정렬'>
+					<select name='sort' value={sort ?? ''} title='게시글 정렬' onChange={handleChange}>
+						<option value={BoardSort.CreatedAt}>최신순</option>
+						<option value={BoardSort.Views}>조회순</option>
+						<option value={BoardSort.Comments}>댓글순</option>
+						<option value={BoardSort.Likes}>공감순</option>
+					</select>
+				</section>
+
+				{/* 출력 수 */}
+				<section aria-label='게시글 출력 수'>
+					<select name='perPage' value={perPage} title='출력 수' onChange={handleChange}>
+						<option value='10'>10개씩</option>
+						<option value='20'>20개씩</option>
+						<option value='30'>30개씩</option>
+						<option value='50'>50개씩</option>
+						<option value='100'>100개씩</option>
+					</select>
+				</section>
+			</div>
+
+			{/* 게시글 목록 */}
+			<LatestListLayout list={list} startIndex={startIndex} />
+
+			{/* 검색 */}
+			<div className='list-footer'>
+				{/* 모바일: 검색 아이콘 → Dialog */}
+				<Dialog open={searchDialogOpen} onOpenChange={setSearchDialogOpen}>
+					<DialogTrigger asChild>
+						<button type='button' className='btn btn-default search-toggle' title='검색'>
+							<FontAwesomeIcon icon={faMagnifyingGlass}/>
+						</button>
+					</DialogTrigger>
+					<DialogContent className='w-[90%] sm:max-w-md'>
+						<DialogHeader>
+							<DialogTitle>게시글 검색</DialogTitle>
+						</DialogHeader>
+						<form onSubmit={handleSearchDialog} autoComplete='off' className='flex flex-col gap-3'>
+							<select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange} className='h-9 rounded-md border px-3'>
+								<option value={PostSearchType.Subject}>제목</option>
+								<option value={PostSearchType.Content}>내용</option>
+								<option value={PostSearchType.Author}>작성자</option>
+								<option value={PostSearchType.Comment}>댓글</option>
+							</select>
+							<input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} className='h-9 rounded-md border px-3' />
+							<button type='submit' className='btn btn-default w-full'>검색</button>
+						</form>
+					</DialogContent>
+				</Dialog>
+
+				{/* 데스크톱: 인라인 검색 폼 */}
+				<section aria-label='게시글 검색'>
+					<form onSubmit={handleSearch} autoComplete='off'>
+						<select name='search' value={search ?? ''} title='검색 구분' onChange={handleChange}>
+							<option value={PostSearchType.Subject}>제목</option>
+							<option value={PostSearchType.Content}>내용</option>
+							<option value={PostSearchType.Author}>작성자</option>
+							<option value={PostSearchType.Comment}>댓글</option>
+						</select>
+						<input type='text' name='keyword' value={keyword} placeholder='검색어를 입력해주세요.' onChange={handleChange} />
+						<button type='submit' className='btn btn-default'>검색</button>
+					</form>
+				</section>
+			</div>
+
+			<Pagination total={total} page={page} perPage={perPage} onChange={handlePageChange} />
+		</div>
+	);
+}

+ 1 - 0
app/(main)/(forum)/post/_component/LatestPosts.tsx

@@ -28,6 +28,7 @@ function toPost(item: PostLatest): Post {
 	return {
 		...item,
 		num: item.no,
+		boardName: '',
 		boardPrefix: item.boardPrefixName ? { id: item.boardPrefixID!, boardID: item.boardID, name: item.boardPrefixName, color: null, posts: 0 } : null,
 		content: '',
 		blames: 0,

+ 18 - 9
app/component/Layout.tsx

@@ -2,7 +2,7 @@
 
 import { useState, useCallback, useEffect } from 'react';
 import Link from 'next/link';
-import { usePathname } from 'next/navigation';
+import { usePathname, useRouter } from 'next/navigation';
 import Styles from '../styles/common.module.scss';
 import useAuth from '@/hooks/useAuth';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -19,6 +19,7 @@ export default function Layout({ children }: Props) {
 	const [sidebarOpen, setSidebarOpen] = useState(false);
 	const [chatOpen, setChatOpen] = useState(false);
 	const pathname = usePathname();
+	const router = useRouter();
 
 	const toggleSidebar = useCallback(() => {
 		setSidebarOpen((prev) => !prev);
@@ -72,13 +73,11 @@ export default function Layout({ children }: Props) {
                                     코인
                                 </Link>
                             </li>
-							{/*
                             <li>
                                 <Link href='/latest'>
                                     토론
                                 </Link>
                             </li>
-							*/}
                             <li>
                                 <Link href='/news'>
                                     뉴스
@@ -160,24 +159,34 @@ export default function Layout({ children }: Props) {
 
 				{/* 모바일 하단 탭바 */}
 				<nav className={Styles.bottomTab}>
-					<Link href='/' className={pathname === '/' ? Styles.active : ''}>
+					<button
+						type='button'
+						className={pathname === '/' ? Styles.active : undefined}
+						onClick={() => {
+							if (pathname === '/') {
+								window.dispatchEvent(new CustomEvent('crypto:showList'));
+							} else {
+								router.push('/');
+							}
+						}}
+					>
 						<FontAwesomeIcon icon={faCoins} />
 						<span>코인</span>
-					</Link>
-					<Link href='/latest' className={pathname.startsWith('/latest') || (pathname.startsWith('/board') && !pathname.startsWith('/board/notice')) || pathname.startsWith('/post') ? Styles.active : ''}>
+					</button>
+					<Link href='/latest' className={pathname.startsWith('/latest') || (pathname.startsWith('/board') && !pathname.startsWith('/board/notice')) || pathname.startsWith('/post') ? Styles.active : undefined}>
 						<FontAwesomeIcon icon={faComments} />
 						<span>토론</span>
 					</Link>
-					<Link href='/news' className={pathname.startsWith('/news') ? Styles.active : ''}>
+					<Link href='/news' className={pathname.startsWith('/news') ? Styles.active : undefined}>
 						<FontAwesomeIcon icon={faNewspaper} />
 						<span>뉴스</span>
 					</Link>
-					<Link href='/board/notice' className={pathname.startsWith('/board/notice') || pathname.startsWith('/support') ? Styles.active : ''}>
+					<Link href='/board/notice' className={pathname.startsWith('/board/notice') || pathname.startsWith('/support') ? Styles.active : undefined}>
 						<FontAwesomeIcon icon={faHeadset} />
 						<span>고객지원</span>
 					</Link>
 					{isAuthenticated ? (
-						<Link href='/profile' className={pathname.startsWith('/profile') ? Styles.active : ''}>
+						<Link href='/profile' className={pathname.startsWith('/profile') ? Styles.active : undefined}>
 							<FontAwesomeIcon icon={faUser} />
 							<span>내 정보</span>
 						</Link>

+ 65 - 4
app/component/crypto/CryptoPageContent.tsx

@@ -1,31 +1,92 @@
 'use client';
 
-import { useEffect, useState } from 'react';
+import { useEffect, useState, useCallback } from 'react';
 import { createPortal } from 'react-dom';
-import { CryptoProvider } from '@/contexts/cryptoProvider';
+import { CryptoProvider, useCryptoContext } from '@/contexts/cryptoProvider';
 import CryptoDashboard from './CryptoDashboard';
 import CryptoSidebar from './CryptoSidebar';
+import MobileCoinList from './MobileCoinList';
 import PopupModal from '@/app/component/PopupModal';
 import Styles from '@/app/styles/common.module.scss';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faChevronLeft } from '@fortawesome/free-solid-svg-icons';
 import type { TickerRestData } from '@/types/crypto';
+import './mobile-coin-list.scss';
 
 type Props = {
 	initialTickers: TickerRestData[];
 };
 
+function MobileBackButton({ onBack }: { onBack: () => void }) {
+	const { selectedMarket, tickerMeta } = useCryptoContext();
+	const meta = tickerMeta.get(selectedMarket);
+	const displayName = meta?.korName ?? selectedMarket.split('-')[1] ?? selectedMarket;
+
+	return (
+		<div className='mobile-back-bar'>
+			<button type='button' onClick={onBack}>
+				<FontAwesomeIcon icon={faChevronLeft} />
+				{displayName}
+			</button>
+		</div>
+	);
+}
+
 export default function CryptoPageContent({ initialTickers }: Props) {
 	const [containerEl, setContainerEl] = useState<HTMLElement | null>(null);
+	const [isMobile, setIsMobile] = useState(false);
+	const [mobileView, setMobileView] = useState<'list' | 'detail'>('list');
 
 	useEffect(() => {
 		setContainerEl(document.getElementById('container'));
 		return () => setContainerEl(null);
 	}, []);
 
+	useEffect(() => {
+		const mq = window.matchMedia('(max-width: 1125px)');
+		setIsMobile(mq.matches);
+		const handler = (e: MediaQueryListEvent) => {
+			setIsMobile(e.matches);
+			if (!e.matches) setMobileView('list');
+		};
+		mq.addEventListener('change', handler);
+		return () => mq.removeEventListener('change', handler);
+	}, []);
+
+	// 하단 탭바 "코인" 클릭 시 목록으로 복귀
+	useEffect(() => {
+		const handler = () => setMobileView('list');
+		window.addEventListener('crypto:showList', handler);
+		return () => window.removeEventListener('crypto:showList', handler);
+	}, []);
+
+	const handleSelectCoin = useCallback(() => {
+		setMobileView('detail');
+	}, []);
+
+	const handleBack = useCallback(() => {
+		setMobileView('list');
+	}, []);
+
 	return (
 		<CryptoProvider>
 			<PopupModal position='main' />
-			<CryptoDashboard />
-			{containerEl && createPortal(
+			{isMobile ? (
+				mobileView === 'list' ? (
+					<MobileCoinList
+						initialTickers={initialTickers}
+						onSelectCoin={handleSelectCoin}
+					/>
+				) : (
+					<>
+						<MobileBackButton onBack={handleBack} />
+						<CryptoDashboard />
+					</>
+				)
+			) : (
+				<CryptoDashboard />
+			)}
+			{!isMobile && containerEl && createPortal(
 				<aside id='aside' className={Styles.aside}>
 					<CryptoSidebar initialTickers={initialTickers} />
 				</aside>,

+ 1 - 24
app/component/crypto/CryptoSidebar.tsx

@@ -5,6 +5,7 @@ import { useCryptoContext } from '@/contexts/cryptoProvider';
 import useTickers from '@/hooks/useTickers';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { faCaretUp, faCaretDown } from '@fortawesome/free-solid-svg-icons';
+import { formatPrice, formatChangeRate, getChangeClass } from '@/lib/utils/crypto';
 import type { TickerRestData } from '@/types/crypto';
 import './sidebar.scss';
 
@@ -18,30 +19,6 @@ type Props = {
 	initialTickers?: TickerRestData[];
 };
 
-function formatPrice(price: number): string {
-	if (price >= 1000) {
-		return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
-	}
-	if (price >= 1) {
-		return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
-	}
-	if (price >= 0.01) {
-		return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
-	}
-	return price.toLocaleString('ko-KR', { maximumFractionDigits: 8 });
-}
-
-function formatChangeRate(rate: number): string {
-	const pct = (rate * 100).toFixed(2);
-	return rate >= 0 ? `+${pct}%` : `${pct}%`;
-}
-
-function getChangeClass(change: string): string {
-	if (change === 'RISE') return 'up';
-	if (change === 'FALL') return 'down';
-	return 'neutral';
-}
-
 export default function CryptoSidebar({ initialTickers }: Props) {
 	const { selectedMarket, setSelectedMarket, quoteMarket, setQuoteMarket, setTickers: setContextTickers, setTickerMeta: setContextMeta } = useCryptoContext();
 	const { tickers, meta } = useTickers(quoteMarket, quoteMarket === 'KRW' ? initialTickers : undefined);

+ 195 - 0
app/component/crypto/MobileCoinList.tsx

@@ -0,0 +1,195 @@
+'use client';
+
+import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
+import { useCryptoContext } from '@/contexts/cryptoProvider';
+import useTickers from '@/hooks/useTickers';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faCaretUp, faCaretDown } from '@fortawesome/free-solid-svg-icons';
+import { formatPrice, formatChangeRate, getChangeClass, formatVolumeMillions } from '@/lib/utils/crypto';
+import type { TickerRestData } from '@/types/crypto';
+import './mobile-coin-list.scss';
+
+const QUOTE_TABS = ['KRW', 'BTC', 'USDT'] as const;
+
+type SortKey = 'name' | 'price' | 'change' | 'volume';
+type SortDir = 'asc' | 'desc';
+type NameMode = 'kor' | 'eng';
+
+type Props = {
+	initialTickers?: TickerRestData[];
+	onSelectCoin: (market: string) => void;
+};
+
+export default function MobileCoinList({ initialTickers, onSelectCoin }: Props) {
+	const { selectedMarket, setSelectedMarket, quoteMarket, setQuoteMarket, setTickers: setContextTickers, setTickerMeta: setContextMeta } = useCryptoContext();
+	const { tickers, meta } = useTickers(quoteMarket, quoteMarket === 'KRW' ? initialTickers : undefined);
+	const [search, setSearch] = useState('');
+	const [sortKey, setSortKey] = useState<SortKey | null>(null);
+	const [sortDir, setSortDir] = useState<SortDir>('desc');
+	const [nameMode, setNameMode] = useState<NameMode>('kor');
+	const prevPricesRef = useRef<Map<string, number>>(new Map());
+
+	const handleSort = useCallback((key: SortKey) => {
+		if (key === 'name' && sortKey === 'name') {
+			setNameMode((prev) => prev === 'kor' ? 'eng' : 'kor');
+		} else if (sortKey === key) {
+			setSortDir((prev) => prev === 'desc' ? 'asc' : 'desc');
+		} else {
+			setSortKey(key);
+			setSortDir(key === 'name' ? 'asc' : 'desc');
+		}
+	}, [sortKey]);
+
+	useEffect(() => {
+		setContextTickers(tickers);
+	}, [tickers, setContextTickers]);
+
+	useEffect(() => {
+		setContextMeta(meta);
+	}, [meta, setContextMeta]);
+
+	const sortedTickers = useMemo(() => {
+		const arr = Array.from(tickers.values());
+		const keyword = search.toLowerCase().trim();
+
+		const filtered = keyword
+			? arr.filter((t) => {
+				const m = meta.get(t.market);
+				return t.symbol.toLowerCase().includes(keyword) ||
+					t.market.toLowerCase().includes(keyword) ||
+					(m?.korName && m.korName.includes(keyword));
+			})
+			: arr;
+
+		if (!sortKey) {
+			return filtered.sort((a, b) => b.accTradePrice24h - a.accTradePrice24h);
+		}
+
+		const dir = sortDir === 'asc' ? 1 : -1;
+		return filtered.sort((a, b) => {
+			if (sortKey === 'name') {
+				const aName = nameMode === 'kor' ? (meta.get(a.market)?.korName ?? a.symbol) : a.symbol;
+				const bName = nameMode === 'kor' ? (meta.get(b.market)?.korName ?? b.symbol) : b.symbol;
+				return dir * aName.localeCompare(bName, nameMode === 'kor' ? 'ko' : 'en');
+			}
+			if (sortKey === 'price') {
+				return dir * (a.tradePrice - b.tradePrice);
+			}
+			if (sortKey === 'change') {
+				return dir * (a.signedChangeRate - b.signedChangeRate);
+			}
+			return dir * (a.accTradePrice24h - b.accTradePrice24h);
+		});
+	}, [tickers, search, meta, sortKey, sortDir, nameMode]);
+
+	// 시세 변동 보더 플래시 애니메이션
+	useEffect(() => {
+		for (const [market, ticker] of tickers) {
+			const prev = prevPricesRef.current.get(market);
+			if (prev !== undefined && prev !== ticker.tradePrice) {
+				const direction = ticker.tradePrice > prev ? 'up' : 'down';
+				const el = document.querySelector(`[data-mcl-market="${market}"]`) as HTMLElement | null;
+				if (el) {
+					el.classList.remove('flash-up', 'flash-down');
+					void el.offsetWidth;
+					el.classList.add(`flash-${direction}`);
+				}
+			}
+			prevPricesRef.current.set(market, ticker.tradePrice);
+		}
+	}, [tickers]);
+
+	const handleCoinClick = useCallback((market: string) => {
+		setSelectedMarket(market);
+		onSelectCoin(market);
+	}, [setSelectedMarket, onSelectCoin]);
+
+	return (
+		<div className='mobile-coin-list'>
+			<div className='mcl-tabs'>
+				{QUOTE_TABS.map((tab) => (
+					<button
+						key={tab}
+						type='button'
+						className={`tab ${quoteMarket === tab ? 'active' : ''}`}
+						onClick={() => setQuoteMarket(tab)}
+					>
+						{tab}
+					</button>
+				))}
+			</div>
+			<div className='mcl-search'>
+				<input
+					type='text'
+					placeholder='코인명/심볼 검색'
+					value={search}
+					onChange={(e) => setSearch(e.target.value)}
+				/>
+			</div>
+			<div className='mcl-sort'>
+				<button
+					type='button'
+					className={`sort-name ${sortKey === 'name' ? 'active' : ''}`}
+					onClick={() => handleSort('name')}
+				>
+					{nameMode === 'kor' ? '한글명' : '영문명'}
+				</button>
+				<button
+					type='button'
+					className={`sort-price ${sortKey === 'price' ? 'active' : ''}`}
+					onClick={() => handleSort('price')}
+				>
+					현재가
+					<FontAwesomeIcon icon={sortKey === 'price' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
+				</button>
+				<button
+					type='button'
+					className={`sort-change ${sortKey === 'change' ? 'active' : ''}`}
+					onClick={() => handleSort('change')}
+				>
+					전일대비
+					<FontAwesomeIcon icon={sortKey === 'change' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
+				</button>
+				<button
+					type='button'
+					className={`sort-volume ${sortKey === 'volume' ? 'active' : ''}`}
+					onClick={() => handleSort('volume')}
+				>
+					거래대금
+					<FontAwesomeIcon icon={sortKey === 'volume' && sortDir === 'asc' ? faCaretUp : faCaretDown} />
+				</button>
+			</div>
+			<div className='mcl-list'>
+				{sortedTickers.map((ticker) => {
+					const tickerMeta = meta.get(ticker.market);
+					return (
+						<button
+							key={ticker.market}
+							type='button'
+							className={`mcl-item ${selectedMarket === ticker.market ? 'active' : ''}`}
+							data-mcl-market={ticker.market}
+							onClick={() => handleCoinClick(ticker.market)}
+						>
+							<div className='col-name'>
+								<span className='name'>{nameMode === 'kor' ? (tickerMeta?.korName ?? ticker.symbol) : ticker.symbol}</span>
+								<span className='symbol'>{ticker.symbol}/{quoteMarket}</span>
+							</div>
+							<div className={`col-price ${getChangeClass(ticker.change)}`}>
+								{formatPrice(ticker.tradePrice)}
+							</div>
+							<div className={`col-change ${getChangeClass(ticker.change)}`}>
+								{formatChangeRate(ticker.signedChangeRate)}
+							</div>
+							<div className='col-volume'>
+								{formatVolumeMillions(ticker.accTradePrice24h)}
+							</div>
+						</button>
+					);
+				})}
+				{sortedTickers.length === 0 && (
+					<div className='mcl-empty'>검색 결과가 없습니다.</div>
+				)}
+			</div>
+		</div>
+	);
+}

+ 264 - 0
app/component/crypto/mobile-coin-list.scss

@@ -0,0 +1,264 @@
+.mobile-coin-list {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	background: #fafafa;
+
+	.mcl-tabs {
+		display: flex;
+		border-bottom: 1px solid #eee;
+
+		.tab {
+			flex: 1;
+			padding: 10px 0;
+			font-size: 0.813rem;
+			font-weight: 600;
+			color: #999;
+			background: transparent;
+			border: none;
+			border-bottom: 2px solid transparent;
+			cursor: pointer;
+			transition: color 0.15s, border-color 0.15s;
+
+			&:hover {
+				color: #232323;
+			}
+
+			&.active {
+				color: #F7931A;
+				border-bottom-color: #F7931A;
+			}
+		}
+	}
+
+	.mcl-search {
+		padding: 8px 12px;
+		border-bottom: 1px solid #eee;
+
+		input {
+			width: 100%;
+			padding: 8px 12px;
+			font-size: 0.875rem;
+			border: 1px solid #ddd;
+			border-radius: 4px;
+			outline: none;
+
+			&:focus {
+				border-color: #F7931A;
+			}
+		}
+	}
+
+	.mcl-sort {
+		display: flex;
+		border-bottom: 1px solid #eee;
+		background: #f5f5f5;
+		padding: 0 12px;
+
+		button {
+			padding: 6px 0;
+			font-size: 0.688rem;
+			font-weight: 500;
+			color: #888;
+			background: transparent;
+			border: none;
+			cursor: pointer;
+			display: flex;
+			align-items: center;
+			justify-content: flex-start;
+			gap: 3px;
+			transition: color 0.15s;
+
+			&:hover {
+				color: #333;
+			}
+
+			&.active {
+				color: #F7931A;
+				font-weight: 700;
+			}
+
+			svg {
+				font-size: 0.625rem;
+			}
+		}
+
+		.sort-name {
+			flex: 2;
+		}
+
+		.sort-price {
+			flex: 1.5;
+			justify-content: flex-end;
+		}
+
+		.sort-change {
+			flex: 1;
+			justify-content: flex-end;
+		}
+
+		.sort-volume {
+			flex: 1.5;
+			justify-content: flex-end;
+		}
+	}
+
+	.mcl-list {
+		flex: 1;
+		overflow-y: auto;
+		-webkit-overflow-scrolling: touch;
+	}
+
+	.mcl-item {
+		display: flex;
+		align-items: center;
+		width: 100%;
+		padding: 10px 12px;
+		border: 1px solid transparent;
+		border-bottom: 1px solid #f0f0f0;
+		background: transparent;
+		cursor: pointer;
+		text-align: left;
+		transition: background 0.15s;
+
+		&:hover {
+			background: #f0f0f0;
+		}
+
+		&:active {
+			background: #e8e8e8;
+		}
+
+		&.active {
+			background: #fff3e0;
+			border-left: 3px solid #F7931A;
+		}
+
+		.col-name {
+			flex: 2;
+			display: flex;
+			flex-direction: column;
+			gap: 1px;
+			min-width: 0;
+
+			.name {
+				font-weight: 600;
+				font-size: 0.813rem;
+				color: #222;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+			}
+
+			.symbol {
+				font-size: 0.688rem;
+				color: #999;
+			}
+		}
+
+		.col-price {
+			flex: 1.5;
+			text-align: right;
+			font-size: 0.813rem;
+			font-weight: 600;
+			font-variant-numeric: tabular-nums;
+		}
+
+		.col-change {
+			flex: 1;
+			text-align: right;
+			font-size: 0.75rem;
+			font-weight: 500;
+			font-variant-numeric: tabular-nums;
+		}
+
+		.col-volume {
+			flex: 1.5;
+			text-align: right;
+			font-size: 0.688rem;
+			color: #666;
+			font-variant-numeric: tabular-nums;
+		}
+
+		.up {
+			color: hsl(var(--crypto-up));
+		}
+
+		.down {
+			color: hsl(var(--crypto-down));
+		}
+
+		.neutral {
+			color: hsl(var(--crypto-neutral));
+		}
+	}
+
+	.mcl-empty {
+		padding: 24px;
+		text-align: center;
+		color: #999;
+		font-size: 0.875rem;
+	}
+}
+
+// 시세 변동 보더 플래시 애니메이션
+.mcl-item.flash-up {
+	animation: mcl-flash-up 0.6s ease-out;
+}
+
+.mcl-item.flash-down {
+	animation: mcl-flash-down 0.6s ease-out;
+}
+
+@keyframes mcl-flash-up {
+	0% {
+		border-color: hsl(var(--crypto-up));
+		box-shadow: inset 0 0 0 1px hsl(var(--crypto-up) / 0.3);
+	}
+	100% {
+		border-color: transparent;
+		box-shadow: none;
+	}
+}
+
+@keyframes mcl-flash-down {
+	0% {
+		border-color: hsl(var(--crypto-down));
+		box-shadow: inset 0 0 0 1px hsl(var(--crypto-down) / 0.3);
+	}
+	100% {
+		border-color: transparent;
+		box-shadow: none;
+	}
+}
+
+// 모바일 뒤로가기 바
+.mobile-back-bar {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+	padding: 10px 12px;
+	background: #fff;
+	border-bottom: 1px solid #eee;
+
+	button {
+		display: flex;
+		align-items: center;
+		gap: 6px;
+		padding: 4px 8px;
+		font-size: 0.875rem;
+		font-weight: 600;
+		color: #333;
+		background: transparent;
+		border: none;
+		cursor: pointer;
+
+		&:hover {
+			color: #F7931A;
+		}
+
+		svg {
+			font-size: 0.75rem;
+		}
+	}
+}

+ 17 - 9
app/styles/common.module.scss

@@ -23,6 +23,15 @@
         grid-template-columns: 1fr;
     }
 
+    // aside가 없을 때 햄버거 숨기기 (모바일 코인 목록 뷰)
+    &:not(:has(> .aside)) {
+        > .header .hamburger {
+            @media (max-width: 1125px) {
+                display: none !important;
+            }
+        }
+    }
+
     > .header {
         grid-area: header;
         height: 56px;
@@ -176,10 +185,6 @@
         grid-area: main;
         overflow-y: auto;
         border-bottom: 1px solid #dedede;
-
-        @media (max-width: 1125px) {
-            padding-bottom: 56px;
-        }
     }
 
     // 우측 사이드바 (채팅)
@@ -296,7 +301,7 @@
             justify-content: space-around;
             align-items: center;
 
-            a {
+            button, a {
                 display: flex;
                 flex-direction: column;
                 align-items: center;
@@ -307,6 +312,9 @@
                 text-decoration: none;
                 flex: 1;
                 height: 100%;
+                border: none;
+                background: transparent;
+                cursor: pointer;
 
                 svg {
                     font-size: 1.25rem;
@@ -315,11 +323,11 @@
                 &:hover {
                     color: #555;
                 }
+            }
 
-                &.active {
-                    color: #333;
-                    font-weight: 600;
-                }
+            button.active, a.active {
+                color: #333;
+                font-weight: 600;
             }
         }
     }

+ 22 - 1
lib/api/forum/board.ts

@@ -1,7 +1,8 @@
 'use server';
 
 import {
-    BoardPostsRequest
+    BoardPostsRequest,
+    AllPostsRequest
 } from '@/types/request/forum/board';
 
 import {
@@ -37,6 +38,26 @@ export async function fetchBoardList(boardGroupCode?: string): Promise<ResultDto
     });
 }
 
+// 전체 게시글 조회
+export async function fetchAllPosts(params: AllPostsRequest): Promise<ResultDto<BoardPostsResponse>>
+{
+    const queryParams = new URLSearchParams();
+
+    queryParams.set('page', String(params.page));
+    queryParams.set('perPage', String(params.perPage));
+
+    if (params.sort !== undefined && params.sort !== null) queryParams.set('sort', String(params.sort));
+    if (params.search !== undefined && params.search !== null) queryParams.set('search', String(params.search));
+    if (params.keyword) queryParams.set('keyword', params.keyword);
+
+    return await fetchJson<BoardPostsResponse>(`/api/forum/posts?${queryParams.toString()}`, {
+        method: 'GET',
+        headers: {
+            'Accept': 'application/json'
+        }
+    });
+}
+
 // 게시판 게시글 조회
 export async function fetchBoardPosts(params: BoardPostsRequest): Promise<ResultDto<BoardPostsResponse>>
 {

+ 28 - 0
lib/utils/crypto.ts

@@ -0,0 +1,28 @@
+export function formatPrice(price: number): string {
+	if (price >= 1000) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
+	}
+	if (price >= 1) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
+	}
+	if (price >= 0.01) {
+		return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
+	}
+	return price.toLocaleString('ko-KR', { maximumFractionDigits: 8 });
+}
+
+export function formatChangeRate(rate: number): string {
+	const pct = (rate * 100).toFixed(2);
+	return rate >= 0 ? `+${pct}%` : `${pct}%`;
+}
+
+export function getChangeClass(change: string): string {
+	if (change === 'RISE') return 'up';
+	if (change === 'FALL') return 'down';
+	return 'neutral';
+}
+
+export function formatVolumeMillions(volume: number): string {
+	const millions = Math.floor(volume / 1_000_000);
+	return millions.toLocaleString('ko-KR') + '백만';
+}

+ 1 - 0
types/forum/post.ts

@@ -5,6 +5,7 @@ export default interface Post {
     num: number;
     id: number;
     boardID: number;
+    boardName: string;
     boardPrefixID: number|null;
 	boardPrefix: BoardPrefix|null;
     memberID: number|null;

+ 9 - 0
types/request/forum/board.ts

@@ -23,4 +23,13 @@ export interface BoardPostsRequest {
 	sort?: BoardSort|null;
 	search?: PostSearchType|null;
 	keyword?: string|null;
+};
+
+// 전체 게시글 목록 조회
+export interface AllPostsRequest {
+	page: number;
+	perPage: number;
+	sort?: BoardSort|null;
+	search?: PostSearchType|null;
+	keyword?: string|null;
 };