Compare commits
8 commits
90d28deffb
...
14de828b4b
| Author | SHA1 | Date | |
|---|---|---|---|
| 14de828b4b | |||
| 5017186c1a | |||
| f73d7c101a | |||
| 143d5a1be7 | |||
| 57c523dd13 | |||
| 5b33ec5f35 | |||
| 96774d9540 | |||
| eb868679c8 |
18 changed files with 684 additions and 65 deletions
|
|
@ -46,18 +46,15 @@ class ElementController
|
|||
}
|
||||
|
||||
$element = $result->getElement();
|
||||
$childElements = [];
|
||||
foreach ($result->getChildElements() as $childElement) {
|
||||
$childElements[] = [
|
||||
'id' => $childElement->getId(),
|
||||
'title' => $childElement->getTitle(),
|
||||
'description' => $childElement->getDescription(),
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'childElements' => $childElements,
|
||||
'childElements' => $this->buildElementSummaryPayloads(
|
||||
$result->getChildElements(),
|
||||
),
|
||||
'element' => $this->buildElementPayload($element),
|
||||
'siblingElements' => $this->buildElementSummaryPayloads(
|
||||
$result->getSiblingElements(),
|
||||
),
|
||||
], 200);
|
||||
}
|
||||
|
||||
|
|
@ -253,6 +250,28 @@ class ElementController
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Element[] $elements
|
||||
* @return array<int, array{
|
||||
* id: int,
|
||||
* title: string,
|
||||
* description: string,
|
||||
* }>
|
||||
*/
|
||||
private function buildElementSummaryPayloads(array $elements): array
|
||||
{
|
||||
$payloads = [];
|
||||
foreach ($elements as $element) {
|
||||
$payloads[] = [
|
||||
'id' => $element->getId(),
|
||||
'title' => $element->getTitle(),
|
||||
'description' => $element->getDescription(),
|
||||
];
|
||||
}
|
||||
|
||||
return $payloads;
|
||||
}
|
||||
|
||||
private function storageUrl(?string $path, string $folderPrefix): ?string
|
||||
{
|
||||
if ($path === null) {
|
||||
|
|
|
|||
|
|
@ -27,10 +27,19 @@ class GetElement
|
|||
throw new NotFoundException('Element not found');
|
||||
}
|
||||
|
||||
$parentElement = $element->getParentElement();
|
||||
if ($parentElement === null) {
|
||||
$siblingElements = [$element];
|
||||
} else {
|
||||
$siblingElements = $this->elementRepository
|
||||
->findByParentElement($parentElement);
|
||||
}
|
||||
|
||||
return new GetElementResult(
|
||||
element: $element,
|
||||
childElements: $this->elementRepository
|
||||
->findByParentElement($element),
|
||||
siblingElements: $siblingElements,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ class GetElementResult
|
|||
{
|
||||
/**
|
||||
* @param Element[] $childElements
|
||||
* @param Element[] $siblingElements
|
||||
*/
|
||||
public function __construct(
|
||||
private Element $element,
|
||||
private array $childElements,
|
||||
private array $siblingElements,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -27,4 +29,12 @@ class GetElementResult
|
|||
{
|
||||
return $this->childElements;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Element[]
|
||||
*/
|
||||
public function getSiblingElements(): array
|
||||
{
|
||||
return $this->siblingElements;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,74 @@ class ElementsEndpointTest extends TestCase
|
|||
'longPdfPath' => '/assets/pdfs/baderech-long.pdf',
|
||||
'youtubeUrl' => $sampleYoutubeUrl,
|
||||
],
|
||||
'siblingElements' => [
|
||||
[
|
||||
'id' => $element->getId(),
|
||||
'title' => 'Baderech HaAvodah',
|
||||
'description' => 'A structured path for growth',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testReturnsSameParentSiblingElements(): void
|
||||
{
|
||||
$setRepository = app(SetRepository::class);
|
||||
$elementRepository = app(ElementRepository::class);
|
||||
$set = $this->createSet($setRepository);
|
||||
$parentElement = $this->createElement(
|
||||
$elementRepository,
|
||||
$set,
|
||||
'Baderech HaAvodah',
|
||||
'A structured path for growth',
|
||||
null,
|
||||
'',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$firstChildElement = $this->createElement(
|
||||
$elementRepository,
|
||||
$set,
|
||||
'Avodah Foundations',
|
||||
'Foundations for steady avodah',
|
||||
null,
|
||||
'<p>Foundations rich text</p>',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$parentElement,
|
||||
);
|
||||
$secondChildElement = $this->createElement(
|
||||
$elementRepository,
|
||||
$set,
|
||||
'Daily Practice',
|
||||
'Daily practices for growth',
|
||||
null,
|
||||
'',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$parentElement,
|
||||
);
|
||||
|
||||
$response = $this->getJson(
|
||||
"/api/elements/{$firstChildElement->getId()}"
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('siblingElements', [
|
||||
[
|
||||
'id' => $firstChildElement->getId(),
|
||||
'title' => 'Avodah Foundations',
|
||||
'description' => 'Foundations for steady avodah',
|
||||
],
|
||||
[
|
||||
'id' => $secondChildElement->getId(),
|
||||
'title' => 'Daily Practice',
|
||||
'description' => 'Daily practices for growth',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,68 @@ class ElementControllerTest extends TestCase
|
|||
'description' => 'Daily practices for growth',
|
||||
],
|
||||
], $body['childElements']);
|
||||
$this->assertSame([
|
||||
[
|
||||
'id' => $element->getId(),
|
||||
'title' => 'Baderech HaAvodah',
|
||||
'description' => 'A structured path for growth',
|
||||
],
|
||||
], $body['siblingElements']);
|
||||
}
|
||||
|
||||
public function testShowReturnsSameParentSiblingPayload(): void
|
||||
{
|
||||
$set = $this->createSet(1, 'Baderech');
|
||||
$parentElement = $this->createElement(
|
||||
$set,
|
||||
'Baderech HaAvodah',
|
||||
'A structured path for growth',
|
||||
null,
|
||||
'',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$firstChildElement = $this->createElement(
|
||||
$set,
|
||||
'Avodah Foundations',
|
||||
'Foundations for steady avodah',
|
||||
null,
|
||||
'<p>Foundations rich text</p>',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$parentElement,
|
||||
);
|
||||
$secondChildElement = $this->createElement(
|
||||
$set,
|
||||
'Daily Practice',
|
||||
'Daily practices for growth',
|
||||
null,
|
||||
'',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$parentElement,
|
||||
);
|
||||
|
||||
$response = $this->controller->show($firstChildElement->getId());
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$body = json_decode($response->getContent(), true);
|
||||
$this->assertSame([
|
||||
[
|
||||
'id' => $firstChildElement->getId(),
|
||||
'title' => 'Avodah Foundations',
|
||||
'description' => 'Foundations for steady avodah',
|
||||
],
|
||||
[
|
||||
'id' => $secondChildElement->getId(),
|
||||
'title' => 'Daily Practice',
|
||||
'description' => 'Daily practices for growth',
|
||||
],
|
||||
], $body['siblingElements']);
|
||||
}
|
||||
|
||||
public function testShowReturns400WhenIdMissing(): void
|
||||
|
|
|
|||
|
|
@ -159,6 +159,134 @@ class GetElementTest extends TestCase
|
|||
);
|
||||
}
|
||||
|
||||
public function testReturnsSiblingElementsWithSameParent(): void
|
||||
{
|
||||
$set = $this->createSet(1, 'Baderech');
|
||||
$parentElement = $this->createElement(
|
||||
$set,
|
||||
'Baderech HaAvodah',
|
||||
'A structured path for growth',
|
||||
null,
|
||||
'<p>A structured path for growth</p>',
|
||||
'/assets/pdfs/baderech.pdf',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$firstChildElement = $this->createElement(
|
||||
$set,
|
||||
'Avodah Foundations',
|
||||
'Foundations for steady avodah',
|
||||
null,
|
||||
'<p>Foundations rich text</p>',
|
||||
'/assets/pdfs/foundations.pdf',
|
||||
null,
|
||||
$parentElement,
|
||||
);
|
||||
$secondChildElement = $this->createElement(
|
||||
$set,
|
||||
'Daily Practice',
|
||||
'Daily practices for growth',
|
||||
null,
|
||||
'<p>Daily practice rich text</p>',
|
||||
null,
|
||||
null,
|
||||
$parentElement,
|
||||
);
|
||||
$this->createElement(
|
||||
$set,
|
||||
'Nested Practice',
|
||||
'Nested description',
|
||||
null,
|
||||
'<p>Nested rich text</p>',
|
||||
null,
|
||||
null,
|
||||
$firstChildElement,
|
||||
);
|
||||
$otherSet = $this->createSet(2, 'Daily Learning');
|
||||
$otherParentElement = $this->createElement(
|
||||
$otherSet,
|
||||
'Other Parent',
|
||||
'Other parent description',
|
||||
null,
|
||||
'<p>Other parent rich text</p>',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$this->createElement(
|
||||
$otherSet,
|
||||
'Other Child',
|
||||
'Other child description',
|
||||
null,
|
||||
'<p>Other child rich text</p>',
|
||||
null,
|
||||
null,
|
||||
$otherParentElement,
|
||||
);
|
||||
|
||||
$result = $this->getElement->execute(new GetElementRequest(
|
||||
id: $firstChildElement->getId(),
|
||||
));
|
||||
$siblingElements = $result->getSiblingElements();
|
||||
|
||||
$this->assertCount(2, $siblingElements);
|
||||
$this->assertSame(
|
||||
$firstChildElement->getId(),
|
||||
$siblingElements[0]->getId(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'Avodah Foundations',
|
||||
$siblingElements[0]->getTitle(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'Foundations for steady avodah',
|
||||
$siblingElements[0]->getDescription(),
|
||||
);
|
||||
$this->assertSame(
|
||||
$secondChildElement->getId(),
|
||||
$siblingElements[1]->getId(),
|
||||
);
|
||||
$this->assertSame('Daily Practice', $siblingElements[1]->getTitle());
|
||||
$this->assertSame(
|
||||
'Daily practices for growth',
|
||||
$siblingElements[1]->getDescription(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testReturnsRootElementAsOnlySibling(): void
|
||||
{
|
||||
$set = $this->createSet(1, 'Baderech');
|
||||
$rootElement = $this->createElement(
|
||||
$set,
|
||||
'Baderech HaAvodah',
|
||||
'A structured path for growth',
|
||||
null,
|
||||
'<p>A structured path for growth</p>',
|
||||
'/assets/pdfs/baderech.pdf',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$this->createElement(
|
||||
$set,
|
||||
'Avodah Foundations',
|
||||
'Foundations for steady avodah',
|
||||
null,
|
||||
'<p>Foundations rich text</p>',
|
||||
'/assets/pdfs/foundations.pdf',
|
||||
null,
|
||||
$rootElement,
|
||||
);
|
||||
|
||||
$result = $this->getElement->execute(new GetElementRequest(
|
||||
id: $rootElement->getId(),
|
||||
));
|
||||
$siblingElements = $result->getSiblingElements();
|
||||
|
||||
$this->assertCount(1, $siblingElements);
|
||||
$this->assertSame($rootElement->getId(), $siblingElements[0]->getId());
|
||||
$this->assertSame('Baderech HaAvodah', $siblingElements[0]->getTitle());
|
||||
}
|
||||
|
||||
public function testThrowsWhenIdMissing(): void
|
||||
{
|
||||
$this->expectException(BadRequestException::class);
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ indent_style = space
|
|||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
max_line_length = 80
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 80,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
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('element sibling navigation', () => {
|
||||
it('navigates public same-parent elements with wraparound', () => {
|
||||
cy.visit('/element/1')
|
||||
cy.contains('h1', 'Baderech HaAvodah').should('be.visible')
|
||||
cy.get('[data-cy="element-sibling-navigation"]').should('not.exist')
|
||||
|
||||
cy.visit('/element/2')
|
||||
cy.contains('h1', '1. Introduction').should('be.visible')
|
||||
cy.get('[data-cy="child-element-list"]').then((childList) => {
|
||||
cy.get('[data-cy="element-sibling-navigation"]').then(
|
||||
(siblingNavigation) => {
|
||||
expect(
|
||||
childList[0].compareDocumentPosition(siblingNavigation[0])
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).to.not.equal(0)
|
||||
},
|
||||
)
|
||||
})
|
||||
cy.get('[data-cy="element-sibling-navigation"]').should('be.visible')
|
||||
cy.get('[data-cy="element-sibling-previous"]')
|
||||
.should('have.attr', 'href', '/element/7')
|
||||
.click()
|
||||
|
||||
cy.location('pathname').should('eq', '/element/7')
|
||||
cy.contains('h1', '6. Fluid Integration').should('be.visible')
|
||||
cy.get('[data-cy="element-sibling-next"]')
|
||||
.should('have.attr', 'href', '/element/2')
|
||||
.click()
|
||||
|
||||
cy.location('pathname').should('eq', '/element/2')
|
||||
cy.contains('h1', '1. Introduction').should('be.visible')
|
||||
cy.get('[data-cy="element-sibling-next"]')
|
||||
.should('have.attr', 'href', '/element/3')
|
||||
.click()
|
||||
|
||||
cy.location('pathname').should('eq', '/element/3')
|
||||
cy.contains('h1', '2. Foundations').should('be.visible')
|
||||
})
|
||||
|
||||
it('navigates admin same-parent elements with wraparound', () => {
|
||||
loginAsAdmin()
|
||||
|
||||
cy.visit('/admin/element/1')
|
||||
cy.get('[data-cy="admin-element-title"]')
|
||||
.should('have.value', 'Baderech HaAvodah')
|
||||
cy.get('[data-cy="admin-sibling-navigation"]').should('not.exist')
|
||||
|
||||
cy.visit('/admin/element/2')
|
||||
cy.get('[data-cy="admin-element-title"]')
|
||||
.should('have.value', '1. Introduction')
|
||||
cy.get('[data-cy="admin-element-save"]').then((saveButton) => {
|
||||
cy.get('[data-cy="admin-sibling-navigation"]').then(
|
||||
(siblingNavigation) => {
|
||||
expect(
|
||||
saveButton[0].compareDocumentPosition(siblingNavigation[0])
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).to.not.equal(0)
|
||||
},
|
||||
)
|
||||
})
|
||||
cy.get('[data-cy="admin-sibling-navigation"]').should('be.visible')
|
||||
cy.get('[data-cy="admin-sibling-previous"]')
|
||||
.should('have.attr', 'href', '/admin/element/7')
|
||||
.click()
|
||||
|
||||
cy.location('pathname').should('eq', '/admin/element/7')
|
||||
cy.get('[data-cy="admin-element-title"]')
|
||||
.should('have.value', '6. Fluid Integration')
|
||||
cy.get('[data-cy="admin-sibling-next"]')
|
||||
.should('have.attr', 'href', '/admin/element/2')
|
||||
.click()
|
||||
|
||||
cy.location('pathname').should('eq', '/admin/element/2')
|
||||
cy.get('[data-cy="admin-element-title"]')
|
||||
.should('have.value', '1. Introduction')
|
||||
cy.get('[data-cy="admin-sibling-next"]')
|
||||
.should('have.attr', 'href', '/admin/element/3')
|
||||
.click()
|
||||
|
||||
cy.location('pathname').should('eq', '/admin/element/3')
|
||||
cy.get('[data-cy="admin-element-title"]')
|
||||
.should('have.value', '2. Foundations')
|
||||
})
|
||||
})
|
||||
138
frontend/rabbi_gerzi/src/components/ElementSiblingNavigation.vue
Normal file
138
frontend/rabbi_gerzi/src/components/ElementSiblingNavigation.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
} from 'lucide-vue-next'
|
||||
import type { ChildElement } from '@/stores/elements'
|
||||
|
||||
type ElementRouteName = 'element' | 'admin-element'
|
||||
type ElementNavigationPrefix = 'element' | 'admin'
|
||||
|
||||
interface Props {
|
||||
currentElementId: number
|
||||
dataCyPrefix: ElementNavigationPrefix
|
||||
routeName: ElementRouteName
|
||||
siblingElements: ChildElement[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const currentSiblingIndex = computed(() => {
|
||||
return props.siblingElements.findIndex((siblingElement) => {
|
||||
return siblingElement.id === props.currentElementId
|
||||
})
|
||||
})
|
||||
|
||||
const hasSiblingNavigation = computed(() => {
|
||||
return props.siblingElements.length > 1 && currentSiblingIndex.value !== -1
|
||||
})
|
||||
|
||||
const previousSibling = computed(() => {
|
||||
return siblingElementAtOffset(-1)
|
||||
})
|
||||
|
||||
const nextSibling = computed(() => {
|
||||
return siblingElementAtOffset(1)
|
||||
})
|
||||
|
||||
const navigationDataCy = computed(() => {
|
||||
return `${props.dataCyPrefix}-sibling-navigation`
|
||||
})
|
||||
|
||||
const previousDataCy = computed(() => {
|
||||
return `${props.dataCyPrefix}-sibling-previous`
|
||||
})
|
||||
|
||||
const nextDataCy = computed(() => {
|
||||
return `${props.dataCyPrefix}-sibling-next`
|
||||
})
|
||||
|
||||
function siblingElementAtOffset(offset: number): ChildElement | null {
|
||||
if (!hasSiblingNavigation.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const siblingCount = props.siblingElements.length
|
||||
const targetIndex =
|
||||
(currentSiblingIndex.value + offset + siblingCount) % siblingCount
|
||||
|
||||
return props.siblingElements[targetIndex] ?? null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
v-if="
|
||||
hasSiblingNavigation && previousSibling !== null && nextSibling !== null
|
||||
"
|
||||
class="element-sibling-navigation"
|
||||
:data-cy="navigationDataCy"
|
||||
aria-label="Sibling elements"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: routeName,
|
||||
params: { id: previousSibling.id },
|
||||
}"
|
||||
class="element-sibling-navigation__link"
|
||||
:data-cy="previousDataCy"
|
||||
>
|
||||
<ChevronLeftIcon :size="18" aria-hidden="true" />
|
||||
<span>Previous</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: routeName,
|
||||
params: { id: nextSibling.id },
|
||||
}"
|
||||
class="element-sibling-navigation__link"
|
||||
:data-cy="nextDataCy"
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRightIcon :size="18" aria-hidden="true" />
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.element-sibling-navigation {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.35rem 0 0;
|
||||
}
|
||||
|
||||
.element-sibling-navigation__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.55rem 0.95rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-white);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 180ms ease,
|
||||
color 180ms ease;
|
||||
}
|
||||
|
||||
.element-sibling-navigation__link:hover,
|
||||
.element-sibling-navigation__link:focus-visible {
|
||||
border-color: var(--color-slate);
|
||||
color: var(--color-slate);
|
||||
}
|
||||
|
||||
.element-sibling-navigation__link:focus-visible {
|
||||
outline: 3px solid rgb(61 78 93 / 24%);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -73,5 +73,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
}
|
||||
}
|
||||
|
||||
return { user, loginError, isSubmitting, isAuthenticated, login, fetchUser, logout }
|
||||
return {
|
||||
user,
|
||||
loginError,
|
||||
isSubmitting,
|
||||
isAuthenticated,
|
||||
login,
|
||||
fetchUser,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type ElementFileType = 'iconImage' | 'shortPdf' | 'longPdf'
|
|||
interface ElementResponse {
|
||||
element: Element
|
||||
childElements: ChildElement[]
|
||||
siblingElements: ChildElement[]
|
||||
}
|
||||
|
||||
export interface UpdateElementInput {
|
||||
|
|
@ -58,6 +59,7 @@ const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update`
|
|||
export const useElementsStore = defineStore('elements', () => {
|
||||
const element = ref<Element | null>(null)
|
||||
const childElements = ref<ChildElement[]>([])
|
||||
const siblingElements = ref<ChildElement[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSaving = ref(false)
|
||||
|
|
@ -71,6 +73,7 @@ export const useElementsStore = defineStore('elements', () => {
|
|||
async function fetchElement(elementId: string): Promise<void> {
|
||||
element.value = null
|
||||
childElements.value = []
|
||||
siblingElements.value = []
|
||||
error.value = null
|
||||
saveError.value = null
|
||||
childActionError.value = null
|
||||
|
|
@ -94,8 +97,10 @@ export const useElementsStore = defineStore('elements', () => {
|
|||
const data: ElementResponse = await response.json()
|
||||
element.value = data.element
|
||||
childElements.value = data.childElements
|
||||
siblingElements.value = data.siblingElements
|
||||
} catch {
|
||||
childElements.value = []
|
||||
siblingElements.value = []
|
||||
error.value = 'Network error - could not load element'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
|
@ -153,8 +158,8 @@ export const useElementsStore = defineStore('elements', () => {
|
|||
childElementId.toString(),
|
||||
)
|
||||
const response = await fetch(
|
||||
`${ELEMENTS_URL}/${encodedParentElementId}`
|
||||
+ `/children/${encodedChildElementId}`,
|
||||
`${ELEMENTS_URL}/${encodedParentElementId}` +
|
||||
`/children/${encodedChildElementId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
|
|
@ -388,6 +393,7 @@ export const useElementsStore = defineStore('elements', () => {
|
|||
return {
|
||||
element,
|
||||
childElements,
|
||||
siblingElements,
|
||||
isLoading,
|
||||
error,
|
||||
isSaving,
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ const teachers: Teacher[] = [
|
|||
},
|
||||
{
|
||||
name: 'Rabbi Efraim Greenblatt',
|
||||
description: 'Halachic authority and loyal student of Rabbi Moshe Feinstein.',
|
||||
description:
|
||||
'Halachic authority and loyal student of Rabbi Moshe Feinstein.',
|
||||
},
|
||||
{
|
||||
name: 'The Sassover Rebbe, The Biala Rebbe, and Rabbi Mordechai Zukerman',
|
||||
|
|
@ -230,8 +231,8 @@ function submitNewsletter(submitEvent: Event): void {
|
|||
<p class="about-hero__eyebrow">Pilzno Institute</p>
|
||||
<h1 class="about-hero__heading">About Rabbi Gerzi</h1>
|
||||
<p class="about-hero__lede">
|
||||
Healthy, Integrated and Balanced Torah living, rooted in mesorah and made practical
|
||||
for real people building real lives.
|
||||
Healthy, Integrated and Balanced Torah living, rooted in mesorah
|
||||
and made practical for real people building real lives.
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
|
|
@ -261,25 +262,32 @@ function submitNewsletter(submitEvent: Event): void {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section about-section--slate" data-cy="about-vision">
|
||||
<section
|
||||
class="about-section about-section--slate"
|
||||
data-cy="about-vision"
|
||||
>
|
||||
<div class="about-section__inner about-section__inner--narrow">
|
||||
<p class="about-section__kicker">Vision</p>
|
||||
<h2 class="about-section__heading">Rabbi Gerzi's Vision</h2>
|
||||
<p class="about-section__paragraph">
|
||||
Rabbi Gerzi guides individuals from vagueness to clarity, from surviving to thriving,
|
||||
from a galut to a geula mindset. Through profound teachings rooted in Torah wisdom,
|
||||
Rabbi Gerzi aims to connect students with their divine purpose, discover balance, and
|
||||
Rabbi Gerzi guides individuals from vagueness to clarity, from
|
||||
surviving to thriving, from a galut to a geula mindset. Through
|
||||
profound teachings rooted in Torah wisdom, Rabbi Gerzi aims to
|
||||
connect students with their divine purpose, discover balance, and
|
||||
live a life of authentic joy and fulfillment.
|
||||
</p>
|
||||
<p class="about-section__paragraph">
|
||||
As spiritual growth can only be accomplished within a fertile social framework, Rabbi
|
||||
Gerzi hosts regular weekly chaburot and various events that create the conditions for
|
||||
authentic life transformation.
|
||||
As spiritual growth can only be accomplished within a fertile social
|
||||
framework, Rabbi Gerzi hosts regular weekly chaburot and various
|
||||
events that create the conditions for authentic life transformation.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section about-section--cream" data-cy="about-mission">
|
||||
<section
|
||||
class="about-section about-section--cream"
|
||||
data-cy="about-mission"
|
||||
>
|
||||
<div class="about-section__inner">
|
||||
<div class="about-section__header">
|
||||
<p class="about-section__kicker">Mission</p>
|
||||
|
|
@ -287,9 +295,10 @@ function submitNewsletter(submitEvent: Event): void {
|
|||
The Mission: To Provide a Clear Path to Wholesome Living
|
||||
</h2>
|
||||
<p class="about-section__intro">
|
||||
Rabbi Gerzi provides a structured path to wisdom, personal growth, and transformation.
|
||||
His educational system is designed to guide individuals through essential life areas,
|
||||
creating not just intellectual understanding but genuine change.
|
||||
Rabbi Gerzi provides a structured path to wisdom, personal growth,
|
||||
and transformation. His educational system is designed to guide
|
||||
individuals through essential life areas, creating not just
|
||||
intellectual understanding but genuine change.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -318,27 +327,35 @@ function submitNewsletter(submitEvent: Event): void {
|
|||
</div>
|
||||
<div class="about-section__body">
|
||||
<p class="about-section__paragraph">
|
||||
Rabbi Gerzi's teachings are deeply influenced by his mentor, Rabbi Yosef Singer, the
|
||||
Pilzno Rav. Under Rabbi Singer's guidance, Rabbi Gerzi absorbed the teachings of the
|
||||
Baal Shem Tov, Chassidut, Kabbalah, and Halacha.
|
||||
Rabbi Gerzi's teachings are deeply influenced by his mentor, Rabbi
|
||||
Yosef Singer, the Pilzno Rav. Under Rabbi Singer's guidance, Rabbi
|
||||
Gerzi absorbed the teachings of the Baal Shem Tov, Chassidut,
|
||||
Kabbalah, and Halacha.
|
||||
</p>
|
||||
<p class="about-section__paragraph">
|
||||
In 2005, the Pilzno Rav instructed Rabbi Gerzi to continue his legacy by building a
|
||||
community in Eretz Yisrael dedicated to this unique form of Healthy, Integrated and
|
||||
Balanced Torah living.
|
||||
In 2005, the Pilzno Rav instructed Rabbi Gerzi to continue his
|
||||
legacy by building a community in Eretz Yisrael dedicated to this
|
||||
unique form of Healthy, Integrated and Balanced Torah living.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="about-section about-section--cream" data-cy="about-teachers">
|
||||
<section
|
||||
class="about-section about-section--cream"
|
||||
data-cy="about-teachers"
|
||||
>
|
||||
<div class="about-section__inner">
|
||||
<div class="about-section__header">
|
||||
<p class="about-section__kicker">Teachers</p>
|
||||
<h2 class="about-section__heading">Rabbi Gerzi's Teachers</h2>
|
||||
</div>
|
||||
<ol class="teacher-list">
|
||||
<li v-for="teacher in teachers" :key="teacher.name" class="teacher-list__item">
|
||||
<li
|
||||
v-for="teacher in teachers"
|
||||
:key="teacher.name"
|
||||
class="teacher-list__item"
|
||||
>
|
||||
<span class="teacher-list__name">{{ teacher.name }}</span>
|
||||
<span class="teacher-list__description">
|
||||
{{ teacher.description }}
|
||||
|
|
@ -397,7 +414,8 @@ function submitNewsletter(submitEvent: Event): void {
|
|||
<div class="get-involved__copy">
|
||||
<h2 class="get-involved__heading">Get Involved</h2>
|
||||
<p class="get-involved__subtext">
|
||||
Subscribe for updates as we release new content, chaburot and events.
|
||||
Subscribe for updates as we release new content, chaburot and
|
||||
events.
|
||||
</p>
|
||||
</div>
|
||||
<form class="get-involved__form" @submit="submitNewsletter">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ElementSiblingNavigation from '@/components/ElementSiblingNavigation.vue'
|
||||
import RichTextEditor from '@/components/RichTextEditor.vue'
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import { useElementsStore } from '@/stores/elements'
|
||||
|
|
@ -23,6 +24,7 @@ const elementsStore = useElementsStore()
|
|||
const {
|
||||
element,
|
||||
childElements,
|
||||
siblingElements,
|
||||
isLoading,
|
||||
error,
|
||||
isSaving,
|
||||
|
|
@ -617,6 +619,13 @@ function resetInput(changeEvent: Event): void {
|
|||
{{ statusMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ElementSiblingNavigation
|
||||
:current-element-id="element.id"
|
||||
:sibling-elements="siblingElements"
|
||||
data-cy-prefix="admin"
|
||||
route-name="admin-element"
|
||||
/>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,15 +40,20 @@ function submitNewsletter(submitEvent: Event): void {
|
|||
<p class="contact-hero__eyebrow">Contact</p>
|
||||
<h1 class="contact-hero__heading">Contact</h1>
|
||||
<p class="contact-hero__body">
|
||||
Rabbi Gerzi receives numerous messages each week. It may take some time to get back to
|
||||
you, but we will do our best to respond in a timely manner.
|
||||
Rabbi Gerzi receives numerous messages each week. It may take some
|
||||
time to get back to you, but we will do our best to respond in a
|
||||
timely manner.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="contact-main">
|
||||
<div class="contact-main__inner">
|
||||
<form class="contact-form" data-cy="contact-form" @submit="submitContact">
|
||||
<form
|
||||
class="contact-form"
|
||||
data-cy="contact-form"
|
||||
@submit="submitContact"
|
||||
>
|
||||
<h2 class="contact-form__heading">Send Rabbi Gerzi a Message</h2>
|
||||
<div class="contact-form__grid">
|
||||
<label class="contact-form__field">
|
||||
|
|
@ -121,7 +126,9 @@ function submitNewsletter(submitEvent: Event): void {
|
|||
</form>
|
||||
|
||||
<aside class="contact-social" data-cy="contact-social">
|
||||
<h2 class="contact-social__heading">Follow Rabbi Gerzi on Social Media</h2>
|
||||
<h2 class="contact-social__heading">
|
||||
Follow Rabbi Gerzi on Social Media
|
||||
</h2>
|
||||
<div class="contact-social__links">
|
||||
<a
|
||||
v-for="socialLink in socialLinks"
|
||||
|
|
@ -143,7 +150,8 @@ function submitNewsletter(submitEvent: Event): void {
|
|||
<div class="get-involved__copy">
|
||||
<h2 class="get-involved__heading">Get Involved</h2>
|
||||
<p class="get-involved__subtext">
|
||||
Subscribe for updates as we release new content, chaburot and events.
|
||||
Subscribe for updates as we release new content, chaburot and
|
||||
events.
|
||||
</p>
|
||||
</div>
|
||||
<form class="get-involved__form" @submit="submitNewsletter">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { storeToRefs } from 'pinia'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ElementSiblingNavigation from '@/components/ElementSiblingNavigation.vue'
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import { useElementsStore } from '@/stores/elements'
|
||||
|
||||
|
|
@ -9,7 +10,8 @@ type TimestampPart = string | undefined
|
|||
|
||||
const route = useRoute()
|
||||
const elementsStore = useElementsStore()
|
||||
const { element, childElements, isLoading, error } = storeToRefs(elementsStore)
|
||||
const { element, childElements, siblingElements, isLoading, error } =
|
||||
storeToRefs(elementsStore)
|
||||
|
||||
const youtubeIframeAllow = [
|
||||
'accelerometer',
|
||||
|
|
@ -297,6 +299,13 @@ function isShortYoutubeHost(hostname: string): boolean {
|
|||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<ElementSiblingNavigation
|
||||
:current-element-id="element.id"
|
||||
:sibling-elements="siblingElements"
|
||||
data-cy-prefix="element"
|
||||
route-name="element"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ const testimonials: Testimonial[] = [
|
|||
},
|
||||
{
|
||||
name: 'Anonymous Student',
|
||||
quote: 'My understanding of Torah has been profoundly improved through ' + 'his teachings.',
|
||||
quote:
|
||||
'My understanding of Torah has been profoundly improved through ' +
|
||||
'his teachings.',
|
||||
},
|
||||
{
|
||||
name: 'Placeholder Student 1',
|
||||
|
|
@ -267,7 +269,8 @@ function projectCardStyle(project: Project): string {
|
|||
<div class="hero__text">
|
||||
<h1 class="hero__headline">
|
||||
Let’s upgrade our lives to a
|
||||
<em>healthy</em>, <em>integrated</em>, and <em>balanced</em> Torah existence.
|
||||
<em>healthy</em>, <em>integrated</em>, and <em>balanced</em> Torah
|
||||
existence.
|
||||
</h1>
|
||||
<p class="hero__attribution">Rabbi Yehoshua Gerzi</p>
|
||||
</div>
|
||||
|
|
@ -297,23 +300,29 @@ function projectCardStyle(project: Project): string {
|
|||
<path d="M10 65 L40 25 L55 45 L70 15 L90 45 L110 65" />
|
||||
<path d="M30 65 C45 55, 60 60, 75 50 S100 55, 110 65" />
|
||||
</svg>
|
||||
<h2 class="discover-path__heading">Discover a Path to a Holistically Engaged Life</h2>
|
||||
<h2 class="discover-path__heading">
|
||||
Discover a Path to a Holistically Engaged Life
|
||||
</h2>
|
||||
<p class="discover-path__paragraph">
|
||||
Rabbi Yehoshua Gerzi has dedicated his life to empowering individuals to achieve a state
|
||||
of “Healthy, Integrated and Balanced Torah Living.” His teachings weave
|
||||
together timeless Torah wisdom, mystical insights and practical guidance, making ancient
|
||||
wisdom accessible and transformative for modern lives.
|
||||
Rabbi Yehoshua Gerzi has dedicated his life to empowering individuals
|
||||
to achieve a state of “Healthy, Integrated and Balanced Torah
|
||||
Living.” His teachings weave together timeless Torah wisdom,
|
||||
mystical insights and practical guidance, making ancient wisdom
|
||||
accessible and transformative for modern lives.
|
||||
</p>
|
||||
<p class="discover-path__paragraph">
|
||||
Rabbi Gerzi is based in Ramat Beit Shemesh, where he serves as Rav of Kehillas Beis David
|
||||
and Director of the Pilzno Institute of Higher Learning.
|
||||
Rabbi Gerzi is based in Ramat Beit Shemesh, where he serves as Rav of
|
||||
Kehillas Beis David and Director of the Pilzno Institute of Higher
|
||||
Learning.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="core-teachings" data-cy="core-teachings">
|
||||
<div class="core-teachings__inner">
|
||||
<h2 class="core-teachings__heading">Baderech HaAvodah (Core Teachings)</h2>
|
||||
<h2 class="core-teachings__heading">
|
||||
Baderech HaAvodah (Core Teachings)
|
||||
</h2>
|
||||
<div class="core-teachings__grid">
|
||||
<div
|
||||
v-for="teaching in coreTeachings"
|
||||
|
|
@ -323,7 +332,9 @@ function projectCardStyle(project: Project): string {
|
|||
<span class="core-teachings__glyph" aria-hidden="true">
|
||||
{{ teaching.glyph }}
|
||||
</span>
|
||||
<p class="core-teachings__label">{{ teaching.number }}) {{ teaching.title }}</p>
|
||||
<p class="core-teachings__label">
|
||||
{{ teaching.number }}) {{ teaching.title }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -348,13 +359,18 @@ function projectCardStyle(project: Project): string {
|
|||
</svg>
|
||||
<h2 class="unique-voice__heading">A Unique Voice for the Generation</h2>
|
||||
<p class="unique-voice__paragraph">
|
||||
Rabbi Gerzi provides a wholly unique and much needed perspective to his students. Carrying
|
||||
decades of experience guided by a handful of the generations most pre-eminent Rabbanim, he
|
||||
teaches a message of practically engaged positivity. His Torah is one of unity, bridging
|
||||
hashkafic boundaries and engaging a diverse cadre of Jews who are open minded to embracing
|
||||
meaningful and experiential Judaism.
|
||||
Rabbi Gerzi provides a wholly unique and much needed perspective to
|
||||
his students. Carrying decades of experience guided by a handful of
|
||||
the generations most pre-eminent Rabbanim, he teaches a message of
|
||||
practically engaged positivity. His Torah is one of unity, bridging
|
||||
hashkafic boundaries and engaging a diverse cadre of Jews who are open
|
||||
minded to embracing meaningful and experiential Judaism.
|
||||
</p>
|
||||
<button class="unique-voice__cta-button" type="button" @click="goToAbout">
|
||||
<button
|
||||
class="unique-voice__cta-button"
|
||||
type="button"
|
||||
@click="goToAbout"
|
||||
>
|
||||
Learn about Rabbi Gerzi’s Approach
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -373,7 +389,9 @@ function projectCardStyle(project: Project): string {
|
|||
:aria-hidden="testimonialPosition(index) !== 'active'"
|
||||
>
|
||||
<h3 class="testimonials__name">{{ testimonial.name }}</h3>
|
||||
<p class="testimonials__quote">“{{ testimonial.quote }}”</p>
|
||||
<p class="testimonials__quote">
|
||||
“{{ testimonial.quote }}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="testimonials__controls">
|
||||
|
|
@ -401,7 +419,9 @@ function projectCardStyle(project: Project): string {
|
|||
|
||||
<section class="sponsors" data-cy="sponsors">
|
||||
<div class="sponsors__inner">
|
||||
<h2 class="sponsors__heading">Organizations Rabbi Gerzi has Worked With</h2>
|
||||
<h2 class="sponsors__heading">
|
||||
Organizations Rabbi Gerzi has Worked With
|
||||
</h2>
|
||||
<div class="sponsors__carousel" data-cy="sponsor-carousel">
|
||||
<div class="sponsors__track" data-cy="sponsor-track">
|
||||
<div
|
||||
|
|
@ -447,9 +467,15 @@ function projectCardStyle(project: Project): string {
|
|||
<div class="journey__inner">
|
||||
<h2 class="journey__heading">Start Your Journey</h2>
|
||||
<div class="journey__cards">
|
||||
<a class="journey__card journey__card--olive" href="/media" data-cy="journey-media">
|
||||
<a
|
||||
class="journey__card journey__card--olive"
|
||||
href="/media"
|
||||
data-cy="journey-media"
|
||||
>
|
||||
<h3 class="journey__card-title">Access<br />Torah Media</h3>
|
||||
<p class="journey__card-body">Explore a wealth of video, audio and written content.</p>
|
||||
<p class="journey__card-body">
|
||||
Explore a wealth of video, audio and written content.
|
||||
</p>
|
||||
</a>
|
||||
<a
|
||||
class="journey__card journey__card--slate"
|
||||
|
|
@ -470,7 +496,8 @@ function projectCardStyle(project: Project): string {
|
|||
<div class="get-involved__copy">
|
||||
<h2 class="get-involved__heading">Get Involved</h2>
|
||||
<p class="get-involved__subtext">
|
||||
Subscribe for updates as we release new content, chaburot and events.
|
||||
Subscribe for updates as we release new content, chaburot and
|
||||
events.
|
||||
</p>
|
||||
</div>
|
||||
<form class="get-involved__form" @submit="submitNewsletter">
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@ async function handleSubmit(): Promise<void> {
|
|||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="authStore.loginError" data-cy="login-error" class="login-card__error">
|
||||
<p
|
||||
v-if="authStore.loginError"
|
||||
data-cy="login-error"
|
||||
class="login-card__error"
|
||||
>
|
||||
{{ authStore.loginError }}
|
||||
</p>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue