Compare commits

..

41 Commits

Author SHA1 Message Date
Flatlogic Bot
7df438b032 perfect modal 2026-02-01 19:30:00 +00:00
Flatlogic Bot
87c4c5dcc3 goog modal 2026-02-01 19:28:07 +00:00
Flatlogic Bot
ea0af5455b Add buttons 2026-02-01 18:44:09 +00:00
Flatlogic Bot
8cf30205a9 30.01 2026-02-01 18:27:00 +00:00
Flatlogic Bot
a3d98d1256 cool cards 2026-01-30 23:05:47 +00:00
Flatlogic Bot
8b8c71261d 30 2026-01-30 22:58:34 +00:00
Flatlogic Bot
dab8b67300 Autosave: 20260130-225658 2026-01-30 22:56:58 +00:00
Flatlogic Bot
7b0ece6750 add list 2026-01-30 22:54:39 +00:00
Flatlogic Bot
3830ef477f ai recipes 2026-01-30 22:49:40 +00:00
Flatlogic Bot
70441c3380 27.01.26 2026-01-27 07:47:41 +00:00
Flatlogic Bot
10dfaaefb3 ai scan 2026-01-27 07:38:26 +00:00
Flatlogic Bot
096fe649f2 Autosave: 20260127-073448 2026-01-27 07:34:48 +00:00
Flatlogic Bot
1e39b8f68a good video and description 2026-01-26 22:31:58 +00:00
Flatlogic Bot
763c93513d norrmal 2026-01-26 22:21:12 +00:00
Flatlogic Bot
c7834000a3 with video 2026-01-26 22:15:07 +00:00
Flatlogic Bot
f7763aae7d Autosave: 20260126-221219 2026-01-26 22:12:19 +00:00
Flatlogic Bot
fdf8f1c93d Good colours 2026-01-26 21:54:15 +00:00
Flatlogic Bot
afdfe09b22 Good registration page 2026-01-26 21:49:35 +00:00
Flatlogic Bot
7643a96d2f Revert to version d7b9033 2026-01-26 21:27:59 +00:00
Flatlogic Bot
3e6a0105dd Autosave: 20260126-212418 2026-01-26 21:24:18 +00:00
Flatlogic Bot
d7b90335da 26.01.26 2026-01-26 21:02:05 +00:00
Flatlogic Bot
05f4c9c979 Autosave: 20260126-195712 2026-01-26 19:57:12 +00:00
Flatlogic Bot
4ea0efca37 26 2025-12-04 21:30:27 +00:00
Flatlogic Bot
e97488f9dc 24 2025-11-30 20:39:20 +00:00
Flatlogic Bot
e0a3f2a9e8 23 2025-11-30 20:34:28 +00:00
Flatlogic Bot
df235d7a3e 22 2025-11-30 20:29:12 +00:00
Flatlogic Bot
54ae6d2455 21 2025-11-30 20:25:53 +00:00
Flatlogic Bot
1d6bd37049 20 2025-11-30 20:21:05 +00:00
Flatlogic Bot
976b30c7f2 19 2025-11-30 20:18:38 +00:00
Flatlogic Bot
21376a41c6 18 2025-11-30 20:13:42 +00:00
Flatlogic Bot
3d551364f5 17 2025-11-30 20:08:53 +00:00
Flatlogic Bot
ce386ed1ad 16 2025-11-30 19:55:57 +00:00
Flatlogic Bot
6e3c890a6f 15 2025-11-30 19:54:49 +00:00
Flatlogic Bot
ecddcf9d88 14 2025-11-30 19:43:38 +00:00
Flatlogic Bot
7368c83e9a 14 2025-11-30 19:38:56 +00:00
Flatlogic Bot
71ee90fe50 12 2025-11-30 19:15:26 +00:00
Flatlogic Bot
d7cd3adc0e 9 2025-11-23 17:38:13 +00:00
Flatlogic Bot
60e4e3f87d 7 2025-11-23 17:28:36 +00:00
Flatlogic Bot
32cc8d6cd2 4 2025-11-12 19:46:05 +00:00
Flatlogic Bot
24d0513664 2 2025-11-09 21:38:45 +00:00
Flatlogic Bot
d39bcc532b 1 2025-11-09 21:15:36 +00:00
56 changed files with 4040 additions and 144 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

109
ai/CustomOpenAI.php Normal file
View File

@ -0,0 +1,109 @@
<?php
/**
* CustomOpenAI - Direct client for OpenAI API to support multimodal features (Vision).
*/
class CustomOpenAI {
private string $apiKey;
private string $baseUrl = 'https://api.openai.com/v1/chat/completions';
public function __construct(string $apiKey) {
$this->apiKey = $apiKey;
}
/**
* Analyze an image (base64 or URL) with a prompt.
*/
public function analyze(array $params): array {
$model = $params['model'] ?? 'gpt-4o';
$prompt = $params['prompt'] ?? 'Analyze this image';
$content = [['type' => 'text', 'text' => $prompt]];
if (!empty($params['image_base64'])) {
$type = $params['image_type'] ?? 'image/jpeg';
$content[] = [
'type' => 'image_url',
'image_url' => [
'url' => "data:$type;base64," . $params['image_base64']
]
];
} elseif (!empty($params['image_url'])) {
$content[] = [
'type' => 'image_url',
'image_url' => [
'url' => $params['image_url']
]
];
}
$payload = [
'model' => $model,
'messages' => [
[
'role' => 'user',
'content' => $content
]
],
'max_tokens' => 2000
];
return $this->request($payload);
}
private function request(array $payload): array {
$ch = curl_init($this->baseUrl);
$jsonPayload = json_encode($payload);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonPayload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->apiKey
],
CURLOPT_TIMEOUT => 60
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
return [
'success' => false,
'error' => 'cURL Error: ' . $curlError
];
}
$decoded = json_decode($response, true);
if ($httpCode !== 200) {
return [
'success' => false,
'error' => $decoded['error']['message'] ?? 'OpenAI API Error (Status: ' . $httpCode . ')',
'raw' => $decoded
];
}
return [
'success' => true,
'data' => $decoded
];
}
public static function extractText(array $response): string {
return $response['data']['choices'][0]['message']['content'] ?? '';
}
public static function decodeJson(array $response): ?array {
$text = self::extractText($response);
if (empty($text)) return null;
$decoded = json_decode($text, true);
if (is_array($decoded)) return $decoded;
// Try stripping markdown fences
$stripped = preg_replace('/^```json|```$/m', '', trim($text));
return json_decode($stripped, true);
}
}

421
ai/LocalAIApi.php Normal file
View File

