Skip to content
Open
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
82 changes: 39 additions & 43 deletions app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { NextResponse } from "next/server"
import { rateLimitByIp, RateLimitError } from "@/lib/rate-limit";
import {
rateLimitByIp,
RateLimitError,
RateLimitUnavailableError,
} from "@/lib/rate-limit";
import { generateBioWithLLM, SupportedModel } from "@/lib/llm-provider";
import {
generateBioRequestSchema,
MAX_ABOUT_YOU_LENGTH,
normalizeGeneratedBio,
} from "@/lib/generation";

const LLM_MODEL = (process.env.NEXT_LLM_MODEL || "gpt-4o") as SupportedModel

/** Allowed platform values – prevents injection via platform field */
const ALLOWED_PLATFORMS = ["instagram", "twitter", "linkedin", "tiktok", "telegram", "youtube"] as const;
/** Allowed tone values – prevents injection via tone field */
const ALLOWED_TONES = ["professional", "friendly", "creative", "humorous"] as const;

const MAX_ABOUT_YOU_LENGTH = 2000;

/**
* Sanitizes user-provided text: truncates length, strips control chars, normalizes whitespace.
* Reduces prompt-injection and token-abuse risk.
Expand Down Expand Up @@ -51,74 +53,68 @@ const BIO_PROMPT_TEMPLATE = `تو یک متخصص نویسنده بیوگراف
- twitter: کوتاه و موجز، مناسب برای اظهارنظر
- linkedin: حرفه‌ای و تجاری
- telegram: ارتباطی و اطلاع‌رسانی
- tiktok: سرگرمی و خلاقیت
- youtube: محتوای ویدیویی و کانال

قوانین خروجی:
- محدودیت کاراکتر پلتفرم را رعایت کن و بیوگرافی را به زبان فارسی بنویس.
- فقط متن بیوگرافی را برگردان، بدون هیچ توضیح، عنوان یا متن اضافی.
- این کلمات را در بیوگرافی استفاده نکن: احسان، عین، عین الله، غفار.`;

export async function POST(request: Request) {
try {
await rateLimitByIp(request);
let body: unknown;

const body = await request.json();
const rawAbout = body?.aboutYou;
const platform = typeof body?.platform === "string" ? body.platform.toLowerCase().trim() : "";
const tone = typeof body?.tone === "string" ? body.tone.toLowerCase().trim() : "";
try {
body = await request.json();
const parsed = generateBioRequestSchema.safeParse(body);

if (!rawAbout || !platform || !tone) {
return NextResponse.json({ error: "لطفاً تمام فیلدهای مورد نیاز را پر کنید." }, { status: 400 });
if (!parsed.success) {
return NextResponse.json(
{ error: "لطفاً اطلاعات ورودی را به‌درستی تکمیل کنید." },
{ status: 400 },
);
}

if (!ALLOWED_PLATFORMS.includes(platform as (typeof ALLOWED_PLATFORMS)[number])) {
return NextResponse.json({ error: "پلتفرم انتخاب‌شده معتبر نیست." }, { status: 400 });
}
if (!ALLOWED_TONES.includes(tone as (typeof ALLOWED_TONES)[number])) {
return NextResponse.json({ error: "لحن انتخاب‌شده معتبر نیست." }, { status: 400 });
}
await rateLimitByIp(request);

const aboutYou = sanitizeUserInput(rawAbout);
if (!aboutYou) {
return NextResponse.json({ error: "محتوای «درباره من» معتبر نیست یا خالی است." }, { status: 400 });
}
const { aboutYou, platform, tone } = parsed.data;

const result = await generateBioWithLLM(LLM_MODEL, BIO_PROMPT_TEMPLATE, {
aboutYou,
platform,
tone,
})

console.log("Generated bio:", result);
const generatedBio = normalizeGeneratedBio(result.text, platform);

// Extract the generated bio from the result
const generatedBio = result.text

// Return the generated bio
return NextResponse.json({
bio: generatedBio,
model: LLM_MODEL,
})
return NextResponse.json(
{ bio: generatedBio, model: LLM_MODEL },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
console.error("Error in generate-bio API:", error)
console.error("Error in generate-bio API:", error instanceof Error ? error.name : "unknown");

if (error instanceof RateLimitError) {
return NextResponse.json(
{ error: error.message },
{ status: 429 }
{ status: 429, headers: { "Cache-Control": "no-store" } },
)
}

if (error instanceof RateLimitUnavailableError) {
return NextResponse.json(
{ error: "سرویس محدودسازی درخواست موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید." },
{ status: 503, headers: { "Cache-Control": "no-store" } },
)
}

try {
const body = await request.json()
const fallbackBio = generateFallbackBio(body.aboutYou, body.platform, body.tone)
const parsed = generateBioRequestSchema.parse(body);
const fallbackBio = generateFallbackBio(parsed.aboutYou, parsed.platform, parsed.tone);
return NextResponse.json(
{
bio: fallbackBio,
bio: normalizeGeneratedBio(fallbackBio, parsed.platform),
note: "تولید شده با سیستم پشتیبان به دلیل مشکل در ارتباط با هوش مصنوعی",
},
{ status: 200 }
{ status: 200, headers: { "Cache-Control": "no-store" } },
)
} catch (fallbackError) {
return NextResponse.json(
Expand Down
43 changes: 27 additions & 16 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export default function BioGenerator() {
const [generatedBio, setGeneratedBio] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const [charCount, setCharCount] = useState(0);
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [isCooldown, setIsCooldown] = useState(false);
Expand All @@ -41,10 +40,6 @@ export default function BioGenerator() {
}
}, []);

useEffect(() => {
setCharCount(aboutYou.length);
}, [aboutYou]);

useEffect(() => {
let interval: ReturnType<typeof setInterval>;
if (isCooldown && cooldownTimer > 0) {
Expand All @@ -57,6 +52,13 @@ export default function BioGenerator() {
return () => clearInterval(interval);
}, [isCooldown, cooldownTimer]);

useEffect(() => {
if (!copied) return;

const timeout = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timeout);
}, [copied]);

const getCurrentPlatform = () => {
return (
platforms.find((p) => p.value === platform) || {
Expand All @@ -67,14 +69,17 @@ export default function BioGenerator() {

const getCharLimitPercent = () => {
const currentLimit = getCurrentPlatform().limit;
return (charCount / currentLimit) * 100;
return (aboutYou.length / currentLimit) * 100;
};

const copyToClipboard = () => {
navigator.clipboard.writeText(generatedBio);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
sonnar("بایو کپی شد", { icon: "✂️" });
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(generatedBio);
setCopied(true);
sonnar("بایو کپی شد", { icon: "✂️" });
} catch {
sonnar.error("کپی کردن بایو ممکن نشد.");
}
};

const generateBio = async () => {
Expand Down Expand Up @@ -202,6 +207,7 @@ export default function BioGenerator() {
</div>
{platform && (
<span
aria-live="polite"
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
getCharLimitPercent() > 90
? "bg-destructive/10 text-destructive"
Expand All @@ -210,33 +216,38 @@ export default function BioGenerator() {
: "bg-emerald-500/10 text-emerald-600"
}`}
>
{charCount} / {getCurrentPlatform().limit}
{aboutYou.length} / {getCurrentPlatform().limit}
</span>
)}
</div>
<label htmlFor="about-you" className="sr-only">
درباره خودت یا پیجت
</label>
<textarea
id="about-you"
aria-describedby="about-you-help"
placeholder="ویژگی یا هرچیزی در مورد خودت یا پیجت بگو..."
className="w-full min-h-[90px] sm:min-h-[120px] resize-none rounded-xl px-3 sm:px-4 py-2.5 sm:py-3 text-sm bg-card border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/40 transition-all duration-200"
value={aboutYou}
onChange={(e) => setAboutYou(e.target.value)}
maxLength={platform ? getCurrentPlatform().limit : 150}
/>
<p className="text-xs text-muted-foreground mt-2">
<p id="about-you-help" className="text-xs text-muted-foreground mt-2">
هرچه جزئیات بیشتری بنویسی، بایوی بهتری دریافت می‌کنی
</p>
</section>

{/* Error */}
{error && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-destructive/5 border border-destructive/15">
<div role="alert" className="flex items-start gap-2 p-3 rounded-xl bg-destructive/5 border border-destructive/15">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
<p className="text-destructive text-sm">{error}</p>
</div>
)}

