refactor: 남은 Tailwind를 @fox로 옮기고 스피너를 실제로 부른다
Tailwind가 프로젝트에서 빠진 뒤에도 클래스만 남아 아무 스타일도 받지 못하던 자리들이 있었고, 전역 스피너는 만들어만 두고 어디서도 호출하지 않았다. 두 손질이 같은 파일들을 건드려 한 커밋으로 둔다. Tailwind 제거: - components/ui의 옛 Tailwind 레이어 16개 삭제. 그중 field·icon-button만 실제로 쓰여 각각 FoxTextArea·FoxFormLabel과 @fox 아이콘 버튼 마크업으로 옮기고 지웠다. - error.tsx 다섯 곳이 같은 모양이라 PageError로 모은다(PageLoading과 같은 이유). - 홈·Empty 레이아웃·게시판 첨부 링크는 module.scss로, 행 관리 버튼 묶음은 FoxButtonGroup로. - 테마 토글은 FoxIconButton을 쓰지 못한다 — label이 필수라 aria-label이 늘 붙고, 그러면 테마별로 바뀌는 안쪽 글자를 스크린리더가 읽지 못한다. @fox 아이콘 버튼의 마크업 계약을 그대로 따라 같은 스타일을 받게 했다. 스피너 호출: - useBlockingAction — 스피너를 켠 채 도는 transition. 삭제처럼 확인 얼럿이 닫힌 뒤 진행돼 표시가 행 아이콘 하나뿐이던 자리 11곳에 붙였다. 버튼이 스스로 "저장 중"을 보여주는 자리(중복확인·주소검색·엑셀 업로드)는 그대로 뒀다. - 스피너 막도 토스트와 같은 이유로 popover(top layer)에 얹는다 — 모달 안에서 시작한 동작도 덮어야 한다. - loading.tsx를 (basic) 한 곳으로 모은다. 다섯 화면에만 있던 것을 모든 하위 화면이 받도록 하고, 새 화면을 추가해도 자동으로 따라오게 한다. Co-Authored-By: Claude Opus 5
@81b90bd2729838a70662359ad01d3ea2ec95e05c
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useState, useTransition } from 'react';
|
|
| 3 |
+import { useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 8 | 9 |
import type { AdminMember } from '@/lib/domain/admin-member';
|
| 9 | 10 |
import styles from './admin-member-row-actions.module.scss'; |
| 10 | 11 |
import { deleteAdminMemberAction } from '../_actions';
|
... | ... | @@ -23,7 +24,7 @@ |
| 23 | 24 |
}: AdminMemberRowActionsProps) {
|
| 24 | 25 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 25 | 26 |
const [isEditOpen, setIsEditOpen] = useState(false); |
| 26 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 27 |
+ const [isDeleting, startDeleting] = useBlockingAction(); |
|
| 27 | 28 |
|
| 28 | 29 |
function runDelete() {
|
| 29 | 30 |
hideAlert(); |
--- app/(protected)/(basic)/admins/error.tsx
+++ app/(protected)/(basic)/admins/error.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useEffect } from 'react';
|
| 4 |
+import { PageError } from '@/components/ui/page-error';
|
|
| 4 | 5 |
|
| 5 | 6 |
interface ErrorPageProps {
|
| 6 | 7 |
error: Error & { digest?: string };
|
... | ... | @@ -16,17 +17,5 @@ |
| 16 | 17 |
console.error(error); |
| 17 | 18 |
}, [error]); |
| 18 | 19 |
|
| 19 |
- return ( |
|
| 20 |
- <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> |
|
| 21 |
- <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> |
|
| 22 |
- <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> |
|
| 23 |
- <button |
|
| 24 |
- type="button" |
|
| 25 |
- onClick={() => unstable_retry()}
|
|
| 26 |
- className="mt-4 bg-foreground px-4 py-2 text-background" |
|
| 27 |
- > |
|
| 28 |
- 다시 시도 |
|
| 29 |
- </button> |
|
| 30 |
- </section> |
|
| 31 |
- ); |
|
| 20 |
+ return <PageError onRetry={unstable_retry} />;
|
|
| 32 | 21 |
} |
--- app/(protected)/(basic)/admins/loading.tsx
... | ... | @@ -1,6 +0,0 @@ |
| 1 | -import { PageLoading } from '@/components/ui/page-loading'; | |
| 2 | - | |
| 3 | -// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다. | |
| 4 | -export default function Loading() { | |
| 5 | - return <PageLoading />; | |
| 6 | -} |
--- app/(protected)/(basic)/boards/_components/board-attachment-field.module.scss
+++ app/(protected)/(basic)/boards/_components/board-attachment-field.module.scss
... | ... | @@ -26,3 +26,16 @@ |
| 26 | 26 |
font-family: fox.font-family(body); |
| 27 | 27 |
font-size: fox.font-size(body-sm); |
| 28 | 28 |
} |
| 29 |
+ |
|
| 30 |
+// 라벨과 입력을 세로로 쌓는다 — `<label>`이 파일 입력을 함께 감싸 암시적으로 연결된다. |
|
| 31 |
+.field {
|
|
| 32 |
+ display: flex; |
|
| 33 |
+ flex-direction: column; |
|
| 34 |
+ gap: fox.gap(2); |
|
| 35 |
+} |
|
| 36 |
+ |
|
| 37 |
+.download {
|
|
| 38 |
+ color: fox.color(font-theme-primary); |
|
| 39 |
+ text-decoration: underline; |
|
| 40 |
+ text-underline-offset: 0.2rem; |
|
| 41 |
+} |
--- app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
+++ app/(protected)/(basic)/boards/_components/board-attachment-field.tsx
... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useState } from 'react';
|
| 4 |
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
|
|
| 4 | 5 |
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
|
| 5 |
-import { Field } from '@/components/ui/field';
|
|
| 6 | 6 |
import styles from './board-attachment-field.module.scss'; |
| 7 | 7 |
import {
|
| 8 | 8 |
FILE_MODULE, |
... | ... | @@ -46,7 +46,8 @@ |
| 46 | 46 |
const [error, setError] = useState<string | undefined>(undefined); |
| 47 | 47 |
|
| 48 | 48 |
return ( |
| 49 |
- <Field label={label}>
|
|
| 49 |
+ <label className={styles.field}>
|
|
| 50 |
+ <FoxFormLabel as="span">{label}</FoxFormLabel>
|
|
| 50 | 51 |
{/* 기존 첨부 id를 함께 제출해 "파일을 안 골랐으면 유지"를 서버가 판단할 수 있게 한다. */}
|
| 51 | 52 |
<input type="hidden" name="attachmentId" value={attachmentId ?? ''} />
|
| 52 | 53 |
|
... | ... | @@ -55,7 +56,7 @@ |
| 55 | 56 |
기존 첨부파일{' '}
|
| 56 | 57 |
<a |
| 57 | 58 |
href={`${ATTACHMENT_DOWNLOAD_PATH}?atchFileId=${encodeURIComponent(attachmentId)}`}
|
| 58 |
- className="text-primary underline underline-offset-2" |
|
| 59 |
+ className={styles.download}
|
|
| 59 | 60 |
> |
| 60 | 61 |
다운로드 |
| 61 | 62 |
</a>{' '}
|
... | ... | @@ -90,6 +91,6 @@ |
| 90 | 91 |
}} |
| 91 | 92 |
/> |
| 92 | 93 |
{error && <FoxHelperText type="danger" message={error} />}
|
| 93 |
- </Field> |
|
| 94 |
+ </label> |
|
| 94 | 95 |
); |
| 95 | 96 |
} |
--- app/(protected)/(basic)/boards/_components/board-content-field.tsx
+++ app/(protected)/(basic)/boards/_components/board-content-field.tsx
... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useState } from 'react';
|
|
| 4 |
-import { Field } from '@/components/ui/field';
|
|
| 3 |
+import { FoxTextArea } from '@fox/core/components/fox-text-area';
|
|
| 5 | 4 |
|
| 6 | 5 |
interface BoardContentFieldProps {
|
| 7 | 6 |
name: string; |
... | ... | @@ -28,22 +27,15 @@ |
| 28 | 27 |
maxLength, |
| 29 | 28 |
placeholder, |
| 30 | 29 |
}: BoardContentFieldProps) {
|
| 31 |
- const [length, setLength] = useState(defaultValue.length); |
|
| 32 |
- |
|
| 33 | 30 |
return ( |
| 34 |
- <Field label={label}>
|
|
| 35 |
- <textarea |
|
| 36 |
- name={name}
|
|
| 37 |
- defaultValue={defaultValue}
|
|
| 38 |
- maxLength={maxLength}
|
|
| 39 |
- placeholder={placeholder}
|
|
| 40 |
- rows={6}
|
|
| 41 |
- onChange={(event) => setLength(event.target.value.length)}
|
|
| 42 |
- className="w-full rounded-md border border-border bg-surface px-3 py-2 text-body-md text-foreground placeholder:text-foreground-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" |
|
| 43 |
- /> |
|
| 44 |
- <p className="text-right text-body-sm text-foreground-muted"> |
|
| 45 |
- {length}/{maxLength}자
|
|
| 46 |
- </p> |
|
| 47 |
- </Field> |
|
| 31 |
+ <FoxTextArea |
|
| 32 |
+ name={name}
|
|
| 33 |
+ label={label}
|
|
| 34 |
+ defaultValue={defaultValue}
|
|
| 35 |
+ maxLength={maxLength}
|
|
| 36 |
+ placeholder={placeholder}
|
|
| 37 |
+ rows={6}
|
|
| 38 |
+ counter |
|
| 39 |
+ /> |
|
| 48 | 40 |
); |
| 49 | 41 |
} |
--- app/(protected)/(basic)/boards/_components/board-post-list.tsx
+++ app/(protected)/(basic)/boards/_components/board-post-list.tsx
... | ... | @@ -1,7 +1,8 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useRouter } from 'next/navigation';
|
| 4 |
-import { useState, useTransition, type ReactNode } from 'react';
|
|
| 4 |
+import { useState, type ReactNode } from 'react';
|
|
| 5 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 5 | 6 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 6 | 7 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 8 |
import { FoxListContainer } from '@fox/core/components/fox-list-container';
|
... | ... | @@ -60,7 +61,7 @@ |
| 60 | 61 |
}: BoardPostListProps) {
|
| 61 | 62 |
const router = useRouter(); |
| 62 | 63 |
const [isCreateOpen, setIsCreateOpen] = useState(false); |
| 63 |
- const [, startRefresh] = useTransition(); |
|
| 64 |
+ const [, startRefresh] = useBlockingAction(); |
|
| 64 | 65 |
|
| 65 | 66 |
function go(patch: Partial<BoardPostQuery>) {
|
| 66 | 67 |
router.replace(buildBoardPostHref(boardType, query, { ...patch, page: 1 }));
|
--- app/(protected)/(basic)/boards/_components/board-row-actions.tsx
+++ app/(protected)/(basic)/boards/_components/board-row-actions.tsx
... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useState, useTransition } from 'react';
|
|
| 3 |
+import { useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 8 | 9 |
import type { BoardPost, BoardType } from '@/lib/domain/board-post';
|
| 9 | 10 |
import type { CommonCode } from '@/lib/domain/common-code';
|
| 10 | 11 |
import { deleteBoardPostAction, loadBoardPostAction } from '../_actions';
|
... | ... | @@ -37,8 +38,8 @@ |
| 37 | 38 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 38 | 39 |
// 목록 행이 아니라 단건 조회 결과로 폼을 채운다 — 행은 화면에 떠 있는 동안 낡을 수 있다. |
| 39 | 40 |
const [editPost, setEditPost] = useState<BoardPost | null>(null); |
| 40 |
- const [isLoading, startLoading] = useTransition(); |
|
| 41 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 41 |
+ const [isLoading, startLoading] = useBlockingAction(); |
|
| 42 |
+ const [isDeleting, startDeleting] = useBlockingAction(); |
|
| 42 | 43 |
|
| 43 | 44 |
function openEdit() {
|
| 44 | 45 |
startLoading(async () => {
|
... | ... | @@ -81,23 +82,25 @@ |
| 81 | 82 |
} |
| 82 | 83 |
|
| 83 | 84 |
return ( |
| 84 |
- <div className="flex items-center gap-1"> |
|
| 85 |
- <FoxIconButton |
|
| 86 |
- type="default" |
|
| 87 |
- size="sm" |
|
| 88 |
- icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
|
|
| 89 |
- label={isLoading ? '불러오는 중' : '수정'}
|
|
| 90 |
- disabled={isLoading}
|
|
| 91 |
- onAction={openEdit}
|
|
| 92 |
- /> |
|
| 93 |
- <FoxIconButton |
|
| 94 |
- type="default" |
|
| 95 |
- size="sm" |
|
| 96 |
- icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
|
|
| 97 |
- label={isDeleting ? '삭제 중' : '삭제'}
|
|
| 98 |
- disabled={isDeleting}
|
|
| 99 |
- onAction={confirmDelete}
|
|
| 100 |
- /> |
|
| 85 |
+ <> |
|
| 86 |
+ <FoxButtonGroup size="sm"> |
|
| 87 |
+ <FoxIconButton |
|
| 88 |
+ type="default" |
|
| 89 |
+ size="sm" |
|
| 90 |
+ icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
|
|
| 91 |
+ label={isLoading ? '불러오는 중' : '수정'}
|
|
| 92 |
+ disabled={isLoading}
|
|
| 93 |
+ onAction={openEdit}
|
|
| 94 |
+ /> |
|
| 95 |
+ <FoxIconButton |
|
| 96 |
+ type="default" |
|
| 97 |
+ size="sm" |
|
| 98 |
+ icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
|
|
| 99 |
+ label={isDeleting ? '삭제 중' : '삭제'}
|
|
| 100 |
+ disabled={isDeleting}
|
|
| 101 |
+ onAction={confirmDelete}
|
|
| 102 |
+ /> |
|
| 103 |
+ </FoxButtonGroup> |
|
| 101 | 104 |
|
| 102 | 105 |
{editPost && (
|
| 103 | 106 |
<BoardPostFormModal |
... | ... | @@ -108,6 +111,6 @@ |
| 108 | 111 |
onClose={() => setEditPost(null)}
|
| 109 | 112 |
/> |
| 110 | 113 |
)} |
| 111 |
- </div> |
|
| 114 |
+ </> |
|
| 112 | 115 |
); |
| 113 | 116 |
} |
--- app/(protected)/(basic)/boards/error.tsx
+++ app/(protected)/(basic)/boards/error.tsx
... | ... | @@ -1,32 +1,21 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useEffect } from 'react';
|
| 4 |
+import { PageError } from '@/components/ui/page-error';
|
|
| 4 | 5 |
|
| 5 | 6 |
interface ErrorPageProps {
|
| 6 | 7 |
error: Error & { digest?: string };
|
| 7 | 8 |
unstable_retry: () => void; |
| 8 | 9 |
} |
| 9 | 10 |
|
| 10 |
-// 게시판 세 화면이 공유하는 에러 경계. 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 |
|
| 11 |
-// 콘솔에 남기고(프로덕션에서는 Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 |
|
| 12 |
-// 치환해 전달한다), 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 |
|
| 13 |
-// 세그먼트 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다. |
|
| 11 |
+// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는 |
|
| 12 |
+// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다), |
|
| 13 |
+// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트 |
|
| 14 |
+// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다. |
|
| 14 | 15 |
export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
|
| 15 | 16 |
useEffect(() => {
|
| 16 | 17 |
console.error(error); |
| 17 | 18 |
}, [error]); |
| 18 | 19 |
|
| 19 |
- return ( |
|
| 20 |
- <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> |
|
| 21 |
- <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> |
|
| 22 |
- <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> |
|
| 23 |
- <button |
|
| 24 |
- type="button" |
|
| 25 |
- onClick={() => unstable_retry()}
|
|
| 26 |
- className="mt-4 bg-foreground px-4 py-2 text-background" |
|
| 27 |
- > |
|
| 28 |
- 다시 시도 |
|
| 29 |
- </button> |
|
| 30 |
- </section> |
|
| 31 |
- ); |
|
| 20 |
+ return <PageError onRetry={unstable_retry} />;
|
|
| 32 | 21 |
} |
--- app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
+++ app/(protected)/(basic)/boards/inquiries/_components/inquiry-row-actions.tsx
... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useState, useTransition } from 'react';
|
|
| 3 |
+import { useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 8 | 9 |
import { formatContentExcerpt, type BoardPost } from '@/lib/domain/board-post';
|
| 9 | 10 |
import type { CommonCode } from '@/lib/domain/common-code';
|
| 10 | 11 |
import { deleteBoardPostAction, loadBoardPostAction } from '../../_actions';
|
... | ... | @@ -28,8 +29,8 @@ |
| 28 | 29 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 29 | 30 |
// 목록 행이 아니라 단건 조회 결과로 채운다 — 답변 중에 남이 고쳤을 수 있다. |
| 30 | 31 |
const [answerPost, setAnswerPost] = useState<BoardPost | null>(null); |
| 31 |
- const [isLoading, startLoading] = useTransition(); |
|
| 32 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 32 |
+ const [isLoading, startLoading] = useBlockingAction(); |
|
| 33 |
+ const [isDeleting, startDeleting] = useBlockingAction(); |
|
| 33 | 34 |
|
| 34 | 35 |
function openAnswer() {
|
| 35 | 36 |
startLoading(async () => {
|
--- app/(protected)/(basic)/boards/loading.tsx
... | ... | @@ -1,6 +0,0 @@ |
| 1 | -import { PageLoading } from '@/components/ui/page-loading'; | |
| 2 | - | |
| 3 | -// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다. | |
| 4 | -export default function Loading() { | |
| 5 | - return <PageLoading />; | |
| 6 | -} |
--- app/(protected)/(basic)/contents/_components/content-row-actions.tsx
+++ app/(protected)/(basic)/contents/_components/content-row-actions.tsx
... | ... | @@ -1,11 +1,11 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useTransition } from 'react';
|
|
| 4 | 3 |
import { useRouter } from 'next/navigation';
|
| 5 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 6 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 7 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 8 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 9 | 9 |
import type { ContentKind } from '@/lib/domain/content';
|
| 10 | 10 |
import { contentEditPath } from '@/lib/domain/content-query';
|
| 11 | 11 |
import { deleteContentAction } from '../_actions';
|
... | ... | @@ -20,7 +20,7 @@ |
| 20 | 20 |
export function ContentRowActions({ kind, id, name }: ContentRowActionsProps) {
|
| 21 | 21 |
const router = useRouter(); |
| 22 | 22 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 23 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 23 |
+ const [isDeleting, startDeleting] = useBlockingAction(); |
|
| 24 | 24 |
|
| 25 | 25 |
function runDelete() {
|
| 26 | 26 |
hideAlert(); |
--- app/(protected)/(basic)/contents/error.tsx
+++ app/(protected)/(basic)/contents/error.tsx
... | ... | @@ -1,32 +1,21 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useEffect } from 'react';
|
| 4 |
+import { PageError } from '@/components/ui/page-error';
|
|
| 4 | 5 |
|
| 5 | 6 |
interface ErrorPageProps {
|
| 6 | 7 |
error: Error & { digest?: string };
|
| 7 | 8 |
unstable_retry: () => void; |
| 8 | 9 |
} |
| 9 | 10 |
|
| 10 |
-// 게시판 세 화면이 공유하는 에러 경계. 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 |
|
| 11 |
-// 콘솔에 남기고(프로덕션에서는 Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 |
|
| 12 |
-// 치환해 전달한다), 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 |
|
| 13 |
-// 세그먼트 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다. |
|
| 11 |
+// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는 |
|
| 12 |
+// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다), |
|
| 13 |
+// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트 |
|
| 14 |
+// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다. |
|
| 14 | 15 |
export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
|
| 15 | 16 |
useEffect(() => {
|
| 16 | 17 |
console.error(error); |
| 17 | 18 |
}, [error]); |
| 18 | 19 |
|
| 19 |
- return ( |
|
| 20 |
- <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> |
|
| 21 |
- <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> |
|
| 22 |
- <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> |
|
| 23 |
- <button |
|
| 24 |
- type="button" |
|
| 25 |
- onClick={() => unstable_retry()}
|
|
| 26 |
- className="mt-4 bg-foreground px-4 py-2 text-background" |
|
| 27 |
- > |
|
| 28 |
- 다시 시도 |
|
| 29 |
- </button> |
|
| 30 |
- </section> |
|
| 31 |
- ); |
|
| 20 |
+ return <PageError onRetry={unstable_retry} />;
|
|
| 32 | 21 |
} |
--- app/(protected)/(basic)/contents/loading.tsx
... | ... | @@ -1,6 +0,0 @@ |
| 1 | -import { PageLoading } from '@/components/ui/page-loading'; | |
| 2 | - | |
| 3 | -// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다. | |
| 4 | -export default function Loading() { | |
| 5 | - return <PageLoading />; | |
| 6 | -} |
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-row-actions.tsx
... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useState, useTransition } from 'react';
|
|
| 3 |
+import { useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 8 | 9 |
import type { CommonCode } from '@/lib/domain/common-code';
|
| 9 | 10 |
import type { DecorationItem } from '@/lib/domain/decoration-item';
|
| 10 | 11 |
import { deleteDecorationItemAction } from '../_actions';
|
... | ... | @@ -31,7 +32,7 @@ |
| 31 | 32 |
}: DecorationItemRowActionsProps) {
|
| 32 | 33 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 33 | 34 |
const [isEditOpen, setIsEditOpen] = useState(false); |
| 34 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 35 |
+ const [isDeleting, startDeleting] = useBlockingAction(); |
|
| 35 | 36 |
|
| 36 | 37 |
function runDelete() {
|
| 37 | 38 |
hideAlert(); |
--- app/(protected)/(basic)/decoration-items/error.tsx
+++ app/(protected)/(basic)/decoration-items/error.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useEffect } from 'react';
|
| 4 |
+import { PageError } from '@/components/ui/page-error';
|
|
| 4 | 5 |
|
| 5 | 6 |
interface ErrorPageProps {
|
| 6 | 7 |
error: Error & { digest?: string };
|
... | ... | @@ -10,23 +11,11 @@ |
| 10 | 11 |
// 원본 메시지·스택은 화면에 내지 않는다 — 개발자 진단용으로만 콘솔에 남기고(프로덕션에서는 |
| 11 | 12 |
// Next.js가 Server Component 에러를 이미 digest 포함 일반화 메시지로 치환해 전달한다), |
| 12 | 13 |
// 사용자에게는 일반화된 문구 + 재시도만 제공한다. Next.js 16.2 문서 권고에 따라 세그먼트 |
| 13 |
-// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다(admins/error.tsx와 동일). |
|
| 14 |
+// 재조회까지 수행하는 `unstable_retry()`를 `reset()` 대신 사용한다. |
|
| 14 | 15 |
export default function ErrorPage({ error, unstable_retry }: ErrorPageProps) {
|
| 15 | 16 |
useEffect(() => {
|
| 16 | 17 |
console.error(error); |
| 17 | 18 |
}, [error]); |
| 18 | 19 |
|
| 19 |
- return ( |
|
| 20 |
- <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> |
|
| 21 |
- <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> |
|
| 22 |
- <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> |
|
| 23 |
- <button |
|
| 24 |
- type="button" |
|
| 25 |
- onClick={() => unstable_retry()}
|
|
| 26 |
- className="mt-4 bg-foreground px-4 py-2 text-background" |
|
| 27 |
- > |
|
| 28 |
- 다시 시도 |
|
| 29 |
- </button> |
|
| 30 |
- </section> |
|
| 31 |
- ); |
|
| 20 |
+ return <PageError onRetry={unstable_retry} />;
|
|
| 32 | 21 |
} |
--- app/(protected)/(basic)/decoration-items/loading.tsx
... | ... | @@ -1,6 +0,0 @@ |
| 1 | -import { PageLoading } from '@/components/ui/page-loading'; | |
| 2 | - | |
| 3 | -// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다. | |
| 4 | -export default function Loading() { | |
| 5 | - return <PageLoading />; | |
| 6 | -} |
+++ app/(protected)/(basic)/home.module.scss
... | ... | @@ -0,0 +1,29 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +// 아직 내용이 없는 홈 — 콘텐츠 자리를 그대로 차지하고 가운데에 안내만 둔다. | |
| 4 | +.root { | |
| 5 | + display: flex; | |
| 6 | + flex: 1 0 0; | |
| 7 | + flex-direction: column; | |
| 8 | + align-items: center; | |
| 9 | + justify-content: center; | |
| 10 | + gap: fox.gap(2); | |
| 11 | + padding: fox.padding(9); | |
| 12 | +} | |
| 13 | + | |
| 14 | +.greeting { | |
| 15 | + margin: 0; | |
| 16 | + color: fox.color(font-neutral-strong); | |
| 17 | + font-family: fox.font-family(title); | |
| 18 | + font-size: fox.font-size(heading-sm); | |
| 19 | + font-weight: fox.font-weight(bold); | |
| 20 | + line-height: 1.5; | |
| 21 | +} | |
| 22 | + | |
| 23 | +.description { | |
| 24 | + margin: 0; | |
| 25 | + color: fox.color(font-neutral-subtle); | |
| 26 | + font-family: fox.font-family(body); | |
| 27 | + font-size: fox.font-size(body-md); | |
| 28 | + line-height: 1.5; | |
| 29 | +} |
+++ app/(protected)/(basic)/loading.tsx
... | ... | @@ -0,0 +1,9 @@ |
| 1 | +import { PageLoading } from '@/components/ui/page-loading'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Basic 레이아웃 아래 모든 화면의 스트리밍 폴백. 세그먼트마다 두지 않고 여기 한 곳에 둔다 — | |
| 5 | + * 새 화면을 추가해도 자동으로 같은 폴백을 받는다. | |
| 6 | + */ | |
| 7 | +export default function Loading() { | |
| 8 | + return <PageLoading />; | |
| 9 | +} |
--- app/(protected)/(basic)/page.tsx
+++ app/(protected)/(basic)/page.tsx
... | ... | @@ -1,12 +1,13 @@ |
| 1 | 1 |
import { verifySession } from '@/lib/auth/dal';
|
| 2 |
+import styles from './home.module.scss'; |
|
| 2 | 3 |
|
| 3 | 4 |
export default async function Page() {
|
| 4 | 5 |
const admin = await verifySession(); |
| 5 | 6 |
|
| 6 | 7 |
return ( |
| 7 |
- <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> |
|
| 8 |
- <p className="text-lg text-foreground">{admin.name}님, 환영합니다.</p>
|
|
| 9 |
- <p className="text-sm text-foreground/60">준비 중인 화면입니다.</p> |
|
| 8 |
+ <section className={styles.root}>
|
|
| 9 |
+ <p className={styles.greeting}>{admin.name}님, 환영합니다.</p>
|
|
| 10 |
+ <p className={styles.description}>준비 중인 화면입니다.</p>
|
|
| 10 | 11 |
</section> |
| 11 | 12 |
); |
| 12 | 13 |
} |
--- app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
+++ app/(protected)/(basic)/points/standards/_components/point-standard-row-actions.tsx
... | ... | @@ -1,10 +1,11 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useState, useTransition } from 'react';
|
|
| 3 |
+import { useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 6 | 6 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
| 7 | 7 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 8 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 8 | 9 |
import type { PointStandard } from '@/lib/domain/point-standard';
|
| 9 | 10 |
import type { CommonCode } from '@/lib/domain/common-code';
|
| 10 | 11 |
import { deletePointStandardAction } from '../_actions';
|
... | ... | @@ -21,7 +22,7 @@ |
| 21 | 22 |
}: PointStandardRowActionsProps) {
|
| 22 | 23 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 23 | 24 |
const [isEditOpen, setIsEditOpen] = useState(false); |
| 24 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 25 |
+ const [isDeleting, startDeleting] = useBlockingAction(); |
|
| 25 | 26 |
|
| 26 | 27 |
function runDelete() {
|
| 27 | 28 |
hideAlert(); |
... | ... | @@ -50,22 +51,24 @@ |
| 50 | 51 |
} |
| 51 | 52 |
|
| 52 | 53 |
return ( |
| 53 |
- <div className="flex items-center gap-1"> |
|
| 54 |
- <FoxIconButton |
|
| 55 |
- type="default" |
|
| 56 |
- size="sm" |
|
| 57 |
- icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
|
|
| 58 |
- label="수정" |
|
| 59 |
- onAction={() => setIsEditOpen(true)}
|
|
| 60 |
- /> |
|
| 61 |
- <FoxIconButton |
|
| 62 |
- type="default" |
|
| 63 |
- size="sm" |
|
| 64 |
- icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
|
|
| 65 |
- label={isDeleting ? '삭제 중' : '삭제'}
|
|
| 66 |
- disabled={isDeleting}
|
|
| 67 |
- onAction={confirmDelete}
|
|
| 68 |
- /> |
|
| 54 |
+ <> |
|
| 55 |
+ <FoxButtonGroup size="sm"> |
|
| 56 |
+ <FoxIconButton |
|
| 57 |
+ type="default" |
|
| 58 |
+ size="sm" |
|
| 59 |
+ icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
|
|
| 60 |
+ label="수정" |
|
| 61 |
+ onAction={() => setIsEditOpen(true)}
|
|
| 62 |
+ /> |
|
| 63 |
+ <FoxIconButton |
|
| 64 |
+ type="default" |
|
| 65 |
+ size="sm" |
|
| 66 |
+ icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
|
|
| 67 |
+ label={isDeleting ? '삭제 중' : '삭제'}
|
|
| 68 |
+ disabled={isDeleting}
|
|
| 69 |
+ onAction={confirmDelete}
|
|
| 70 |
+ /> |
|
| 71 |
+ </FoxButtonGroup> |
|
| 69 | 72 |
|
| 70 | 73 |
{isEditOpen && (
|
| 71 | 74 |
<PointStandardFormModal |
... | ... | @@ -74,6 +77,6 @@ |
| 74 | 77 |
onClose={() => setIsEditOpen(false)}
|
| 75 | 78 |
/> |
| 76 | 79 |
)} |
| 77 |
- </div> |
|
| 80 |
+ </> |
|
| 78 | 81 |
); |
| 79 | 82 |
} |
--- app/(protected)/(basic)/students/error.tsx
+++ app/(protected)/(basic)/students/error.tsx
... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useEffect } from 'react';
|
| 4 |
+import { PageError } from '@/components/ui/page-error';
|
|
| 4 | 5 |
|
| 5 | 6 |
interface ErrorPageProps {
|
| 6 | 7 |
error: Error & { digest?: string };
|
... | ... | @@ -16,17 +17,5 @@ |
| 16 | 17 |
console.error(error); |
| 17 | 18 |
}, [error]); |
| 18 | 19 |
|
| 19 |
- return ( |
|
| 20 |
- <section className="flex flex-1 flex-col items-center justify-center gap-2 p-8"> |
|
| 21 |
- <h1 className="text-lg text-foreground">문제가 발생했습니다.</h1> |
|
| 22 |
- <p className="text-sm text-foreground/60">잠시 후 다시 시도해 주세요.</p> |
|
| 23 |
- <button |
|
| 24 |
- type="button" |
|
| 25 |
- onClick={() => unstable_retry()}
|
|
| 26 |
- className="mt-4 bg-foreground px-4 py-2 text-background" |
|
| 27 |
- > |
|
| 28 |
- 다시 시도 |
|
| 29 |
- </button> |
|
| 30 |
- </section> |
|
| 31 |
- ); |
|
| 20 |
+ return <PageError onRetry={unstable_retry} />;
|
|
| 32 | 21 |
} |
--- app/(protected)/(basic)/students/loading.tsx
... | ... | @@ -1,6 +0,0 @@ |
| 1 | -import { PageLoading } from '@/components/ui/page-loading'; | |
| 2 | - | |
| 3 | -// page.tsx가 스트리밍되는 동안의 폴백. 네 화면이 같은 모양이라 공용 컴포넌트를 쓴다. | |
| 4 | -export default function Loading() { | |
| 5 | - return <PageLoading />; | |
| 6 | -} |
--- app/(protected)/(basic)/system/banned-words/_components/banned-word-list.tsx
+++ app/(protected)/(basic)/system/banned-words/_components/banned-word-list.tsx
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useRouter } from 'next/navigation';
|
| 4 |
-import { useState, useTransition } from 'react';
|
|
| 4 |
+import { useState } from 'react';
|
|
| 5 | 5 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 6 | 6 |
import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
|
| 7 | 7 |
import { FoxIconButton } from '@fox/core/components/fox-icon-button';
|
... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 |
} from '@fox/core/components/fox-list-container'; |
| 12 | 12 |
import { FoxSelectText } from '@fox/core/components/fox-select-text';
|
| 13 | 13 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 14 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 14 | 15 |
import { formatOptionalValue, type BannedWord } from '@/lib/domain/banned-word';
|
| 15 | 16 |
import {
|
| 16 | 17 |
BANNED_WORD_NEW_PATH, |
... | ... | @@ -40,7 +41,7 @@ |
| 40 | 41 |
const router = useRouter(); |
| 41 | 42 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 42 | 43 |
const [isExcelOpen, setIsExcelOpen] = useState(false); |
| 43 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 44 |
+ const [isDeleting, startDeleting] = useBlockingAction(); |
|
| 44 | 45 |
|
| 45 | 46 |
function go(patch: Partial<BannedWordQuery>) {
|
| 46 | 47 |
router.replace(buildBannedWordHref(query, { ...patch, page: 1 }));
|
--- app/(protected)/(basic)/system/codes/_components/code-management.tsx
+++ app/(protected)/(basic)/system/codes/_components/code-management.tsx
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 | 3 |
import { useRouter } from 'next/navigation';
|
| 4 |
-import { useState, useTransition } from 'react';
|
|
| 4 |
+import { useState } from 'react';
|
|
| 5 | 5 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 6 | 6 |
import { FoxHeadingGroup } from '@fox/core/components/fox-heading-group';
|
| 7 | 7 |
import {
|
... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 |
} from '@fox/core/components/fox-list-container'; |
| 11 | 11 |
import { FoxPageHeader } from '@fox/core/components/fox-page-header';
|
| 12 | 12 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 13 |
+import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
|
|
| 13 | 14 |
import {
|
| 14 | 15 |
formatOptionalText, |
| 15 | 16 |
type CommonCodeDetail, |
... | ... | @@ -91,7 +92,7 @@ |
| 91 | 92 |
const router = useRouter(); |
| 92 | 93 |
const { showAlert, hideAlert, showToast } = useFeedback();
|
| 93 | 94 |
const [modal, setModal] = useState<OpenModal>(null); |
| 94 |
- const [isDeleting, startDeleting] = useTransition(); |
|
| 95 |
+ const [isDeleting, startDeleting] = useBlockingAction(); |
|
| 95 | 96 |
|
| 96 | 97 |
function go(patch: Partial<CommonCodeQuery>) {
|
| 97 | 98 |
router.replace(buildCommonCodeHref(query, patch)); |
+++ app/(protected)/(empty)/layout.module.scss
... | ... | @@ -0,0 +1,6 @@ |
| 1 | +// Empty 레이아웃 셸 — 헤더·사이드바 없이 화면 전체를 세로로 쓴다. | |
| 2 | +.root { | |
| 3 | + display: flex; | |
| 4 | + min-block-size: 100vh; | |
| 5 | + flex-direction: column; | |
| 6 | +} |
--- app/(protected)/(empty)/layout.tsx
+++ app/(protected)/(empty)/layout.tsx
... | ... | @@ -1,3 +1,5 @@ |
| 1 |
+import styles from './layout.module.scss'; |
|
| 2 |
+ |
|
| 1 | 3 |
// Empty 레이아웃 — 헤더·사이드바 없이 전체 화면을 쓰는 보호 화면 진입점. 보안 강제는 상위 |
| 2 | 4 |
// `(protected)/layout.tsx`가 담당하므로(세션 재검증·bfcache 방어 상속) 여기서는 크롬을 두지 않는다. |
| 3 | 5 |
// 이 그룹에는 아직 화면(page.tsx)이 없다 — 최초 Empty 화면 작업 시 이 레이아웃 아래에 추가한다. |
... | ... | @@ -6,5 +8,5 @@ |
| 6 | 8 |
}: Readonly<{
|
| 7 | 9 |
children: React.ReactNode; |
| 8 | 10 |
}>) {
|
| 9 |
- return <div className="flex min-h-screen flex-col">{children}</div>;
|
|
| 11 |
+ return <div className={styles.root}>{children}</div>;
|
|
| 10 | 12 |
} |
--- app/_components/feedback-host.module.scss
+++ app/_components/feedback-host.module.scss
... | ... | @@ -47,12 +47,33 @@ |
| 47 | 47 |
} |
| 48 | 48 |
} |
| 49 | 49 |
|
| 50 |
-.spinnerOverlay {
|
|
| 50 |
+// 스피너 막도 같은 이유로 top layer에 얹는다 — 모달 안에서 시작한 저장·삭제도 덮어야 한다. |
|
| 51 |
+// 토스트와 달리 필요할 때만 DOM에 붙인다(막이 늘 떠 있으면 안 되므로). |
|
| 52 |
+.spinnerLayer {
|
|
| 51 | 53 |
position: fixed; |
| 52 | 54 |
inset: 0; |
| 53 |
- z-index: 50; |
|
| 55 |
+ inline-size: auto; |
|
| 56 |
+ max-inline-size: none; |
|
| 57 |
+ block-size: auto; |
|
| 58 |
+ max-block-size: none; |
|
| 59 |
+ margin: 0; |
|
| 60 |
+ padding: 0; |
|
| 61 |
+ border: 0; |
|
| 62 |
+ overflow: hidden; |
|
| 63 |
+ background: fox.color(background-dim); |
|
| 64 |
+} |
|
| 65 |
+ |
|
| 66 |
+.spinnerLayer:popover-open {
|
|
| 54 | 67 |
display: flex; |
| 55 | 68 |
align-items: center; |
| 56 | 69 |
justify-content: center; |
| 57 |
- background: fox.color(background-dim); |
|
| 70 |
+} |
|
| 71 |
+ |
|
| 72 |
+@supports not selector(:popover-open) {
|
|
| 73 |
+ .spinnerLayer {
|
|
| 74 |
+ display: flex; |
|
| 75 |
+ align-items: center; |
|
| 76 |
+ justify-content: center; |
|
| 77 |
+ z-index: 50; |
|
| 78 |
+ } |
|
| 58 | 79 |
} |
--- app/_components/feedback-host.tsx
+++ app/_components/feedback-host.tsx
... | ... | @@ -19,7 +19,9 @@ |
| 19 | 19 |
export function FeedbackHost() {
|
| 20 | 20 |
const { toasts, alert, spinnerCount, hideAlert } = useFeedback();
|
| 21 | 21 |
const toastLayerRef = useRef<HTMLDivElement>(null); |
| 22 |
+ const spinnerLayerRef = useRef<HTMLDivElement>(null); |
|
| 22 | 23 |
const hasToasts = toasts.length > 0; |
| 24 |
+ const isBusy = spinnerCount > 0; |
|
| 23 | 25 |
|
| 24 | 26 |
// 모달 위에서도 보이도록 top layer에 얹는다(feedback-host.module.scss의 이슈 주석 참고). |
| 25 | 27 |
useEffect(() => {
|
... | ... | @@ -35,6 +37,16 @@ |
| 35 | 37 |
layer.hidePopover(); |
| 36 | 38 |
} |
| 37 | 39 |
}, [hasToasts]); |
| 40 |
+ |
|
| 41 |
+ useEffect(() => {
|
|
| 42 |
+ const layer = spinnerLayerRef.current; |
|
| 43 |
+ if (!layer || typeof layer.showPopover !== 'function') {
|
|
| 44 |
+ return; |
|
| 45 |
+ } |
|
| 46 |
+ if (!layer.matches(':popover-open')) {
|
|
| 47 |
+ layer.showPopover(); |
|
| 48 |
+ } |
|
| 49 |
+ }, [isBusy]); |
|
| 38 | 50 |
|
| 39 | 51 |
return ( |
| 40 | 52 |
<> |
... | ... | @@ -69,8 +81,8 @@ |
| 69 | 81 |
</FoxModal> |
| 70 | 82 |
)} |
| 71 | 83 |
|
| 72 |
- {spinnerCount > 0 && (
|
|
| 73 |
- <div className={styles.spinnerOverlay}>
|
|
| 84 |
+ {isBusy && (
|
|
| 85 |
+ <div ref={spinnerLayerRef} popover="manual" className={styles.spinnerLayer}>
|
|
| 74 | 86 |
<Spinner size="lg" tone="inverse" /> |
| 75 | 87 |
</div> |
| 76 | 88 |
)} |
+++ app/_hooks/use-blocking-action.ts
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useTransition } from 'react'; | |
| 4 | +import { useFeedback } from '@/app/_hooks/use-feedback'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 화면이 기다려야 하는 동작을 전역 스피너와 함께 돌린다 — `useTransition`을 그대로 쓰되 | |
| 8 | + * 진행 중 표시를 붙인 것이다. | |
| 9 | + * | |
| 10 | + * 삭제처럼 확인 얼럿이 닫힌 뒤에 진행되는 동작은 표시가 행 아이콘 하나뿐이라 눌렸는지조차 | |
| 11 | + * 알기 어렵다. 그런 자리에 쓴다 — 버튼이 스스로 "저장 중"을 보여주는 자리에는 쓰지 않는다. | |
| 12 | + * | |
| 13 | + * 스피너를 켜는 것은 transition 밖에서 한다. 안에서 켜면 낮은 우선순위로 밀려 늦게 그려진다. | |
| 14 | + * 끄는 것은 안에서 해야 갱신된 화면이 준비될 때까지 스피너가 남는다. | |
| 15 | + */ | |
| 16 | +export function useBlockingAction(): [ | |
| 17 | + boolean, | |
| 18 | + (action: () => void | Promise<void>) => void, | |
| 19 | +] { | |
| 20 | + const { showSpinner, hideSpinner } = useFeedback(); | |
| 21 | + const [isPending, startTransition] = useTransition(); | |
| 22 | + | |
| 23 | + function run(action: () => void | Promise<void>) { | |
| 24 | + showSpinner(); | |
| 25 | + startTransition(async () => { | |
| 26 | + try { | |
| 27 | + await action(); | |
| 28 | + } finally { | |
| 29 | + hideSpinner(); | |
| 30 | + } | |
| 31 | + }); | |
| 32 | + } | |
| 33 | + | |
| 34 | + return [isPending, run]; | |
| 35 | +} |
--- components/ui/alert.tsx
... | ... | @@ -1,39 +0,0 @@ |
| 1 | -import type { ReactNode } from "react"; | |
| 2 | - | |
| 3 | -type AlertVariant = "info" | "danger"; | |
| 4 | - | |
| 5 | -const TITLE_TONE_CLASSES: Record<AlertVariant, string> = { | |
| 6 | - info: "text-foreground", | |
| 7 | - danger: "text-danger", | |
| 8 | -}; | |
| 9 | - | |
| 10 | -interface AlertProps { | |
| 11 | - variant?: AlertVariant; | |
| 12 | - title: string; | |
| 13 | - children?: ReactNode; | |
| 14 | - actions?: ReactNode; | |
| 15 | -} | |
| 16 | - | |
| 17 | -/** | |
| 18 | - * 확인/경고 다이얼로그 비주얼(scrim 오버레이 + 패널). | |
| 19 | - * 열림/닫힘 여부는 호출부(feature)가 마운트/언마운트로 제어하고, 포커스 트랩도 | |
| 20 | - * 호출부 책임이다. 이 컴포넌트는 상태를 가지지 않는다. | |
| 21 | - */ | |
| 22 | -export function Alert({ variant = "info", title, children, actions }: AlertProps) { | |
| 23 | - return ( | |
| 24 | - <div className="fixed inset-0 z-50 flex items-center justify-center bg-scrim p-4"> | |
| 25 | - <div | |
| 26 | - role="alertdialog" | |
| 27 | - aria-modal="true" | |
| 28 | - aria-label={title} | |
| 29 | - className="w-full max-w-sm animate-dialog-in rounded-xl bg-surface p-6 shadow-dialog" | |
| 30 | - > | |
| 31 | - <h2 className={`text-title-lg ${TITLE_TONE_CLASSES[variant]}`}>{title}</h2> | |
| 32 | - {children && ( | |
| 33 | - <div className="mt-2 text-body-md text-foreground-muted">{children}</div> | |
| 34 | - )} | |
| 35 | - {actions && <div className="mt-6 flex justify-end gap-2">{actions}</div>} | |
| 36 | - </div> | |
| 37 | - </div> | |
| 38 | - ); | |
| 39 | -} |
--- components/ui/auth-card.tsx
... | ... | @@ -1,29 +0,0 @@ |
| 1 | -import Image from "next/image"; | |
| 2 | -import type { ReactNode } from "react"; | |
| 3 | - | |
| 4 | -interface AuthCardProps { | |
| 5 | - title?: string; | |
| 6 | - children: ReactNode; | |
| 7 | -} | |
| 8 | - | |
| 9 | -/** primary 배경 전면 위 중앙 카드(로그인 등 인증 화면용). */ | |
| 10 | -export function AuthCard({ title, children }: AuthCardProps) { | |
| 11 | - return ( | |
| 12 | - <div className="flex min-h-screen items-center justify-center bg-primary p-6"> | |
| 13 | - <div className="w-full max-w-sm rounded-2xl bg-surface p-8 shadow-list-card"> | |
| 14 | - <Image | |
| 15 | - src="/images/logo-foxpay.svg" | |
| 16 | - alt="FoxPay" | |
| 17 | - width={170} | |
| 18 | - height={62} | |
| 19 | - className="mx-auto h-10 w-auto" | |
| 20 | - priority | |
| 21 | - /> | |
| 22 | - {title && ( | |
| 23 | - <h1 className="mt-6 text-center text-title-lg text-foreground">{title}</h1> | |
| 24 | - )} | |
| 25 | - <div className="mt-6">{children}</div> | |
| 26 | - </div> | |
| 27 | - </div> | |
| 28 | - ); | |
| 29 | -} |
--- components/ui/field.tsx
... | ... | @@ -1,21 +0,0 @@ |
| 1 | -import type { ReactNode } from "react"; | |
| 2 | - | |
| 3 | -interface FieldProps { | |
| 4 | - label: string; | |
| 5 | - children: ReactNode; | |
| 6 | - /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */ | |
| 7 | - className?: string; | |
| 8 | -} | |
| 9 | - | |
| 10 | -/** | |
| 11 | - * 폼 라벨 + 값 배치. `<label>`이 children을 함께 감싸 암시적 라벨 연결(implicit label)을 | |
| 12 | - * 제공한다 — 조회 팝업에서 readOnly `Input`을 감싸는 용도. | |
| 13 | - */ | |
| 14 | -export function Field({ label, children, className }: FieldProps) { | |
| 15 | - return ( | |
| 16 | - <label className={`flex flex-col gap-1 ${className ?? ""}`}> | |
| 17 | - <span className="text-label-md text-foreground-muted">{label}</span> | |
| 18 | - {children} | |
| 19 | - </label> | |
| 20 | - ); | |
| 21 | -} |
--- components/ui/header.tsx
... | ... | @@ -1,34 +0,0 @@ |
| 1 | -import Image from "next/image"; | |
| 2 | -import type { ReactNode } from "react"; | |
| 3 | - | |
| 4 | -interface HeaderProps { | |
| 5 | - title?: string; | |
| 6 | - leading?: ReactNode; | |
| 7 | - /** 우측 슬롯 */ | |
| 8 | - children?: ReactNode; | |
| 9 | - /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값(색상·타이포·간격 등) 주입 금지. */ | |
| 10 | - className?: string; | |
| 11 | -} | |
| 12 | - | |
| 13 | -/** 상단 헤더 셸 — 로고 + 타이틀 + 우측 슬롯. */ | |
| 14 | -export function Header({ title, leading, children, className }: HeaderProps) { | |
| 15 | - return ( | |
| 16 | - <header | |
| 17 | - className={`flex items-center justify-between gap-4 border-b border-border bg-surface px-6 py-4 ${className ?? ""}`} | |
| 18 | - > | |
| 19 | - <div className="flex items-center gap-3"> | |
| 20 | - {leading} | |
| 21 | - <Image | |
| 22 | - src="/images/logo-foxpay.svg" | |
| 23 | - alt="FoxPay" | |
| 24 | - width={170} | |
| 25 | - height={62} | |
| 26 | - className="h-8 w-auto" | |
| 27 | - priority | |
| 28 | - /> | |
| 29 | - {title && <span className="text-title-md text-foreground">{title}</span>} | |
| 30 | - </div> | |
| 31 | - {children && <div className="flex items-center gap-3">{children}</div>} | |
| 32 | - </header> | |
| 33 | - ); | |
| 34 | -} |
--- components/ui/input.tsx
... | ... | @@ -1,16 +0,0 @@ |
| 1 | -import type { ComponentProps } from "react"; | |
| 2 | - | |
| 3 | -type InputProps = ComponentProps<"input">; | |
| 4 | - | |
| 5 | -/** 기본 텍스트 입력. `readOnly`일 때 배경 톤 + 기본 커서로 편집 불가 상태를 시각 구분한다. */ | |
| 6 | -export function Input({ className, readOnly, ...props }: InputProps) { | |
| 7 | - return ( | |
| 8 | - <input | |
| 9 | - readOnly={readOnly} | |
| 10 | - className={`h-10 w-full rounded-md border border-border px-3 text-body-md text-foreground placeholder:text-foreground-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${ | |
| 11 | - readOnly ? "cursor-default bg-surface-muted" : "bg-surface" | |
| 12 | - } ${className ?? ""}`} | |
| 13 | - {...props} | |
| 14 | - /> | |
| 15 | - ); | |
| 16 | -} |
--- components/ui/modal.tsx
... | ... | @@ -1,62 +0,0 @@ |
| 1 | -"use client"; | |
| 2 | - | |
| 3 | -import { useEffect, useId, type ReactNode } from "react"; | |
| 4 | -import { IconButton } from "@/components/ui/icon-button"; | |
| 5 | - | |
| 6 | -interface ModalProps { | |
| 7 | - title: string; | |
| 8 | - children: ReactNode; | |
| 9 | - footer?: ReactNode; | |
| 10 | - /** scrim 클릭·ESC·닫기 버튼에서 호출된다. 실제로 마운트 해제할지는 호출부(feature) 책임. */ | |
| 11 | - onClose: () => void; | |
| 12 | -} | |
| 13 | - | |
| 14 | -/** | |
| 15 | - * 조회/입력 팝업 셸(scrim + 패널). 열림 여부는 호출부가 마운트/언마운트로 제어한다. | |
| 16 | - * alert.tsx(확인 다이얼로그)와 달리 폼 필드를 담는 용도라 ESC 키·scrim 클릭·닫기 버튼에서 | |
| 17 | - * onClose를 호출하는 상호작용을 자체적으로 가진다 — 그래서 이 컴포넌트만 'use client'. | |
| 18 | - * 포커스 트랩은 다루지 않는다(호출부 책임). | |
| 19 | - */ | |
| 20 | -export function Modal({ title, children, footer, onClose }: ModalProps) { | |
| 21 | - const titleId = useId(); | |
| 22 | - | |
| 23 | - useEffect(() => { | |
| 24 | - function handleKeyDown(event: KeyboardEvent) { | |
| 25 | - if (event.key === "Escape") { | |
| 26 | - onClose(); | |
| 27 | - } | |
| 28 | - } | |
| 29 | - document.addEventListener("keydown", handleKeyDown); | |
| 30 | - return () => { | |
| 31 | - document.removeEventListener("keydown", handleKeyDown); | |
| 32 | - }; | |
| 33 | - }, [onClose]); | |
| 34 | - | |
| 35 | - return ( | |
| 36 | - <div | |
| 37 | - className="fixed inset-0 z-50 flex items-center justify-center bg-scrim p-4" | |
| 38 | - onClick={onClose} | |
| 39 | - > | |
| 40 | - <div | |
| 41 | - role="dialog" | |
| 42 | - aria-modal="true" | |
| 43 | - aria-labelledby={titleId} | |
| 44 | - onClick={(event) => event.stopPropagation()} | |
| 45 | - className="w-full max-w-lg animate-dialog-in rounded-xl bg-surface p-6 shadow-dialog" | |
| 46 | - > | |
| 47 | - <div className="flex items-center justify-between gap-4"> | |
| 48 | - <h2 id={titleId} className="text-title-lg text-foreground"> | |
| 49 | - {title} | |
| 50 | - </h2> | |
| 51 | - <IconButton label="닫기" onClick={onClose}> | |
| 52 | - <span aria-hidden="true" className="text-title-md leading-none"> | |
| 53 | - × | |
| 54 | - </span> | |
| 55 | - </IconButton> | |
| 56 | - </div> | |
| 57 | - <div className="mt-4">{children}</div> | |
| 58 | - {footer && <div className="mt-6 flex justify-end gap-2">{footer}</div>} | |
| 59 | - </div> | |
| 60 | - </div> | |
| 61 | - ); | |
| 62 | -} |
+++ components/ui/page-error.module.scss
... | ... | @@ -0,0 +1,35 @@ |
| 1 | +@use "@fox/styles/abstracts" as fox; | |
| 2 | + | |
| 3 | +// 콘텐츠가 들어올 자리를 그대로 차지하고 가운데에 안내를 놓는다 — PageLoading과 같은 규칙이라 | |
| 4 | +// 로딩에서 오류로 바뀔 때 레이아웃이 흔들리지 않는다. | |
| 5 | +.root { | |
| 6 | + display: flex; | |
| 7 | + flex: 1 0 0; | |
| 8 | + flex-direction: column; | |
| 9 | + align-items: center; | |
| 10 | + justify-content: center; | |
| 11 | + gap: fox.gap(3); | |
| 12 | + min-block-size: 24rem; | |
| 13 | + padding: fox.padding(9); | |
| 14 | +} | |
| 15 | + | |
| 16 | +.title { | |
| 17 | + margin: 0; | |
| 18 | + color: fox.color(font-neutral-strong); | |
| 19 | + font-family: fox.font-family(title); | |
| 20 | + font-size: fox.font-size(heading-sm); | |
| 21 | + font-weight: fox.font-weight(bold); | |
| 22 | + line-height: 1.5; | |
| 23 | +} | |
| 24 | + | |
| 25 | +.description { | |
| 26 | + margin: 0; | |
| 27 | + color: fox.color(font-neutral-subtle); | |
| 28 | + font-family: fox.font-family(body); | |
| 29 | + font-size: fox.font-size(body-md); | |
| 30 | + line-height: 1.5; | |
| 31 | +} | |
| 32 | + | |
| 33 | +.action { | |
| 34 | + margin-block-start: fox.gap(3); | |
| 35 | +} |
+++ components/ui/page-error.tsx
... | ... | @@ -0,0 +1,31 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { FoxButton } from '@fox/core/components/fox-button'; | |
| 4 | +import styles from './page-error.module.scss'; | |
| 5 | + | |
| 6 | +interface PageErrorProps { | |
| 7 | + onRetry: () => void; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * 라우트 세그먼트 오류 폴백. 화면 다섯 곳의 `error.tsx`가 같은 모양이라 여기 한 번만 둔다 | |
| 12 | + * (PageLoading과 같은 이유). | |
| 13 | + * | |
| 14 | + * 원본 메시지·스택은 여기서 받지 않는다 — 진단용 기록은 호출부(`error.tsx`)가 콘솔에만 남기고, | |
| 15 | + * 화면에는 일반화된 문구와 재시도만 낸다. | |
| 16 | + */ | |
| 17 | +export function PageError({ onRetry }: PageErrorProps) { | |
| 18 | + return ( | |
| 19 | + <section className={styles.root}> | |
| 20 | + <h1 className={styles.title}>문제가 발생했습니다.</h1> | |
| 21 | + <p className={styles.description}>잠시 후 다시 시도해 주세요.</p> | |
| 22 | + <FoxButton | |
| 23 | + type="primary" | |
| 24 | + size="md" | |
| 25 | + label="다시 시도" | |
| 26 | + onAction={onRetry} | |
| 27 | + className={styles.action} | |
| 28 | + /> | |
| 29 | + </section> | |
| 30 | + ); | |
| 31 | +} |
--- components/ui/pagination.tsx
... | ... | @@ -1,109 +0,0 @@ |
| 1 | -import Link from "next/link"; | |
| 2 | -import type { ReactNode } from "react"; | |
| 3 | - | |
| 4 | -interface PaginationProps { | |
| 5 | - /** 1-based. */ | |
| 6 | - currentPage: number; | |
| 7 | - totalPages: number; | |
| 8 | - /** 각 페이지 번호로 이동할 href를 만들어 준다 — 라우팅은 소비처 책임. */ | |
| 9 | - buildHref: (page: number) => string; | |
| 10 | - /** 한 번에 보여줄 번호 개수. 기본 5. */ | |
| 11 | - windowSize?: number; | |
| 12 | -} | |
| 13 | - | |
| 14 | -const ITEM_CLASSES = | |
| 15 | - "inline-flex size-9 items-center justify-center rounded-md text-body-sm transition-colors duration-200"; | |
| 16 | -const ENABLED_CLASSES = "text-foreground-muted hover:bg-surface-muted hover:text-foreground"; | |
| 17 | -const DISABLED_CLASSES = "text-foreground-subtle opacity-50"; | |
| 18 | -const CURRENT_CLASSES = "bg-primary text-on-primary"; | |
| 19 | - | |
| 20 | -/** 현재 페이지를 중심으로 windowSize개 번호를 [1, totalPages] 범위 안에서 슬라이딩한다. */ | |
| 21 | -function getPageWindow(currentPage: number, totalPages: number, windowSize: number): number[] { | |
| 22 | - if (totalPages < 1) { | |
| 23 | - return []; | |
| 24 | - } | |
| 25 | - const half = Math.floor(windowSize / 2); | |
| 26 | - let start = Math.max(1, currentPage - half); | |
| 27 | - const end = Math.min(totalPages, start + windowSize - 1); | |
| 28 | - start = Math.max(1, end - windowSize + 1); | |
| 29 | - | |
| 30 | - const pages: number[] = []; | |
| 31 | - for (let page = start; page <= end; page += 1) { | |
| 32 | - pages.push(page); | |
| 33 | - } | |
| 34 | - return pages; | |
| 35 | -} | |
| 36 | - | |
| 37 | -interface PaginationItemProps { | |
| 38 | - disabled: boolean; | |
| 39 | - href: string; | |
| 40 | - label: string; | |
| 41 | - children: ReactNode; | |
| 42 | -} | |
| 43 | - | |
| 44 | -/** 처음·이전·다음·끝 이동 항목 — 비활성일 때는 링크 대신 aria-disabled span으로 렌더한다. */ | |
| 45 | -function PaginationItem({ disabled, href, label, children }: PaginationItemProps) { | |
| 46 | - if (disabled) { | |
| 47 | - return ( | |
| 48 | - <span aria-disabled="true" aria-label={label} className={`${ITEM_CLASSES} ${DISABLED_CLASSES}`}> | |
| 49 | - {children} | |
| 50 | - </span> | |
| 51 | - ); | |
| 52 | - } | |
| 53 | - return ( | |
| 54 | - <Link href={href} aria-label={label} className={`${ITEM_CLASSES} ${ENABLED_CLASSES}`}> | |
| 55 | - {children} | |
| 56 | - </Link> | |
| 57 | - ); | |
| 58 | -} | |
| 59 | - | |
| 60 | -/** | |
| 61 | - * 페이지네이션 — « ‹ [번호들] › ». 페이지 이동은 `next/link` href 기반이며 실제 라우팅(쿼리 | |
| 62 | - * 구성 등)은 `buildHref`를 통해 호출부(feature)가 책임진다. 첫/마지막 페이지에서는 처음·이전· | |
| 63 | - * 다음·끝 이동이 비활성 처리된다. | |
| 64 | - */ | |
| 65 | -export function Pagination({ | |
| 66 | - currentPage, | |
| 67 | - totalPages, | |
| 68 | - buildHref, | |
| 69 | - windowSize = 5, | |
| 70 | -}: PaginationProps) { | |
| 71 | - const pages = getPageWindow(currentPage, totalPages, windowSize); | |
| 72 | - const isFirst = currentPage <= 1; | |
| 73 | - const isLast = currentPage >= totalPages; | |
| 74 | - | |
| 75 | - return ( | |
| 76 | - <nav aria-label="페이지네이션" className="flex items-center justify-center gap-1"> | |
| 77 | - <PaginationItem disabled={isFirst} href={buildHref(1)} label="처음 페이지"> | |
| 78 | - « | |
| 79 | - </PaginationItem> | |
| 80 | - <PaginationItem | |
| 81 | - disabled={isFirst} | |
| 82 | - href={buildHref(Math.max(1, currentPage - 1))} | |
| 83 | - label="이전 페이지" | |
| 84 | - > | |
| 85 | - ‹ | |
| 86 | - </PaginationItem> | |
| 87 | - {pages.map((page) => ( | |
| 88 | - <Link | |
| 89 | - key={page} | |
| 90 | - href={buildHref(page)} | |
| 91 | - aria-current={page === currentPage ? "page" : undefined} | |
| 92 | - className={`${ITEM_CLASSES} ${page === currentPage ? CURRENT_CLASSES : ENABLED_CLASSES}`} | |
| 93 | - > | |
| 94 | - {page} | |
| 95 | - </Link> | |
| 96 | - ))} | |
| 97 | - <PaginationItem | |
| 98 | - disabled={isLast} | |
| 99 | - href={buildHref(Math.min(totalPages, currentPage + 1))} | |
| 100 | - label="다음 페이지" | |
| 101 | - > | |
| 102 | - › | |
| 103 | - </PaginationItem> | |
| 104 | - <PaginationItem disabled={isLast} href={buildHref(totalPages)} label="마지막 페이지"> | |
| 105 | - » | |
| 106 | - </PaginationItem> | |
| 107 | - </nav> | |
| 108 | - ); | |
| 109 | -} |
--- components/ui/radio-group.tsx
... | ... | @@ -1,40 +0,0 @@ |
| 1 | -interface RadioGroupOption { | |
| 2 | - value: string; | |
| 3 | - label: string; | |
| 4 | -} | |
| 5 | - | |
| 6 | -interface RadioGroupProps { | |
| 7 | - name: string; | |
| 8 | - options: RadioGroupOption[]; | |
| 9 | - defaultValue?: string; | |
| 10 | - disabled?: boolean; | |
| 11 | -} | |
| 12 | - | |
| 13 | -/** | |
| 14 | - * 네이티브 라디오 그룹(비제어) — 폼 제출 시 `name` 필드로 선택값이 함께 전달된다. | |
| 15 | - * React 상태를 가지지 않으므로 `'use client'`가 필요 없다. | |
| 16 | - */ | |
| 17 | -export function RadioGroup({ name, options, defaultValue, disabled = false }: RadioGroupProps) { | |
| 18 | - return ( | |
| 19 | - <div role="radiogroup" className="flex items-center gap-4"> | |
| 20 | - {options.map((option) => ( | |
| 21 | - <label | |
| 22 | - key={option.value} | |
| 23 | - className={`flex items-center gap-2 text-body-md ${ | |
| 24 | - disabled ? "text-foreground-subtle opacity-50" : "text-foreground" | |
| 25 | - }`} | |
| 26 | - > | |
| 27 | - <input | |
| 28 | - type="radio" | |
| 29 | - name={name} | |
| 30 | - value={option.value} | |
| 31 | - defaultChecked={option.value === defaultValue} | |
| 32 | - disabled={disabled} | |
| 33 | - className="size-4 accent-primary" | |
| 34 | - /> | |
| 35 | - {option.label} | |
| 36 | - </label> | |
| 37 | - ))} | |
| 38 | - </div> | |
| 39 | - ); | |
| 40 | -} |
--- components/ui/select.tsx
... | ... | @@ -1,13 +0,0 @@ |
| 1 | -import type { ComponentProps } from "react"; | |
| 2 | - | |
| 3 | -type SelectProps = ComponentProps<"select">; | |
| 4 | - | |
| 5 | -/** 네이티브 `<select>` 래퍼(라이브러리 미사용) — 브라우저 기본 표시(화살표 등)를 그대로 쓴다. */ | |
| 6 | -export function Select({ className, ...props }: SelectProps) { | |
| 7 | - return ( | |
| 8 | - <select | |
| 9 | - className={`h-10 rounded-md border border-border bg-surface px-3 text-body-md text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:pointer-events-none disabled:opacity-50 ${className ?? ""}`} | |
| 10 | - {...props} | |
| 11 | - /> | |
| 12 | - ); | |
| 13 | -} |
--- components/ui/table.tsx
... | ... | @@ -1,61 +0,0 @@ |
| 1 | -import type { ReactNode } from "react"; | |
| 2 | - | |
| 3 | -interface TableProps { | |
| 4 | - children: ReactNode; | |
| 5 | - /** 배치(레이아웃) 조정용 클래스 패스스루만 허용 — 디자인 값 주입 금지. */ | |
| 6 | - className?: string; | |
| 7 | -} | |
| 8 | - | |
| 9 | -/** 테이블 컨테이너 — 가로 스크롤 래퍼(`overflow-x-auto`) + `<table>`. */ | |
| 10 | -export function Table({ children, className }: TableProps) { | |
| 11 | - return ( | |
| 12 | - <div className={`w-full overflow-x-auto ${className ?? ""}`}> | |
| 13 | - <table className="w-full border-collapse text-body-sm text-foreground">{children}</table> | |
| 14 | - </div> | |
| 15 | - ); | |
| 16 | -} | |
| 17 | - | |
| 18 | -interface TableSectionProps { | |
| 19 | - children: ReactNode; | |
| 20 | -} | |
| 21 | - | |
| 22 | -/** 테이블 헤더 영역(`<thead>`). */ | |
| 23 | -export function TableHead({ children }: TableSectionProps) { | |
| 24 | - return <thead className="bg-surface-muted">{children}</thead>; | |
| 25 | -} | |
| 26 | - | |
| 27 | -/** 테이블 본문 영역(`<tbody>`). */ | |
| 28 | -export function TableBody({ children }: TableSectionProps) { | |
| 29 | - return <tbody>{children}</tbody>; | |
| 30 | -} | |
| 31 | - | |
| 32 | -interface TableRowProps { | |
| 33 | - children: ReactNode; | |
| 34 | - className?: string; | |
| 35 | -} | |
| 36 | - | |
| 37 | -/** 테이블 행(`<tr>`) — 아래쪽 구분선을 기본으로 갖는다. */ | |
| 38 | -export function TableRow({ children, className }: TableRowProps) { | |
| 39 | - return <tr className={`border-b border-border ${className ?? ""}`}>{children}</tr>; | |
| 40 | -} | |
| 41 | - | |
| 42 | -interface TableCellProps { | |
| 43 | - children: ReactNode; | |
| 44 | - className?: string; | |
| 45 | -} | |
| 46 | - | |
| 47 | -/** 헤더 셀(`<th>`). */ | |
| 48 | -export function TableHeaderCell({ children, className }: TableCellProps) { | |
| 49 | - return ( | |
| 50 | - <th | |
| 51 | - className={`px-3 py-2 text-left text-label-md text-foreground-muted ${className ?? ""}`} | |
| 52 | - > | |
| 53 | - {children} | |
| 54 | - </th> | |
| 55 | - ); | |
| 56 | -} | |
| 57 | - | |
| 58 | -/** 본문 셀(`<td>`). */ | |
| 59 | -export function TableCell({ children, className }: TableCellProps) { | |
| 60 | - return <td className={`px-3 py-2 text-body-sm ${className ?? ""}`}>{children}</td>; | |
| 61 | -} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?