본문 바로가기
C.W.K.
Lesson 02 of 05 · published

페이지 넷, 반응형 WebUI 하나

~13 min · react, 초안, localstorage, 라이트박스, 반응형 ui

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

문서엔 화면 여섯, 빌드엔 페이지 넷

아키텍처 문서는 스튜디오의 주요 화면을 여섯 개로 적어. Souls, Sets, On Air, Studio, Batches, References. 그런데 앱을 열면 사이드바에 페이지가 넷이야. 여기선 코드가 기준이고, 그 목록은 App.tsx에 있어. Soul collections, Create image, Image library, Batches & review.

나머지 이름이 사라진 건 아니야. 넷 안으로 들어갔어.

문서 속 이름실제 앱에서 사는 곳
Souls, SetsSoul collections: 소울 하나의 일상 세트와 프리셋 세트
On AirSoul collections 안의 채널 토글. Everyday & presets 옆에 있어
References소울에서 여는 레퍼런스 편집기, 그리고 순서 있는 레퍼런스 트레이
StudioCreate image 안의 세그먼트 버튼: studio, diary, thumbnail, clip, create, regenerate, remix
BatchesBatches & review

영어 인터페이스 하나가 기본 다크 모드로 책상 모니터, 아이패드, 폰을 다 맡아. 빌드한 번들은 그대로 iOS 앱에도 들어가. 그 얘긴 다음 레슨에서 열어 볼게.

내리지 말고 숨겨

페이지 셋은 늘 그려 둔 채 hidden 속성으로 보였다 숨겼다만 해. Create image를 쓰다가 라이브러리로 넘어가도 반쯤 쓴 프롬프트, 골라 둔 등장 대상, 맞춰 둔 생성기가 안 날아가. 컴포넌트가 한 번도 내려가지 않았으니까. 예외는 Batches & review야. 열려 있을 때만 올라와. 이 페이지는 큐에 있거나 준비, 제출, 실행, 복사 중인 태스크가 하나라도 있으면 2.5초마다 엔진에 물어봐. 숨겨진 채로 계속 묻는 페이지는 티 안 나게 비용을 써. 밑에 깔린 규칙은 간단해. 사용자가 쓰다 만 일을 품은 페이지는 살려 둬. 서버 상태를 보여 주기만 하는 페이지는 놓아 줘.

재시작에도 살아남는 초안

숨긴 페이지는 페이지 전환을 버텨. 앱을 다시 켜는 건 저장소가 있어야 버텨. draft.ts는 18줄이야. localStorage에 avatars: 접두사를 붙여 JSON을 읽고 쓰고, 저장소 오류는 전부 삼켜. 주석이 경계를 적어 둬. 기기에만 있는 초안은 서버가 가진 작업과 선택을 거들 뿐이고, 용량 한도나 개인정보 설정 때문에 편집이나 제출이 막히면 절대 안 된다고.

초안은 세 종류야. 순서 있는 레퍼런스 트레이는 에셋 id 목록으로 저장했다가, 시작할 때 엔진에서 다시 불러오는데, 더는 안 찾아지는 건 빼. 생성 폼은 세트마다 키를 따로 쓰고, 세트를 안 골랐으면 scene 키에 저장해. 그리고 리뷰 단계도 저장해. 이게 보기보다 중요해. 리뷰엔 얼려 둔 요청이 들어 있거든. 리뷰를 열 때 crypto.randomUUID()로 만든 request_id까지. 리뷰 화면에서 스튜디오를 닫아도 리뷰는 그 폼의 키 밑에 저장돼 있어. 세트 없이 만든 리뷰는 다음에 Create image를 열면 그대로 기다리고 있어. 세트에서 만든 리뷰는 그 세트로 생성 화면을 다시 열어야 돌아와. 어느 세트에서 왔는지는 저장 안 하고, 다시 켜면 Soul collections부터 열리거든. 어느 쪽이든 제출을 누르면 같은 id가 가니까, 엔진은 이미 만들었을지도 모르는 그 배치로 답해. 서버 쪽 약속은 트랙 2에서 다뤄.

이 중 어느 것도 정본 작업이 아니야. 배치, 선택, 발행은 엔진에 살고 웹과 폰이 같이 써. 초안은 기기 하나에 남은 쓰다 만 문장일 뿐이야.

라이브러리와 라이트박스

Image library는 라벨과 프롬프트를 검색하고, 소울과 출처로 거르고, 원하면 보관한 이미지도 보여 줘. 한 페이지에 24장이고, 처음·마지막 버튼과 직접 치는 페이지 번호 칸이 있어. 검색은 마지막 키 입력 뒤 180밀리초를 기다렸다 가고, 끝을 넘긴 페이지는 실제 마지막 페이지로 옮겨 가.

