Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df438b032 | ||
|
|
87c4c5dcc3 | ||
|
|
ea0af5455b | ||
|
|
8cf30205a9 | ||
|
|
a3d98d1256 | ||
|
|
8b8c71261d | ||
|
|
dab8b67300 | ||
|
|
7b0ece6750 | ||
|
|
3830ef477f | ||
|
|
70441c3380 | ||
|
|
10dfaaefb3 | ||
|
|
096fe649f2 | ||
|
|
1e39b8f68a | ||
|
|
763c93513d | ||
|
|
c7834000a3 | ||
|
|
f7763aae7d | ||
|
|
fdf8f1c93d | ||
|
|
afdfe09b22 | ||
|
|
7643a96d2f | ||
|
|
3e6a0105dd | ||
|
|
d7b90335da | ||
|
|
05f4c9c979 | ||
|
|
4ea0efca37 | ||
|
|
e97488f9dc | ||
|
|
e0a3f2a9e8 | ||
|
|
df235d7a3e | ||
|
|
54ae6d2455 | ||
|
|
1d6bd37049 | ||
|
|
976b30c7f2 | ||
|
|
21376a41c6 | ||
|
|
3d551364f5 | ||
|
|
ce386ed1ad | ||
|
|
6e3c890a6f | ||
|
|
ecddcf9d88 | ||
|
|
7368c83e9a | ||
|
|
71ee90fe50 | ||
|
|
d7cd3adc0e | ||
|
|
60e4e3f87d | ||
|
|
32cc8d6cd2 | ||
|
|
24d0513664 | ||
|
|
d39bcc532b |
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
109
ai/CustomOpenAI.php
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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()]);
|
||||
}
|
||||
21
api/save_shopping_list.php
Normal 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
@ -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
@ -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
@ -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
@ -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; }
|
||||
}
|
||||
BIN
assets/images/auth-bg-new.jpg
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
assets/images/auth-bg.jpg
Normal file
|
After Width: | Height: | Size: 509 KiB |
BIN
assets/images/auth-video.mp4
Normal file
16
assets/images/christmas/bauble.svg
Normal 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 |
31
assets/images/christmas/candy-cane.svg
Normal 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 |
6
assets/images/christmas/star.svg
Normal 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 |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
1191
assets/js/main.js
Normal file
BIN
assets/pasted-20251109-213319-755f95ba.png
Normal file
|
After Width: | Height: | Size: 983 KiB |
BIN
assets/pasted-20251109-215032-744d90f1.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/pasted-20251117-200938-7a012c0d.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/pasted-20251123-172354-6ed9c79c.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/pasted-20251123-172752-08f5dba8.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
assets/pasted-20251130-190335-947532cc.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
assets/pasted-20251130-190720-ae17f828.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
assets/pasted-20251130-191602-0ffc27f4.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
assets/pasted-20251130-194432-3a0cbe61.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
assets/vm-shot-2025-11-09T21-33-11-565Z.jpg
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/vm-shot-2025-11-09T21-50-13-547Z.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
assets/vm-shot-2025-11-23T17-18-09-594Z.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
42
db/migrate.php
Normal 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();
|
||||
16
db/migrations/001_create_recipes_tables.sql
Normal 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;
|
||||
1
db/migrations/002_add_category_to_recipes.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `recipes` ADD `category` VARCHAR(255) NULL DEFAULT NULL AFTER `guests`;
|
||||
1
db/migrations/003_add_image_to_recipes.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE recipes ADD COLUMN image_url VARCHAR(255) DEFAULT NULL;
|
||||
1
db/migrations/004_add_category_to_ingredients.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `ingredients` ADD `category` VARCHAR(50) NOT NULL DEFAULT 'food';
|
||||
6
db/migrations/005_create_users_table.sql
Normal 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
|
||||
);
|
||||
2
db/migrations/006_add_user_id_to_recipes.sql
Normal 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;
|
||||
2
db/migrations/007_add_shopping_list_to_users.sql
Normal 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;
|
||||
2
db/migrations/008_add_instructions_to_recipes.sql
Normal 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
@ -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;
|
||||
}
|
||||
675
index.php
@ -1,150 +1,537 @@
|
||||
<?php
|
||||
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>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<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=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--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>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- SEO & Meta Tags -->
|
||||
<title>FoodieFlow — An AI-powered flow from recipe to grocery</title>
|
||||
<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.">
|
||||
<meta name="keywords" content="FoodieFlow, recipe manager, shopping list, AI recipe scanner, cooking organizer, meal planner">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="FoodieFlow — An AI-powered flow from recipe to grocery">
|
||||
<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:image" content="">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FoodieFlow — An AI-powered flow from recipe to grocery">
|
||||
<meta name="twitter:description" content="An AI-powered flow from recipe to grocery — every day, every occasion.">
|
||||
<meta name="twitter:image" content="">
|
||||
|
||||
<!-- Styles -->
|
||||
<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(); ?>_v21">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
</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>
|
||||
|
||||
<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</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>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 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">© <?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>
|
||||
|
||||
<!-- 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>
|
||||
</html>
|
||||
</html>
|
||||
198
test.php
Normal 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">© <?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>
|
||||