From 4cf620a6323509167cf16d9e548c06a0dbfe18fe Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 5 Feb 2026 10:09:44 +0000 Subject: [PATCH] Initial version --- .gitignore | 3 + .perm_test_apache | 0 .perm_test_exec | 0 ai/LocalAIApi.php | 493 ++++++++++++++++++++++++++++++++++++++++++ ai/config.php | 52 +++++ assets/css/custom.css | 346 +++++++++++++++++++++++++++++ assets/js/main.js | 73 +++++++ db/config.php | 17 ++ index.php | 150 +++++++++++++ mail/MailService.php | 235 ++++++++++++++++++++ mail/config.php | 76 +++++++ 11 files changed, 1445 insertions(+) create mode 100644 .gitignore create mode 100644 .perm_test_apache create mode 100644 .perm_test_exec create mode 100644 ai/LocalAIApi.php create mode 100644 ai/config.php create mode 100644 assets/css/custom.css create mode 100644 assets/js/main.js create mode 100644 db/config.php create mode 100644 index.php create mode 100644 mail/MailService.php create mode 100644 mail/config.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e427ff3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*/node_modules/ +*/build/ diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/ai/LocalAIApi.php b/ai/LocalAIApi.php new file mode 100644 index 0000000..d428248 --- /dev/null +++ b/ai/LocalAIApi.php @@ -0,0 +1,493 @@ + [ +// ['role' => 'system', 'content' => 'You are a helpful assistant.'], +// ['role' => 'user', 'content' => 'Tell me a bedtime story.'], +// ], +// ]); +// if (!empty($response['success'])) { +// // response['data'] contains full payload, e.g.: +// // { +// // "id": "resp_xxx", +// // "status": "completed", +// // "output": [ +// // {"type": "reasoning", "summary": []}, +// // {"type": "message", "content": [{"type": "output_text", "text": "Your final answer here."}]} +// // ] +// // } +// $decoded = LocalAIApi::decodeJsonFromResponse($response); // or inspect $response['data'] / extractText(...) +// } +// Poll settings override: +// LocalAIApi::createResponse($payload, ['poll_interval' => 5, 'poll_timeout' => 300]); + +class LocalAIApi +{ + /** @var array|null */ + private static ?array $configCache = null; + + /** + * Signature compatible with the OpenAI Responses API. + * + * @param array $params Request body (model, input, text, reasoning, metadata, etc.). + * @param array $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']; + } + + $initial = self::request($options['path'] ?? null, $payload, $options); + if (empty($initial['success'])) { + return $initial; + } + + // Async flow: if backend returns ai_request_id, poll status until ready + $data = $initial['data'] ?? null; + if (is_array($data) && isset($data['ai_request_id'])) { + $aiRequestId = $data['ai_request_id']; + $pollTimeout = isset($options['poll_timeout']) ? (int) $options['poll_timeout'] : 300; // seconds + $pollInterval = isset($options['poll_interval']) ? (int) $options['poll_interval'] : 5; // seconds + return self::awaitResponse($aiRequestId, [ + 'timeout' => $pollTimeout, + 'interval' => $pollInterval, + 'headers' => $options['headers'] ?? [], + 'timeout_per_call' => $options['timeout'] ?? null, + ]); + } + + return $initial; + } + + /** + * Snake_case alias for createResponse (matches the provided example). + * + * @param array $params + * @param array $options + * @return array + */ + 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 $payload JSON payload. + * @param array $options Additional request options. + * @return array + */ + public static function request(?string $path = null, array $payload = [], array $options = []): array + { + $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 ($body === false) { + return [ + 'success' => false, + 'error' => 'json_encode_failed', + 'message' => 'Failed to encode request body to JSON.', + ]; + } + + return self::sendCurl($url, 'POST', $body, $headers, $timeout, $verifyTls); + } + + /** + * Poll AI request status until ready or timeout. + * + * @param int|string $aiRequestId + * @param array $options + * @return array + */ + public static function awaitResponse($aiRequestId, array $options = []): array + { + $cfg = self::config(); + + $timeout = isset($options['timeout']) ? (int) $options['timeout'] : 300; // seconds + $interval = isset($options['interval']) ? (int) $options['interval'] : 5; // seconds + if ($interval <= 0) { + $interval = 5; + } + $perCallTimeout = isset($options['timeout_per_call']) ? (int) $options['timeout_per_call'] : null; + + $deadline = time() + max($timeout, $interval); + $headers = $options['headers'] ?? []; + + while (true) { + $statusResp = self::fetchStatus($aiRequestId, [ + 'headers' => $headers, + 'timeout' => $perCallTimeout, + ]); + if (!empty($statusResp['success'])) { + $data = $statusResp['data'] ?? []; + if (is_array($data)) { + $statusValue = $data['status'] ?? null; + if ($statusValue === 'success') { + return [ + 'success' => true, + 'status' => 200, + 'data' => $data['response'] ?? $data, + ]; + } + if ($statusValue === 'failed') { + return [ + 'success' => false, + 'status' => 500, + 'error' => isset($data['error']) ? (string)$data['error'] : 'AI request failed', + 'data' => $data, + ]; + } + } + } else { + return $statusResp; + } + + if (time() >= $deadline) { + return [ + 'success' => false, + 'error' => 'timeout', + 'message' => 'Timed out waiting for AI response.', + ]; + } + sleep($interval); + } + } + + /** + * Fetch status for queued AI request. + * + * @param int|string $aiRequestId + * @param array $options + * @return array + */ + public static function fetchStatus($aiRequestId, array $options = []): array + { + $cfg = self::config(); + $projectUuid = $cfg['project_uuid']; + if (empty($projectUuid)) { + return [ + 'success' => false, + 'error' => 'project_uuid_missing', + 'message' => 'PROJECT_UUID is not defined; aborting status check.', + ]; + } + + $statusPath = self::resolveStatusPath($aiRequestId, $cfg); + $url = self::buildUrl($statusPath, $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 = [ + 'Accept: application/json', + $projectHeader . ': ' . $projectUuid, + ]; + if (!empty($options['headers']) && is_array($options['headers'])) { + foreach ($options['headers'] as $header) { + if (is_string($header) && $header !== '') { + $headers[] = $header; + } + } + } + + return self::sendCurl($url, 'GET', null, $headers, $timeout, $verifyTls); + } + + /** + * Extract plain text from a Responses API payload. + * + * @param array $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 (!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 $response + * @return array|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 + */ + 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; + } + + /** + * Resolve status path based on configured responses_path and ai_request_id. + * + * @param int|string $aiRequestId + * @param array $cfg + * @return string + */ + private static function resolveStatusPath($aiRequestId, array $cfg): string + { + $basePath = $cfg['responses_path'] ?? ''; + $trimmed = rtrim($basePath, '/'); + if ($trimmed === '') { + return '/ai-request/' . rawurlencode((string)$aiRequestId) . '/status'; + } + if (substr($trimmed, -11) !== '/ai-request') { + $trimmed .= '/ai-request'; + } + return $trimmed . '/' . rawurlencode((string)$aiRequestId) . '/status'; + } + + /** + * Shared CURL sender for GET/POST requests. + * + * @param string $url + * @param string $method + * @param string|null $body + * @param array $headers + * @param int $timeout + * @param bool $verifyTls + * @return array + */ + private static function sendCurl(string $url, string $method, ?string $body, array $headers, int $timeout, bool $verifyTls): 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.', + ]; + } + + $ch = curl_init($url); + 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); + + $upper = strtoupper($method); + if ($upper === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? ''); + } else { + curl_setopt($ch, CURLOPT_HTTPGET, true); + } + + $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, + ]; + } +} + +// Legacy alias for backward compatibility with the previous class name. +if (!class_exists('OpenAIService')) { + class_alias(LocalAIApi::class, 'OpenAIService'); +} diff --git a/ai/config.php b/ai/config.php new file mode 100644 index 0000000..c890698 --- /dev/null +++ b/ai/config.php @@ -0,0 +1,52 @@ + $baseUrl, + 'responses_path' => $responsesPath, + 'project_id' => $projectId, + 'project_uuid' => $projectUuid, + 'project_header' => 'project-uuid', + 'default_model' => 'gpt-5-mini', + 'timeout' => 30, + 'verify_tls' => true, +]; diff --git a/assets/css/custom.css b/assets/css/custom.css new file mode 100644 index 0000000..65a1626 --- /dev/null +++ b/assets/css/custom.css @@ -0,0 +1,346 @@ +:root { + --color-bg: #ffffff; + --color-text: #1a1a1a; + --color-primary: #2563EB; /* Vibrant Blue */ + --color-secondary: #000000; + --color-accent: #A3E635; /* Lime Green */ + --color-surface: #f8f9fa; + --font-heading: 'Space Grotesk', sans-serif; + --font-body: 'Inter', sans-serif; + --border-width: 2px; + --shadow-hard: 5px 5px 0px #000; + --shadow-hover: 8px 8px 0px #000; + --radius-pill: 50rem; + --radius-card: 1rem; +} + +body { + font-family: var(--font-body); + background-color: var(--color-bg); + color: var(--color-text); + overflow-x: hidden; +} + +h1, h2, h3, h4, h5, h6, .navbar-brand { + font-family: var(--font-heading); + letter-spacing: -0.03em; +} + +/* Utilities */ +.text-primary { color: var(--color-primary) !important; } +.bg-black { background-color: #000 !important; } +.text-white { color: #fff !important; } +.shadow-hard { box-shadow: var(--shadow-hard); } +.border-2-black { border: var(--border-width) solid #000; } +.py-section { padding-top: 5rem; padding-bottom: 5rem; } + +/* Navbar */ +.navbar { + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + border-bottom: var(--border-width) solid transparent; + transition: all 0.3s; + padding-top: 1rem; + padding-bottom: 1rem; +} + +.navbar.scrolled { + border-bottom-color: #000; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.brand-text { + font-size: 1.5rem; + font-weight: 800; +} + +.nav-link { + font-weight: 500; + color: var(--color-text); + margin-left: 1rem; + position: relative; +} + +.nav-link:hover, .nav-link.active { + color: var(--color-primary); +} + +/* Buttons */ +.btn { + font-weight: 700; + font-family: var(--font-heading); + padding: 0.8rem 2rem; + border-radius: var(--radius-pill); + border: var(--border-width) solid #000; + transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1); + box-shadow: var(--shadow-hard); +} + +.btn:hover { + transform: translate(-2px, -2px); + box-shadow: var(--shadow-hover); +} + +.btn:active { + transform: translate(2px, 2px); + box-shadow: 0 0 0 #000; +} + +.btn-primary { + background-color: var(--color-primary); + border-color: #000; + color: #fff; +} + +.btn-primary:hover { + background-color: #1d4ed8; + border-color: #000; + color: #fff; +} + +.btn-outline-dark { + background-color: #fff; + color: #000; +} + +.btn-cta { + background-color: var(--color-accent); + color: #000; +} + +.btn-cta:hover { + background-color: #8cc629; + color: #000; +} + +/* Hero Section */ +.hero-section { + min-height: 100vh; + padding-top: 80px; +} + +.background-blob { + position: absolute; + border-radius: 50%; + filter: blur(80px); + opacity: 0.6; + z-index: 1; +} + +.blob-1 { + top: -10%; + right: -10%; + width: 600px; + height: 600px; + background: radial-gradient(circle, var(--color-accent), transparent); +} + +.blob-2 { + bottom: 10%; + left: -10%; + width: 500px; + height: 500px; + background: radial-gradient(circle, var(--color-primary), transparent); +} + +.highlight-text { + background: linear-gradient(120deg, transparent 0%, transparent 40%, var(--color-accent) 40%, var(--color-accent) 100%); + background-repeat: no-repeat; + background-size: 100% 40%; + background-position: 0 88%; + padding: 0 5px; +} + +.dot { color: var(--color-primary); } + +.badge-pill { + display: inline-block; + padding: 0.5rem 1rem; + border: 2px solid #000; + border-radius: 50px; + font-weight: 700; + background: #fff; + box-shadow: 4px 4px 0 #000; + font-family: var(--font-heading); + font-size: 0.9rem; +} + +/* Marquee */ +.marquee-container { + overflow: hidden; + white-space: nowrap; + border-top: 2px solid #000; + border-bottom: 2px solid #000; +} + +.rotate-divider { + transform: rotate(-2deg) scale(1.05); + z-index: 10; + position: relative; + margin-top: -50px; + margin-bottom: 30px; +} + +.marquee-content { + display: inline-block; + animation: marquee 20s linear infinite; + font-family: var(--font-heading); + font-weight: 700; + font-size: 1.5rem; + letter-spacing: 2px; +} + +@keyframes marquee { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +/* Portfolio Cards */ +.project-card { + border: 2px solid #000; + border-radius: var(--radius-card); + overflow: hidden; + background: #fff; + transition: transform 0.3s ease; + box-shadow: var(--shadow-hard); + height: 100%; + display: flex; + flex-direction: column; +} + +.project-card:hover { + transform: translateY(-10px); + box-shadow: 8px 8px 0 #000; +} + +.card-img-holder { + height: 250px; + display: flex; + align-items: center; + justify-content: center; + border-bottom: 2px solid #000; + position: relative; + font-size: 4rem; +} + +.placeholder-art { + transition: transform 0.3s ease; +} + +.project-card:hover .placeholder-art { + transform: scale(1.2) rotate(10deg); +} + +.bg-soft-blue { background-color: #e0f2fe; } +.bg-soft-green { background-color: #dcfce7; } +.bg-soft-purple { background-color: #f3e8ff; } +.bg-soft-yellow { background-color: #fef9c3; } + +.category-tag { + position: absolute; + top: 15px; + right: 15px; + background: #000; + color: #fff; + padding: 5px 12px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 700; +} + +.card-body { padding: 1.5rem; } + +.link-arrow { + text-decoration: none; + color: #000; + font-weight: 700; + display: inline-flex; + align-items: center; + margin-top: auto; +} + +.link-arrow i { transition: transform 0.2s; margin-left: 5px; } +.link-arrow:hover i { transform: translateX(5px); } + +/* About */ +.about-image-stack { + position: relative; + height: 400px; + width: 100%; +} + +.stack-card { + position: absolute; + width: 80%; + height: 100%; + border-radius: var(--radius-card); + border: 2px solid #000; + box-shadow: var(--shadow-hard); + left: 10%; + transform: rotate(-3deg); + background-size: cover; +} + +/* Forms */ +.form-control { + border: 2px solid #000; + border-radius: 0.5rem; + padding: 1rem; + font-weight: 500; + background: #f8f9fa; +} + +.form-control:focus { + box-shadow: 4px 4px 0 var(--color-primary); + border-color: #000; + background: #fff; +} + +/* Animations */ +.animate-up { + opacity: 0; + transform: translateY(30px); + animation: fadeUp 0.8s ease forwards; +} + +.delay-100 { animation-delay: 0.1s; } +.delay-200 { animation-delay: 0.2s; } + +@keyframes fadeUp { + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Social */ +.social-links a { + transition: transform 0.2s; + display: inline-block; +} +.social-links a:hover { + transform: scale(1.2) rotate(10deg); + color: var(--color-accent) !important; +} + +/* Responsive */ +@media (max-width: 991px) { + .rotate-divider { + transform: rotate(0); + margin-top: 0; + margin-bottom: 2rem; + } + + .hero-section { + padding-top: 120px; + text-align: center; + min-height: auto; + padding-bottom: 100px; + } + + .display-1 { font-size: 3.5rem; } + + .blob-1 { width: 300px; height: 300px; right: -20%; } + .blob-2 { width: 300px; height: 300px; left: -20%; } +} diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..fdf2cfd --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,73 @@ +document.addEventListener('DOMContentLoaded', () => { + + // Smooth scrolling for navigation links + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const targetId = this.getAttribute('href'); + if (targetId === '#') return; + + const targetElement = document.querySelector(targetId); + if (targetElement) { + // Close mobile menu if open + const navbarToggler = document.querySelector('.navbar-toggler'); + const navbarCollapse = document.querySelector('.navbar-collapse'); + if (navbarCollapse.classList.contains('show')) { + navbarToggler.click(); + } + + // Scroll with offset + const offset = 80; + const elementPosition = targetElement.getBoundingClientRect().top; + const offsetPosition = elementPosition + window.pageYOffset - offset; + + window.scrollTo({ + top: offsetPosition, + behavior: "smooth" + }); + } + }); + }); + + // Navbar scroll effect + const navbar = document.querySelector('.navbar'); + window.addEventListener('scroll', () => { + if (window.scrollY > 50) { + navbar.classList.add('scrolled', 'shadow-sm', 'bg-white'); + navbar.classList.remove('bg-transparent'); + } else { + navbar.classList.remove('scrolled', 'shadow-sm', 'bg-white'); + navbar.classList.add('bg-transparent'); + } + }); + + // Intersection Observer for fade-up animations + const observerOptions = { + threshold: 0.1, + rootMargin: "0px 0px -50px 0px" + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('animate-up'); + entry.target.style.opacity = "1"; + observer.unobserve(entry.target); // Only animate once + } + }); + }, observerOptions); + + // Select elements to animate (add a class 'reveal' to them in HTML if not already handled by CSS animation) + // For now, let's just make sure the hero animations run. + // If we want scroll animations, we'd add opacity: 0 to elements in CSS and reveal them here. + // Given the request, the CSS animation I added runs on load for Hero. + // Let's make the project cards animate in. + + const projectCards = document.querySelectorAll('.project-card'); + projectCards.forEach((card, index) => { + card.style.opacity = "0"; + card.style.animationDelay = `${index * 0.1}s`; + observer.observe(card); + }); + +}); \ No newline at end of file diff --git a/db/config.php b/db/config.php new file mode 100644 index 0000000..63ec588 --- /dev/null +++ b/db/config.php @@ -0,0 +1,17 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } + return $pdo; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..7205f3d --- /dev/null +++ b/index.php @@ -0,0 +1,150 @@ + + + + + + + New Style + + + + + + + + + + + + + + + + + + + + + +
+
+

Analyzing your requirements and generating your website…

+
+ Loading… +
+

AI is collecting your requirements and applying the first changes.

+

This page will update automatically as the plan is implemented.

+

Runtime: PHP — UTC

+
+
+
+ Page updated: (UTC) +
+ + diff --git a/mail/MailService.php b/mail/MailService.php new file mode 100644 index 0000000..d801067 --- /dev/null +++ b/mail/MailService.php @@ -0,0 +1,235 @@ + false, 'error' => 'PHPMailer not available' ]; + } + + $mail = new PHPMailer\PHPMailer\PHPMailer(true); + try { + $mail->isSMTP(); + $mail->Host = $cfg['smtp_host'] ?? ''; + $mail->Port = (int)($cfg['smtp_port'] ?? 587); + $secure = $cfg['smtp_secure'] ?? 'tls'; + if ($secure === 'ssl') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; + elseif ($secure === 'tls') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; + else $mail->SMTPSecure = false; + $mail->SMTPAuth = true; + $mail->Username = $cfg['smtp_user'] ?? ''; + $mail->Password = $cfg['smtp_pass'] ?? ''; + + $fromEmail = $opts['from_email'] ?? ($cfg['from_email'] ?? 'no-reply@localhost'); + $fromName = $opts['from_name'] ?? ($cfg['from_name'] ?? 'App'); + $mail->setFrom($fromEmail, $fromName); + if (!empty($opts['reply_to']) && filter_var($opts['reply_to'], FILTER_VALIDATE_EMAIL)) { + $mail->addReplyTo($opts['reply_to']); + } elseif (!empty($cfg['reply_to'])) { + $mail->addReplyTo($cfg['reply_to']); + } + + // Recipients + $toList = []; + if ($to) { + if (is_string($to)) $toList = array_map('trim', explode(',', $to)); + elseif (is_array($to)) $toList = $to; + } elseif (!empty(getenv('MAIL_TO'))) { + $toList = array_map('trim', explode(',', getenv('MAIL_TO'))); + } + $added = 0; + foreach ($toList as $addr) { + if (filter_var($addr, FILTER_VALIDATE_EMAIL)) { $mail->addAddress($addr); $added++; } + } + if ($added === 0) { + return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ]; + } + + foreach ((array)($opts['cc'] ?? []) as $cc) { if (filter_var($cc, FILTER_VALIDATE_EMAIL)) $mail->addCC($cc); } + foreach ((array)($opts['bcc'] ?? []) as $bcc){ if (filter_var($bcc, FILTER_VALIDATE_EMAIL)) $mail->addBCC($bcc); } + + // Optional DKIM + if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) { + $mail->DKIM_domain = $cfg['dkim_domain']; + $mail->DKIM_selector = $cfg['dkim_selector']; + $mail->DKIM_private = $cfg['dkim_private_key_path']; + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlBody; + $mail->AltBody = $textBody ?? strip_tags($htmlBody); + $ok = $mail->send(); + return [ 'success' => $ok ]; + } catch (\Throwable $e) { + return [ 'success' => false, 'error' => 'PHPMailer error: ' . $e->getMessage() ]; + } + } + private static function loadConfig(): array + { + $configPath = __DIR__ . '/config.php'; + if (!file_exists($configPath)) { + throw new \RuntimeException('Mail config not found. Copy mail/config.sample.php to mail/config.php and fill in credentials.'); + } + $cfg = require $configPath; + if (!is_array($cfg)) { + throw new \RuntimeException('Invalid mail config format: expected array'); + } + return $cfg; + } + + // Send a contact message + // $to can be: a single email string, a comma-separated list, an array of emails, or null (fallback to MAIL_TO/MAIL_FROM) + public static function sendContactMessage(string $name, string $email, string $message, $to = null, string $subject = 'New contact form') + { + $cfg = self::loadConfig(); + + // Try Composer autoload if available (for PHPMailer) + $autoload = __DIR__ . '/../vendor/autoload.php'; + if (file_exists($autoload)) { + require_once $autoload; + } + // Fallback to system-wide PHPMailer (installed via apt: libphp-phpmailer) + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + // Debian/Ubuntu package layout (libphp-phpmailer) + @require_once 'libphp-phpmailer/autoload.php'; + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'libphp-phpmailer/src/Exception.php'; + @require_once 'libphp-phpmailer/src/SMTP.php'; + @require_once 'libphp-phpmailer/src/PHPMailer.php'; + } + // Alternative layout (older PHPMailer package names) + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'PHPMailer/src/Exception.php'; + @require_once 'PHPMailer/src/SMTP.php'; + @require_once 'PHPMailer/src/PHPMailer.php'; + } + if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + @require_once 'PHPMailer/Exception.php'; + @require_once 'PHPMailer/SMTP.php'; + @require_once 'PHPMailer/PHPMailer.php'; + } + } + + $transport = $cfg['transport'] ?? 'smtp'; + if ($transport === 'smtp' && class_exists('PHPMailer\\PHPMailer\\PHPMailer')) { + return self::sendViaPHPMailer($cfg, $name, $email, $message, $to, $subject); + } + + // Fallback: attempt native mail() — works only if MTA is configured on the VM + return self::sendViaNativeMail($cfg, $name, $email, $message, $to, $subject); + } + + private static function sendViaPHPMailer(array $cfg, string $name, string $email, string $body, $to, string $subject) + { + $mail = new PHPMailer\PHPMailer\PHPMailer(true); + try { + $mail->isSMTP(); + $mail->Host = $cfg['smtp_host'] ?? ''; + $mail->Port = (int)($cfg['smtp_port'] ?? 587); + $secure = $cfg['smtp_secure'] ?? 'tls'; + if ($secure === 'ssl') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; + elseif ($secure === 'tls') $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS; + else $mail->SMTPSecure = false; + $mail->SMTPAuth = true; + $mail->Username = $cfg['smtp_user'] ?? ''; + $mail->Password = $cfg['smtp_pass'] ?? ''; + + $fromEmail = $cfg['from_email'] ?? 'no-reply@localhost'; + $fromName = $cfg['from_name'] ?? 'App'; + $mail->setFrom($fromEmail, $fromName); + + // Use Reply-To for the user's email to avoid spoofing From + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + $mail->addReplyTo($email, $name ?: $email); + } + if (!empty($cfg['reply_to'])) { + $mail->addReplyTo($cfg['reply_to']); + } + + // Destination: prefer dynamic recipients ($to), fallback to MAIL_TO; no silent FROM fallback + $toList = []; + if ($to) { + if (is_string($to)) { + // allow comma-separated list + $toList = array_map('trim', explode(',', $to)); + } elseif (is_array($to)) { + $toList = $to; + } + } elseif (!empty(getenv('MAIL_TO'))) { + $toList = array_map('trim', explode(',', getenv('MAIL_TO'))); + } + $added = 0; + foreach ($toList as $addr) { + if (filter_var($addr, FILTER_VALIDATE_EMAIL)) { + $mail->addAddress($addr); + $added++; + } + } + if ($added === 0) { + return [ 'success' => false, 'error' => 'No recipients defined (set MAIL_TO or pass $to)' ]; + } + + // DKIM (optional) + if (!empty($cfg['dkim_domain']) && !empty($cfg['dkim_selector']) && !empty($cfg['dkim_private_key_path'])) { + $mail->DKIM_domain = $cfg['dkim_domain']; + $mail->DKIM_selector = $cfg['dkim_selector']; + $mail->DKIM_private = $cfg['dkim_private_key_path']; + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeEmail = htmlspecialchars($email, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $safeBody = nl2br(htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + $mail->Body = "

Name: {$safeName}

Email: {$safeEmail}


{$safeBody}"; + $mail->AltBody = "Name: {$name}\nEmail: {$email}\n\n{$body}"; + + $ok = $mail->send(); + return [ 'success' => $ok ]; + } catch (\Throwable $e) { + return [ 'success' => false, 'error' => 'PHPMailer error: ' . $e->getMessage() ]; + } + } + + private static function sendViaNativeMail(array $cfg, string $name, string $email, string $body, $to, string $subject) + { + $opts = ['reply_to' => $email]; + $html = nl2br(htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); + return self::sendMail($to, $subject, $html, $body, $opts); + } +} diff --git a/mail/config.php b/mail/config.php new file mode 100644 index 0000000..626cca1 --- /dev/null +++ b/mail/config.php @@ -0,0 +1,76 @@ + config array for MailService. + +function env_val(string $key, $default = null) { + $v = getenv($key); + return ($v === false || $v === null || $v === '') ? $default : $v; +} + +// Fallback: if critical vars are missing from process env, try to parse executor/.env +// This helps in web/Apache contexts where .env is not exported. +// Supports simple KEY=VALUE lines; ignores quotes and comments. +function load_dotenv_if_needed(array $keys): void { + $missing = array_filter($keys, fn($k) => getenv($k) === false || getenv($k) === ''); + if (empty($missing)) return; + static $loaded = false; + if ($loaded) return; + $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) { + if ($line[0] === '#' || trim($line) === '') continue; + if (!str_contains($line, '=')) continue; + [$k, $v] = array_map('trim', explode('=', $line, 2)); + // Strip potential surrounding quotes + $v = trim($v, "\"' "); + // Do not override existing env + if ($k !== '' && (getenv($k) === false || getenv($k) === '')) { + putenv("{$k}={$v}"); + } + } + $loaded = true; + } +} + +load_dotenv_if_needed([ + 'MAIL_TRANSPORT','SMTP_HOST','SMTP_PORT','SMTP_SECURE','SMTP_USER','SMTP_PASS', + 'MAIL_FROM','MAIL_FROM_NAME','MAIL_REPLY_TO','MAIL_TO', + 'DKIM_DOMAIN','DKIM_SELECTOR','DKIM_PRIVATE_KEY_PATH' +]); + +$transport = env_val('MAIL_TRANSPORT', 'smtp'); +$smtp_host = env_val('SMTP_HOST'); +$smtp_port = (int) env_val('SMTP_PORT', 587); +$smtp_secure = env_val('SMTP_SECURE', 'tls'); // tls | ssl | null +$smtp_user = env_val('SMTP_USER'); +$smtp_pass = env_val('SMTP_PASS'); + +$from_email = env_val('MAIL_FROM', 'no-reply@localhost'); +$from_name = env_val('MAIL_FROM_NAME', 'App'); +$reply_to = env_val('MAIL_REPLY_TO'); + +$dkim_domain = env_val('DKIM_DOMAIN'); +$dkim_selector = env_val('DKIM_SELECTOR'); +$dkim_private_key_path = env_val('DKIM_PRIVATE_KEY_PATH'); + +return [ + 'transport' => $transport, + + // SMTP + 'smtp_host' => $smtp_host, + 'smtp_port' => $smtp_port, + 'smtp_secure' => $smtp_secure, + 'smtp_user' => $smtp_user, + 'smtp_pass' => $smtp_pass, + + // From / Reply-To + 'from_email' => $from_email, + 'from_name' => $from_name, + 'reply_to' => $reply_to, + + // DKIM (optional) + 'dkim_domain' => $dkim_domain, + 'dkim_selector' => $dkim_selector, + 'dkim_private_key_path' => $dkim_private_key_path, +];