feat(demo): add story 1 — Sorano: Rock and Time

This commit is contained in:
2026-06-20 21:19:57 +02:00
parent 42ed59a6b3
commit 8f87155c1d
5508 changed files with 1595740 additions and 124 deletions
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Api\Controllers;
use Grav\Common\Page\Media;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Serializers\MediaSerializer;
use Grav\Plugin\Api\Services\BlueprintPathResolver;
use Grav\Plugin\Api\Services\ThumbnailService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
/**
* Read-only file browse endpoint for blueprint fields that declare a
* `folder:` option (filepicker, mediapicker, …).
*
* Mirrors admin-classic's `taskGetFilesInFolder` semantics — `folder` can be
* any Grav stream (`user://media`, `theme://images`, `account://`, …), a
* `self@:subpath` token resolved against `scope`, or a plain relative path
* confined under `user/`.
*
* The page-attached media case (`@self` / `self@` / empty) is intentionally
* not handled here. The admin-next client already has the page's media via
* `/pages/{route}/media`; rerouting it through this controller would force
* a round-trip for the most common case. Calls with a `@self` literal get
* a 422 sentinel so the client can fall back.
*/
class BlueprintFilesController extends AbstractApiController
{
private ?BlueprintPathResolver $resolver = null;
private ?MediaSerializer $serializer = null;
/**
* GET /blueprint-files?folder=<stream-or-token>&scope=<scope>&accept=<csv>&preview_images=1
*/
public function list(ServerRequestInterface $request): ResponseInterface
{
$this->requirePermission($request, 'api.media.read');
$query = $request->getQueryParams();
$folder = (string)($query['folder'] ?? '');
$scope = (string)($query['scope'] ?? '');
$acceptRaw = (string)($query['accept'] ?? '');
if ($folder === '') {
throw new ValidationException('folder is required.');
}
$resolver = $this->resolver();
$resolver->assertSafe($folder);
// `@self` / `self@` literals are page-media — the client has that already.
if ($resolver->isSelfLiteral($folder)) {
return ApiResponse::create([
'error' => 'PAGE_MEDIA_ONLY',
'message' => 'Use /pages/{route}/media for @self / self@ folders.',
], 422);
}
$abs = $resolver->resolve($folder, $scope, $this->getUser($request));
$logicalFolder = $resolver->logicalParent($folder, $scope);
// Resolve the file list (or empty list when the folder doesn't exist
// yet — common for fresh installs targeting `theme://images` on a
// theme that ships no images).
$items = [];
if (is_dir($abs)) {
$accept = $this->parseAccept($acceptRaw);
foreach ($this->iterateMedia($abs) as $name => $medium) {
if (!$this->matchesAccept((string)$name, (string)($medium->get('mime') ?? ''), $accept)) {
continue;
}
$items[] = $this->serializer()->serialize($medium);
}
}
// Use the paginated envelope (`{ data: [...], meta: { pagination, … } }`)
// even though we don't actually paginate — it matches the shape the
// admin-next client already expects from `/media` and avoids the
// double-wrap that `ApiResponse::create` would impose on a hand-built
// `{ data, meta }` payload.
$total = count($items);
return ApiResponse::paginated(
$items,
$total,
1,
max($total, 1),
$this->getApiBaseUrl() . '/blueprint-files',
200,
[],
[
'folder' => $logicalFolder,
'scope' => $scope !== '' ? $scope : null,
'exists' => is_dir($abs),
],
);
}
/**
* Seam for tests. Yields `filename => Medium` over the given absolute
* directory. Production path delegates to Grav's real Media class.
*/
protected function iterateMedia(string $absoluteDir): iterable
{
return (new Media($absoluteDir))->all();
}
/**
* Parse the comma-separated `accept` query param into an array of
* patterns. Empty input → no filtering.
*/
private function parseAccept(string $raw): array
{
if ($raw === '') return [];
$parts = array_filter(array_map('trim', explode(',', $raw)), static fn($s) => $s !== '');
return array_values($parts);
}
/**
* Mirror admin-classic's accept regex: extension form (`.pdf`, `*.jpg`)
* matches the filename; mime form (`image/png`, `image/*`) matches the
* Grav-detected mime. The `*` / `+` / `.` escaping mirrors
* AdminBaseController::taskFilesUpload.
*
* @param string[] $patterns
*/
private function matchesAccept(string $filename, string $mime, array $patterns): bool
{
if ($patterns === []) return true;
foreach ($patterns as $type) {
if ($type === '*') return true;
$find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type);
$isMime = str_contains($type, '/');
if ($isMime) {
if (preg_match('#' . $find . '$#', $mime)) return true;
} else {
if (preg_match('#' . $find . '$#', $filename)) return true;
}
}
return false;
}
private function resolver(): BlueprintPathResolver
{
return $this->resolver ??= new BlueprintPathResolver($this->grav);
}
private function serializer(): MediaSerializer
{
if (!$this->serializer) {
$cacheDir = $this->grav['locator']->findResource('cache://') . '/api/thumbnails';
$thumb = new ThumbnailService($cacheDir);
$this->serializer = new MediaSerializer($thumb, $this->getApiBaseUrl());
}
return $this->serializer;
}
}