From 50df56a0cbfb5331d64ad91a748ab63f868aa79b Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 26 Jun 2026 10:50:21 +0300
Subject: [PATCH 1/6] test set creation endpoint
---
backend/tests/Feature/SetsEndpointTest.php | 122 +++++++++++++++++++++
1 file changed, 122 insertions(+)
diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php
index 2f8264b..74592cc 100644
--- a/backend/tests/Feature/SetsEndpointTest.php
+++ b/backend/tests/Feature/SetsEndpointTest.php
@@ -2,11 +2,20 @@
namespace Tests\Feature;
+use App\Auth\CreateSessionDto;
+use App\Auth\SessionRepository;
use App\Element\CreateElementDto;
use App\Element\ElementRepository;
use App\Set\CreateSetDto;
use App\Set\SetRepository;
+use App\Shared\ValueObject\EmailAddress;
+use App\User\CreateUserDto;
+use App\User\UserRepository;
+use DateTimeImmutable;
+use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class SetsEndpointTest extends TestCase
@@ -63,4 +72,117 @@ class SetsEndpointTest extends TestCase
],
]);
}
+
+ public function testCreateSetRequiresAuthentication(): void
+ {
+ $response = $this->postJson('/api/sets', [
+ 'name' => 'New Series',
+ 'description' => 'A new learning series',
+ ]);
+
+ $response->assertUnauthorized();
+ $response->assertExactJson([
+ 'error' => 'unauthenticated',
+ ]);
+ }
+
+ public function testAuthenticatedCreateSetCreatesRootElement(): void
+ {
+ Storage::fake('public');
+ $setRepository = app(SetRepository::class);
+ $elementRepository = app(ElementRepository::class);
+ $this->createSession('valid-token');
+
+ $response = $this->withCredentials()
+ ->withUnencryptedCookie('auth_token', 'valid-token')
+ ->post('/api/sets', [
+ 'name' => 'New Series',
+ 'description' => 'A new learning series',
+ 'iconImage' => $this->iconImageUpload(),
+ ]);
+
+ $response->assertCreated();
+ $body = $response->json();
+ $setId = $body['set']['id'];
+ $rootElementId = $body['set']['rootElementId'];
+ $this->assertIsInt($setId);
+ $this->assertIsInt($rootElementId);
+ $this->assertSame('New Series', $body['set']['name']);
+ $this->assertSame(
+ 'A new learning series',
+ $body['set']['description'],
+ );
+ $this->assertStringContainsString(
+ '/storage/set-icons/',
+ $body['set']['iconImageUrl'],
+ );
+
+ $createdSet = $setRepository->find($setId);
+ $this->assertNotNull($createdSet);
+ $rootElement = $elementRepository->findRootBySet($createdSet);
+ $this->assertNotNull($rootElement);
+ $this->assertSame($rootElementId, $rootElement->getId());
+ $this->assertSame('New Series', $rootElement->getTitle());
+ $this->assertSame(
+ 'A new learning series',
+ $rootElement->getDescription(),
+ );
+ $storedIconPath = $createdSet->getIconImageUrl();
+ $this->assertStringStartsWith('set-icons/', $storedIconPath);
+ $this->assertSame($storedIconPath, $rootElement->getIconImageUrl());
+ Storage::disk('public')->assertExists($storedIconPath);
+ }
+
+ public function testCreateSetRequiresIconImage(): void
+ {
+ $this->createSession('valid-token');
+
+ $response = $this->withCredentials()
+ ->withUnencryptedCookie('auth_token', 'valid-token')
+ ->post('/api/sets', [
+ 'name' => 'New Series',
+ 'description' => 'A new learning series',
+ ]);
+
+ $response->assertBadRequest();
+ $response->assertExactJson([
+ 'error' => 'icon image is required',
+ ]);
+ }
+
+ private function createSession(string $token): void
+ {
+ $userRepository = app(UserRepository::class);
+ $sessionRepository = app(SessionRepository::class);
+ $user = $userRepository->create(new CreateUserDto(
+ email: new EmailAddress('admin@example.com'),
+ passwordHash: 'password-hash',
+ ));
+ $now = new DateTimeImmutable(
+ '2026-05-01T00:00:00',
+ new DateTimeZone('UTC'),
+ );
+ $sessionRepository->create(new CreateSessionDto(
+ token: $token,
+ user: $user,
+ createdAt: $now,
+ expiresAt: new DateTimeImmutable(
+ '2099-01-01T00:00:00',
+ new DateTimeZone('UTC'),
+ ),
+ ));
+ }
+
+ private function iconImageUpload(): UploadedFile
+ {
+ $fixturePath = __DIR__ . '/../fixtures/icon.png';
+
+ return new UploadedFile(
+ $fixturePath,
+ 'icon.png',
+ 'image/png',
+ null,
+ true,
+ );
+ }
}
From 18073f147f82097abe1bc791972e0bfd28c9924c Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 26 Jun 2026 10:52:00 +0300
Subject: [PATCH 2/6] create set endpoint
---
backend/app/Controllers/SetController.php | 105 ++++++++++++++++-
.../CreateSetWithRoot/CreateSetWithRoot.php | 106 ++++++++++++++++++
.../CreateSetWithRootRequest.php | 16 +++
backend/routes/api.php | 2 +
4 files changed, 228 insertions(+), 1 deletion(-)
create mode 100644 backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php
create mode 100644 backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php
diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php
index 6fcbb8c..37b846f 100644
--- a/backend/app/Controllers/SetController.php
+++ b/backend/app/Controllers/SetController.php
@@ -3,15 +3,23 @@
namespace App\Controllers;
use App\Element\ElementRepository;
+use App\Exceptions\BadRequestException;
use App\Set\Set as DomainSet;
use App\Set\SetRepository;
+use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRoot;
+use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRootRequest;
+use App\Shared\Files\Filesystem;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
class SetController
{
public function __construct(
private SetRepository $setRepository,
private ElementRepository $elementRepository,
+ private CreateSetWithRoot $createSetWithRoot,
+ private Filesystem $filesystem,
) {
}
@@ -27,6 +35,32 @@ class SetController
], 200);
}
+ public function create(Request $request): JsonResponse
+ {
+ $iconImage = $this->uploadedFileInput($request, 'iconImage');
+
+ try {
+ $set = $this->createSetWithRoot->execute(
+ new CreateSetWithRootRequest(
+ name: $this->stringInput($request, 'name'),
+ description: $this->stringInput($request, 'description'),
+ iconImageContents: $iconImage['contents'],
+ iconImageOriginalName: $iconImage['originalName'],
+ iconImageMimeType: $iconImage['mimeType'],
+ iconImageSizeBytes: $iconImage['sizeBytes'],
+ )
+ );
+ } catch (BadRequestException $exception) {
+ return new JsonResponse([
+ 'error' => $exception->getMessage(),
+ ], 400);
+ }
+
+ return new JsonResponse([
+ 'set' => $this->buildSetPayload($set),
+ ], 201);
+ }
+
/**
* @return array{
* id: int,
@@ -44,8 +78,77 @@ class SetController
'id' => $set->getId(),
'name' => $set->getName(),
'description' => $set->getDescription(),
- 'iconImageUrl' => $set->getIconImageUrl(),
+ 'iconImageUrl' => $this->storageUrl(
+ $set->getIconImageUrl(),
+ 'set-icons/',
+ ),
'rootElementId' => $rootElement?->getId(),
];
}
+
+ private function stringInput(Request $request, string $key): ?string
+ {
+ if (! $request->exists($key)) {
+ return null;
+ }
+
+ $value = $request->input($key);
+ if ($value === null) {
+ return '';
+ }
+
+ if (! is_string($value)) {
+ return null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * @return array{
+ * contents: string|null,
+ * originalName: string|null,
+ * mimeType: string|null,
+ * sizeBytes: int|null
+ * }
+ */
+ private function uploadedFileInput(Request $request, string $key): array
+ {
+ $emptyFileInput = [
+ 'contents' => null,
+ 'originalName' => null,
+ 'mimeType' => null,
+ 'sizeBytes' => null,
+ ];
+
+ if (! $request->hasFile($key)) {
+ return $emptyFileInput;
+ }
+
+ $file = $request->file($key);
+ if (! $file instanceof UploadedFile) {
+ return $emptyFileInput;
+ }
+
+ $realPath = $file->getRealPath();
+ if ($realPath === false) {
+ return $emptyFileInput;
+ }
+
+ return [
+ 'contents' => (string) file_get_contents($realPath),
+ 'originalName' => $file->getClientOriginalName(),
+ 'mimeType' => (string) $file->getMimeType(),
+ 'sizeBytes' => (int) $file->getSize(),
+ ];
+ }
+
+ private function storageUrl(string $path, string $folderPrefix): string
+ {
+ if (str_starts_with($path, $folderPrefix)) {
+ return $this->filesystem->url($path);
+ }
+
+ return $path;
+ }
}
diff --git a/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php b/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php
new file mode 100644
index 0000000..9d9c98d
--- /dev/null
+++ b/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php
@@ -0,0 +1,106 @@
+name === null || $request->name === '') {
+ throw new BadRequestException('name is required');
+ }
+ if ($request->description === null || $request->description === '') {
+ throw new BadRequestException('description is required');
+ }
+
+ $iconPath = $this->uploadIconImage($request);
+ $set = $this->setRepository->create(new CreateSetDto(
+ name: $request->name,
+ description: $request->description,
+ iconImageUrl: $iconPath,
+ ));
+
+ $this->elementRepository->create(new CreateElementDto(
+ set: $set,
+ title: $set->getName(),
+ description: $set->getDescription(),
+ iconImageUrl: $iconPath,
+ richText: '',
+ shortPdfPath: null,
+ longPdfPath: null,
+ youtubeUrl: null,
+ parentElement: null,
+ ));
+
+ return $set;
+ }
+
+ /**
+ * @throws BadRequestException
+ */
+ private function uploadIconImage(CreateSetWithRootRequest $request): string
+ {
+ if (
+ $request->iconImageContents === null
+ || $request->iconImageOriginalName === null
+ || $request->iconImageMimeType === null
+ || $request->iconImageSizeBytes === null
+ ) {
+ throw new BadRequestException('icon image is required');
+ }
+
+ if (
+ ! in_array(
+ $request->iconImageMimeType,
+ self::ALLOWED_MIME_TYPES,
+ true,
+ )
+ ) {
+ throw new BadRequestException(
+ 'icon image must be a jpeg, png or webp image',
+ );
+ }
+
+ if ($request->iconImageSizeBytes > self::MAX_SIZE_BYTES) {
+ throw new BadRequestException(
+ 'icon image must be 5MB or smaller',
+ );
+ }
+
+ $iconImage = new FileToUpload(
+ contents: $request->iconImageContents,
+ originalName: $request->iconImageOriginalName,
+ mimeType: $request->iconImageMimeType,
+ sizeBytes: $request->iconImageSizeBytes,
+ );
+
+ return $this->filesystem->upload($iconImage, 'set-icons');
+ }
+}
diff --git a/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php b/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php
new file mode 100644
index 0000000..166a901
--- /dev/null
+++ b/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php
@@ -0,0 +1,16 @@
+middleware(AuthMiddleware::class);
Route::get('/sets', [SetController::class, 'index']);
+Route::post('/sets', [SetController::class, 'create'])
+ ->middleware(AuthMiddleware::class);
Route::get('/elements/{id}', [ElementController::class, 'show']);
Route::get('/element-pdfs/{folder}/{fileName}', [
ElementController::class,
From b3c6f6af0b77c536c20a953c640a751c76924c19 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 26 Jun 2026 10:53:30 +0300
Subject: [PATCH 3/6] test media set creation
---
frontend/rabbi_gerzi/cypress/e2e/media.cy.ts | 42 ++++++++++++++++++++
1 file changed, 42 insertions(+)
diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts
index 0233789..9aca794 100644
--- a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts
+++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts
@@ -1,3 +1,14 @@
+function loginAsAdmin(): void {
+ cy.visit('/login')
+
+ cy.get('[data-cy="login-email"]').type('admin@rabbigerzi.test')
+ cy.get('[data-cy="login-password"]').type('password123!@#')
+ cy.get('[data-cy="login-submit"]').click()
+
+ cy.url().should('eq', Cypress.config().baseUrl + '/')
+ cy.getCookie('auth_token').should('exist')
+}
+
describe('media page sets', () => {
it('fetches and renders seeded set cards', () => {
const introductionAudioTitle =
@@ -163,4 +174,35 @@ describe('media page sets', () => {
.and('include', 'https://www.youtube.com/embed/yHx-r4p6hHU')
.and('include', 'start=1')
})
+
+ it('does not show the create set card to logged-out users', () => {
+ cy.visit('/media')
+
+ cy.get('[data-cy="media-create-set-card"]').should('not.exist')
+ })
+
+ it('creates a set from the logged-in media page modal', () => {
+ loginAsAdmin()
+ cy.visit('/media')
+ cy.intercept('POST', /\/api\/sets$/).as('createSet')
+
+ cy.get('[data-cy="media-create-set-card"]')
+ .should('be.visible')
+ .and('contain.text', 'Create another set')
+ .click()
+ cy.get('[data-cy="create-set-modal"]').should('be.visible')
+ cy.get('[data-cy="create-set-name"]').type('New Cypress Set')
+ cy.get('[data-cy="create-set-description"]')
+ .type('Created through the media page modal')
+ cy.get('[data-cy="create-set-icon"]')
+ .selectFile('public/assets/baderech-haavodah-icon.png')
+ cy.get('[data-cy="create-set-submit"]').click()
+ cy.wait('@createSet')
+
+ cy.location('pathname').should('match', /^\/admin\/element\/\d+$/)
+ cy.get('[data-cy="admin-element-title"]')
+ .should('have.value', 'New Cypress Set')
+ cy.get('[data-cy="admin-element-description"]')
+ .should('have.value', 'Created through the media page modal')
+ })
})
From 47254ea3563d931f9b3002025732d34c11a0d036 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 26 Jun 2026 10:57:25 +0300
Subject: [PATCH 4/6] test root set icon url
---
backend/tests/Feature/SetsEndpointTest.php | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php
index 74592cc..8f4af23 100644
--- a/backend/tests/Feature/SetsEndpointTest.php
+++ b/backend/tests/Feature/SetsEndpointTest.php
@@ -131,6 +131,13 @@ class SetsEndpointTest extends TestCase
$this->assertStringStartsWith('set-icons/', $storedIconPath);
$this->assertSame($storedIconPath, $rootElement->getIconImageUrl());
Storage::disk('public')->assertExists($storedIconPath);
+
+ $elementResponse = $this->getJson("/api/elements/$rootElementId");
+ $elementResponse->assertOk();
+ $elementResponse->assertJsonPath(
+ 'element.iconImageUrl',
+ $body['set']['iconImageUrl'],
+ );
}
public function testCreateSetRequiresIconImage(): void
From 0ec8403d4f10d04312b9411e0189512520c03a9d Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 26 Jun 2026 10:58:04 +0300
Subject: [PATCH 5/6] fix root icon urls
---
backend/app/Controllers/ElementController.php | 21 ++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php
index 5fc14d7..31c162c 100644
--- a/backend/app/Controllers/ElementController.php
+++ b/backend/app/Controllers/ElementController.php
@@ -339,9 +339,8 @@ class ElementController
'id' => $element->getId(),
'title' => $element->getTitle(),
'description' => $element->getDescription(),
- 'iconImageUrl' => $this->storageUrl(
- $element->getIconImageUrl(),
- 'element-icons/',
+ 'iconImageUrl' => $this->iconImageUrl(
+ $element->getIconImageUrl()
),
'richText' => $element->getRichText(),
'shortPdfPath' => $this->storageUrl(
@@ -397,6 +396,22 @@ class ElementController
];
}
+ private function iconImageUrl(?string $path): ?string
+ {
+ if ($path === null) {
+ return null;
+ }
+
+ if (
+ str_starts_with($path, 'element-icons/')
+ || str_starts_with($path, 'set-icons/')
+ ) {
+ return $this->filesystem->url($path);
+ }
+
+ return $path;
+ }
+
private function storageUrl(?string $path, string $folderPrefix): ?string
{
if ($path === null) {
From be1e890330ca6a5fb3c0095695e8316a3603b1e6 Mon Sep 17 00:00:00 2001
From: Yisroel Baum
Date: Fri, 26 Jun 2026 10:58:32 +0300
Subject: [PATCH 6/6] add media create modal
---
frontend/rabbi_gerzi/src/stores/mediaSets.ts | 62 ++-
frontend/rabbi_gerzi/src/views/MediaPage.vue | 435 ++++++++++++++++++-
2 files changed, 493 insertions(+), 4 deletions(-)
diff --git a/frontend/rabbi_gerzi/src/stores/mediaSets.ts b/frontend/rabbi_gerzi/src/stores/mediaSets.ts
index b899929..e869470 100644
--- a/frontend/rabbi_gerzi/src/stores/mediaSets.ts
+++ b/frontend/rabbi_gerzi/src/stores/mediaSets.ts
@@ -9,16 +9,32 @@ export interface MediaSet {
rootElementId: number | null
}
+export interface CreateMediaSetInput {
+ name: string
+ description: string
+ iconImage: File
+}
+
interface SetsResponse {
sets: MediaSet[]
}
+interface CreateSetResponse {
+ set: MediaSet
+}
+
+interface ErrorResponse {
+ error?: string
+}
+
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
export const useMediaSetsStore = defineStore('mediaSets', () => {
const sets = ref([])
const isLoading = ref(false)
const error = ref(null)
+ const isCreating = ref(false)
+ const createError = ref(null)
async function fetchSets(): Promise {
error.value = null
@@ -43,5 +59,49 @@ export const useMediaSetsStore = defineStore('mediaSets', () => {
}
}
- return { sets, isLoading, error, fetchSets }
+ async function createSet(
+ input: CreateMediaSetInput,
+ ): Promise {
+ createError.value = null
+ isCreating.value = true
+
+ const formData = new FormData()
+ formData.append('name', input.name)
+ formData.append('description', input.description)
+ formData.append('iconImage', input.iconImage)
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/sets`, {
+ method: 'POST',
+ credentials: 'include',
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const data: ErrorResponse = await response.json()
+ createError.value = data.error ?? 'Could not create media set'
+ return null
+ }
+
+ const data: CreateSetResponse = await response.json()
+ sets.value = [...sets.value, data.set]
+
+ return data.set
+ } catch {
+ createError.value = 'Network error - could not create media set'
+ return null
+ } finally {
+ isCreating.value = false
+ }
+ }
+
+ return {
+ sets,
+ isLoading,
+ error,
+ isCreating,
+ createError,
+ fetchSets,
+ createSet,
+ }
})
diff --git a/frontend/rabbi_gerzi/src/views/MediaPage.vue b/frontend/rabbi_gerzi/src/views/MediaPage.vue
index c1880cd..513a329 100644
--- a/frontend/rabbi_gerzi/src/views/MediaPage.vue
+++ b/frontend/rabbi_gerzi/src/views/MediaPage.vue
@@ -1,15 +1,121 @@
@@ -29,7 +135,10 @@ onMounted(() => {
>
{{ error }}
-
+
No media sets are available yet.
@@ -68,9 +177,115 @@ onMounted(() => {
+
+
@@ -183,6 +398,205 @@ a.media-page__card:focus-visible {
line-height: 1.38;
}
+.media-page__create-card {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ border-style: dashed;
+ border-color: #b8a16a;
+ background: #fbf8ef;
+ color: var(--color-slate);
+}
+
+.media-page__create-card:hover,
+.media-page__create-card:focus-visible {
+ border-color: #8d7b4e;
+ background: #f7f0db;
+ box-shadow: 0 14px 35px rgb(44 44 44 / 8%);
+ transform: translateY(-2px);
+}
+
+.media-page__create-card:focus-visible {
+ outline: 3px solid rgb(94 107 76 / 35%);
+ outline-offset: 4px;
+}
+
+.media-page__create-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 140px;
+ height: 140px;
+ margin: 0 auto 2.65rem;
+ background: var(--color-white);
+ border: 1px solid #e5cf9f;
+ border-radius: 9999px;
+ color: var(--color-olive);
+}
+
+.media-page__create-title {
+ color: #333333;
+ font-family: var(--font-serif);
+ font-size: clamp(2rem, 3.7vw, 2.4rem);
+ font-weight: 400;
+ line-height: 1.2;
+}
+
+.media-page__create-description {
+ margin-top: 2.15rem;
+ color: var(--color-text-muted);
+ font-family: var(--font-serif);
+ font-size: clamp(1.25rem, 2.3vw, 1.45rem);
+ line-height: 1.38;
+}
+
+.create-set-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ background: rgb(31 31 31 / 46%);
+}
+
+.create-set-modal__panel {
+ width: min(100%, 520px);
+ padding: 1.5rem;
+ background: var(--color-white);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ box-shadow: 0 24px 70px rgb(31 31 31 / 20%);
+}
+
+.create-set-modal__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.create-set-modal__title {
+ margin: 0;
+ color: var(--color-slate);
+ font-family: var(--font-serif);
+ font-size: 1.6rem;
+ font-weight: 400;
+ line-height: 1.2;
+}
+
+.create-set-modal__close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ color: var(--color-slate);
+ background: var(--color-cream);
+ border: 1px solid var(--color-border);
+ border-radius: 9999px;
+}
+
+.create-set-modal__form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ margin-top: 1.5rem;
+}
+
+.create-set-modal__field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.create-set-modal__label {
+ color: var(--color-text-muted);
+ font-size: 0.9rem;
+}
+
+.create-set-modal__input,
+.create-set-modal__textarea {
+ width: 100%;
+ padding: 0.7rem 0.8rem;
+ color: var(--color-text);
+ background: var(--color-white);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ font-family: var(--font-sans);
+ font-size: 0.95rem;
+}
+
+.create-set-modal__textarea {
+ resize: vertical;
+}
+
+.create-set-modal__input:focus,
+.create-set-modal__textarea:focus {
+ border-color: var(--color-olive);
+ outline: 3px solid rgb(94 107 76 / 18%);
+}
+
+.create-set-modal__file {
+ width: 100%;
+ color: var(--color-text);
+ font-size: 0.92rem;
+}
+
+.create-set-modal__file-name {
+ color: var(--color-text-muted);
+ font-size: 0.85rem;
+}
+
+.create-set-modal__error {
+ margin: 0;
+ padding: 0.65rem 0.8rem;
+ color: #7c2d2d;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-radius: 6px;
+ font-size: 0.9rem;
+}
+
+.create-set-modal__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-top: 0.5rem;
+}
+
+.create-set-modal__secondary,
+.create-set-modal__submit {
+ min-width: 7.5rem;
+ padding: 0.7rem 1rem;
+ border-radius: 6px;
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.create-set-modal__secondary {
+ color: var(--color-slate);
+ background: var(--color-white);
+ border: 1px solid var(--color-border);
+}
+
+.create-set-modal__submit {
+ color: var(--color-white);
+ background: var(--color-olive);
+ border: 1px solid var(--color-olive);
+}
+
+.create-set-modal__secondary:disabled,
+.create-set-modal__submit:disabled,
+.create-set-modal__close:disabled {
+ cursor: not-allowed;
+ opacity: 0.65;
+}
+
@media (max-width: 768px) {
.media-page__main {
padding: 3rem 1rem;
@@ -207,5 +621,20 @@ a.media-page__card:focus-visible {
height: 118px;
margin-bottom: 2rem;
}
+
+ .media-page__create-icon {
+ width: 118px;
+ height: 118px;
+ margin-bottom: 2rem;
+ }
+
+ .create-set-modal {
+ align-items: flex-start;
+ padding: 1rem;
+ }
+
+ .create-set-modal__panel {
+ padding: 1.25rem;
+ }
}