This commit is contained in:
Flatlogic Bot 2026-01-26 21:02:05 +00:00
parent 05f4c9c979
commit d7b90335da
21 changed files with 983 additions and 604 deletions

View File

@ -48,7 +48,107 @@ class LocalAIApi
$payload['model'] = $cfg['default_model']; $payload['model'] = $cfg['default_model'];
} }
return self::request($options['path'] ?? null, $payload, $options); $resp = self::request($options['path'] ?? null, $payload, $options);
if ($resp['success'] && isset($resp['data']['ai_request_id']) && isset($resp['data']['status']) && $resp['data']['status'] === 'queued') {
return self::awaitResponse($resp['data']['ai_request_id'], $options);
}
return $resp;
}
/**
* Poll the status of an asynchronous AI request.
*
* @param string|int $requestId
* @param array<string,mixed> $options
* @return array<string,mixed>
*/
public static function awaitResponse($requestId, array $options = []): array
{
$interval = (int) ($options['poll_interval'] ?? 5);
$timeout = (int) ($options['poll_timeout'] ?? 300);
$start = time();
while (time() - $start < $timeout) {
$statusResp = self::fetchStatus($requestId, $options);
if (!$statusResp['success']) {
return $statusResp;
}
$data = $statusResp['data'] ?? [];
$status = $data['status'] ?? 'unknown';
if ($status === 'completed' || $status === 'success') {
return $statusResp;
}
if ($status === 'failed' || $status === 'cancelled') {
return [
'success' => false,
'error' => $status,
'message' => $data['error'] ?? "AI request $status",
'data' => $data
];
}
sleep($interval);
}
return [
'success' => false,
'error' => 'timeout',
'message' => 'Timed out waiting for AI response.'
];
}
/**
* Fetch the status of an AI request.
*
* @param string|int $requestId
* @param array<string,mixed> $options
* @return array<string,mixed>
*/
public static function fetchStatus($requestId, array $options = []): array
{
$cfg = self::config();
$projectId = $cfg['project_id'];
$path = "/projects/{$projectId}/ai-request/{$requestId}/status";
$ch = curl_init(self::buildUrl($path, $cfg['base_url']));
$headers = [
'Accept: application/json',
$cfg['project_header'] . ': ' . $cfg['project_uuid']
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $cfg['verify_tls'] ?? true);
$responseBody = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (file_exists(__DIR__ . '/../api/scan_debug.log')) {
file_put_contents(__DIR__ . '/../api/scan_debug.log', date('Y-m-d H:i:s') . " fetchStatus($requestId) status: $status, body: $responseBody" . PHP_EOL, FILE_APPEND);
}
$decoded = json_decode($responseBody, true);
if ($status >= 200 && $status < 300) {
return [
'success' => true,
'status' => $status,
'data' => $decoded
];
}
return [
'success' => false,
'status' => $status,
'error' => $decoded['error'] ?? 'Failed to fetch status'
];
} }
/** /**
@ -134,6 +234,11 @@ class LocalAIApi
} }
$body = json_encode($payload, JSON_UNESCAPED_UNICODE); $body = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (file_exists(__DIR__ . '/../api/scan_debug.log')) {
// Log a truncated version of the body if it's too large (images are large)
$logBody = strlen($body) > 1000 ? substr($body, 0, 1000) . '... [TRUNCATED]' : $body;
file_put_contents(__DIR__ . '/../api/scan_debug.log', date('Y-m-d H:i:s') . " AI Request to $url: " . $logBody . PHP_EOL, FILE_APPEND);
}
if ($body === false) { if ($body === false) {
return [ return [
'success' => false, 'success' => false,
@ -211,6 +316,11 @@ class LocalAIApi
return ''; return '';
} }
// If the payload contains a 'response' object (typical for status checks), use it.
if (isset($payload['response']) && is_array($payload['response'])) {
$payload = $payload['response'];
}
if (!empty($payload['output']) && is_array($payload['output'])) { if (!empty($payload['output']) && is_array($payload['output'])) {
$combined = ''; $combined = '';
foreach ($payload['output'] as $item) { foreach ($payload['output'] as $item) {

View File

@ -46,7 +46,7 @@ return [
'project_id' => $projectId, 'project_id' => $projectId,
'project_uuid' => $projectUuid, 'project_uuid' => $projectUuid,
'project_header' => 'project-uuid', 'project_header' => 'project-uuid',
'default_model' => 'gpt-5', 'default_model' => 'gpt-4o-mini',
'timeout' => 30, 'timeout' => 30,
'verify_tls' => true, 'verify_tls' => true,
]; ];

23
api/auth_helper.php Normal file
View File

@ -0,0 +1,23 @@
<?php
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once __DIR__ . '/../db/config.php';
function is_logged_in() {
return isset($_SESSION['user_id']);
}
function get_logged_in_user_id() {
return $_SESSION['user_id'] ?? null;
}
function login_user($user_id) {
$_SESSION['user_id'] = $user_id;
}
function logout_user() {
unset($_SESSION['user_id']);
session_destroy();
}

25
api/check_auth.php Normal file
View File

@ -0,0 +1,25 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/auth_helper.php';
if (is_logged_in()) {
try {
$pdo = db();
$stmt = $pdo->prepare("SELECT id, email, shopping_list FROM users WHERE id = ?");
$stmt->execute([get_logged_in_user_id()]);
$user = $stmt->fetch();
if ($user) {
if ($user['shopping_list']) {
$user['shopping_list'] = json_decode($user['shopping_list'], true);
}
echo json_encode(['success' => true, 'logged_in' => true, 'user' => $user]);
} else {
logout_user();
echo json_encode(['success' => true, 'logged_in' => false]);
}
} catch (PDOException $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
} else {
echo json_encode(['success' => true, 'logged_in' => false]);
}

View File

@ -1,6 +1,6 @@
<?php <?php
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php'; require_once __DIR__ . '/auth_helper.php';
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
@ -10,10 +10,21 @@ if (!$data || !isset($data['id'])) {
} }
$recipeId = $data['id']; $recipeId = $data['id'];
$userId = get_logged_in_user_id();
$pdo = db(); $pdo = db();
try { try {
// Check ownership
$stmt = $pdo->prepare("SELECT user_id FROM recipes WHERE id = ?");
$stmt->execute([$recipeId]);
$recipe = $stmt->fetch();
if ($recipe && $recipe['user_id'] !== null && $recipe['user_id'] != $userId) {
echo json_encode(['success' => false, 'error' => 'Unauthorized to delete this recipe.']);
exit;
}
$stmt = $pdo->prepare("DELETE FROM recipes WHERE id = ?"); $stmt = $pdo->prepare("DELETE FROM recipes WHERE id = ?");
$stmt->execute([$recipeId]); $stmt->execute([$recipeId]);

View File

@ -1,14 +1,27 @@
<?php <?php
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php'; require_once __DIR__ . '/auth_helper.php';
try { try {
$pdo = db(); $pdo = db();
$recipes_stmt = $pdo->query('SELECT * FROM recipes ORDER BY created_at DESC'); $userId = get_logged_in_user_id();
if ($userId) {
$recipes_stmt = $pdo->prepare('SELECT * FROM recipes WHERE user_id = ? OR user_id IS NULL ORDER BY created_at DESC');
$recipes_stmt->execute([$userId]);
} else {
$recipes_stmt = $pdo->query('SELECT * FROM recipes WHERE user_id IS NULL ORDER BY created_at DESC');
}
$recipes = $recipes_stmt->fetchAll(); $recipes = $recipes_stmt->fetchAll();
$ingredients_stmt = $pdo->query('SELECT * FROM ingredients'); $recipeIds = array_column($recipes, 'id');
$all_ingredients = $ingredients_stmt->fetchAll(); $all_ingredients = [];
if (!empty($recipeIds)) {
$placeholders = implode(',', array_fill(0, count($recipeIds), '?'));
$ingredients_stmt = $pdo->prepare("SELECT * FROM ingredients WHERE recipe_id IN ($placeholders)");
$ingredients_stmt->execute($recipeIds);
$all_ingredients = $ingredients_stmt->fetchAll();
}
$ingredients_by_recipe = []; $ingredients_by_recipe = [];
foreach ($all_ingredients as $ingredient) { foreach ($all_ingredients as $ingredient) {

28
api/login.php Normal file
View File

@ -0,0 +1,28 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/auth_helper.php';
$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
if (empty($email) || empty($password)) {
echo json_encode(['success' => false, 'error' => 'Email and password are required.']);
exit;
}
try {
$pdo = db();
$stmt = $pdo->prepare("SELECT id, password_hash FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
login_user($user['id']);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Invalid email or password.']);
}
} catch (PDOException $e) {
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
}

6
api/logout.php Normal file
View File

@ -0,0 +1,6 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/auth_helper.php';
logout_user();
echo json_encode(['success' => true]);

40
api/register.php Normal file
View File

@ -0,0 +1,40 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/auth_helper.php';
$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
if (empty($email) || empty($password)) {
echo json_encode(['success' => false, 'error' => 'Email and password are required.']);
exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo json_encode(['success' => false, 'error' => 'Invalid email format.']);
exit;
}
try {
$pdo = db();
// Check if user already exists
$stmt = $pdo->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) {
echo json_encode(['success' => false, 'error' => 'Email already registered.']);
exit;
}
$password_hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("INSERT INTO users (email, password_hash) VALUES (?, ?)");
$stmt->execute([$email, $password_hash]);
$user_id = $pdo->lastInsertId();
login_user($user_id);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
}

View File

@ -1,6 +1,6 @@
<?php <?php
header('Content-Type: application/json'); header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php'; require_once __DIR__ . '/auth_helper.php';
function get_ingredient_category($name) { function get_ingredient_category($name) {
$name = strtolower($name); $name = strtolower($name);
@ -17,6 +17,8 @@ function get_ingredient_category($name) {
$data = $_POST; $data = $_POST;
$files = $_FILES; $files = $_FILES;
$userId = get_logged_in_user_id();
if (!isset($data['name']) || !isset($data['guests']) || !isset($data['ingredients'])) { if (!isset($data['name']) || !isset($data['guests']) || !isset($data['ingredients'])) {
echo json_encode(['success' => false, 'error' => 'Invalid input.']); echo json_encode(['success' => false, 'error' => 'Invalid input.']);
exit; exit;
@ -55,16 +57,22 @@ try {
$recipeId = $data['id']; $recipeId = $data['id'];
$category = !empty($data['category']) ? $data['category'] : 'No category'; $category = !empty($data['category']) ? $data['category'] : 'No category';
// Fetch existing image URL if a new one isn't uploaded // Check if recipe belongs to user
if ($imageUrl === null) { $stmt = $pdo->prepare("SELECT user_id, image_url FROM recipes WHERE id = ?");
$stmt = $pdo->prepare("SELECT image_url FROM recipes WHERE id = ?"); $stmt->execute([$recipeId]);
$stmt->execute([$recipeId]); $existing = $stmt->fetch();
$existing = $stmt->fetch();
$imageUrl = $existing ? $existing['image_url'] : null; if (!$existing || ($existing['user_id'] !== null && $existing['user_id'] != $userId)) {
throw new Exception('Unauthorized to update this recipe.');
} }
$stmt = $pdo->prepare("UPDATE recipes SET name = ?, guests = ?, category = ?, image_url = ? WHERE id = ?"); // Fetch existing image URL if a new one isn't uploaded
$stmt->execute([$data['name'], $data['guests'], $category, $imageUrl, $recipeId]); if ($imageUrl === null) {
$imageUrl = $existing['image_url'];
}
$stmt = $pdo->prepare("UPDATE recipes SET name = ?, guests = ?, category = ?, image_url = ?, user_id = ? WHERE id = ?");
$stmt->execute([$data['name'], $data['guests'], $category, $imageUrl, $userId, $recipeId]);
// Easiest way to handle ingredients is to delete old ones and insert new ones // Easiest way to handle ingredients is to delete old ones and insert new ones
$stmt = $pdo->prepare("DELETE FROM ingredients WHERE recipe_id = ?"); $stmt = $pdo->prepare("DELETE FROM ingredients WHERE recipe_id = ?");
@ -73,8 +81,8 @@ try {
} else { } else {
// Insert new recipe // Insert new recipe
$category = !empty($data['category']) ? $data['category'] : 'No category'; $category = !empty($data['category']) ? $data['category'] : 'No category';
$stmt = $pdo->prepare("INSERT INTO recipes (name, guests, category, image_url) VALUES (?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO recipes (name, guests, category, image_url, user_id) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$data['name'], $data['guests'], $category, $imageUrl]); $stmt->execute([$data['name'], $data['guests'], $category, $imageUrl, $userId]);
$recipeId = $pdo->lastInsertId(); $recipeId = $pdo->lastInsertId();
} }

View File

@ -0,0 +1,21 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/auth_helper.php';
if (!is_logged_in()) {
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
try {
$data = json_decode(file_get_contents('php://input'), true);
$shoppingList = $data['shopping_list'] ?? null;
$pdo = db();
$stmt = $pdo->prepare("UPDATE users SET shopping_list = ? WHERE id = ?");
$stmt->execute([json_encode($shoppingList), get_logged_in_user_id()]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

20
api/scan_debug.log Normal file
View File

@ -0,0 +1,20 @@
2026-01-26 20:36:16 Processing image: type=image/jpeg, size=85908 bytes
2026-01-26 20:36:16 Data URL prefix: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...
2026-01-26 20:36:17 AI Request to https://flatlogic.com/projects/35604/ai-request: {"model":"gpt-4o","input":[{"role":"user","content":[{"type":"text","text":"Analyze this image. If it's a photo of a dish, identify what it is and generate a possible recipe for it. If it's a photo of a written recipe, extract the information. \nExtract or generate the following information in JSON format:\n- name: The name of the dish\/recipe\n- category: One of 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'\n- ingredients: An array of objects, each with:\n - name: Name of the ingredient\n - quantity: Numeric quantity (float)\n - unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'piece', 'pack')\n- guests: The default number of guests\/servings the recipe is for (integer)\n\nImportant: \n1. The quantities should be for 1 person if possible, or for the number of guests specified. If you're generating a recipe for a dish, default to 1 or 2 guests.\n2. Return ONLY the JSON object.\n3. If you can't determine something, provide a best guess or null."},{"type":"image_url","imag... [TRUNCATED]
2026-01-26 20:36:17 fetchStatus(1149) status: 200, body: {"status":"failed","error":"the server responded with status 400"}
2026-01-26 20:36:17 AI Scan Error: failed
2026-01-26 20:36:27 Processing image: type=image/jpeg, size=85908 bytes
2026-01-26 20:36:27 Data URL prefix: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...
2026-01-26 20:36:27 AI Request to https://flatlogic.com/projects/35604/ai-request: {"model":"gpt-4o","input":[{"role":"user","content":[{"type":"text","text":"Analyze this image. If it's a photo of a dish, identify what it is and generate a possible recipe for it. If it's a photo of a written recipe, extract the information. \nExtract or generate the following information in JSON format:\n- name: The name of the dish\/recipe\n- category: One of 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'\n- ingredients: An array of objects, each with:\n - name: Name of the ingredient\n - quantity: Numeric quantity (float)\n - unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'piece', 'pack')\n- guests: The default number of guests\/servings the recipe is for (integer)\n\nImportant: \n1. The quantities should be for 1 person if possible, or for the number of guests specified. If you're generating a recipe for a dish, default to 1 or 2 guests.\n2. Return ONLY the JSON object.\n3. If you can't determine something, provide a best guess or null."},{"type":"image_url","imag... [TRUNCATED]
2026-01-26 20:36:27 fetchStatus(1150) status: 200, body: {"status":"failed","error":"the server responded with status 400"}
2026-01-26 20:36:27 AI Scan Error: failed
2026-01-26 20:36:43 Processing image: type=image/jpeg, size=85908 bytes
2026-01-26 20:36:43 Data URL prefix: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...
2026-01-26 20:36:43 AI Request to https://flatlogic.com/projects/35604/ai-request: {"model":"gpt-4o","input":[{"role":"user","content":[{"type":"text","text":"Analyze this image. If it's a photo of a dish, identify what it is and generate a possible recipe for it. If it's a photo of a written recipe, extract the information. \nExtract or generate the following information in JSON format:\n- name: The name of the dish\/recipe\n- category: One of 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'\n- ingredients: An array of objects, each with:\n - name: Name of the ingredient\n - quantity: Numeric quantity (float)\n - unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'piece', 'pack')\n- guests: The default number of guests\/servings the recipe is for (integer)\n\nImportant: \n1. The quantities should be for 1 person if possible, or for the number of guests specified. If you're generating a recipe for a dish, default to 1 or 2 guests.\n2. Return ONLY the JSON object.\n3. If you can't determine something, provide a best guess or null."},{"type":"image_url","imag... [TRUNCATED]
2026-01-26 20:36:43 fetchStatus(1151) status: 200, body: {"status":"failed","error":"the server responded with status 400"}
2026-01-26 20:36:43 AI Scan Error: failed
2026-01-26 20:37:58 Processing image: type=image/jpeg, size=85908 bytes
2026-01-26 20:37:58 Data URL prefix: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...
2026-01-26 20:37:58 AI Request to https://flatlogic.com/projects/35604/ai-request: {"model":"gpt-4o","input":[{"role":"user","content":[{"type":"text","text":"Analyze this image. If it's a photo of a dish, identify what it is and generate a possible recipe for it. If it's a photo of a written recipe, extract the information. \nExtract or generate the following information in JSON format:\n- name: The name of the dish\/recipe\n- category: One of 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'\n- ingredients: An array of objects, each with:\n - name: Name of the ingredient\n - quantity: Numeric quantity (float)\n - unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'piece', 'pack')\n- guests: The default number of guests\/servings the recipe is for (integer)\n\nImportant: \n1. The quantities should be for 1 person if possible, or for the number of guests specified. If you're generating a recipe for a dish, default to 1 or 2 guests.\n2. Return ONLY the JSON object.\n3. If you can't determine something, provide a best guess or null."},{"type":"image_url","imag... [TRUNCATED]
2026-01-26 20:37:58 fetchStatus(1152) status: 200, body: {"status":"failed","error":"the server responded with status 400"}
2026-01-26 20:37:58 AI Scan Error: failed

View File

@ -13,9 +13,13 @@ if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
} }
$imagePath = $_FILES['image']['tmp_name']; $imagePath = $_FILES['image']['tmp_name'];
$imageSize = filesize($imagePath);
$imageData = base64_encode(file_get_contents($imagePath)); $imageData = base64_encode(file_get_contents($imagePath));
$imageType = $_FILES['image']['type']; $imageType = $_FILES['image']['type'];
file_put_contents(__DIR__ . '/scan_debug.log', date('Y-m-d H:i:s') . " Processing image: type=$imageType, size=$imageSize bytes" . PHP_EOL, FILE_APPEND);
file_put_contents(__DIR__ . '/scan_debug.log', date('Y-m-d H:i:s') . " Data URL prefix: " . substr("data:$imageType;base64,$imageData", 0, 50) . "..." . PHP_EOL, FILE_APPEND);
$prompt = "Analyze this image. If it's a photo of a dish, identify what it is and generate a possible recipe for it. If it's a photo of a written recipe, extract the information. $prompt = "Analyze this image. If it's a photo of a dish, identify what it is and generate a possible recipe for it. If it's a photo of a written recipe, extract the information.
Extract or generate the following information in JSON format: Extract or generate the following information in JSON format:
- name: The name of the dish/recipe - name: The name of the dish/recipe
@ -33,6 +37,7 @@ Important:
try { try {
$response = LocalAIApi::createResponse([ $response = LocalAIApi::createResponse([
'model' => 'gpt-4o',
'input' => [ 'input' => [
[ [
'role' => 'user', 'role' => 'user',
@ -44,7 +49,8 @@ try {
[ [
'type' => 'image_url', 'type' => 'image_url',
'image_url' => [ 'image_url' => [
'url' => "data:$imageType;base64,$imageData" 'url' => "data:$imageType;base64,$imageData",
'detail' => 'auto'
] ]
] ]
] ]
@ -53,13 +59,18 @@ try {
]); ]);
if (!$response['success']) { if (!$response['success']) {
file_put_contents(__DIR__ . '/scan_debug.log', date('Y-m-d H:i:s') . ' AI Scan Error: ' . ($response['error'] ?? 'AI request failed') . PHP_EOL, FILE_APPEND);
throw new Exception($response['error'] ?? 'AI request failed'); throw new Exception($response['error'] ?? 'AI request failed');
} }
file_put_contents(__DIR__ . '/scan_debug.log', date('Y-m-d H:i:s') . ' AI Full Response: ' . json_encode($response) . PHP_EOL, FILE_APPEND);
$text = LocalAIApi::extractText($response);
file_put_contents(__DIR__ . '/scan_debug.log', date('Y-m-d H:i:s') . ' AI Scan Raw Text: ' . $text . PHP_EOL, FILE_APPEND);
$recipeData = LocalAIApi::decodeJsonFromResponse($response); $recipeData = LocalAIApi::decodeJsonFromResponse($response);
if (!$recipeData) { if (!$recipeData) {
throw new Exception('Failed to parse AI response as JSON. Raw response: ' . LocalAIApi::extractText($response)); file_put_contents(__DIR__ . '/scan_debug.log', date('Y-m-d H:i:s') . ' AI Scan JSON Parse Error. Raw: ' . $text . PHP_EOL, FILE_APPEND);
throw new Exception('Failed to parse AI response as JSON. Please try again with a clearer image.');
} }
echo json_encode(['success' => true, 'data' => $recipeData]); echo json_encode(['success' => true, 'data' => $recipeData]);

15
api/test_ai.php Normal file
View File

@ -0,0 +1,15 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../ai/LocalAIApi.php';
try {
$response = LocalAIApi::createResponse([
'input' => [
['role' => 'user', 'content' => 'Hello, tell me a short joke.']
]
]);
echo json_encode($response);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

View File

@ -1,573 +1,397 @@
/* Christmas Decorations: Fixed Positioning */
/* General Body Styles */ /* General Body Styles */
body { body {
background-color: #142E35; /* Dark green background */ background-color: #FAF9F6; /* Light beige background */
color: #ffffff; /* White text */ color: #2D2D2D; /* Dark gray text */
font-family: 'Poppins', sans-serif; font-family: 'Poppins', sans-serif;
} }
/* Garland */
body::before {
content: '';
position: fixed;
top: 10px;
left: -5%;
width: 110%;
height: 20px;
background:
radial-gradient(circle, #de4950 4px, transparent 5px),
radial-gradient(circle, #142E35 4px, transparent 5px),
radial-gradient(circle, #7accb8 4px, transparent 5px),
radial-gradient(circle, #edd46e 4px, transparent 5px),
radial-gradient(circle, #6e98ed 4px, transparent 5px);
background-size: 100px 20px;
background-position: 0 0, 20px 0, 40px 0, 60px 0, 80px 0;
background-repeat: repeat-x;
z-index: 1031;
animation: garland-animation 1.5s infinite;
}
@keyframes garland-animation {
50% {
filter: brightness(0.7);
}
}
#christmas-decorations-right {
position: fixed;
top: 0px;
right: 250px;
width: 220px;
z-index: 1033;
}
#christmas-decorations-right img {
width: 100%;
}
#christmas-decorations-left {
position: fixed;
top: 0px;
left: 250px;
width: 220px;
z-index: 1033;
transform: scaleX(-1);
}
#christmas-decorations-left img {
width: 100%;
}
/* Headings */ /* Headings */
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
color: #ffffff !important; /* Use !important to override other styles if necessary */ color: #2D2D2D !important;
font-weight: 700;
} }
/* Buttons and Inputs */ /* Buttons and Inputs */
input, input,
button { button,
border-radius: 16px; .form-control,
.form-select {
border-radius: 12px;
} }
.btn { .btn {
border-radius: 50px !important; /* Fully rounded corners */ border-radius: 50px !important;
}
/* Navbar */
.navbar {
background-color: rgba(20, 46, 53, 0.8) !important; /* Semi-transparent dark green */
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.navbar-brand {
color: #ffffff !important;
font-weight: 600; font-weight: 600;
padding: 10px 24px;
transition: all 0.3s ease;
} }
/* Main Content */ /* Main Content */
.display-4 { .display-4 {
font-weight: 700; font-weight: 700;
color: #ffffff; color: #2D2D2D;
} }
.lead { .lead {
color: #ffffff; color: #666666;
} }
/* Cards */ /* Cards */
.card { .card {
background-color: rgba(255, 255, 255, 0.05); background-color: #ffffff;
border: none; border: none;
border-radius: 15px; border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
transition: transform 0.3s ease, box-shadow 0.3s ease;
} }
.card-body { .card-body {
color: #ffffff; color: #2D2D2D;
} }
/* Forms */ /* Forms */
.form-label { .form-label {
color: #ffffff; color: #2D2D2D;
font-weight: 600;
margin-bottom: 0.5rem;
} }
.form-control { .form-control,
background-color: rgba(0, 0, 0, 0.2); .form-select {
border: 1px solid rgba(255, 255, 255, 0.1); background-color: #ffffff;
color: #ffffff; border: 2px solid #EEEEEE;
color: #2D2D2D;
padding: 12px 16px;
} }
.form-control:focus { .form-control:focus,
background-color: rgba(0, 0, 0, 0.3); .form-select:focus {
border-color: #0a1a1f; /* Dark green accent */ background-color: #ffffff;
box-shadow: 0 0 0 0.25rem rgba(10, 26, 31, 0.25); border-color: #FF7F50; /* Orange accent */
color: #ffffff; box-shadow: 0 0 0 0.25rem rgba(255, 127, 80, 0.15);
color: #2D2D2D;
} }
.form-control::placeholder { .form-control::placeholder {
color: rgba(255, 255, 255, 0.5); color: #AAAAAA;
} }
/* Buttons */ /* Buttons */
.btn-primary, .btn-danger { .btn-primary {
background-color: #de4950 !important; /* Coral red */ background-color: #FF7F50 !important; /* Orange */
border-color: #de4950 !important; border-color: #FF7F50 !important;
font-weight: 600;
padding: 12px 30px;
transition: all 0.3s ease;
}
.btn-primary:hover, .btn-danger:hover {
background-color: #a02929 !important;
border-color: #a02929 !important;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(222, 73, 80, 0.2);
}
.btn-outline-secondary {
border-color: rgba(255, 255, 255, 0.8);
color: #ffffff;
font-weight: 600;
padding: 12px 30px;
transition: all 0.3s ease;
}
.btn-outline-secondary:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #ffffff;
border-color: #ffffff;
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.15);
border: none;
color: #ffffff;
padding: 12px 30px;
}
/* Shopping List & Recipe Cards */
#shopping-list-container .text-muted,
#recipe-cards-container .text-muted {
color: #ffffff !important; color: #ffffff !important;
} }
.recipe-card { .btn-primary:hover {
background-color: rgba(255, 255, 255, 0.1); background-color: #E66E45 !important;
border-radius: 10px; border-color: #E66E45 !important;
padding: 15px; transform: translateY(-2px);
margin-bottom: 15px; box-shadow: 0 8px 20px rgba(255, 127, 80, 0.3);
} }
/* 3-Column Layout Adjustments */ .btn-danger {
.row.g-4 > [class*='col-'] .card { background-color: #FF4B2B !important;
height: 100%; /* Make cards in columns equal height */ border-color: #FF4B2B !important;
color: #ffffff !important;
}
.btn-outline-secondary {
border: 2px solid #EEEEEE;
color: #666666;
}
.btn-outline-secondary:hover {
background-color: #F8F8F8;
color: #2D2D2D;
border-color: #DDDDDD;
}
.btn-secondary {
background-color: #EEEEEE;
border: none;
color: #666666;
}
.btn-secondary:hover {
background-color: #DDDDDD;
color: #2D2D2D;
}
/* Shopping List & Recipe Cards */
.recipe-card {
background-color: #ffffff;
border-radius: 20px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #F0F0F0;
} }
#recipe-cards-container, #shopping-list-container { #recipe-cards-container, #shopping-list-container {
max-height: 60vh; /* Adjust as needed */ max-height: 70vh;
overflow-y: auto; overflow-y: auto;
padding: 10px; padding: 10px;
} }
/* Custom scrollbar for webkit browsers */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2); background: transparent;
border-radius: 4px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #0a1a1f; /* Even darker green */ background: #DDDDDD;
border-radius: 4px; border-radius: 10px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #10242B; /* Slightly lighter than thumb */ background: #CCCCCC;
} }
/* Footer */ /* Footer */
footer.bg-light { footer {
background-color: transparent !important; border-top: 1px solid #EEEEEE;
border-top: 1px solid rgba(255, 255, 255, 0.1); color: #999999;
color: #ffffff; font-size: 0.9rem;
}
/* Snow Effect */
#snow-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1032;
overflow: hidden;
}
.snowflake {
position: absolute;
top: -20px;
background: #fff;
border-radius: 50%;
opacity: 0.8;
pointer-events: none;
animation: fall linear infinite;
}
@keyframes fall {
to {
transform: translateY(105vh);
opacity: 0;
}
} }
/* Shopping List Checkbox */ /* Shopping List Checkbox */
.list-group-item {
background-color: transparent !important;
border-bottom: 1px solid #F0F0F0 !important;
padding: 15px 0 !important;
color: #2D2D2D !important;
}
.list-group-item.checked .form-check-label { .list-group-item.checked .form-check-label {
text-decoration: line-through; text-decoration: line-through;
opacity: 0.6; opacity: 0.5;
} }
.unit-btn { .form-check-input {
padding: 0.375rem 0.5rem; width: 1.25em;
font-size: 0.875rem; height: 1.25em;
} border: 2px solid #DDDDDD;
cursor: pointer;
/* Adjust padding for the remove button in the ingredient row */
.ingredient-row .remove-ingredient {
padding: 0.375rem 0.75rem;
}
/* Custom Shopping List Styles */
.col-md-4:has(#shopping-list-container) .card {
background-color: transparent;
box-shadow: none !important;
border: 1px solid rgba(255, 255, 255, 0.1);
}
#shopping-list-container .list-group-item {
background-color: transparent;
color: #ffffff;
border-color: rgba(255, 255, 255, 0.1) !important;
}
/* Custom Checkbox Styles */
.form-.form-check-input {
background-color: transparent;
border: 1px solid #ffffff;
} }
.form-check-input:checked { .form-check-input:checked {
background-color: #de4950; background-color: #FF7F50;
border-color: #de4950; border-color: #FF7F50;
} }
.form-check-input:focus { /* Unit buttons */
border-color: #0a1a1f; .unit-selector .btn {
box-shadow: 0 0 0 0.25rem rgba(10, 26, 31, 0.25); padding: 6px 12px !important;
font-size: 0.85rem;
} }
@media print { .unit-selector .btn-secondary {
body * { background-color: #FF7F50 !important;
visibility: hidden; color: white !important;
}
#shopping-list-container, #shopping-list-container * {
visibility: visible;
}
#shopping-list-container {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.card {
border: none !important;
box-shadow: none !important;
}
.list-group-item {
color: #000 !important;
background-color: #fff !important;
}
.badge {
color: #000 !important;
background-color: #fff !important;
border: 1px solid #ccc;
}
.form-check-input {
border: 1px solid #000 !important;
}
h2, h3 {
color: #000 !important;
}
} }
/* Badges */
.bg-custom-green { .bg-custom-green {
background-color: #142E35 !important; background-color: #FFF0EB !important;
color: #FF7F50 !important;
font-weight: 700;
border: 1px solid #FFE4DB;
} }
/* Modal Styles */ /* Quantity Modifiers */
#recipe-form-modal .modal-content, #add-product-modal .modal-content, #confirmRemoveModal .modal-content, #view-recipe-modal .modal-content { .btn-quantity-modifier {
background-color: #142E35; width: 28px;
color: white; height: 28px;
} border-radius: 8px !important;
#recipe-form-modal .modal-header,
#add-product-modal .modal-header,
#confirmRemoveModal .modal-header,
#view-recipe-modal .modal-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
#recipe-form-modal .modal-footer,
#add-product-modal .modal-footer,
#confirmRemoveModal .modal-footer,
#view-recipe-modal .modal-footer {
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
#recipe-form-modal .btn-close,
#add-product-modal .btn-close,
#confirmRemoveModal .btn-close,
#view-recipe-modal .btn-close {
filter: invert(1);
}
/* Category Filter Button Styles */
#category-filters .btn {
border-radius: 20px; /* Rounded corners */
padding: 6px 12px;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.5;
border: 1px solid rgba(255, 255, 255, 0.2);
background-color: rgba(255, 255, 255, 0.1);
color: white;
transition: background-color 0.2s ease;
}
#category-filters .btn:hover {
background-color: rgba(255, 255, 255, 0.2);
}
#category-filters .btn.active {
background-color: #de4950; /* Coral red for active */
border-color: #de4950;
color: white;
}
/* Recipe Card Category Label */
.recipe-category-label {
background-color: #142E35;
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 0.8em;
font-weight: 600;
margin-left: 10px; /* Add some space between title and label */
white-space: nowrap; /* Prevent the label itself from wrapping */
align-self: flex-start; /* Align to the top of the flex container */
}
/* Shopping List Quantity Controls */
.btn.btn-quantity-modifier {
width: 32px;
height: 32px;
border-radius: 50%;
padding: 0;
font-size: 1.2rem;
font-weight: bold;
line-height: 1;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.2); background-color: #F8F8F8;
outline: none; border: 1px solid #EEEEEE;
box-shadow: none; color: #666666;
padding: 0 !important;
}
.btn-quantity-modifier:hover {
background-color: #FF7F50;
color: white; color: white;
background-color: rgba(255, 255, 255, 0.1); border-color: #FF7F50;
transition: background-color 0.2s ease;
} }
.btn.btn-quantity-modifier:hover { /* Modal Styles */
background-color: rgba(255, 255, 255, 0.2); .modal-content {
background-color: #ffffff;
border: none;
border-radius: 24px;
overflow: hidden;
} }
.quantity-controls .quantity { .modal-header {
margin: 0 10px; border-bottom: 1px solid #F0F0F0;
padding: 24px;
} }
/* Make delete recipe button same height as edit button */ .modal-footer {
.card .btn.delete-recipe { border-top: 1px solid #F0F0F0;
padding: .25rem .5rem; padding: 20px 24px;
margin-left: 8px; }
/* Category Label */
.recipe-category-label {
background-color: #FFF0EB;
color: #FF7F50;
padding: 4px 12px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Category Filters */
#category-filters .btn {
border: 2px solid #EEEEEE;
background-color: #ffffff;
color: #666666;
font-size: 0.85rem;
padding: 8px 16px !important;
}
#category-filters .btn.active {
background-color: #FF7F50 !important;
border-color: #FF7F50 !important;
color: #ffffff !important;
}
/* Search Container */
#recipe-search {
padding-left: 3rem;
height: 54px;
border-color: #EEEEEE;
}
.search-container .bi-search {
left: 1.25rem;
color: #AAAAAA;
font-size: 1.1rem;
} }
/* Recipe Card Image */ /* Recipe Card Image */
.card .card-img-top { .card .card-img-top {
height: 200px; height: 180px;
object-fit: cover; object-fit: cover;
border-top-left-radius: 20px;
border-top-right-radius: 20px;
} }
/* View Recipe Modal */
/* Recipe Card Animation */
.recipe-card-enter {
animation: fade-in 0.5s ease-in-out;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
#christmas-decorations-right,
#christmas-decorations-left {
display: none;
}
body {
font-size: 14px;
}
.display-4 {
font-size: 2.5rem;
}
.btn {
padding: 10px 20px;
font-size: 0.9rem;
}
h1, .h1 { font-size: 2rem; }
h2, .h2 { font-size: 1.5rem; }
h3, .h3 { font-size: 1.5rem; }
.card .btn.edit-recipe {
padding-top: .25rem;
padding-bottom: .25rem;
}
#print-shopping-list-btn {
font-size: 0; /* Hide the text */
padding: .25rem .5rem; /* Match other small buttons */
}
#print-shopping-list-btn .bi-printer {
font-size: 1rem; /* Restore icon size */
}
/* Make Add Recipe and Add Product buttons same height as Print button on mobile */
#add-product-btn,
.col-md-6:first-child .btn-primary {
padding-top: .375rem;
padding-bottom: .375rem;
}
/* Allow unit selector buttons to wrap in modals */
.unit-selector {
flex-wrap: wrap;
gap: 0.5rem;
}
/* Stack form elements in modals on mobile */
#add-product-modal .row .col,
#recipe-form-modal .row .col {
flex: 1 0 100%; /* Make columns full width */
}
/* Make unit selector more compact on mobile */
.unit-selector {
justify-content: flex-start;
gap: 0.25rem; /* Reduce gap between buttons */
}
.unit-selector .unit-btn {
padding: 0.25rem 0.5rem; /* Reduce padding */
font-size: 0.8rem; /* Slightly smaller font */
}
}
/* Search Input with Icon */
.search-container {
position: relative;
}
@media (min-width: 769px) {
.btn-primary,
#print-shopping-list-btn {
padding-top: 8px;
padding-bottom: 8px;
}
.btn.edit-recipe {
padding-left: 16px;
padding-right: 16px;
}
}
.search-container .bi-search {
position: absolute;
top: 50%;
left: 1rem;
transform: translateY(-50%);
z-index: 2;
color: rgba(255, 255, 255, 0.5);
}
#recipe-search {
padding-left: 2.5rem;
border-radius: 50px;
}
/* Unit selector button styling */
.unit-selector .btn.btn-outline-secondary {
border-color: transparent;
opacity: 0.7;
}
.card-title {
white-space: normal;
word-wrap: break-word;
}
#view-recipe-ingredients { #view-recipe-ingredients {
background-color: rgba(0, 0, 0, 0.2); /* Darker shade */ background-color: #F9F9F9;
padding: 15px; border-radius: 16px;
border-radius: 10px; padding: 20px;
margin-top: 10px;
} }
#view-recipe-ingredients .list-group-item { #view-recipe-ingredients .list-group-item {
background-color: transparent; border-bottom: 1px solid #EEEEEE !important;
border: none; padding: 10px 0 !important;
color: #fff; }
#view-recipe-ingredients .list-group-item:last-child {
border-bottom: none !important;
}
/* Auth Screen Styles */
.auth-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
z-index: 1050;
background-color: #ffffff;
}
.auth-image-container {
background-image: url('../images/auth-bg.jpg');
background-size: cover;
background-position: center;
position: relative;
}
.auth-image-overlay {
background: radial-gradient(circle at center, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%);
width: 100%;
height: 100%;
}
.auth-branding-content {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 30px;
padding: 60px 40px;
max-width: 600px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.2);
}
.auth-branding-content p.lead {
color: rgba(255, 255, 255, 0.9) !important;
}
.auth-form-container {
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.05);
background-color: #ffffff;
}
.auth-form-container h2 {
color: #2D2D2D;
font-weight: 700;
letter-spacing: -1px;
}
.auth-screen .form-control-lg {
padding: 14px 20px;
font-size: 1rem;
border-radius: 14px;
border: 2px solid #F5F5F5;
background-color: #FAFAFA;
}
.auth-screen .form-control-lg:focus {
background-color: #ffffff;
border-color: #FF7F50;
}
#auth-nav.guest-nav {
display: none !important;
}
body:has(#guest-view:not(.d-none)) .navbar {
display: none !important;
}
/* Footer hidden on auth screen */
body:has(#guest-view:not(.d-none)) footer {
display: none !important;
}
@media (max-width: 991.98px) {
.auth-form-container {
padding: 40px 20px !important;
}
}
/* Print Styles */
@media print {
body { background-color: white; }
.card { box-shadow: none; border: 1px solid #EEE; }
}
@media (max-width: 768px) {
.display-4 { font-size: 2rem; }
} }

