A project with a board now carries its key, its address and the address of the board's public showcase in the public API, so the website can show its roadmap, releases and tickets for newcomers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
6.2 KiB
PHP
162 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Resources\PublicApi;
|
|
|
|
use App\Models\Media;
|
|
use App\Models\Project;
|
|
use App\Services\TaskStream\TaskStreamClient;
|
|
use App\Support\ImagePayload;
|
|
use App\Support\SiteUrls;
|
|
use App\Support\TranslatablePayload;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Resources\Json\JsonResource;
|
|
|
|
/**
|
|
* @mixin Project
|
|
*/
|
|
class PublicProjectResource extends JsonResource
|
|
{
|
|
/**
|
|
* Whether to include the long-form fields. Listings stay light; the detail
|
|
* endpoint opts in.
|
|
*/
|
|
protected bool $detailed = false;
|
|
|
|
public function detailed(bool $detailed = true): static
|
|
{
|
|
$this->detailed = $detailed;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function toArray(Request $request): array
|
|
{
|
|
$locale = app()->getLocale();
|
|
|
|
$summary = TranslatablePayload::resolve($this->resource, ['title', 'slug', 'tagline'], $locale);
|
|
|
|
return [
|
|
'id' => $this->id,
|
|
'locale' => $locale,
|
|
|
|
...$summary,
|
|
|
|
'status' => $this->status->value,
|
|
'is_featured' => $this->is_featured,
|
|
|
|
// True when flagged directly, or when the project carries an open
|
|
// call. Read from a count rather than the relation so a listing
|
|
// does not trigger a query per project.
|
|
'needs_help' => $this->needs_help || $this->openCallCount() > 0,
|
|
// A full image object rather than a path: the frontend needs
|
|
// width and height to reserve the box, a srcset so a phone does
|
|
// not download the original, and the alt text to be accessible.
|
|
'cover_image' => ImagePayload::make(
|
|
$this->cover_image,
|
|
$this->loadedMedia('coverMedia'),
|
|
$this->getTranslation('cover_image_alt', $locale, false) ?: null,
|
|
),
|
|
'demo_url' => $this->demo_url,
|
|
|
|
// Who the work is for. Central to a non-profit collective's story
|
|
// in a way it never is for a commercial portfolio — and now a
|
|
// record of its own, so the same organisation is the same thing on
|
|
// every project it appears in.
|
|
'client' => PublicClientResource::make($this->whenLoaded('client')),
|
|
|
|
'started_at' => $this->started_at?->toDateString(),
|
|
'ended_at' => $this->ended_at?->toDateString(),
|
|
'published_at' => $this->published_at?->toIso8601String(),
|
|
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
|
|
|
// The long form: the decisions and the postmortem are the content
|
|
// other developers actually read, so they are never truncated.
|
|
$this->mergeWhen($this->detailed, fn () => TranslatablePayload::resolve(
|
|
$this->resource,
|
|
['description', 'decisions', 'postmortem', 'meta_title', 'meta_description'],
|
|
$locale,
|
|
)),
|
|
|
|
// Only on the detail endpoint: a listing showing twenty projects
|
|
// has no use for every screenshot of each one.
|
|
'gallery' => $this->when(
|
|
$this->detailed && $this->resource->relationLoaded('images'),
|
|
fn () => $this->images->map(fn ($image) => [
|
|
...ImagePayload::make(
|
|
$image->path,
|
|
$image->relationLoaded('media') ? $image->media : null,
|
|
$image->getTranslation('alt', $locale, false) ?: null,
|
|
),
|
|
|
|
// Shown to everyone, unlike the alt text.
|
|
'caption' => $image->getTranslation('caption', $locale, false) ?: null,
|
|
])->all(),
|
|
),
|
|
|
|
'technologies' => PublicTechnologyResource::collection($this->whenLoaded('technologies')),
|
|
'services' => PublicServiceResource::collection($this->whenLoaded('services')),
|
|
'testimonials' => PublicTestimonialResource::collection($this->whenLoaded('testimonials')),
|
|
'talks' => PublicTalkResource::collection($this->whenLoaded('talks')),
|
|
'members' => PublicMemberResource::collection($this->whenLoaded('members')),
|
|
'lead' => PublicMemberResource::make($this->whenLoaded('lead')),
|
|
'calls' => PublicCallResource::collection($this->whenLoaded('calls')),
|
|
|
|
// Live repository activity — the part that keeps the site looking
|
|
// alive without anyone maintaining it.
|
|
'repositories' => PublicRepositoryResource::collection($this->whenLoaded('repositories')),
|
|
|
|
// Its board in TaskStream, when it has one: the public site reads
|
|
// the roadmap, releases and tickets for newcomers from there.
|
|
'taskstream' => $this->taskstream_key === null ? null : $this->taskstream(),
|
|
|
|
'alternates' => SiteUrls::alternates($this->resource, 'project'),
|
|
'path' => SiteUrls::alternates($this->resource, 'project')[$locale] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{key: string, url: string, web_url: string, showcase_url: string}
|
|
*/
|
|
protected function taskstream(): array
|
|
{
|
|
$client = app(TaskStreamClient::class);
|
|
|
|
return [
|
|
'key' => $this->taskstream_key,
|
|
'url' => $client->projectUrl($this->taskstream_key),
|
|
'web_url' => $client->webUrl(),
|
|
'showcase_url' => $client->showcaseUrl($this->taskstream_key),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The media row when the caller loaded it, and null otherwise — never a
|
|
* lazy load, because this runs once per row in a listing.
|
|
*/
|
|
protected function loadedMedia(string $relation): ?Media
|
|
{
|
|
return $this->resource->relationLoaded($relation)
|
|
? $this->resource->getRelation($relation)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Open calls attached to this project, from whichever source the caller
|
|
* already loaded — never by lazy-loading the relation.
|
|
*/
|
|
protected function openCallCount(): int
|
|
{
|
|
if ($this->resource->relationLoaded('calls')) {
|
|
return $this->calls->count();
|
|
}
|
|
|
|
return (int) ($this->resource->open_calls_count ?? 0);
|
|
}
|
|
}
|