@ -0,0 +1,421 @@
<?php
// LocalAIApi — proxy client for the Responses API.
// Usage:
// require_once __DIR__ . '/ai/LocalAIApi.php';
// $response = LocalAIApi::createResponse([
// 'input' => [
// ['role' => 'system', 'content' => 'You are a helpful assistant.'],
// ['role' => 'user', 'content' => 'Tell me a bedtime story.'],
// ],
// ]);
// if (!empty($response['success'])) {
// $decoded = LocalAIApi::decodeJsonFromResponse($response);
// }
class LocalAIApi
{
/** @var array<string,mixed>|null */
private static ?array $configCache = null;
/**
* Signature compatible with the OpenAI Responses API.
*
* @param array<string,mixed> $params Request body (model, input, text, reasoning, metadata, etc.).
* @param array<string,mixed> $options Extra options (timeout, verify_tls, headers, path, project_uuid).
* @return array{
* success:bool,
* status?:int,
* data?:mixed,
* error?:string,
* response?:mixed,
* message?:string
* }
*/
public static function createResponse(array $params, array $options = []): array
{
$cfg = self::config();
$payload = $params;
if (empty($payload['input']) || !is_array($payload['input'])) {
return [
'success' => false,
'error' => 'input_missing',
'message' => 'Parameter "input" is required and must be an array.',
];
}
if (!isset($payload['model']) || $payload['model'] === '') {
$payload['model'] = $cfg['default_model'];
}
$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'
];
}
/**
* Snake_case alias for createResponse (matches the provided example).
*
* @param array<string,mixed> $params
* @param array<string,mixed> $options
* @return array<string,mixed>
*/
public static function create_response(array $params, array $options = []): array
{
return self::createResponse($params, $options);
}
/**
* Perform a raw request to the AI proxy.
*
* @param string $path Endpoint (may be an absolute URL).
* @param array<string,mixed> $payload JSON payload.
* @param array<string,mixed> $options Additional request options.
* @return array<string,mixed>
*/
public static function request(?string $path = null, array $payload = [], array $options = []): array
{
if (!function_exists('curl_init')) {
return [
'success' => false,
'error' => 'curl_missing',
'message' => 'PHP cURL extension is missing. Install or enable it on the VM.',
];
}
$cfg = self::config();
$projectUuid = $cfg['project_uuid'];
if (empty($projectUuid)) {
return [
'success' => false,
'error' => 'project_uuid_missing',
'message' => 'PROJECT_UUID is not defined; aborting AI request.',
];
}
$defaultPath = $cfg['responses_path'] ?? null;
$resolvedPath = $path ?? ($options['path'] ?? $defaultPath);
if (empty($resolvedPath)) {
return [
'success' => false,
'error' => 'project_id_missing',
'message' => 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.',
];
}
$url = self::buildUrl($resolvedPath, $cfg['base_url']);
$baseTimeout = isset($cfg['timeout']) ? (int) $cfg['timeout'] : 30;
$timeout = isset($options['timeout']) ? (int) $options['timeout'] : $baseTimeout;
if ($timeout <= 0) {
$timeout = 30;
}
$baseVerifyTls = array_key_exists('verify_tls', $cfg) ? (bool) $cfg['verify_tls'] : true;
$verifyTls = array_key_exists('verify_tls', $options)
? (bool) $options['verify_tls']
: $baseVerifyTls;
$projectHeader = $cfg['project_header'];
$headers = [
'Content-Type: application/json',
'Accept: application/json',
];
$headers[] = $projectHeader . ': ' . $projectUuid;
if (!empty($options['headers']) && is_array($options['headers'])) {
foreach ($options['headers'] as $header) {
if (is_string($header) && $header !== '') {
$headers[] = $header;
}
}
}
if (!empty($projectUuid) && !array_key_exists('project_uuid', $payload)) {
$payload['project_uuid'] = $projectUuid;
}
$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,
'error' => 'json_encode_failed',
'message' => 'Failed to encode request body to JSON.',
];
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifyTls);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifyTls ? 2 : 0);
curl_setopt($ch, CURLOPT_FAILONERROR, false);
$responseBody = curl_exec($ch);
if ($responseBody === false) {
$error = curl_error($ch) ?: 'Unknown cURL error';
curl_close($ch);
return [
'success' => false,
'error' => 'curl_error',
'message' => $error,
];
}
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = null;
if ($responseBody !== '' && $responseBody !== null) {
$decoded = json_decode($responseBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$decoded = null;
}
}
if ($status >= 200 && $status < 300) {
return [
'success' => true,
'status' => $status,
'data' => $decoded ?? $responseBody,
];
}
$errorMessage = 'AI proxy request failed';
if (is_array($decoded)) {
$errorMessage = $decoded['error'] ?? $decoded['message'] ?? $errorMessage;
} elseif (is_string($responseBody) && $responseBody !== '') {
$errorMessage = $responseBody;
}
return [
'success' => false,
'status' => $status,
'error' => $errorMessage,
'response' => $decoded ?? $responseBody,
];
}
/**
* Extract plain text from a Responses API payload.
*
* @param array<string,mixed> $response Result of LocalAIApi::createResponse|request.
* @return string
*/
public static function extractText(array $response): string
{
$payload = $response['data'] ?? $response;
if (!is_array($payload)) {
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) {
if (!isset($item['content']) || !is_array($item['content'])) {
continue;
}
foreach ($item['content'] as $block) {
if (is_array($block) && ($block['type'] ?? '') === 'output_text' && !empty($block['text'])) {
$combined .= $block['text'];
}
}
}
if ($combined !== '') {
return $combined;
}
}
if (!empty($payload['choices'][0]['message']['content'])) {
return (string) $payload['choices'][0]['message']['content'];
}
return '';
}
/**
* Attempt to decode JSON emitted by the model (handles markdown fences).
*
* @param array<string,mixed> $response
* @return array<string,mixed>|null
*/
public static function decodeJsonFromResponse(array $response): ?array
{
$text = self::extractText($response);
if ($text === '') {
return null;
}
$decoded = json_decode($text, true);
if (is_array($decoded)) {
return $decoded;
}
$stripped = preg_replace('/^```json|```$/m', '', trim($text));
if ($stripped !== null && $stripped !== $text) {
$decoded = json_decode($stripped, true);
if (is_array($decoded)) {
return $decoded;
}
}
return null;
}
/**
* Load configuration from ai/config.php.
*
* @return array<string,mixed>
*/
private static function config(): array
{
if (self::$configCache === null) {
$configPath = __DIR__ . '/config.php';
if (!file_exists($configPath)) {
throw new RuntimeException('AI config file not found: ai/config.php');
}
$cfg = require $configPath;
if (!is_array($cfg)) {
throw new RuntimeException('Invalid AI config format: expected array');
}
self::$configCache = $cfg;
}
return self::$configCache;
}
/**
* Build an absolute URL from base_url and a path.
*/
private static function buildUrl(string $path, string $baseUrl): string
{
$trimmed = trim($path);
if ($trimmed === '') {
return $baseUrl;
}
if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) {
return $trimmed;
}
if ($trimmed[0] === '/') {
return $baseUrl . $trimmed;
}
return $baseUrl . '/' . $trimmed;
}
}
// Legacy alias for backward compatibility with the previous class name.
if (!class_exists('OpenAIService')) {
class_alias(LocalAIApi::class, 'OpenAIService');
}

52
ai/config.php Normal file
View File

@ -0,0 +1,52 @@
<?php
// OpenAI proxy configuration (workspace scope).
// Reads values from environment variables or executor/.env.
$projectUuid = getenv('PROJECT_UUID');
$projectId = getenv('PROJECT_ID');
if (
($projectUuid === false || $projectUuid === null || $projectUuid === '') ||
($projectId === false || $projectId === null || $projectId === '')
) {
$envPath = realpath(__DIR__ . '/../../.env'); // executor/.env
if ($envPath && is_readable($envPath)) {
$lines = @file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') {
continue;
}
if (!str_contains($line, '=')) {
continue;
}
[$key, $value] = array_map('trim', explode('=', $line, 2));
if ($key === '') {
continue;
}
$value = trim($value, "\"' ");
if (getenv($key) === false || getenv($key) === '') {
putenv("{$key}={$value}");
}
}
$projectUuid = getenv('PROJECT_UUID');
$projectId = getenv('PROJECT_ID');
}
}
$projectUuid = ($projectUuid === false) ? null : $projectUuid;
$projectId = ($projectId === false) ? null : $projectId;
$baseUrl = 'https://flatlogic.com';
$responsesPath = $projectId ? "/projects/{$projectId}/ai-request" : null;
return [
'base_url' => $baseUrl,
'responses_path' => $responsesPath,
'project_id' => $projectId,
'project_uuid' => $projectUuid,
'project_header' => 'project-uuid',
'default_model' => 'gpt-4o-mini',
'timeout' => 30,
'verify_tls' => true,
];

11
ai/keys.php Normal file
View File

@ -0,0 +1,11 @@
<?php
/**
* ai/keys.php
* Store your private API keys here.
*/
// Define your OpenAI API Key here
define('OPENAI_API_KEY', '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'); // <--- PUT YOUR KEY HERE
// You can also use environment variables if you prefer
// define('OPENAI_API_KEY', getenv('OPENAI_API_KEY') ?: '');

104
api/ai_analyze.php Normal file
View File

@ -0,0 +1,104 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../ai/LocalAIApi.php';
require_once __DIR__ . '/../ai/CustomOpenAI.php';
@include_once __DIR__ . '/../ai/keys.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
$prompt = $_POST['prompt'] ?? "Analyze this image and describe what you see.";
$model = $_POST['model'] ?? 'gpt-4o';
$imageUrl = $_POST['image_url'] ?? null;
$apiKey = defined('OPENAI_API_KEY') ? OPENAI_API_KEY : '';
// Prefer CustomOpenAI if key is available
$useCustom = !empty($apiKey);
if ($useCustom) {
$ai = new CustomOpenAI($apiKey);
$params = [
'model' => $model,
'prompt' => $prompt
];
if (isset($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
$params['image_base64'] = base64_encode(file_get_contents($_FILES['image']['tmp_name']));
$params['image_type'] = $_FILES['image']['type'];
} elseif (!empty($imageUrl)) {
$params['image_url'] = $imageUrl;
}
$response = $ai->analyze($params);
if ($response['success']) {
echo json_encode([
'success' => true,
'text' => CustomOpenAI::extractText($response),
'data' => CustomOpenAI::decodeJson($response),
'source' => 'CustomOpenAI'
]);
} else {
echo json_encode(['success' => false, 'error' => $response['error']]);
}
exit;
}
// Fallback to LocalAIApi (original logic)
$content = [['type' => 'text', 'text' => $prompt]];
// Handle file upload
if (isset($_FILES['image']) && $_FILES['image']['error'] === UPLOAD_ERR_OK) {
$imagePath = $_FILES['image']['tmp_name'];
$imageData = base64_encode(file_get_contents($imagePath));
$imageType = $_FILES['image']['type'];
$content[] = [
'type' => 'image_url',
'image_url' => [
'url' => "data:$imageType;base64,$imageData",
'detail' => 'auto'
]
];
} elseif (!empty($imageUrl)) {
// Handle image URL
$content[] = [
'type' => 'image_url',
'image_url' => [
'url' => $imageUrl,
'detail' => 'auto'
]
];
} else {
// If no image, it's just a text prompt (optional but allowed)
}
try {
$response = LocalAIApi::createResponse([
'model' => $model,
'input' => [
[
'role' => 'user',
'content' => $content
]
]
]);
if (!$response['success']) {
throw new Exception($response['error'] ?? 'AI request failed');
}
$text = LocalAIApi::extractText($response);
$json = LocalAIApi::decodeJsonFromResponse($response);
echo json_encode([
'success' => true,
'text' => $text,
'data' => $json,
'raw' => $response
]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

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]);
}

35
api/delete_recipe.php Normal file
View File

