Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions packages/web/app/admin/content-studio/AssetPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,11 @@ import type {
AssetTargetFormat,
ContentPacket,
ContentVariant,
ResearchRun,
} from "@/lib/content-studio";

type Props = {
packet: ContentPacket | null;
researchRun: ResearchRun | null;
variants: ContentVariant[];
useResearchInCopy: boolean;
};

const ALL_FORMATS: AssetTargetFormat[] = [
Expand Down Expand Up @@ -60,12 +57,7 @@ function copyText(text: string) {
}
}

export function AssetPanel({
packet,
researchRun,
variants,
useResearchInCopy,
}: Props) {
export function AssetPanel({ packet, variants }: Props) {
const [selected, setSelected] = useState<AssetTargetFormat[]>([
"instagram_feed",
]);
Expand All @@ -74,7 +66,7 @@ export function AssetPanel({
const [error, setError] = useState<string | null>(null);
const [state, setState] = useState<"idle" | "running" | "error">("idle");
const [imageState, setImageState] = useState<"idle" | "running" | "error">(
"idle"
"idle",
);
const [imageError, setImageError] = useState<string | null>(null);
const [useReferenceImages, setUseReferenceImages] = useState(true);
Expand All @@ -87,7 +79,7 @@ export function AssetPanel({
setSelected((current) =>
current.includes(format)
? current.filter((item) => item !== format)
: [...current, format]
: [...current, format],
);
}

Expand All @@ -101,10 +93,8 @@ export function AssetPanel({
warning: string | null;
}>("/api/v1/content/assets/plan", {
packet,
researchRun: researchRun ?? undefined,
variants,
assetTypes: selected,
useResearchInCopy,
embedHeadline,
});
setPlan(data.plan);
Expand Down Expand Up @@ -135,7 +125,7 @@ export function AssetPanel({
setImageState("idle");
} catch (err) {
setImageError(
err instanceof Error ? err.message : "Image generation failed"
err instanceof Error ? err.message : "Image generation failed",
);
setImageState("error");
}
Expand Down Expand Up @@ -325,7 +315,7 @@ export function AssetPanel({
copyText(
[overlay.headline, overlay.subheadline]
.filter(Boolean)
.join("\n")
.join("\n"),
)
}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
Expand Down
210 changes: 210 additions & 0 deletions packages/web/app/admin/content-studio/PostPickerModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, Search, X } from "lucide-react";

type PostItem = {
id: string;
image_url: string;
title: string | null;
artist_name: string | null;
group_name: string | null;
context: string | null;
created_at: string;
view_count: number;
};

type PostSearchResponse = {
items: PostItem[];
nextCursor: string | null;
};

export function PostPickerModal({
open,
onClose,
onSelect,
}: {
open: boolean;
onClose: () => void;
onSelect: (postId: string) => void;
}) {
const [query, setQuery] = useState("");
const [posts, setPosts] = useState<PostItem[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [initialLoad, setInitialLoad] = useState(true);
const sentinelRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const abortRef = useRef<AbortController>(undefined);

const fetchPosts = useCallback(
async (searchQuery: string, pageCursor: string | null, append: boolean) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;

setLoading(true);
try {
const params = new URLSearchParams();
if (searchQuery) params.set("q", searchQuery);
if (pageCursor) params.set("cursor", pageCursor);

const response = await fetch(
`/api/v1/content/posts/search?${params.toString()}`,
{ credentials: "include", signal: controller.signal }
);

if (!response.ok) return;

const data: PostSearchResponse = await response.json();

if (append) {
setPosts((prev) => [...prev, ...data.items]);
} else {
setPosts(data.items);
}
setCursor(data.nextCursor);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
} finally {
setLoading(false);
setInitialLoad(false);
}
},
[]
);

useEffect(() => {
if (!open) return;
setInitialLoad(true);
fetchPosts("", null, false);
}, [open, fetchPosts]);

useEffect(() => {
if (!open) return;
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setCursor(null);
fetchPosts(query, null, false);
}, 300);
return () => clearTimeout(debounceRef.current);
}, [query, open, fetchPosts]);

useEffect(() => {
if (!open || !cursor) return;
const sentinel = sentinelRef.current;
if (!sentinel) return;

const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && cursor && !loading) {
fetchPosts(query, cursor, true);
}
},
{ rootMargin: "200px" }
);

observer.observe(sentinel);
return () => observer.disconnect();
}, [open, cursor, loading, query, fetchPosts]);

useEffect(() => {
if (!open) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);

if (!open) return null;

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="relative flex h-[85vh] w-full max-w-4xl flex-col rounded-xl border border-border bg-card shadow-2xl">
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-lg font-semibold text-foreground">포스트 선택</h2>
<button
type="button"
onClick={onClose}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>

<div className="border-b border-border px-5 py-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="아티스트, 그룹, 제목으로 검색..."
className="h-10 w-full rounded-md border border-input bg-background pl-10 pr-3 text-sm text-foreground outline-none focus:border-foreground"
autoFocus
/>
</div>
</div>

<div className="flex-1 overflow-y-auto p-5">
{initialLoad ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : posts.length === 0 ? (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
{query ? "검색 결과가 없습니다" : "포스트가 없습니다"}
</div>
) : (
<>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
{posts.map((post) => (
<button
key={post.id}
type="button"
onClick={() => {
onSelect(post.id);
onClose();
}}
className="group relative overflow-hidden rounded-lg border border-border bg-muted transition-all hover:border-foreground hover:ring-2 hover:ring-foreground/20"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={post.image_url}
alt={post.title ?? post.artist_name ?? "post"}
className="aspect-[3/4] w-full object-cover"
loading="lazy"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-2 pt-6">
{post.artist_name && (
<p className="truncate text-xs font-semibold text-white">
{post.artist_name}
</p>
)}
{post.title && (
<p className="truncate text-[11px] text-white/80">
{post.title}
</p>
)}
</div>
</button>
))}
</div>

<div ref={sentinelRef} className="flex justify-center py-4">
{loading && (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
)}
</div>
</>
)}
</div>
</div>
</div>
);
}
Loading
Loading