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'];
}
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);
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) {
return [
'success' => false,
@ -211,6 +316,11 @@ class LocalAIApi
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'])) {
$combined = '';
foreach ($payload['output'] as $item) {

View File

@ -46,7 +46,7 @@ return [
'project_id' => $projectId,
'project_uuid' => $projectUuid,
'project_header' => 'project-uuid',
'default_model' => 'gpt-5',
'default_model' => 'gpt-4o-mini',
'timeout' => 30,
'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
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);
@ -10,10 +10,21 @@ if (!$data || !isset($data['id'])) {
}
$recipeId = $data['id'];
$userId = get_logged_in_user_id();
$pdo = db();
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->execute([$recipeId]);

View File

@ -1,14 +1,27 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/auth_helper.php';
try {
$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();
$ingredients_stmt = $pdo->query('SELECT * FROM ingredients');
$recipeIds = array_column($recipes, 'id');
$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 = [];
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
header('Content-Type: application/json');
require_once __DIR__ . '/../db/config.php';
require_once __DIR__ . '/auth_helper.php';
function get_ingredient_category($name) {
$name = strtolower($name);
@ -17,6 +17,8 @@ function get_ingredient_category($name) {
$data = $_POST;
$files = $_FILES;
$userId = get_logged_in_user_id();
if (!isset($data['name']) || !isset($data['guests']) || !isset($data['ingredients'])) {
echo json_encode(['success' => false, 'error' => 'Invalid input.']);
exit;
@ -55,16 +57,22 @@ try {
$recipeId = $data['id'];
$category = !empty($data['category']) ? $data['category'] : 'No category';
// Fetch existing image URL if a new one isn't uploaded
if ($imageUrl === null) {
$stmt = $pdo->prepare("SELECT image_url FROM recipes WHERE id = ?");
// Check if recipe belongs to user
$stmt = $pdo->prepare("SELECT user_id, image_url FROM recipes WHERE id = ?");
$stmt->execute([$recipeId]);
$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 = ?");
$stmt->execute([$data['name'], $data['guests'], $category, $imageUrl, $recipeId]);
// Fetch existing image URL if a new one isn't uploaded
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
$stmt = $pdo->prepare("DELETE FROM ingredients WHERE recipe_id = ?");
@ -73,8 +81,8 @@ try {
} else {
// Insert new recipe
$category = !empty($data['category']) ? $data['category'] : 'No category';
$stmt = $pdo->prepare("INSERT INTO recipes (name, guests, category, image_url) VALUES (?, ?, ?, ?)");
$stmt->execute([$data['name'], $data['guests'], $category, $imageUrl]);
$stmt = $pdo->prepare("INSERT INTO recipes (name, guests, category, image_url, user_id) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$data['name'], $data['guests'], $category, $imageUrl, $userId]);
$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'];
$imageSize = filesize($imagePath);
$imageData = base64_encode(file_get_contents($imagePath));
$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.
Extract or generate the following information in JSON format:
- name: The name of the dish/recipe
@ -33,6 +37,7 @@ Important:
try {
$response = LocalAIApi::createResponse([
'model' => 'gpt-4o',
'input' => [
[
'role' => 'user',
@ -44,7 +49,8 @@ try {
[
'type' => 'image_url',
'image_url' => [
'url' => "data:$imageType;base64,$imageData"
'url' => "data:$imageType;base64,$imageData",
'detail' => 'auto'
]
]
]
@ -53,13 +59,18 @@ try {
]);
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');
}
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);
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]);

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 */
body {
background-color: #142E35; /* Dark green background */
color: #ffffff; /* White text */
background-color: #FAF9F6; /* Light beige background */
color: #2D2D2D; /* Dark gray text */
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 */
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 */
input,
button {
border-radius: 16px;
button,
.form-control,
.form-select {
border-radius: 12px;
}
.btn {
border-radius: 50px !important; /* Fully rounded corners */
}
/* 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;
border-radius: 50px !important;
font-weight: 600;
padding: 10px 24px;
transition: all 0.3s ease;
}
/* Main Content */
.display-4 {
font-weight: 700;
color: #ffffff;
color: #2D2D2D;
}
.lead {
color: #ffffff;
color: #666666;
}
/* Cards */
.card {
background-color: rgba(255, 255, 255, 0.05);
background-color: #ffffff;
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 {
color: #ffffff;
color: #2D2D2D;
}
/* Forms */
.form-label {
color: #ffffff;
color: #2D2D2D;
font-weight: 600;
margin-bottom: 0.5rem;
}
.form-control {
background-color: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
.form-control,
.form-select {
background-color: #ffffff;
border: 2px solid #EEEEEE;
color: #2D2D2D;
padding: 12px 16px;
}
.form-control:focus {
background-color: rgba(0, 0, 0, 0.3);
border-color: #0a1a1f; /* Dark green accent */
box-shadow: 0 0 0 0.25rem rgba(10, 26, 31, 0.25);
color: #ffffff;
.form-control:focus,
.form-select:focus {
background-color: #ffffff;
border-color: #FF7F50; /* Orange accent */
box-shadow: 0 0 0 0.25rem rgba(255, 127, 80, 0.15);
color: #2D2D2D;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0.5);
color: #AAAAAA;
}
/* Buttons */
.btn-primary, .btn-danger {
background-color: #de4950 !important; /* Coral red */
border-color: #de4950 !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 {
.btn-primary {
background-color: #FF7F50 !important; /* Orange */
border-color: #FF7F50 !important;
color: #ffffff !important;
}
.recipe-card {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 15px;
margin-bottom: 15px;
.btn-primary:hover {
background-color: #E66E45 !important;
border-color: #E66E45 !important;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(255, 127, 80, 0.3);
}
/* 3-Column Layout Adjustments */
.row.g-4 > [class*='col-'] .card {
height: 100%; /* Make cards in columns equal height */
.btn-danger {
background-color: #FF4B2B !important;
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 {
max-height: 60vh; /* Adjust as needed */
max-height: 70vh;
overflow-y: auto;
padding: 10px;
}
/* Custom scrollbar for webkit browsers */
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #0a1a1f; /* Even darker green */
border-radius: 4px;
background: #DDDDDD;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #10242B; /* Slightly lighter than thumb */
background: #CCCCCC;
}
/* Footer */
footer.bg-light {
background-color: transparent !important;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
}
/* 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;
}
footer {
border-top: 1px solid #EEEEEE;
color: #999999;
font-size: 0.9rem;
}
/* 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 {
text-decoration: line-through;
opacity: 0.6;
opacity: 0.5;
}
.unit-btn {
padding: 0.375rem 0.5rem;
font-size: 0.875rem;
}
/* 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 {
width: 1.25em;
height: 1.25em;
border: 2px solid #DDDDDD;
cursor: pointer;
}
.form-check-input:checked {
background-color: #de4950;
border-color: #de4950;
background-color: #FF7F50;
border-color: #FF7F50;
}
.form-check-input:focus {
border-color: #0a1a1f;
box-shadow: 0 0 0 0.25rem rgba(10, 26, 31, 0.25);
/* Unit buttons */
.unit-selector .btn {
padding: 6px 12px !important;
font-size: 0.85rem;
}
@media print {
body * {
visibility: hidden;
}
#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;
}
.unit-selector .btn-secondary {
background-color: #FF7F50 !important;
color: white !important;
}
/* Badges */
.bg-custom-green {
background-color: #142E35 !important;
background-color: #FFF0EB !important;
color: #FF7F50 !important;
font-weight: 700;
border: 1px solid #FFE4DB;
}
/* Modal Styles */
#recipe-form-modal .modal-content, #add-product-modal .modal-content, #confirmRemoveModal .modal-content, #view-recipe-modal .modal-content {
background-color: #142E35;
color: white;
}
#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;
/* Quantity Modifiers */
.btn-quantity-modifier {
width: 28px;
height: 28px;
border-radius: 8px !important;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.2);
outline: none;
box-shadow: none;
background-color: #F8F8F8;
border: 1px solid #EEEEEE;
color: #666666;
padding: 0 !important;
}
.btn-quantity-modifier:hover {
background-color: #FF7F50;
color: white;
background-color: rgba(255, 255, 255, 0.1);
transition: background-color 0.2s ease;
border-color: #FF7F50;
}
.btn.btn-quantity-modifier:hover {
background-color: rgba(255, 255, 255, 0.2);
/* Modal Styles */
.modal-content {
background-color: #ffffff;
border: none;
border-radius: 24px;
overflow: hidden;
}
.quantity-controls .quantity {
margin: 0 10px;
.modal-header {
border-bottom: 1px solid #F0F0F0;
padding: 24px;
}
/* Make delete recipe button same height as edit button */
.card .btn.delete-recipe {
padding: .25rem .5rem;
margin-left: 8px;
.modal-footer {
border-top: 1px solid #F0F0F0;
padding: 20px 24px;
}
/* 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 */
.card .card-img-top {
height: 200px;
height: 180px;
object-fit: cover;
border-top-left-radius: 20px;
border-top-right-radius: 20px;
}
/* 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 Modal */
#view-recipe-ingredients {
background-color: rgba(0, 0, 0, 0.2); /* Darker shade */
padding: 15px;
border-radius: 10px;
margin-top: 10px;
background-color: #F9F9F9;
border-radius: 16px;
padding: 20px;
}
#view-recipe-ingredients .list-group-item {
background-color: transparent;
border: none;
color: #fff;
border-bottom: 1px solid #EEEEEE !important;
padding: 10px 0 !important;
}
#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: [],
confirmedRecipeProducts: [],
checkedItems: [],
additionalProducts: []
additionalProducts: [],
user: null
},
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() {
try {
const response = await fetch('api/get_recipes.php');
@ -99,20 +197,7 @@ const app = {
const text = document.createElement('p');
text.className = 'card-text text-muted';
text.textContent = `Serves ${recipe.guests} | ${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>
`;
text.textContent = `${recipe.ingredients.length} ingredients`;
const buttonGroup = document.createElement('div');
buttonGroup.className = 'mt-auto pt-2';
@ -124,7 +209,6 @@ const app = {
cardBody.appendChild(titleWrapper);
cardBody.appendChild(text);
cardBody.appendChild(guestPortionControls);
cardBody.appendChild(buttonGroup);
card.appendChild(cardBody);
cardCol.appendChild(card);
@ -398,38 +482,98 @@ const app = {
}
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() {
const checkedItems = localStorage.getItem('checkedItems');
if (checkedItems) {
app.state.checkedItems = JSON.parse(checkedItems);
const stored = localStorage.getItem('checkedItems');
if (stored) {
app.state.checkedItems = JSON.parse(stored);
}
},
saveCheckedItems() {
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: {
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.aiScanBtn.addEventListener('click', async function() {
@ -608,6 +752,7 @@ const app = {
productToModify.quantity++;
app.ui.updateShoppingList();
app.api.saveShoppingList();
} else if (e.target.matches('.decrement-item')) {
const key = e.target.dataset.key;
@ -647,6 +792,7 @@ const app = {
if (app.state.confirmedRecipeProducts.includes(confirmationKey)) {
productToModify.quantity--;
app.ui.updateShoppingList();
app.api.saveShoppingList();
} else {
document.getElementById('modal-recipe-name').textContent = recipeNameForModal;
document.getElementById('modal-ingredient-name').textContent = productToModify.name;
@ -658,6 +804,7 @@ const app = {
app.state.confirmedRecipeProducts.push(confirmationKey);
productToModify.quantity--;
app.ui.updateShoppingList();
app.api.saveShoppingList();
confirmModal.hide();
};
}
@ -665,6 +812,7 @@ const app = {
// It's not a recipe ingredient about to be removed, but has been added via '+'
productToModify.quantity--;
app.ui.updateShoppingList();
app.api.saveShoppingList();
}
// If not a recipe ingredient and quantity is 0, do nothing.
}
@ -756,6 +904,7 @@ const app = {
}
app.ui.updateShoppingList();
app.api.saveShoppingList();
// Reset form
app.dom.productNameInput.value = '';
@ -776,16 +925,6 @@ const app = {
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 () {
if (!app.dom.recipeIdInput.value) {
app.ui.clearForm();
@ -821,21 +960,20 @@ const app = {
productQuantityInput: document.getElementById('productQuantity'),
productCategoryWrapper: document.getElementById('product-category-wrapper'),
productCategory: document.getElementById('productCategory'),
christmasMusic: document.getElementById('christmas-music'),
musicToggle: document.getElementById('music-toggle'),
};
app.ui.createSnowflakes();
app.ui.loadCheckedItems();
app.events.attachEventListeners();
app.dom.cancelEditBtn.style.display = 'none';
app.ui.addIngredientRow();
app.api.checkAuth().then(() => {
app.ui.updateAuthNav();
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;

128
index.php
View File

@ -5,20 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO & Meta Tags -->
<title>Christmas Recipe Calculator</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="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.">
<title>Smart Recipe & Shopping List</title>
<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 manager, shopping list generator, AI recipe scanner, cooking organizer, meal planner">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content="Christmas Recipe Calculator">
<meta property="og:description" content="Easily calculate shopping lists for your holiday recipes.">
<meta property="og:title" content="Smart Recipe & Shopping List">
<meta property="og:description" content="Manage your recipes and generate smart shopping lists.">
<meta property="og:image" content="">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Christmas Recipe Calculator">
<meta name="twitter:description" content="Easily calculate shopping lists for your holiday recipes.">
<meta name="twitter:title" content="Smart Recipe & Shopping List">
<meta name="twitter:description" content="Manage your recipes and generate smart shopping lists.">
<meta name="twitter:image" content="">
<!-- Styles -->
@ -27,25 +27,107 @@
<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 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>
<body>
<div id="christmas-decorations-left">
<img src="assets/pasted-20251130-194432-3a0cbe61.png" alt="Christmas Decorations" />
<nav class="navbar navbar-expand-lg navbar-light bg-light shadow-sm sticky-top mb-4">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="/">
<i class="bi bi-book-half me-2" style="color: var(--accent-color);"></i>
<span class="fw-bold">SmartRecipe</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto align-items-center" id="auth-nav">
<!-- Auth elements will be injected here -->
<li class="nav-item">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</li>
</ul>
</div>
<div id="christmas-decorations-right">
<img src="assets/pasted-20251130-191602-0ffc27f4.png" alt="Christmas Decorations" />
</div>
</nav>
<div id="guest-view" class="d-none auth-screen">
<div class="container-fluid h-100 p-0">
<div class="row h-100 g-0">
<!-- Left Column: Image -->
<div class="col-lg-6 d-none d-lg-block auth-image-container">
<div class="auth-image-overlay d-flex align-items-center justify-content-center p-5 text-white">
<div class="auth-branding-content text-center">
<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>
<!-- Right Column: Forms -->
<div class="col-lg-6 d-flex align-items-center justify-content-center p-5 bg-white auth-form-container">
<div class="w-100" style="max-width: 450px;">
<!-- Login Form -->
<div id="login-container">
<div class="mb-5">
<h2 class="display-5 mb-2">Welcome back</h2>
<p class="text-muted">Enter your credentials to access your recipes.</p>
</div>
<form id="login-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">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>
<div id="snow-container"></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 id="app-view" class="d-none">
<main class="container my-5">
<div class="text-center mb-5" style="padding-top: 20px;">
<h1 class="display-4 mt-4">Hey, it's Christmas time!</h1>
<p class="lead">Let's get your holiday recipes sorted.</p>
<div id="christmas-countdown" class="lead"></div>
<h1 class="display-4 mt-4">My Recipe Book</h1>
<p class="lead">Plan your meals and get your shopping lists sorted.</p>
</div>
<div class="row g-4">
@ -95,6 +177,7 @@
</div>
</div>
</main>
</div>
<!-- Modal Recipe Form -->
<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">
<div class="mb-3">
<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 class="mb-3">
<label for="recipeCategory" class="form-label">Category</label>
@ -251,19 +334,12 @@
</div>
<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>
<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 -->
<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 -->
<div class="modal fade" id="confirmRemoveModal" tabindex="-1" aria-labelledby="confirmRemoveModalLabel" aria-hidden="true">