From 135e1ac6820ea53be26a67f457b6fca0c751fecc Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 17 May 2026 21:56:54 +0200 Subject: [PATCH] update .gitignore, enhance README, implement Pexels API integration, and improve page generation logic --- .gitignore | 3 +- Pexels.md | 129 +++++++++++++++++++++++++++ README.md | 5 +- web/generator.php | 145 +++++++++++++++++++++++++++++- web/index.php | 220 ++++++++++++++++++++++++++++------------------ 5 files changed, 411 insertions(+), 91 deletions(-) create mode 100644 Pexels.md diff --git a/.gitignore b/.gitignore index 4655933..b384963 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ vendor pages/*.html -web/previews/*.jpg \ No newline at end of file +web/previews/*.jpg +.env.local \ No newline at end of file diff --git a/Pexels.md b/Pexels.md new file mode 100644 index 0000000..0f6c4df --- /dev/null +++ b/Pexels.md @@ -0,0 +1,129 @@ +--- +name: pexels-api +description: Domain knowledge for the Pexels stock photo and video API. +--- + +# Pexels API + +Pexels provides a large library of high-quality stock photos and videos licensed under the Pexels License (personal and commercial use, no attribution required but appreciated). + +## Data Model + +### Photo Resource +| Field | Type | Description | +|---|---|---| +| `id` | integer | Unique numeric photo identifier | +| `width` | integer | Original width in pixels | +| `height` | integer | Original height in pixels | +| `url` | string | Pexels web page URL for this photo | +| `photographer` | string | Photographer display name | +| `photographer_url` | string | Photographer's Pexels profile URL | +| `photographer_id` | integer | Photographer's numeric ID | +| `avg_color` | string | Hex color (e.g. `#978E82`), useful as image placeholder | +| `alt` | string | Alt text describing the photo | +| `liked` | boolean | Whether the current user liked this photo | +| `src` | object | Pre-sized image URLs (see below) | + +**`src` object sizes:** +| Key | Dimensions | Notes | +|---|---|---| +| `original` | Full resolution | Matches `width`×`height` | +| `large2x` | W 940 × H 650 @2x | Retina-ready | +| `large` | W 940 × H 650 | Standard large | +| `medium` | H 350 (proportional) | Proportionally scaled | +| `small` | H 130 (proportional) | Thumbnail | +| `portrait` | W 800 × H 1200 | Cropped portrait | +| `landscape` | W 1200 × H 627 | Cropped landscape | +| `tiny` | W 280 × H 200 | Smallest preview | + +### Video Resource +| Field | Type | Description | +|---|---|---| +| `id` | integer | Unique numeric video identifier | +| `width` | integer | Original width in pixels | +| `height` | integer | Original height in pixels | +| `url` | string | Pexels web page URL for this video | +| `image` | string | Video screenshot/poster URL | +| `duration` | integer | Duration in seconds | +| `user` | object | Videographer: `id` (int), `name` (str), `url` (str) | +| `video_files` | array | Available quality versions (see below) | +| `video_pictures` | array | Preview thumbnails: `id`, `picture` (URL), `nr` (index) | + +**`video_files` element:** +| Field | Type | Description | +|---|---|---| +| `id` | integer | File variant ID | +| `quality` | string/null | `"hd"`, `"sd"`, `"hls"`, or null | +| `file_type` | string | MIME type (e.g. `video/mp4`) | +| `width` | integer/null | Width (null for HLS) | +| `height` | integer/null | Height (null for HLS) | +| `fps` | number | Frames per second | +| `link` | string | Direct download URL | +| `size` | integer | File size in bytes | + +### Collection Resource +| Field | Type | Description | +|---|---|---| +| `id` | string | Alphanumeric collection identifier (e.g. `"8xntbhr"`) | +| `title` | string | Collection name | +| `description` | string/null | Optional description | +| `private` | boolean | Whether the collection is private | +| `media_count` | integer | Total photos + videos | +| `photos_count` | integer | Number of photos | +| `videos_count` | integer | Number of videos | + +## Entity Relationships + +- A **Photo** belongs to a **Photographer** (via `photographer_id`). +- A **Video** belongs to a **User/Videographer** (via `user.id`). +- A **Collection** contains mixed **Photo** and **Video** objects. Collection media items include an extra `type` field (`"Photo"` or `"Video"`) to discriminate. +- Collections are read-only via API — create/edit on pexels.com, iOS, or Android app. + +## API Endpoints + +### Photos (base: `/v1/`) +| Endpoint | Method | Description | +|---|---|---| +| `/v1/search?query=...` | GET | Full-text search with `orientation`, `size`, `color`, `locale` filters | +| `/v1/curated` | GET | Trending editor-curated photos, updated hourly | +| `/v1/photos/:id` | GET | Single Photo by numeric ID | + +### Videos (base: `/v1/videos/`) +| Endpoint | Method | Description | +|---|---|---| +| `/v1/videos/search?query=...` | GET | Full-text search with `orientation`, `size`, `locale` filters | +| `/v1/videos/popular` | GET | Trending videos with `min_width`, `min_height`, `min_duration`, `max_duration` filters | +| `/v1/videos/videos/:id` | GET | Single Video by numeric ID | + +### Collections (base: `/v1/collections/`) +| Endpoint | Method | Description | +|---|---|---| +| `/v1/collections/featured` | GET | Editor-curated themed collections | +| `/v1/collections` | GET | Your saved collections | +| `/v1/collections/:id` | GET | All media in a collection, filterable by `type` (photos/videos) and `sort` (asc/desc) | + +## Pagination + +All list endpoints accept `page` (default 1) and `per_page` (default 15, max 80). Responses include `page`, `per_page`, `total_results`, and optional `next_page`/`prev_page` URL strings. The `total_results` for search endpoints is capped at a high number (e.g. 8000) even when more results exist. + +## Rate Limits + +- **Default**: 200 requests/hour, 20,000 requests/month. +- **Response headers**: `X-Ratelimit-Limit` (monthly quota), `X-Ratelimit-Remaining`, `X-Ratelimit-Reset` (UNIX timestamp for monthly rollover). +- Rate limit headers are only returned on successful (2xx) responses, not on 429 errors. +- Higher limits available with proper attribution — contact api@pexels.com. + +## Search Tips & Gotchas + +- Queries can be broad ("nature", "people") or specific ("group of people working"). +- **Color filter** (photos only): Named colors (red, orange, yellow, green, turquoise, blue, violet, pink, brown, black, gray, white) or hex codes (e.g. `#ffffff`). +- **Locale** affects search relevance for non-English queries. Supported: en-US, pt-BR, es-ES, ca-ES, de-DE, it-IT, fr-FR, sv-SE, id-ID, pl-PL, ja-JP, zh-TW, zh-CN, ko-KR, th-TH, nl-NL, hu-HU, vi-VN, cs-CZ, da-DK, fi-FI, uk-UA, el-GR, ro-RO, nb-NO, sk-SK, tr-TR, ru-RU. +- **Size filter** means different things: for photos, `large` = 24MP+, `medium` = 12MP+, `small` = 4MP+; for videos, `large` = 4K, `medium` = Full HD, `small` = HD. +- Video `quality` field may be null for newer video files — use `width`/`height` to determine actual resolution. +- Collection IDs are alphanumeric strings (not integers like photo/video IDs). +- The deprecated video base URL `https://api.pexels.com/videos/` still works but should not be used — use `/v1/videos/` instead. + +## Attribution + +Best practice: link to the photo/video page on Pexels and credit the photographer/videographer (e.g. "Photo by John Doe on Pexels"). Use the `url` field from the response for attribution links. + diff --git a/README.md b/README.md index 7bb4cb5..b5e9beb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# AI on request +# Website on Request + +![](https://pictshare.net/gobrfa.png) ## Benchmarks: `lama-server -hf unsloth/gemma-4-E4B-it-GGUF:UD-Q3_K_XL --reasoning off -fa on -ngl 99 -b 2048 -ub 2048 -c 4096 --temp 1.0 --top-p 0.95 --top-k 64` -> 130 t/s - `llama-server -hf unsloth/gemma-4-E2B-it-GGUF:Q4_K_S --reasoning off -fa on -ngl 99 -b 2048 -ub 2048 -c 4096 --temp 1.0 --top-p 0.95 --top-k 64` -> 187 t/s `llama-server -hf unsloth/gemma-4-E2B-it-GGUF:Q3_K_S --reasoning off -fa on -ngl 99 -b 2048 -ub 2048 -c 4096 --temp 1.0 --top-p 0.95 --top-k 64` -> 186 t/s diff --git a/web/generator.php b/web/generator.php index 131421b..79f1c68 100644 --- a/web/generator.php +++ b/web/generator.php @@ -4,6 +4,137 @@ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; +function readLocalEnvValue(string $envFilePath, string $key): string +{ + if (! is_file($envFilePath) || ! is_readable($envFilePath)) { + return ''; + } + + $lines = file($envFilePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return ''; + } + + foreach ($lines as $line) { + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + continue; + } + + $parts = explode('=', $trimmed, 2); + if (count($parts) !== 2) { + continue; + } + + if (trim($parts[0]) !== $key) { + continue; + } + + return trim(trim($parts[1]), "\"'"); + } + + return ''; +} + +function extractHtmlTitle(string $html): string +{ + if (preg_match('/(.*?)<\/title>/is', $html, $matches) === 1) { + return trim(strip_tags($matches[1])); + } + + return ''; +} + +/** + * Fetch one landscape-ish photo from Pexels for the given query. + * + * @return array{url:string,alt:string,photographer:string,photoUrl:string}|null + */ +function fetchPexelsPhoto(string $pexelsKey, string $query): ?array +{ + if ($pexelsKey === '' || $query === '') { + return null; + } + + $endpoint = 'https://api.pexels.com/v1/search?query=' . rawurlencode($query) . '&per_page=1&orientation=landscape&size=large'; + + $raw = false; + + if (function_exists('curl_init')) { + $ch = curl_init($endpoint); + if ($ch !== false) { + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: ' . $pexelsKey, + 'Accept: application/json', + ], + CURLOPT_TIMEOUT => 12, + ]); + $raw = curl_exec($ch); + curl_close($ch); + } + } + + if ($raw === false) { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Authorization: {$pexelsKey}\r\nAccept: application/json\r\n", + 'timeout' => 12, + ], + ]); + + $raw = @file_get_contents($endpoint, false, $context); + } + + if ($raw === false) { + return null; + } + + $json = json_decode($raw, true); + if (! is_array($json) || ! isset($json['photos'][0]) || ! is_array($json['photos'][0])) { + return null; + } + + $photo = $json['photos'][0]; + $src = $photo['src'] ?? []; + $url = (string) ($src['landscape'] ?? $src['large'] ?? $src['original'] ?? ''); + if ($url === '') { + return null; + } + + return [ + 'url' => $url, + 'alt' => trim((string) ($photo['alt'] ?? 'Hero image')), + 'photographer' => trim((string) ($photo['photographer'] ?? 'Pexels photographer')), + 'photoUrl' => trim((string) ($photo['url'] ?? 'https://www.pexels.com/')), + ]; +} + +/** + * Replace hero placeholders with Pexels image metadata. + */ +function injectHeroImagePlaceholders(string $html, ?array $photo): string +{ + $fallbackSvg = rawurlencode('<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="627"><rect width="100%" height="100%" fill="#111827"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#9CA3AF" font-family="Arial, sans-serif" font-size="36">Hero image unavailable</text></svg>'); + $fallbackUrl = 'data:image/svg+xml;utf8,' . $fallbackSvg; + + $imageUrl = $photo['url'] ?? $fallbackUrl; + $imageAlt = $photo['alt'] ?? 'Hero image'; + $photographer = $photo['photographer'] ?? 'Pexels'; + $photoUrl = $photo['photoUrl'] ?? 'https://www.pexels.com/'; + + $replacements = [ + '__PEXELS_HERO_IMAGE__' => htmlspecialchars((string) $imageUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + '__PEXELS_HERO_ALT__' => htmlspecialchars((string) $imageAlt, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + '__PEXELS_PHOTOGRAPHER__' => htmlspecialchars((string) $photographer, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + '__PEXELS_PHOTO_URL__' => htmlspecialchars((string) $photoUrl, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + ]; + + return strtr($html, $replacements); +} + /** * Render a JPG preview for a generated page using a local headless browser. */ @@ -102,6 +233,7 @@ header('Content-Type: text/html; charset=UTF-8'); $startedAt = microtime(true); +$pexelsKey = 'Clyqp9i9pewaxiiAdqVya0yZuuedjHtUtJdjRipNltp7wf0dY8K10h4h'; $apiKey = getenv('LLAMA_API_KEY') ?: ''; $model = getenv('LLAMA_MODEL') ?: 'local-model'; $maxTokens = (int) (getenv('LLAMA_MAX_TOKENS') ?: 4096); @@ -156,7 +288,7 @@ You are a senior creative web designer and front-end engineer. Task: - On every run, first choose a unique page concept with a specific topic, visual theme, and style direction. - Then generate one complete, production-ready HTML document from scratch. -- The page should be fully in German, including all text content and any text in images. +- The page should be fully in English, including all text content and any text in images. Hard output rules: - Return only raw HTML. No markdown. No code fences. No explanations. @@ -166,6 +298,9 @@ Hard output rules: - Inline any custom CSS and JavaScript in <style> and <script> tags. - Make the page responsive and fully usable on mobile and desktop. - Include accessible semantic structure and visible focus states. +- In the hero section, include one main image with: src="__PEXELS_HERO_IMAGE__" and alt="__PEXELS_HERO_ALT__". +- Add a small attribution near that image: "Photo by __PEXELS_PHOTOGRAPHER__ on Pexels" and link to __PEXELS_PHOTO_URL__. +- Do not use other stock-image providers or random external image URLs for the hero image. Creative direction: - Avoid generic templates. @@ -223,6 +358,14 @@ try { throw new RuntimeException('Model did not return a full HTML document.'); } + $imageQuery = $topicHint !== '' ? $topicHint : extractHtmlTitle($html); + if ($imageQuery === '') { + $imageQuery = $selectedCategory; + } + + $pexelsPhoto = fetchPexelsPhoto($pexelsKey, $imageQuery); + $html = injectHeroImagePlaceholders($html, $pexelsPhoto); + $timingBlock = '<div style="position:fixed;right:12px;bottom:12px;z-index:2147483647;background:rgba(17,24,39,.92);color:#fff;padding:8px 10px;border-radius:10px;font:12px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;box-shadow:0 8px 20px rgba(0,0,0,.28)">Generated in ' . $elapsedSeconds . 's</div>'; if (stripos($html, '</body>') !== false) { $html = preg_replace('/<\/body>/i', $timingBlock . '</body>', $html, 1) ?? ($html . $timingBlock); diff --git a/web/index.php b/web/index.php index 259f588..99b12a6 100644 --- a/web/index.php +++ b/web/index.php @@ -2,6 +2,70 @@ declare(strict_types=1); +function collectSeedPages(): array +{ + $pageFiles = glob(__DIR__ . '/../pages/seed-*.html') ?: []; + usort($pageFiles, static fn (string $a, string $b): int => filemtime($b) <=> filemtime($a)); + + $items = []; + foreach ($pageFiles as $pageFile) { + $seed = basename($pageFile, '.html'); + $previewWebPath = '/previews/' . $seed . '.jpg'; + $previewFsPath = __DIR__ . '/previews/' . $seed . '.jpg'; + $hasPreview = file_exists($previewFsPath); + + $title = $seed; + $rawHtml = file_get_contents($pageFile); + if ($rawHtml !== false && preg_match('/<title>(.*?)<\/title>/is', $rawHtml, $matches) === 1) { + $title = trim(strip_tags($matches[1])); + } + + $items[] = [ + 'seed' => $seed, + 'title' => $title, + 'updated' => date('Y-m-d H:i:s', filemtime($pageFile)), + 'hasPreview' => $hasPreview, + 'previewWebPath' => $previewWebPath, + ]; + } + + return $items; +} + +function renderSeedCards(array $items): string +{ + if ($items === []) { + return '<p class="text-zinc-300">No generated seed pages found yet.</p>'; + } + + $html = '<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">'; + foreach ($items as $item) { + $seed = htmlspecialchars((string) $item['seed'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $title = htmlspecialchars((string) $item['title'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $updated = htmlspecialchars((string) $item['updated'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $previewWebPath = htmlspecialchars((string) $item['previewWebPath'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $hasPreview = (bool) $item['hasPreview']; + + $html .= '<a href="/' . $seed . '" class="block rounded-xl overflow-hidden border border-zinc-800 bg-zinc-900/70 hover:border-emerald-500 transition">'; + $html .= '<div class="aspect-[16/10] bg-zinc-800">'; + if ($hasPreview) { + $html .= '<img loading="lazy" src="' . $previewWebPath . '" alt="Preview for ' . $seed . '" class="w-full h-full object-cover">'; + } else { + $html .= '<div class="w-full h-full grid place-items-center text-zinc-400 text-sm">Preview not available yet</div>'; + } + $html .= '</div>'; + $html .= '<div class="p-4">'; + $html .= '<h2 class="text-lg font-semibold mb-1 line-clamp-2">' . $title . '</h2>'; + $html .= '<p class="text-xs text-zinc-400 mb-1">' . $seed . '</p>'; + $html .= '<p class="text-xs text-zinc-500">Updated: ' . $updated . '</p>'; + $html .= '</div></a>'; + } + + $html .= '</div>'; + + return $html; +} + $path = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $segments = array_values(array_filter(explode('/', ltrim($path, '/')))); $firstSegment = $segments[0] ?? ''; @@ -24,11 +88,15 @@ if ($firstSegment === 'generate') { exit; } -if ($firstSegment === 'list') { +if ($firstSegment === 'list-cards') { header('Content-Type: text/html; charset=UTF-8'); + echo renderSeedCards(collectSeedPages()); + exit; +} - $pageFiles = glob(__DIR__ . '/../pages/seed-*.html') ?: []; - usort($pageFiles, static fn (string $a, string $b): int => filemtime($b) <=> filemtime($a)); +if ($firstSegment === '' || $firstSegment === 'list') { + header('Content-Type: text/html; charset=UTF-8'); + $cardsHtml = renderSeedCards(collectSeedPages()); echo '<!doctype html><html lang="en"><head><meta charset="UTF-8">'; echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">'; @@ -36,98 +104,76 @@ if ($firstSegment === 'list') { echo '<script src="https://cdn.tailwindcss.com"></script></head>'; echo '<body class="min-h-screen bg-zinc-950 text-zinc-100 p-6 md:p-10">'; echo '<div class="max-w-7xl mx-auto">'; - echo '<div class="flex flex-wrap items-center justify-between gap-4 mb-8">'; + echo '<div class="flex flex-wrap items-center justify-between gap-4 mb-3">'; echo '<h1 class="text-3xl md:text-4xl font-bold">Generated Seeds</h1>'; - echo '<a href="/" class="px-4 py-2 rounded-md bg-emerald-600 hover:bg-emerald-500 transition text-white font-medium">Generate New</a>'; + echo '<div class="flex flex-wrap items-center gap-3">'; + echo '<button id="generate-btn" type="button" class="px-4 py-2 rounded-md bg-emerald-600 hover:bg-emerald-500 transition text-white font-medium">Generate New</button>'; + echo '<label class="inline-flex items-center gap-2 text-sm text-zinc-200 select-none">'; + echo '<input id="auto-gen-toggle" type="checkbox" class="h-4 w-4 accent-emerald-500">'; + echo '<span>Auto Gen</span>'; + echo '</label>'; + echo '</div>'; + echo '</div>'; + echo '<p id="generation-status" class="text-sm text-zinc-400 mb-6">Idle.</p>'; + echo '<div id="seed-cards">' . $cardsHtml . '</div>'; echo '</div>'; - if ($pageFiles === []) { - echo '<p class="text-zinc-300">No generated seed pages found yet.</p>'; - } else { - echo '<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">'; + echo '<script>'; + echo '(function () {'; + echo 'const generateBtn = document.getElementById("generate-btn");'; + echo 'const autoToggle = document.getElementById("auto-gen-toggle");'; + echo 'const statusEl = document.getElementById("generation-status");'; + echo 'const cardsEl = document.getElementById("seed-cards");'; + echo 'let inFlight = false;'; - foreach ($pageFiles as $pageFile) { - $seed = basename($pageFile, '.html'); - $previewWebPath = '/previews/' . $seed . '.jpg'; - $previewFsPath = __DIR__ . '/previews/' . $seed . '.jpg'; - $hasPreview = file_exists($previewFsPath); + echo 'async function refreshCards() {'; + echo ' const res = await fetch("/list-cards", { cache: "no-store" });'; + echo ' if (!res.ok) throw new Error("Could not refresh list");'; + echo ' cardsEl.innerHTML = await res.text();'; + echo '}'; - $title = $seed; - $rawHtml = file_get_contents($pageFile); - if ($rawHtml !== false && preg_match('/<title>(.*?)<\/title>/is', $rawHtml, $matches) === 1) { - $title = trim(strip_tags($matches[1])); - } + echo 'async function runGeneration() {'; + echo ' if (inFlight) return;'; + echo ' inFlight = true;'; + echo ' generateBtn.disabled = true;'; + echo ' generateBtn.classList.add("opacity-60", "cursor-not-allowed");'; + echo ' statusEl.textContent = "Generating new page...";'; + echo ' try {'; + echo ' const res = await fetch("/generate", { cache: "no-store" });'; + echo ' if (!res.ok) throw new Error("Generation failed");'; + echo ' const redirectTo = res.headers.get("HX-Redirect") || "";'; + echo ' await refreshCards();'; + echo ' statusEl.textContent = redirectTo ? ("Done: " + redirectTo + " (list updated)") : "Done (list updated)";'; + echo ' } catch (err) {'; + echo ' statusEl.textContent = "Generation failed. Check backend logs.";'; + echo ' autoToggle.checked = false;'; + echo ' } finally {'; + echo ' inFlight = false;'; + echo ' generateBtn.disabled = false;'; + echo ' generateBtn.classList.remove("opacity-60", "cursor-not-allowed");'; + echo ' if (autoToggle.checked) {'; + echo ' statusEl.textContent = "Auto Gen enabled. Starting next generation...";'; + echo ' setTimeout(function () { runGeneration(); }, 800);'; + echo ' }'; + echo ' }'; + echo '}'; - $updated = date('Y-m-d H:i:s', filemtime($pageFile)); + echo 'generateBtn.addEventListener("click", function () { runGeneration(); });'; + echo 'autoToggle.addEventListener("change", function () {'; + echo ' if (autoToggle.checked) {'; + echo ' statusEl.textContent = inFlight ? "Auto Gen enabled. Waiting for current run..." : "Auto Gen enabled. Starting generation...";'; + echo ' if (!inFlight) runGeneration();'; + echo ' } else {'; + echo ' statusEl.textContent = inFlight ? "Auto Gen disabled. Current run will finish." : "Auto Gen disabled.";'; + echo ' }'; + echo '});'; - echo '<a href="/' . htmlspecialchars($seed, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" class="block rounded-xl overflow-hidden border border-zinc-800 bg-zinc-900/70 hover:border-emerald-500 transition">'; - echo '<div class="aspect-[16/10] bg-zinc-800">'; - if ($hasPreview) { - echo '<img loading="lazy" src="' . htmlspecialchars($previewWebPath, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" alt="Preview for ' . htmlspecialchars($seed, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" class="w-full h-full object-cover">'; - } else { - echo '<div class="w-full h-full grid place-items-center text-zinc-400 text-sm">Preview not available yet</div>'; - } - echo '</div>'; - echo '<div class="p-4">'; - echo '<h2 class="text-lg font-semibold mb-1 line-clamp-2">' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h2>'; - echo '<p class="text-xs text-zinc-400 mb-1">' . htmlspecialchars($seed, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>'; - echo '<p class="text-xs text-zinc-500">Updated: ' . htmlspecialchars($updated, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>'; - echo '</div></a>'; - } + echo '})();'; + echo '</script>'; - echo '</div>'; - } - - echo '</div></body></html>'; + echo '</body></html>'; exit; } -?> - - -<!doctype html> -<html> - -<head> - <meta charset="UTF-8"> - <title>Generation Error - - - - - -

Page being generated, please wait

-

- Generating content... this should complete in about 30 seconds. -

-
-
-
-

0%

- - - - - \ No newline at end of file +http_response_code(404); +echo 'Not found';