Add image search prototype UI
build-push / docker (push) Successful in 4m38s

This commit is contained in:
GHStaK
2026-03-24 11:40:54 +09:00
parent 279a042561
commit 89e25c560b
4 changed files with 209 additions and 4 deletions
+14
View File
@@ -266,6 +266,20 @@
- backend debug broadcasts - backend debug broadcasts
## Recent Change Log ## Recent Change Log
- Date: `2026-03-24`
- What changed:
- Added a frontend-only `Video / Image` media-type toggle to Zone A.
- Kept the existing backend-connected video search flow as the default mode.
- Added an image-search prototype panel with sample prompt chips and test images.
- Added mock image-result cards so the image-search layout can be reviewed before backend image search exists.
- Why it changed:
- Image search is planned next, and the user wanted the Zone A UI shape in place first so the workflow can be tested before backend integration.
- How it was verified:
- `node --check frontend/app.js`
- What is still risky or incomplete:
- This is UI-only; image mode does not call a backend API yet.
- The test images currently use placeholder assets and do not represent final data contracts or modal behavior.
- Date: `2026-03-24` - Date: `2026-03-24`
- What changed: - What changed:
- Added an operating rule that future plans recorded in this repo should be written in Korean by default. - Added an operating rule that future plans recorded in this repo should be written in Korean by default.
+124 -1
View File
@@ -5,7 +5,13 @@ const searchQuery = document.getElementById("searchQuery");
const searchResults = document.getElementById("searchResults"); const searchResults = document.getElementById("searchResults");
const searchWarning = document.getElementById("searchWarning"); const searchWarning = document.getElementById("searchWarning");
const queryVariants = document.getElementById("queryVariants"); const queryVariants = document.getElementById("queryVariants");
const searchModeTitle = document.getElementById("searchModeTitle");
const searchModeHint = document.getElementById("searchModeHint");
const searchSubmitButton = document.getElementById("searchSubmitButton");
const mediaTypeToggles = Array.from(document.querySelectorAll("[data-media-type-toggle]"));
const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]")); const platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]"));
const imageSearchSandbox = document.getElementById("imageSearchSandbox");
const imagePromptChips = Array.from(document.querySelectorAll("[data-image-prompt]"));
const dropzone = document.getElementById("dropzone"); const dropzone = document.getElementById("dropzone");
const fileInput = document.getElementById("fileInput"); const fileInput = document.getElementById("fileInput");
const uploadResult = document.getElementById("uploadResult"); const uploadResult = document.getElementById("uploadResult");
@@ -13,6 +19,7 @@ const downloadForm = document.getElementById("downloadForm");
const downloadUrl = document.getElementById("downloadUrl"); const downloadUrl = document.getElementById("downloadUrl");
const downloadResult = document.getElementById("downloadResult"); const downloadResult = document.getElementById("downloadResult");
const cardTemplate = document.getElementById("searchCardTemplate"); const cardTemplate = document.getElementById("searchCardTemplate");
const imageCardTemplate = document.getElementById("imageCardTemplate");
const previewModal = document.getElementById("previewModal"); const previewModal = document.getElementById("previewModal");
const previewMediaFrame = document.getElementById("previewMediaFrame"); const previewMediaFrame = document.getElementById("previewMediaFrame");
const previewTitle = document.getElementById("previewTitle"); const previewTitle = document.getElementById("previewTitle");
@@ -89,7 +96,46 @@ const summaryTranslationInflight = new Map();
const resultPreviewCache = new Map(); const resultPreviewCache = new Map();
const resultPreviewInflight = new Map(); const resultPreviewInflight = new Map();
let cardSummaryObserver = null; let cardSummaryObserver = null;
let activeMediaType = "video";
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview"; const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
const MOCK_IMAGE_RESULTS = [
{
title: "Neon Crosswalk Portrait",
tag: "Test Image",
caption: "도심 야간 조명과 보케를 강조한 테스트용 이미지 카드입니다. 향후 이미지 검색 결과 카드 레이아웃 검증에 사용할 수 있습니다.",
imageUrl: "https://placehold.co/1200x900/0f172a/f8fafc?text=Neon+Crosswalk",
},
{
title: "Editorial Summer Street",
tag: "Prototype",
caption: "에디토리얼 라이프스타일 톤을 가정한 샘플 썸네일입니다. 카드 비율과 타이포 계층을 보기에 적당합니다.",
imageUrl: "https://placehold.co/1200x900/f59e0b/111827?text=Summer+Street",
},
{
title: "Minimal Product Tabletop",
tag: "Mock Result",
caption: "제품 컷 기반 이미지 검색을 상정한 목업 결과입니다. 이미지 중심 레이아웃에서 텍스트 양을 테스트하기 위한 예시입니다.",
imageUrl: "https://placehold.co/1200x900/e5e7eb/111827?text=Product+Tabletop",
},
{
title: "Vintage Fashion Frame",
tag: "Image Search",
caption: "패션 무드보드나 포스터 레퍼런스 탐색 화면에 어울리도록 구성한 테스트용 결과입니다.",
imageUrl: "https://placehold.co/1200x900/7c3aed/f5f3ff?text=Vintage+Fashion",
},
{
title: "Botanical Studio Light",
tag: "Preview",
caption: "정적인 피사체 중심 이미지 검색 UI에서 호버 없이도 충분히 읽히는 카드 밀도를 보기 위한 샘플입니다.",
imageUrl: "https://placehold.co/1200x900/14532d/d1fae5?text=Botanical+Studio",
},
{
title: "Magazine Cover Layout",
tag: "Test Asset",
caption: "향후 이미지 상세 모달이나 컬렉션 기능을 붙일 때 사용할 수 있는 테스트용 카드 자리입니다.",
imageUrl: "https://placehold.co/1200x900/111827/fde68a?text=Magazine+Cover",
},
];
function proxiedPreviewURL(src) { function proxiedPreviewURL(src) {
if (!src) { if (!src) {
@@ -281,6 +327,59 @@ function renderQueryVariants(queries = []) {
queryVariants.classList.add("hidden"); queryVariants.classList.add("hidden");
} }
function syncMediaTypeButtons() {
for (const button of mediaTypeToggles) {
const type = button.dataset.mediaTypeToggle;
const active = type === activeMediaType;
button.classList.toggle("bg-white", active);
button.classList.toggle("text-black", active);
button.classList.toggle("text-zinc-300", !active);
}
}
function renderMockImageResults(queryText = "") {
const queryLabel = String(queryText || "").trim() || "test image";
searchResults.innerHTML = "";
searchResults.classList.remove("xl:grid-cols-3");
searchResults.classList.add("xl:grid-cols-4");
for (const item of MOCK_IMAGE_RESULTS) {
const node = imageCardTemplate.content.firstElementChild.cloneNode(true);
const image = node.querySelector("img");
image.src = item.imageUrl;
image.alt = item.title;
node.querySelector(".image-card-tag").textContent = `${item.tag} / ${queryLabel}`;
node.querySelector(".image-card-title").textContent = item.title;
node.querySelector(".image-card-caption").textContent = item.caption;
searchResults.appendChild(node);
}
}
function applyMediaTypeUI() {
const isImageMode = activeMediaType === "image";
syncMediaTypeButtons();
setHidden(imageSearchSandbox, !isImageMode, "block");
setHidden(queryVariants, true, "");
showWarning("");
searchModeTitle.textContent = isImageMode ? "AI Image Discovery" : "AI Smart Discovery";
searchModeHint.textContent = isImageMode
? "이미지 검색 프로토타입 모드입니다. 현재는 UI 전용 테스트 결과를 표시합니다."
: "비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다.";
searchQuery.placeholder = isImageMode ? "검색할 이미지를 설명하세요" : "한글 검색어를 입력하세요";
searchSubmitButton.textContent = isImageMode ? "Image Search Test" : "AI Search";
for (const button of platformToggles) {
button.classList.toggle("hidden", isImageMode);
}
if (isImageMode) {
setStatus("image prototype mode", 0);
renderMockImageResults(searchQuery.value);
} else {
searchResults.classList.add("xl:grid-cols-3");
searchResults.classList.remove("xl:grid-cols-4");
searchResults.innerHTML = "";
setStatus(`active platforms: ${Array.from(activePlatforms).join(", ")}`, 0);
}
}
function syncPlatformButtons() { function syncPlatformButtons() {
for (const button of platformToggles) { for (const button of platformToggles) {
const platform = button.dataset.platformToggle; const platform = button.dataset.platformToggle;
@@ -796,6 +895,12 @@ function closeResultViewer() {
searchForm.addEventListener("submit", async (event) => { searchForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
if (activeMediaType === "image") {
renderMockImageResults(searchQuery.value);
logEvent("image-search:prototype", { query: searchQuery.value, results: MOCK_IMAGE_RESULTS.length });
setStatus("image prototype results ready", 100);
return;
}
setStatus("preparing search", 5); setStatus("preparing search", 5);
showWarning(""); showWarning("");
try { try {
@@ -816,6 +921,24 @@ searchForm.addEventListener("submit", async (event) => {
} }
}); });
for (const button of mediaTypeToggles) {
button.addEventListener("click", () => {
activeMediaType = button.dataset.mediaTypeToggle || "video";
applyMediaTypeUI();
logEvent("media-type:update", { active: activeMediaType });
});
}
for (const chip of imagePromptChips) {
chip.addEventListener("click", () => {
searchQuery.value = chip.dataset.imagePrompt || "";
if (activeMediaType === "image") {
renderMockImageResults(searchQuery.value);
setStatus("image prompt applied", 100);
}
});
}
async function uploadFile(file) { async function uploadFile(file) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
@@ -1075,5 +1198,5 @@ window.addEventListener("unhandledrejection", (event) => {
connectWS(); connectWS();
syncPlatformButtons(); syncPlatformButtons();
setStatus(`active platforms: ${Array.from(activePlatforms).join(", ")}`, 0); applyMediaTypeUI();
logEvent("app:ready", { activePlatforms: Array.from(activePlatforms) }); logEvent("app:ready", { activePlatforms: Array.from(activePlatforms) });
+51 -3
View File
@@ -33,9 +33,16 @@
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<div> <div>
<p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone A</p> <p class="text-xs uppercase tracking-[0.3em] text-zinc-500">Zone A</p>
<h2 class="text-2xl font-semibold text-white">AI Smart Discovery</h2> <h2 id="searchModeTitle" class="text-2xl font-semibold text-white">AI Smart Discovery</h2>
</div> </div>
</div> </div>
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="inline-flex rounded-full border border-white/10 bg-black/30 p-1">
<button data-media-type-toggle="video" class="media-type-toggle rounded-full bg-white px-4 py-2 text-sm font-medium text-black transition">Video</button>
<button data-media-type-toggle="image" class="media-type-toggle rounded-full px-4 py-2 text-sm font-medium text-zinc-300 transition">Image</button>
</div>
<p id="searchModeHint" class="text-sm text-zinc-400">비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다.</p>
</div>
<div class="mb-4 flex flex-wrap gap-3"> <div class="mb-4 flex flex-wrap gap-3">
<button data-platform-toggle="envato" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Envato</button> <button data-platform-toggle="envato" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Envato</button>
<button data-platform-toggle="artgrid" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Artgrid</button> <button data-platform-toggle="artgrid" class="platform-toggle rounded-full border border-white bg-white px-4 py-2 text-sm font-medium text-black">Artgrid</button>
@@ -43,8 +50,34 @@
</div> </div>
<form id="searchForm" class="flex flex-col gap-3 md:flex-row"> <form id="searchForm" class="flex flex-col gap-3 md:flex-row">
<input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-5 py-4 text-base text-white outline-none ring-0 placeholder:text-zinc-500" /> <input id="searchQuery" type="text" placeholder="한글 검색어를 입력하세요" class="flex-1 rounded-2xl border border-white/10 bg-black/40 px-5 py-4 text-base text-white outline-none ring-0 placeholder:text-zinc-500" />
<button class="rounded-2xl border border-white bg-white px-7 py-4 text-base font-medium text-black transition hover:bg-zinc-200">AI Search</button> <button id="searchSubmitButton" class="rounded-2xl border border-white bg-white px-7 py-4 text-base font-medium text-black transition hover:bg-zinc-200">AI Search</button>
</form> </form>
<div id="imageSearchSandbox" class="mt-4 hidden rounded-3xl border border-white/10 bg-[linear-gradient(135deg,rgba(250,204,21,0.07),rgba(59,130,246,0.08))] p-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="space-y-2">
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">Image Search Prototype</p>
<p class="max-w-2xl text-sm leading-6 text-zinc-300">
현재는 UI 전용 테스트 모드입니다. 아래 샘플 스타일 버튼과 테스트 이미지를 사용해 향후 이미지 검색 화면 구성을 미리 볼 수 있습니다.
</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="image-prompt-chip rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-200" data-image-prompt="cinematic city night">Cinematic City</button>
<button type="button" class="image-prompt-chip rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-200" data-image-prompt="vintage fashion portrait">Vintage Portrait</button>
<button type="button" class="image-prompt-chip rounded-full border border-white/10 px-3 py-2 text-xs uppercase tracking-[0.2em] text-zinc-200" data-image-prompt="minimal product mockup">Product Mockup</button>
</div>
</div>
<div id="imageSearchPreviewStrip" class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img src="https://placehold.co/1200x900/0f172a/e2e8f0?text=Test+Image+01" alt="Test image 01" class="aspect-[4/3] w-full object-cover" />
</div>
<div class="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img src="https://placehold.co/1200x900/1f2937/fde68a?text=Test+Image+02" alt="Test image 02" class="aspect-[4/3] w-full object-cover" />
</div>
<div class="overflow-hidden rounded-2xl border border-white/10 bg-black/30">
<img src="https://placehold.co/1200x900/3f3f46/dbeafe?text=Test+Image+03" alt="Test image 03" class="aspect-[4/3] w-full object-cover" />
</div>
</div>
</div>
<div id="searchWarning" class="mt-3 hidden rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200"></div> <div id="searchWarning" class="mt-3 hidden rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-200"></div>
<div id="queryVariants" class="hidden"></div> <div id="queryVariants" class="hidden"></div>
<div id="searchResults" class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3"></div> <div id="searchResults" class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3"></div>
@@ -224,6 +257,21 @@
</button> </button>
</template> </template>
<script src="/app.js?v=20260317c" defer></script> <template id="imageCardTemplate">
<button type="button" class="group overflow-hidden rounded-3xl border border-white/10 bg-black/30 text-left transition hover:border-white/30">
<div class="relative aspect-[4/3] overflow-hidden bg-zinc-900">
<img class="h-full w-full object-cover transition duration-500 group-hover:scale-105" alt="" />
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent p-4">
<p class="image-card-tag text-[11px] uppercase tracking-[0.25em] text-zinc-300"></p>
</div>
</div>
<div class="space-y-2 p-5">
<h3 class="image-card-title line-clamp-2 text-base font-medium text-white"></h3>
<p class="image-card-caption line-clamp-3 text-sm text-zinc-300"></p>
</div>
</button>
</template>
<script src="/app.js?v=20260324a" defer></script>
</body> </body>
</html> </html>
+20
View File
@@ -26,6 +26,26 @@ body {
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
} }
.line-clamp-4 {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 4;
}
.media-type-toggle {
min-width: 6rem;
}
.image-prompt-chip {
background: rgba(255, 255, 255, 0.03);
}
.image-prompt-chip:hover {
border-color: rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.08);
}
.dual-slider__thumb { .dual-slider__thumb {
touch-action: none; touch-action: none;
cursor: ew-resize; cursor: ew-resize;