@ -0,0 +1,35 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/auth_helper.php';
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['id'])) {
echo json_encode(['success' => false, 'error' => 'Invalid input. Recipe ID is missing.']);
exit;
}
$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]);
echo json_encode(['success' => true]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

18
api/fetch_auth_bg.php Normal file
View File

@ -0,0 +1,18 @@
<?php
require_once __DIR__.'/../includes/pexels.php';
$query = 'delicious food dishes table top view';
$url = 'https://api.pexels.com/v1/search?query=' . urlencode($query) . '&orientation=portrait&per_page=5&page=1';
$data = pexels_get($url);
if ($data && !empty($data['photos'])) {
// Pick a random one from the top 5 to get some variety if re-run
$photo = $data['photos'][array_rand($data['photos'])];
$src = $photo['src']['large2x'] ?? $photo['src']['large'];
$target = __DIR__ . '/../assets/images/auth-bg-new.jpg';
if (download_to($src, $target)) {
echo "Success: Downloaded " . $target;
} else {
echo "Error: Failed to download image.";
}
} else {
echo "Error: Failed to fetch image info from Pexels.";
}

36
api/fetch_auth_video.php Normal file
View File

@ -0,0 +1,36 @@
<?php
require_once __DIR__.'/../includes/pexels.php';
// We'll try to find a video that combines these themes.
// Since we can't easily find a single clip with everything, we'll pick the most descriptive one.
$query = 'woman laptop kitchen budget cooking';
$url = 'https://api.pexels.com/videos/search?query=' . urlencode($query) . '&orientation=portrait&per_page=1&page=1';
$data = pexels_get($url);
if ($data && !empty($data['videos'])) {
$video = $data['videos'][0];
$bestFile = null;
foreach ($video['video_files'] as $file) {
if ($file['quality'] == 'hd' && ($file['width'] == 1080 || $file['width'] == 720)) {
$bestFile = $file;
break;
}
}
if (!$bestFile) $bestFile = $video['video_files'][0];
$src = $bestFile['link'];
$target = __DIR__ . '/../assets/images/auth-video.mp4';
// Using curl for video as file_get_contents might be slow/limited for large files
$ch = curl_init($src);
$fp = fopen($target, 'wb');
curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_exec($ch);
curl_close($ch);
fclose($fp);
echo "Success: Downloaded " . $target;
} else {
echo "Error: Failed to fetch video info from Pexels.";
}

39
api/get_recipes.php Normal file
View File

@ -0,0 +1,39 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/auth_helper.php';
try {
$pdo = db();
$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();
$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) {
$ingredients_by_recipe[$ingredient['recipe_id']][] = $ingredient;
}
foreach ($recipes as $i => $recipe) {
$recipes[$i]['ingredients'] = $ingredients_by_recipe[$recipe['id']] ?? [];
}
echo json_encode(['success' => true, 'recipes' => $recipes]);
} catch (PDOException $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

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 ($email === '' || $password === '') {
echo json_encode(['success' => false, 'error' => 'Email and password are required.']);
exit;
}
try {
$stmt = db()->prepare("SELECT id, email, password_hash FROM users WHERE email = ?");
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['email'] = $user['email'];
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'] ?? '';
$confirmPassword = $data['confirm_password'] ?? '';
if ($email === '' || $password === '') {
echo json_encode(['success' => false, 'error' => 'Email and password are required.']);
exit;
}
if ($password !== $confirmPassword) {
echo json_encode(['success' => false, 'error' => 'Passwords do not match.']);
exit;
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo json_encode(['success' => false, 'error' => 'Invalid email format.']);
exit;
}
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
try {
$stmt = db()->prepare("INSERT INTO users (email, password_hash) VALUES (?, ?)");
if ($stmt->execute([$email, $hashedPassword])) {
$_SESSION['user_id'] = db()->lastInsertId();
$_SESSION['email'] = $email;
echo json_encode(['success' => true]);
}
} catch (PDOException $e) {
if ($e->getCode() == 23000) {
echo json_encode(['success' => false, 'error' => 'Email is already registered.']);
} else {
echo json_encode(['success' => false, 'error' => 'Database error: ' . $e->getMessage()]);
}
}

114
api/save_recipe.php Normal file
View File

@ -0,0 +1,114 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/auth_helper.php';
function get_ingredient_category($name) {
$name = strtolower($name);
$drink_keywords = ["water", "juice", "milk", "wine", "beer", "soda", "spirit", "vodka", "gin", "rum", "tequila", "whiskey", "liqueur", "coke", "pepsi", "tea", "coffee"];
foreach ($drink_keywords as $keyword) {
if (strpos($name, $keyword) !== false) {
return 'drink';
}
}
return 'food';
}
// The request is now multipart/form-data, so we use $_POST and $_FILES
$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;
}
$ingredients = json_decode($data['ingredients'], true);
if (json_last_error() !== JSON_ERROR_NONE) {
echo json_encode(['success' => false, 'error' => 'Invalid ingredients format.']);
exit;
}
$pdo = db();
$imageUrl = null;
try {
// Handle file upload
if (isset($files['image']) && $files['image']['error'] === UPLOAD_ERR_OK) {
$uploadDir = __DIR__ . '/../assets/images/recipes/';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0777, true);
}
$filename = uniqid() . '-' . basename($files['image']['name']);
$uploadFile = $uploadDir . $filename;
if (move_uploaded_file($files['image']['tmp_name'], $uploadFile)) {
$imageUrl = 'assets/images/recipes/' . $filename;
} else {
throw new Exception('Failed to move uploaded file.');
}
}
$pdo->beginTransaction();
if (isset($data['id']) && !empty($data['id'])) {
// Update existing recipe
$recipeId = $data['id'];
$category = !empty($data['category']) ? $data['category'] : 'No category';
// Check if recipe belongs to user
$stmt = $pdo->prepare("SELECT user_id, image_url FROM recipes WHERE id = ?");
$stmt->execute([$recipeId]);
$existing = $stmt->fetch();
if (!$existing || ($existing['user_id'] !== null && $existing['user_id'] != $userId)) {
throw new Exception('Unauthorized to update this recipe.');
}
// 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 = ?, instructions = ?, user_id = ? WHERE id = ?");
$stmt->execute([$data['name'], $data['guests'], $category, $imageUrl, $data['instructions'] ?? null, $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 = ?");
$stmt->execute([$recipeId]);
} else {
// Insert new recipe
$category = !empty($data['category']) ? $data['category'] : 'No category';
$stmt = $pdo->prepare("INSERT INTO recipes (name, guests, category, image_url, instructions, user_id) VALUES (?, ?, ?, ?, ?, ?)");
$stmt->execute([$data['name'], $data['guests'], $category, $imageUrl, $data['instructions'] ?? null, $userId]);
$recipeId = $pdo->lastInsertId();
}
// Insert ingredients
$stmt = $pdo->prepare("INSERT INTO ingredients (recipe_id, name, quantity, unit, category) VALUES (?, ?, ?, ?, ?)");
foreach ($ingredients as $ing) {
$ingredientCategory = get_ingredient_category($ing['name']);
$stmt->execute([$recipeId, $ing['name'], $ing['quantity'], $ing['unit'], $ingredientCategory]);
}
$pdo->commit();
// Fetch the newly created/updated recipe to return it to the client
$stmt = $pdo->prepare("SELECT * FROM recipes WHERE id = ?");
$stmt->execute([$recipeId]);
$recipe = $stmt->fetch();
$stmt = $pdo->prepare("SELECT * FROM ingredients WHERE recipe_id = ?");
$stmt->execute([$recipeId]);
$recipe['ingredients'] = $stmt->fetchAll();
echo json_encode(['success' => true, 'recipe' => $recipe]);
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

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()]);
}

44
api/scan_debug.log Normal file
View File

