diff --git a/src/app/[locale]/(core)/portraits/[id]/page.tsx b/src/app/[locale]/(core)/portraits/[id]/page.tsx index 431d38a..28321a3 100644 --- a/src/app/[locale]/(core)/portraits/[id]/page.tsx +++ b/src/app/[locale]/(core)/portraits/[id]/page.tsx @@ -1,7 +1,12 @@ import { notFound } from "next/navigation"; import { PortraitView } from "@/components/domains/portraits"; -import { getDashboard } from "@/entities/dashboard/api"; +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; +import { DashboardData, DashboardSchema } from "@/entities/dashboard/types"; + +// Force dynamic to always get fresh data +export const dynamic = "force-dynamic"; export default async function PortraitPage({ params, @@ -10,8 +15,12 @@ export default async function PortraitPage({ }) { const { id } = await params; - // Get portrait data from dashboard - const dashboard = await getDashboard(); + // Get fresh dashboard data without cache + const dashboard = await http.get(API_ROUTES.dashboard(), { + cache: "no-store", + schema: DashboardSchema, + }); + const portrait = dashboard.partnerPortraits?.find(p => p._id === id); if (!portrait || portrait.status !== "done" || !portrait.imageUrl) { @@ -23,6 +32,7 @@ export default async function PortraitPage({ id={portrait._id} title={portrait.title} imageUrl={portrait.imageUrl} + result={portrait.result} /> ); } diff --git a/src/components/domains/portraits/PortraitView/PortraitView.module.scss b/src/components/domains/portraits/PortraitView/PortraitView.module.scss index f78901e..677f344 100644 --- a/src/components/domains/portraits/PortraitView/PortraitView.module.scss +++ b/src/components/domains/portraits/PortraitView/PortraitView.module.scss @@ -50,10 +50,11 @@ .imageWrapper { flex: 1; display: flex; - align-items: flex-start; - justify-content: center; - padding: 24px 24px 24px 24px; + flex-direction: column; + align-items: center; + padding: 24px; overflow-y: auto; + gap: 32px; } .imageContainer { @@ -106,3 +107,9 @@ display: block; } } + +.descriptionWrapper { + width: 100%; + max-width: 500px; + padding: 0; +} diff --git a/src/components/domains/portraits/PortraitView/PortraitView.tsx b/src/components/domains/portraits/PortraitView/PortraitView.tsx index 9731ba3..0d1e671 100644 --- a/src/components/domains/portraits/PortraitView/PortraitView.tsx +++ b/src/components/domains/portraits/PortraitView/PortraitView.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; -import { Icon, IconName, Typography } from "@/components/ui"; +import { Icon, IconName, MarkdownText, Typography } from "@/components/ui"; import styles from "./PortraitView.module.scss"; @@ -11,9 +11,10 @@ interface PortraitViewProps { id: string; title: string; imageUrl: string; + result?: string | null; } -export default function PortraitView({ title, imageUrl }: PortraitViewProps) { +export default function PortraitView({ title, imageUrl, result }: PortraitViewProps) { const router = useRouter(); const handleDownload = async () => { @@ -46,7 +47,7 @@ export default function PortraitView({ title, imageUrl }: PortraitViewProps) { - {/* Portrait Image */} + {/* Portrait Image and Description */}
@@ -93,6 +94,13 @@ export default function PortraitView({ title, imageUrl }: PortraitViewProps) {
+ + {/* Portrait Description (Markdown) */} + {result && ( +
+ +
+ )}
); diff --git a/src/components/ui/MarkdownText/MarkdownText.module.scss b/src/components/ui/MarkdownText/MarkdownText.module.scss new file mode 100644 index 0000000..277c033 --- /dev/null +++ b/src/components/ui/MarkdownText/MarkdownText.module.scss @@ -0,0 +1,88 @@ +.markdown { + line-height: 1.6; + color: var(--text-primary, #1a1a1a); + + // Headers + .h1 { + font-size: 28px; + font-weight: 600; + line-height: 1.3; + margin: 8px 0 6px; + + &:first-child { + margin-top: 0; + } + } + + .h2 { + font-size: 24px; + font-weight: 600; + line-height: 1.3; + margin: 8px 0 4px; + + &:first-child { + margin-top: 0; + } + } + + .h3 { + font-size: 20px; + font-weight: 600; + line-height: 1.4; + margin: 6px 0 4px; + + &:first-child { + margin-top: 0; + } + } + + .h4, + .h5, + .h6 { + font-size: 18px; + font-weight: 600; + line-height: 1.4; + margin: 6px 0 4px; + + &:first-child { + margin-top: 0; + } + } + + // Paragraph + .paragraph { + font-size: 16px; + font-weight: 400; + line-height: 1.6; + margin: 0 0 6px; + + &:last-child { + margin-bottom: 0; + } + } + + // Lists + .listItem { + font-size: 16px; + font-weight: 400; + line-height: 1.5; + margin: 1px 0 1px 24px; + list-style-position: outside; + } + + // Inline formatting + .bold { + font-weight: 600; + } + + .italic { + font-style: italic; + } + + // Line breaks + br { + display: block; + content: ""; + margin: 2px 0; + } +} diff --git a/src/components/ui/MarkdownText/MarkdownText.tsx b/src/components/ui/MarkdownText/MarkdownText.tsx new file mode 100644 index 0000000..c893a2e --- /dev/null +++ b/src/components/ui/MarkdownText/MarkdownText.tsx @@ -0,0 +1,126 @@ +"use client"; + +import React from "react"; + +import styles from "./MarkdownText.module.scss"; + +interface MarkdownTextProps { + content: string; + className?: string; +} + +export default function MarkdownText({ + content, + className, +}: MarkdownTextProps) { + // Simple markdown parser for basic formatting + const parseMarkdown = (text: string): React.ReactNode[] => { + const lines = text.split("\n"); + const elements: React.ReactNode[] = []; + let key = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Skip empty lines + if (line.trim() === "") { + elements.push(
); + continue; + } + + // Headers (# ## ###) + const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headerMatch) { + const level = headerMatch[1].length; + const text = headerMatch[2]; + const HeaderTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + elements.push( + React.createElement( + HeaderTag, + { key: `h${level}-${key++}`, className: styles[`h${level}`] }, + parseInlineMarkdown(text) + ) + ); + continue; + } + + // Unordered lists (- or *) + const listMatch = line.match(/^[\*\-]\s+(.+)$/); + if (listMatch) { + elements.push( +
  • + {parseInlineMarkdown(listMatch[1])} +
  • + ); + continue; + } + + // Ordered lists (1. 2. etc) + const orderedListMatch = line.match(/^\d+\.\s+(.+)$/); + if (orderedListMatch) { + elements.push( +
  • + {parseInlineMarkdown(orderedListMatch[1])} +
  • + ); + continue; + } + + // Regular paragraph + elements.push( +

    + {parseInlineMarkdown(line)} +

    + ); + } + + return elements; + }; + + // Parse inline markdown (bold, italic, links) + const parseInlineMarkdown = (text: string): React.ReactNode[] => { + const parts: React.ReactNode[] = []; + let remaining = text; + let key = 0; + + while (remaining.length > 0) { + // Bold (**text** or __text__) + const boldMatch = remaining.match(/^(.*?)(\*\*|__)(.*?)\2/); + if (boldMatch) { + if (boldMatch[1]) parts.push(boldMatch[1]); + parts.push( + + {boldMatch[3]} + + ); + remaining = remaining.substring(boldMatch[0].length); + continue; + } + + // Italic (*text* or _text_) + const italicMatch = remaining.match(/^(.*?)(\*|_)(.*?)\2/); + if (italicMatch) { + if (italicMatch[1]) parts.push(italicMatch[1]); + parts.push( + + {italicMatch[3]} + + ); + remaining = remaining.substring(italicMatch[0].length); + continue; + } + + // No more markdown, add remaining text + parts.push(remaining); + break; + } + + return parts; + }; + + return ( +
    + {parseMarkdown(content)} +
    + ); +} diff --git a/src/components/ui/MarkdownText/index.ts b/src/components/ui/MarkdownText/index.ts new file mode 100644 index 0000000..fece5cd --- /dev/null +++ b/src/components/ui/MarkdownText/index.ts @@ -0,0 +1 @@ +export { default } from "./MarkdownText"; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 98df716..498ac49 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -8,10 +8,8 @@ export { default as FullScreenBlurModal } from "./FullScreenBlurModal/FullScreen export { default as GPTAnimationText } from "./GPTAnimationText/GPTAnimationText"; export { default as Grid } from "./Grid/Grid"; export { default as Icon, IconName, type IconProps } from "./Icon/Icon"; -export { - default as IconLabel, - type IconLabelProps, -} from "./IconLabel/IconLabel"; +export { default as IconLabel, type IconLabelProps } from "./IconLabel/IconLabel"; +export { default as MarkdownText } from "./MarkdownText/MarkdownText"; export { default as MetaLabel } from "./MetaLabel/MetaLabel"; export { default as Modal, type ModalProps } from "./Modal/Modal"; export { default as ModalSheet } from "./ModalSheet/ModalSheet"; diff --git a/src/entities/dashboard/types.ts b/src/entities/dashboard/types.ts index fc2f42c..29a784e 100644 --- a/src/entities/dashboard/types.ts +++ b/src/entities/dashboard/types.ts @@ -54,6 +54,7 @@ export const PartnerPortraitSchema = z.object({ _id: z.string(), title: z.string(), status: z.enum(["queued", "processing", "done", "error"]), + result: z.string().nullable(), // Markdown text content imageUrl: z.string().url().nullable(), createdAt: z.string(), finishedAt: z.string().nullable(), diff --git a/src/hooks/generations/useGenerationStatus.ts b/src/hooks/generations/useGenerationStatus.ts index 5b2c766..615f04c 100644 --- a/src/hooks/generations/useGenerationStatus.ts +++ b/src/hooks/generations/useGenerationStatus.ts @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; interface GenerationStatus { @@ -11,6 +12,7 @@ interface GenerationStatus { export function useGenerationStatus(jobId: string, initialStatus: string) { const [status, setStatus] = useState(initialStatus); const [imageUrl, setImageUrl] = useState(null); + const router = useRouter(); useEffect(() => { // Don't poll if already done or error @@ -32,10 +34,19 @@ export function useGenerationStatus(jobId: string, initialStatus: string) { const data: GenerationStatus = await response.json(); + // Check if status changed to "done" + const wasProcessing = status === "processing" || status === "queued"; + const isDoneNow = data.status === "done"; + setStatus(data.status); if (data.status === "done" && data.imageUrl) { setImageUrl(data.imageUrl); + + // Refresh the page cache to update dashboard data + if (wasProcessing && isDoneNow) { + router.refresh(); + } } } catch { // Silently fail - don't break UI if polling fails @@ -50,7 +61,7 @@ export function useGenerationStatus(jobId: string, initialStatus: string) { const interval = setInterval(pollStatus, 5000); return () => clearInterval(interval); - }, [jobId, status]); + }, [jobId, status, router]); return { status, imageUrl }; }