nnuer
강희태@gmlxo9917

공간 사진 갤러리 + 크게 보기 라이트박스

갤러리 블록

공간별(로비·스튜디오·장비실·작업실)로 묶은 사진 15장 그리드입니다. 사진을 누르면 어두운 라이트박스에서 크게 보고 이전/다음으로 넘길 수 있습니다.

#gallery#lightbox#grid#photos2026. 10. 16. 업데이트

블록 신고

운영자가 확인한 뒤 필요하면 블록을 숨깁니다.

사유
Claude Code · claude-opus-5-5 · React (TSX)

라이트박스가 열려 있는 동안 document.body 의 overflow 를 잠시 hidden 으로 바꿉니다.

결과 코드펼치기
import { useCallback, useEffect, useRef, useState } from "react";

const GROUPS: { title: string; photos: { src: string; name: string }[] }[] = [
  {
    title: "로비 · 라운지",
    photos: [
      { src: "https://nnuer.site/media/FYtz8JVI2ZgB", name: "로비" },
      { src: "https://nnuer.site/media/ACZ8f8usUHbc", name: "창가 라운지" },
      { src: "https://nnuer.site/media/oKFbMVX4tS5I", name: "테라스 카페" },
    ],
  },
  {
    title: "스튜디오",
    photos: [
      { src: "https://nnuer.site/media/betlMhmdcBcN", name: "스튜디오 A 작업대" },
      { src: "https://nnuer.site/media/mFeUmbAeXyFY", name: "스튜디오 A 리뷰석" },
      { src: "https://nnuer.site/media/lSBnvazHctQ3", name: "스튜디오 B 작업대" },
      { src: "https://nnuer.site/media/vSAVTa0EygQF", name: "스튜디오 B 리뷰석" },
      { src: "https://nnuer.site/media/RA2andSsj9Mo", name: "편집 코너" },
      { src: "https://nnuer.site/media/6ayCkPRaDLTD", name: "보정 데스크" },
    ],
  },
  {
    title: "장비실",
    photos: [
      { src: "https://nnuer.site/media/PniLADeFOiqT", name: "대여 장비 진열" },
      { src: "https://nnuer.site/media/tKB7ObZUD2H1", name: "액세서리 보관함" },
      { src: "https://nnuer.site/media/l1fT68SfxHEQ", name: "필름 카메라 코너" },
    ],
  },
  {
    title: "개인 작업실",
    photos: [
      { src: "https://nnuer.site/media/FQ0OkmAl2ny6", name: "작업실 1" },
      { src: "https://nnuer.site/media/7t5KQTMBq53J", name: "작업실 2" },
      { src: "https://nnuer.site/media/SXQxsQ8yvCVI", name: "작업실 3" },
    ],
  },
];

const ALL = GROUPS.flatMap((g) => g.photos);
const FONT = '"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif';

export default function FacilityGallery() {
  const [index, setIndex] = useState<number | null>(null);
  const opener = useRef<HTMLButtonElement | null>(null);
  const closeBtn = useRef<HTMLButtonElement>(null);

  const close = useCallback(() => {
    setIndex(null);
    opener.current?.focus();
  }, []);
  const move = useCallback((step: number) => {
    setIndex((i) => (i === null ? i : (i + step + ALL.length) % ALL.length));
  }, []);

  useEffect(() => {
    if (index === null) return;
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") close();
      if (e.key === "ArrowLeft") move(-1);
      if (e.key === "ArrowRight") move(1);
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.body.style.overflow = prev;
      document.removeEventListener("keydown", onKey);
    };
  }, [index, close, move]);

  useEffect(() => {
    if (index !== null) closeBtn.current?.focus();
  }, [index !== null]); // eslint-disable-line react-hooks/exhaustive-deps

  let flat = -1;
  return (
    <section className="w-full bg-white" style={{ fontFamily: FONT }}>
      <div className="mx-auto w-full max-w-[1200px] px-4 py-12 sm:px-6 lg:py-16">
        {GROUPS.map((g, gi) => (
          <section key={g.title} className={gi > 0 ? "mt-[72px]" : ""}>
            <h2 className="mb-5 text-2xl font-extrabold text-[#172031]">{g.title}</h2>
            <ul className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-3">
              {g.photos.map((p) => {
                flat += 1;
                const i = flat;
                return (
                  <li key={p.src}>
                    <button
                      type="button"
                      onClick={(e) => {
                        opener.current = e.currentTarget;
                        setIndex(i);
                      }}
                      className="group block w-full text-left"
                    >
                      <span className="block aspect-[4/3] overflow-hidden rounded-2xl bg-[#f4f6f9]">
                        <img src={p.src} alt="" loading="lazy" className="h-full w-full object-cover transition-transform duration-[400ms] group-hover:scale-105" />
                      </span>
                      <span className="mt-3 block text-sm font-bold text-[#172031] sm:text-base">{p.name}</span>
                    </button>
                  </li>
                );
              })}
            </ul>
          </section>
        ))}
      </div>

      {index !== null && (
        <div
          role="dialog"
          aria-modal="true"
          aria-label="공간 사진 크게 보기"
          onClick={(e) => {
            if (e.target === e.currentTarget) close();
          }}
          className="fixed inset-0 z-[300] flex items-center justify-center bg-[rgba(12,17,27,0.94)] px-3 py-16 sm:px-20 sm:py-14"
        >
          <figure className="m-0 w-full max-w-[1200px]">
            <img src={ALL[index].src} alt={ALL[index].name} className="mx-auto max-h-[calc(100vh-160px)] rounded-[10px]" />
            <figcaption className="mt-3.5 text-center font-semibold text-white">
              {ALL[index].name}
              <span className="ml-2.5 tabular-nums text-white/50">
                {index + 1} / {ALL.length}
              </span>
            </figcaption>
          </figure>
          <button ref={closeBtn} type="button" onClick={close} className="absolute right-5 top-5 h-[52px] rounded-full bg-white/10 px-5 font-bold text-white hover:bg-[#F5C842] hover:text-[#1B263B]">
            닫기
          </button>
          <button
            type="button"
            onClick={() => move(-1)}
            aria-label="이전 사진"
            className="absolute bottom-4 left-4 flex h-[52px] w-[52px] items-center justify-center rounded-full bg-white/10 text-white hover:bg-[#F5C842] hover:text-[#1B263B] sm:bottom-auto sm:top-1/2 sm:-translate-y-1/2"
          >
            <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M15 18l-6-6 6-6" />
            </svg>
          </button>
          <button
            type="button"
            onClick={() => move(1)}
            aria-label="다음 사진"
            className="absolute bottom-4 right-4 flex h-[52px] w-[52px] items-center justify-center rounded-full bg-white/10 text-white hover:bg-[#F5C842] hover:text-[#1B263B] sm:bottom-auto sm:top-1/2 sm:-translate-y-1/2"
          >
            <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M9 18l6-6-6-6" />
            </svg>
          </button>
        </div>
      )}
    </section>
  );
}

적용 사이트 0

이 블록이 실제로 쓰인 사이트입니다.

로그인하고 내 사이트 추가

아직 등록된 사이트가 없습니다. 이 블록을 쓴 사이트가 있다면 알려 주세요.

로그인하고 재현 결과 올리기

재현 후기

v1 · 0개

아직 이 버전의 프롬프트로 직접 만들어 본 사람이 없습니다. 첫 번째로 재현해 보세요.