diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 31c162c..5fc14d7 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -339,8 +339,9 @@ class ElementController 'id' => $element->getId(), 'title' => $element->getTitle(), 'description' => $element->getDescription(), - 'iconImageUrl' => $this->iconImageUrl( - $element->getIconImageUrl() + 'iconImageUrl' => $this->storageUrl( + $element->getIconImageUrl(), + 'element-icons/', ), 'richText' => $element->getRichText(), 'shortPdfPath' => $this->storageUrl( @@ -396,22 +397,6 @@ 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) { diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php index 37b846f..6fcbb8c 100644 --- a/backend/app/Controllers/SetController.php +++ b/backend/app/Controllers/SetController.php @@ -3,23 +3,15 @@ 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, ) { } @@ -35,32 +27,6 @@ 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, @@ -78,77 +44,8 @@ class SetController 'id' => $set->getId(), 'name' => $set->getName(), 'description' => $set->getDescription(), - 'iconImageUrl' => $this->storageUrl( - $set->getIconImageUrl(), - 'set-icons/', - ), + 'iconImageUrl' => $set->getIconImageUrl(), '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 deleted file mode 100644 index 9d9c98d..0000000 --- a/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 166a901..0000000 --- a/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php +++ /dev/null @@ -1,16 +0,0 @@ -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, diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php index 8f4af23..2f8264b 100644 --- a/backend/tests/Feature/SetsEndpointTest.php +++ b/backend/tests/Feature/SetsEndpointTest.php @@ -2,20 +2,11 @@ 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 @@ -72,124 +63,4 @@ 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); - - $elementResponse = $this->getJson("/api/elements/$rootElementId"); - $elementResponse->assertOk(); - $elementResponse->assertJsonPath( - 'element.iconImageUrl', - $body['set']['iconImageUrl'], - ); - } - - 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, - ); - } } diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts index 9aca794..0233789 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts @@ -1,14 +1,3 @@ -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 = @@ -174,35 +163,4 @@ 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') - }) }) diff --git a/frontend/rabbi_gerzi/src/stores/mediaSets.ts b/frontend/rabbi_gerzi/src/stores/mediaSets.ts index e869470..b899929 100644 --- a/frontend/rabbi_gerzi/src/stores/mediaSets.ts +++ b/frontend/rabbi_gerzi/src/stores/mediaSets.ts @@ -9,32 +9,16 @@ 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 @@ -59,49 +43,5 @@ export const useMediaSetsStore = defineStore('mediaSets', () => { } } - 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, - } + return { sets, isLoading, error, fetchSets } }) diff --git a/frontend/rabbi_gerzi/src/views/MediaPage.vue b/frontend/rabbi_gerzi/src/views/MediaPage.vue index 513a329..c1880cd 100644 --- a/frontend/rabbi_gerzi/src/views/MediaPage.vue +++ b/frontend/rabbi_gerzi/src/views/MediaPage.vue @@ -1,121 +1,15 @@ - - @@ -398,205 +183,6 @@ 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; @@ -621,20 +207,5 @@ 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; - } }