update .gitignore, enhance README, implement Pexels API integration, and improve page generation logic
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
vendor
|
vendor
|
||||||
pages/*.html
|
pages/*.html
|
||||||
web/previews/*.jpg
|
web/previews/*.jpg
|
||||||
|
.env.local
|
||||||
129
Pexels.md
Normal file
129
Pexels.md
Normal file
@@ -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.
|
||||||
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
# AI on request
|
# Website on Request
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Benchmarks:
|
## 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
|
`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: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
|
`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
|
||||||
|
|||||||
@@ -4,6 +4,137 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once __DIR__ . '/../vendor/autoload.php';
|
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>(.*?)<\/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.
|
* 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);
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
|
$pexelsKey = 'Clyqp9i9pewaxiiAdqVya0yZuuedjHtUtJdjRipNltp7wf0dY8K10h4h';
|
||||||
$apiKey = getenv('LLAMA_API_KEY') ?: '';
|
$apiKey = getenv('LLAMA_API_KEY') ?: '';
|
||||||
$model = getenv('LLAMA_MODEL') ?: 'local-model';
|
$model = getenv('LLAMA_MODEL') ?: 'local-model';
|
||||||
$maxTokens = (int) (getenv('LLAMA_MAX_TOKENS') ?: 4096);
|
$maxTokens = (int) (getenv('LLAMA_MAX_TOKENS') ?: 4096);
|
||||||
@@ -156,7 +288,7 @@ You are a senior creative web designer and front-end engineer.
|
|||||||
Task:
|
Task:
|
||||||
- On every run, first choose a unique page concept with a specific topic, visual theme, and style direction.
|
- 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.
|
- 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:
|
Hard output rules:
|
||||||
- Return only raw HTML. No markdown. No code fences. No explanations.
|
- 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.
|
- Inline any custom CSS and JavaScript in <style> and <script> tags.
|
||||||
- Make the page responsive and fully usable on mobile and desktop.
|
- Make the page responsive and fully usable on mobile and desktop.
|
||||||
- Include accessible semantic structure and visible focus states.
|
- 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:
|
Creative direction:
|
||||||
- Avoid generic templates.
|
- Avoid generic templates.
|
||||||
@@ -223,6 +358,14 @@ try {
|
|||||||
throw new RuntimeException('Model did not return a full HTML document.');
|
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>';
|
$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) {
|
if (stripos($html, '</body>') !== false) {
|
||||||
$html = preg_replace('/<\/body>/i', $timingBlock . '</body>', $html, 1) ?? ($html . $timingBlock);
|
$html = preg_replace('/<\/body>/i', $timingBlock . '</body>', $html, 1) ?? ($html . $timingBlock);
|
||||||
|
|||||||
220
web/index.php
220
web/index.php
@@ -2,6 +2,70 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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);
|
$path = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
$segments = array_values(array_filter(explode('/', ltrim($path, '/'))));
|
$segments = array_values(array_filter(explode('/', ltrim($path, '/'))));
|
||||||
$firstSegment = $segments[0] ?? '';
|
$firstSegment = $segments[0] ?? '';
|
||||||
@@ -24,11 +88,15 @@ if ($firstSegment === 'generate') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($firstSegment === 'list') {
|
if ($firstSegment === 'list-cards') {
|
||||||
header('Content-Type: text/html; charset=UTF-8');
|
header('Content-Type: text/html; charset=UTF-8');
|
||||||
|
echo renderSeedCards(collectSeedPages());
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$pageFiles = glob(__DIR__ . '/../pages/seed-*.html') ?: [];
|
if ($firstSegment === '' || $firstSegment === 'list') {
|
||||||
usort($pageFiles, static fn (string $a, string $b): int => filemtime($b) <=> filemtime($a));
|
header('Content-Type: text/html; charset=UTF-8');
|
||||||
|
$cardsHtml = renderSeedCards(collectSeedPages());
|
||||||
|
|
||||||
echo '<!doctype html><html lang="en"><head><meta charset="UTF-8">';
|
echo '<!doctype html><html lang="en"><head><meta charset="UTF-8">';
|
||||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
|
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 '<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 '<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="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 '<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>';
|
echo '</div>';
|
||||||
|
|
||||||
if ($pageFiles === []) {
|
echo '<script>';
|
||||||
echo '<p class="text-zinc-300">No generated seed pages found yet.</p>';
|
echo '(function () {';
|
||||||
} else {
|
echo 'const generateBtn = document.getElementById("generate-btn");';
|
||||||
echo '<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-6">';
|
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) {
|
echo 'async function refreshCards() {';
|
||||||
$seed = basename($pageFile, '.html');
|
echo ' const res = await fetch("/list-cards", { cache: "no-store" });';
|
||||||
$previewWebPath = '/previews/' . $seed . '.jpg';
|
echo ' if (!res.ok) throw new Error("Could not refresh list");';
|
||||||
$previewFsPath = __DIR__ . '/previews/' . $seed . '.jpg';
|
echo ' cardsEl.innerHTML = await res.text();';
|
||||||
$hasPreview = file_exists($previewFsPath);
|
echo '}';
|
||||||
|
|
||||||
$title = $seed;
|
echo 'async function runGeneration() {';
|
||||||
$rawHtml = file_get_contents($pageFile);
|
echo ' if (inFlight) return;';
|
||||||
if ($rawHtml !== false && preg_match('/<title>(.*?)<\/title>/is', $rawHtml, $matches) === 1) {
|
echo ' inFlight = true;';
|
||||||
$title = trim(strip_tags($matches[1]));
|
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 '})();';
|
||||||
echo '<div class="aspect-[16/10] bg-zinc-800">';
|
echo '</script>';
|
||||||
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 '</div>';
|
echo '</body></html>';
|
||||||
}
|
|
||||||
|
|
||||||
echo '</div></body></html>';
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
http_response_code(404);
|
||||||
|
echo 'Not found';
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Generation Error</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="min-h-screen bg-zinc-950 text-zinc-100 p-8" hx-get="/generate" hx-trigger="load">
|
|
||||||
<h1 class="text-2xl font-bold mb-4">Page being generated, please wait</h1>
|
|
||||||
<p class="text-zinc-300 mb-3">
|
|
||||||
Generating content... this should complete in about 30 seconds.
|
|
||||||
</p>
|
|
||||||
<div class="w-full max-w-xl h-3 bg-zinc-800 rounded overflow-hidden mb-2" aria-label="Generation progress">
|
|
||||||
<div id="progress-bar" class="h-full bg-emerald-500 w-0 transition-[width] duration-100"></div>
|
|
||||||
</div>
|
|
||||||
<p id="progress-text" class="text-zinc-400 text-sm mb-3">0%</p>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
const durationMs = 30000;
|
|
||||||
const start = performance.now();
|
|
||||||
const bar = document.getElementById('progress-bar');
|
|
||||||
const text = document.getElementById('progress-text');
|
|
||||||
|
|
||||||
function tick(now) {
|
|
||||||
const elapsed = now - start;
|
|
||||||
const progress = Math.min(elapsed / durationMs, 1);
|
|
||||||
const percent = Math.round(progress * 100);
|
|
||||||
|
|
||||||
bar.style.width = percent + '%';
|
|
||||||
text.textContent = percent + '%';
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user