@ -0,0 +1,44 @@
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
2026-01-26 22:39:24 Processing image: type=image/jpeg, size=216567 bytes
2026-01-26 22:39:24 Data URL prefix: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...
2026-01-26 22:39:24 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 create a possible recipe for it. If it's a photo of a written recipe, extract the information from it.\nProvide the information in JSON format IN ENGLISH:\n- name: Dish\/recipe name\n- category: One of the following values: 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'\n- ingredients: Array of objects, each containing:\n - name: Ingredient name\n - quantity: Numeric quantity (float)\n - unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'pcs', 'pack')\n- guests: Default number of guests\/portions (integer)\n\nImportant:\n1. Quantities should be specified per 1 person if possible. If you are creating a recipe for a dish, default to 1 or 2 guests.\n2. Return ONLY the JSON object.\n3. If something cannot be determined, make the most accurate assumption or specify null."},{"type":"image_url","image_url":{"url":"data:image\/jpeg;base... [TRUNCATED]
2026-01-26 22:39:30 fetchStatus(1153) status: 200, body: {"status":"pending"}
2026-01-26 22:39:35 fetchStatus(1153) status: 200, body: {"status":"failed","error":"the server responded with status 400"}
2026-01-26 22:39:35 AI Scan Error: failed
2026-01-26 22:39:43 Processing image: type=image/jpeg, size=85908 bytes
2026-01-26 22:39:43 Data URL prefix: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...
2026-01-26 22:39: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 create a possible recipe for it. If it's a photo of a written recipe, extract the information from it.\nProvide the information in JSON format IN ENGLISH:\n- name: Dish\/recipe name\n- category: One of the following values: 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'\n- ingredients: Array of objects, each containing:\n - name: Ingredient name\n - quantity: Numeric quantity (float)\n - unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'pcs', 'pack')\n- guests: Default number of guests\/portions (integer)\n\nImportant:\n1. Quantities should be specified per 1 person if possible. If you are creating a recipe for a dish, default to 1 or 2 guests.\n2. Return ONLY the JSON object.\n3. If something cannot be determined, make the most accurate assumption or specify null."},{"type":"image_url","image_url":{"url":"data:image\/jpeg;base... [TRUNCATED]
2026-01-26 22:39:44 fetchStatus(1154) status: 200, body: {"status":"failed","error":"the server responded with status 400"}
2026-01-26 22:39:44 AI Scan Error: failed
2026-01-27 06:46:26 Processing image: type=image/jpeg, size=268319 bytes
2026-01-27 06:46:26 Data URL prefix: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...
2026-01-27 06:46:26 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 create a possible recipe for it. If it's a photo of a written recipe, extract the information from it.\nProvide the information in JSON format IN ENGLISH:\n- name: Dish\/recipe name\n- category: One of the following values: 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'\n- ingredients: Array of objects, each containing:\n - name: Ingredient name\n - quantity: Numeric quantity (float)\n - unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'pcs', 'pack')\n- guests: Default number of guests\/portions (integer)\n\nImportant:\n1. Quantities should be specified per 1 person if possible. If you are creating a recipe for a dish, default to 1 or 2 guests.\n2. Return ONLY the JSON object.\n3. If something cannot be determined, make the most accurate assumption or specify null."},{"type":"image_url","image_url":{"url":"data:image\/jpeg;base... [TRUNCATED]
2026-01-27 06:46:31 fetchStatus(1156) status: 200, body: {"status":"pending"}
2026-01-27 06:46:36 fetchStatus(1156) status: 200, body: {"status":"failed","error":"the server responded with status 400"}
2026-01-27 06:46:36 AI Scan Error: failed
2026-01-27 06:46:44 Processing image: type=image/jpeg, size=85908 bytes
2026-01-27 06:46:44 Data URL prefix: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD...
2026-01-27 06:46:44 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 create a possible recipe for it. If it's a photo of a written recipe, extract the information from it.\nProvide the information in JSON format IN ENGLISH:\n- name: Dish\/recipe name\n- category: One of the following values: 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'\n- ingredients: Array of objects, each containing:\n - name: Ingredient name\n - quantity: Numeric quantity (float)\n - unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'pcs', 'pack')\n- guests: Default number of guests\/portions (integer)\n\nImportant:\n1. Quantities should be specified per 1 person if possible. If you are creating a recipe for a dish, default to 1 or 2 guests.\n2. Return ONLY the JSON object.\n3. If something cannot be determined, make the most accurate assumption or specify null."},{"type":"image_url","image_url":{"url":"data:image\/jpeg;base... [TRUNCATED]
2026-01-27 06:46:44 fetchStatus(1157) status: 200, body: {"status":"failed","error":"the server responded with status 400"}
2026-01-27 06:46:44 AI Scan Error: failed
2026-01-27 07:32:16 AI Request to https://flatlogic.com/projects/35604/ai-request: {"model":"gpt-4o-mini","input":[{"role":"user","content":[{"type":"text","text":"Analyze this image. If it's a dish, identify it and provide a recipe in JSON format: { \"name\": \"...\", \"ingredients\": [...] }"},{"type":"image_url","image_url":{"url":"data:image\/webp;base64,UklGRhK1AABXRUJQVlA4IAa1AACQPAKdASqmAmoBPlUmj0UjoiEnqPUcWPAKiU3REdmQXFpa77txeh2tz7hpr\/5fpE\/lc4FZu\/fuG6DyWeRe2f5\/95\/z\/rJ7F+xPLl6n80f\/J9bn639ij9jP2o7FP\/B9O3+w8+jpLOrr6E\/\/w+0\/+7+T6zFvNv5fvP8onx799\/0n\/l9j\/HX7F\/deY38+\/NX9f1mf2Xfr809RT81\/rH\/N9O2Dl2i\/V9CP4T\/I+aH+h\/8PT39z\/2v\/q9w3+pf4D\/y+vv\/K8Yf83\/vP22+Av+s\/4H\/z\/578vPpw\/xv\/v\/wv+F60fqv\/7f7X4Hf6P\/hP2W9qn\/+\/7\/4KfsV\/9fdZ\/Y\/\/4HN0vygqkj13cTU+ufy8eCpP9bLemXYHORiiXbAfOOUjOOt\/bNCTJJeEfioXA2s+09q8pmOOZ0vSOwDYAYRyxZLm0kZggHXZB0cl9wG2NnSlwukM2uBXyLDz8EMveC0gcDHesG0tnbRdaBNSdaO1KrwZIoYghbkS+EeYHyvAFEnLzpqrVEkKEjjNxoPKflcuw\/lnU\/iyoGk5wNjUHc44c3OSVdBFfbmdbCrQ\/hee3pLddPPa95GuBysCi\/F28ZJC5Me4MXYhZu8F2iun96szFlLSMZW12npp54onZzx\/rM4ZKl40obwCMX7... [TRUNCATED]
2026-01-27 07:32:17 fetchStatus(1158) status: 200, body: {"status":"failed","error":"the server responded with status 400"}

125
api/scan_recipe.php Normal file
View File

@ -0,0 +1,125 @@
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../ai/LocalAIApi.php';
require_once __DIR__ . '/../ai/CustomOpenAI.php';
@include_once __DIR__ . '/../ai/keys.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['success' => false, 'error' => 'No image uploaded or upload error']);
exit;
}
$imagePath = $_FILES['image']['tmp_name'];
$imageSize = filesize($imagePath);
$imageData = base64_encode(file_get_contents($imagePath));
$imageType = $_FILES['image']['type'];
$apiKey = defined('OPENAI_API_KEY') ? OPENAI_API_KEY : '';
$prompt = "Analyze this image. If it's a photo of a dish, identify what it is and create a possible recipe for it. If it's a photo of a written recipe, extract the information from it.
Provide the information in JSON format IN ENGLISH:
- name: Dish/recipe name
- category: One of the following values: 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'
- ingredients: Array of objects, each containing:
- name: Ingredient name
- quantity: Numeric quantity (float)
- unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'pcs', 'pack')
- instructions: A string containing clear, step-by-step cooking instructions. Use newlines (\n) between steps.
- guests: Default number of guests/portions (integer)
Important:
1. Quantities should be specified per 1 person if possible. If you are creating a recipe for a dish, default to 1 or 2 guests.
2. Return ONLY the JSON object.
3. If something cannot be determined, make the most accurate assumption or specify null.";
if (!empty($apiKey)) {
$ai = new CustomOpenAI($apiKey);
$response = $ai->analyze([
'model' => 'gpt-4o',
'prompt' => $prompt,
'image_base64' => $imageData,
'image_type' => $imageType
]);
if (!$response['success']) {
echo json_encode(['success' => false, 'error' => $response['error']]);
exit;
}
$recipeData = CustomOpenAI::decodeJson($response);
if (!$recipeData) {
echo json_encode(['success' => false, 'error' => 'Failed to parse AI response as JSON.']);
exit;
}
echo json_encode(['success' => true, 'data' => $recipeData, 'source' => 'CustomOpenAI']);
exit;
}
// Fallback to LocalAIApi
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 create a possible recipe for it. If it's a photo of a written recipe, extract the information from it.
Provide the information in JSON format IN ENGLISH:
- name: Dish/recipe name
- category: One of the following values: 'Drinks', 'Breakfast', 'Dinner', 'Appetizers'
- ingredients: Array of objects, each containing:
- name: Ingredient name
- quantity: Numeric quantity (float)
- unit: Unit of measurement (e.g., 'g', 'kg', 'ml', 'l', 'pcs', 'pack')
- instructions: A string containing clear, step-by-step cooking instructions. Use newlines (\n) between steps.
- guests: Default number of guests/portions (integer)
Important:
1. Quantities should be specified per 1 person if possible. If you are creating a recipe for a dish, default to 1 or 2 guests.
2. Return ONLY the JSON object.
3. If something cannot be determined, make the most accurate assumption or specify null.";
try {
$response = LocalAIApi::createResponse([
'model' => 'gpt-4o',
'input' => [
[
'role' => 'user',
'content' => [
[
'type' => 'text',
'text' => $prompt
],
[
'type' => 'image_url',
'image_url' => [
'url' => "data:$imageType;base64,$imageData",
'detail' => 'auto'
]
]
]
]
]
]);
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) {
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]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

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()]);
}

703
assets/css/custom.css Normal file
View File

