Add GIPHY image search feature
This commit is contained in:
+142
-61
@@ -8,10 +8,15 @@ const queryVariants = document.getElementById("queryVariants");
|
||||
const searchModeTitle = document.getElementById("searchModeTitle");
|
||||
const searchModeHint = document.getElementById("searchModeHint");
|
||||
const searchSubmitButton = document.getElementById("searchSubmitButton");
|
||||
const searchResultsViewport = document.getElementById("searchResultsViewport");
|
||||
const mediaTypeToggles = Array.from(document.querySelectorAll("[data-media-type-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 giphyMetaPanel = document.getElementById("giphyMetaPanel");
|
||||
const giphyOriginalQuery = document.getElementById("giphyOriginalQuery");
|
||||
const giphyResultCount = document.getElementById("giphyResultCount");
|
||||
const giphyExpandedQueries = document.getElementById("giphyExpandedQueries");
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const uploadResult = document.getElementById("uploadResult");
|
||||
@@ -98,44 +103,7 @@ const resultPreviewInflight = new Map();
|
||||
let cardSummaryObserver = null;
|
||||
let activeMediaType = "video";
|
||||
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",
|
||||
},
|
||||
];
|
||||
let activeImageSearchResponse = null;
|
||||
|
||||
function proxiedPreviewURL(src) {
|
||||
if (!src) {
|
||||
@@ -337,19 +305,55 @@ function syncMediaTypeButtons() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderMockImageResults(queryText = "") {
|
||||
const queryLabel = String(queryText || "").trim() || "test image";
|
||||
function renderImageEmptyState(message) {
|
||||
searchResults.innerHTML = "";
|
||||
searchResults.innerHTML = `<div class="rounded-3xl border border-white/10 bg-black/30 p-5 text-sm text-zinc-400">${message}</div>`;
|
||||
}
|
||||
|
||||
function renderExpandedQueries(queries = []) {
|
||||
giphyExpandedQueries.innerHTML = "";
|
||||
for (const item of queries) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-zinc-300";
|
||||
chip.textContent = item;
|
||||
giphyExpandedQueries.appendChild(chip);
|
||||
}
|
||||
}
|
||||
|
||||
function updateImageSearchMeta(data = null) {
|
||||
activeImageSearchResponse = data;
|
||||
const visible = Boolean(data);
|
||||
setHidden(giphyMetaPanel, !visible, "block");
|
||||
if (!visible) {
|
||||
giphyOriginalQuery.textContent = "Original query: -";
|
||||
giphyResultCount.textContent = "0 results";
|
||||
giphyExpandedQueries.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
giphyOriginalQuery.textContent = `Original query: ${data.originalQuery || "-"}`;
|
||||
giphyResultCount.textContent = `${Number(data.total || 0)} results`;
|
||||
renderExpandedQueries(data.expandedQueries || []);
|
||||
}
|
||||
|
||||
function renderImageResults(items = []) {
|
||||
searchResults.innerHTML = "";
|
||||
searchResults.classList.remove("xl:grid-cols-3");
|
||||
searchResults.classList.add("xl:grid-cols-4");
|
||||
for (const item of MOCK_IMAGE_RESULTS) {
|
||||
if (!items.length) {
|
||||
renderImageEmptyState("GIPHY에서 표시할 이미지/GIF를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const node = imageCardTemplate.content.firstElementChild.cloneNode(true);
|
||||
const image = node.querySelector("img");
|
||||
image.src = item.imageUrl;
|
||||
image.loading = "lazy";
|
||||
image.src = item.previewStillUrl || item.previewUrl || item.fullUrl || PREVIEW_PLACEHOLDER;
|
||||
image.alt = item.title;
|
||||
node.querySelector(".image-card-tag").textContent = `${item.tag} / ${queryLabel}`;
|
||||
node.querySelector(".image-card-tag").textContent = `GIPHY / ${item.searchQuery || "query"}`;
|
||||
node.querySelector(".image-card-title").textContent = item.title;
|
||||
node.querySelector(".image-card-caption").textContent = item.caption;
|
||||
node.querySelector(".image-card-caption").textContent = item.title || "Untitled GIPHY result";
|
||||
node.querySelector(".image-card-meta").textContent = `${item.rating || "unrated"} / ${item.width || "?"}x${item.height || "?"}`;
|
||||
node.addEventListener("click", () => openResultModal(item));
|
||||
searchResults.appendChild(node);
|
||||
}
|
||||
}
|
||||
@@ -358,20 +362,23 @@ function applyMediaTypeUI() {
|
||||
const isImageMode = activeMediaType === "image";
|
||||
syncMediaTypeButtons();
|
||||
setHidden(imageSearchSandbox, !isImageMode, "block");
|
||||
setHidden(giphyMetaPanel, true, "block");
|
||||
setHidden(queryVariants, true, "");
|
||||
showWarning("");
|
||||
searchResultsViewport.classList.toggle("image-results-scroll", isImageMode);
|
||||
searchModeTitle.textContent = isImageMode ? "AI Image Discovery" : "AI Smart Discovery";
|
||||
searchModeHint.textContent = isImageMode
|
||||
? "이미지 검색 프로토타입 모드입니다. 현재는 UI 전용 테스트 결과를 표시합니다."
|
||||
? "GIPHY 이미지/GIF 검색 모드입니다. Gemini가 영어 검색어 5개로 확장한 뒤 최대 100개 결과를 보여줍니다."
|
||||
: "비디오 검색 모드입니다. 실제 검색 API와 연결되어 있습니다.";
|
||||
searchQuery.placeholder = isImageMode ? "검색할 이미지를 설명하세요" : "한글 검색어를 입력하세요";
|
||||
searchSubmitButton.textContent = isImageMode ? "Image Search Test" : "AI Search";
|
||||
searchSubmitButton.textContent = isImageMode ? "Search GIPHY" : "AI Search";
|
||||
for (const button of platformToggles) {
|
||||
button.classList.toggle("hidden", isImageMode);
|
||||
}
|
||||
if (isImageMode) {
|
||||
setStatus("image prototype mode", 0);
|
||||
renderMockImageResults(searchQuery.value);
|
||||
updateImageSearchMeta(null);
|
||||
setStatus("giphy image mode", 0);
|
||||
renderImageEmptyState("GIPHY 검색어를 입력하면 여기에 최대 100개의 이미지/GIF 결과가 표시됩니다.");
|
||||
} else {
|
||||
searchResults.classList.add("xl:grid-cols-3");
|
||||
searchResults.classList.remove("xl:grid-cols-4");
|
||||
@@ -579,6 +586,10 @@ function resetResultModalMedia() {
|
||||
setHidden(resultModalGooglePanel, true, "flex");
|
||||
}
|
||||
|
||||
function isGiphyResult(item) {
|
||||
return String(item?.provider || "").toLowerCase() === "giphy" || String(item?.source || "").toLowerCase() === "giphy";
|
||||
}
|
||||
|
||||
function showResultModalFrame(src) {
|
||||
if (!src) {
|
||||
return;
|
||||
@@ -830,21 +841,36 @@ async function openResultModal(item) {
|
||||
logEvent("result:modal:error", { message: "result modal is not fully initialized" });
|
||||
return;
|
||||
}
|
||||
const giphyItem = isGiphyResult(item);
|
||||
activeResultItem = item;
|
||||
activeResultModalSummaryRequest += 1;
|
||||
const summaryRequestId = activeResultModalSummaryRequest;
|
||||
resultModalTitle.textContent = item.title || "Untitled";
|
||||
resultModalSource.textContent = item.source || "";
|
||||
resultModalReason.textContent = summarizeReason(item.reason) || "AI 노트가 없습니다.";
|
||||
const originalSummary = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.";
|
||||
resultModalReason.textContent = giphyItem
|
||||
? [
|
||||
`Original Query: ${item.originalQuery || "-"}`,
|
||||
`Expanded Query: ${item.searchQuery || "-"}`,
|
||||
`Rating: ${item.rating || "unrated"}`,
|
||||
].join("\n")
|
||||
: (summarizeReason(item.reason) || "AI 노트가 없습니다.");
|
||||
const originalSummary = giphyItem
|
||||
? `Powered by GIPHY\n${item.width || "?"} x ${item.height || "?"}\n${item.sourcePageUrl || item.openUrl || item.link || ""}`.trim()
|
||||
: (item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.");
|
||||
resultModalSnippet.textContent = originalSummary;
|
||||
resultModalOpenExternal.href = item.link || "#";
|
||||
resultModalOpenExternal.href = item.openUrl || item.sourcePageUrl || item.link || "#";
|
||||
resultModalDownload.classList.toggle("hidden", !item.actionType);
|
||||
resultModalDownload.textContent = item.actionLabel || "Open Source";
|
||||
const showSecondary = Boolean(item.secondaryActionLabel && item.link);
|
||||
const showSecondary = Boolean(item.secondaryActionLabel && (item.openUrl || item.link));
|
||||
resultModalSecondaryAction.classList.toggle("hidden", !showSecondary);
|
||||
resultModalSecondaryAction.textContent = item.secondaryActionLabel || "Open Source";
|
||||
resetResultModalMedia();
|
||||
if (giphyItem) {
|
||||
showResultModalThumbnail(item.fullUrl || item.previewUrl || item.previewStillUrl, item.title || "");
|
||||
showModal(resultModal);
|
||||
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link, provider: item.provider });
|
||||
return;
|
||||
}
|
||||
const embedURL = buildResultModalEmbedURL(item);
|
||||
const fallbackReason = item.previewBlockedReason || "Embedded view was unavailable, switched to fallback preview.";
|
||||
let resolvedPreviewURL = item.previewVideoUrl || "";
|
||||
@@ -896,9 +922,25 @@ function closeResultViewer() {
|
||||
searchForm.addEventListener("submit", async (event) => {
|
||||
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);
|
||||
setStatus("searching GIPHY", 10);
|
||||
showWarning("");
|
||||
try {
|
||||
const data = await api("/api/giphy/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: searchQuery.value, maxResults: 100 }),
|
||||
});
|
||||
updateImageSearchMeta(data);
|
||||
renderImageResults(data.items || []);
|
||||
showWarning(data.warning || "");
|
||||
logEvent("image-search:completed", { query: data.originalQuery || searchQuery.value, total: data.total || 0, expandedQueries: data.expandedQueries || [] });
|
||||
setStatus("giphy search complete", 100);
|
||||
} catch (error) {
|
||||
updateImageSearchMeta(null);
|
||||
renderImageEmptyState("GIPHY 검색 결과를 불러오지 못했습니다.");
|
||||
showWarning(error.message);
|
||||
setStatus("giphy search failed", 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStatus("preparing search", 5);
|
||||
@@ -933,8 +975,7 @@ for (const chip of imagePromptChips) {
|
||||
chip.addEventListener("click", () => {
|
||||
searchQuery.value = chip.dataset.imagePrompt || "";
|
||||
if (activeMediaType === "image") {
|
||||
renderMockImageResults(searchQuery.value);
|
||||
setStatus("image prompt applied", 100);
|
||||
setStatus("image prompt applied", 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -991,6 +1032,35 @@ function closeModal() {
|
||||
pendingDownload = null;
|
||||
}
|
||||
|
||||
async function downloadGiphyItem(item) {
|
||||
resultModalDownload.disabled = true;
|
||||
const originalLabel = resultModalDownload.textContent;
|
||||
resultModalDownload.textContent = "Downloading...";
|
||||
try {
|
||||
const data = await api("/api/giphy/download", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerId: item.providerId,
|
||||
title: item.title,
|
||||
downloadUrl: item.downloadUrl,
|
||||
originalQuery: item.originalQuery,
|
||||
selectedExpansionQuery: item.searchQuery,
|
||||
}),
|
||||
});
|
||||
downloadResult.textContent = `${data.fileName} saved to ${data.savedPath}`;
|
||||
setStatus("giphy download complete", 100);
|
||||
logEvent("giphy:download:completed", { providerId: item.providerId, fileName: data.fileName, savedPath: data.savedPath });
|
||||
} catch (error) {
|
||||
downloadResult.textContent = error.message;
|
||||
setStatus("giphy download failed", 100);
|
||||
logEvent("giphy:download:error", { providerId: item.providerId, message: error.message, data: error.data || null });
|
||||
} finally {
|
||||
resultModalDownload.disabled = false;
|
||||
resultModalDownload.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
dropzone.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
dropzone.classList.add("border-white/60", "bg-white/[0.08]");
|
||||
@@ -1059,10 +1129,14 @@ if (resultModalReady) {
|
||||
}
|
||||
});
|
||||
resultModalDownload.addEventListener("click", async () => {
|
||||
if (!activeResultItem?.link) {
|
||||
if (!activeResultItem) {
|
||||
return;
|
||||
}
|
||||
const currentItem = activeResultItem;
|
||||
if (currentItem.actionType === "giphy_download") {
|
||||
await downloadGiphyItem(currentItem);
|
||||
return;
|
||||
}
|
||||
if (currentItem.actionType === "download") {
|
||||
try {
|
||||
closeResultViewer();
|
||||
@@ -1073,13 +1147,13 @@ if (resultModalReady) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
window.open(currentItem.link, "_blank", "noopener,noreferrer");
|
||||
window.open(currentItem.openUrl || currentItem.sourcePageUrl || currentItem.link, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
resultModalSecondaryAction.addEventListener("click", () => {
|
||||
if (!activeResultItem?.link) {
|
||||
if (!activeResultItem) {
|
||||
return;
|
||||
}
|
||||
window.open(activeResultItem.link, "_blank", "noopener,noreferrer");
|
||||
window.open(activeResultItem.openUrl || activeResultItem.sourcePageUrl || activeResultItem.link, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
}
|
||||
previewModal.addEventListener("click", (event) => {
|
||||
@@ -1195,6 +1269,13 @@ window.addEventListener("error", (event) => {
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
logEvent("window:unhandledrejection", { reason: String(event.reason) });
|
||||
});
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
closeResultViewer();
|
||||
});
|
||||
|
||||
connectWS();
|
||||
syncPlatformButtons();
|
||||
|
||||
+20
-12
@@ -55,9 +55,12 @@
|
||||
<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>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<p class="text-xs uppercase tracking-[0.25em] text-zinc-500">GIPHY Image Search</p>
|
||||
<span class="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] uppercase tracking-[0.24em] text-zinc-300">Powered by GIPHY</span>
|
||||
</div>
|
||||
<p class="max-w-2xl text-sm leading-6 text-zinc-300">
|
||||
현재는 UI 전용 테스트 모드입니다. 아래 샘플 스타일 버튼과 테스트 이미지를 사용해 향후 이미지 검색 화면 구성을 미리 볼 수 있습니다.
|
||||
어떤 언어로 검색해도 Gemini가 영어 검색어 5개로 확장한 뒤 GIPHY 이미지/GIF 검색을 수행합니다. 결과는 아래 내부 스크롤 패널에서 최대 100개까지 탐색할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -66,21 +69,25 @@
|
||||
<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 id="giphyMetaPanel" class="mt-4 hidden rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||
<div class="grid gap-3 lg:grid-cols-[1.4fr_1fr]">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-[0.22em] text-zinc-500">Search Meta</p>
|
||||
<p id="giphyOriginalQuery" class="text-sm text-zinc-200">Original query: -</p>
|
||||
<p id="giphyResultCount" class="text-sm text-zinc-400">0 results</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-[0.22em] text-zinc-500">Expanded Queries</p>
|
||||
<div id="giphyExpandedQueries" class="flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
</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="queryVariants" class="hidden"></div>
|
||||
<div id="searchResults" class="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-3"></div>
|
||||
<div id="searchResultsViewport" class="mt-6">
|
||||
<div id="searchResults" class="grid gap-5 sm:grid-cols-2 xl:grid-cols-3"></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="grid gap-8">
|
||||
@@ -268,6 +275,7 @@
|
||||
<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>
|
||||
<p class="image-card-meta text-[11px] uppercase tracking-[0.22em] text-zinc-500"></p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -46,6 +46,25 @@ body {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
#searchResultsViewport {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
#searchResultsViewport.image-results-scroll {
|
||||
height: min(62dvh, 58rem);
|
||||
overflow-y: auto;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
#searchResultsViewport.image-results-scroll::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
#searchResultsViewport.image-results-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.dual-slider__thumb {
|
||||
touch-action: none;
|
||||
cursor: ew-resize;
|
||||
|
||||
Reference in New Issue
Block a user