{/* Note */}
{note && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-amber-500/5 border border-amber-500/15">
<div role="status" className="flex items-start gap-2 p-3 rounded-xl bg-amber-500/5 border border-amber-500/15">
<Info className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
<p className="text-amber-700 text-sm">{note}</p>
</div>
Expand Down Expand Up @@ -268,7 +279,7 @@ export default function BioGenerator() {
</div>

{/* Right panel: Output */}
<div ref={outputRef} className="glass-surface rounded-2xl p-4 sm:p-6 lg:p-8">
<div ref={outputRef} aria-live="polite" aria-busy={isGenerating} className="glass-surface rounded-2xl p-4 sm:p-6 lg:p-8">
<OutputPanel
generatedBio={generatedBio}
platform={platform}
Expand Down
1 change: 0 additions & 1 deletion app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://bio.eindev.ir",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
Expand Down
3 changes: 3 additions & 0 deletions components/OutputPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default function OutputPanel({
{error}
</p>
<button
type="button"
onClick={onRegenerate}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all duration-200"
>
Expand Down Expand Up @@ -146,6 +147,7 @@ export default function OutputPanel({
{generatedBio && !isGenerating && !error && (
<div className="flex gap-3 pt-2 animate-in fade-in slide-in-from-bottom-1 duration-300 delay-200">
<button
type="button"
onClick={onCopy}
className={cn(
"flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-medium",
Expand All @@ -168,6 +170,7 @@ export default function OutputPanel({
)}
</button>
<button
type="button"
onClick={onRegenerate}
className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-medium border border-border bg-card text-foreground hover:bg-accent transition-all duration-200"
>
Expand Down
52 changes: 25 additions & 27 deletions components/PlatformSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Instagram, Linkedin, MessageCircle, Twitter } from "lucide-react";
import { cn } from "@/lib/utils";
import { PLATFORM_LIMITS, PLATFORMS } from "@/lib/generation";

export interface Platform {
value: string;
Expand All @@ -10,32 +11,26 @@ export interface Platform {
limit: number;
}

export const platforms: Platform[] = [
{
value: "instagram",
label: "اینستاگرام",
icon: <Instagram className="h-4 w-4" />,
limit: 150,
},
{
value: "twitter",
label: "توییتر/ایکس",
icon: <Twitter className="h-4 w-4" />,
limit: 160,
},
{
value: "linkedin",
label: "لینکدین",
icon: <Linkedin className="h-4 w-4" />,
limit: 220,
},
{
value: "telegram",
label: "تلگرام",
icon: <MessageCircle className="h-4 w-4" />,
limit: 70,
},
];
const platformLabels: Record<(typeof PLATFORMS)[number], string> = {
instagram: "اینستاگرام",
twitter: "توییتر/ایکس",
linkedin: "لینکدین",
telegram: "تلگرام",
};

const platformIcons: Record<(typeof PLATFORMS)[number], React.ReactNode> = {
instagram: <Instagram className="h-4 w-4" />,
twitter: <Twitter className="h-4 w-4" />,
linkedin: <Linkedin className="h-4 w-4" />,
telegram: <MessageCircle className="h-4 w-4" />,
};

export const platforms: Platform[] = PLATFORMS.map((value) => ({
value,
label: platformLabels[value],
icon: platformIcons[value],
limit: PLATFORM_LIMITS[value],
}));

interface PlatformSelectorProps {
selected: string;
Expand All @@ -47,13 +42,16 @@ export default function PlatformSelector({
onSelect,
}: PlatformSelectorProps) {
return (
<div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2">
<div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2" role="radiogroup" aria-label="شبکه اجتماعی">
{platforms.map((plat) => {
const isActive = selected === plat.value;
return (
<button
type="button"
key={plat.value}
onClick={() => onSelect(plat.value)}
role="radio"
aria-checked={isActive}
className={cn(
"relative flex items-center justify-center sm:justify-start gap-1.5 sm:gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-xl text-xs sm:text-sm font-medium",
"transition-all duration-200 ease-out cursor-pointer",
Expand Down
Loading