Merge pull request #64 from pennyteenycat/develop

add text soulmate
This commit is contained in:
pennyteenycat 2025-10-13 18:47:49 +02:00 committed by GitHub
commit 70e79962c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 264 additions and 14 deletions

View File

@ -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}
/>
);
}

View File

@ -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;
}

View File

@ -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>
);

View 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;
}
}

View 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>
);
}

View File

@ -0,0 +1 @@
export { default } from "./MarkdownText";

View File

@ -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";

View File

@ -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(),

View File

@ -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 };
}