Mediathek mit Ablagefläche, Updateverfahren geplant

Das Auswahlfeld des Browsers sah aus wie das Auswahlfeld des Browsers.
Hochgeladen wird jetzt über eine Ablagefläche: Bilder hineinziehen oder
klicken, darunter steht, welche Dateien gewählt sind und welche übersprungen
wurden.

Darunter liegt weiter ein echtes input[type=file] - unsichtbar, aber
fokussierbar, mit der Fläche als label und dem Fokusring über :focus-within.
Ohne JavaScript bleibt genau das übrig: ein Dateifeld mit Beschriftung. Das
Skript ergänzt Ziehen und benennt die Auswahl; abgeschickt wird nie von
selbst. Kein "required", weil der Browser seine Meldung nicht an etwas
Unsichtbares hängen kann - der Server weist einen leeren Upload mit einem
lesbaren Satz ab.

docs/UPDATE.md plant das Update über einen Git-Tag: Vorprüfung, Sicherung,
Wartungszustand mit 503 für die Website und offenem /admin, Checkout,
composer, Migration, Cache, Rauchtest, Protokoll - und den Rückweg in
derselben Reihenfolge rückwärts. Migrationen bleiben nicht umkehrbar, der
Rückweg ist die Sicherung. Kein Updater, der eine Adresse aus dem Formular
zieht, und kein automatisches Update im Hintergrund. Zwei Entscheidungen
hängen am Hosting: Composer auf dem Server oder vendor/ im Tag, und ob es
Shell-Zugriff gibt. Gebaut ist davon nichts.

219 PHP-Tests / 881 Assertions unter PHP 8.4.22, darunter Upload über das
Formular mit zwei Dateien, CSRF-Pflicht, Versionskonflikt, Archivfilter und
Blättern mit 26 Bildern. Playwright prüft die Ablagefläche am echten
Dateifeld; axe bleibt auf elf Bildschirmen ohne Verstöße.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 18:22:37 +02:00
1 parent 14031a5880
commit 68380c6295
11 files changed
+273 -6

No files matched your search

+88
View File
@@ -5,6 +5,7 @@ namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
@@ -470,6 +471,93 @@ final class EditorSecurityTest extends WebTestCase
}
}
public function testTheMediaScreenUploadsAndEditsWithoutJavaScript(): void
{
$client = $this->login('editor');
$crawler = $client->request('GET', '/admin/media');
self::assertResponseIsSuccessful();
// The images are on the page itself, not behind a button that opens a dialog.
self::assertCount(0, $crawler->filter('.library-tile'));
self::assertCount(1, $crawler->filter('form[action="/admin/media/upload"] input[type="file"]'));
$token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value');
$path = tempnam(sys_get_temp_dir(), 'kant-form-image-');
try {
$image = imagecreatetruecolor(900, 600);
imagepng($image, $path);
imagedestroy($image);
$client->request('POST', '/admin/media/upload', ['_csrf_token' => 'wrong'], ['files' => [new UploadedFile($path, 'ohne-token.png', test: true)]]);
self::assertResponseRedirects('/admin/media');
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
// Two files in one submit, the way the drop area collects them.
$client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [
new UploadedFile($path, 'erstes.png', test: true),
new UploadedFile($path, 'zweites.png', test: true),
]]);
self::assertResponseRedirects('/admin/media');
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
$crawler = $client->followRedirect();
self::assertCount(2, $crawler->filter('.library-tile'));
self::assertCount(1, $crawler->filter('.notice.ok'));
self::assertStringContainsString('2 Bild(er) hochgeladen', $crawler->filter('.notice.ok')->text());
$id = (string) $this->db->fetchOne('SELECT id FROM media LIMIT 1');
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Bergblick', 'alt' => 'Grüne Berglandschaft', 'archived' => '1', 'version' => 1]);
self::assertResponseRedirects('/admin/media');
self::assertSame(['Bergblick', 'Grüne Berglandschaft', 1], array_values($this->db->fetchNumeric('SELECT title, alt, archived FROM media WHERE id = ?', [$id])));
// An archived image is out of the default listing but still delivered, and the archive
// view shows it again.
self::assertCount(1, $client->request('GET', '/admin/media')->filter('.library-tile'));
self::assertCount(2, $client->request('GET', '/admin/media?archiv=1')->filter('.library-tile'));
// A second save with the same version is refused instead of overwriting the newer one.
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Nochmal', 'alt' => '', 'version' => 1]);
self::assertResponseRedirects('/admin/media');
self::assertSame('Bergblick', $this->db->fetchOne('SELECT title FROM media WHERE id = ?', [$id]));
self::assertCount(1, $client->followRedirect()->filter('.notice.error'));
} finally {
unlink($path);
}
}
public function testTheMediaScreenPaginatesAndKeepsTheRequestedPageAfterASave(): void
{
$client = $this->login('editor');
for ($number = 1; $number <= 26; ++$number) {
$this->db->insert('media', [
'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT),
'title' => 'Bild '.$number,
'alt' => '',
'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]),
'archived' => 0,
'version' => 1,
// Ascending dates, so "newest first" puts number 26 on the first page.
'created_at' => gmdate('Y-m-d H:i:s', strtotime('2026-09-01 00:00:00') + $number * 60),
]);
}
$first = $client->request('GET', '/admin/media');
self::assertCount(MediaLibrary::PER_PAGE, $first->filter('.library-tile'));
self::assertStringContainsString('Bild 26', $first->filter('.library-tile')->first()->text());
self::assertCount(1, $first->filter('.pager a[rel="next"]'));
self::assertCount(0, $first->filter('.pager a[rel="prev"]'));
$second = $client->request('GET', '/admin/media?seite=2');
self::assertCount(2, $second->filter('.library-tile'));
self::assertStringContainsString('Bild 2', $second->filter('.library-tile')->first()->text());
// A page beyond the end is clamped instead of showing an empty screen.
self::assertCount(2, $client->request('GET', '/admin/media?seite=99')->filter('.library-tile'));
// Saving from page two comes back to page two.
$token = $second->filter('form[action^="/admin/media/000"] input[name="_csrf_token"]')->first()->attr('value');
$id = str_pad('2', 32, '0', STR_PAD_LEFT);
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Umbenannt', 'alt' => '', 'version' => 1, 'seite' => 2]);
self::assertResponseRedirects('/admin/media?seite=2');
}
public function testPasswordUpgradeRefreshesTheSessionUser(): void
{
$provider = new UserProvider($this->db);