Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf532ba35d | ||
|
|
0896dd46d1 | ||
|
|
e9ba9e2700 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# DeepSeek AI API Key
|
||||
# Get your API key from https://platform.deepseek.com/
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@ import { getDatabase } from "@/lib/database";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await req.json();
|
||||
const { userId, useExternalModel } = await req.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
|
||||
@@ -21,7 +21,7 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Construct prompt for Ollama
|
||||
// Construct prompt
|
||||
const prompt = `
|
||||
You are a professional fitness trainer and nutritionist.
|
||||
Generate a detailed daily recommendation for a user with the following profile:
|
||||
@@ -42,66 +42,144 @@ export async function POST(req: Request) {
|
||||
}
|
||||
`;
|
||||
|
||||
// Call Ollama
|
||||
const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gemma3:latest", // Make sure this model is pulled
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
format: "json",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
console.error("Ollama API error:", await ollamaResponse.text());
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate recommendation from AI service" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const aiData = await ollamaResponse.json();
|
||||
console.log("Raw AI Response:", aiData.response);
|
||||
|
||||
let parsedResponse;
|
||||
try {
|
||||
// Helper to clean up the response
|
||||
let cleanResponse = aiData.response.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (cleanResponse.startsWith("```json")) {
|
||||
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
|
||||
} else if (cleanResponse.startsWith("```")) {
|
||||
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
|
||||
if (useExternalModel) {
|
||||
// Use DeepSeek AI
|
||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||
|
||||
if (!deepseekApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "DeepSeek API key not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the first '{' and last '}' to extract the JSON object
|
||||
const firstBrace = cleanResponse.indexOf("{");
|
||||
const lastBrace = cleanResponse.lastIndexOf("}");
|
||||
console.log("Using DeepSeek AI model...");
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
||||
const deepseekResponse = await fetch("https://api.deepseek.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${deepseekApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 1000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!deepseekResponse.ok) {
|
||||
const errorText = await deepseekResponse.text();
|
||||
console.error("DeepSeek API error:", errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate recommendation from DeepSeek AI" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
parsedResponse = JSON.parse(cleanResponse);
|
||||
} catch (e) {
|
||||
// Fallback if model doesn't return perfect JSON despite instruction
|
||||
console.error("Failed to parse AI response:", aiData.response);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response format from AI model" },
|
||||
{ status: 500 }
|
||||
);
|
||||
const deepseekData = await deepseekResponse.json();
|
||||
console.log("Raw DeepSeek Response:", deepseekData);
|
||||
|
||||
try {
|
||||
const content = deepseekData.choices[0].message.content;
|
||||
let cleanResponse = content.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (cleanResponse.startsWith("```json")) {
|
||||
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
|
||||
} else if (cleanResponse.startsWith("```")) {
|
||||
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
|
||||
}
|
||||
|
||||
// Find the first '{' and last '}' to extract the JSON object
|
||||
const firstBrace = cleanResponse.indexOf("{");
|
||||
const lastBrace = cleanResponse.lastIndexOf("}");
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
parsedResponse = JSON.parse(cleanResponse);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse DeepSeek response:", deepseekData);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response format from DeepSeek AI" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Use local Ollama
|
||||
console.log("Using local Ollama model...");
|
||||
|
||||
const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gemma3:latest",
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
format: "json",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
console.error("Ollama API error:", await ollamaResponse.text());
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate recommendation from Ollama" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const aiData = await ollamaResponse.json();
|
||||
console.log("Raw Ollama Response:", aiData.response);
|
||||
|
||||
try {
|
||||
let cleanResponse = aiData.response.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (cleanResponse.startsWith("```json")) {
|
||||
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
|
||||
} else if (cleanResponse.startsWith("```")) {
|
||||
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
|
||||
}
|
||||
|
||||
// Find the first '{' and last '}' to extract the JSON object
|
||||
const firstBrace = cleanResponse.indexOf("{");
|
||||
const lastBrace = cleanResponse.lastIndexOf("}");
|
||||
|
||||
if (firstBrace !== -1 && lastBrace !== -1) {
|
||||
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
parsedResponse = JSON.parse(cleanResponse);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse Ollama response:", aiData.response);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response format from Ollama" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const recommendation = await db.createRecommendation({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
fitnessProfileId: profile.userId, // Using userId as ID for now since it's 1:1
|
||||
fitnessProfileId: profile.userId,
|
||||
type: 'ai_plan',
|
||||
content: parsedResponse.recommendationText,
|
||||
activityPlan: parsedResponse.activityPlan,
|
||||
|
||||
@@ -28,16 +28,22 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
|
||||
if (currentUserId !== targetUserId) {
|
||||
const currentUser = await db.getUserById(currentUserId)
|
||||
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
|
||||
const currentUser = await db.getUserById(currentUserId)
|
||||
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
|
||||
|
||||
if (currentUserId !== targetUserId) {
|
||||
if (!isStaff) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const recommendations = await db.getRecommendationsByUserId(targetUserId)
|
||||
let recommendations = await db.getRecommendationsByUserId(targetUserId)
|
||||
|
||||
// Non-staff users should only see approved recommendations
|
||||
if (!isStaff) {
|
||||
recommendations = recommendations.filter((rec: any) => rec.status === 'approved')
|
||||
}
|
||||
|
||||
return NextResponse.json(recommendations)
|
||||
} catch (error) {
|
||||
console.error('Error fetching recommendations:', error)
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function RecommendationsPage() {
|
||||
const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState<string | null>(null);
|
||||
const [useExternalModel, setUseExternalModel] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -56,7 +57,7 @@ export default function RecommendationsPage() {
|
||||
const res = await fetch("/api/recommendations/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({ userId, useExternalModel }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -98,6 +99,41 @@ export default function RecommendationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (rec: Recommendation) => {
|
||||
const newContent = prompt("Edit Advice:", rec.content);
|
||||
const newActivityPlan = prompt("Edit Activity Plan:", rec.activityPlan);
|
||||
const newDietPlan = prompt("Edit Diet Plan:", rec.dietPlan);
|
||||
|
||||
if (newContent === null || newActivityPlan === null || newDietPlan === null) {
|
||||
// User cancelled one of the prompts
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/recommendations", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: rec.id,
|
||||
content: newContent,
|
||||
activityPlan: newActivityPlan,
|
||||
dietPlan: newDietPlan,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
alert(`Failed to update recommendation: ${errorData.error || 'Unknown error'}`);
|
||||
} else {
|
||||
alert("Recommendation updated successfully!");
|
||||
fetchData(); // Refresh data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating recommendation:", error);
|
||||
alert("Failed to update recommendation.");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
@@ -108,7 +144,29 @@ export default function RecommendationsPage() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4">
|
||||
<h1 className="text-3xl font-bold mb-8">AI Recommendations</h1>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold">AI Recommendations</h1>
|
||||
|
||||
{/* Model Selection Toggle */}
|
||||
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-lg shadow">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{useExternalModel ? "DeepSeek AI" : "Local Ollama"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setUseExternalModel(!useExternalModel)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${useExternalModel ? "bg-blue-600" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${useExternalModel ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{useExternalModel ? "External" : "Local"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Generate Section */}
|
||||
@@ -171,6 +229,12 @@ export default function RecommendationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEdit(rec)}
|
||||
className="flex-1 bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(rec.id, "approved")}
|
||||
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
@@ -9,22 +10,59 @@ import {
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
Brain
|
||||
Brain,
|
||||
} from "lucide-react";
|
||||
import { UserButton, useUser } from "@clerk/nextjs";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
|
||||
{ icon: Users, label: "Users", href: "/users" },
|
||||
{ icon: Brain, label: "AI Recommendations", href: "/recommendations" },
|
||||
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
|
||||
{ icon: CreditCard, label: "Payments", href: "/payments" },
|
||||
{ icon: Settings, label: "Settings", href: "/settings" },
|
||||
];
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useUser();
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPending = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/recommendations");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const pending = (data.recommendations || []).filter(
|
||||
(r: Recommendation) => r.status === "pending"
|
||||
);
|
||||
setPendingCount(pending.length);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch pending recommendations", e);
|
||||
}
|
||||
};
|
||||
fetchPending();
|
||||
}, []);
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
|
||||
{ icon: Users, label: "Users", href: "/users" },
|
||||
{
|
||||
icon: Brain,
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
AI Recommendations
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-2 inline-block bg-red-600 text-xs rounded-full px-2 py-0.5">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
href: "/recommendations",
|
||||
},
|
||||
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
|
||||
{ icon: CreditCard, label: "Payments", href: "/payments" },
|
||||
{ icon: Settings, label: "Settings", href: "/settings" },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800">
|
||||
@@ -38,18 +76,17 @@ export function Sidebar() {
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
|
||||
const label = typeof item.label === "string" ? item.label : item.label;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive
|
||||
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
}`}
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"}`}
|
||||
>
|
||||
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, FlatList, ActivityIndicator, StyleSheet, RefreshControl } from "react-native";
|
||||
import { useAuth } from "@clerk/clerk-expo";
|
||||
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function RecommendationsScreen() {
|
||||
const { getToken, userId } = useAuth();
|
||||
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
try {
|
||||
if (!userId) {
|
||||
console.error('No userId available');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`;
|
||||
console.log('Fetching recommendations from:', url);
|
||||
|
||||
const res = await fetch(url, { headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
console.error('API Error:', res.status, errorText);
|
||||
throw new Error(`Network response was not ok: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log('Recommendations data:', data);
|
||||
setRecommendations(data.recommendations || data || []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load recommendations', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecommendations();
|
||||
}, []);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
fetchRecommendations();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color="#000" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.header}>AI Recommendations</Text>
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.empty}>No recommendations available yet.</Text>
|
||||
<Text style={styles.emptySub}>Pull down to refresh</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.status}>{item.status.toUpperCase()}</Text>
|
||||
<Text style={styles.date}>{new Date(item.createdAt).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
||||
<Text style={styles.content}>{item.content}</Text>
|
||||
|
||||
{item.activityPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Activity Plan</Text>
|
||||
<Text style={styles.content}>{item.activityPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.dietPlan && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Diet Plan</Text>
|
||||
<Text style={styles.content}>{item.dietPlan}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
header: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
padding: 20,
|
||||
paddingBottom: 12,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 12,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 3,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
},
|
||||
status: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#2e7d32',
|
||||
backgroundColor: '#e8f5e9',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
},
|
||||
date: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1a1a1a',
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
content: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
empty: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptySub: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user