Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.94% covered (success)
97.94%
95 / 97
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Media
97.94% covered (success)
97.94%
95 / 97
75.00% covered (warning)
75.00%
6 / 8
23
0.00% covered (danger)
0.00%
0 / 1
 index
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 search
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 picker
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 save
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 delete
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 screen
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 detail
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 localeIds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller\Panel;
6
7use Celema\Core\Exception\HttpNotFound;
8use Celema\Core\Factory\Factory;
9use Celema\Core\Response;
10use Celema\Quma\Database;
11use Cosray\Assets\Asset;
12use Cosray\Assets\Library;
13use Cosray\Assets\Meta;
14use Cosray\Locales;
15use Cosray\Panel\MediaScreen;
16use Cosray\Panel\System;
17use Cosray\References\Usage;
18
19/**
20 * The media screen: the asset catalog as a filterable grid with a detail
21 * inspector. The URL holds the whole state (MediaScreen); htmx asks for a
22 * fragment by the region it swaps: `#media-detail` for the inspector,
23 * `#media-more` for the next page of tiles.
24 */
25final class Media extends Panel
26{
27    protected const string AREA = 'media';
28
29    /** @return array<string, mixed> */
30    public function index(Database $db, Locales $locales): array
31    {
32        $screen = $this->screen();
33
34        if ($this->target() === 'media-detail') {
35            return ['part' => 'detail', ...$this->detail($db, $locales, $screen)];
36        }
37
38        $listing = new Library($db, $this->config)->page(
39            kinds: $screen->kinds,
40            q: $screen->q,
41            since: Library::rangeSince($screen->range),
42            page: $screen->page,
43        );
44
45        if ($this->target() === 'media-more') {
46            return [
47                'part' => 'tiles',
48                'screen' => $screen,
49                'listing' => $listing,
50                'localeId' => $this->localeId(),
51            ];
52        }
53
54        $system = new System($this->config, $locales, $this->csrfToken())->payload();
55
56        return $this->context([
57            'part' => 'page',
58            'screen' => $screen,
59            'listing' => $listing,
60            'detail' => $screen->file === null ? null : $this->detail($db, $locales, $screen),
61            'contentLocales' => $system['locales'],
62            'defaultLocale' => $system['defaultLocale'],
63            'inspectorCollapsed' => $this->request->cookie('cosray_inspector', '') === 'collapsed',
64            // Feeds the window.Cosray bridge, whose toasts report upload failures.
65            'system' => $system,
66            'rail' => true,
67        ]);
68    }
69
70    /**
71     * Assets whose filename matches `q`, as JSON for the menu editor's
72     * asset picker: the listing's first page, narrowed by `kind`.
73     */
74    public function search(Database $db, Factory $factory): Response
75    {
76        $kind = $this->request->param('kind', '');
77        $q = $this->request->param('q', '');
78        $page = new Library($db, $this->config)->page(
79            kinds: Library::filterKinds(is_string($kind) ? $kind : ''),
80            q: is_string($q) ? $q : '',
81        );
82
83        return Response::create($factory)->json([
84            'ok' => true,
85            'assets' => array_map(Library::item(...), $page->assets),
86        ]);
87    }
88
89    /**
90     * The library as controls embed it to pick an asset: a search and the
91     * tile grid, whose tiles carry the asset they stand for. `kind` narrows
92     * the listing, `file` marks the current pick.
93     *
94     * @return array<string, mixed>
95     */
96    public function picker(Database $db): array
97    {
98        $screen = MediaScreen::fromParams($this->panelPath() . '/media/picker', $this->request->params());
99
100        return [
101            'part' => $this->target() === 'media-more' ? 'tiles' : 'picker',
102            'picker' => true,
103            'screen' => $screen,
104            'listing' => new Library($db, $this->config)->page(
105                kinds: $screen->kinds,
106                q: $screen->q,
107                page: $screen->page,
108            ),
109            'localeId' => $this->localeId(),
110        ];
111    }
112
113    /**
114     * Saves the editable meta slice from the detail form: localized alt,
115     * title and caption, the credit line and an image's focal point.
116     *
117     * @return array<string, mixed>
118     */
119    public function save(Database $db, Locales $locales, string $uid): array
120    {
121        $row = $db->assets->byUid(['uid' => $uid])->first();
122
123        if (!$row) {
124            throw new HttpNotFound($this->request);
125        }
126
127        $asset = Asset::fromRow($row, $this->config);
128        $input = $this->formData()['meta'] ?? [];
129        $meta = Meta::apply(
130            $asset->meta,
131            is_array($input) ? $input : [],
132            $this->localeIds($locales),
133            $asset->kind === 'image',
134        );
135        $db->assets->updateMeta(['uid' => $uid, 'meta' => json_encode($meta)])->run();
136
137        return [
138            'part' => 'detail',
139            'saved' => true,
140            ...$this->detail($db, $locales, $this->screen()->withFile($uid)),
141        ];
142    }
143
144    /**
145     * Deletes an unreferenced asset and returns to the listing; an asset in
146     * use stays, and its detail names what still uses it.
147     *
148     * @return array<string, mixed>|Response
149     */
150    public function delete(Database $db, Locales $locales, Factory $factory, string $uid): array|Response
151    {
152        $screen = $this->screen()->withFile($uid);
153        $owners = new Library($db, $this->config)->delete($uid);
154
155        if ($owners === null) {
156            throw new HttpNotFound($this->request);
157        }
158
159        if ($owners !== []) {
160            return ['part' => 'detail', 'blocked' => $owners, ...$this->detail($db, $locales, $screen)];
161        }
162
163        $url = $screen->url(['file' => null]);
164
165        if ($this->request->hasHeader('HX-Request')) {
166            return Response::create($factory)->header(
167                'HX-Location',
168                (string) json_encode(['path' => $url, 'target' => '#main'], JSON_UNESCAPED_SLASHES),
169            );
170        }
171
172        return Response::create($factory)->redirect($url, 303);
173    }
174
175    private function screen(): MediaScreen
176    {
177        return MediaScreen::fromParams($this->panelPath() . '/media', $this->request->params());
178    }
179
180    /**
181     * The inspector's view of the selected file; a file that went away
182     * leaves the asset null.
183     *
184     * @return array<string, mixed>
185     */
186    private function detail(Database $db, Locales $locales, MediaScreen $screen): array
187    {
188        $uid = $screen->file ?? '';
189        $row = $uid === '' ? null : $db->assets->byUid(['uid' => $uid])->first();
190        $system = new System($this->config, $locales, $this->csrfToken())->payload();
191
192        return [
193            'screen' => $screen,
194            'localeId' => $this->localeId(),
195            'asset' => $row ? Asset::fromRow($row, $this->config) : null,
196            'usage' => $row ? new Usage($db)->forAsset($uid) : [],
197            'contentLocales' => $system['locales'],
198            'defaultLocale' => $system['defaultLocale'],
199            'csrfToken' => $system['csrfToken'],
200        ];
201    }
202
203    /** @return list<string> */
204    private function localeIds(Locales $locales): array
205    {
206        $ids = [];
207
208        foreach ($locales as $locale) {
209            $ids[] = $locale->id;
210        }
211
212        return $ids;
213    }
214}