라이브러리, 컬렉션, 배치 리뷰는 전부 같은 라이트박스를 열어. 네이티브 <dialog>를 showModal()로 띄운 거야. Escape를 누르면 닫히고, 화살표 키로 갤러리를 넘기고, 닫으면 포커스가 열었던 요소로 돌아가. 확대는 1배에서 4배까지 0.5씩 되고, Fit 버튼도 있어. 클립은 playsInline을 단 <video>에서 포스터 프레임과 함께 그 자리에서 재생돼. Open in Ashen Reel을 누르면 엔진이 Avatars가 도는 Mac에서 자기가 가진 파일을 열어. 브라우저는 에셋 이름만 전해. 아키텍처가 뷰어에 건 규칙은 하나야. 보거나 넘겨도 후보 선택은 안 바뀌고, 프레임도 발행되지 않아.

폰이 알아채는 작은 것들

  • 안전 영역. 페이지가 viewport-fit=cover를 쓰니까, 위아래에 붙는 화면은 전부 env(safe-area-inset-*)로 스스로 여백을 줘.
  • 버튼은 한 줄. 공용 키트 스타일시트가 컨트롤 라벨이 줄바꿈되지 않게 막아. 문장형 버튼만 이름을 콕 집어 예외로 둬.
  • 원인을 말하는 실패. 아무 데도 닿지 못한 요청은 Tailscale을 확인하라고 말해. 같은 Mac의 루프백과 얘기하던 중이면 예외야. 그땐 Tailscale이 문제가 아니니까.
  • 동기화 이벤트 하나. studio:sync 이벤트가 오면 소울, 세트, 생성기, 설정을 다시 불러와. 열려 있는 배치도 이 이벤트를 들어. iOS 호스트는 연결 확인이 성공할 때마다 이걸 쏴.
원칙: 쓰다 만 문장은 기기가 쥐고, 이미 한 말은 전부 서버가 쥐어.

Code

cwkAvatars, frontend/src/App.tsx: 앱이 실제로 내놓는 페이지 넷 (원문 그대로, 37-47행)·typescript
type Page = "collections" | "generate" | "library" | "batches";
const pages = [
  { id: "collections" as const, label: "Soul collections", icon: Layers3 },
  { id: "generate" as const, label: "Create image", icon: Sparkles },
  { id: "library" as const, label: "Image library", icon: Images },
  {
    id: "batches" as const,
    label: "Batches & review",
    icon: GalleryVerticalEnd,
  },
];
cwkAvatars, frontend/src/App.tsx: 라이브러리는 올린 채 숨기고, 배치는 열 때만 올려 (원문 그대로, 365-382행)·tsx
              <div hidden={page !== "library"}>
                <Library
                  souls={souls}
                  references={references}
                  onReferences={setReferences}
                  error={error}
                  refresh={revision}
                />
              </div>
              {page === "batches" && (
                <Batches
                  sets={sets}
                  selectedId={batch}
                  onReload={() => void reload()}
                  onReference={addReference}
                  error={error}
                />
              )}
cwkAvatars, frontend/src/draft.ts: 저장소가 실패해도 깨지지 않는 기기 초안 (원문 그대로, 1-18행)·typescript
// Device-local drafts complement server-owned jobs and selections. A storage
// quota or privacy setting must never prevent editing or submission.
export function readDraft<T>(key: string, fallback: T): T {
  try {
    return (
      JSON.parse(localStorage.getItem(`avatars:${key}`) ?? "null") ?? fallback
    );
  } catch {
    return fallback;
  }
}
export function saveDraft(key: string, value: unknown) {
  try {
    localStorage.setItem(`avatars:${key}`, JSON.stringify(value));
  } catch {
    /* Editing remains available. */
  }
}

External links

Exercise

탭이 있고 그중 하나에 긴 폼이 있는 React 앱을 골라. 폼 탭을 조건부 렌더링 대신 hidden 속성으로 그리고, 탭을 오가도 입력한 게 남는지 확인해. 그다음 localStorage에 접두사를 붙여 JSON을 읽고 쓰는 초안 도우미를 try/catch 안에 만들어. 바뀔 때마다 폼을 저장하고, 새로고침해서 돌아오는지 봐. 마지막으로 crypto.randomUUID()로 id를 만드는 확인 단계를 더하고, 그 단계를 id와 같이 저장해. 새로고침한 뒤 제출해도 같은 id가 가는 걸 보여 줘.
Hint
실패 경로도 시험해 봐. 브라우저 콘솔에서 Storage.prototype.setItem을 예외를 던지는 함수로 바꿔 놓고 계속 입력해. localStorage.setItem에 바로 대입하면 setItem이라는 이름의 항목이 하나 저장될 뿐이고, 메서드는 멀쩡히 돌아가. 초안만 잃고 편집과 제출은 그대로 돼야 해. 다 보고 나면 원래 메서드를 돌려놔.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.