BIN
assets/images/auth-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

View File

@ -4,9 +4,107 @@ const app = {
recipes: [], recipes: [],
confirmedRecipeProducts: [], confirmedRecipeProducts: [],
checkedItems: [], checkedItems: [],
additionalProducts: [] additionalProducts: [],
user: null
}, },
api: { api: {
async checkAuth() {
try {
const response = await fetch('api/check_auth.php');
const data = await response.json();
if (data.success && data.logged_in) {
app.state.user = data.user;
if (data.user.shopping_list) {
app.state.checkedItems = data.user.shopping_list.checkedItems || [];
app.state.additionalProducts = data.user.shopping_list.additionalProducts || [];
app.state.confirmedRecipeProducts = data.user.shopping_list.confirmedRecipeProducts || [];
}
} else {
app.state.user = null;
}
} catch (error) {
console.error('Auth check failed:', error);
}
},
async saveShoppingList() {
if (!app.state.user) return;
try {
await fetch('api/save_shopping_list.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
shopping_list: {
checkedItems: app.state.checkedItems,
additionalProducts: app.state.additionalProducts,
confirmedRecipeProducts: app.state.confirmedRecipeProducts
}
})
});
} catch (error) {
console.error('Failed to save shopping list:', error);
}
},
async login(email, password) {
try {
const response = await fetch('api/login.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (data.success) {
await app.api.checkAuth();
await app.api.getRecipes();
app.ui.updateAuthNav();
app.ui.renderRecipeCards(app.state.recipes);
app.ui.updateShoppingList();
return { success: true };
}
return data;
} catch (error) {
return { success: false, error: error.message };
}
},
async register(email, password) {
try {
const response = await fetch('api/register.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (data.success) {
await app.api.checkAuth();
await app.api.getRecipes();
app.ui.updateAuthNav();
app.ui.renderRecipeCards(app.state.recipes);
app.ui.updateShoppingList();
return { success: true };
}
return data;
} catch (error) {
return { success: false, error: error.message };
}
},
async logout() {
try {
const response = await fetch('api/logout.php');
const data = await response.json();
if (data.success) {
app.state.user = null;
app.state.recipes = [];
app.state.checkedItems = [];
app.state.additionalProducts = [];
app.state.confirmedRecipeProducts = [];
await app.api.getRecipes();
app.ui.updateAuthNav();
app.ui.renderRecipeCards(app.state.recipes);
app.ui.updateShoppingList();
}
} catch (error) {
console.error('Logout failed:', error);
}
},
async getRecipes() { async getRecipes() {
try { try {
const response = await fetch('api/get_recipes.php'); const response = await fetch('api/get_recipes.php');
@ -99,20 +197,7 @@ const app = {
const text = document.createElement('p'); const text = document.createElement('p');
text.className = 'card-text text-muted'; text.className = 'card-text text-muted';
text.textContent = `Serves ${recipe.guests} | ${recipe.ingredients.length} ingredients`; text.textContent = `${recipe.ingredients.length} ingredients`;
const guestPortionControls = document.createElement('div');
guestPortionControls.className = 'row g-2 mb-3';
guestPortionControls.innerHTML = `
<div class="col">
<label for="recipe-guests-${recipe.id}" class="form-label form-label-sm">Guests</label>
<input type="number" id="recipe-guests-${recipe.id}" class="form-control form-control-sm recipe-guests-input" value="${recipe.guests}" min="1" data-id="${recipe.id}">
</div>
<div class="col">
<label for="recipe-portions-${recipe.id}" class="form-label form-label-sm">Portions</label>
<input type="number" id="recipe-portions-${recipe.id}" class="form-control form-control-sm recipe-portions-input" value="1" min="1" data-id="${recipe.id}">
</div>
`;
const buttonGroup = document.createElement('div'); const buttonGroup = document.createElement('div');
buttonGroup.className = 'mt-auto pt-2'; buttonGroup.className = 'mt-auto pt-2';
@ -124,7 +209,6 @@ const app = {
cardBody.appendChild(titleWrapper); cardBody.appendChild(titleWrapper);
cardBody.appendChild(text); cardBody.appendChild(text);
cardBody.appendChild(guestPortionControls);
cardBody.appendChild(buttonGroup); cardBody.appendChild(buttonGroup);
card.appendChild(cardBody); card.appendChild(cardBody);
cardCol.appendChild(card); cardCol.appendChild(card);
@ -398,38 +482,98 @@ const app = {
} }
return null; return null;
}, },
createSnowflakes() {
const snowContainer = document.getElementById('snow-container');
if (!snowContainer) return;
snowContainer.innerHTML = '';
const numberOfSnowflakes = 50;
for (let i = 0; i < numberOfSnowflakes; i++) {
const snowflake = document.createElement('div');
snowflake.className = 'snowflake';
const size = Math.random() * 4 + 2;
snowflake.style.width = `${size}px`;
snowflake.style.height = `${size}px`;
snowflake.style.left = Math.random() * 100 + 'vw';
const animationDuration = Math.random() * 5 + 5;
snowflake.style.animationDuration = `${animationDuration}s`;
const animationDelay = Math.random() * 5;
snowflake.style.animationDelay = `${animationDelay}s`;
snowflake.style.opacity = Math.random() * 0.7 + 0.3;
snowContainer.appendChild(snowflake);
}
},
loadCheckedItems() { loadCheckedItems() {
const checkedItems = localStorage.getItem('checkedItems'); const stored = localStorage.getItem('checkedItems');
if (checkedItems) { if (stored) {
app.state.checkedItems = JSON.parse(checkedItems); app.state.checkedItems = JSON.parse(stored);
} }
}, },
saveCheckedItems() { saveCheckedItems() {
localStorage.setItem('checkedItems', JSON.stringify(app.state.checkedItems)); localStorage.setItem('checkedItems', JSON.stringify(app.state.checkedItems));
app.api.saveShoppingList();
},
updateAuthNav() {
const nav = document.getElementById('auth-nav');
const guestView = document.getElementById('guest-view');
const appView = document.getElementById('app-view');
if (!nav) return;
if (app.state.user) {
nav.innerHTML = `
<li class="nav-item me-3 d-none d-lg-block">
<span class="text-muted">Welcome, ${app.state.user.email}</span>
</li>
<li class="nav-item">
<button class="btn btn-outline-primary btn-sm" id="logout-btn">Logout</button>
</li>
`;
if (guestView) guestView.classList.add('d-none');
if (appView) appView.classList.remove('d-none');
} else {
nav.innerHTML = ''; // Landing page handles login/register
if (guestView) guestView.classList.remove('d-none');
if (appView) appView.classList.add('d-none');
}
} }
}, },
events: { events: {
attachEventListeners() { attachEventListeners() {
// Auth events (Landing Page)
const loginFormLanding = document.getElementById('login-form-landing');
if (loginFormLanding) {
loginFormLanding.addEventListener('submit', async (e) => {
e.preventDefault();
const email = loginFormLanding.querySelector('[name="email"]').value;
const password = loginFormLanding.querySelector('[name="password"]').value;
const result = await app.api.login(email, password);
if (result.success) {
loginFormLanding.reset();
} else {
alert(result.error);
}
});
}
const registerFormLanding = document.getElementById('register-form-landing');
if (registerFormLanding) {
registerFormLanding.addEventListener('submit', async (e) => {
e.preventDefault();
const email = registerFormLanding.querySelector('[name="email"]').value;
const password = registerFormLanding.querySelector('[name="password"]').value;
const result = await app.api.register(email, password);
if (result.success) {
registerFormLanding.reset();
} else {
alert(result.error);
}
});
}
// Toggle login/register on landing
const showRegister = document.getElementById('show-register');
const showLogin = document.getElementById('show-login');
const loginContainer = document.getElementById('login-container');
const registerContainer = document.getElementById('register-container');
if (showRegister && showLogin) {
showRegister.addEventListener('click', (e) => {
e.preventDefault();
loginContainer.classList.add('d-none');
registerContainer.classList.remove('d-none');
});
showLogin.addEventListener('click', (e) => {
e.preventDefault();
registerContainer.classList.add('d-none');
loginContainer.classList.remove('d-none');
});
}
document.getElementById('auth-nav').addEventListener('click', (e) => {
if (e.target.id === 'logout-btn') {
app.api.logout();
}
});
app.dom.addIngredientBtn.addEventListener('click', () => app.ui.addIngredientRow()); app.dom.addIngredientBtn.addEventListener('click', () => app.ui.addIngredientRow());
app.dom.aiScanBtn.addEventListener('click', async function() { app.dom.aiScanBtn.addEventListener('click', async function() {
@ -608,6 +752,7 @@ const app = {
productToModify.quantity++; productToModify.quantity++;
app.ui.updateShoppingList(); app.ui.updateShoppingList();
app.api.saveShoppingList();
} else if (e.target.matches('.decrement-item')) { } else if (e.target.matches('.decrement-item')) {
const key = e.target.dataset.key; const key = e.target.dataset.key;
@ -647,6 +792,7 @@ const app = {
if (app.state.confirmedRecipeProducts.includes(confirmationKey)) { if (app.state.confirmedRecipeProducts.includes(confirmationKey)) {
productToModify.quantity--; productToModify.quantity--;
app.ui.updateShoppingList(); app.ui.updateShoppingList();
app.api.saveShoppingList();
} else { } else {
document.getElementById('modal-recipe-name').textContent = recipeNameForModal; document.getElementById('modal-recipe-name').textContent = recipeNameForModal;
document.getElementById('modal-ingredient-name').textContent = productToModify.name; document.getElementById('modal-ingredient-name').textContent = productToModify.name;
@ -658,6 +804,7 @@ const app = {
app.state.confirmedRecipeProducts.push(confirmationKey); app.state.confirmedRecipeProducts.push(confirmationKey);
productToModify.quantity--; productToModify.quantity--;
app.ui.updateShoppingList(); app.ui.updateShoppingList();
app.api.saveShoppingList();
confirmModal.hide(); confirmModal.hide();
}; };
} }
@ -665,6 +812,7 @@ const app = {
// It's not a recipe ingredient about to be removed, but has been added via '+' // It's not a recipe ingredient about to be removed, but has been added via '+'
productToModify.quantity--; productToModify.quantity--;
app.ui.updateShoppingList(); app.ui.updateShoppingList();
app.api.saveShoppingList();
} }
// If not a recipe ingredient and quantity is 0, do nothing. // If not a recipe ingredient and quantity is 0, do nothing.
} }
@ -756,6 +904,7 @@ const app = {
} }
app.ui.updateShoppingList(); app.ui.updateShoppingList();
app.api.saveShoppingList();
// Reset form // Reset form
app.dom.productNameInput.value = ''; app.dom.productNameInput.value = '';
@ -776,16 +925,6 @@ const app = {
app.dom.addProductModal.hide(); app.dom.addProductModal.hide();
}); });
app.dom.musicToggle.addEventListener('click', function() {
if (app.dom.christmasMusic.paused) {
app.dom.christmasMusic.play();
app.dom.musicToggle.innerHTML = '<i class="bi bi-pause-fill" style="font-size: 1.5rem;"></i>';
} else {
app.dom.christmasMusic.pause();
app.dom.musicToggle.innerHTML = '<i class="bi bi-play-fill" style="font-size: 1.5rem;"></i>';
}
});
document.getElementById('recipe-form-modal').addEventListener('show.bs.modal', function () { document.getElementById('recipe-form-modal').addEventListener('show.bs.modal', function () {
if (!app.dom.recipeIdInput.value) { if (!app.dom.recipeIdInput.value) {
app.ui.clearForm(); app.ui.clearForm();
@ -821,20 +960,19 @@ const app = {
productQuantityInput: document.getElementById('productQuantity'), productQuantityInput: document.getElementById('productQuantity'),
productCategoryWrapper: document.getElementById('product-category-wrapper'), productCategoryWrapper: document.getElementById('product-category-wrapper'),
productCategory: document.getElementById('productCategory'), productCategory: document.getElementById('productCategory'),
christmasMusic: document.getElementById('christmas-music'),
musicToggle: document.getElementById('music-toggle'),
}; };
app.ui.createSnowflakes();
app.ui.loadCheckedItems(); app.ui.loadCheckedItems();
app.events.attachEventListeners(); app.events.attachEventListeners();
app.dom.cancelEditBtn.style.display = 'none'; app.dom.cancelEditBtn.style.display = 'none';
app.ui.addIngredientRow(); app.ui.addIngredientRow();
app.api.getRecipes().then(() => { app.api.checkAuth().then(() => {
app.ui.renderRecipeCards(app.state.recipes); app.ui.updateAuthNav();
app.ui.updateShoppingList(); app.api.getRecipes().then(() => {
app.ui.renderRecipeCards(app.state.recipes);
app.ui.updateShoppingList();
});
}); });
} }
}; };

View File

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

View File

@ -0,0 +1,2 @@
ALTER TABLE recipes ADD COLUMN user_id INT DEFAULT NULL;
ALTER TABLE recipes ADD CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;

View File

@ -0,0 +1,2 @@
-- Add shopping_list column to users table to store checked items and additional products
ALTER TABLE users ADD COLUMN shopping_list LONGTEXT DEFAULT NULL;

212
index.php
View File

@ -5,20 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO & Meta Tags --> <!-- SEO & Meta Tags -->
<title>Christmas Recipe Calculator</title> <title>Smart Recipe & Shopping List</title>
<meta name="description" content="Create and calculate holiday recipe shopping lists. Enter a recipe for one, specify your number of guests, and get a shopping list for your Christmas feast. Built with Flatlogic Generator."> <meta name="description" content="Manage your recipes and generate smart shopping lists. Identify dishes with AI and calculate ingredients effortlessly.">
<meta name="keywords" content="recipe calculator, Christmas recipes, holiday cooking, shopping list generator, party food calculator, festive meals, cooking for a crowd, recipe scaler, Built with Flatlogic Generator."> <meta name="keywords" content="recipe manager, shopping list generator, AI recipe scanner, cooking organizer, meal planner">
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="Christmas Recipe Calculator"> <meta property="og:title" content="Smart Recipe & Shopping List">
<meta property="og:description" content="Easily calculate shopping lists for your holiday recipes."> <meta property="og:description" content="Manage your recipes and generate smart shopping lists.">
<meta property="og:image" content=""> <meta property="og:image" content="">
<!-- Twitter --> <!-- Twitter -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Christmas Recipe Calculator"> <meta name="twitter:title" content="Smart Recipe & Shopping List">
<meta name="twitter:description" content="Easily calculate shopping lists for your holiday recipes."> <meta name="twitter:description" content="Manage your recipes and generate smart shopping lists.">
<meta name="twitter:image" content=""> <meta name="twitter:image" content="">
<!-- Styles --> <!-- Styles -->
@ -27,74 +27,157 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>"> <link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>_v3">
</head> </head>
<body> <body>
<div id="christmas-decorations-left"> <nav class="navbar navbar-expand-lg navbar-light bg-light shadow-sm sticky-top mb-4">
<img src="assets/pasted-20251130-194432-3a0cbe61.png" alt="Christmas Decorations" /> <div class="container">
</div> <a class="navbar-brand d-flex align-items-center" href="/">
<div id="christmas-decorations-right"> <i class="bi bi-book-half me-2" style="color: var(--accent-color);"></i>
<img src="assets/pasted-20251130-191602-0ffc27f4.png" alt="Christmas Decorations" /> <span class="fw-bold">SmartRecipe</span>
</div> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<div id="snow-container"></div> <span class="navbar-toggler-icon"></span>
</button>
<main class="container my-5"> <div class="collapse navbar-collapse" id="navbarNav">
<div class="text-center mb-5" style="padding-top: 20px;"> <ul class="navbar-nav ms-auto align-items-center" id="auth-nav">
<h1 class="display-4 mt-4">Hey, it's Christmas time!</h1> <!-- Auth elements will be injected here -->
<p class="lead">Let's get your holiday recipes sorted.</p> <li class="nav-item">
<div id="christmas-countdown" class="lead"></div> <div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</li>
</ul>
</div>
</div> </div>
</nav>
<div class="row g-4"> <div id="guest-view" class="d-none auth-screen">
<!-- Left Column: All Recipes --> <div class="container-fluid h-100 p-0">
<div class="col-md-6"> <div class="row h-100 g-0">
<div class="d-flex justify-content-between align-items-center mb-4"> <!-- Left Column: Image -->
<h2 class="text-center mb-0">All Recipes</h2> <div class="col-lg-6 d-none d-lg-block auth-image-container">
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#recipe-form-modal"> <div class="auth-image-overlay d-flex align-items-center justify-content-center p-5 text-white">
Add Recipe <div class="auth-branding-content text-center">
</button> <i class="bi bi-book-half display-1 mb-4" style="color: #FF7F50; filter: drop-shadow(0 0 10px rgba(255, 127, 80, 0.3));"></i>
<h1 class="display-3 fw-bold mb-4">SmartRecipe</h1>
<p class="lead fs-4">Manage your recipes and generate smart shopping lists. Identify dishes with AI and calculate ingredients effortlessly.</p>
</div>
</div>
</div> </div>
<div class="mb-3 search-container"> <!-- Right Column: Forms -->
<i class="bi bi-search"></i> <div class="col-lg-6 d-flex align-items-center justify-content-center p-5 bg-white auth-form-container">
<input type="text" id="recipe-search" class="form-control" placeholder="Search recipes..."> <div class="w-100" style="max-width: 450px;">
</div>
<div class="mb-3 d-flex flex-wrap gap-2" id="category-filters"> <!-- Login Form -->
<button class="btn btn-secondary active" data-category="all">All</button> <div id="login-container">
<button class="btn btn-outline-secondary" data-category="Drinks">Drinks</button> <div class="mb-5">
<button class="btn btn-outline-secondary" data-category="Breakfast">Breakfast</button> <h2 class="display-5 mb-2">Welcome back</h2>
<button class="btn btn-outline-secondary" data-category="Dinner">Dinner</button> <p class="text-muted">Enter your credentials to access your recipes.</p>
<button class="btn btn-outline-secondary" data-category="Appetizers">Appetizers</button> </div>
<button class="btn btn-outline-secondary" data-category="No category">No category</button> <form id="login-form-landing">
</div> <div class="mb-4">
<div id="recipe-cards-container" class="row"> <label class="form-label">Email Address</label>
<div class="col-12"> <input type="email" class="form-control form-control-lg" name="email" placeholder="name@example.com" required>
<p class="text-center text-muted">Your saved recipes will appear here.</p> </div>
<div class="mb-4">
<label class="form-label">Password</label>
<input type="password" class="form-control form-control-lg" name="password" placeholder="••••••••" required>
</div>
<div class="d-grid gap-2 mb-4">
<button type="submit" class="btn btn-primary btn-lg">Sign In</button>
</div>
</form>
<p class="text-center text-muted">
Don't have an account? <a href="#" id="show-register" class="text-primary fw-bold text-decoration-none">Create account</a>
</p>
</div>
<!-- Register Form -->
<div id="register-container" class="d-none">
<div class="mb-5">
<h2 class="display-5 mb-2">Create Account</h2>
<p class="text-muted">Start your smart cooking journey today.</p>
</div>
<form id="register-form-landing">
<div class="mb-4">
<label class="form-label">Email Address</label>
<input type="email" class="form-control form-control-lg" name="email" placeholder="name@example.com" required>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<input type="password" class="form-control form-control-lg" name="password" placeholder="••••••••" required>
</div>
<div class="d-grid gap-2 mb-4">
<button type="submit" class="btn btn-primary btn-lg">Register</button>
</div>
</form>
<p class="text-center text-muted">
Already have an account? <a href="#" id="show-login" class="text-primary fw-bold text-decoration-none">Login here</a>
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Right Column: Shopping List / Products --> <div id="app-view" class="d-none">
<div class="col-md-6"> <main class="container my-5">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="text-center mb-5" style="padding-top: 20px;">
<h2 class="text-center mb-0">Shopping List</h2> <h1 class="display-4 mt-4">My Recipe Book</h1>
<div> <p class="lead">Plan your meals and get your shopping lists sorted.</p>
<button id="add-product-btn" class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#add-product-modal">Add Product</button> </div>
<button id="print-shopping-list-btn" class="btn btn-outline-secondary"><i class="bi bi-printer"></i> Print</button>
<div class="row g-4">
<!-- Left Column: All Recipes -->
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="text-center mb-0">All Recipes</h2>
<button class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#recipe-form-modal">
Add Recipe
</button>
</div>
<div class="mb-3 search-container">
<i class="bi bi-search"></i>
<input type="text" id="recipe-search" class="form-control" placeholder="Search recipes...">
</div>
<div class="mb-3 d-flex flex-wrap gap-2" id="category-filters">
<button class="btn btn-secondary active" data-category="all">All</button>
<button class="btn btn-outline-secondary" data-category="Drinks">Drinks</button>
<button class="btn btn-outline-secondary" data-category="Breakfast">Breakfast</button>
<button class="btn btn-outline-secondary" data-category="Dinner">Dinner</button>
<button class="btn btn-outline-secondary" data-category="Appetizers">Appetizers</button>
<button class="btn btn-outline-secondary" data-category="No category">No category</button>
</div>
<div id="recipe-cards-container" class="row">
<div class="col-12">
<p class="text-center text-muted">Your saved recipes will appear here.</p>
</div>
</div> </div>
</div> </div>
<div class="card shadow">
<div class="card-body" id="shopping-list-container"> <!-- Right Column: Shopping List / Products -->
<div class="text-center text-muted p-5"> <div class="col-md-6">
<p>Your calculated list will appear here.</p> <div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="text-center mb-0">Shopping List</h2>
<div>
<button id="add-product-btn" class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#add-product-modal">Add Product</button>
<button id="print-shopping-list-btn" class="btn btn-outline-secondary"><i class="bi bi-printer"></i> Print</button>
</div>
</div>
<div class="card shadow">
<div class="card-body" id="shopping-list-container">
<div class="text-center text-muted p-5">
<p>Your calculated list will appear here.</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </main>
</main> </div>
<!-- Modal Recipe Form --> <!-- Modal Recipe Form -->
<div class="modal fade" id="recipe-form-modal" tabindex="-1" aria-labelledby="recipe-form-modal-label" aria-hidden="true"> <div class="modal fade" id="recipe-form-modal" tabindex="-1" aria-labelledby="recipe-form-modal-label" aria-hidden="true">
@ -110,7 +193,7 @@
<input type="hidden" id="recipeId"> <input type="hidden" id="recipeId">
<div class="mb-3"> <div class="mb-3">
<label for="recipeName" class="form-label">Recipe Name</label> <label for="recipeName" class="form-label">Recipe Name</label>
<input type="text" class="form-control" id="recipeName" placeholder="e.g., Gingerbread Cookies"> <input type="text" class="form-control" id="recipeName" placeholder="e.g., Avocado Toast">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="recipeCategory" class="form-label">Category</label> <label for="recipeCategory" class="form-label">Category</label>
@ -251,19 +334,12 @@
</div> </div>
<footer class="text-center py-4 mt-5"> <footer class="text-center py-4 mt-5">
<p class="mb-0">&copy; <?php echo date("Y"); ?> Christmas Recipe Calculator. Happy Holidays!</p> <p class="mb-0">&copy; <?php echo date("Y"); ?> Smart Recipe & Shopping List.</p>
</footer> </footer>
<audio id="christmas-music" loop>
<source src="https://www.fesliyanstudios.com/download-link.php?i=230" type="audio/mpeg">
</audio>
<button id="music-toggle" class="btn btn-light" style="position: fixed; bottom: 20px; right: 20px; border-radius: 50%; width: 50px; height: 50px; display: flex; align-items: center; justify-content: center;">
<i class="bi bi-play-fill" style="font-size: 1.5rem;"></i>
</button>
<!-- Scripts --> <!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/main.js?v=1700253313"></script> <script src="assets/js/main.js?v=<?php echo time(); ?>"></script>
<!-- Confirmation Modal --> <!-- Confirmation Modal -->
<div class="modal fade" id="confirmRemoveModal" tabindex="-1" aria-labelledby="confirmRemoveModalLabel" aria-hidden="true"> <div class="modal fade" id="confirmRemoveModal" tabindex="-1" aria-labelledby="confirmRemoveModalLabel" aria-hidden="true">