From 7ae25eed19babb34fb30915ef0256120d2c886a3 Mon Sep 17 00:00:00 2001 From: chaeyn Date: Tue, 14 Jul 2026 09:31:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=EB=B3=84=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=88=EB=A0=88=ED=86=A4=20UI=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance/ui/AttendanceDialog.tsx | 33 ++--- .../ui/ChapterRanking.style.ts | 21 +-- .../chapter/components/MissionContainer.tsx | 16 +- .../ui/rival-competition/battle/Battle.tsx | 36 ++--- .../group-side-tab/panel/GroupFormPanel.tsx | 10 +- .../src/features/home/ui/ranking/Ranking.tsx | 19 ++- .../features/major-choice/ui/test/Test.tsx | 11 +- .../src/features/notice/ui/TopbarNotice.tsx | 6 +- .../GitHubActivityPanel.tsx | 19 ++- .../ui/profile-tabs/item-panel/ItemPanel.tsx | 7 +- .../src/features/record/ui/task/Task.tsx | 4 +- .../src/features/record/ui/timer/Timer.tsx | 3 +- .../src/features/record/ui/todo/Todo.tsx | 4 +- .../ui/rivals-management/RivalsManagement.tsx | 18 +-- .../section-progress/ui/SectionProgress.tsx | 8 +- .../section/components/PreviewModal.style.ts | 21 +-- .../rival/RivalCompetitionPage.tsx | 6 +- .../src/pages/group/GroupPage.style.ts | 128 ++++++++++++++++ .../renderer/src/pages/group/GroupPage.tsx | 6 +- .../src/pages/group/GroupPageSkeleton.tsx | 59 ++++++++ .../src/renderer/src/pages/home/ui/Home.tsx | 19 ++- .../src/pages/roadmap/chapter/ChapterPage.tsx | 5 +- apps/mac/src/renderer/src/shared/ui/index.ts | 8 + .../src/shared/ui/skeleton/Skeleton.style.ts | 138 ++++++++++++++++-- .../src/shared/ui/skeleton/Skeleton.tsx | 133 ++++++++++++++++- .../renderer/src/shared/ui/skeleton/index.ts | 9 +- .../group-activity/ui/GroupActivity.tsx | 18 ++- .../src/widgets/home-rivals/Rival.tsx | 6 +- .../widgets/profile-rivals/RivalContainer.tsx | 12 +- 29 files changed, 585 insertions(+), 198 deletions(-) create mode 100644 apps/mac/src/renderer/src/pages/group/GroupPageSkeleton.tsx diff --git a/apps/mac/src/renderer/src/features/attendance/ui/AttendanceDialog.tsx b/apps/mac/src/renderer/src/features/attendance/ui/AttendanceDialog.tsx index d8576e44..435166a3 100644 --- a/apps/mac/src/renderer/src/features/attendance/ui/AttendanceDialog.tsx +++ b/apps/mac/src/renderer/src/features/attendance/ui/AttendanceDialog.tsx @@ -1,4 +1,4 @@ -import { Button, Dialog } from "@/shared/ui"; +import { Button, Dialog, SkeletonPanel } from "@/shared/ui"; import { isAttended, type WeeklyAttendanceResponse } from "@/entities/attendance"; import * as S from "./AttendanceDialog.style"; @@ -48,29 +48,20 @@ export const AttendanceDialog = ({ return ( - - - - {isLoading - ? "출석 현황을 불러오는 중이에요." - : hasLoadError - ? "출석 현황을 불러오지 못했어요." - : "표시할 출석 현황이 없어요."} - - - {isLoading - ? "잠시만 기다려 주세요." - : loadErrorMessage || "잠시 후 다시 확인해 주세요."} - - {!isLoading && ( + {isLoading ? ( + + ) : ( + + + + {hasLoadError ? "출석 현황을 불러오지 못했어요." : "표시할 출석 현황이 없어요."} + + {loadErrorMessage || "잠시 후 다시 확인해 주세요."} - )} - + + )} ); } diff --git a/apps/mac/src/renderer/src/features/chapter-ranking/ui/ChapterRanking.style.ts b/apps/mac/src/renderer/src/features/chapter-ranking/ui/ChapterRanking.style.ts index 2cbee1f7..670bad82 100644 --- a/apps/mac/src/renderer/src/features/chapter-ranking/ui/ChapterRanking.style.ts +++ b/apps/mac/src/renderer/src/features/chapter-ranking/ui/ChapterRanking.style.ts @@ -1,4 +1,4 @@ -import styled, { css, keyframes } from "styled-components"; +import styled, { css } from "styled-components"; import { font } from "@clash/design-tokens/font"; import { InlineNotice } from "@clash/ui"; import FirstFrameIcon from "../assets/first-frame.svg"; @@ -10,25 +10,8 @@ import { DefaultProfileIcon } from "@/shared/ui"; export type RankingPageEnum = "section" | "chapter"; export type RankingPositionEnum = "top" | "bottom"; -const skeletonWave = keyframes` - 0% { - background-position: 200% 0; - } - - 100% { - background-position: -200% 0; - } -`; - const skeletonBase = css` - background: linear-gradient( - 90deg, - ${({ theme }) => theme.fill.normal} 0%, - ${({ theme }) => theme.fill.alternative} 50%, - ${({ theme }) => theme.fill.normal} 100% - ); - background-size: 200% 100%; - animation: ${skeletonWave} 1.6s ease-in-out infinite; + background-color: ${({ theme }) => theme.fill.neutral}; `; export const RankingContainer = styled.div<{ $page: RankingPageEnum }>` diff --git a/apps/mac/src/renderer/src/features/chapter/components/MissionContainer.tsx b/apps/mac/src/renderer/src/features/chapter/components/MissionContainer.tsx index b3eb083f..271a2d66 100644 --- a/apps/mac/src/renderer/src/features/chapter/components/MissionContainer.tsx +++ b/apps/mac/src/renderer/src/features/chapter/components/MissionContainer.tsx @@ -1,6 +1,6 @@ import * as S from "./MissionContainer.style"; import { useEffect, useRef, useState } from "react"; -import { SidePanel } from "@/shared/ui"; +import { SidePanel, SkeletonPanel } from "@/shared/ui"; import { getErrorMessage } from "@/shared/lib"; import { MarkdownCodeContent } from "@/shared/ui/markdown-code-content/MarkdownCodeContent"; import type { Mission } from "@/features/chapter/model/chapter.types"; @@ -344,9 +344,7 @@ export const MissionContainer = ({ } }; - const descriptionText = isLoading - ? "챕터 정보를 불러오는 중입니다." - : description?.trim() || "이 챕터에 대한 설명이 아직 준비되지 않았습니다."; + const descriptionText = description?.trim() || "이 챕터에 대한 설명이 아직 준비되지 않았습니다."; const isCompletedStage = isFinishedSection || (currentStage.totalMissions > 0 && currentStage.currentProgress >= currentStage.totalMissions); @@ -392,9 +390,13 @@ export const MissionContainer = ({ ) : ( <> - - {descriptionText} - + {isLoading ? ( + + ) : ( + + {descriptionText} + + )} diff --git a/apps/mac/src/renderer/src/features/competition/ui/rival-competition/battle/Battle.tsx b/apps/mac/src/renderer/src/features/competition/ui/rival-competition/battle/Battle.tsx index bd03025f..6c313657 100644 --- a/apps/mac/src/renderer/src/features/competition/ui/rival-competition/battle/Battle.tsx +++ b/apps/mac/src/renderer/src/features/competition/ui/rival-competition/battle/Battle.tsx @@ -1,6 +1,14 @@ import { useState } from "react"; import * as S from "./Battle.style"; -import { Button, DefaultProfileIcon, Dialog, Select, SlideSelector } from "@/shared/ui"; +import { + Button, + DefaultProfileIcon, + Dialog, + Select, + SkeletonPanel, + SkeletonRows, + SlideSelector, +} from "@/shared/ui"; import type { AnalyzeCategory } from "@/entities/competition"; import { MATCH_VALUE } from "@/entities/competition"; import { useBattle } from "@/features/competition/model/useBattle"; @@ -69,11 +77,7 @@ export const Battle = () => { )} {battle.queries.info.isPending ? ( - - - 배틀 정보를 불러오는 중이에요. - - + ) : battle.queries.info.isError && !battle.battleData ? ( @@ -139,13 +143,7 @@ export const Battle = () => { {battle.isBattleSelected ? ( battle.queries.detail.isPending || battle.queries.detail.isPlaceholderData ? ( - - - - 배틀 상세 정보를 불러오는 중이에요. - - - + ) : battle.queries.detail.isError && !battle.battleDetailData ? ( @@ -248,9 +246,7 @@ export const Battle = () => { {battle.queries.analyze.isPending || battle.queries.analyze.isPlaceholderData ? ( - - 세부 분석을 불러오는 중이에요. - + ) : battle.queries.analyze.isError && battle.queries.analyze.data === undefined ? ( @@ -379,9 +375,7 @@ export const Battle = () => { {battle.queries.rivals.isPending ? ( - - 신청 가능한 라이벌을 불러오는 중이에요. - + ) : battle.queries.rivals.isError && !battle.battleList ? ( 신청 가능한 라이벌을 불러오지 못했어요. @@ -475,9 +469,7 @@ export const Battle = () => { aria-busy={battle.queries.applications.isFetching || undefined} > {battle.queries.applications.isPending ? ( - - 배틀 신청 목록을 불러오는 중이에요. - + ) : battle.queries.applications.isError && !battle.battleApplyList ? ( 배틀 신청 목록을 불러오지 못했어요. diff --git a/apps/mac/src/renderer/src/features/group/ui/group-side-tab/panel/GroupFormPanel.tsx b/apps/mac/src/renderer/src/features/group/ui/group-side-tab/panel/GroupFormPanel.tsx index 768b758b..103435c3 100644 --- a/apps/mac/src/renderer/src/features/group/ui/group-side-tab/panel/GroupFormPanel.tsx +++ b/apps/mac/src/renderer/src/features/group/ui/group-side-tab/panel/GroupFormPanel.tsx @@ -1,4 +1,4 @@ -import { Button, SlideSelector } from "@/shared/ui"; +import { Button, SkeletonCards, SlideSelector } from "@/shared/ui"; import { GROUP_CATEGORY_LABELS } from "@/entities/group"; import type { GroupFormPanelProps } from "../../../model/useGroup"; import { useGroupFormPanel } from "../../../model/useGroupFormPanel"; @@ -78,9 +78,11 @@ export const GroupFormPanel = ({ {isLoading ? ( - - 로딩 중... - + ) : groups.length === 0 ? ( diff --git a/apps/mac/src/renderer/src/features/home/ui/ranking/Ranking.tsx b/apps/mac/src/renderer/src/features/home/ui/ranking/Ranking.tsx index 9429db2b..67889da4 100644 --- a/apps/mac/src/renderer/src/features/home/ui/ranking/Ranking.tsx +++ b/apps/mac/src/renderer/src/features/home/ui/ranking/Ranking.tsx @@ -1,7 +1,7 @@ import * as S from "./Ranking.style"; import { rankingRewardTooltipContent } from "./Ranking.constants"; import { useRanking } from "@/features/home/model/useRanking"; -import { Button, Select, QuestionTooltip } from "@/shared/ui"; +import { Button, Select, QuestionTooltip, SkeletonRows } from "@/shared/ui"; import type { CategoryType, PeriodType, RankingItem } from "@/entities/ranking"; import { UserRanking } from "./user-ranking/UserRanking"; @@ -58,15 +58,14 @@ export const Ranking = () => { {domain.isLoading || domain.isPlaceholderData ? ( - - - - {domain.isPlaceholderData - ? "선택한 조건의 랭킹을 불러오는 중이에요." - : "랭킹을 불러오는 중이에요."} - - - + ) : domain.isError && !hasRankings ? ( diff --git a/apps/mac/src/renderer/src/features/major-choice/ui/test/Test.tsx b/apps/mac/src/renderer/src/features/major-choice/ui/test/Test.tsx index 91364062..c488aeff 100644 --- a/apps/mac/src/renderer/src/features/major-choice/ui/test/Test.tsx +++ b/apps/mac/src/renderer/src/features/major-choice/ui/test/Test.tsx @@ -1,6 +1,7 @@ import * as S from "./Test.style"; import type { TestProps } from "@/features/major-choice/model/useMajorChoice"; import { Button } from "@/shared/ui/button"; +import { SkeletonRows } from "@/shared/ui/skeleton"; import { AGREEMENT_LABELS, LevelSlider } from "@/shared/ui/level-slider"; import type { LevelSliderValue } from "@/shared/ui/level-slider/types"; @@ -61,9 +62,13 @@ export const Test = ({ {isQuestionsLoading ? ( - - 검사 문항을 불러오는 중이에요. - + ) : questionsError && questionData.length === 0 ? ( 검사 문항을 불러오지 못했어요. diff --git a/apps/mac/src/renderer/src/features/notice/ui/TopbarNotice.tsx b/apps/mac/src/renderer/src/features/notice/ui/TopbarNotice.tsx index 84070faa..1fe7a3fd 100644 --- a/apps/mac/src/renderer/src/features/notice/ui/TopbarNotice.tsx +++ b/apps/mac/src/renderer/src/features/notice/ui/TopbarNotice.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { KeyboardEvent } from "react"; import { formatNoticeDate, useTopbarNotice } from "@/features/notice/model/useTopbarNotice"; -import { Button, Popover } from "@/shared/ui"; +import { Button, Popover, SkeletonRows } from "@/shared/ui"; import * as S from "./TopbarNotice.style"; import { getErrorMessage } from "@/shared/lib"; @@ -157,9 +157,7 @@ export const TopbarNotice = () => { )} {isLoading ? ( - - 알림을 불러오는 중입니다. - + ) : isError && !hasQueryData ? ( {getErrorMessage(error, "알림을 불러오지 못했어요.")} diff --git a/apps/mac/src/renderer/src/features/profile/ui/profile-tabs/github-activity-panel/GitHubActivityPanel.tsx b/apps/mac/src/renderer/src/features/profile/ui/profile-tabs/github-activity-panel/GitHubActivityPanel.tsx index 1e582675..df127fa9 100644 --- a/apps/mac/src/renderer/src/features/profile/ui/profile-tabs/github-activity-panel/GitHubActivityPanel.tsx +++ b/apps/mac/src/renderer/src/features/profile/ui/profile-tabs/github-activity-panel/GitHubActivityPanel.tsx @@ -3,6 +3,7 @@ import { GitHubInfo } from "@/features/profile/ui/profile-tabs/github-info/GitHu import { GitHubStreak } from "@/features/profile/ui/profile-tabs/github-streak/GitHubStreak"; import { useProfileGitHubDetailQuery } from "@/entities/profile"; import { useProfileGitHubStreak } from "@/features/profile/model/useProfileTabs"; +import { SkeletonPanel } from "@/shared/ui"; import * as S from "./GitHubActivityPanel.style"; const formatKoreanDate = (ymd: string) => { @@ -73,13 +74,17 @@ export const GitHubActivityPanel = () => { - + {selectedDate && !displayDetail && (detailQuery.isFetching || detailQuery.isLoading) ? ( + + ) : ( + + )} ); diff --git a/apps/mac/src/renderer/src/features/profile/ui/profile-tabs/item-panel/ItemPanel.tsx b/apps/mac/src/renderer/src/features/profile/ui/profile-tabs/item-panel/ItemPanel.tsx index f7d9d412..7adec0fd 100644 --- a/apps/mac/src/renderer/src/features/profile/ui/profile-tabs/item-panel/ItemPanel.tsx +++ b/apps/mac/src/renderer/src/features/profile/ui/profile-tabs/item-panel/ItemPanel.tsx @@ -12,7 +12,7 @@ import { import { sortEquippedItemsFirst } from "@/features/profile/lib/sortEquippedItemsFirst"; import { useGetMyProfile, userQueryKeys } from "@/entities/user"; import { getErrorMessage } from "@/shared/lib"; -import { Button } from "@/shared/ui"; +import { Button, SkeletonCards } from "@/shared/ui"; const FILTER_OPTIONS = [ { key: OwnedItemCategory.ALL, label: "전체" }, @@ -126,10 +126,7 @@ export const ItemPanel = () => { ) : isLoading ? ( - - 아이템을 불러오는 중... - 잠시만 기다려 주세요. - + ) : items.length === 0 ? ( 보유한 아이템이 아직 없어요. diff --git a/apps/mac/src/renderer/src/features/record/ui/task/Task.tsx b/apps/mac/src/renderer/src/features/record/ui/task/Task.tsx index 756da309..d914d7b6 100644 --- a/apps/mac/src/renderer/src/features/record/ui/task/Task.tsx +++ b/apps/mac/src/renderer/src/features/record/ui/task/Task.tsx @@ -1,7 +1,7 @@ import * as S from "./Task.style"; import { formatTime, getErrorMessage } from "@/shared/lib"; import { useTaskList } from "../../model/useTaskList"; -import { Button, ConfirmDialog, Popover, Tooltip } from "@/shared/ui"; +import { Button, ConfirmDialog, Popover, SkeletonRows, Tooltip } from "@/shared/ui"; interface TaskProps { selectedDate?: string; @@ -74,7 +74,7 @@ export const Task = ({ selectedDate }: TaskProps) => { {subjectsQuery.isPending ? ( - 과목을 불러오는 중이에요. + ) : hasInitialLoadError ? ( {getErrorMessage(subjectsQuery.error, "과목을 불러오지 못했어요.")} diff --git a/apps/mac/src/renderer/src/features/record/ui/timer/Timer.tsx b/apps/mac/src/renderer/src/features/record/ui/timer/Timer.tsx index 71d2a961..1fabe7f6 100644 --- a/apps/mac/src/renderer/src/features/record/ui/timer/Timer.tsx +++ b/apps/mac/src/renderer/src/features/record/ui/timer/Timer.tsx @@ -2,6 +2,7 @@ import * as S from "./Timer.style"; import { formatTime } from "@/shared/lib"; import { useRecordStore } from "../../model/recordStore"; import { useLiveRecordStudyTime } from "../../model/useLiveRecordStudyTime"; +import { Skeleton } from "@/shared/ui"; interface TimerProps { date: string; @@ -103,7 +104,7 @@ export const Timer = ({ aria-label={isLoading ? "총 학습 시간을 불러오는 중" : `총 학습 시간 ${displayTime}`} $loading={isLoading} > - {displayTime} + {isLoading ? : displayTime} {stopButtonPosition === "RIGHT" && stopButton} diff --git a/apps/mac/src/renderer/src/features/record/ui/todo/Todo.tsx b/apps/mac/src/renderer/src/features/record/ui/todo/Todo.tsx index 836b8189..94eb6fb3 100644 --- a/apps/mac/src/renderer/src/features/record/ui/todo/Todo.tsx +++ b/apps/mac/src/renderer/src/features/record/ui/todo/Todo.tsx @@ -1,4 +1,4 @@ -import { Button, Popover, Select, Tooltip } from "@/shared/ui"; +import { Button, Popover, Select, SkeletonRows, Tooltip } from "@/shared/ui"; import * as S from "./Todo.style"; import { useTodoList } from "../../model/useTodoList"; import { getErrorMessage } from "@/shared/lib"; @@ -84,7 +84,7 @@ export const Todo = ({ selectedDate }: TodoProps) => { )} {tasksQuery.isPending ? ( - 할 일을 불러오는 중이에요. + ) : hasInitialTasksError ? ( {getErrorMessage(tasksQuery.error, "할 일을 불러오지 못했어요.")} diff --git a/apps/mac/src/renderer/src/features/rival-management/ui/rivals-management/RivalsManagement.tsx b/apps/mac/src/renderer/src/features/rival-management/ui/rivals-management/RivalsManagement.tsx index 59270cba..6ce610ca 100644 --- a/apps/mac/src/renderer/src/features/rival-management/ui/rivals-management/RivalsManagement.tsx +++ b/apps/mac/src/renderer/src/features/rival-management/ui/rivals-management/RivalsManagement.tsx @@ -1,6 +1,6 @@ import { useState, type ChangeEvent } from "react"; import * as S from "./RivalsManagement.style"; -import { Button } from "@/shared/ui/button"; +import { Button, SkeletonRows } from "@/shared/ui"; import { Dialog } from "@/shared/ui/dialog"; import { SearchInput } from "@/shared/ui/search-input"; import { SlideSelector } from "@/shared/ui/slide-selector"; @@ -80,9 +80,7 @@ export const RivalsManagementDialog = ({ isOpen, onClose, rival }: AddRivalsDial {rival.queries.myRivals.isPending ? ( - - 현재 라이벌 정보를 확인하는 중이에요. - + ) : rival.queries.myRivals.isError ? ( 현재 라이벌 정보를 확인하지 못했어요. @@ -99,9 +97,7 @@ export const RivalsManagementDialog = ({ isOpen, onClose, rival }: AddRivalsDial ) : rival.queries.available.isPending ? ( - - 추가할 수 있는 라이벌을 불러오는 중이에요. - + ) : rival.queries.available.isError ? ( 라이벌 목록을 불러오지 못했어요. @@ -204,9 +200,7 @@ export const RivalsManagementDialog = ({ isOpen, onClose, rival }: AddRivalsDial {rival.queries.myRivals.isPending ? ( - - 현재 라이벌을 불러오는 중이에요. - + ) : rival.queries.myRivals.isError && !rival.rivalsData ? ( 현재 라이벌을 불러오지 못했어요. @@ -268,9 +262,7 @@ export const RivalsManagementDialog = ({ isOpen, onClose, rival }: AddRivalsDial {rival.queries.requests.isPending ? ( - - 라이벌 신청 목록을 불러오는 중이에요. - + ) : rival.queries.requests.isError && !rival.rivalSignAll ? ( 라이벌 신청 목록을 불러오지 못했어요. diff --git a/apps/mac/src/renderer/src/features/section-progress/ui/SectionProgress.tsx b/apps/mac/src/renderer/src/features/section-progress/ui/SectionProgress.tsx index 745a7d04..7c8be604 100644 --- a/apps/mac/src/renderer/src/features/section-progress/ui/SectionProgress.tsx +++ b/apps/mac/src/renderer/src/features/section-progress/ui/SectionProgress.tsx @@ -1,7 +1,7 @@ import * as S from "./SectionProgress.style"; import { useGetMyProfile } from "@/entities/user"; import { MajorEnum, useMajorSectionQuery } from "@/entities/roadmap"; -import { Button } from "@/shared/ui"; +import { Button, SkeletonPanel } from "@/shared/ui"; interface SectionProgressProps { completed?: number; @@ -25,10 +25,8 @@ export const SectionProgress = ({ if (needsRemoteCounts && (profileQuery.isLoading || sectionQuery.isLoading)) { return ( - - - 진행률을 불러오는 중이에요. - + + ); } diff --git a/apps/mac/src/renderer/src/features/section/components/PreviewModal.style.ts b/apps/mac/src/renderer/src/features/section/components/PreviewModal.style.ts index e87a0e78..ba494b20 100644 --- a/apps/mac/src/renderer/src/features/section/components/PreviewModal.style.ts +++ b/apps/mac/src/renderer/src/features/section/components/PreviewModal.style.ts @@ -1,4 +1,4 @@ -import styled, { css, keyframes } from "styled-components"; +import styled, { css } from "styled-components"; import { font } from "@clash/design-tokens/font"; import { palette } from "@clash/design-tokens/theme"; import Check from "@/shared/ui/assets/check.svg"; @@ -7,25 +7,8 @@ import Flag from "../assets/flag.svg"; import Arrow from "../assets/arrow.svg"; import Star from "../assets/star.svg"; -const skeletonWave = keyframes` - 0% { - background-position: 200% 0; - } - - 100% { - background-position: -200% 0; - } -`; - const skeletonBase = css` - background: linear-gradient( - 90deg, - ${({ theme }) => theme.fill.normal} 0%, - ${({ theme }) => theme.fill.alternative} 50%, - ${({ theme }) => theme.fill.normal} 100% - ); - background-size: 200% 100%; - animation: ${skeletonWave} 1.6s ease-in-out infinite; + background-color: ${({ theme }) => theme.fill.neutral}; `; export const PreviewModalWrapper = styled.div` diff --git a/apps/mac/src/renderer/src/pages/competition/rival/RivalCompetitionPage.tsx b/apps/mac/src/renderer/src/pages/competition/rival/RivalCompetitionPage.tsx index ea8d8041..a2a87970 100644 --- a/apps/mac/src/renderer/src/pages/competition/rival/RivalCompetitionPage.tsx +++ b/apps/mac/src/renderer/src/pages/competition/rival/RivalCompetitionPage.tsx @@ -1,7 +1,7 @@ import * as S from "./RivalCompetitionPage.style"; import { RivalCompetition } from "@/features/competition"; import { RivalsManagementDialog, useRival } from "@/features/rival-management"; -import { Button } from "@/shared/ui"; +import { Button, SkeletonPanel } from "@/shared/ui"; import { getErrorMessage } from "@/shared/lib"; export const RivalCompetitionPage = () => { @@ -24,9 +24,7 @@ export const RivalCompetitionPage = () => { )} {rival.queries.myRivals.isPending ? ( - - 라이벌 경쟁 정보를 불러오는 중이에요. - + ) : rival.queries.myRivals.isError && !rival.rivalsData ? ( diff --git a/apps/mac/src/renderer/src/pages/group/GroupPage.style.ts b/apps/mac/src/renderer/src/pages/group/GroupPage.style.ts index ee9cbe24..0e47dc44 100644 --- a/apps/mac/src/renderer/src/pages/group/GroupPage.style.ts +++ b/apps/mac/src/renderer/src/pages/group/GroupPage.style.ts @@ -19,6 +19,134 @@ export const Content = styled.div` min-height: 0; `; +export const ActivitySkeleton = styled.div` + display: flex; + flex: 2; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 2.5rem; + padding: 2rem; + border-radius: 1rem; + background-color: ${({ theme }) => theme.background.normal}; +`; + +export const ActivityHeaderSkeleton = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + padding: 0 1.5rem; +`; + +export const TimerSkeleton = styled.div` + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 0.75rem; +`; + +export const StudySummarySkeleton = styled.div` + display: flex; + flex-shrink: 0; + flex-direction: column; + align-items: center; + gap: 0.875rem; +`; + +export const ActivityBodySkeleton = styled.div` + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 2.5rem; +`; + +export const GroupInfoSkeleton = styled.div` + display: flex; + flex-direction: column; + gap: 0.875rem; +`; + +export const GroupStatsSkeleton = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; +`; + +export const MemberGridSkeleton = styled.div` + display: grid; + flex: 1; + min-height: 0; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-content: start; + column-gap: 1rem; + row-gap: 1.25rem; + overflow: hidden; +`; + +export const MemberSkeleton = styled.div` + display: flex; + min-width: 0; + flex-direction: column; + align-items: center; + gap: 0.5rem; +`; + +export const GroupSideSkeleton = styled.div` + display: flex; + flex: 1; + min-width: 0; + min-height: 0; + flex-direction: column; + padding: 1.5rem; + border-radius: 1rem; + background-color: ${({ theme }) => theme.background.normal}; +`; + +export const GroupSideHeaderSkeleton = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +`; + +export const GroupListSkeleton = styled.div` + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + margin-top: 1rem; + padding-bottom: 4rem; + overflow: hidden; +`; + +export const GroupListItemSkeleton = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 1.25rem 0.75rem; + border-bottom: 2px solid ${({ theme }) => theme.fill.alternative}; +`; + +export const GroupListItemLeftSkeleton = styled.div` + display: flex; + min-width: 0; + align-items: center; + gap: 0.75rem; +`; + +export const GroupListItemRightSkeleton = styled.div` + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.5rem; +`; + export const PageState = styled.div` display: flex; flex: 1; diff --git a/apps/mac/src/renderer/src/pages/group/GroupPage.tsx b/apps/mac/src/renderer/src/pages/group/GroupPage.tsx index d6abe283..349ea0d7 100644 --- a/apps/mac/src/renderer/src/pages/group/GroupPage.tsx +++ b/apps/mac/src/renderer/src/pages/group/GroupPage.tsx @@ -6,6 +6,7 @@ import { shiftRecordDate, useTodayRecordDate } from "@/features/record"; import { GroupActivity } from "@/widgets/group-activity"; import { Button } from "@/shared/ui"; import { getErrorMessage } from "@/shared/lib"; +import { GroupPageSkeleton } from "./GroupPageSkeleton"; export const GroupPage = () => { const [selectedGroupId, setSelectedGroupId] = useState(null); @@ -38,10 +39,7 @@ export const GroupPage = () => { if (isGroupsPending && !myGroupsResponse) { return ( - - 그룹을 불러오는 중이에요. - 잠시만 기다려 주세요. - + ); } diff --git a/apps/mac/src/renderer/src/pages/group/GroupPageSkeleton.tsx b/apps/mac/src/renderer/src/pages/group/GroupPageSkeleton.tsx new file mode 100644 index 00000000..777f9fad --- /dev/null +++ b/apps/mac/src/renderer/src/pages/group/GroupPageSkeleton.tsx @@ -0,0 +1,59 @@ +import { Skeleton } from "@/shared/ui"; +import * as S from "./GroupPage.style"; + +const memberSkeletons = Array.from({ length: 10 }, (_, index) => ( + +)); + +const groupSkeletons = Array.from({ length: 5 }, (_, index) => ( + +)); + +export const GroupPageSkeleton = () => ( + + + + + + + {memberSkeletons} + + + + + + {groupSkeletons} + + +); diff --git a/apps/mac/src/renderer/src/pages/home/ui/Home.tsx b/apps/mac/src/renderer/src/pages/home/ui/Home.tsx index 6522d3eb..5c58866c 100644 --- a/apps/mac/src/renderer/src/pages/home/ui/Home.tsx +++ b/apps/mac/src/renderer/src/pages/home/ui/Home.tsx @@ -3,8 +3,7 @@ import { Active, Ranking, Transition } from "@/features/home"; import { Rival } from "@/widgets/home-rivals"; import { useQueryClient } from "@tanstack/react-query"; import axios from "axios"; -import { Dialog } from "@/shared/ui"; -import { Skeleton } from "@/shared/ui/skeleton"; +import { DashboardCardSkeleton, Dialog } from "@/shared/ui"; import * as S from "./Home.style"; import { activeQueryKeys, @@ -103,10 +102,10 @@ export const Home = () => { if (isChecking) { return ( <> - - - - + + + + ); } @@ -114,10 +113,10 @@ export const Home = () => { if (isGitHubNotReady) { return ( <> - - - - + + + + {!isGitHubNotLinked && ( diff --git a/apps/mac/src/renderer/src/pages/roadmap/chapter/ChapterPage.tsx b/apps/mac/src/renderer/src/pages/roadmap/chapter/ChapterPage.tsx index c07814db..e6dcec6b 100644 --- a/apps/mac/src/renderer/src/pages/roadmap/chapter/ChapterPage.tsx +++ b/apps/mac/src/renderer/src/pages/roadmap/chapter/ChapterPage.tsx @@ -6,6 +6,7 @@ import { MissionContainer, Roadmap, useChapter } from "@/features/chapter"; import { MajorEnum, useMajorSectionQuery } from "@/entities/roadmap"; import { useEffect, useMemo } from "react"; import { useGetMyProfile } from "@/entities/user"; +import { SkeletonPanel } from "@/shared/ui"; export const ChapterPage = () => { const { sectionId } = useParams<{ sectionId: string }>(); @@ -76,7 +77,9 @@ export const ChapterPage = () => { - 로딩 중.. + + + ); diff --git a/apps/mac/src/renderer/src/shared/ui/index.ts b/apps/mac/src/renderer/src/shared/ui/index.ts index 63dfb8f5..d5f6bec5 100644 --- a/apps/mac/src/renderer/src/shared/ui/index.ts +++ b/apps/mac/src/renderer/src/shared/ui/index.ts @@ -41,3 +41,11 @@ export type { ProfileAvatarProps } from "./profile-avatar"; export { NameTag } from "./name-tag"; export type { NameTagProps, NameTagSize, NameTagTextAlign, NameTagWidth } from "./name-tag"; export { ClashLoadingIcon } from "./clash-loading-icon/ClashLoadingIcon"; +export { + DashboardCardSkeleton, + Skeleton, + SkeletonCards, + SkeletonPanel, + SkeletonRows, +} from "./skeleton"; +export type { SkeletonProps } from "./skeleton"; diff --git a/apps/mac/src/renderer/src/shared/ui/skeleton/Skeleton.style.ts b/apps/mac/src/renderer/src/shared/ui/skeleton/Skeleton.style.ts index 316ef06b..0616661a 100644 --- a/apps/mac/src/renderer/src/shared/ui/skeleton/Skeleton.style.ts +++ b/apps/mac/src/renderer/src/shared/ui/skeleton/Skeleton.style.ts @@ -1,21 +1,133 @@ -import styled from "styled-components"; +import type { CSSProperties } from "react"; +import styled, { css } from "styled-components"; -export const SkeletonContainer = styled.div` +const skeletonSurface = css` + background-color: ${({ theme }) => theme.fill.neutral}; +`; + +export const SkeletonBlock = styled.span<{ + $width: CSSProperties["width"]; + $height: CSSProperties["height"]; + $radius: CSSProperties["borderRadius"]; +}>` + ${skeletonSurface}; + display: block; + width: ${({ $width }) => $width}; + height: ${({ $height }) => $height}; + min-width: 0; + border-radius: ${({ $radius }) => $radius}; + flex-shrink: 0; +`; + +export const SkeletonStatus = styled.div` + display: flex; + flex: 1; + grid-column: 1 / -1; + grid-row: 1 / -1; width: 100%; - height: 100%; - padding: 2rem; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 0.75rem; +`; + +export const SkeletonRow = styled.div<{ $surface: boolean; $compact: boolean }>` display: flex; + align-items: center; + gap: ${({ $compact }) => ($compact ? "0.625rem" : "0.875rem")}; + width: 100%; + min-width: 0; + min-height: ${({ $compact }) => ($compact ? "3.5rem" : "4.75rem")}; + padding: ${({ $compact }) => ($compact ? "0.625rem 0.75rem" : "0.875rem 1rem")}; + box-sizing: border-box; + border-radius: 0.75rem; + background-color: ${({ $surface, theme }) => + $surface ? theme.background.alternative : "transparent"}; +`; + +export const SkeletonTextGroup = styled.div` + display: flex; + flex: 1; + min-width: 0; flex-direction: column; - align-items: start; - justify-content: start; - background-color: ${({ theme }) => theme.background.normal}; - border-radius: 1rem; + gap: 0.5rem; +`; + +export const SkeletonCardGrid = styled.div<{ $columns: number }>` + display: grid; + flex: 1; + grid-column: 1 / -1; + grid-row: 1 / -1; + width: 100%; + min-width: 0; + min-height: 0; + grid-template-columns: repeat(${({ $columns }) => $columns}, minmax(0, 1fr)); gap: 1rem; + overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + @media (max-width: 48rem) { + grid-template-columns: 1fr; + } `; -export const SkeletonBox = styled.div<{ $width: number; $height: number }>` - width: ${({ $width }) => $width}%; - height: ${({ $height }) => $height}%; - background-color: ${({ theme }) => theme.fill.neutral}; - border-radius: 0.5rem; +export const SkeletonCard = styled.div` + display: flex; + min-width: 0; + min-height: 7.5rem; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border-radius: 0.75rem; + background-color: ${({ theme }) => theme.background.alternative}; +`; + +export const SkeletonCardHeader = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +`; + +export const SkeletonPanel = styled.div<{ $compact: boolean }>` + display: flex; + flex: 1; + width: 100%; + min-width: 0; + min-height: ${({ $compact }) => ($compact ? "3rem" : "10rem")}; + flex-direction: column; + justify-content: center; + gap: ${({ $compact }) => ($compact ? "0.625rem" : "1rem")}; + padding: ${({ $compact }) => ($compact ? "0.5rem 0.75rem" : "1.5rem")}; + box-sizing: border-box; + border-radius: 0.75rem; + background-color: ${({ theme }) => theme.background.alternative}; +`; + +export const DashboardCardSkeleton = styled.div` + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + box-sizing: border-box; + border-radius: 1rem; + background-color: ${({ theme }) => theme.background.normal}; +`; + +export const DashboardSkeletonRow = styled.div` + display: flex; + flex: 1; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + min-height: 0; `; diff --git a/apps/mac/src/renderer/src/shared/ui/skeleton/Skeleton.tsx b/apps/mac/src/renderer/src/shared/ui/skeleton/Skeleton.tsx index 80979d51..10c37aa5 100644 --- a/apps/mac/src/renderer/src/shared/ui/skeleton/Skeleton.tsx +++ b/apps/mac/src/renderer/src/shared/ui/skeleton/Skeleton.tsx @@ -1,10 +1,127 @@ +import type { CSSProperties, HTMLAttributes } from "react"; import * as S from "./Skeleton.style"; -export const Skeleton = () => { - return ( - - - - - ); -}; +export interface SkeletonProps extends Omit, "children"> { + width?: CSSProperties["width"]; + height?: CSSProperties["height"]; + radius?: CSSProperties["borderRadius"]; +} + +export const Skeleton = ({ + width = "100%", + height = "1rem", + radius = "0.5rem", + ...props +}: SkeletonProps) => ( +