diff --git a/ai/shared.md b/ai/shared.md index 3d0522e..b183a7b 100644 --- a/ai/shared.md +++ b/ai/shared.md @@ -20,6 +20,13 @@ guides (`backend-context.md`, `frontend-context.md`) extend these. batch multiple behaviors into one failing-test commit and one implementation commit when they can be reviewed separately. +## Runtime assumptions + +- Assume every process defined in `process-compose.yml` is already running + for local development and verification unless a command proves otherwise. + Do not start duplicate PostgreSQL, backend, or frontend processes just to + run checks. + ## Code style - Lines should not exceed 80 columns, but should use up to 80 columns when diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php index 1a035c9..6fcbb8c 100644 --- a/backend/app/Controllers/SetController.php +++ b/backend/app/Controllers/SetController.php @@ -2,14 +2,17 @@ namespace App\Controllers; +use App\Element\ElementRepository; use App\Set\Set as DomainSet; use App\Set\SetRepository; use Illuminate\Http\JsonResponse; class SetController { - public function __construct(private SetRepository $setRepository) - { + public function __construct( + private SetRepository $setRepository, + private ElementRepository $elementRepository, + ) { } public function index(): JsonResponse @@ -29,16 +32,20 @@ class SetController * id: int, * name: string, * description: string, - * iconImageUrl: string + * iconImageUrl: string, + * rootElementId: int|null * } */ private function buildSetPayload(DomainSet $set): array { + $rootElement = $this->elementRepository->findRootBySet($set); + return [ 'id' => $set->getId(), 'name' => $set->getName(), 'description' => $set->getDescription(), 'iconImageUrl' => $set->getIconImageUrl(), + 'rootElementId' => $rootElement?->getId(), ]; } } diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index 4f39fb6..8ed13bd 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -10,6 +10,8 @@ interface ElementRepository public function find(int $id): ?Element; + public function findRootBySet(DomainSet $set): ?Element; + /** * @return Element[] */ diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index 61e1433..68452de 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -35,6 +35,16 @@ class EloquentElementRepository implements ElementRepository return $model === null ? null : $this->toDomain($model); } + public function findRootBySet(DomainSet $set): ?Element + { + $model = ElementModel::where('set_id', $set->getId()) + ->whereNull('parent_element_id') + ->orderBy('id') + ->first(); + + return $model === null ? null : $this->toDomain($model); + } + /** * @return Element[] */ diff --git a/backend/database/seeders/SetSeeder.php b/backend/database/seeders/SetSeeder.php index b2ca3b5..a8f28ac 100644 --- a/backend/database/seeders/SetSeeder.php +++ b/backend/database/seeders/SetSeeder.php @@ -11,14 +11,19 @@ class SetSeeder extends Seeder public function run(): void { $setRepository = app(SetRepository::class); - $title = 'Baderech HaAvodah'; + $baderechTitle = 'Baderech HaAvodah'; - $set = $setRepository->create(new CreateSetDto( - name: $title, + $setRepository->create(new CreateSetDto( + name: $baderechTitle, description: 'Baderech HaAvodah is a way of living - ' . 'a structured path for inner and outer growth, ' . 'spiritual refinement, and personal development.', iconImageUrl: '/assets/baderech-haavodah-icon.png', )); + $setRepository->create(new CreateSetDto( + name: 'Daily Learning', + description: 'Daily learning for steady growth', + iconImageUrl: '/assets/daily-learning-icon.svg', + )); } } diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index 65dbcad..cbb4e13 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -37,6 +37,20 @@ class FakeElementRepository implements ElementRepository return $this->cloneElement($this->elementsById[$id]); } + public function findRootBySet(DomainSet $set): ?Element + { + foreach ($this->elementsById as $element) { + if ( + $element->getSet()->getId() === $set->getId() + && $element->getParentElement() === null + ) { + return $this->cloneElement($element); + } + } + + return null; + } + /** * @return Element[] */ diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php index f1ab5cc..6b79a7a 100644 --- a/backend/tests/Feature/SetsEndpointTest.php +++ b/backend/tests/Feature/SetsEndpointTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature; +use App\Element\CreateElementDto; +use App\Element\ElementRepository; use App\Set\CreateSetDto; use App\Set\SetRepository; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -14,6 +16,7 @@ class SetsEndpointTest extends TestCase public function testReturnsAllSets(): void { $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); $baderechSet = $setRepository->create(new CreateSetDto( name: 'Baderech HaAvodah', description: 'Baderech HaAvodah is a way of living', @@ -24,6 +27,13 @@ class SetsEndpointTest extends TestCase description: 'Daily learning for steady growth', iconImageUrl: '/assets/daily-learning-icon.svg', )); + $baderechRootElement = $elementRepository->create( + new CreateElementDto( + set: $baderechSet, + title: $baderechSet->getName(), + parentElement: null, + ) + ); $response = $this->getJson('/api/sets'); @@ -35,12 +45,14 @@ class SetsEndpointTest extends TestCase 'name' => $baderechSet->getName(), 'description' => $baderechSet->getDescription(), 'iconImageUrl' => $baderechSet->getIconImageUrl(), + 'rootElementId' => $baderechRootElement->getId(), ], [ 'id' => $dailyLearningSet->getId(), 'name' => $dailyLearningSet->getName(), 'description' => $dailyLearningSet->getDescription(), 'iconImageUrl' => $dailyLearningSet->getIconImageUrl(), + 'rootElementId' => null, ], ], ]); diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts index 1202ae9..2a093a0 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts @@ -11,8 +11,11 @@ describe('media page sets', () => { cy.contains('h1', 'Torah Media').should('be.visible') cy.get('[data-cy="media-set-grid"]').should('be.visible') cy.get('[data-cy="media-set-card"]', { timeout: 10000 }) - .should('have.length', 1) - .first() + .should('have.length', 2) + + cy.contains('[data-cy="media-set-card"]', 'Baderech HaAvodah') + .as('baderechCard') + .should('have.attr', 'href', '/element/1') .within(() => { cy.get('img[data-cy="media-set-icon"]') .should('be.visible') @@ -22,5 +25,21 @@ describe('media page sets', () => { cy.contains('a structured path for inner and outer growth') .should('be.visible') }) + + cy.contains('[data-cy="media-set-card"]', 'Daily Learning') + .as('dailyLearningCard') + .should('match', 'article') + .and('not.have.attr', 'href') + + cy.get('@dailyLearningCard') + .within(() => { + cy.contains('h2', 'Daily Learning').should('be.visible') + cy.contains('Daily learning for steady growth').should('be.visible') + }) + + cy.get('@baderechCard').click() + cy.location('pathname').should('eq', '/element/1') + cy.get('[data-cy="element-page"]').should('be.visible') + cy.contains('h1', 'Element 1').should('be.visible') }) }) diff --git a/frontend/rabbi_gerzi/src/router/index.ts b/frontend/rabbi_gerzi/src/router/index.ts index 6c71efd..3d5962f 100644 --- a/frontend/rabbi_gerzi/src/router/index.ts +++ b/frontend/rabbi_gerzi/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import HomePage from '@/views/HomePage.vue' import LoginPage from '@/views/LoginPage.vue' import MediaPage from '@/views/MediaPage.vue' +import ElementPage from '@/views/ElementPage.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -21,6 +22,11 @@ const router = createRouter({ name: 'media', component: MediaPage, }, + { + path: '/element/:id', + name: 'element', + component: ElementPage, + }, ], }) diff --git a/frontend/rabbi_gerzi/src/stores/mediaSets.ts b/frontend/rabbi_gerzi/src/stores/mediaSets.ts index 0ed97dd..b899929 100644 --- a/frontend/rabbi_gerzi/src/stores/mediaSets.ts +++ b/frontend/rabbi_gerzi/src/stores/mediaSets.ts @@ -6,6 +6,7 @@ export interface MediaSet { name: string description: string iconImageUrl: string + rootElementId: number | null } interface SetsResponse { diff --git a/frontend/rabbi_gerzi/src/views/ElementPage.vue b/frontend/rabbi_gerzi/src/views/ElementPage.vue new file mode 100644 index 0000000..22191ce --- /dev/null +++ b/frontend/rabbi_gerzi/src/views/ElementPage.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/frontend/rabbi_gerzi/src/views/MediaPage.vue b/frontend/rabbi_gerzi/src/views/MediaPage.vue index 314d3d3..c1880cd 100644 --- a/frontend/rabbi_gerzi/src/views/MediaPage.vue +++ b/frontend/rabbi_gerzi/src/views/MediaPage.vue @@ -33,23 +33,41 @@ onMounted(() => { No media sets are available yet.

-
- -

{{ mediaSet.name }}

-

- {{ mediaSet.description }} -

-
+
@@ -114,7 +132,29 @@ onMounted(() => { background: var(--color-white); border: 1px solid #e5cf9f; border-radius: 17px; + color: inherit; text-align: center; + text-decoration: none; + transition: + border-color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; +} + +a.media-page__card:hover, +a.media-page__card:focus-visible { + border-color: #d4ad5f; + box-shadow: 0 14px 35px rgb(44 44 44 / 10%); + transform: translateY(-2px); +} + +a.media-page__card:focus-visible { + outline: 3px solid rgb(212 173 95 / 45%); + outline-offset: 4px; +} + +.media-page__card--disabled { + opacity: 0.72; } .media-page__card-icon {