@ -0,0 +1,703 @@
:root {
--brand-primary: #2D6A4F; /* Deep Emerald Green */
--brand-primary-hover: #1B4332;
--brand-secondary: #D4A373; /* Warm Gold/Honey */
--brand-accent: #E9C46A; /* Saffron */
--brand-danger: #BC4749; /* Deep Earthy Red */
--bg-main: #FDFBF7; /* Cream/Off-white */
--text-main: #2D2D2D;
--text-muted: #666666;
--card-shadow: 0 12px 40px rgba(45, 106, 79, 0.08);
}
/* General Body Styles */
body {
background-color: var(--bg-main);
color: var(--text-main);
font-family: 'Poppins', sans-serif;
}
/* Headings */
h1, h2, h3, h4, h5, h6 {
color: var(--text-main) !important;
font-weight: 700;
}
/* Buttons and Inputs */
input,
button,
.form-control,
.form-select {
border-radius: 12px;
}
.btn {
border-radius: 50px !important;
font-weight: 600;
padding: 10px 24px;
transition: all 0.3s ease;
}
/* Main Content */
.display-4 {
font-weight: 700;
color: var(--text-main);
}
.lead {
color: var(--text-muted);
}
/* Cards */
.card {
background-color: #ffffff;
border: none;
border-radius: 24px;
box-shadow: var(--card-shadow);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card-body {
color: var(--text-main);
}
/* Forms */
.form-label {
color: var(--text-main);
font-weight: 600;
margin-bottom: 0.5rem;
}
.form-control,
.form-select {
background-color: #ffffff;
border: 2px solid #F0F0F0;
color: var(--text-main);
padding: 12px 16px;
}
.form-control:focus,
.form-select:focus {
background-color: #ffffff;
border-color: var(--brand-primary);
box-shadow: 0 0 0 0.25rem rgba(45, 106, 79, 0.1);
color: var(--text-main);
}
.form-control::placeholder {
color: #AAAAAA;
}
/* Buttons */
.btn-primary {
background-color: var(--brand-primary) !important;
border-color: var(--brand-primary) !important;
color: #ffffff !important;
}
.btn-primary:hover {
background-color: var(--brand-primary-hover) !important;
border-color: var(--brand-primary-hover) !important;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(45, 106, 79, 0.2);
}
.btn-outline-primary {
color: var(--brand-primary) !important;
border-color: var(--brand-primary) !important;
border-width: 2px;
}
.btn-outline-primary:hover {
background-color: var(--brand-primary) !important;
color: #ffffff !important;
}
.text-primary {
color: var(--brand-primary) !important;
}
.btn-danger {
background-color: var(--brand-danger) !important;
border-color: var(--brand-danger) !important;
color: #ffffff !important;
}
/* ... rest of existing styles ... */
/* Feature Items */
.feature-item {
background: transparent;
border: none;
padding: 8px 0 !important;
transition: all 0.3s ease;
}
.feature-item:hover {
transform: translateX(8px);
}
.checkmark {
color: #FFFFFF;
font-weight: bold;
font-size: 1.2rem;
flex-shrink: 0;
margin-right: 1rem;
}
.feature-list h5 {
color: #ffffff !important;
font-size: 1.1rem;
font-weight: 500;
margin: 0;
}
.btn-outline-secondary {
border: 2px solid #EEEEEE;
color: var(--text-muted);
}
.btn-outline-secondary:hover {
background-color: #F8F8F8;
color: var(--text-main);
border-color: #DDDDDD;
}
.btn-secondary {
background-color: #F0F0F0;
border: none;
color: var(--text-muted);
}
.btn-secondary:hover {
background-color: #E0E0E0;
color: var(--text-main);
}
/* Shopping List & Recipe Cards */
.recipe-card {
background-color: #ffffff;
border-radius: 24px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #F0F0F0;
}
#recipe-cards-container, #shopping-list-container {
max-height: 70vh;
overflow-y: auto;
padding: 10px;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #DDDDDD;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--brand-secondary);
}
/* Footer */
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 #F8F8F8 !important;
padding: 15px 0 !important;
color: var(--text-main) !important;
}
.list-group-item.checked .form-check-label {
text-decoration: line-through;
opacity: 0.5;
}
.form-check-input {
width: 1.25em;
height: 1.25em;
border: 2px solid #DDDDDD;
cursor: pointer;
}
.form-check-input:checked {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
}
/* Unit buttons */
.unit-selector .btn {
padding: 6px 12px !important;
font-size: 0.85rem;
}
.unit-selector .btn-secondary {
background-color: var(--brand-primary) !important;
color: white !important;
}
/* Badges */
.bg-custom-green {
background-color: #E9F5EF !important;
color: var(--brand-primary) !important;
font-weight: 700;
border: 1px solid #D1EADE;
}
/* Quantity Modifiers */
.btn-quantity-modifier {
width: 28px;
height: 28px;
border-radius: 8px !important;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: #F8F8F8;
border: 1px solid #EEEEEE;
color: var(--text-muted);
padding: 0 !important;
}
.btn-quantity-modifier:hover {
background-color: var(--brand-primary);
color: white;
border-color: var(--brand-primary);
}
/* Modal Styles */
.modal-content {
background-color: #ffffff;
border: none;
border-radius: 32px;
overflow: hidden;
}
.modal-header {
border-bottom: 1px solid #F8F8F8;
padding: 24px;
}
.modal-footer {
border-top: 1px solid #F8F8F8;
padding: 20px 24px;
}
/* Recipe Card Selection */
.recipe-selection-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 2px solid transparent;
}
.recipe-selection-card.selected {
border-color: var(--brand-primary);
box-shadow: 0 15px 45px rgba(45, 106, 79, 0.15);
background-color: #F7FAF9;
}
.recipe-selection-card .select-recipe:checked {
background-color: var(--brand-primary);
border-color: var(--brand-primary);
}
.recipe-controls {
border: 1px solid #EAEAEA;
transition: all 0.3s ease;
}
.recipe-selection-card.selected .recipe-controls {
background-color: #ffffff !important;
border-color: var(--brand-primary);
}
.recipe-selection-card .form-check-input {
width: 1.5em;
height: 1.5em;
}
.recipe-selection-card .form-check-label {
cursor: pointer;
user-select: none;
}
/* Category Label */
.recipe-category-label {
background-color: #E9F5EF;
color: var(--brand-primary);
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 #F0F0F0;
background-color: #ffffff;
color: var(--text-muted);
font-size: 0.85rem;
padding: 8px 16px !important;
}
#category-filters .btn.active {
background-color: var(--brand-primary) !important;
border-color: var(--brand-primary) !important;
color: #ffffff !important;
}
/* Search Container */
#recipe-search {
padding-left: 3rem;
height: 54px;
border-color: #F0F0F0;
}
.search-container .bi-search {
left: 1.25rem;
color: #AAAAAA;
font-size: 1.1rem;
}
/* Recipe Card Image */
.card .card-img-top {
height: 160px;
width: 160px;
object-fit: cover;
border-radius: 50% !important;
margin: 20px auto 10px auto;
border: 5px solid #ffffff;
box-shadow: 0 8px 15px rgba(0,0,0,0.08);
}
.recipe-selection-card {
border-radius: 40px !important;
text-align: center;
overflow: hidden;
padding-bottom: 15px;
}
.card-body {
padding: 1rem !important;
color: var(--text-main);
}
.recipe-selection-card .h5 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.recipe-selection-card .selection-wrapper {
justify-content: center;
}
.recipe-selection-card .form-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.recipe-selection-card .recipe-category-label {
margin: 5px auto;
display: inline-block;
}
.recipe-selection-card .card-text {
margin-bottom: 1rem;
}
.recipe-controls {
border: 1px solid #EAEAEA;
transition: all 0.3s ease;
padding: 10px !important;
border-radius: 20px !important;
margin: 0 10px 15px 10px;
}
.recipe-controls label {
font-size: 0.75rem !important;
}
.recipe-controls .form-control-sm {
padding: 4px 8px;
font-size: 0.8rem;
}
/* View Recipe Modal */
#view-recipe-ingredients {
background-color: #FBFBFB;
border-radius: 20px;
padding: 20px;
}
#view-recipe-ingredients .list-group-item {
border-bottom: 1px solid #F0F0F0 !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 {
position: relative;
overflow: hidden;
background-color: #2D2D2D; /* Neutral dark fallback */
}
.auth-background-video {
position: absolute;
top: 50%;
left: 50%;
min-width: 100%;
min-height: 100%;
width: auto;
height: auto;
transform: translate(-50%, -50%);
object-fit: cover;
z-index: 0;
}
.auth-image-overlay {
background: linear-gradient(135deg, rgba(45, 106, 79, 0.1) 0%, rgba(0, 0, 0, 0.4) 100%);
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}
/* Animated decorative circles in background */
.auth-image-overlay::before,
.auth-image-overlay::after {
content: '';
position: absolute;
border-radius: 50%;
background: rgba(45, 106, 79, 0.2);
filter: blur(80px);
z-index: 0;
}
.auth-image-overlay::before {
width: 300px;
height: 300px;
top: -100px;
right: -100px;
animation: pulse 10s infinite alternate;
}
.auth-image-overlay::after {
width: 400px;
height: 400px;
bottom: -150px;
left: -150px;
animation: pulse 15s infinite alternate-reverse;
}
@keyframes pulse {
0% { transform: scale(1) translate(0, 0); opacity: 0.2; }
100% { transform: scale(1.2) translate(50px, 30px); opacity: 0.4; }
}
.auth-branding-content {
background: rgba(45, 106, 79, 0.15);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 40px;
padding: 40px;
max-width: 520px;
box-shadow: 0 40px 100px -20px rgba(0, 0, 0, 0.3);
animation: slideUpFade 1s cubic-bezier(0.2, 0.8, 0.2, 1);
z-index: 1;
}
@keyframes slideUpFade {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.floating-emoji {
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(var(--rotate)); }
50% { transform: translateY(-20px) rotate(calc(var(--rotate) + 10deg)); }
}
.auth-branding-content p.lead {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
font-size: 1.15rem;
line-height: 1.5;
letter-spacing: -0.2px;
}
.feature-list h5 {
color: #ffffff !important;
font-size: 1.2rem;
font-weight: 600;
margin: 0;
}
.feature-list .bi {
background: rgba(255, 255, 255, 0.15) !important;
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.auth-form-container {
box-shadow: -20px 0 60px rgba(0, 0, 0, 0.05);
background-color: #ffffff;
z-index: 2;
}
.auth-form-container h2 {
color: var(--text-main);
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: var(--brand-primary);
}
#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;
}
}
/* Modal Custom Styles */
.modal-content {
border-radius: 28px !important;
}
.add-option-btn {
border: 2px solid #F0F0F0 !important;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important;
background-color: #ffffff !important;
color: var(--text-main) !important;
}
.add-option-btn:hover {
border-color: var(--brand-primary) !important;
background-color: #ffffff !important;
transform: translateY(-5px);
box-shadow: 0 15px 35px rgba(45, 106, 79, 0.1) !important;
}
.add-option-btn i {
color: var(--brand-primary);
background: #F7FAF9;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
margin-bottom: 1.5rem !important;
transition: all 0.4s ease;
border: 1px solid #E9F5EF;
}
.add-option-btn:hover i {
background: var(--brand-primary);
color: #ffffff;
transform: rotate(10deg) scale(1.1);
}
.add-option-btn .fw-bold {
color: var(--text-main);
font-size: 1rem;
}
#take-photo-btn, #take-products-photo-btn {
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-hover) 100%) !important;
border: none !important;
box-shadow: 0 10px 20px rgba(45, 106, 79, 0.2);
}
#take-photo-btn:hover, #take-products-photo-btn:hover {
transform: translateY(-5px);
box-shadow: 0 15px 30px rgba(45, 106, 79, 0.3);
}
#upload-image-btn {
border: 2px dashed var(--brand-primary) !important;
background: #F7FAF9 !important;
color: var(--brand-primary) !important;
}
#upload-image-btn:hover {
background: #E9F5EF !important;
transform: translateY(-5px);
}
#manual-ingredients-list {
border-radius: 18px;
padding: 15px;
background-color: #F9F9F9;
}
/* 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; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

View File

@ -0,0 +1,16 @@
<svg width="100" height="120" viewBox="0 0 100 120" xmlns="http://www.w3.org/2000/svg">
<!-- Bauble Cap -->
<rect x="40" y="0" width="20" height="10" fill="#f1c40f"/>
<!-- Bauble Loop -->
<path d="M 50 10 Q 55 20 60 10" stroke="#f1c40f" stroke-width="2" fill="none"/>
<!-- Main Bauble with Gradient -->
<defs>
<radialGradient id="baubleGradient" cx="0.3" cy="0.3" r="0.7">
<stop offset="0%" style="stop-color: #e74c3c; stop-opacity: 1" />
<stop offset="100%" style="stop-color: #c0392b; stop-opacity: 1" />
</radialGradient>
</defs>
<circle cx="50" cy="60" r="40" fill="url(#baubleGradient)"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

View File

@ -0,0 +1,31 @@
<svg width="125" height="195" viewBox="-5 0 105 155" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="candy-stripes" gradientTransform="rotate(45)">
<stop offset="0%" stop-color="#d51f28" />
<stop offset="12.5%" stop-color="#d51f28" />
<stop offset="12.5%" stop-color="white" />
<stop offset="25%" stop-color="white" />
<stop offset="25%" stop-color="#d51f28" />
<stop offset="37.5%" stop-color="#d51f28" />
<stop offset="37.5%" stop-color="white" />
<stop offset="50%" stop-color="white" />
<stop offset="50%" stop-color="#d51f28" />
<stop offset="62.5%" stop-color="#d51f28" />
<stop offset="62.5%" stop-color="white" />
<stop offset="75%" stop-color="white" />
<stop offset="75%" stop-color="#d51f28" />
<stop offset="87.5%" stop-color="#d51f28" />
<stop offset="87.5%" stop-color="white" />
<stop offset="100%" stop-color="white" />
</linearGradient>
</defs>
<path
d="M 75 140 V 50 C 75 22.38 52.62 0 25 0 S -25 22.38 -25 50"
transform="translate(25, 5)"
stroke="url(#candy-stripes)"
stroke-width="25"
fill="none"
stroke-linecap="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,6 @@
<svg width="100" height="100" viewBox="0 0 100 100">
<polygon
points="50,5 61,40 98,40 68,62 79,96 50,75 21,96 32,62 2,40 39,40"
fill="#FFD700"
/>
</svg>

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

1191
assets/js/main.js Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

42
db/migrate.php Normal file
View File

@ -0,0 +1,42 @@
<?php
require_once __DIR__ . '/config.php';
function run_migrations() {
$pdo = db();
// Ensure migrations table exists
$pdo->exec("CREATE TABLE IF NOT EXISTS migrations (migration VARCHAR(255) NOT NULL, PRIMARY KEY (migration))");
// Get executed migrations
$executedMigrations = $pdo->query("SELECT migration FROM migrations")->fetchAll(PDO::FETCH_COLUMN);
$migrationsDir = __DIR__ . '/migrations';
$files = glob($migrationsDir . '/*.sql');
sort($files);
foreach ($files as $file) {
$migrationName = basename($file);
if (in_array($migrationName, $executedMigrations)) {
continue;
}
echo "Running migration: " . $migrationName . "\n";
$sql = file_get_contents($file);
try {
$pdo->exec($sql);
// Record migration
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?)");
$stmt->execute([$migrationName]);
echo "Success.\n";
} catch (PDOException $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
}
echo "All migrations have been run.\n";
}
run_migrations();

View File

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `recipes` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
`guests` INT NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `ingredients` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`recipe_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`quantity` FLOAT NOT NULL,
`unit` VARCHAR(50) NOT NULL,
FOREIGN KEY (`recipe_id`) REFERENCES `recipes`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1 @@
ALTER TABLE `recipes` ADD `category` VARCHAR(255) NULL DEFAULT NULL AFTER `guests`;

View File

@ -0,0 +1 @@
ALTER TABLE recipes ADD COLUMN image_url VARCHAR(255) DEFAULT NULL;

View File

@ -0,0 +1 @@
ALTER TABLE `ingredients` ADD `category` VARCHAR(50) NOT NULL DEFAULT 'food';

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;

View File

@ -0,0 +1,2 @@
-- Add instructions column to recipes table
ALTER TABLE `recipes` ADD COLUMN `instructions` TEXT DEFAULT NULL;

25
includes/pexels.php Normal file
View File

@ -0,0 +1,25 @@
<?php
function pexels_key() {
$k = getenv('PEXELS_KEY');
return $k && strlen($k) > 0 ? $k : 'Vc99rnmOhHhJAbgGQoKLZtsaIVfkeownoQNbTj78VemUjKh08ZYRbf18';
}
function pexels_get($url) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [ 'Authorization: '. pexels_key() ],
CURLOPT_TIMEOUT => 15,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300 && $resp) return json_decode($resp, true);
return null;
}
function download_to($srcUrl, $destPath) {
$data = @file_get_contents($srcUrl);
if ($data === false) return false;
if (!is_dir(dirname($destPath))) mkdir(dirname($destPath), 0775, true);
return file_put_contents($destPath, $data) !== false;
}

663
index.php
View File

@ -1,150 +1,537 @@
<?php <!DOCTYPE html>
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
?>
<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Style</title>
<?php <!-- SEO & Meta Tags -->
// Read project preview data from environment <title>FoodieFlow An AI-powered flow from recipe to grocery</title>
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; <meta name="description" content="FoodieFlow — An AI-powered flow from recipe to grocery — every day, every occasion. Manage recipes, create smart shopping lists, and optimize your grocery budget with AI.">
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; <meta name="keywords" content="FoodieFlow, recipe manager, shopping list, AI recipe scanner, cooking organizer, meal planner">
?>
<?php if ($projectDescription): ?> <!-- Open Graph / Facebook -->
<!-- Meta description --> <meta property="og:type" content="website">
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' /> <meta property="og:title" content="FoodieFlow — An AI-powered flow from recipe to grocery">
<!-- Open Graph meta tags --> <meta property="og:description" content="Manage recipes and create smart shopping lists. An AI-powered flow from recipe to grocery — every day, every occasion.">
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <meta property="og:image" content="">
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <!-- Twitter -->
<?php endif; ?> <meta name="twitter:card" content="summary_large_image">
<?php if ($projectImageUrl): ?> <meta name="twitter:title" content="FoodieFlow — An AI-powered flow from recipe to grocery">
<!-- Open Graph image --> <meta name="twitter:description" content="An AI-powered flow from recipe to grocery — every day, every occasion.">
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta name="twitter:image" content="">
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <!-- Styles -->
<?php endif; ?> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<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=Inter:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet">
<style> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
:root { <link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>_v21">
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head> </head>
<body> <body>
<main>
<div class="card"> <nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm sticky-top mb-4">
<h1>Analyzing your requirements and generating your website…</h1> <div class="container">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <a class="navbar-brand d-flex align-items-center" href="/">
<span class="sr-only">Loading…</span> <i class="bi bi-flow me-2" style="color: var(--brand-primary);"></i>
<span class="fw-bold">FoodieFlow</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 items will be inserted here -->
<li class="nav-item">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</li>
</ul>
</div>
</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: Video & Description -->
<div class="col-lg-6 d-none d-lg-block auth-image-container">
<video autoplay muted loop playsinline class="auth-background-video">
<source src="assets/images/auth-video.mp4" type="video/mp4">
</video>
<div class="auth-image-overlay d-flex align-items-center justify-content-center p-5 text-white">
<div class="auth-branding-content position-relative overflow-hidden text-center">
<!-- Floating decorative emojis with animation -->
<div class="position-absolute floating-emoji" style="top: 8%; right: 10%; font-size: 3rem; --rotate: 15deg;">🥑</div>
<div class="position-absolute floating-emoji" style="bottom: 12%; left: 8%; font-size: 3.5rem; --rotate: -15deg; animation-delay: 1s;">🛍️</div>
<div class="position-absolute floating-emoji" style="top: 35%; left: 5%; font-size: 2.5rem; --rotate: -20deg; animation-delay: 2s;">🥖</div>
<div class="position-absolute floating-emoji" style="bottom: 25%; right: 5%; font-size: 2rem; --rotate: 10deg; animation-delay: 1.5s;">🥗</div>
<div class="mb-5">
<h1 class="display-3 fw-bold mb-4 d-flex align-items-center justify-content-center text-white" style="color: #ffffff !important;">
<i class="bi bi-flow me-3"></i>FoodieFlow
</h1>
<p class="lead text-white opacity-90 fw-medium" style="color: #ffffff !important;">
An AI-powered flow from recipe to grocery every day, every occasion
</p>
</div>
<div class="feature-list mt-4 text-start position-relative">
<div class="d-flex mb-3 align-items-center transition-all feature-item">
<span class="checkmark"></span>
<h5 class="mb-0">AI Recipes from Images & Prompts</h5>
</div>
<div class="d-flex mb-3 align-items-center transition-all feature-item">
<span class="checkmark"></span>
<h5 class="mb-0">Auto-Generated Shopping Lists</h5>
</div>
<div class="d-flex mb-3 align-items-center transition-all feature-item">
<span class="checkmark"></span>
<h5 class="mb-0">Smart Budget Calculator</h5>
</div>
<div class="d-flex align-items-center transition-all feature-item">
<span class="checkmark"></span>
<h5 class="mb-0">Expense Control & Splitting</h5>
</div>
</div>
</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">Log in 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">Log 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 culinary 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="mb-4">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control form-control-lg" name="confirm_password" placeholder="••••••••" required>
</div>
<div class="d-grid gap-2 mb-4">
<button type="submit" class="btn btn-primary btn-lg">Sign Up</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">Log in here</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="app-view" class="d-none">
<main class="container my-5">
<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="#add-recipe-options-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">Lunch/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>
<!-- Right Column: Shopping List / Products -->
<div class="col-md-6">
<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 grocery list will appear here.</p>
</div>
</div>
</div>
</div> </div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div> </div>
</main> </main>
<footer> </div>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
<!-- Modal Add Recipe Options -->
<div class="modal fade" id="add-recipe-options-modal" tabindex="-1" aria-labelledby="add-recipe-options-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 20px;">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold" id="add-recipe-options-modal-label">Choose method</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div class="row g-3 row-cols-2">
<div class="col">
<button class="btn btn-outline-primary h-100 w-100 p-4 text-center d-flex flex-column align-items-center rounded-4 add-option-btn" data-method="photo">
<i class="bi bi-camera fs-1 mb-3"></i>
<div>
<div class="fw-bold lh-sm mb-1">Create from a photo</div>
<div class="small text-muted" style="font-size: 0.75rem;">AI turns a photo into a recipe</div>
</div>
</button>
</div>
<div class="col">
<button class="btn btn-outline-primary h-100 w-100 p-4 text-center d-flex flex-column align-items-center rounded-4 add-option-btn" data-method="link">
<i class="bi bi-link-45deg fs-1 mb-3"></i>
<div>
<div class="fw-bold lh-sm mb-1">Save from a link</div>
<div class="small text-muted" style="font-size: 0.75rem;">From any website or social post</div>
</div>
</button>
</div>
<div class="col">
<button class="btn btn-outline-primary h-100 w-100 p-4 text-center d-flex flex-column align-items-center rounded-4 add-option-btn" data-method="ingredients">
<i class="bi bi-basket fs-1 mb-3"></i>
<div>
<div class="fw-bold lh-sm mb-1">From ingredients</div>
<div class="small text-muted" style="font-size: 0.75rem;">Generate from what you have</div>
</div>
</button>
</div>
<div class="col">
<button class="btn btn-outline-primary h-100 w-100 p-4 text-center d-flex flex-column align-items-center rounded-4 add-option-btn" data-method="scratch">
<i class="bi bi-pencil-square fs-1 mb-3"></i>
<div>
<div class="fw-bold lh-sm mb-1">From scratch</div>
<div class="small text-muted" style="font-size: 0.75rem;">Enter steps manually</div>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: Create from Photo -->
<div class="modal fade" id="add-recipe-photo-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 20px;">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">Create from a photo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div class="d-grid gap-3">
<button class="btn btn-primary p-4 rounded-4 d-flex flex-column align-items-center" id="take-photo-btn">
<i class="bi bi-camera-fill fs-1 mb-2"></i>
<span class="fw-bold">Take a photo</span>
</button>
<button class="btn btn-outline-primary p-4 rounded-4 d-flex flex-column align-items-center" id="upload-image-btn">
<i class="bi bi-image-fill fs-1 mb-2"></i>
<span class="fw-bold">Upload an image</span>
</button>
<input type="file" id="photo-camera-input" accept="image/*" capture="environment" class="d-none">
<input type="file" id="photo-upload-input" accept="image/*" class="d-none">
</div>
</div>
</div>
</div>
</div>
<!-- Modal: Save from Link -->
<div class="modal fade" id="add-recipe-link-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 20px;">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">Save from a link</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div class="mb-4">
<label class="form-label text-muted">Paste a link to any website, Instagram, TikTok or Pinterest</label>
<input type="url" class="form-control form-control-lg rounded-3" id="link-recipe-url" placeholder="https://example.com/recipe or https://instagram.com/p/...">
</div>
<div class="d-grid">
<button class="btn btn-primary btn-lg rounded-3" id="save-from-link-confirm">Extract recipe</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal: Create from Ingredients -->
<div class="modal fade" id="add-recipe-ingredients-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow-lg" style="border-radius: 20px;">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">Create from ingredients</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-4">
<div class="d-grid gap-3">
<button class="btn btn-primary p-4 rounded-4 d-flex flex-column align-items-center" id="take-products-photo-btn">
<i class="bi bi-camera-fill fs-1 mb-2"></i>
<span class="fw-bold">Take a photo of the products</span>
</button>
<div class="text-center text-muted small my-1">OR</div>
<div class="mb-3">
<label class="form-label text-muted">Enter the list manually</label>
<textarea class="form-control rounded-3" id="manual-ingredients-list" rows="3" placeholder="Chicken, broccoli, rice..."></textarea>
</div>
<div class="d-grid">
<button class="btn btn-outline-primary btn-lg rounded-3" id="generate-from-ingredients-btn">Generate recipe</button>
</div>
<input type="file" id="products-camera-input" accept="image/*" capture="environment" class="d-none">
</div>
</div>
</div>
</div>
</div>
<!-- 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-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="recipe-form-modal-label">Add recipe</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="card p-4 shadow">
<form id="recipe-form">
<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. Avocado toast">
</div>
<div class="mb-3">
<label for="recipeCategory" class="form-label">Category</label>
<select class="form-control" id="recipeCategory">
<option value="" selected disabled>Choose...</option>
<option value="Drinks">Drinks</option>
<option value="Breakfast">Breakfast</option>
<option value="Dinner">Lunch/Dinner</option>
<option value="Appetizers">Appetizers</option>
</select>
</div>
<div class="mb-3">
<label for="recipeImage" class="form-label d-flex justify-content-between">
Image
<button type="button" id="ai-scan-btn" class="btn btn-outline-primary btn-sm">
<i class="bi bi-magic"></i> AI Scanning
</button>
</label>
<input type="file" class="form-control" id="recipeImage">
<div id="ai-scan-loading" class="text-primary mt-2 d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
AI is recognizing the dish and creating a recipe...
</div>
</div>
<hr class="my-4 border-secondary">
<h3 class="h5 mb-3">Ingredients (per 1 person)</h3>
<div id="ingredients-container">
<!-- Ingredient rows will be injected here by JS -->
</div>
<button type="button" id="add-ingredient" class="btn btn-secondary btn-sm mt-2">+ Add ingredient</button>
<hr class="my-4 border-secondary">
<div class="mb-3">
<label for="recipeInstructions" class="form-label">Cooking instructions</label>
<textarea class="form-control" id="recipeInstructions" rows="5" placeholder="Step 1. Prep... \nStep 2. Cook..."></textarea>
</div>
<div class="row">
<div class="col">
<div class="mb-3">
<label for="guestCount" class="form-label">How many guests?</label>
<input type="number" class="form-control" id="guestCount" placeholder="e.g. 8" min="1" value="1">
</div>
</div>
<div class="col">
<div class="mb-3">
<label for="portionsPerGuest" class="form-label">Portions per guest</label>
<input type="number" class="form-control" id="portionsPerGuest" placeholder="e.g. 2" min="1" value="1">
</div>
</div>
</div>
<div class="d-grid gap-2 mt-4">
<button type="button" id="new-recipe-btn" class="btn btn-primary">Save recipe</button>
<button type="button" id="cancel-edit-btn" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Add Product -->
<div class="modal fade" id="add-product-modal" tabindex="-1" aria-labelledby="add-product-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="add-product-modal-label">Add product</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="add-product-form">
<div class="mb-3">
<label for="productName" class="form-label">Product name</label>
<input type="text" class="form-control" id="productName" placeholder="e.g. Milk">
</div>
<div class="row">
<div class="col">
<div class="mb-3">
<label for="productQuantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="productQuantity" placeholder="1" min="1" value="1">
</div>
</div>
<div class="col">
<div class="mb-3">
<label class="form-label">Unit</label>
<div class="btn-group unit-selector" role="group" aria-label="Unit selector">
<button type="button" class="btn btn-secondary unit-btn">g</button>
<button type="button" class="btn btn-outline-secondary unit-btn">kg</button>
<button type="button" class="btn btn-outline-secondary unit-btn">ml</button>
<button type="button" class="btn btn-outline-secondary unit-btn">l</button>
<button type="button" class="btn btn-outline-secondary unit-btn">pcs</button>
<button type="button" class="btn btn-outline-secondary unit-btn">pack</button>
</div>
</div>
</div>
</div>
<div class="mb-3" id="product-category-wrapper">
<label for="productCategory" class="form-label">Category</label>
<select class="form-select" id="productCategory">
<option value="Food" selected>Food</option>
<option value="Drinks">Drinks</option>
<option value="Cooking and serving">Kitchen & serving</option>
<option value="Tableware and consumables">Tableware & consumables</option>
</select>
</div>
<div class="d-grid gap-2 mt-4">
<button type="submit" class="btn btn-primary">Add product</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Modal View Recipe -->
<div class="modal fade" id="view-recipe-modal" tabindex="-1" aria-labelledby="view-recipe-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="view-recipe-modal-label">View recipe</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h2 id="view-recipe-name"></h2>
<p><strong>Category:</strong> <span id="view-recipe-category"></span></p>
<p><strong>Guest count:</strong> <span id="view-recipe-guests"></span></p>
<hr>
<h3>Ingredients</h3>
<ul id="view-recipe-ingredients" class="list-group mb-4">
</ul>
<hr>
<h3>Cooking instructions</h3>
<div id="view-recipe-instructions" class="p-3 bg-light rounded" style="white-space: pre-line;">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<footer class="text-center py-4 mt-5">
<p class="mb-0">&copy; <?php echo date("Y"); ?></p>
<div class="mt-2">
<a href="test.php" class="text-muted text-decoration-none small"><i class="bi bi-bug"></i> Test Playground</a>
</div>
</footer> </footer>
<!-- 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=<?php echo time(); ?>_v13"></script>
<!-- Confirmation Modal -->
<div class="modal fade" id="confirmRemoveModal" tabindex="-1" aria-labelledby="confirmRemoveModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmRemoveModalLabel">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>You are about to remove an ingredient from the recipe. This will modify the recipe. Are you sure?</p>
<p><strong>Recipe:</strong> <span id="modal-recipe-name"></span></p>
<p><strong>Ingredient:</strong> <span id="modal-ingredient-name"></span></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-remove-btn">Delete</button>
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>

198
test.php Normal file
View File

@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Playground FoodieFlow</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com">
<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(); ?>">
<style>
.test-card {
transition: transform 0.2s;
cursor: pointer;
}
.test-card:hover {
transform: translateY(-5px);
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm sticky-top mb-4">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="/">
<i class="bi bi-flow me-2" style="color: var(--brand-primary);"></i>
<span class="fw-bold">FoodieFlow Test Lab</span>
</a>
<div class="ms-auto">
<a href="/" class="btn btn-outline-primary btn-sm">Back to Home</a>
</div>
</div>
</nav>
<main class="container py-5">
<div class="row mb-4">
<div class="col-md-8 mx-auto">
<div class="alert alert-warning border-0 shadow-sm d-flex align-items-center">
<i class="bi bi-exclamation-triangle-fill fs-4 me-3"></i>
<div>
<strong>Attention:</strong> To use image analysis, you must set your OpenAI API Key in <code>ai/keys.php</code>.
Direct communication with OpenAI is enabled to support multimodal features.
</div>
</div>
</div>
</div>
<div class="row mb-5">
<div class="col-12 text-center">
<h1 class="display-4 fw-bold">Test Playground</h1>
<p class="lead text-muted">A place to test new features before they go live.</p>
</div>
</div>
<div class="row g-4">
<!-- AI Testing Section -->
<div class="col-md-8 mx-auto">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="card-title d-flex align-items-center mb-4">
<i class="bi bi-magic me-2 text-primary"></i>
Advanced AI Analyzer
</h5>
<div class="mb-4">
<label class="form-label fw-bold">1. Select Input Source</label>
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="pills-file-tab" data-bs-toggle="pill" data-bs-target="#pills-file" type="button" role="tab">Upload File</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="pills-url-tab" data-bs-toggle="pill" data-bs-target="#pills-url" type="button" role="tab">Image URL</button>
</li>
</ul>
<div class="tab-content" id="pills-tabContent">
<div class="tab-pane fade show active" id="pills-file" role="tabpanel">
<input type="file" id="ai-file-input" class="form-control">
</div>
<div class="tab-pane fade" id="pills-url" role="tabpanel">
<input type="text" id="ai-url-input" class="form-control" placeholder="https://example.com/image.jpg">
</div>
</div>
</div>
<div class="mb-4">
<label class="form-label fw-bold">2. Custom Prompt</label>
<textarea id="ai-prompt" class="form-control" rows="3">Analyze this image. If it's a dish, identify it and provide a recipe in JSON format: { "name": "...", "ingredients": [...] }</textarea>
</div>
<div class="mb-4">
<label class="form-label fw-bold">3. Select Model</label>
<select id="ai-model" class="form-select">
<option value="gpt-4o">GPT-4o (Best for Vision)</option>
<option value="gpt-4o-mini" selected>GPT-4o Mini (Fast & Efficient)</option>
</select>
</div>
<button id="analyze-btn" class="btn btn-primary w-100 py-2 fw-bold">
<i class="bi bi-cpu me-2"></i> Run Analysis
</button>
<div id="ai-results" class="mt-4 d-none">
<h6 class="fw-bold border-bottom pb-2">Analysis Result:</h6>
<div class="mb-3">
<label class="small text-muted">Text Output:</label>
<div class="bg-white border rounded p-3" id="ai-text-output" style="white-space: pre-wrap;"></div>
</div>
<div id="json-section" class="d-none">
<label class="small text-muted">Extracted Data (JSON):</label>
<pre class="bg-dark text-white p-3 rounded" id="ai-json-output"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="text-center py-4 mt-5">
<p class="mb-0 text-muted">&copy; <?php echo date("Y"); ?> FoodieFlow Lab</p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.getElementById('analyze-btn')?.addEventListener('click', async () => {
const fileInput = document.getElementById('ai-file-input');
const urlInput = document.getElementById('ai-url-input');
const promptInput = document.getElementById('ai-prompt');
const modelSelect = document.getElementById('ai-model');
const resultsDiv = document.getElementById('ai-results');
const textOutput = document.getElementById('ai-text-output');
const jsonOutput = document.getElementById('ai-json-output');
const jsonSection = document.getElementById('json-section');
const btn = document.getElementById('analyze-btn');
const originalText = btn.innerHTML;
const isUrl = document.getElementById('pills-url-tab').classList.contains('active');
const formData = new FormData();
formData.append('prompt', promptInput.value);
formData.append('model', modelSelect.value);
if (isUrl) {
if (!urlInput.value) {
alert('Please enter an image URL.');
return;
}
formData.append('image_url', urlInput.value);
} else {
if (!fileInput.files.length) {
alert('Please select an image file.');
return;
}
formData.append('image', fileInput.files[0]);
}
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Processing...';
btn.disabled = true;
resultsDiv.classList.add('d-none');
try {
const response = await fetch('api/ai_analyze.php', {
method: 'POST',
body: formData
});
const data = await response.json();
resultsDiv.classList.remove('d-none');
if (data.success) {
textOutput.textContent = data.text || 'No text output returned.';
if (data.data) {
jsonSection.classList.remove('d-none');
jsonOutput.textContent = JSON.stringify(data.data, null, 2);
} else {
jsonSection.classList.add('d-none');
}
} else {
textOutput.innerHTML = `<span class="text-danger">Error: ${data.error}</span>`;
jsonSection.classList.add('d-none');
}
} catch (error) {
console.error('Error:', error);
textOutput.innerHTML = `<span class="text-danger">Failed to connect to the server.</span>`;
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
});
</script>
</body>
</html>