detailed = $detailed; return $this; } /** * @return array */ 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')), 'alternates' => SiteUrls::alternates($this->resource, 'project'), 'path' => SiteUrls::alternates($this->resource, 'project')[$locale] ?? null, ]; } /** * 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); } }