diff --git a/src/app/globals.css b/src/app/globals.css index ef0cae6a..d2c0510a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,4 +2,5 @@ @import '../styles/colors.css'; @import '../styles/typography.css'; +@import '../styles/animations.css'; @import '../styles/base.css'; diff --git a/src/domains/home/components/bookmark-container/bookmark-container.tsx b/src/domains/home/components/bookmark-container/bookmark-container.tsx index a6f8192e..7e1fc6c4 100644 --- a/src/domains/home/components/bookmark-container/bookmark-container.tsx +++ b/src/domains/home/components/bookmark-container/bookmark-container.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import { cn } from '@/lib/cn'; -import { BookmarkIcon } from '@/shared/components/icons'; +import { BookmarkButton } from '@/shared/components/ui'; type BookmarkOverlayVariant = 'card' | 'summary'; @@ -29,24 +29,11 @@ export const BookmarkContainer = ({
{children} - +
); }; diff --git a/src/domains/posts/components/post-detail-profile-header/post-detail-profile-header.tsx b/src/domains/posts/components/post-detail-profile-header/post-detail-profile-header.tsx index 7f079ba9..517ea6d2 100644 --- a/src/domains/posts/components/post-detail-profile-header/post-detail-profile-header.tsx +++ b/src/domains/posts/components/post-detail-profile-header/post-detail-profile-header.tsx @@ -8,11 +8,9 @@ import { POST_MUTATION_OPTIONS } from '@/domains/posts/api/query'; import { PostRecruitmentStatusBottomSheet } from '@/domains/posts/components/post-recruitment-status-bottom-sheet/post-recruitment-status-bottom-sheet'; import { PostRecruitmentStatusButton } from '@/domains/posts/components/post-recruitment-status-button/post-recruitment-status-button'; import type { PostRecruitmentStatusTypes } from '@/domains/posts/model/post-recruitment-status'; -import { cn } from '@/lib/cn'; import { POST_QUERY_KEY, RECOMMENDATION_QUERY_KEY } from '@/shared/api'; import { defaultProfileImage } from '@/shared/assets/illustrations'; -import { BookmarkIcon } from '@/shared/components/icons'; -import { useToast } from '@/shared/components/ui'; +import { BookmarkButton, useToast } from '@/shared/components/ui'; import { CommonImage } from '@/shared/components/ui/common-image/common-image'; import { ROUTES } from '@/shared/config'; @@ -146,20 +144,11 @@ export const PostDetailProfileHeader = ({ /> ) : ( - + /> )} ); diff --git a/src/shared/components/ui/bookmark-button/bookmark-button.tsx b/src/shared/components/ui/bookmark-button/bookmark-button.tsx new file mode 100644 index 00000000..b28d331c --- /dev/null +++ b/src/shared/components/ui/bookmark-button/bookmark-button.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { cn } from '@/lib/cn'; +import { BookmarkIcon } from '@/shared/components/icons'; + +interface BookmarkButtonProps { + isBookmarked: boolean; + onClick: () => void; + className?: string; +} + +export const BookmarkButton = ({ + isBookmarked, + onClick, + className, +}: BookmarkButtonProps) => { + return ( + + ); +}; diff --git a/src/shared/components/ui/card-list/card-list-skeleton.tsx b/src/shared/components/ui/card-list/card-list-skeleton.tsx new file mode 100644 index 00000000..9a3d12c9 --- /dev/null +++ b/src/shared/components/ui/card-list/card-list-skeleton.tsx @@ -0,0 +1,31 @@ +import { cn } from '@/lib/cn'; +import { Skeleton } from '@/shared/components/ui/skeleton/skeleton'; + +const SKELETON_IMAGE_KEYS = [1, 2, 3, 4] as const; + +interface CardListSkeletonProps { + className?: string; +} + +export const CardListSkeleton = ({ className }: CardListSkeletonProps) => { + return ( +
+
+ + +
+ +
+ {SKELETON_IMAGE_KEYS.map((imageKey) => ( + + ))} +
+
+ ); +}; diff --git a/src/shared/components/ui/card-list/card-list.tsx b/src/shared/components/ui/card-list/card-list.tsx new file mode 100644 index 00000000..099a36b3 --- /dev/null +++ b/src/shared/components/ui/card-list/card-list.tsx @@ -0,0 +1,90 @@ +'use client'; + +import Link from 'next/link'; + +import { cn } from '@/lib/cn'; +import { BookmarkButton } from '@/shared/components/ui/bookmark-button/bookmark-button'; +import { CommonImage } from '@/shared/components/ui/common-image/common-image'; + +interface CardListImage { + src: string; + alt: string; +} + +interface CardListProps { + title: string; + description: string; + images: CardListImage[]; + isBookmarked: boolean; + onBookmarkClick: () => void; + href?: string; + className?: string; +} + +export const CardList = ({ + title, + description, + images, + isBookmarked, + onBookmarkClick, + href, + className, +}: CardListProps) => { + const information = ( + <> +

{title}

+

+ {description} +

+ + ); + + return ( +
+
+ {href ? ( + + {information} + + ) : ( +
+ {information} +
+ )} + + +
+ +
+ {images.map(({ src, alt }) => ( + + ))} +
+
+ ); +}; diff --git a/src/shared/components/ui/card-list/index.ts b/src/shared/components/ui/card-list/index.ts new file mode 100644 index 00000000..1a266200 --- /dev/null +++ b/src/shared/components/ui/card-list/index.ts @@ -0,0 +1,2 @@ +export { CardList } from './card-list'; +export { CardListSkeleton } from './card-list-skeleton'; diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts index f19096a7..8a89905f 100644 --- a/src/shared/components/ui/index.ts +++ b/src/shared/components/ui/index.ts @@ -8,6 +8,7 @@ export { AsyncLoadingState, type AsyncLoadingStateProps, } from './async-boundary'; +export { BookmarkButton } from './bookmark-button/bookmark-button'; export { BottomActionBar } from './bottom-action-bar/bottom-action-bar'; export { BottomSheet } from './bottom-sheet/bottom-sheet'; export { Button, type ButtonProps } from './button/button'; @@ -15,6 +16,7 @@ export { IconButton, type IconButtonProps } from './button/icon-button'; export { Card } from './card/card'; export { CardDate } from './card/card-date'; export { PostStatusTag, type RecruitmentStatus, Tag } from './card/card-tag'; +export { CardList, CardListSkeleton } from './card-list'; export { Chip, ChipButton } from './chip/chip'; export { ChipGroup, type ChipGroupProps } from './chip-group/chip-group'; export { CommonImage } from './common-image/common-image'; @@ -31,6 +33,7 @@ export { ProfileImageInput } from './profile-image-input/profile-image-input'; export { ProgressBar } from './progress-bar/progress-bar'; export { Searchbar, type SearchbarSize } from './searchbar/searchbar'; export { SearchbarWithDropdown } from './searchbar/searchbar-with-dropdown'; +export { Skeleton } from './skeleton/skeleton'; export { Tab } from './tab/tab'; export { TextArea, type TextAreaStatus } from './text-area/text-area'; export { TextField, type TextFieldStatus } from './text-field/text-field'; diff --git a/src/shared/components/ui/skeleton/skeleton.tsx b/src/shared/components/ui/skeleton/skeleton.tsx new file mode 100644 index 00000000..82f4ca00 --- /dev/null +++ b/src/shared/components/ui/skeleton/skeleton.tsx @@ -0,0 +1,14 @@ +import { cn } from '@/lib/cn'; + +interface SkeletonProps { + className?: string; +} + +export const Skeleton = ({ className }: SkeletonProps) => { + return ( +
+ ); +}; diff --git a/src/styles/animations.css b/src/styles/animations.css new file mode 100644 index 00000000..02a6c928 --- /dev/null +++ b/src/styles/animations.css @@ -0,0 +1,30 @@ +@keyframes skeleton-gradient-wave { + to { + transform: translateX(100%); + } +} + +@utility animate-skeleton-wave { + position: relative; + overflow: hidden; + + &::after { + position: absolute; + inset: 0; + background: linear-gradient( + 100deg, + transparent 20%, + color-mix(in srgb, var(--color-white) 55%, transparent) 50%, + transparent 80% + ); + content: ''; + transform: translateX(-100%); + animation: skeleton-gradient-wave 1.7s linear infinite; + } +} + +@media (prefers-reduced-motion: reduce) { + .animate-skeleton-wave::after { + animation: none; + } +}