Spaces:
Sleeping
Sleeping
جيد الان بالنسبه للغه اريدها ان تشمل التطبيق كاملا واريد تعدد اللغات
Browse files- src/ai/flows/ai-generate-post-ideas.ts +22 -17
- src/app/layout.tsx +39 -18
- src/app/login/page.tsx +6 -7
- src/app/profile/page.tsx +29 -20
- src/components/ai-world/ai-world-feed.tsx +43 -47
- src/components/group-chat/group-chat.tsx +22 -21
- src/components/voice-chat/voice-chat.tsx +32 -45
- src/contexts/ai-world-context.tsx +20 -14
- src/lib/gemini-client.ts +6 -7
- src/lib/translations.ts +50 -2
src/ai/flows/ai-generate-post-ideas.ts
CHANGED
|
@@ -1,29 +1,33 @@
|
|
| 1 |
-
|
| 2 |
'use server';
|
| 3 |
|
| 4 |
import { safeGenerateContent } from '@/lib/gemini-client';
|
| 5 |
import type { GeneratePostIdeasInput, GeneratePostIdeasOutput } from './types';
|
| 6 |
|
| 7 |
-
const promptTemplate = (count: number, location: string, language: string, age?: number) => `You are a
|
| 8 |
-
|
| 9 |
-
- Location
|
| 10 |
-
- Age: ${age || '
|
| 11 |
-
- Language: ${language === 'en' ? 'English' : '
|
| 12 |
|
| 13 |
-
STRICT
|
| 14 |
-
1. PERSONALIZATION:
|
| 15 |
-
2.
|
| 16 |
-
3.
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
JSON Schema:
|
| 21 |
{
|
| 22 |
"posts": [
|
| 23 |
{
|
| 24 |
-
"content": "Post text in the local dialect/language",
|
| 25 |
"isMeme": boolean,
|
| 26 |
-
"imageQuery": "
|
| 27 |
"category": "funny | sad | deep | angry | inspirational | general",
|
| 28 |
"initialComments": ["Local dialect comment 1", "Local dialect comment 2"]
|
| 29 |
}
|
|
@@ -34,16 +38,17 @@ JSON Schema:
|
|
| 34 |
export async function generatePostIdeas(
|
| 35 |
input: GeneratePostIdeasInput & { age?: number }
|
| 36 |
): Promise<GeneratePostIdeasOutput> {
|
| 37 |
-
const location = input.location || '
|
| 38 |
const language = input.language || 'ar';
|
| 39 |
-
const count = 30; //
|
| 40 |
|
| 41 |
const prompt = promptTemplate(count, location, language, input.age);
|
| 42 |
const output = await safeGenerateContent(prompt);
|
| 43 |
|
| 44 |
if (!output || !Array.isArray(output.posts)) {
|
| 45 |
-
throw new Error("Invalid output format from AI");
|
| 46 |
}
|
| 47 |
|
|
|
|
| 48 |
return output as GeneratePostIdeasOutput;
|
| 49 |
}
|
|
|
|
|
|
|
| 1 |
'use server';
|
| 2 |
|
| 3 |
import { safeGenerateContent } from '@/lib/gemini-client';
|
| 4 |
import type { GeneratePostIdeasInput, GeneratePostIdeasOutput } from './types';
|
| 5 |
|
| 6 |
+
const promptTemplate = (count: number, location: string, language: string, age?: number) => `You are a world-class AI social media strategist using Gemini 2.5 Flash Lite.
|
| 7 |
+
Your task is to generate EXACTLY ${count} highly engaging and personalized social media posts for a user with the following profile:
|
| 8 |
+
- Location: ${location}
|
| 9 |
+
- User Age: ${age || 'Unknown'}
|
| 10 |
+
- Preferred Interface Language: ${language === 'en' ? 'English' : 'Arabic'}
|
| 11 |
|
| 12 |
+
STRICT STRATEGY GUIDELINES:
|
| 13 |
+
1. PERSONALIZATION: Tailor the tone and topics to someone aged ${age || 'any'} living in ${location}. Use relevant slang, trends, and interests for this demographic.
|
| 14 |
+
2. LOCAL CONTEXT: If the user is in an Arabic-speaking country, use the local dialect. If they are in a Western country, use the local culture and language. Do NOT default to Egypt unless the location is Egypt.
|
| 15 |
+
3. CONTENT SAFETY:
|
| 16 |
+
- STRICTLY NO sexual content, nudity, or alcohol/drugs.
|
| 17 |
+
- AVOID religious controversy or offensive political statements.
|
| 18 |
+
- RESPECT the cultural values of ${location}.
|
| 19 |
+
4. IMAGERY (imageQuery):
|
| 20 |
+
- The "imageQuery" MUST BE in English.
|
| 21 |
+
- CRITICAL: NO humans, people, faces, eyes, or body parts in the imagery. Focus on nature, abstract patterns, architecture, technology, or objects.
|
| 22 |
+
5. FORMAT: Return ONLY a valid JSON object. No markdown blocks.
|
| 23 |
|
| 24 |
JSON Schema:
|
| 25 |
{
|
| 26 |
"posts": [
|
| 27 |
{
|
| 28 |
+
"content": "Post text in the appropriate local dialect/language",
|
| 29 |
"isMeme": boolean,
|
| 30 |
+
"imageQuery": "Precise English search query (STRICTLY NO PEOPLE/HUMANS)",
|
| 31 |
"category": "funny | sad | deep | angry | inspirational | general",
|
| 32 |
"initialComments": ["Local dialect comment 1", "Local dialect comment 2"]
|
| 33 |
}
|
|
|
|
| 38 |
export async function generatePostIdeas(
|
| 39 |
input: GeneratePostIdeasInput & { age?: number }
|
| 40 |
): Promise<GeneratePostIdeasOutput> {
|
| 41 |
+
const location = input.location || 'Global';
|
| 42 |
const language = input.language || 'ar';
|
| 43 |
+
const count = 30; // Enforcing exactly 30 posts as requested
|
| 44 |
|
| 45 |
const prompt = promptTemplate(count, location, language, input.age);
|
| 46 |
const output = await safeGenerateContent(prompt);
|
| 47 |
|
| 48 |
if (!output || !Array.isArray(output.posts)) {
|
| 49 |
+
throw new Error("Invalid output format from AI model");
|
| 50 |
}
|
| 51 |
|
| 52 |
+
// Ensure we have exactly 30 if possible, or at least the generated ones
|
| 53 |
return output as GeneratePostIdeasOutput;
|
| 54 |
}
|
src/app/layout.tsx
CHANGED
|
@@ -1,21 +1,37 @@
|
|
| 1 |
-
|
| 2 |
"use client";
|
| 3 |
|
| 4 |
import { ThemeProvider } from '@/components/theme-provider';
|
| 5 |
import { Toaster } from '@/components/ui/toaster';
|
| 6 |
import { AIWorldProvider } from '@/contexts/ai-world-context';
|
| 7 |
import { AuthProvider } from '@/contexts/auth-context';
|
| 8 |
-
import { LanguageProvider } from '@/contexts/language-context';
|
| 9 |
import Script from 'next/script';
|
|
|
|
| 10 |
import './globals.css';
|
| 11 |
|
| 12 |
-
|
| 13 |
-
children,
|
| 14 |
-
}: Readonly<{
|
| 15 |
-
children: React.ReactNode;
|
| 16 |
-
}>) {
|
| 17 |
return (
|
| 18 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
<head>
|
| 20 |
<title>ProtoChat</title>
|
| 21 |
<meta name="description" content="An AI-powered chat application" />
|
|
@@ -27,16 +43,9 @@ export default function RootLayout({
|
|
| 27 |
/>
|
| 28 |
</head>
|
| 29 |
<body className="font-body antialiased">
|
| 30 |
-
<
|
| 31 |
-
<
|
| 32 |
-
|
| 33 |
-
<AIWorldProvider>
|
| 34 |
-
{children}
|
| 35 |
-
<Toaster />
|
| 36 |
-
</AIWorldProvider>
|
| 37 |
-
</AuthProvider>
|
| 38 |
-
</ThemeProvider>
|
| 39 |
-
</LanguageProvider>
|
| 40 |
<Script
|
| 41 |
src="https://accounts.google.com/gsi/client"
|
| 42 |
strategy="afterInteractive"
|
|
@@ -45,3 +54,15 @@ export default function RootLayout({
|
|
| 45 |
</html>
|
| 46 |
);
|
| 47 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { ThemeProvider } from '@/components/theme-provider';
|
| 4 |
import { Toaster } from '@/components/ui/toaster';
|
| 5 |
import { AIWorldProvider } from '@/contexts/ai-world-context';
|
| 6 |
import { AuthProvider } from '@/contexts/auth-context';
|
| 7 |
+
import { LanguageProvider, useLanguage } from '@/contexts/language-context';
|
| 8 |
import Script from 'next/script';
|
| 9 |
+
import { useEffect, useState } from 'react';
|
| 10 |
import './globals.css';
|
| 11 |
|
| 12 |
+
function Providers({ children }: { children: React.ReactNode }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
return (
|
| 14 |
+
<ThemeProvider>
|
| 15 |
+
<AuthProvider>
|
| 16 |
+
<AIWorldProvider>
|
| 17 |
+
{children}
|
| 18 |
+
<Toaster />
|
| 19 |
+
</AIWorldProvider>
|
| 20 |
+
</AuthProvider>
|
| 21 |
+
</ThemeProvider>
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function LayoutContent({ children }: { children: React.ReactNode }) {
|
| 26 |
+
const { lang } = useLanguage();
|
| 27 |
+
const [mounted, setMounted] = useState(false);
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
setMounted(true);
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<html lang={lang} dir={lang === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
|
| 35 |
<head>
|
| 36 |
<title>ProtoChat</title>
|
| 37 |
<meta name="description" content="An AI-powered chat application" />
|
|
|
|
| 43 |
/>
|
| 44 |
</head>
|
| 45 |
<body className="font-body antialiased">
|
| 46 |
+
<Providers>
|
| 47 |
+
{mounted ? children : <div className="min-h-screen bg-background" />}
|
| 48 |
+
</Providers>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
<Script
|
| 50 |
src="https://accounts.google.com/gsi/client"
|
| 51 |
strategy="afterInteractive"
|
|
|
|
| 54 |
</html>
|
| 55 |
);
|
| 56 |
}
|
| 57 |
+
|
| 58 |
+
export default function RootLayout({
|
| 59 |
+
children,
|
| 60 |
+
}: Readonly<{
|
| 61 |
+
children: React.ReactNode;
|
| 62 |
+
}>) {
|
| 63 |
+
return (
|
| 64 |
+
<LanguageProvider>
|
| 65 |
+
<LayoutContent>{children}</LayoutContent>
|
| 66 |
+
</LanguageProvider>
|
| 67 |
+
);
|
| 68 |
+
}
|
src/app/login/page.tsx
CHANGED
|
@@ -89,8 +89,7 @@ export default function LoginPage() {
|
|
| 89 |
<Card className="w-full max-w-md">
|
| 90 |
<form onSubmit={handleCompleteProfile}>
|
| 91 |
<CardHeader>
|
| 92 |
-
<CardTitle>
|
| 93 |
-
<CardDescription>يرجى كتابة بلدك وعمرك للمتابعة</CardDescription>
|
| 94 |
</CardHeader>
|
| 95 |
<CardContent className="space-y-4">
|
| 96 |
<div className="space-y-2">
|
|
@@ -99,11 +98,11 @@ export default function LoginPage() {
|
|
| 99 |
</div>
|
| 100 |
<div className="space-y-2">
|
| 101 |
<Label>{t.location}</Label>
|
| 102 |
-
<Input value={location} onChange={e => setLocation(e.target.value)} placeholder=
|
| 103 |
</div>
|
| 104 |
</CardContent>
|
| 105 |
<CardFooter>
|
| 106 |
-
<Button type="submit" className="w-full" disabled={isSigningIn}>
|
| 107 |
</CardFooter>
|
| 108 |
</form>
|
| 109 |
</Card>
|
|
@@ -138,7 +137,7 @@ export default function LoginPage() {
|
|
| 138 |
</CardHeader>
|
| 139 |
<CardContent className="py-8">
|
| 140 |
<Button variant="outline" size="lg" className="w-full h-16 text-lg rounded-xl" onClick={signInWithGoogle} disabled={isSigningIn}>
|
| 141 |
-
<Chrome className="
|
| 142 |
<span>{t.googleAction}</span>
|
| 143 |
</Button>
|
| 144 |
</CardContent>
|
|
@@ -167,13 +166,13 @@ export default function LoginPage() {
|
|
| 167 |
</div>
|
| 168 |
<div className="space-y-2">
|
| 169 |
<Label>{t.location}</Label>
|
| 170 |
-
<Input value={location} onChange={e => setLocation(e.target.value)} placeholder=
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
</CardContent>
|
| 174 |
<CardFooter>
|
| 175 |
<Button type="submit" className="w-full h-12" disabled={isSigningIn}>
|
| 176 |
-
{isSigningIn ? <Loader2 className="animate-spin" /> : <LogIn className="
|
| 177 |
{t.loginAction}
|
| 178 |
</Button>
|
| 179 |
</CardFooter>
|
|
|
|
| 89 |
<Card className="w-full max-w-md">
|
| 90 |
<form onSubmit={handleCompleteProfile}>
|
| 91 |
<CardHeader>
|
| 92 |
+
<CardTitle>{t.completeProfile}</CardTitle>
|
|
|
|
| 93 |
</CardHeader>
|
| 94 |
<CardContent className="space-y-4">
|
| 95 |
<div className="space-y-2">
|
|
|
|
| 98 |
</div>
|
| 99 |
<div className="space-y-2">
|
| 100 |
<Label>{t.location}</Label>
|
| 101 |
+
<Input value={location} onChange={e => setLocation(e.target.value)} placeholder={t.location} required />
|
| 102 |
</div>
|
| 103 |
</CardContent>
|
| 104 |
<CardFooter>
|
| 105 |
+
<Button type="submit" className="w-full" disabled={isSigningIn}>{t.loginAction}</Button>
|
| 106 |
</CardFooter>
|
| 107 |
</form>
|
| 108 |
</Card>
|
|
|
|
| 137 |
</CardHeader>
|
| 138 |
<CardContent className="py-8">
|
| 139 |
<Button variant="outline" size="lg" className="w-full h-16 text-lg rounded-xl" onClick={signInWithGoogle} disabled={isSigningIn}>
|
| 140 |
+
<Chrome className="mx-3 h-6 w-6 text-primary" />
|
| 141 |
<span>{t.googleAction}</span>
|
| 142 |
</Button>
|
| 143 |
</CardContent>
|
|
|
|
| 166 |
</div>
|
| 167 |
<div className="space-y-2">
|
| 168 |
<Label>{t.location}</Label>
|
| 169 |
+
<Input value={location} onChange={e => setLocation(e.target.value)} placeholder={t.location} required />
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
</CardContent>
|
| 173 |
<CardFooter>
|
| 174 |
<Button type="submit" className="w-full h-12" disabled={isSigningIn}>
|
| 175 |
+
{isSigningIn ? <Loader2 className="animate-spin" /> : <LogIn className="mx-2" />}
|
| 176 |
{t.loginAction}
|
| 177 |
</Button>
|
| 178 |
</CardFooter>
|
src/app/profile/page.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import { useState, useEffect } from 'react';
|
|
@@ -11,6 +12,7 @@ import { Input } from '@/components/ui/input';
|
|
| 11 |
import { Label } from '@/components/ui/label';
|
| 12 |
import { Loader2, Trash2, AlertTriangle } from 'lucide-react';
|
| 13 |
import { useToast } from '@/hooks/use-toast';
|
|
|
|
| 14 |
import {
|
| 15 |
AlertDialog,
|
| 16 |
AlertDialogAction,
|
|
@@ -25,6 +27,7 @@ import {
|
|
| 25 |
|
| 26 |
export default function ProfilePage() {
|
| 27 |
const { user, userData, loading, updateUserProfile, deleteUserAccount } = useAuth();
|
|
|
|
| 28 |
const router = useRouter();
|
| 29 |
const { toast } = useToast();
|
| 30 |
|
|
@@ -50,7 +53,7 @@ export default function ProfilePage() {
|
|
| 50 |
const handleSave = async () => {
|
| 51 |
if (!user) return;
|
| 52 |
if (!displayName.trim()) {
|
| 53 |
-
toast({ title: '
|
| 54 |
return;
|
| 55 |
}
|
| 56 |
setIsSaving(true);
|
|
@@ -64,7 +67,14 @@ export default function ProfilePage() {
|
|
| 64 |
|
| 65 |
const handleDeleteAccount = async () => {
|
| 66 |
setIsDeleting(true);
|
| 67 |
-
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
};
|
| 69 |
|
| 70 |
if (!user || loading) {
|
|
@@ -77,13 +87,12 @@ export default function ProfilePage() {
|
|
| 77 |
|
| 78 |
return (
|
| 79 |
<div className="flex h-screen w-full flex-col bg-background">
|
| 80 |
-
<Header title=
|
| 81 |
<main className="flex-1 overflow-y-auto bg-muted/40 p-4 md:p-8">
|
| 82 |
<div className="mx-auto max-w-2xl space-y-6">
|
| 83 |
<Card className="border-border/50 shadow-sm">
|
| 84 |
<CardHeader>
|
| 85 |
-
<CardTitle>
|
| 86 |
-
<CardDescription>قم بتحديث معلومات ملفك الشخصي هنا.</CardDescription>
|
| 87 |
</CardHeader>
|
| 88 |
<CardContent className="space-y-6">
|
| 89 |
<div className="flex items-center gap-4">
|
|
@@ -95,21 +104,21 @@ export default function ProfilePage() {
|
|
| 95 |
</Avatar>
|
| 96 |
<div>
|
| 97 |
<h3 className="text-xl font-bold">{user.displayName}</h3>
|
| 98 |
-
<p className="text-sm text-muted-foreground">{user.email || '
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
<div className="space-y-4">
|
| 102 |
<div className="space-y-2">
|
| 103 |
-
<Label htmlFor="displayName">
|
| 104 |
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
| 105 |
</div>
|
| 106 |
<div className="grid grid-cols-2 gap-4">
|
| 107 |
<div className="space-y-2">
|
| 108 |
-
<Label htmlFor="age">
|
| 109 |
<Input id="age" type="number" value={age} onChange={(e) => setAge(e.target.value)} />
|
| 110 |
</div>
|
| 111 |
<div className="space-y-2">
|
| 112 |
-
<Label htmlFor="location">
|
| 113 |
<Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} />
|
| 114 |
</div>
|
| 115 |
</div>
|
|
@@ -117,8 +126,8 @@ export default function ProfilePage() {
|
|
| 117 |
</CardContent>
|
| 118 |
<CardFooter className="border-t px-6 py-4 bg-muted/20">
|
| 119 |
<Button onClick={handleSave} disabled={isSaving} className="rounded-xl px-8">
|
| 120 |
-
{isSaving && <Loader2 className="
|
| 121 |
-
|
| 122 |
</Button>
|
| 123 |
</CardFooter>
|
| 124 |
</Card>
|
|
@@ -127,30 +136,30 @@ export default function ProfilePage() {
|
|
| 127 |
<CardHeader className="bg-destructive/5">
|
| 128 |
<div className="flex items-center gap-2 text-destructive">
|
| 129 |
<AlertTriangle className="h-5 w-5" />
|
| 130 |
-
<CardTitle className="text-lg">
|
| 131 |
</div>
|
| 132 |
</CardHeader>
|
| 133 |
<CardContent className="pt-6">
|
| 134 |
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
| 135 |
<div className="space-y-1">
|
| 136 |
-
<h4 className="font-bold">
|
| 137 |
-
<p className="text-sm text-muted-foreground">
|
| 138 |
</div>
|
| 139 |
<AlertDialog>
|
| 140 |
<AlertDialogTrigger asChild>
|
| 141 |
<Button variant="destructive" className="rounded-xl flex items-center gap-2">
|
| 142 |
-
<Trash2 className="h-4 w-4" />
|
| 143 |
</Button>
|
| 144 |
</AlertDialogTrigger>
|
| 145 |
<AlertDialogContent className="rounded-2xl">
|
| 146 |
<AlertDialogHeader>
|
| 147 |
-
<AlertDialogTitle>
|
| 148 |
-
<AlertDialogDescription>
|
| 149 |
</AlertDialogHeader>
|
| 150 |
<AlertDialogFooter>
|
| 151 |
-
<AlertDialogCancel>
|
| 152 |
<AlertDialogAction onClick={handleDeleteAccount} className="bg-destructive hover:bg-destructive/90" disabled={isDeleting}>
|
| 153 |
-
|
| 154 |
</AlertDialogAction>
|
| 155 |
</AlertDialogFooter>
|
| 156 |
</AlertDialogContent>
|
|
@@ -162,4 +171,4 @@ export default function ProfilePage() {
|
|
| 162 |
</main>
|
| 163 |
</div>
|
| 164 |
);
|
| 165 |
-
}
|
|
|
|
| 1 |
+
|
| 2 |
'use client';
|
| 3 |
|
| 4 |
import { useState, useEffect } from 'react';
|
|
|
|
| 12 |
import { Label } from '@/components/ui/label';
|
| 13 |
import { Loader2, Trash2, AlertTriangle } from 'lucide-react';
|
| 14 |
import { useToast } from '@/hooks/use-toast';
|
| 15 |
+
import { useLanguage } from '@/contexts/language-context';
|
| 16 |
import {
|
| 17 |
AlertDialog,
|
| 18 |
AlertDialogAction,
|
|
|
|
| 27 |
|
| 28 |
export default function ProfilePage() {
|
| 29 |
const { user, userData, loading, updateUserProfile, deleteUserAccount } = useAuth();
|
| 30 |
+
const { t } = useLanguage();
|
| 31 |
const router = useRouter();
|
| 32 |
const { toast } = useToast();
|
| 33 |
|
|
|
|
| 53 |
const handleSave = async () => {
|
| 54 |
if (!user) return;
|
| 55 |
if (!displayName.trim()) {
|
| 56 |
+
toast({ title: 'Error', description: 'Display name cannot be empty.', variant: 'destructive' });
|
| 57 |
return;
|
| 58 |
}
|
| 59 |
setIsSaving(true);
|
|
|
|
| 67 |
|
| 68 |
const handleDeleteAccount = async () => {
|
| 69 |
setIsDeleting(true);
|
| 70 |
+
try {
|
| 71 |
+
await deleteUserAccount();
|
| 72 |
+
toast({ title: t.accountDeleted });
|
| 73 |
+
} catch (error) {
|
| 74 |
+
console.error(error);
|
| 75 |
+
} finally {
|
| 76 |
+
setIsDeleting(false);
|
| 77 |
+
}
|
| 78 |
};
|
| 79 |
|
| 80 |
if (!user || loading) {
|
|
|
|
| 87 |
|
| 88 |
return (
|
| 89 |
<div className="flex h-screen w-full flex-col bg-background">
|
| 90 |
+
<Header title={t.profileTitle} />
|
| 91 |
<main className="flex-1 overflow-y-auto bg-muted/40 p-4 md:p-8">
|
| 92 |
<div className="mx-auto max-w-2xl space-y-6">
|
| 93 |
<Card className="border-border/50 shadow-sm">
|
| 94 |
<CardHeader>
|
| 95 |
+
<CardTitle>{t.settings}</CardTitle>
|
|
|
|
| 96 |
</CardHeader>
|
| 97 |
<CardContent className="space-y-6">
|
| 98 |
<div className="flex items-center gap-4">
|
|
|
|
| 104 |
</Avatar>
|
| 105 |
<div>
|
| 106 |
<h3 className="text-xl font-bold">{user.displayName}</h3>
|
| 107 |
+
<p className="text-sm text-muted-foreground">{user.email || 'Manual Account'}</p>
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
<div className="space-y-4">
|
| 111 |
<div className="space-y-2">
|
| 112 |
+
<Label htmlFor="displayName">{t.username}</Label>
|
| 113 |
<Input id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
| 114 |
</div>
|
| 115 |
<div className="grid grid-cols-2 gap-4">
|
| 116 |
<div className="space-y-2">
|
| 117 |
+
<Label htmlFor="age">{t.age}</Label>
|
| 118 |
<Input id="age" type="number" value={age} onChange={(e) => setAge(e.target.value)} />
|
| 119 |
</div>
|
| 120 |
<div className="space-y-2">
|
| 121 |
+
<Label htmlFor="location">{t.location}</Label>
|
| 122 |
<Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} />
|
| 123 |
</div>
|
| 124 |
</div>
|
|
|
|
| 126 |
</CardContent>
|
| 127 |
<CardFooter className="border-t px-6 py-4 bg-muted/20">
|
| 128 |
<Button onClick={handleSave} disabled={isSaving} className="rounded-xl px-8">
|
| 129 |
+
{isSaving && <Loader2 className="mx-2 h-4 w-4 animate-spin" />}
|
| 130 |
+
{t.saveChanges}
|
| 131 |
</Button>
|
| 132 |
</CardFooter>
|
| 133 |
</Card>
|
|
|
|
| 136 |
<CardHeader className="bg-destructive/5">
|
| 137 |
<div className="flex items-center gap-2 text-destructive">
|
| 138 |
<AlertTriangle className="h-5 w-5" />
|
| 139 |
+
<CardTitle className="text-lg">{t.dangerZone}</CardTitle>
|
| 140 |
</div>
|
| 141 |
</CardHeader>
|
| 142 |
<CardContent className="pt-6">
|
| 143 |
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
| 144 |
<div className="space-y-1">
|
| 145 |
+
<h4 className="font-bold">{t.deleteAccount}</h4>
|
| 146 |
+
<p className="text-sm text-muted-foreground">{t.deleteConfirmDesc}</p>
|
| 147 |
</div>
|
| 148 |
<AlertDialog>
|
| 149 |
<AlertDialogTrigger asChild>
|
| 150 |
<Button variant="destructive" className="rounded-xl flex items-center gap-2">
|
| 151 |
+
<Trash2 className="h-4 w-4" /> {t.deleteAccount}
|
| 152 |
</Button>
|
| 153 |
</AlertDialogTrigger>
|
| 154 |
<AlertDialogContent className="rounded-2xl">
|
| 155 |
<AlertDialogHeader>
|
| 156 |
+
<AlertDialogTitle>{t.deleteConfirmTitle}</AlertDialogTitle>
|
| 157 |
+
<AlertDialogDescription>{t.deleteConfirmDesc}</AlertDialogDescription>
|
| 158 |
</AlertDialogHeader>
|
| 159 |
<AlertDialogFooter>
|
| 160 |
+
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
|
| 161 |
<AlertDialogAction onClick={handleDeleteAccount} className="bg-destructive hover:bg-destructive/90" disabled={isDeleting}>
|
| 162 |
+
{t.confirmDelete}
|
| 163 |
</AlertDialogAction>
|
| 164 |
</AlertDialogFooter>
|
| 165 |
</AlertDialogContent>
|
|
|
|
| 171 |
</main>
|
| 172 |
</div>
|
| 173 |
);
|
| 174 |
+
}
|
src/components/ai-world/ai-world-feed.tsx
CHANGED
|
@@ -13,20 +13,22 @@ import { useToast } from '@/hooks/use-toast';
|
|
| 13 |
import type { AIComment, AIUser, PostWithUIState } from '@/lib/types';
|
| 14 |
import { AIWorldContext } from '@/contexts/ai-world-context';
|
| 15 |
import { formatDistanceToNow } from 'date-fns';
|
| 16 |
-
import { ar } from 'date-fns/locale';
|
| 17 |
import { checkConsumption } from '@/lib/consumption';
|
| 18 |
import { useAuth } from '@/contexts/auth-context';
|
| 19 |
import type { User } from 'firebase/auth';
|
| 20 |
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu';
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
const AiPostCard = React.memo(({ post, onReact, onAddComment, onReport, onToggleComments, currentUser }: {
|
| 24 |
post: PostWithUIState,
|
| 25 |
onReact: (postId: string, reaction: string) => void,
|
| 26 |
onAddComment: (postId: string, commentText: string) => void,
|
| 27 |
onReport: (postId: string) => void,
|
| 28 |
onToggleComments: (postId: string) => void,
|
| 29 |
currentUser: User | null;
|
|
|
|
|
|
|
| 30 |
}) => {
|
| 31 |
const [commentText, setCommentText] = useState("");
|
| 32 |
const [formattedDate, setFormattedDate] = useState('');
|
|
@@ -48,10 +50,10 @@ const AiPostCard = React.memo(({ post, onReact, onAddComment, onReport, onToggle
|
|
| 48 |
if (post.createdAt) {
|
| 49 |
setFormattedDate(formatDistanceToNow(new Date(post.createdAt), {
|
| 50 |
addSuffix: true,
|
| 51 |
-
locale: ar,
|
| 52 |
}));
|
| 53 |
}
|
| 54 |
-
}, [post.createdAt]);
|
| 55 |
|
| 56 |
const handleCommentSubmit = () => {
|
| 57 |
if (commentText.trim()) {
|
|
@@ -99,8 +101,8 @@ const AiPostCard = React.memo(({ post, onReact, onAddComment, onReport, onToggle
|
|
| 99 |
</DropdownMenuTrigger>
|
| 100 |
<DropdownMenuContent align="end">
|
| 101 |
<DropdownMenuItem onClick={() => onReport(post.id)}>
|
| 102 |
-
<Flag className="
|
| 103 |
-
<span>
|
| 104 |
</DropdownMenuItem>
|
| 105 |
</DropdownMenuContent>
|
| 106 |
</DropdownMenu>
|
|
@@ -143,7 +145,7 @@ const AiPostCard = React.memo(({ post, onReact, onAddComment, onReport, onToggle
|
|
| 143 |
</div>
|
| 144 |
}
|
| 145 |
{ totalComments > 0 &&
|
| 146 |
-
<span className='hover:underline cursor-pointer' onClick={() => onToggleComments(post.id)}>{totalComments}
|
| 147 |
}
|
| 148 |
</div>
|
| 149 |
}
|
|
@@ -151,15 +153,15 @@ const AiPostCard = React.memo(({ post, onReact, onAddComment, onReport, onToggle
|
|
| 151 |
<div className="flex justify-around border-y">
|
| 152 |
<Button variant="ghost" className={cn("w-full flex items-center gap-2", isLiked && "text-primary font-semibold")} onClick={() => onReact(post.id, 'like')}>
|
| 153 |
<ThumbsUp />
|
| 154 |
-
<span>
|
| 155 |
</Button>
|
| 156 |
<Button variant="ghost" className="w-full flex items-center gap-2" onClick={handleCommentActionClick}>
|
| 157 |
<MessageCircle />
|
| 158 |
-
<span>
|
| 159 |
</Button>
|
| 160 |
<Button variant="ghost" className="w-full flex items-center gap-2">
|
| 161 |
<Share2 />
|
| 162 |
-
<span>
|
| 163 |
</Button>
|
| 164 |
</div>
|
| 165 |
|
|
@@ -181,16 +183,16 @@ const AiPostCard = React.memo(({ post, onReact, onAddComment, onReport, onToggle
|
|
| 181 |
{post.uiState.isGeneratingComment && (
|
| 182 |
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
| 183 |
<Loader2 className="h-4 w-4 animate-spin"/>
|
| 184 |
-
<span>
|
| 185 |
</div>
|
| 186 |
)}
|
| 187 |
{currentUser && (
|
| 188 |
<div className="flex items-center gap-2 pt-2">
|
| 189 |
-
<Image src={currentUser?.photoURL || `https://picsum.photos/seed/${currentUser?.uid || 'default'}/32/32`} alt={currentUser?.displayName || '
|
| 190 |
<div className="relative w-full">
|
| 191 |
<Input
|
| 192 |
ref={commentInputRef}
|
| 193 |
-
placeholder=
|
| 194 |
value={commentText}
|
| 195 |
onChange={(e) => setCommentText(e.target.value)}
|
| 196 |
onKeyDown={(e) => e.key === 'Enter' && handleCommentSubmit()}
|
|
@@ -235,11 +237,12 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 235 |
} = useContext(AIWorldContext);
|
| 236 |
|
| 237 |
const { user } = useAuth();
|
|
|
|
| 238 |
const observer = useRef<IntersectionObserver | null>(null);
|
| 239 |
const [consumption, setConsumption] = useState({ used: 0, remaining: 0, limit: 200 });
|
| 240 |
const [searchQuery, setSearchQuery] = useState('');
|
| 241 |
const [isSearchActive, setIsSearchActive] = useState(false);
|
| 242 |
-
const userName = user?.displayName?.split(' ')[0] || '
|
| 243 |
|
| 244 |
useEffect(() => {
|
| 245 |
const { remaining } = checkConsumption();
|
|
@@ -309,7 +312,7 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 309 |
}, [toggleCommentsVisibility]);
|
| 310 |
|
| 311 |
const sortedMainFeed = [...allPosts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
| 312 |
-
const sortedSearchResults = [...searchedPosts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
| 313 |
|
| 314 |
const postsToDisplay = isSearchActive ? sortedSearchResults : sortedMainFeed;
|
| 315 |
|
|
@@ -321,12 +324,12 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 321 |
<ArrowLeft />
|
| 322 |
</Button>
|
| 323 |
)}
|
| 324 |
-
<h1 className="text-xl font-bold text-primary">
|
| 325 |
<div className="flex items-center gap-2 ml-auto">
|
| 326 |
<div className="relative flex-1 max-w-xs">
|
| 327 |
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
| 328 |
<Input
|
| 329 |
-
placeholder=
|
| 330 |
value={searchQuery}
|
| 331 |
onChange={(e) => setSearchQuery(e.target.value)}
|
| 332 |
onKeyDown={(e) => {
|
|
@@ -350,9 +353,9 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 350 |
</PopoverTrigger>
|
| 351 |
<PopoverContent className="w-auto text-sm mr-4">
|
| 352 |
<div className="space-y-2">
|
| 353 |
-
<p className="font-bold text-center">
|
| 354 |
-
<p>
|
| 355 |
-
<p>
|
| 356 |
</div>
|
| 357 |
</PopoverContent>
|
| 358 |
</Popover>
|
|
@@ -363,10 +366,10 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 363 |
{user && !isSearchActive && (
|
| 364 |
<div className="bg-card p-3 rounded-lg border shadow-sm">
|
| 365 |
<div className="flex items-center gap-3">
|
| 366 |
-
<Image src={user?.photoURL || `https://picsum.photos/seed/${user?.uid || 'default'}/40/40`} alt={user?.displayName || '
|
| 367 |
<div className="w-full">
|
| 368 |
<Input
|
| 369 |
-
placeholder={
|
| 370 |
onKeyDown={(e) => {
|
| 371 |
if (e.key === 'Enter' && e.currentTarget.value.trim()) {
|
| 372 |
createPostFromUserInput(e.currentTarget.value);
|
|
@@ -378,9 +381,9 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 378 |
</div>
|
| 379 |
</div>
|
| 380 |
<div className="border-t mt-3 pt-2 flex justify-around">
|
| 381 |
-
<Button variant="ghost" className="w-full flex items-center gap-2" onClick={() => generatePostsFromAI("
|
| 382 |
<Sparkles className="text-purple-500" />
|
| 383 |
-
<span>
|
| 384 |
</Button>
|
| 385 |
</div>
|
| 386 |
</div>
|
|
@@ -414,29 +417,24 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 414 |
onReport={handleReport}
|
| 415 |
onToggleComments={handleToggleComments}
|
| 416 |
currentUser={user}
|
|
|
|
|
|
|
| 417 |
/>
|
| 418 |
</div>
|
| 419 |
))
|
| 420 |
)}
|
| 421 |
-
{ isLoading
|
| 422 |
<div className="text-center text-muted-foreground py-4 space-y-4">
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
<
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
</div>
|
| 434 |
-
))}
|
| 435 |
-
</div>
|
| 436 |
-
)}
|
| 437 |
-
{!isLoading && !canLoadMore && !isSearchActive && sortedMainFeed.length > 0 && (
|
| 438 |
-
<div className="text-center text-muted-foreground p-4">
|
| 439 |
-
<p>وصلت إلى نهاية المنشورات.</p>
|
| 440 |
</div>
|
| 441 |
)}
|
| 442 |
{postsToDisplay.length === 0 && !isLoading && !isGeneratingFromQuery && !isSearching && (
|
|
@@ -444,10 +442,10 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 444 |
{isSearchActive ? (
|
| 445 |
<>
|
| 446 |
<Search className="h-12 w-12 text-muted-foreground/50" />
|
| 447 |
-
<p>
|
| 448 |
</>
|
| 449 |
) : (
|
| 450 |
-
<p>
|
| 451 |
)}
|
| 452 |
</div>
|
| 453 |
)}
|
|
@@ -456,5 +454,3 @@ export const AIWorldFeed: React.FC<AIWorldFeedProps> = ({ onBack }) => {
|
|
| 456 |
</div>
|
| 457 |
);
|
| 458 |
}
|
| 459 |
-
|
| 460 |
-
|
|
|
|
| 13 |
import type { AIComment, AIUser, PostWithUIState } from '@/lib/types';
|
| 14 |
import { AIWorldContext } from '@/contexts/ai-world-context';
|
| 15 |
import { formatDistanceToNow } from 'date-fns';
|
| 16 |
+
import { ar, enUS } from 'date-fns/locale';
|
| 17 |
import { checkConsumption } from '@/lib/consumption';
|
| 18 |
import { useAuth } from '@/contexts/auth-context';
|
| 19 |
import type { User } from 'firebase/auth';
|
| 20 |
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui/dropdown-menu';
|
| 21 |
+
import { useLanguage } from '@/contexts/language-context';
|
| 22 |
|
| 23 |
+
const AiPostCard = React.memo(({ post, onReact, onAddComment, onReport, onToggleComments, currentUser, t, lang }: {
|
|
|
|
| 24 |
post: PostWithUIState,
|
| 25 |
onReact: (postId: string, reaction: string) => void,
|
| 26 |
onAddComment: (postId: string, commentText: string) => void,
|
| 27 |
onReport: (postId: string) => void,
|
| 28 |
onToggleComments: (postId: string) => void,
|
| 29 |
currentUser: User | null;
|
| 30 |
+
t: any;
|
| 31 |
+
lang: string;
|
| 32 |
}) => {
|
| 33 |
const [commentText, setCommentText] = useState("");
|
| 34 |
const [formattedDate, setFormattedDate] = useState('');
|
|
|
|
| 50 |
if (post.createdAt) {
|
| 51 |
setFormattedDate(formatDistanceToNow(new Date(post.createdAt), {
|
| 52 |
addSuffix: true,
|
| 53 |
+
locale: lang === 'ar' ? ar : enUS,
|
| 54 |
}));
|
| 55 |
}
|
| 56 |
+
}, [post.createdAt, lang]);
|
| 57 |
|
| 58 |
const handleCommentSubmit = () => {
|
| 59 |
if (commentText.trim()) {
|
|
|
|
| 101 |
</DropdownMenuTrigger>
|
| 102 |
<DropdownMenuContent align="end">
|
| 103 |
<DropdownMenuItem onClick={() => onReport(post.id)}>
|
| 104 |
+
<Flag className="mx-2 h-4 w-4" />
|
| 105 |
+
<span>{t.report}</span>
|
| 106 |
</DropdownMenuItem>
|
| 107 |
</DropdownMenuContent>
|
| 108 |
</DropdownMenu>
|
|
|
|
| 145 |
</div>
|
| 146 |
}
|
| 147 |
{ totalComments > 0 &&
|
| 148 |
+
<span className='hover:underline cursor-pointer' onClick={() => onToggleComments(post.id)}>{totalComments} {t.comments}</span>
|
| 149 |
}
|
| 150 |
</div>
|
| 151 |
}
|
|
|
|
| 153 |
<div className="flex justify-around border-y">
|
| 154 |
<Button variant="ghost" className={cn("w-full flex items-center gap-2", isLiked && "text-primary font-semibold")} onClick={() => onReact(post.id, 'like')}>
|
| 155 |
<ThumbsUp />
|
| 156 |
+
<span>{t.like}</span>
|
| 157 |
</Button>
|
| 158 |
<Button variant="ghost" className="w-full flex items-center gap-2" onClick={handleCommentActionClick}>
|
| 159 |
<MessageCircle />
|
| 160 |
+
<span>{t.comment}</span>
|
| 161 |
</Button>
|
| 162 |
<Button variant="ghost" className="w-full flex items-center gap-2">
|
| 163 |
<Share2 />
|
| 164 |
+
<span>{t.share}</span>
|
| 165 |
</Button>
|
| 166 |
</div>
|
| 167 |
|
|
|
|
| 183 |
{post.uiState.isGeneratingComment && (
|
| 184 |
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
| 185 |
<Loader2 className="h-4 w-4 animate-spin"/>
|
| 186 |
+
<span>{t.loading}</span>
|
| 187 |
</div>
|
| 188 |
)}
|
| 189 |
{currentUser && (
|
| 190 |
<div className="flex items-center gap-2 pt-2">
|
| 191 |
+
<Image src={currentUser?.photoURL || `https://picsum.photos/seed/${currentUser?.uid || 'default'}/32/32`} alt={currentUser?.displayName || 'You'} width={32} height={32} className="w-8 h-8 rounded-full" />
|
| 192 |
<div className="relative w-full">
|
| 193 |
<Input
|
| 194 |
ref={commentInputRef}
|
| 195 |
+
placeholder={t.writeComment}
|
| 196 |
value={commentText}
|
| 197 |
onChange={(e) => setCommentText(e.target.value)}
|
| 198 |
onKeyDown={(e) => e.key === 'Enter' && handleCommentSubmit()}
|
|
|
|
| 237 |
} = useContext(AIWorldContext);
|
| 238 |
|
| 239 |
const { user } = useAuth();
|
| 240 |
+
const { t, lang } = useLanguage();
|
| 241 |
const observer = useRef<IntersectionObserver | null>(null);
|
| 242 |
const [consumption, setConsumption] = useState({ used: 0, remaining: 0, limit: 200 });
|
| 243 |
const [searchQuery, setSearchQuery] = useState('');
|
| 244 |
const [isSearchActive, setIsSearchActive] = useState(false);
|
| 245 |
+
const userName = user?.displayName?.split(' ')[0] || 'User';
|
| 246 |
|
| 247 |
useEffect(() => {
|
| 248 |
const { remaining } = checkConsumption();
|
|
|
|
| 312 |
}, [toggleCommentsVisibility]);
|
| 313 |
|
| 314 |
const sortedMainFeed = [...allPosts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
| 315 |
+
const sortedSearchResults = Array.isArray(searchedPosts) ? [...searchedPosts].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) : [];
|
| 316 |
|
| 317 |
const postsToDisplay = isSearchActive ? sortedSearchResults : sortedMainFeed;
|
| 318 |
|
|
|
|
| 324 |
<ArrowLeft />
|
| 325 |
</Button>
|
| 326 |
)}
|
| 327 |
+
<h1 className="text-xl font-bold text-primary">{t.worldTitle}</h1>
|
| 328 |
<div className="flex items-center gap-2 ml-auto">
|
| 329 |
<div className="relative flex-1 max-w-xs">
|
| 330 |
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
| 331 |
<Input
|
| 332 |
+
placeholder={t.search}
|
| 333 |
value={searchQuery}
|
| 334 |
onChange={(e) => setSearchQuery(e.target.value)}
|
| 335 |
onKeyDown={(e) => {
|
|
|
|
| 353 |
</PopoverTrigger>
|
| 354 |
<PopoverContent className="w-auto text-sm mr-4">
|
| 355 |
<div className="space-y-2">
|
| 356 |
+
<p className="font-bold text-center">{t.apiConsumption}</p>
|
| 357 |
+
<p>{t.used}: {consumption.used} / {consumption.limit}</p>
|
| 358 |
+
<p>{t.remaining}: {consumption.remaining}</p>
|
| 359 |
</div>
|
| 360 |
</PopoverContent>
|
| 361 |
</Popover>
|
|
|
|
| 366 |
{user && !isSearchActive && (
|
| 367 |
<div className="bg-card p-3 rounded-lg border shadow-sm">
|
| 368 |
<div className="flex items-center gap-3">
|
| 369 |
+
<Image src={user?.photoURL || `https://picsum.photos/seed/${user?.uid || 'default'}/40/40`} alt={user?.displayName || 'You'} width={40} height={40} className="w-10 h-10 rounded-full" />
|
| 370 |
<div className="w-full">
|
| 371 |
<Input
|
| 372 |
+
placeholder={t.thinking.replace('{name}', userName)}
|
| 373 |
onKeyDown={(e) => {
|
| 374 |
if (e.key === 'Enter' && e.currentTarget.value.trim()) {
|
| 375 |
createPostFromUserInput(e.currentTarget.value);
|
|
|
|
| 381 |
</div>
|
| 382 |
</div>
|
| 383 |
<div className="border-t mt-3 pt-2 flex justify-around">
|
| 384 |
+
<Button variant="ghost" className="w-full flex items-center gap-2" onClick={() => generatePostsFromAI("random topic")}>
|
| 385 |
<Sparkles className="text-purple-500" />
|
| 386 |
+
<span>{t.generatePosts}</span>
|
| 387 |
</Button>
|
| 388 |
</div>
|
| 389 |
</div>
|
|
|
|
| 417 |
onReport={handleReport}
|
| 418 |
onToggleComments={handleToggleComments}
|
| 419 |
currentUser={user}
|
| 420 |
+
t={t}
|
| 421 |
+
lang={lang}
|
| 422 |
/>
|
| 423 |
</div>
|
| 424 |
))
|
| 425 |
)}
|
| 426 |
+
{ (isLoading || isGeneratingFromQuery) && postsToDisplay.length > 0 && (
|
| 427 |
<div className="text-center text-muted-foreground py-4 space-y-4">
|
| 428 |
+
<div className="bg-card border rounded-lg shadow-sm w-full space-y-3 p-4">
|
| 429 |
+
<div className='flex items-center gap-3'>
|
| 430 |
+
<Skeleton className="h-10 w-10 rounded-full" />
|
| 431 |
+
<div className='space-y-2'>
|
| 432 |
+
<Skeleton className="h-4 w-24" />
|
| 433 |
+
<Skeleton className="h-3 w-16" />
|
| 434 |
+
</div>
|
| 435 |
+
</div>
|
| 436 |
+
<Skeleton className="h-6 w-3/4" />
|
| 437 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
</div>
|
| 439 |
)}
|
| 440 |
{postsToDisplay.length === 0 && !isLoading && !isGeneratingFromQuery && !isSearching && (
|
|
|
|
| 442 |
{isSearchActive ? (
|
| 443 |
<>
|
| 444 |
<Search className="h-12 w-12 text-muted-foreground/50" />
|
| 445 |
+
<p>{t.noPosts}</p>
|
| 446 |
</>
|
| 447 |
) : (
|
| 448 |
+
<p>{t.noPosts}</p>
|
| 449 |
)}
|
| 450 |
</div>
|
| 451 |
)}
|
|
|
|
| 454 |
</div>
|
| 455 |
);
|
| 456 |
}
|
|
|
|
|
|
src/components/group-chat/group-chat.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import { useState, useEffect, useRef } from 'react';
|
|
@@ -13,6 +14,7 @@ import { Input } from '../ui/input';
|
|
| 13 |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
| 14 |
import { cannedConversations } from '@/lib/ai-world-conversations';
|
| 15 |
import { checkConsumption, recordConsumption } from '@/lib/consumption';
|
|
|
|
| 16 |
|
| 17 |
const conversationTopics = cannedConversations.map(c => c.topic);
|
| 18 |
|
|
@@ -23,6 +25,7 @@ export function GroupChat() {
|
|
| 23 |
const [currentTopic, setCurrentTopic] = useState('');
|
| 24 |
const [topicInput, setTopicInput] = useState('');
|
| 25 |
const { toast } = useToast();
|
|
|
|
| 26 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 27 |
|
| 28 |
const getPersona = (name: string) => personas.find(p => p.name === name);
|
|
@@ -63,8 +66,7 @@ export function GroupChat() {
|
|
| 63 |
const consumption = checkConsumption();
|
| 64 |
if (!consumption.allowed) {
|
| 65 |
toast({
|
| 66 |
-
title:
|
| 67 |
-
description: `لقد وصلت إلى الحد الأقصى من الاستخدام اليومي لتوليد المحادثات.`,
|
| 68 |
variant: 'destructive',
|
| 69 |
});
|
| 70 |
return;
|
|
@@ -74,17 +76,17 @@ export function GroupChat() {
|
|
| 74 |
setMessages([]);
|
| 75 |
setPersonas([]);
|
| 76 |
setCurrentTopic(topic);
|
| 77 |
-
setTopicInput('');
|
| 78 |
|
| 79 |
try {
|
| 80 |
const aiResponse = await generateGroupChat({ topic });
|
| 81 |
-
recordConsumption();
|
| 82 |
await displayConversation(aiResponse);
|
| 83 |
} catch (error) {
|
| 84 |
console.error('Error getting AI response:', error);
|
| 85 |
toast({
|
| 86 |
-
title: '
|
| 87 |
-
description:
|
| 88 |
variant: 'destructive',
|
| 89 |
});
|
| 90 |
setIsLoading(false);
|
|
@@ -126,37 +128,36 @@ export function GroupChat() {
|
|
| 126 |
<div className='flex items-center gap-3'>
|
| 127 |
<div className="relative flex">
|
| 128 |
{personas.slice(0, 3).map((p, i) => (
|
| 129 |
-
<Avatar key={p.name} className={`h-8 w-8 border-2 border-background ${i > 0 ? '-ml-3' : ''}`}>
|
| 130 |
<AvatarImage src={p.avatarUrl} />
|
| 131 |
<AvatarFallback>{p.name.charAt(0)}</AvatarFallback>
|
| 132 |
</Avatar>
|
| 133 |
))}
|
| 134 |
{personas.length > 3 &&
|
| 135 |
-
<Avatar className='h-8 w-8 border-2 border-background -ml-3'>
|
| 136 |
<AvatarFallback>+{personas.length - 3}</AvatarFallback>
|
| 137 |
</Avatar>
|
| 138 |
}
|
| 139 |
</div>
|
| 140 |
<div>
|
| 141 |
-
<h2 className="font-bold text-lg">
|
| 142 |
-
<p className="text-sm text-muted-foreground">{currentTopic ||
|
| 143 |
</div>
|
| 144 |
</div>
|
| 145 |
<div className="flex items-center gap-2 p-4 flex-wrap">
|
| 146 |
<div className="flex-1 min-w-40">
|
| 147 |
<Select onValueChange={handleCannedSelection} disabled={isLoading}>
|
| 148 |
<SelectTrigger>
|
| 149 |
-
<SelectValue placeholder=
|
| 150 |
</SelectTrigger>
|
| 151 |
<SelectContent>
|
| 152 |
{cannedConversations.map(c => <SelectItem key={c.topic} value={c.topic}>{c.topic}</SelectItem>)}
|
| 153 |
</SelectContent>
|
| 154 |
</Select>
|
| 155 |
</div>
|
| 156 |
-
<div className="text-center text-sm text-muted-foreground mx-2">أو</div>
|
| 157 |
<div className="flex-1 min-w-40">
|
| 158 |
<Input
|
| 159 |
-
placeholder=
|
| 160 |
value={topicInput}
|
| 161 |
onChange={(e) => setTopicInput(e.target.value)}
|
| 162 |
disabled={isLoading}
|
|
@@ -168,12 +169,12 @@ export function GroupChat() {
|
|
| 168 |
/>
|
| 169 |
</div>
|
| 170 |
<Button onClick={() => fetchAndDisplayGeneratedConversation(topicInput)} disabled={isLoading || !topicInput.trim()} size="sm">
|
| 171 |
-
{isLoading && currentTopic === topicInput ? <Loader2 className="
|
| 172 |
-
|
| 173 |
</Button>
|
| 174 |
<Button onClick={() => fetchAndDisplayGeneratedConversation(conversationTopics[Math.floor(Math.random() * conversationTopics.length)])} disabled={isLoading} variant="outline" size="sm">
|
| 175 |
-
<RefreshCw className="
|
| 176 |
-
|
| 177 |
</Button>
|
| 178 |
</div>
|
| 179 |
</div>
|
|
@@ -184,8 +185,8 @@ export function GroupChat() {
|
|
| 184 |
) : messages.length === 0 && !isLoading ? (
|
| 185 |
<div className="flex flex-col items-center justify-center h-full text-center text-muted-foreground">
|
| 186 |
<Users className="h-16 w-16 mb-4" />
|
| 187 |
-
<h3 className="text-lg font-semibold">
|
| 188 |
-
<p>
|
| 189 |
</div>
|
| 190 |
) : (
|
| 191 |
messages.map((message, index) => (
|
|
@@ -206,8 +207,8 @@ export function GroupChat() {
|
|
| 206 |
<div className="border-t bg-card p-4 md:p-6">
|
| 207 |
<div className="mx-auto w-full max-w-3xl bg-muted/50 p-3 rounded-lg text-center">
|
| 208 |
<p className="text-sm text-muted-foreground">
|
| 209 |
-
<MessageCircle className="inline-block
|
| 210 |
-
|
| 211 |
</p>
|
| 212 |
</div>
|
| 213 |
</div>
|
|
|
|
| 1 |
+
|
| 2 |
'use client';
|
| 3 |
|
| 4 |
import { useState, useEffect, useRef } from 'react';
|
|
|
|
| 14 |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
| 15 |
import { cannedConversations } from '@/lib/ai-world-conversations';
|
| 16 |
import { checkConsumption, recordConsumption } from '@/lib/consumption';
|
| 17 |
+
import { useLanguage } from '@/contexts/language-context';
|
| 18 |
|
| 19 |
const conversationTopics = cannedConversations.map(c => c.topic);
|
| 20 |
|
|
|
|
| 25 |
const [currentTopic, setCurrentTopic] = useState('');
|
| 26 |
const [topicInput, setTopicInput] = useState('');
|
| 27 |
const { toast } = useToast();
|
| 28 |
+
const { t } = useLanguage();
|
| 29 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 30 |
|
| 31 |
const getPersona = (name: string) => personas.find(p => p.name === name);
|
|
|
|
| 66 |
const consumption = checkConsumption();
|
| 67 |
if (!consumption.allowed) {
|
| 68 |
toast({
|
| 69 |
+
title: t.dailyLimitReached,
|
|
|
|
| 70 |
variant: 'destructive',
|
| 71 |
});
|
| 72 |
return;
|
|
|
|
| 76 |
setMessages([]);
|
| 77 |
setPersonas([]);
|
| 78 |
setCurrentTopic(topic);
|
| 79 |
+
setTopicInput('');
|
| 80 |
|
| 81 |
try {
|
| 82 |
const aiResponse = await generateGroupChat({ topic });
|
| 83 |
+
recordConsumption();
|
| 84 |
await displayConversation(aiResponse);
|
| 85 |
} catch (error) {
|
| 86 |
console.error('Error getting AI response:', error);
|
| 87 |
toast({
|
| 88 |
+
title: 'Error',
|
| 89 |
+
description: t.errorAI,
|
| 90 |
variant: 'destructive',
|
| 91 |
});
|
| 92 |
setIsLoading(false);
|
|
|
|
| 128 |
<div className='flex items-center gap-3'>
|
| 129 |
<div className="relative flex">
|
| 130 |
{personas.slice(0, 3).map((p, i) => (
|
| 131 |
+
<Avatar key={p.name} className={`h-8 w-8 border-2 border-background ${i > 0 ? (t.appTitle === 'ProtoChat' ? '-mr-3' : '-ml-3') : ''}`}>
|
| 132 |
<AvatarImage src={p.avatarUrl} />
|
| 133 |
<AvatarFallback>{p.name.charAt(0)}</AvatarFallback>
|
| 134 |
</Avatar>
|
| 135 |
))}
|
| 136 |
{personas.length > 3 &&
|
| 137 |
+
<Avatar className={cn('h-8 w-8 border-2 border-background', t.appTitle === 'ProtoChat' ? '-mr-3' : '-ml-3')}>
|
| 138 |
<AvatarFallback>+{personas.length - 3}</AvatarFallback>
|
| 139 |
</Avatar>
|
| 140 |
}
|
| 141 |
</div>
|
| 142 |
<div>
|
| 143 |
+
<h2 className="font-bold text-lg">{t.groupChatTitle}</h2>
|
| 144 |
+
<p className="text-sm text-muted-foreground">{currentTopic || t.groupChatDesc}</p>
|
| 145 |
</div>
|
| 146 |
</div>
|
| 147 |
<div className="flex items-center gap-2 p-4 flex-wrap">
|
| 148 |
<div className="flex-1 min-w-40">
|
| 149 |
<Select onValueChange={handleCannedSelection} disabled={isLoading}>
|
| 150 |
<SelectTrigger>
|
| 151 |
+
<SelectValue placeholder={t.chooseCanned} />
|
| 152 |
</SelectTrigger>
|
| 153 |
<SelectContent>
|
| 154 |
{cannedConversations.map(c => <SelectItem key={c.topic} value={c.topic}>{c.topic}</SelectItem>)}
|
| 155 |
</SelectContent>
|
| 156 |
</Select>
|
| 157 |
</div>
|
|
|
|
| 158 |
<div className="flex-1 min-w-40">
|
| 159 |
<Input
|
| 160 |
+
placeholder={t.writeTopic}
|
| 161 |
value={topicInput}
|
| 162 |
onChange={(e) => setTopicInput(e.target.value)}
|
| 163 |
disabled={isLoading}
|
|
|
|
| 169 |
/>
|
| 170 |
</div>
|
| 171 |
<Button onClick={() => fetchAndDisplayGeneratedConversation(topicInput)} disabled={isLoading || !topicInput.trim()} size="sm">
|
| 172 |
+
{isLoading && currentTopic === topicInput ? <Loader2 className="mx-2 h-4 w-4 animate-spin" /> : <Sparkles className="mx-2 h-4 w-4" />}
|
| 173 |
+
{t.generate}
|
| 174 |
</Button>
|
| 175 |
<Button onClick={() => fetchAndDisplayGeneratedConversation(conversationTopics[Math.floor(Math.random() * conversationTopics.length)])} disabled={isLoading} variant="outline" size="sm">
|
| 176 |
+
<RefreshCw className="mx-2 h-4 w-4" />
|
| 177 |
+
{t.randomChat}
|
| 178 |
</Button>
|
| 179 |
</div>
|
| 180 |
</div>
|
|
|
|
| 185 |
) : messages.length === 0 && !isLoading ? (
|
| 186 |
<div className="flex flex-col items-center justify-center h-full text-center text-muted-foreground">
|
| 187 |
<Users className="h-16 w-16 mb-4" />
|
| 188 |
+
<h3 className="text-lg font-semibold">{t.startGroupChat}</h3>
|
| 189 |
+
<p>{t.groupChatDesc}</p>
|
| 190 |
</div>
|
| 191 |
) : (
|
| 192 |
messages.map((message, index) => (
|
|
|
|
| 207 |
<div className="border-t bg-card p-4 md:p-6">
|
| 208 |
<div className="mx-auto w-full max-w-3xl bg-muted/50 p-3 rounded-lg text-center">
|
| 209 |
<p className="text-sm text-muted-foreground">
|
| 210 |
+
<MessageCircle className="inline-block mx-2 h-4 w-4" />
|
| 211 |
+
{t.watchingOnly}
|
| 212 |
</p>
|
| 213 |
</div>
|
| 214 |
</div>
|
src/components/voice-chat/voice-chat.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
import { useState, useRef, useEffect } from 'react';
|
|
@@ -7,6 +8,7 @@ import { cn } from '@/lib/utils';
|
|
| 7 |
import { useToast } from '@/hooks/use-toast';
|
| 8 |
import { getChatResponse } from '@/ai/flows/ai-chat-response';
|
| 9 |
import { checkConsumption, recordConsumption } from '@/lib/consumption';
|
|
|
|
| 10 |
|
| 11 |
interface TranscriptEntry {
|
| 12 |
speaker: 'user' | 'ai';
|
|
@@ -17,31 +19,31 @@ export function VoiceChat() {
|
|
| 17 |
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
|
| 18 |
const [isListening, setIsListening] = useState(false);
|
| 19 |
const [isLoading, setIsLoading] = useState(false);
|
| 20 |
-
const recognitionRef = useRef<
|
| 21 |
const { toast } = useToast();
|
|
|
|
| 22 |
|
| 23 |
// State for speech synthesis
|
| 24 |
-
const [
|
| 25 |
|
| 26 |
// Load voices for speech synthesis
|
| 27 |
useEffect(() => {
|
| 28 |
const loadVoices = () => {
|
| 29 |
const voices = window.speechSynthesis.getVoices();
|
| 30 |
-
// Prioritize known good voices
|
| 31 |
-
const
|
| 32 |
-
const
|
| 33 |
-
|
|
|
|
| 34 |
};
|
| 35 |
|
| 36 |
-
// Voices are loaded asynchronously, so we need to listen for the event
|
| 37 |
window.speechSynthesis.onvoiceschanged = loadVoices;
|
| 38 |
-
loadVoices();
|
| 39 |
-
}, []);
|
| 40 |
|
| 41 |
const speakText = (text: string) => {
|
| 42 |
if (!('speechSynthesis' in window)) {
|
| 43 |
-
|
| 44 |
-
toast({ title: 'غير مدعوم', description: 'متصفحك لا يدعم نطق النص.', variant: 'destructive' });
|
| 45 |
return;
|
| 46 |
}
|
| 47 |
|
|
@@ -50,21 +52,11 @@ export function VoiceChat() {
|
|
| 50 |
|
| 51 |
const utterance = new SpeechSynthesisUtterance(cleanText);
|
| 52 |
|
| 53 |
-
// Smart voice selection logic
|
| 54 |
-
let voiceToUse = arabicVoice;
|
| 55 |
-
if (!voiceToUse) {
|
| 56 |
-
// If the state hasn't been set yet, try one last time to get a voice directly
|
| 57 |
-
const voices = window.speechSynthesis.getVoices();
|
| 58 |
-
voiceToUse = voices.find(v => v.lang.startsWith('ar')) || null;
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
if (voiceToUse) {
|
| 62 |
utterance.voice = voiceToUse;
|
| 63 |
utterance.lang = voiceToUse.lang;
|
| 64 |
} else {
|
| 65 |
-
utterance.lang = 'ar-EG'
|
| 66 |
-
console.error('Speech synthesis not supported or no voice selected.');
|
| 67 |
-
// Do not toast here to avoid annoying the user on every attempt, but log it.
|
| 68 |
}
|
| 69 |
|
| 70 |
utterance.rate = 1.0;
|
|
@@ -75,21 +67,20 @@ export function VoiceChat() {
|
|
| 75 |
|
| 76 |
// Speech Recognition setup
|
| 77 |
useEffect(() => {
|
| 78 |
-
const SpeechRecognition = window.SpeechRecognition || (window as any).webkitSpeechRecognition;
|
| 79 |
if (SpeechRecognition) {
|
| 80 |
const recognition = new SpeechRecognition();
|
| 81 |
recognition.continuous = false;
|
| 82 |
-
recognition.lang = 'ar-EG';
|
| 83 |
recognition.interimResults = false;
|
| 84 |
|
| 85 |
-
recognition.onresult = (event) => {
|
| 86 |
const spokenText = event.results[0][0].transcript;
|
| 87 |
handleUserSpeech(spokenText);
|
| 88 |
};
|
| 89 |
|
| 90 |
-
recognition.onerror = (event) => {
|
| 91 |
console.error('Speech recognition error', event.error);
|
| 92 |
-
toast({ title: 'خطأ في التعرف على الصوت', description: event.error, variant: 'destructive' });
|
| 93 |
setIsListening(false);
|
| 94 |
};
|
| 95 |
|
|
@@ -98,15 +89,13 @@ export function VoiceChat() {
|
|
| 98 |
}
|
| 99 |
|
| 100 |
recognitionRef.current = recognition;
|
| 101 |
-
} else {
|
| 102 |
-
toast({ title: 'غير مدعوم', description: 'متصفحك لا يدعم التعرف على الصوت.', variant: 'destructive' });
|
| 103 |
}
|
| 104 |
|
| 105 |
return () => {
|
| 106 |
recognitionRef.current?.abort();
|
| 107 |
-
window.speechSynthesis.cancel();
|
| 108 |
}
|
| 109 |
-
}, [
|
| 110 |
|
| 111 |
const handleUserSpeech = async (text: string) => {
|
| 112 |
if (!text) return;
|
|
@@ -115,34 +104,33 @@ export function VoiceChat() {
|
|
| 115 |
setIsLoading(true);
|
| 116 |
|
| 117 |
try {
|
| 118 |
-
// 1. Get AI text response (this is the only part that consumes API quota)
|
| 119 |
const consumption = checkConsumption();
|
| 120 |
if (!consumption.allowed) {
|
| 121 |
-
throw new Error(
|
| 122 |
}
|
| 123 |
|
| 124 |
const chatHistory = [...transcript, { speaker: 'user', text }].map(entry => `${entry.speaker}: ${entry.text}`);
|
| 125 |
const aiTextResponse = await getChatResponse({ chatHistory });
|
| 126 |
|
| 127 |
-
recordConsumption();
|
| 128 |
|
| 129 |
setTranscript(prev => [...prev, { speaker: 'ai', text: aiTextResponse.response }]);
|
| 130 |
-
|
| 131 |
-
// 2. Speak the AI response using the free, built-in browser API
|
| 132 |
speakText(aiTextResponse.response);
|
| 133 |
|
| 134 |
} catch (error: any) {
|
| 135 |
console.error('Error in AI voice chat:', error);
|
| 136 |
-
toast({ title: '
|
| 137 |
} finally {
|
| 138 |
setIsLoading(false);
|
| 139 |
}
|
| 140 |
};
|
| 141 |
|
| 142 |
const toggleListening = () => {
|
| 143 |
-
if (!recognitionRef.current)
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
-
// Stop any ongoing speech before starting to listen
|
| 146 |
window.speechSynthesis.cancel();
|
| 147 |
|
| 148 |
if (isListening) {
|
|
@@ -160,8 +148,8 @@ export function VoiceChat() {
|
|
| 160 |
{transcript.length === 0 && !isListening && !isLoading && (
|
| 161 |
<div className="text-center text-muted-foreground h-full flex flex-col justify-center items-center">
|
| 162 |
<Mic className="w-16 h-16 mb-4"/>
|
| 163 |
-
<h2 className="text-2xl font-bold">
|
| 164 |
-
<p>
|
| 165 |
</div>
|
| 166 |
)}
|
| 167 |
{transcript.map((entry, index) => (
|
|
@@ -183,7 +171,7 @@ export function VoiceChat() {
|
|
| 183 |
))}
|
| 184 |
{isListening && (
|
| 185 |
<div className="text-center text-muted-foreground animate-pulse">
|
| 186 |
-
<p>.
|
| 187 |
</div>
|
| 188 |
)}
|
| 189 |
{isLoading && (
|
|
@@ -193,7 +181,7 @@ export function VoiceChat() {
|
|
| 193 |
</div>
|
| 194 |
<div className="flex items-center gap-2 pt-2">
|
| 195 |
<Loader2 className="h-5 w-5 animate-spin" />
|
| 196 |
-
<p>
|
| 197 |
</div>
|
| 198 |
</div>
|
| 199 |
)}
|
|
@@ -212,10 +200,9 @@ export function VoiceChat() {
|
|
| 212 |
{isListening ? <MicOff className="h-8 w-8" /> : <Mic className="h-8 w-8" />}
|
| 213 |
</Button>
|
| 214 |
<p className="text-sm text-muted-foreground">
|
| 215 |
-
{isListening ?
|
| 216 |
</p>
|
| 217 |
</div>
|
| 218 |
-
{/* The <audio> element is no longer needed with Web Speech API */}
|
| 219 |
</div>
|
| 220 |
);
|
| 221 |
}
|
|
|
|
| 1 |
+
|
| 2 |
'use client';
|
| 3 |
|
| 4 |
import { useState, useRef, useEffect } from 'react';
|
|
|
|
| 8 |
import { useToast } from '@/hooks/use-toast';
|
| 9 |
import { getChatResponse } from '@/ai/flows/ai-chat-response';
|
| 10 |
import { checkConsumption, recordConsumption } from '@/lib/consumption';
|
| 11 |
+
import { useLanguage } from '@/contexts/language-context';
|
| 12 |
|
| 13 |
interface TranscriptEntry {
|
| 14 |
speaker: 'user' | 'ai';
|
|
|
|
| 19 |
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
|
| 20 |
const [isListening, setIsListening] = useState(false);
|
| 21 |
const [isLoading, setIsLoading] = useState(false);
|
| 22 |
+
const recognitionRef = useRef<any>(null);
|
| 23 |
const { toast } = useToast();
|
| 24 |
+
const { t, lang } = useLanguage();
|
| 25 |
|
| 26 |
// State for speech synthesis
|
| 27 |
+
const [voiceToUse, setVoiceToUse] = useState<SpeechSynthesisVoice | null>(null);
|
| 28 |
|
| 29 |
// Load voices for speech synthesis
|
| 30 |
useEffect(() => {
|
| 31 |
const loadVoices = () => {
|
| 32 |
const voices = window.speechSynthesis.getVoices();
|
| 33 |
+
// Prioritize known good voices for the selected language
|
| 34 |
+
const targetLang = lang === 'ar' ? 'ar-EG' : 'en-US';
|
| 35 |
+
const preferredVoice = voices.find(voice => voice.lang.startsWith(lang) && (voice.name.includes('Maged') || voice.name.includes('Google') || voice.name.includes('Samantha')));
|
| 36 |
+
const fallbackVoice = voices.find(voice => voice.lang.startsWith(lang));
|
| 37 |
+
setVoiceToUse(preferredVoice || fallbackVoice || null);
|
| 38 |
};
|
| 39 |
|
|
|
|
| 40 |
window.speechSynthesis.onvoiceschanged = loadVoices;
|
| 41 |
+
loadVoices();
|
| 42 |
+
}, [lang]);
|
| 43 |
|
| 44 |
const speakText = (text: string) => {
|
| 45 |
if (!('speechSynthesis' in window)) {
|
| 46 |
+
toast({ title: 'Error', description: 'Your browser does not support text-to-speech.', variant: 'destructive' });
|
|
|
|
| 47 |
return;
|
| 48 |
}
|
| 49 |
|
|
|
|
| 52 |
|
| 53 |
const utterance = new SpeechSynthesisUtterance(cleanText);
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
if (voiceToUse) {
|
| 56 |
utterance.voice = voiceToUse;
|
| 57 |
utterance.lang = voiceToUse.lang;
|
| 58 |
} else {
|
| 59 |
+
utterance.lang = lang === 'ar' ? 'ar-EG' : 'en-US';
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
utterance.rate = 1.0;
|
|
|
|
| 67 |
|
| 68 |
// Speech Recognition setup
|
| 69 |
useEffect(() => {
|
| 70 |
+
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
| 71 |
if (SpeechRecognition) {
|
| 72 |
const recognition = new SpeechRecognition();
|
| 73 |
recognition.continuous = false;
|
| 74 |
+
recognition.lang = lang === 'ar' ? 'ar-EG' : 'en-US';
|
| 75 |
recognition.interimResults = false;
|
| 76 |
|
| 77 |
+
recognition.onresult = (event: any) => {
|
| 78 |
const spokenText = event.results[0][0].transcript;
|
| 79 |
handleUserSpeech(spokenText);
|
| 80 |
};
|
| 81 |
|
| 82 |
+
recognition.onerror = (event: any) => {
|
| 83 |
console.error('Speech recognition error', event.error);
|
|
|
|
| 84 |
setIsListening(false);
|
| 85 |
};
|
| 86 |
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
recognitionRef.current = recognition;
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
return () => {
|
| 95 |
recognitionRef.current?.abort();
|
| 96 |
+
window.speechSynthesis.cancel();
|
| 97 |
}
|
| 98 |
+
}, [lang]);
|
| 99 |
|
| 100 |
const handleUserSpeech = async (text: string) => {
|
| 101 |
if (!text) return;
|
|
|
|
| 104 |
setIsLoading(true);
|
| 105 |
|
| 106 |
try {
|
|
|
|
| 107 |
const consumption = checkConsumption();
|
| 108 |
if (!consumption.allowed) {
|
| 109 |
+
throw new Error(t.dailyLimitReached);
|
| 110 |
}
|
| 111 |
|
| 112 |
const chatHistory = [...transcript, { speaker: 'user', text }].map(entry => `${entry.speaker}: ${entry.text}`);
|
| 113 |
const aiTextResponse = await getChatResponse({ chatHistory });
|
| 114 |
|
| 115 |
+
recordConsumption();
|
| 116 |
|
| 117 |
setTranscript(prev => [...prev, { speaker: 'ai', text: aiTextResponse.response }]);
|
|
|
|
|
|
|
| 118 |
speakText(aiTextResponse.response);
|
| 119 |
|
| 120 |
} catch (error: any) {
|
| 121 |
console.error('Error in AI voice chat:', error);
|
| 122 |
+
toast({ title: 'Error', description: error.message || t.errorAI, variant: 'destructive' });
|
| 123 |
} finally {
|
| 124 |
setIsLoading(false);
|
| 125 |
}
|
| 126 |
};
|
| 127 |
|
| 128 |
const toggleListening = () => {
|
| 129 |
+
if (!recognitionRef.current) {
|
| 130 |
+
toast({ title: 'Not Supported', description: 'Speech recognition is not supported in this browser.', variant: 'destructive' });
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
|
|
|
|
| 134 |
window.speechSynthesis.cancel();
|
| 135 |
|
| 136 |
if (isListening) {
|
|
|
|
| 148 |
{transcript.length === 0 && !isListening && !isLoading && (
|
| 149 |
<div className="text-center text-muted-foreground h-full flex flex-col justify-center items-center">
|
| 150 |
<Mic className="w-16 h-16 mb-4"/>
|
| 151 |
+
<h2 className="text-2xl font-bold">{t.voiceWelcome}</h2>
|
| 152 |
+
<p>{t.voiceDesc}</p>
|
| 153 |
</div>
|
| 154 |
)}
|
| 155 |
{transcript.map((entry, index) => (
|
|
|
|
| 171 |
))}
|
| 172 |
{isListening && (
|
| 173 |
<div className="text-center text-muted-foreground animate-pulse">
|
| 174 |
+
<p>{t.listening}</p>
|
| 175 |
</div>
|
| 176 |
)}
|
| 177 |
{isLoading && (
|
|
|
|
| 181 |
</div>
|
| 182 |
<div className="flex items-center gap-2 pt-2">
|
| 183 |
<Loader2 className="h-5 w-5 animate-spin" />
|
| 184 |
+
<p>{t.protoThinking}</p>
|
| 185 |
</div>
|
| 186 |
</div>
|
| 187 |
)}
|
|
|
|
| 200 |
{isListening ? <MicOff className="h-8 w-8" /> : <Mic className="h-8 w-8" />}
|
| 201 |
</Button>
|
| 202 |
<p className="text-sm text-muted-foreground">
|
| 203 |
+
{isListening ? t.pressToStop : t.pressToTalk}
|
| 204 |
</p>
|
| 205 |
</div>
|
|
|
|
| 206 |
</div>
|
| 207 |
);
|
| 208 |
}
|
src/contexts/ai-world-context.tsx
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
|
| 2 |
"use client";
|
| 3 |
|
| 4 |
import React, { createContext, useState, useCallback, useEffect, useRef } from 'react';
|
|
@@ -34,8 +33,8 @@ interface AIWorldContextType {
|
|
| 34 |
|
| 35 |
export const AIWorldContext = createContext<AIWorldContextType>({} as AIWorldContextType);
|
| 36 |
|
| 37 |
-
const POSTS_PER_PAGE =
|
| 38 |
-
const POSTS_TO_GENERATE_AI = 30; // 30 posts per
|
| 39 |
|
| 40 |
export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
| 41 |
const [allPosts, setAllPosts] = useState<PostWithUIState[]>([]);
|
|
@@ -45,7 +44,7 @@ export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|
| 45 |
const [searchedPosts, setSearchedPosts] = useState<PostWithUIState[]>([]);
|
| 46 |
const { toast } = useToast();
|
| 47 |
const { user, userData } = useAuth();
|
| 48 |
-
const { lang } = useLanguage();
|
| 49 |
const isFetchingRef = useRef(false);
|
| 50 |
const [canLoadMore, setCanLoadMore] = useState(true);
|
| 51 |
const [lastLoadedKey, setLastLoadedKey] = useState<string | null>(null);
|
|
@@ -100,7 +99,7 @@ export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|
| 100 |
setLastLoadedKey(newPosts[newPosts.length - 1].id);
|
| 101 |
setAllPosts(prev => [...prev, ...newPosts]);
|
| 102 |
}
|
| 103 |
-
if (newPosts.length < POSTS_PER_PAGE) setCanLoadMore(false);
|
| 104 |
} else {
|
| 105 |
setCanLoadMore(false);
|
| 106 |
}
|
|
@@ -117,7 +116,7 @@ export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|
| 117 |
const generatePostsFromAI = async () => {
|
| 118 |
const consumption = checkConsumption();
|
| 119 |
if (!consumption.allowed) {
|
| 120 |
-
toast({ title:
|
| 121 |
return;
|
| 122 |
}
|
| 123 |
|
|
@@ -134,9 +133,8 @@ export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|
| 134 |
recordConsumption();
|
| 135 |
|
| 136 |
const updates: any = {};
|
| 137 |
-
const
|
| 138 |
|
| 139 |
-
// Parallel image fetching
|
| 140 |
const postPromises = result.posts.map(async (idea) => {
|
| 141 |
const postId = push(ref(database, 'posts')).key!;
|
| 142 |
const author = getRandomAIUser();
|
|
@@ -164,15 +162,14 @@ export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|
| 164 |
const results = await Promise.all(postPromises);
|
| 165 |
results.forEach(({ postId, postData }) => {
|
| 166 |
updates[`posts/${postId}`] = postData;
|
| 167 |
-
|
| 168 |
});
|
| 169 |
|
| 170 |
await update(ref(database), updates);
|
| 171 |
-
setAllPosts(prev => [...
|
| 172 |
-
toast({ title: "تم التوليد", description: `تمت إضافة ${newPosts.length} منشورات مخصصة لك بناءً على عمرك وموقعك.` });
|
| 173 |
} catch (error) {
|
| 174 |
console.error(error);
|
| 175 |
-
toast({ title: "
|
| 176 |
} finally {
|
| 177 |
setIsGeneratingFromQuery(false);
|
| 178 |
}
|
|
@@ -220,6 +217,15 @@ export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|
| 220 |
setAllPosts(prev => prev.map(p => p.id === postId ? { ...p, comments: [...p.comments, newComment] } : p));
|
| 221 |
};
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
const searchPostsInDB = (searchTerm: string) => {
|
| 224 |
setIsSearching(true);
|
| 225 |
const results = allPosts.filter(p => p.content.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
@@ -229,8 +235,8 @@ export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|
| 229 |
|
| 230 |
const value: AIWorldContextType = {
|
| 231 |
allPosts, isLoading, loadNextPage, canLoadMore, generatePostsFromAI, createPostFromUserInput,
|
| 232 |
-
isGeneratingFromQuery, reactToPost, addCommentToPost, reportPost
|
| 233 |
-
triggerCommentGeneration
|
| 234 |
toggleCommentsVisibility: (id) => setAllPosts(prev => prev.map(p => p.id === id ? { ...p, uiState: { ...p.uiState, areCommentsVisible: !p.uiState.areCommentsVisible } } : p)),
|
| 235 |
searchPostsInDB, isSearching, searchedPosts, clearSearch: () => { setSearchedPosts([]); setIsSearching(false); }
|
| 236 |
};
|
|
|
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { createContext, useState, useCallback, useEffect, useRef } from 'react';
|
|
|
|
| 33 |
|
| 34 |
export const AIWorldContext = createContext<AIWorldContextType>({} as AIWorldContextType);
|
| 35 |
|
| 36 |
+
const POSTS_PER_PAGE = 10;
|
| 37 |
+
const POSTS_TO_GENERATE_AI = 30; // 30 posts generated per request
|
| 38 |
|
| 39 |
export const AIWorldProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
| 40 |
const [allPosts, setAllPosts] = useState<PostWithUIState[]>([]);
|
|
|
|
| 44 |
const [searchedPosts, setSearchedPosts] = useState<PostWithUIState[]>([]);
|
| 45 |
const { toast } = useToast();
|
| 46 |
const { user, userData } = useAuth();
|
| 47 |
+
const { lang, t } = useLanguage();
|
| 48 |
const isFetchingRef = useRef(false);
|
| 49 |
const [canLoadMore, setCanLoadMore] = useState(true);
|
| 50 |
const [lastLoadedKey, setLastLoadedKey] = useState<string | null>(null);
|
|
|
|
| 99 |
setLastLoadedKey(newPosts[newPosts.length - 1].id);
|
| 100 |
setAllPosts(prev => [...prev, ...newPosts]);
|
| 101 |
}
|
| 102 |
+
if (newPosts.length <= POSTS_PER_PAGE) setCanLoadMore(false);
|
| 103 |
} else {
|
| 104 |
setCanLoadMore(false);
|
| 105 |
}
|
|
|
|
| 116 |
const generatePostsFromAI = async () => {
|
| 117 |
const consumption = checkConsumption();
|
| 118 |
if (!consumption.allowed) {
|
| 119 |
+
toast({ title: t.dailyLimitReached, variant: "destructive" });
|
| 120 |
return;
|
| 121 |
}
|
| 122 |
|
|
|
|
| 133 |
recordConsumption();
|
| 134 |
|
| 135 |
const updates: any = {};
|
| 136 |
+
const newPostsBatch: PostWithUIState[] = [];
|
| 137 |
|
|
|
|
| 138 |
const postPromises = result.posts.map(async (idea) => {
|
| 139 |
const postId = push(ref(database, 'posts')).key!;
|
| 140 |
const author = getRandomAIUser();
|
|
|
|
| 162 |
const results = await Promise.all(postPromises);
|
| 163 |
results.forEach(({ postId, postData }) => {
|
| 164 |
updates[`posts/${postId}`] = postData;
|
| 165 |
+
newPostsBatch.push(processDbPost(postId, postData));
|
| 166 |
});
|
| 167 |
|
| 168 |
await update(ref(database), updates);
|
| 169 |
+
setAllPosts(prev => [...newPostsBatch, ...prev]);
|
|
|
|
| 170 |
} catch (error) {
|
| 171 |
console.error(error);
|
| 172 |
+
toast({ title: "Error", description: t.errorAI, variant: "destructive" });
|
| 173 |
} finally {
|
| 174 |
setIsGeneratingFromQuery(false);
|
| 175 |
}
|
|
|
|
| 217 |
setAllPosts(prev => prev.map(p => p.id === postId ? { ...p, comments: [...p.comments, newComment] } : p));
|
| 218 |
};
|
| 219 |
|
| 220 |
+
const reportPost = (postId: string) => {
|
| 221 |
+
setAllPosts(prev => prev.map(p => p.id === postId ? { ...p, uiState: { ...p.uiState, isReported: true } } : p));
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
const triggerCommentGeneration = (postIds: string[]) => {
|
| 225 |
+
// Placeholder for future bulk AI comment logic
|
| 226 |
+
console.log("Triggering bulk comments for:", postIds);
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
const searchPostsInDB = (searchTerm: string) => {
|
| 230 |
setIsSearching(true);
|
| 231 |
const results = allPosts.filter(p => p.content.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
|
|
| 235 |
|
| 236 |
const value: AIWorldContextType = {
|
| 237 |
allPosts, isLoading, loadNextPage, canLoadMore, generatePostsFromAI, createPostFromUserInput,
|
| 238 |
+
isGeneratingFromQuery, reactToPost, addCommentToPost, reportPost,
|
| 239 |
+
triggerCommentGeneration,
|
| 240 |
toggleCommentsVisibility: (id) => setAllPosts(prev => prev.map(p => p.id === id ? { ...p, uiState: { ...p.uiState, areCommentsVisible: !p.uiState.areCommentsVisible } } : p)),
|
| 241 |
searchPostsInDB, isSearching, searchedPosts, clearSearch: () => { setSearchedPosts([]); setIsSearching(false); }
|
| 242 |
};
|
src/lib/gemini-client.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
|
| 2 |
const GEMINI_API_KEYS = process.env.GEMINI_API_KEY ? process.env.GEMINI_API_KEY.split(',').map(k => k.trim()) : [];
|
| 3 |
|
| 4 |
async function askGoogleAI(prompt: string, model: string): Promise<{ success: true, answer: string, model: string } | { success: false, message: string }> {
|
|
@@ -38,13 +37,13 @@ async function askGoogleAI(prompt: string, model: string): Promise<{ success: tr
|
|
| 38 |
}
|
| 39 |
|
| 40 |
export async function askAI(prompt: string): Promise<{ success: true, answer: string, model: string } | { success: false, message: string }> {
|
| 41 |
-
// Specifically using gemini-2.5-flash-lite as requested
|
| 42 |
const googleModels = ["gemini-2.5-flash-lite", "gemini-2.0-flash-lite-preview-02-05", "gemini-1.5-flash-latest"];
|
| 43 |
for (const modelId of googleModels) {
|
| 44 |
const result = await askGoogleAI(prompt, modelId);
|
| 45 |
if (result.success) return result;
|
| 46 |
}
|
| 47 |
-
return { success: false, message: "
|
| 48 |
}
|
| 49 |
|
| 50 |
export const safeGenerateContent = async (prompt: string): Promise<any> => {
|
|
@@ -53,7 +52,7 @@ export const safeGenerateContent = async (prompt: string): Promise<any> => {
|
|
| 53 |
|
| 54 |
let text = result.answer;
|
| 55 |
try {
|
| 56 |
-
//
|
| 57 |
const startBracket = text.indexOf('{');
|
| 58 |
const startSquare = text.indexOf('[');
|
| 59 |
const startIndex = (startBracket !== -1 && (startSquare === -1 || startBracket < startSquare)) ? startBracket : startSquare;
|
|
@@ -62,13 +61,13 @@ export const safeGenerateContent = async (prompt: string): Promise<any> => {
|
|
| 62 |
const endSquare = text.lastIndexOf(']');
|
| 63 |
const endIndex = (endBracket !== -1 && (endSquare === -1 || endBracket > endSquare)) ? endBracket : endSquare;
|
| 64 |
|
| 65 |
-
if (startIndex === -1 || endIndex === -1) throw new Error("No JSON found");
|
| 66 |
|
| 67 |
const cleanedJson = text.substring(startIndex, endIndex + 1);
|
| 68 |
return JSON.parse(cleanedJson);
|
| 69 |
} catch (error) {
|
| 70 |
-
console.error("AI JSON Parse Error. Model:", result.model, "Raw text:", text);
|
| 71 |
-
throw new Error("
|
| 72 |
}
|
| 73 |
};
|
| 74 |
|
|
|
|
|
|
|
| 1 |
const GEMINI_API_KEYS = process.env.GEMINI_API_KEY ? process.env.GEMINI_API_KEY.split(',').map(k => k.trim()) : [];
|
| 2 |
|
| 3 |
async function askGoogleAI(prompt: string, model: string): Promise<{ success: true, answer: string, model: string } | { success: false, message: string }> {
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
export async function askAI(prompt: string): Promise<{ success: true, answer: string, model: string } | { success: false, message: string }> {
|
| 40 |
+
// Specifically using gemini-2.5-flash-lite as requested
|
| 41 |
const googleModels = ["gemini-2.5-flash-lite", "gemini-2.0-flash-lite-preview-02-05", "gemini-1.5-flash-latest"];
|
| 42 |
for (const modelId of googleModels) {
|
| 43 |
const result = await askGoogleAI(prompt, modelId);
|
| 44 |
if (result.success) return result;
|
| 45 |
}
|
| 46 |
+
return { success: false, message: "Models are currently busy. Please try again." };
|
| 47 |
}
|
| 48 |
|
| 49 |
export const safeGenerateContent = async (prompt: string): Promise<any> => {
|
|
|
|
| 52 |
|
| 53 |
let text = result.answer;
|
| 54 |
try {
|
| 55 |
+
// Robust JSON extraction to handle any extra text or markdown wrappers
|
| 56 |
const startBracket = text.indexOf('{');
|
| 57 |
const startSquare = text.indexOf('[');
|
| 58 |
const startIndex = (startBracket !== -1 && (startSquare === -1 || startBracket < startSquare)) ? startBracket : startSquare;
|
|
|
|
| 61 |
const endSquare = text.lastIndexOf(']');
|
| 62 |
const endIndex = (endBracket !== -1 && (endSquare === -1 || endBracket > endSquare)) ? endBracket : endSquare;
|
| 63 |
|
| 64 |
+
if (startIndex === -1 || endIndex === -1) throw new Error("No valid JSON found in AI response");
|
| 65 |
|
| 66 |
const cleanedJson = text.substring(startIndex, endIndex + 1);
|
| 67 |
return JSON.parse(cleanedJson);
|
| 68 |
} catch (error) {
|
| 69 |
+
console.error("AI JSON Parse Error. Model:", result.model, "Raw text sample:", text.substring(0, 100));
|
| 70 |
+
throw new Error("Failed to parse AI response. Please try again.");
|
| 71 |
}
|
| 72 |
};
|
| 73 |
|
src/lib/translations.ts
CHANGED
|
@@ -34,7 +34,7 @@ export const translations = {
|
|
| 34 |
confirmDelete: 'نعم، احذف حسابي',
|
| 35 |
search: 'بحث...',
|
| 36 |
generatePosts: 'ولّد منشورات',
|
| 37 |
-
thinking: 'بماذا تفكر؟',
|
| 38 |
localContext: 'منشورات مخصصة لمنطقتك',
|
| 39 |
switchLang: 'English',
|
| 40 |
noPosts: 'لا توجد منشورات حالياً.',
|
|
@@ -43,6 +43,30 @@ export const translations = {
|
|
| 43 |
comment: 'تعليق',
|
| 44 |
share: 'مشاركة',
|
| 45 |
writeComment: 'اكتب تعليقاً...',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
},
|
| 47 |
en: {
|
| 48 |
appTitle: 'ProtoChat',
|
|
@@ -76,7 +100,7 @@ export const translations = {
|
|
| 76 |
confirmDelete: 'Yes, delete my account',
|
| 77 |
search: 'Search...',
|
| 78 |
generatePosts: 'Generate Posts',
|
| 79 |
-
thinking: 'What\'s on your mind?',
|
| 80 |
localContext: 'Posts customized for your area',
|
| 81 |
switchLang: 'العربية',
|
| 82 |
noPosts: 'No posts yet.',
|
|
@@ -85,5 +109,29 @@ export const translations = {
|
|
| 85 |
comment: 'Comment',
|
| 86 |
share: 'Share',
|
| 87 |
writeComment: 'Write a comment...',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
};
|
|
|
|
| 34 |
confirmDelete: 'نعم، احذف حسابي',
|
| 35 |
search: 'بحث...',
|
| 36 |
generatePosts: 'ولّد منشورات',
|
| 37 |
+
thinking: 'بماذا تفكر يا {name}؟',
|
| 38 |
localContext: 'منشورات مخصصة لمنطقتك',
|
| 39 |
switchLang: 'English',
|
| 40 |
noPosts: 'لا توجد منشورات حالياً.',
|
|
|
|
| 43 |
comment: 'تعليق',
|
| 44 |
share: 'مشاركة',
|
| 45 |
writeComment: 'اكتب تعليقاً...',
|
| 46 |
+
report: 'إبلاغ عن المحتوى',
|
| 47 |
+
apiConsumption: 'استهلاك الـ API اليومي',
|
| 48 |
+
used: 'المستخدم',
|
| 49 |
+
remaining: 'المتبقي',
|
| 50 |
+
backToChat: 'العودة للدردشة',
|
| 51 |
+
watchingOnly: 'هذه محادثة مشاهدة فقط. لا يمكنك إرسال رسائل هنا.',
|
| 52 |
+
chooseCanned: 'اختر محادثة جاهزة...',
|
| 53 |
+
writeTopic: 'اكتب موضوعاً لتوليده...',
|
| 54 |
+
generate: 'توليد',
|
| 55 |
+
randomChat: 'محادثة عشوائية',
|
| 56 |
+
startGroupChat: 'ابدأ محادثة جماعية',
|
| 57 |
+
groupChatDesc: 'اختر من المحادثات الجاهزة، أو اكتب موضوعًا، أو اضغط "محادثة عشوائية".',
|
| 58 |
+
voiceWelcome: 'مرحباً بك في المحادثة الصوتية',
|
| 59 |
+
voiceDesc: 'اضغط على الميكروفون لبدء التحدث مع بروتو',
|
| 60 |
+
listening: 'يستمع...',
|
| 61 |
+
protoThinking: 'بروتو يفكر...',
|
| 62 |
+
pressToStop: 'اضغط للإيقاف',
|
| 63 |
+
pressToTalk: 'اضغط للتحدث',
|
| 64 |
+
successUpdate: 'تم التحديث بنجاح',
|
| 65 |
+
accountDeleted: 'تم حذف الحساب',
|
| 66 |
+
completeProfile: 'يرجى إكمال بياناتك للمتابعة',
|
| 67 |
+
helloUser: 'مرحباً بك {name}',
|
| 68 |
+
errorAI: 'فشل الحصول على استجابة الذكاء الاصطناعي.',
|
| 69 |
+
dailyLimitReached: 'تم الوصول للحد اليومي للاستخدام.',
|
| 70 |
},
|
| 71 |
en: {
|
| 72 |
appTitle: 'ProtoChat',
|
|
|
|
| 100 |
confirmDelete: 'Yes, delete my account',
|
| 101 |
search: 'Search...',
|
| 102 |
generatePosts: 'Generate Posts',
|
| 103 |
+
thinking: 'What\'s on your mind, {name}?',
|
| 104 |
localContext: 'Posts customized for your area',
|
| 105 |
switchLang: 'العربية',
|
| 106 |
noPosts: 'No posts yet.',
|
|
|
|
| 109 |
comment: 'Comment',
|
| 110 |
share: 'Share',
|
| 111 |
writeComment: 'Write a comment...',
|
| 112 |
+
report: 'Report Content',
|
| 113 |
+
apiConsumption: 'Daily API Consumption',
|
| 114 |
+
used: 'Used',
|
| 115 |
+
remaining: 'Remaining',
|
| 116 |
+
backToChat: 'Back to Chat',
|
| 117 |
+
watchingOnly: 'This is a view-only chat. You cannot send messages here.',
|
| 118 |
+
chooseCanned: 'Choose a ready conversation...',
|
| 119 |
+
writeTopic: 'Write a topic to generate...',
|
| 120 |
+
generate: 'Generate',
|
| 121 |
+
randomChat: 'Random Chat',
|
| 122 |
+
startGroupChat: 'Start a Group Chat',
|
| 123 |
+
groupChatDesc: 'Choose from ready chats, write a topic, or press "Random Chat".',
|
| 124 |
+
voiceWelcome: 'Welcome to Voice Chat',
|
| 125 |
+
voiceDesc: 'Press the microphone to start talking with Proto',
|
| 126 |
+
listening: 'Listening...',
|
| 127 |
+
protoThinking: 'Proto is thinking...',
|
| 128 |
+
pressToStop: 'Press to stop',
|
| 129 |
+
pressToTalk: 'Press to talk',
|
| 130 |
+
successUpdate: 'Updated successfully',
|
| 131 |
+
accountDeleted: 'Account deleted',
|
| 132 |
+
completeProfile: 'Please complete your data to continue',
|
| 133 |
+
helloUser: 'Welcome {name}',
|
| 134 |
+
errorAI: 'Failed to get AI response.',
|
| 135 |
+
dailyLimitReached: 'Daily usage limit reached.',
|
| 136 |
}
|
| 137 |
};
|