commit
70e79962c9
@ -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<DashboardData>(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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{/* Portrait Image */}
|
||||
{/* Portrait Image and Description */}
|
||||
<div className={styles.imageWrapper}>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.imageInner}>
|
||||
@ -93,6 +94,13 @@ export default function PortraitView({ title, imageUrl }: PortraitViewProps) {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Portrait Description (Markdown) */}
|
||||
{result && (
|
||||
<div className={styles.descriptionWrapper}>
|
||||
<MarkdownText content={result} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
88
src/components/ui/MarkdownText/MarkdownText.module.scss
Normal file
88
src/components/ui/MarkdownText/MarkdownText.module.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
126
src/components/ui/MarkdownText/MarkdownText.tsx
Normal file
126
src/components/ui/MarkdownText/MarkdownText.tsx
Normal file
@ -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(<br key={`br-${key++}`} />);
|
||||
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(
|
||||
<li key={`li-${key++}`} className={styles.listItem}>
|
||||
{parseInlineMarkdown(listMatch[1])}
|
||||
</li>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered lists (1. 2. etc)
|
||||
const orderedListMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (orderedListMatch) {
|
||||
elements.push(
|
||||
<li key={`oli-${key++}`} className={styles.listItem}>
|
||||
{parseInlineMarkdown(orderedListMatch[1])}
|
||||
</li>
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
elements.push(
|
||||
<p key={`p-${key++}`} className={styles.paragraph}>
|
||||
{parseInlineMarkdown(line)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<strong key={`bold-${key++}`} className={styles.bold}>
|
||||
{boldMatch[3]}
|
||||
</strong>
|
||||
);
|
||||
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(
|
||||
<em key={`italic-${key++}`} className={styles.italic}>
|
||||
{italicMatch[3]}
|
||||
</em>
|
||||
);
|
||||
remaining = remaining.substring(italicMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No more markdown, add remaining text
|
||||
parts.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.markdown} ${className || ""}`}>
|
||||
{parseMarkdown(content)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/components/ui/MarkdownText/index.ts
Normal file
1
src/components/ui/MarkdownText/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "./MarkdownText";
|
||||
@ -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";
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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<string | null>(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 };
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user