Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/entities/post/api/getBookmarkedPosts.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { instance } from '@/shared/api/ky'
import {
BookmarkedPostSchema,
PostListItemSchema,
ApiResponseSchema,
} from '@/entities/post/model/schema'
import type { BookmarkedPost } from '@/entities/post/model/types'
import type { PostListItem } from '@/entities/post/model/types'

export async function getBookmarkedPosts(): Promise<BookmarkedPost[]> {
export async function getBookmarkedPosts(): Promise<PostListItem[]> {
const response = await instance.get('api/v1/posts/bookmarks')

const raw = await response.json()

const parsed = ApiResponseSchema(BookmarkedPostSchema.array()).parse(raw)
const parsed = ApiResponseSchema(PostListItemSchema.array()).parse(raw)

if (!parsed.success) {
throw new Error(parsed.message || 'Failed to load bookmarked posts')
Expand Down
20 changes: 20 additions & 0 deletions src/entities/post/api/getExplorePosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { instance } from '@/shared/api/ky'
import {
PostListItemSchema,
ApiResponseSchema,
} from '@/entities/post/model/schema'
import type { PostListItem } from '@/entities/post/model/types'

export async function getExplorePosts(): Promise<PostListItem[]> {
const response = await instance.get('api/v1/posts/search')

const raw = await response.json()

const parsed = ApiResponseSchema(PostListItemSchema.array()).parse(raw)

if (!parsed.success) {
throw new Error(parsed.message || 'Failed to load explore posts')
}

return parsed.data
}
4 changes: 2 additions & 2 deletions src/entities/post/model/hooks/useBookmarkedPostsQuery.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query'
import { getBookmarkedPosts } from '@/entities/post/api/getBookmarkedPosts'
import type { BookmarkedPost } from '@/entities/post/model/types'
import type { PostListItem } from '@/entities/post/model/types'

export function useBookmarkedPostsQuery() {
return useQuery<BookmarkedPost[]>({
return useQuery<PostListItem[]>({
queryKey: ['posts', 'bookmarks'],
queryFn: () => getBookmarkedPosts(),
retry: false,
Expand Down
11 changes: 11 additions & 0 deletions src/entities/post/model/hooks/useExplorePostsQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { getExplorePosts } from '@/entities/post/api/getExplorePosts'
import type { PostListItem } from '@/entities/post/model/types'

export function useExplorePostsQuery() {
return useQuery<PostListItem[]>({
queryKey: ['posts', 'explore'],
queryFn: () => getExplorePosts(),
retry: false,
})
}
2 changes: 1 addition & 1 deletion src/entities/post/model/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const PostImageSchema = z.object({
orderIndex: z.number(),
})

export const BookmarkedPostSchema = z.object({
export const PostListItemSchema = z.object({
id: z.number(),
userId: z.number(),
nickname: z.string(),
Expand Down
4 changes: 2 additions & 2 deletions src/entities/post/model/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { z } from 'zod'
import {
BookmarkedPostSchema,
PostListItemSchema,
PostImageSchema,
ApiResponseSchema,
} from './schema'

export type PostImage = z.infer<typeof PostImageSchema>
export type BookmarkedPost = z.infer<typeof BookmarkedPostSchema>
export type PostListItem = z.infer<typeof PostListItemSchema>

export type ApiResponse<T> = z.infer<
ReturnType<typeof ApiResponseSchema<z.ZodType<T>>>
Expand Down
76 changes: 76 additions & 0 deletions src/features/explore/ui/ExplorePostGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useMemo } from 'react'

import { cn } from '@/shared/lib/utils'
import type { PostListItem } from '@/entities/post/model/types'

import { ExplorePostTile } from './ExplorePostTile'

export type LayoutTile = {
item: PostListItem
rowSpan: 1 | 2
}

function buildExploreLayout(items: PostListItem[]): LayoutTile[] {
const n = items.length
if (n === 0) return []

const tailSize = Math.min(6, n)
const headEnd = n - tailSize

const layout: LayoutTile[] = []

let i = 0
let blockIndex = 0

while (i < headEnd) {
const remain = headEnd - i

if (remain >= 5) {
const bigIndex = blockIndex % 2 === 0 ? 0 : 2

for (let j = 0; j < 5; j++) {
layout.push({
item: items[i + j],
rowSpan: j === bigIndex ? 2 : 1,
})
}

i += 5
blockIndex++
continue
}

for (; i < headEnd; i++) {
layout.push({ item: items[i], rowSpan: 1 })
}
}

for (let k = headEnd; k < n; k++) {
layout.push({ item: items[k], rowSpan: 1 })
}

return layout
}

export type ExplorePostGridProps = {
className?: string
items: PostListItem[]
}

export function ExplorePostGrid({ className, items }: ExplorePostGridProps) {
const layout = useMemo(() => buildExploreLayout(items), [items])

return (
<div
className={cn(
'grid w-full grid-flow-dense grid-cols-3 gap-0.5',
className
)}
role="list"
>
{layout.map(({ item, rowSpan }) => (
<ExplorePostTile key={item.id} item={item} rowSpan={rowSpan} />
))}
</div>
)
}
82 changes: 82 additions & 0 deletions src/features/explore/ui/ExplorePostTile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Heart, MessageCircle } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useNavigate, useLocation } from '@tanstack/react-router'

import { cn } from '@/shared/lib/utils'
import { ImageFallback } from '@/shared/ui/image-fallback'
import type { PostListItem } from '@/entities/post/model/types'

type ExplorePostTileProps = {
className?: string
item: PostListItem
rowSpan: 1 | 2
}

export function ExplorePostTile({
className,
item,
rowSpan,
}: ExplorePostTileProps) {
const [isImageError, setIsImageError] = useState(false)
const navigate = useNavigate()
const location = useLocation()

const thumbnailUrl = item.images[0]?.url
const ariaLabel = useMemo(
() =>
`게시글: ${item.content || '내용 없음'}, 좋아요 ${item.likeCount}개, 댓글 ${item.commentCount}개`,
[item.commentCount, item.content, item.likeCount]
)

const handleClick = () => {
navigate({
to: '/p/$post_id',
params: { post_id: String(item.id) },
search: {
returnToPath: location.pathname,
returnToSearch: location.search,
},
})
}

return (
<button
type="button"
className={cn(
'group relative w-full overflow-hidden bg-neutral-200',
rowSpan === 1 ? 'aspect-square' : 'row-span-2 h-full min-h-0',
className
)}
aria-label={ariaLabel}
onClick={handleClick}
>
{thumbnailUrl && !isImageError ? (
<img
src={thumbnailUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
onError={() => setIsImageError(true)}
/>
) : (
<ImageFallback
className="absolute inset-0 h-full w-full"
ariaLabel="게시물 이미지 없음"
/>
)}

<div className="pointer-events-none absolute inset-0 flex items-center justify-center gap-6 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<div className="flex items-center gap-2 text-white">
<Heart className="size-5" aria-hidden />
<span className="text-sm font-semibold">{item.likeCount}</span>
<span className="sr-only">좋아요</span>
</div>
<div className="flex items-center gap-2 text-white">
<MessageCircle className="size-5" aria-hidden />
<span className="text-sm font-semibold">{item.commentCount}</span>
<span className="sr-only">댓글</span>
</div>
</div>
</button>
)
}
28 changes: 28 additions & 0 deletions src/mocks/db/post.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,32 @@ export const posts = [
likeCount: 120,
commentCount: 8,
},
{
id: '21',
images: [
'https://picsum.photos/id/70/800/800',
'https://picsum.photos/id/71/800/800',
'https://picsum.photos/id/72/800/800',
],
caption: '여러 장의 사진 테스트입니다.',
username: 'test_user21',
userImage: 'https://picsum.photos/id/84/50/50',
createdAt: '2024-03-20T10:00:00Z',
likeCount: 120,
commentCount: 8,
},
{
id: '22',
images: [
'https://picsum.photos/id/73/800/800',
'https://picsum.photos/id/74/800/800',
'https://picsum.photos/id/75/800/800',
],
caption: '여러 장의 사진 테스트입니다.',
username: 'test_user22',
userImage: 'https://picsum.photos/id/85/50/50',
createdAt: '2024-03-20T10:00:00Z',
likeCount: 120,
commentCount: 8,
},
]
32 changes: 32 additions & 0 deletions src/mocks/handlers/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,36 @@ export const postHandlers = [
success: true,
})
}),
http.get('*/api/v1/posts/search', () => {
const searchResults = posts.map((p, index) => {
const postId = Number(p.id)
const userId = 1
return {
id: postId,
userId,
nickname: p.username,
profileImageUrl: p.userImage,
content: p.caption,
albumId: null as number | null,
images: (p.images ?? []).map((url, imgIndex) => ({
id: postId * 100 + imgIndex,
url,
orderIndex: imgIndex,
})),
likeCount: p.likeCount,
commentCount: p.commentCount,
createdAt: p.createdAt,
updatedAt: p.createdAt,
liked: index % 2 === 0,
bookmarked: index % 3 === 0,
}
})

return HttpResponse.json({
code: '200',
message: '요청에 성공하였습니다.',
data: searchResults,
success: true,
})
}),
]
26 changes: 26 additions & 0 deletions src/pages/ExplorePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ContentContainer } from '@/widgets/profile-layout'
import { useExplorePostsQuery } from '@/entities/post/model/hooks/useExplorePostsQuery'
import { AppFooter } from '@/shared/ui/app-footer'

import { ExplorePostGrid } from '@/features/explore/ui/ExplorePostGrid'

export function ExplorePage() {
const { data: posts = [], isLoading } = useExplorePostsQuery()

if (isLoading) {
return (
<ContentContainer className="py-6">
<div className="py-12 text-center text-sm text-zinc-500">
로딩 중...
</div>
</ContentContainer>
)
}

return (
<ContentContainer className="flex flex-col gap-0 py-6">
<ExplorePostGrid items={posts} />
<AppFooter />
</ContentContainer>
)
}
4 changes: 2 additions & 2 deletions src/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import LoginCard from '../components/auth/LoginCard'
import SignupCard from '../components/auth/SignupCard'
import LoginFooter from '../components/auth/LoginFooter'
import { AppFooter } from '@/shared/ui/app-footer'
import LoginVisual from '../components/auth/LoginVisual'
import LocationSelectView from '../components/auth/LocationSelectView'
import CitySelectView from '../components/auth/CitySelectView'
Expand Down Expand Up @@ -51,7 +51,7 @@ const LoginPage = () => {
<LiteDownloadView onBack={() => setView('login')} />
)}
</main>
<LoginFooter
<AppFooter
onLocationClick={() => setView('location')}
onLiteClick={() => setView('lite')}
/>
Expand Down
6 changes: 3 additions & 3 deletions src/routes/_app/$profile_name/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'

import { ProfilePostsGrid } from '@/features/profile-posts/ui/ProfilePostsGrid'
import { ProfileContentContainer } from '@/widgets/profile-layout'
import { ContentContainer } from '@/widgets/profile-layout'

export const Route = createFileRoute('/_app/$profile_name/')({
component: RouteComponent,
Expand All @@ -25,8 +25,8 @@ const FALLBACK_POST_ITEMS = Array.from(

function RouteComponent() {
return (
<ProfileContentContainer className="py-6">
<ContentContainer className="py-6">
<ProfilePostsGrid items={FALLBACK_POST_ITEMS} />
</ProfileContentContainer>
</ContentContainer>
)
}
Loading