From 76a1717ad22b16e7288fd56e92be6438a2634991 Mon Sep 17 00:00:00 2001 From: martinyde Date: Thu, 22 Jan 2026 08:02:22 +0100 Subject: [PATCH 01/49] Added 1st attempt --- .../assets/css/hoeringsportal.scss | 2 +- .../assets/css/module/timeline.scss | 183 ++++++++++++++++++ .../assets/js/hoeringsportal.js | 3 + .../hoeringsportal/assets/js/mini-timeline.js | 25 +++ .../hoeringsportal/assets/js/timeline.js | 31 +++ .../hoeringsportal/assets/timeline-data.json | 68 +++++++ .../templates/components/timeline.html.twig | 101 ++++++++++ .../node--project-main-page--full.html.twig | 6 +- 8 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 web/themes/custom/hoeringsportal/assets/css/module/timeline.scss create mode 100644 web/themes/custom/hoeringsportal/assets/js/mini-timeline.js create mode 100644 web/themes/custom/hoeringsportal/assets/js/timeline.js create mode 100644 web/themes/custom/hoeringsportal/assets/timeline-data.json create mode 100644 web/themes/custom/hoeringsportal/templates/components/timeline.html.twig diff --git a/web/themes/custom/hoeringsportal/assets/css/hoeringsportal.scss b/web/themes/custom/hoeringsportal/assets/css/hoeringsportal.scss index 72835dfe7..6294cd31d 100755 --- a/web/themes/custom/hoeringsportal/assets/css/hoeringsportal.scss +++ b/web/themes/custom/hoeringsportal/assets/css/hoeringsportal.scss @@ -4,7 +4,7 @@ "module/footer", "module/content", "module/drupal", "module/underline", "module/dialogue", "module/status-messages", "module/campaign", "module/splash", "module/lead", "module/line-clamp", "module/page-teaser", - "module/responsive-image-as-background", "module/map", + "module/responsive-image-as-background", "module/map", "module/timeline", "module/social-sharing-buttons", "module/form", "module/image-gallery", "module/spinner", "module/signup", "module/list", "module/pager", "module/paragraph-background-image", "module/paragraph-content-promotion", diff --git a/web/themes/custom/hoeringsportal/assets/css/module/timeline.scss b/web/themes/custom/hoeringsportal/assets/css/module/timeline.scss new file mode 100644 index 000000000..c52d6d50a --- /dev/null +++ b/web/themes/custom/hoeringsportal/assets/css/module/timeline.scss @@ -0,0 +1,183 @@ +.timeline { + --petroleum-500: #008486; + --petroleum-100: #e5eef0; + --orange-500: #ff5f31; + --gray-100: #f6f6f6; + --gray-300: #e6e6e6; + --gray-600: #9d9d9d; + --gray-800: #525252; + --white: #fff; + --black: #333; + --note-500: #3661D8; + --note-200: #c4d4f5; + --pink-500: #e91e63; + --pink-100: #fce4ec; + + font-family: system-ui, -apple-system, sans-serif; + position: relative; + + /* Global Item Styles */ + .timeline-card { + background: var(--white); + border: 1px solid var(--gray-300); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transition: box-shadow 0.2s ease; + overflow: hidden; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + } + } + + .status-badge { + display: inline-flex; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + margin-bottom: 12px; + } + + /* Vertical Timeline Specifics */ + .timeline--vertical { + max-width: 900px; + margin: 0 auto; + padding: 40px 0; + + .timeline-track { + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 2px; + background: linear-gradient(180deg, var(--gray-800) 0%, var(--gray-800) 72%, var(--gray-300) 80%, var(--gray-300) 100%); + transform: translateX(-50%); + } + + .timeline-item { + display: flex; + position: relative; + margin-bottom: -60px; + width: 100%; + + &.item--left { justify-content: flex-start; } + &.item--right { justify-content: flex-end; } + + .item-dot { + position: absolute; + left: 50%; + top: 24px; + transform: translateX(-50%); + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--petroleum-500); + border: 3px solid var(--white); + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + z-index: 10; + } + + .timeline-card { + width: calc(50% - 32px); + } + } + } + + /* Horizontal Timeline Specifics */ + .timeline-horizontal { + max-width: 900px; + margin: 0 auto; + padding: 24px 0; + + .progress-bar { + height: 4px; + background: var(--gray-300); + margin-bottom: 24px; + position: relative; + + .progress-fill { + position: absolute; + height: 100%; + background: var(--petroleum-500); + transition: width 0.4s ease; + } + } + + .horizontal-nav { + display: flex; + justify-content: space-between; + margin-bottom: 32px; + + .nav-point { + display: flex; + flex-direction: column; + align-items: center; + background: none; + border: none; + cursor: pointer; + opacity: 0.4; + + &.active { opacity: 1; } + + .point-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--gray-400); + margin-bottom: 6px; + } + } + } + + .slider-container { + overflow: hidden; + .slider-track { + display: flex; + transition: transform 0.4s ease; + } + } + } + + /* Mini Timeline (Sticky Navigation) */ + #mini-timeline { + position: fixed; + right: 20px; + top: 50%; + transform: translateY(-50%); + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 10px; + background: var(--white); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); + border: 1px solid var(--gray-300); + + .mini-dot { + width: 10px; + height: 10px; + border-radius: 50%; + margin: 4px 0; + border: none; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { transform: scale(1.3); } + &.active { outline: 2px solid var(--petroleum-200); outline-offset: 2px; } + } + } + + /* Mobile Adjustments */ + @media (max-width: 768px) { + &.timeline--vertical { + .timeline-track { left: 16px; transform: none; } + .timeline-item { + justify-content: flex-end; + padding-left: 40px; + margin-bottom: 24px; + .item-dot { left: 10px; transform: none; } + .timeline-card { width: 100%; } + } + } + #mini-timeline { display: none; } + } +} diff --git a/web/themes/custom/hoeringsportal/assets/js/hoeringsportal.js b/web/themes/custom/hoeringsportal/assets/js/hoeringsportal.js index 37aef10ab..7f95a17d5 100755 --- a/web/themes/custom/hoeringsportal/assets/js/hoeringsportal.js +++ b/web/themes/custom/hoeringsportal/assets/js/hoeringsportal.js @@ -21,6 +21,9 @@ require("./modify-dialogue-form.js"); require("./modify-dialogue-proposal-comments.js"); require("./animated-svg.js"); require("./accordion.js"); +require("./timeline.js"); +require("./mini-timeline.js"); +require("./accordion.js"); // Enable popovers. $(function () { diff --git a/web/themes/custom/hoeringsportal/assets/js/mini-timeline.js b/web/themes/custom/hoeringsportal/assets/js/mini-timeline.js new file mode 100644 index 000000000..ef34bd016 --- /dev/null +++ b/web/themes/custom/hoeringsportal/assets/js/mini-timeline.js @@ -0,0 +1,25 @@ +export function initMiniTimeline(data) { + const container = document.getElementById('mini-timeline'); + if (!container) return; + + data.forEach((item, index) => { + const btn = document.createElement('button'); + btn.className = 'mini-dot'; + btn.title = item.title; + + // Simple color logic based on status + let color = '#9d9d9d'; // Default + if (item.status === 'completed') color = '#008486'; + if (item.status === 'current') color = '#ff5f31'; + if (item.accentColor === 'pink') color = '#e91e63'; + + btn.style.backgroundColor = color; + + btn.onclick = () => { + const element = document.getElementById(`item-${item.id}`); + element?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + + container.appendChild(btn); + }); +} diff --git a/web/themes/custom/hoeringsportal/assets/js/timeline.js b/web/themes/custom/hoeringsportal/assets/js/timeline.js new file mode 100644 index 000000000..c47cb07b9 --- /dev/null +++ b/web/themes/custom/hoeringsportal/assets/js/timeline.js @@ -0,0 +1,31 @@ +import { initMiniTimeline } from './mini-timeline.js'; + +document.addEventListener('DOMContentLoaded', () => { + // Fetch the data + fetch('/themes/custom/hoeringsportal/assets/timeline-data.json') + .then(response => response.json()) + .then(data => { + initMiniTimeline(data.timelineData); + initScrollTracking(); + }); + + const viewToggle = document.getElementById('view-toggle'); + viewToggle?.addEventListener('click', (e) => { + const isHorizontal = document.body.classList.toggle('view-horizontal'); + e.target.textContent = isHorizontal ? '↕ Vertikal' : '↔ Horisontal'; + }); +}); + +function initScrollTracking() { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + // Handle active state in mini-timeline + const id = entry.target.id.split('-')[1]; + console.log(`Now viewing item: ${id}`); + } + }); + }, { threshold: 0.5 }); + + document.querySelectorAll('.timeline-item').forEach(item => observer.observe(item)); +} diff --git a/web/themes/custom/hoeringsportal/assets/timeline-data.json b/web/themes/custom/hoeringsportal/assets/timeline-data.json new file mode 100644 index 000000000..73b3be6c6 --- /dev/null +++ b/web/themes/custom/hoeringsportal/assets/timeline-data.json @@ -0,0 +1,68 @@ +{ + "images": { + "logo": "https://deltag.aarhus.dk/themes/custom/hoeringsportal/static/images/AAK_02_venstrejusteret_sh.svg", + "logoWhite": "https://deltag.aarhus.dk/themes/custom/hoeringsportal/static/images/AAK_02_venstrejusteret_hvid.svg", + "hero": "https://deltag.aarhus.dk/sites/default/files/images/IMG_1251_cropped_0.jpg", + "gaatur": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg", + "workshop": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1227%20%281%29%20-%20cropped.jpg", + "popup": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Billede3.jpg", + "digital": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Kategorier_stor_0.png" + }, + "timelineData": [ + { + "id": 1, + "date": "2023", + "month": "", + "title": "Udpegning", + "subtitle": "Strategisk Byrumsplan", + "description": "Hjortshøj Stationsplads udpeges som ét af 13 byrum i Aarhus Kommunes Strategiske Byrumsplan.", + "status": "completed", + "link": "https://aarhus.dk/nyt/teknik-og-miljoe/2023/august-2023/raadmand-vil-puste-nyt-liv-i-13-af-byens-rum", + "linkText": "Læs om byrumsplanen" + }, + { + "id": 2, + "date": "2025", + "month": "Forår", + "title": "Dialog med Fællesråd", + "subtitle": "Hjortshøj Landsbyforum", + "description": "Gåtur på Stationspladsen og efterfølgende møde om stedets identitet og potentialer med fællesrådet.", + "status": "completed", + "image": "gaatur", + "link": "https://deltag.aarhus.dk/public_meeting/1296", + "linkText": "Se detaljer om mødet" + }, + { + "id": 3, + "date": "2025", + "month": "August", + "title": "Workshop med Virupskolen", + "subtitle": "Børne- og ungeperspektiv", + "description": "Formiddag sammen med skoleelever fra Virupskolen om fremtidens stationsplads.", + "status": "completed", + "image": "workshop", + "link": "https://deltag.aarhus.dk/public_meeting/1298", + "linkText": "Se workshoppen", + "accentColor": "pink" + }, + { + "id": 6, + "date": "2025", + "month": "Efterår", + "title": "Udarbejdelse af Forslag", + "subtitle": "Design & Planlægning", + "description": "De mange input danner fundament for udarbejdelse af et forslag til omdannelsen af Hjortshøj Stationsplads.", + "status": "current" + }, + { + "id": 9, + "date": "Note", + "month": "", + "title": "Redaktionel bemærkning", + "subtitle": "Note", + "description": "Dette er en redaktionel note fra projektteamet med vigtig information om projektet.", + "status": "note", + "accentColor": "blue" + } + ] +} diff --git a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig new file mode 100644 index 000000000..aef50630b --- /dev/null +++ b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig @@ -0,0 +1,101 @@ +{% set data = { + "images": { + "logo": "https://deltag.aarhus.dk/themes/custom/hoeringsportal/static/images/AAK_02_venstrejusteret_sh.svg", + "logoWhite": "https://deltag.aarhus.dk/themes/custom/hoeringsportal/static/images/AAK_02_venstrejusteret_hvid.svg", + "hero": "https://deltag.aarhus.dk/sites/default/files/images/IMG_1251_cropped_0.jpg", + "gaatur": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg", + "workshop": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1227%20%281%29%20-%20cropped.jpg", + "popup": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Billede3.jpg", + "digital": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Kategorier_stor_0.png" + }, + "timelineData": [ + { + "id": 1, + "date": "2023", + "month": "", + "title": "Udpegning", + "subtitle": "Strategisk Byrumsplan", + "description": "Hjortshøj Stationsplads udpeges som ét af 13 byrum i Aarhus Kommunes Strategiske Byrumsplan.", + "status": "completed", + "link": "https://aarhus.dk/nyt/teknik-og-miljoe/2023/august-2023/raadmand-vil-puste-nyt-liv-i-13-af-byens-rum", + "linkText": "Læs om byrumsplanen" + }, + { + "id": 2, + "date": "2025", + "month": "Forår", + "title": "Dialog med Fællesråd", + "subtitle": "Hjortshøj Landsbyforum", + "description": "Gåtur på Stationspladsen og efterfølgende møde om stedets identitet og potentialer med fællesrådet.", + "status": "completed", + "image": "gaatur", + "link": "https://deltag.aarhus.dk/public_meeting/1296", + "linkText": "Se detaljer om mødet" + }, + { + "id": 3, + "date": "2025", + "month": "August", + "title": "Workshop med Virupskolen", + "subtitle": "Børne- og ungeperspektiv", + "description": "Formiddag sammen med skoleelever fra Virupskolen om fremtidens stationsplads.", + "status": "completed", + "image": "workshop", + "link": "https://deltag.aarhus.dk/public_meeting/1298", + "linkText": "Se workshoppen", + "accentColor": "pink" + }, + { + "id": 6, + "date": "2025", + "month": "Efterår", + "title": "Udarbejdelse af Forslag", + "subtitle": "Design & Planlægning", + "description": "De mange input danner fundament for udarbejdelse af et forslag til omdannelsen af Hjortshøj Stationsplads.", + "status": "current" + }, + { + "id": 9, + "date": "Note", + "month": "", + "title": "Redaktionel bemærkning", + "subtitle": "Note", + "description": "Dette er en redaktionel note fra projektteamet med vigtig information om projektet.", + "status": "note", + "accentColor": "blue" + } + ] +} %} + +
+
+

Projektets tidslinje

+ +
+ +
+
+ + {% for item in data.timelineData %} +
+
+ {% if item.image %} + {{ item.title }} + {% endif %} + +
+ + {{ item.month }} {{ item.date }} + +

{{ item.title }}

+

{{ item.description }}

+ + {% if item.link %} + {{ item.linkText }} → + {% endif %} +
+
+
+ {% endfor %} +
+
diff --git a/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig b/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig index 994b65d26..0207c6f77 100755 --- a/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig +++ b/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig @@ -39,8 +39,10 @@
- {{ include(directory ~ '/template/components/timeline.html.twig') }} -
+
+ {{ include(directory ~ '/templates/components/timeline.html.twig') }} +
+
From bc44259ff4eb2277ef10b8f2a3520b0ba82bb753 Mon Sep 17 00:00:00 2001 From: martinyde Date: Thu, 22 Jan 2026 10:00:05 +0100 Subject: [PATCH 02/49] Added styling, html and css for timeline --- .../assets/css/module/_timeline.scss | 1165 +++++++++++++++++ .../assets/css/module/timeline.scss | 183 --- .../assets/js/hoeringsportal.js | 1 - .../hoeringsportal/assets/js/timeline.js | 356 ++++- .../project-timeline-card.html.twig | 104 ++ .../project-timeline-legend.html.twig | 19 + .../project-timeline-mini-nav.html.twig | 36 + .../templates/components/timeline.html.twig | 316 +++-- .../node--project-main-page--full.html.twig | 1 - 9 files changed, 1886 insertions(+), 295 deletions(-) create mode 100644 web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss delete mode 100644 web/themes/custom/hoeringsportal/assets/css/module/timeline.scss create mode 100644 web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig create mode 100644 web/themes/custom/hoeringsportal/templates/components/project-timeline-legend.html.twig create mode 100644 web/themes/custom/hoeringsportal/templates/components/project-timeline-mini-nav.html.twig diff --git a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss new file mode 100644 index 000000000..d29eb689c --- /dev/null +++ b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss @@ -0,0 +1,1165 @@ +/** + * @file + * Project Timeline Component Styles + * + * Matches the React artifact design with: + * - Centered timeline with alternating cards (left/right) + * - Status colors: completed (petroleum), current (orange), upcoming (gray), note (blue) + * - Accent colors: pink, blue + * - Fixed mini-nav on right side + */ + +/* ============================================================================= + CSS Custom Properties + ============================================================================= */ +.project-timeline { + /* Status colors */ + --timeline-status-completed: var(--primitive-petroleum-500, #008486); + --timeline-status-completed-bg: var(--primitive-petroleum-100, #e5eef0); + --timeline-status-current: var(--primitive-orange-500, #ff5f31); + --timeline-status-current-bg: var(--primitive-gray-100, #f6f6f6); + --timeline-status-upcoming: var(--primitive-gray-600, #9d9d9d); + --timeline-status-upcoming-bg: var(--primitive-gray-200, #eaeaea); + --timeline-status-note: #3661d8; + --timeline-status-note-bg: #c4d4f5; + + /* Accent colors */ + --timeline-accent-pink: #e91e63; + --timeline-accent-pink-bg: #fce4ec; + --timeline-accent-blue: #3661d8; + --timeline-accent-blue-bg: #c4d4f5; + + /* Layout */ + --timeline-max-width: 900px; + --timeline-card-gap: 32px; + --timeline-line-width: 2px; + --timeline-dot-size: 12px; + + /* Base styles */ + font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; +} + +/* ============================================================================= + Main Container + ============================================================================= */ +.project-timeline { + position: relative; + padding: 1rem 0; + cursor: default; +} + +/* Override theme's grab cursor from old Vis.js timeline */ +.project-timeline[data-project-timeline] div { + cursor: default !important; +} + +/* ============================================================================= + Timeline Header (Title + Description + View Toggle) + ============================================================================= */ +.project-timeline__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 24px; +} + +.project-timeline__header-content { + flex: 1; + min-width: 280px; +} + +.project-timeline__title { + margin: 0 0 8px; + font-size: 24px; + font-weight: 700; + color: var(--primitive-black, #333); +} + +.project-timeline__description { + margin: 0; + font-size: 16px; + line-height: 1.5; + color: var(--primitive-gray-700, #858585); + max-width: 600px; +} + +/* ============================================================================= + View Toggle + ============================================================================= */ +.project-timeline__view-toggle { + display: inline-flex; + background: var(--primitive-gray-100, #f6f6f6); + padding: 4px; + flex-shrink: 0; +} + +.project-timeline__view-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: none; + background: transparent; + color: var(--primitive-gray-800, #525252); + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s ease; +} + +.project-timeline__view-icon { + font-size: 14px; + line-height: 1; +} + +.project-timeline__view-text { + line-height: 1; +} + +.project-timeline__view-btn:hover { + color: var(--primitive-black, #333); +} + +.project-timeline__view-btn[aria-selected="true"] { + background: var(--primitive-petroleum-500, #008486); + color: var(--primitive-white, #fff); +} + +.project-timeline__view-btn:focus-visible { + outline: 2px solid var(--focus-ring-color, #008486); + outline-offset: 2px; +} + +/* ============================================================================= + Vertical View - Centered Timeline with Alternating Cards + ============================================================================= */ +.project-timeline__vertical { + position: relative; +} + +.project-timeline__vertical-wrapper { + display: flex; + gap: 0; +} + +.project-timeline__cards { + flex: 1; + display: flex; + flex-direction: column; + position: relative; + padding: 40px 0 120px; + max-width: var(--timeline-max-width); + margin: 0 auto; +} + +/* Centered timeline vertical line */ +.project-timeline__cards::before { + content: ""; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: var(--timeline-line-width); + background: linear-gradient( + 180deg, + var(--primitive-gray-800, #525252) 0%, + var(--primitive-gray-800, #525252) 72%, + var(--primitive-gray-300, #e6e6e6) 80%, + var(--primitive-gray-300, #e6e6e6) 100% + ); + transform: translateX(-50%); +} + +/* ============================================================================= + Timeline Card - Alternating Layout + ============================================================================= */ +.project-timeline-card { + display: flex; + position: relative; + margin-bottom: -60px; + z-index: 1; +} + +/* Alternating: odd cards align left, even cards align right */ +.project-timeline-card:nth-child(odd) { + justify-content: flex-start; +} + +.project-timeline-card:nth-child(even) { + justify-content: flex-end; +} + +/* Reset z-index for proper stacking */ +.project-timeline-card:nth-child(1) { + z-index: 10; +} +.project-timeline-card:nth-child(2) { + z-index: 9; +} +.project-timeline-card:nth-child(3) { + z-index: 8; +} +.project-timeline-card:nth-child(4) { + z-index: 7; +} +.project-timeline-card:nth-child(5) { + z-index: 6; +} +.project-timeline-card:nth-child(6) { + z-index: 5; +} +.project-timeline-card:nth-child(7) { + z-index: 4; +} +.project-timeline-card:nth-child(8) { + z-index: 3; +} +.project-timeline-card:nth-child(9) { + z-index: 2; +} +.project-timeline-card:nth-child(10) { + z-index: 1; +} + +.project-timeline-card:last-child { + margin-bottom: 0; +} + +/* Timeline dot - centered, ensure circular shape */ +.project-timeline-card__connector { + position: absolute; + left: 50%; + top: 24px; + transform: translateX(-50%); + z-index: 10; + display: flex; + align-items: center; + justify-content: center; +} + +/* Adjust dot position when card has image */ +.project-timeline-card:has(.project-timeline-card__image-wrapper) +.project-timeline-card__connector { + top: 60px; +} + +.project-timeline-card__dot { + width: var(--timeline-dot-size); + height: var(--timeline-dot-size); + min-width: var(--timeline-dot-size); + min-height: var(--timeline-dot-size); + border-radius: 50%; + background: var(--timeline-status-upcoming); + border: 3px solid var(--primitive-white, #fff); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + flex-shrink: 0; +} + +/* Horizontal connector line from card to dot */ +.project-timeline-card::after { + content: ""; + position: absolute; + top: 29px; + width: 32px; + height: 2px; + background: var(--timeline-status-upcoming); +} + +/* Adjust connector position when card has image */ +.project-timeline-card:has(.project-timeline-card__image-wrapper)::after { + top: 65px; +} + +/* Connector position for odd cards (left side) */ +.project-timeline-card:nth-child(odd)::after { + left: calc(50% - 32px); +} + +/* Connector position for even cards (right side) */ +.project-timeline-card:nth-child(even)::after { + left: 50%; +} + +/* Card content container */ +.project-timeline-card__content { + width: calc(50% - 32px); + background: var(--primitive-white, #fff); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + border: 1px solid var(--primitive-gray-300, #e6e6e6); + transition: box-shadow 0.2s ease; + position: relative; + overflow: hidden; + cursor: default; +} + +.project-timeline-card__content:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +/* Accent bar on card edge */ +.project-timeline-card__content::before { + content: ""; + position: absolute; + top: 0; + width: 4px; + height: 100%; + background: var(--timeline-status-upcoming); +} + +/* Accent bar on right for odd cards, left for even */ +.project-timeline-card:nth-child(odd) .project-timeline-card__content::before { + right: 0; +} + +.project-timeline-card:nth-child(even) .project-timeline-card__content::before { + left: 0; +} + +/* Move accent bar below image if present */ +.project-timeline-card__content:has( + .project-timeline-card__image-wrapper + )::before { + top: 140px; + height: calc(100% - 140px); +} + +/* Card header with date */ +.project-timeline-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 20px 24px 0; +} + +.project-timeline-card__date-wrapper { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--timeline-status-upcoming-bg); + font-size: 12px; + font-weight: 600; +} + +.project-timeline-card__month { + color: var(--primitive-petroleum-800, #3d6d6d); +} + +/* Status badge */ +.project-timeline-card__status { + position: absolute; + top: 8px; + right: 12px; + padding: 3px 8px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--primitive-white, #fff); + background: var(--timeline-status-upcoming); +} + +/* Image */ +.project-timeline-card__image-wrapper { + width: 100%; + height: 140px; + overflow: hidden; + border-bottom: 1px solid var(--primitive-gray-200, #eaeaea); +} + +.project-timeline-card__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* When image is present, move status badge down */ +.project-timeline-card__content:has(.project-timeline-card__image-wrapper) +.project-timeline-card__status { + top: 148px; +} + +/* Card body */ +.project-timeline-card__body { + padding: 12px 24px 20px; +} + +.project-timeline-card__title { + margin: 0 0 4px; + font-size: 17px; + font-weight: 700; + line-height: 1.3; + color: var(--primitive-black, #333); +} + +.project-timeline-card__subtitle { + margin: 0 0 10px; + font-size: 12px; + font-weight: 600; + color: var(--timeline-status-upcoming); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.project-timeline-card__description { + margin: 0; + font-size: 14px; + line-height: 1.55; + color: var(--primitive-gray-800, #525252); +} + +/* Card footer with link */ +.project-timeline-card__footer { + padding: 0 24px 20px; +} + +.project-timeline-card__link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--timeline-status-upcoming); + color: var(--primitive-white, #fff); + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: background 0.2s ease; +} + +.project-timeline-card__link:hover { + background: var(--primitive-gray-700, #858585); + color: var(--primitive-white, #fff); + text-decoration: none; +} + +.project-timeline-card__link-icon { + transition: transform 0.15s ease; +} + +.project-timeline-card__link:hover .project-timeline-card__link-icon { + transform: translateX(2px); +} + +/* ============================================================================= + Card Status Variants + ============================================================================= */ + +/* Completed */ +.project-timeline-card--completed .project-timeline-card__dot { + background: var(--timeline-status-completed); +} + +.project-timeline-card--completed::after { + background: var(--timeline-status-completed); +} + +.project-timeline-card--completed .project-timeline-card__content::before { + background: var(--timeline-status-completed); +} + +.project-timeline-card--completed .project-timeline-card__date-wrapper { + background: var(--timeline-status-completed-bg); +} + +.project-timeline-card--completed .project-timeline-card__status { + background: var(--timeline-status-completed); +} + +.project-timeline-card--completed .project-timeline-card__subtitle { + color: var(--timeline-status-completed); +} + +.project-timeline-card--completed .project-timeline-card__link { + background: var(--timeline-status-completed); +} + +.project-timeline-card--completed .project-timeline-card__link:hover { + background: var(--primitive-petroleum-800, #3d6d6d); + color: var(--primitive-white, #fff); +} + +/* Current */ +.project-timeline-card--current .project-timeline-card__dot { + background: var(--primitive-white, #fff); + border: 2px solid var(--timeline-status-current); +} + +.project-timeline-card--current::after { + background: var(--timeline-status-current); +} + +.project-timeline-card--current .project-timeline-card__content::before { + background: var(--timeline-status-current); +} + +.project-timeline-card--current .project-timeline-card__date-wrapper { + background: var(--timeline-status-current-bg); +} + +.project-timeline-card--current .project-timeline-card__status { + background: var(--timeline-status-current); +} + +.project-timeline-card--current .project-timeline-card__subtitle { + color: var(--timeline-status-current); +} + +.project-timeline-card--current .project-timeline-card__link { + background: var(--timeline-status-current); +} + +.project-timeline-card--current .project-timeline-card__link:hover { + background: #e54a20; + color: var(--primitive-white, #fff); +} + +/* Note */ +.project-timeline-card--note .project-timeline-card__dot { + background: var(--timeline-status-note); +} + +.project-timeline-card--note::after { + background: var(--timeline-status-note); +} + +.project-timeline-card--note .project-timeline-card__content::before { + background: var(--timeline-status-note); +} + +.project-timeline-card--note .project-timeline-card__date-wrapper { + background: var(--timeline-status-note-bg); +} + +.project-timeline-card--note .project-timeline-card__status { + background: var(--timeline-status-note); +} + +.project-timeline-card--note .project-timeline-card__subtitle { + color: var(--timeline-status-note); +} + +.project-timeline-card--note .project-timeline-card__link { + background: var(--timeline-status-note); +} + +.project-timeline-card--note .project-timeline-card__link:hover { + background: #2a4db5; + color: var(--primitive-white, #fff); +} + +/* Upcoming - lower opacity */ +.project-timeline-card--upcoming { + opacity: 0.6; +} + +/* ============================================================================= + Card Accent Color Variants + ============================================================================= */ + +/* Pink accent */ +.project-timeline-card--accent-pink .project-timeline-card__dot { + background: var(--timeline-accent-pink); +} + +.project-timeline-card--accent-pink::after { + background: var(--timeline-accent-pink); +} + +.project-timeline-card--accent-pink .project-timeline-card__content::before { + background: var(--timeline-accent-pink); +} + +.project-timeline-card--accent-pink .project-timeline-card__date-wrapper { + background: var(--timeline-accent-pink-bg); + color: var(--timeline-accent-pink); +} + +.project-timeline-card--accent-pink .project-timeline-card__status { + background: var(--timeline-accent-pink); +} + +.project-timeline-card--accent-pink .project-timeline-card__subtitle { + color: var(--timeline-accent-pink); +} + +.project-timeline-card--accent-pink .project-timeline-card__link { + background: var(--timeline-accent-pink); +} + +.project-timeline-card--accent-pink .project-timeline-card__link:hover { + background: #c2185b; + color: var(--primitive-white, #fff); +} + +/* Blue accent */ +.project-timeline-card--accent-blue .project-timeline-card__dot { + background: var(--timeline-accent-blue); +} + +.project-timeline-card--accent-blue::after { + background: var(--timeline-accent-blue); +} + +.project-timeline-card--accent-blue .project-timeline-card__content::before { + background: var(--timeline-accent-blue); +} + +.project-timeline-card--accent-blue .project-timeline-card__date-wrapper { + background: var(--timeline-accent-blue-bg); + color: var(--timeline-accent-blue); +} + +.project-timeline-card--accent-blue .project-timeline-card__status { + background: var(--timeline-accent-blue); +} + +.project-timeline-card--accent-blue .project-timeline-card__subtitle { + color: var(--timeline-accent-blue); +} + +.project-timeline-card--accent-blue .project-timeline-card__link { + background: var(--timeline-accent-blue); +} + +.project-timeline-card--accent-blue .project-timeline-card__link:hover { + background: #2a4db5; + color: var(--primitive-white, #fff); +} + +/* ============================================================================= + Mini Navigation - Fixed on Right Side + ============================================================================= */ +.project-timeline-mini-nav { + position: fixed; + right: 20px; + top: 50%; + transform: translateY(-50%); + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 10px; + background: var(--primitive-white, #fff); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); + border: 1px solid var(--primitive-gray-300, #e6e6e6); +} + +/* Vertical "TIDSLINJE" header */ +.project-timeline-mini-nav__header { + font-size: 9px; + font-weight: 600; + color: var(--primitive-petroleum-500, #008486); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 12px; + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +.project-timeline-mini-nav__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +.project-timeline-mini-nav__item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Connecting line between dots */ +.project-timeline-mini-nav__item:not(:last-child)::after { + content: ""; + width: 2px; + height: 16px; + background: var(--primitive-gray-300, #e6e6e6); +} + +.project-timeline-mini-nav__link { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + text-decoration: none; + position: relative; + background: none; + border: none; + cursor: pointer; +} + +.project-timeline-mini-nav__dot { + width: 10px; + height: 10px; + min-width: 10px; + min-height: 10px; + border-radius: 50%; + background: var(--primitive-gray-400, #dfdfdf); + transition: all 0.2s ease; + flex-shrink: 0; +} + +.project-timeline-mini-nav__link:hover .project-timeline-mini-nav__dot, +.project-timeline-mini-nav__link.is-active .project-timeline-mini-nav__dot { + transform: scale(1.3); +} + +.project-timeline-mini-nav__link.is-active .project-timeline-mini-nav__dot { + outline: 2px solid var(--primitive-petroleum-200, #cfe0e2); + outline-offset: 2px; +} + +/* Hide label by default, show on hover */ +.project-timeline-mini-nav__label { + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + background: var(--primitive-gray-900, #333); + color: var(--primitive-white, #fff); + padding: 6px 10px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.project-timeline-mini-nav__link:hover .project-timeline-mini-nav__label { + opacity: 1; +} + +/* Mini nav status colors */ +.project-timeline-mini-nav__link--completed .project-timeline-mini-nav__dot { + background: var(--timeline-status-completed); +} + +.project-timeline-mini-nav__link--current .project-timeline-mini-nav__dot { + background: var(--primitive-white, #fff); + border: 2px solid var(--timeline-status-current); +} + +.project-timeline-mini-nav__link--upcoming .project-timeline-mini-nav__dot { + background: var(--timeline-status-upcoming); +} + +.project-timeline-mini-nav__link--note .project-timeline-mini-nav__dot { + background: var(--timeline-status-note); +} + +/* Connecting line colors based on progress */ +.project-timeline-mini-nav__item:has( + .project-timeline-mini-nav__link--completed + )::after { + background: var(--primitive-gray-800, #525252); +} + +/* "I dag" indicator at bottom */ +.project-timeline-mini-nav__today { + margin-top: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.project-timeline-mini-nav__today-dot { + width: 10px; + height: 10px; + min-width: 10px; + min-height: 10px; + border-radius: 50%; + border: 2px solid var(--timeline-status-current); + background: var(--primitive-white, #fff); + flex-shrink: 0; +} + +.project-timeline-mini-nav__today-label { + font-size: 9px; + color: var(--primitive-gray-700, #858585); + text-align: center; +} + +/* ============================================================================= + Horizontal View (Carousel) + ============================================================================= */ +.project-timeline__horizontal { + position: relative; + max-width: var(--timeline-max-width); + margin: 0 auto; + padding: 24px 0; +} + +/* Progress bar at top */ +.project-timeline__horizontal::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--primitive-gray-300, #e6e6e6); +} + +.project-timeline__carousel-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 28px; +} + +.project-timeline__carousel-btn { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + padding: 0; + border: none; + background: var(--primitive-petroleum-500, #008486); + color: var(--primitive-white, #fff); + cursor: pointer; + font-size: 20px; + transition: all 0.2s ease; +} + +.project-timeline__carousel-btn:hover:not(:disabled) { + background: var(--primitive-petroleum-800, #3d6d6d); + color: var(--primitive-white, #fff); +} + +.project-timeline__carousel-btn:disabled { + background: var(--primitive-gray-200, #eaeaea); + color: var(--primitive-gray-600, #9d9d9d); + cursor: not-allowed; +} + +.project-timeline__carousel-btn:focus-visible { + outline: 2px solid var(--focus-ring-color, #008486); + outline-offset: 2px; +} + +.project-timeline__carousel-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 500; + color: var(--primitive-gray-700, #858585); +} + +.project-timeline__carousel-indicator [data-carousel-current] { + font-weight: 700; + color: var(--primitive-black, #333); +} + +.project-timeline__carousel-viewport { + overflow: hidden; + margin-top: 32px; +} + +.project-timeline__carousel-track { + display: flex; + transition: transform 0.4s ease; +} + +.project-timeline__carousel-slide { + flex: 0 0 100%; + padding: 0 8px; + box-sizing: border-box; +} + +/* Carousel card styles - full width, side-by-side image */ +.project-timeline__horizontal .project-timeline-card { + margin-bottom: 0; + justify-content: center; +} + +.project-timeline__horizontal .project-timeline-card::after { + display: none; +} + +.project-timeline__horizontal .project-timeline-card__connector { + display: none; +} + +.project-timeline__horizontal .project-timeline-card__content { + width: 100%; + display: flex; + flex-direction: row; + min-height: 280px; +} + +.project-timeline__horizontal .project-timeline-card__content::before { + left: 0; + right: auto; +} + +.project-timeline__horizontal .project-timeline-card__image-wrapper { + width: 40%; + height: auto; + min-height: 280px; + flex-shrink: 0; + border-bottom: none; + border-right: 1px solid var(--primitive-gray-200, #eaeaea); +} + +.project-timeline__horizontal +.project-timeline-card__content:has( + .project-timeline-card__image-wrapper + )::before { + top: 0; + height: 100%; +} + +.project-timeline__horizontal +.project-timeline-card__content:has(.project-timeline-card__image-wrapper) +.project-timeline-card__status { + top: 16px; +} + +.project-timeline__horizontal .project-timeline-card__header { + padding: 48px 36px 0; +} + +.project-timeline__horizontal .project-timeline-card__body { + padding: 12px 36px 20px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.project-timeline__horizontal .project-timeline-card__title { + font-size: 26px; +} + +.project-timeline__horizontal .project-timeline-card__description { + font-size: 15px; + line-height: 1.6; +} + +.project-timeline__horizontal .project-timeline-card__footer { + padding: 32px 0; +} + +.project-timeline__horizontal .project-timeline-card__link { + padding: 10px 18px; + font-size: 14px; +} + +/* ============================================================================= + Legend + ============================================================================= */ +.project-timeline-legend { + max-width: 500px; + margin: 32px auto 0; + padding: 20px 24px; + background: var(--primitive-white, #fff); + border: 1px solid var(--primitive-gray-300, #e6e6e6); +} + +.project-timeline-legend__list { + display: flex; + flex-wrap: wrap; + gap: 20px; + list-style: none; + margin: 0; + padding: 0; +} + +.project-timeline-legend__list::before { + content: "Forklaring"; + display: block; + width: 100%; + margin-bottom: 14px; + font-size: 12px; + font-weight: 700; + color: var(--primitive-black, #333); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.project-timeline-legend__item { + display: flex; + align-items: center; + gap: 8px; +} + +.project-timeline-legend__dot { + width: 12px; + height: 12px; + min-width: 12px; + min-height: 12px; + border-radius: 50%; + background: var(--primitive-gray-400, #dfdfdf); + flex-shrink: 0; +} + +.project-timeline-legend__label { + font-size: 13px; + color: var(--primitive-gray-800, #525252); +} + +/* Legend status colors */ +.project-timeline-legend__item--completed .project-timeline-legend__dot { + background: var(--timeline-status-completed); +} + +.project-timeline-legend__item--current .project-timeline-legend__dot { + background: var(--primitive-white, #fff); + border: 2px solid var(--timeline-status-current); +} + +.project-timeline-legend__item--upcoming .project-timeline-legend__dot { + background: var(--timeline-status-upcoming); +} + +.project-timeline-legend__item--note .project-timeline-legend__dot { + background: var(--timeline-status-note); +} + +/* ============================================================================= + Responsive: Mobile + ============================================================================= */ +@media (max-width: 767px) { + .project-timeline__header { + flex-direction: column; + } + + .project-timeline__view-toggle { + align-self: flex-start; + } + + .project-timeline__vertical { + display: none !important; + } + + .project-timeline__horizontal { + display: block !important; + } + + .project-timeline__horizontal[hidden] { + display: block !important; + } + + .project-timeline-mini-nav { + display: none; + } + + /* Mobile carousel card layout */ + .project-timeline__horizontal .project-timeline-card__content { + flex-direction: column; + min-height: auto; + } + + .project-timeline__horizontal .project-timeline-card__image-wrapper { + width: 100%; + height: 160px; + min-height: 160px; + border-right: none; + border-bottom: 1px solid var(--primitive-gray-200, #eaeaea); + } + + .project-timeline__horizontal .project-timeline-card__header { + padding: 16px; + } + + .project-timeline__horizontal .project-timeline-card__body { + padding: 8px 16px 16px; + } + + .project-timeline__horizontal .project-timeline-card__title { + font-size: 18px; + } + + .project-timeline__horizontal .project-timeline-card__description { + font-size: 13px; + } + + .project-timeline__horizontal .project-timeline-card__footer { + padding: 0 16px 16px; + } + + .project-timeline__horizontal .project-timeline-card__link { + padding: 8px 14px; + font-size: 13px; + } + + .project-timeline__horizontal + .project-timeline-card__content:has(.project-timeline-card__image-wrapper) + .project-timeline-card__status { + top: 168px; + } +} + +/* ============================================================================= + Responsive: Tablet - Show vertical but with adjusted layout + ============================================================================= */ +@media (min-width: 768px) and (max-width: 991px) { + .project-timeline-card__content { + width: calc(50% - 24px); + } + + .project-timeline-card::after { + width: 24px; + } + + .project-timeline-card:nth-child(odd)::after { + left: calc(50% - 24px); + } +} + +/* ============================================================================= + Reduced Motion + ============================================================================= */ +@media (prefers-reduced-motion: reduce) { + .project-timeline-card__content, + .project-timeline-card__link-icon, + .project-timeline-mini-nav__dot, + .project-timeline-mini-nav__label, + .project-timeline__carousel-track, + .project-timeline__view-btn, + .project-timeline__carousel-btn, + .project-timeline-card__link { + transition: none; + } +} + +/* ============================================================================= + Print Styles + ============================================================================= */ +@media print { + .project-timeline__view-toggle, + .project-timeline-mini-nav, + .project-timeline__horizontal { + display: none !important; + } + + .project-timeline__vertical { + display: block !important; + } + + .project-timeline__vertical[hidden] { + display: block !important; + } + + .project-timeline-card { + margin-bottom: 24px; + opacity: 1 !important; + } + + .project-timeline-card__content { + break-inside: avoid; + box-shadow: none; + border: 1px solid #000; + } +} diff --git a/web/themes/custom/hoeringsportal/assets/css/module/timeline.scss b/web/themes/custom/hoeringsportal/assets/css/module/timeline.scss deleted file mode 100644 index c52d6d50a..000000000 --- a/web/themes/custom/hoeringsportal/assets/css/module/timeline.scss +++ /dev/null @@ -1,183 +0,0 @@ -.timeline { - --petroleum-500: #008486; - --petroleum-100: #e5eef0; - --orange-500: #ff5f31; - --gray-100: #f6f6f6; - --gray-300: #e6e6e6; - --gray-600: #9d9d9d; - --gray-800: #525252; - --white: #fff; - --black: #333; - --note-500: #3661D8; - --note-200: #c4d4f5; - --pink-500: #e91e63; - --pink-100: #fce4ec; - - font-family: system-ui, -apple-system, sans-serif; - position: relative; - - /* Global Item Styles */ - .timeline-card { - background: var(--white); - border: 1px solid var(--gray-300); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); - transition: box-shadow 0.2s ease; - overflow: hidden; - - &:hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); - } - } - - .status-badge { - display: inline-flex; - padding: 4px 10px; - font-size: 12px; - font-weight: 600; - margin-bottom: 12px; - } - - /* Vertical Timeline Specifics */ - .timeline--vertical { - max-width: 900px; - margin: 0 auto; - padding: 40px 0; - - .timeline-track { - position: absolute; - left: 50%; - top: 0; - bottom: 0; - width: 2px; - background: linear-gradient(180deg, var(--gray-800) 0%, var(--gray-800) 72%, var(--gray-300) 80%, var(--gray-300) 100%); - transform: translateX(-50%); - } - - .timeline-item { - display: flex; - position: relative; - margin-bottom: -60px; - width: 100%; - - &.item--left { justify-content: flex-start; } - &.item--right { justify-content: flex-end; } - - .item-dot { - position: absolute; - left: 50%; - top: 24px; - transform: translateX(-50%); - width: 12px; - height: 12px; - border-radius: 50%; - background: var(--petroleum-500); - border: 3px solid var(--white); - box-shadow: 0 2px 6px rgba(0,0,0,0.15); - z-index: 10; - } - - .timeline-card { - width: calc(50% - 32px); - } - } - } - - /* Horizontal Timeline Specifics */ - .timeline-horizontal { - max-width: 900px; - margin: 0 auto; - padding: 24px 0; - - .progress-bar { - height: 4px; - background: var(--gray-300); - margin-bottom: 24px; - position: relative; - - .progress-fill { - position: absolute; - height: 100%; - background: var(--petroleum-500); - transition: width 0.4s ease; - } - } - - .horizontal-nav { - display: flex; - justify-content: space-between; - margin-bottom: 32px; - - .nav-point { - display: flex; - flex-direction: column; - align-items: center; - background: none; - border: none; - cursor: pointer; - opacity: 0.4; - - &.active { opacity: 1; } - - .point-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--gray-400); - margin-bottom: 6px; - } - } - } - - .slider-container { - overflow: hidden; - .slider-track { - display: flex; - transition: transform 0.4s ease; - } - } - } - - /* Mini Timeline (Sticky Navigation) */ - #mini-timeline { - position: fixed; - right: 20px; - top: 50%; - transform: translateY(-50%); - z-index: 100; - display: flex; - flex-direction: column; - align-items: center; - padding: 16px 10px; - background: var(--white); - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); - border: 1px solid var(--gray-300); - - .mini-dot { - width: 10px; - height: 10px; - border-radius: 50%; - margin: 4px 0; - border: none; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { transform: scale(1.3); } - &.active { outline: 2px solid var(--petroleum-200); outline-offset: 2px; } - } - } - - /* Mobile Adjustments */ - @media (max-width: 768px) { - &.timeline--vertical { - .timeline-track { left: 16px; transform: none; } - .timeline-item { - justify-content: flex-end; - padding-left: 40px; - margin-bottom: 24px; - .item-dot { left: 10px; transform: none; } - .timeline-card { width: 100%; } - } - } - #mini-timeline { display: none; } - } -} diff --git a/web/themes/custom/hoeringsportal/assets/js/hoeringsportal.js b/web/themes/custom/hoeringsportal/assets/js/hoeringsportal.js index 7f95a17d5..5dbd8a852 100755 --- a/web/themes/custom/hoeringsportal/assets/js/hoeringsportal.js +++ b/web/themes/custom/hoeringsportal/assets/js/hoeringsportal.js @@ -22,7 +22,6 @@ require("./modify-dialogue-proposal-comments.js"); require("./animated-svg.js"); require("./accordion.js"); require("./timeline.js"); -require("./mini-timeline.js"); require("./accordion.js"); // Enable popovers. diff --git a/web/themes/custom/hoeringsportal/assets/js/timeline.js b/web/themes/custom/hoeringsportal/assets/js/timeline.js index c47cb07b9..a5a029fc1 100644 --- a/web/themes/custom/hoeringsportal/assets/js/timeline.js +++ b/web/themes/custom/hoeringsportal/assets/js/timeline.js @@ -1,31 +1,325 @@ -import { initMiniTimeline } from './mini-timeline.js'; - -document.addEventListener('DOMContentLoaded', () => { - // Fetch the data - fetch('/themes/custom/hoeringsportal/assets/timeline-data.json') - .then(response => response.json()) - .then(data => { - initMiniTimeline(data.timelineData); - initScrollTracking(); - }); - - const viewToggle = document.getElementById('view-toggle'); - viewToggle?.addEventListener('click', (e) => { - const isHorizontal = document.body.classList.toggle('view-horizontal'); - e.target.textContent = isHorizontal ? '↕ Vertikal' : '↔ Horisontal'; - }); -}); - -function initScrollTracking() { - const observer = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - // Handle active state in mini-timeline - const id = entry.target.id.split('-')[1]; - console.log(`Now viewing item: ${id}`); - } - }); - }, { threshold: 0.5 }); - - document.querySelectorAll('.timeline-item').forEach(item => observer.observe(item)); -} +/** + * @file + * Project Timeline JavaScript. + * + * Provides view mode toggle, scroll tracking, and carousel navigation. + */ + +(function (Drupal, once) { + "use strict"; + + /** + * Timeline component initialization. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.projectTimeline = { + attach: function (context) { + var timelines = once( + "project-timeline", + "[data-project-timeline]", + context, + ); + + timelines.forEach(function (timeline) { + initTimeline(timeline); + }); + }, + }; + + /** + * Initialize a single timeline component. + * + * @param {HTMLElement} timeline + * The timeline container element. + */ + function initTimeline(timeline) { + var state = { + currentView: timeline.dataset.defaultView || "vertical", + carouselIndex: 0, + carouselTotal: 0, + observer: null, + }; + + // Cache DOM elements + var elements = { + viewButtons: timeline.querySelectorAll("[data-view]"), + verticalPanel: timeline.querySelector("#project-timeline-vertical"), + horizontalPanel: timeline.querySelector("#project-timeline-horizontal"), + miniNav: timeline.querySelector("[data-mini-nav]"), + cards: timeline.querySelectorAll("[data-timeline-card]"), + carouselTrack: timeline.querySelector("[data-carousel-track]"), + carouselSlides: timeline.querySelectorAll("[data-carousel-slide]"), + carouselPrev: timeline.querySelector("[data-carousel-prev]"), + carouselNext: timeline.querySelector("[data-carousel-next]"), + carouselCurrent: timeline.querySelector("[data-carousel-current]"), + carouselTotal: timeline.querySelector("[data-carousel-total]"), + navLinks: timeline.querySelectorAll("[data-nav-link]"), + }; + + state.carouselTotal = elements.carouselSlides.length; + + // Initialize components + initViewToggle(); + initMiniNavigation(); + initCarousel(); + initScrollTracking(); + initKeyboardNavigation(); + + /** + * Initialize view mode toggle buttons. + */ + function initViewToggle() { + elements.viewButtons.forEach(function (button) { + button.addEventListener("click", function () { + var view = button.dataset.view; + if (view !== state.currentView) { + switchView(view); + } + }); + }); + } + + /** + * Switch between vertical and horizontal views. + * + * @param {string} view + * The view to switch to ('vertical' or 'horizontal'). + */ + function switchView(view) { + state.currentView = view; + + // Update button states + elements.viewButtons.forEach(function (btn) { + var isSelected = btn.dataset.view === view; + btn.setAttribute("aria-selected", isSelected ? "true" : "false"); + }); + + // Toggle panel visibility + if (view === "vertical") { + elements.verticalPanel.removeAttribute("hidden"); + elements.horizontalPanel.setAttribute("hidden", ""); + } else { + elements.horizontalPanel.removeAttribute("hidden"); + elements.verticalPanel.setAttribute("hidden", ""); + updateCarouselPosition(); + } + } + + /** + * Initialize mini navigation click handlers. + */ + function initMiniNavigation() { + elements.navLinks.forEach(function (link) { + link.addEventListener("click", function (e) { + e.preventDefault(); + var cardId = link.dataset.navLink; + var targetCard = timeline.querySelector( + '[data-card-id="' + cardId + '"]', + ); + if (targetCard) { + targetCard.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }); + }); + } + + /** + * Initialize scroll tracking with IntersectionObserver. + */ + function initScrollTracking() { + if (!("IntersectionObserver" in window)) { + return; + } + + var observerOptions = { + root: null, + rootMargin: "-30% 0px -30% 0px", + threshold: 0, + }; + + state.observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + var cardId = entry.target.dataset.cardId; + updateActiveNavLink(cardId); + } + }); + }, observerOptions); + + elements.cards.forEach(function (card) { + state.observer.observe(card); + }); + } + + /** + * Update active state on mini navigation links. + * + * @param {string} cardId + * The ID of the active card. + */ + function updateActiveNavLink(cardId) { + elements.navLinks.forEach(function (link) { + var isActive = link.dataset.navLink === cardId; + link.classList.toggle("is-active", isActive); + }); + } + + /** + * Initialize carousel navigation. + */ + function initCarousel() { + if (elements.carouselPrev) { + elements.carouselPrev.addEventListener("click", function () { + goToSlide(state.carouselIndex - 1); + }); + } + + if (elements.carouselNext) { + elements.carouselNext.addEventListener("click", function () { + goToSlide(state.carouselIndex + 1); + }); + } + + // Touch/swipe support + initTouchNavigation(); + } + + /** + * Navigate to a specific carousel slide. + * + * @param {number} index + * The slide index to navigate to. + */ + function goToSlide(index) { + // Clamp index to valid range + index = Math.max(0, Math.min(index, state.carouselTotal - 1)); + state.carouselIndex = index; + updateCarouselPosition(); + } + + /** + * Update carousel position and button states. + */ + function updateCarouselPosition() { + if (elements.carouselTrack) { + var offset = state.carouselIndex * -100; + elements.carouselTrack.style.transform = "translateX(" + offset + "%)"; + } + + // Update indicator + if (elements.carouselCurrent) { + elements.carouselCurrent.textContent = state.carouselIndex + 1; + } + + // Update button states + if (elements.carouselPrev) { + elements.carouselPrev.disabled = state.carouselIndex === 0; + } + if (elements.carouselNext) { + elements.carouselNext.disabled = + state.carouselIndex >= state.carouselTotal - 1; + } + } + + /** + * Initialize touch/swipe navigation for carousel. + */ + function initTouchNavigation() { + if (!elements.carouselTrack) { + return; + } + + var touchState = { + startX: 0, + startY: 0, + deltaX: 0, + isSwiping: false, + }; + + elements.carouselTrack.addEventListener( + "touchstart", + function (e) { + touchState.startX = e.touches[0].clientX; + touchState.startY = e.touches[0].clientY; + touchState.isSwiping = false; + }, + { passive: true }, + ); + + elements.carouselTrack.addEventListener( + "touchmove", + function (e) { + touchState.deltaX = e.touches[0].clientX - touchState.startX; + var deltaY = e.touches[0].clientY - touchState.startY; + + // Determine if horizontal swipe + if ( + !touchState.isSwiping && + Math.abs(touchState.deltaX) > Math.abs(deltaY) + ) { + touchState.isSwiping = true; + } + }, + { passive: true }, + ); + + elements.carouselTrack.addEventListener("touchend", function () { + if (touchState.isSwiping) { + var threshold = 50; + if (touchState.deltaX > threshold) { + goToSlide(state.carouselIndex - 1); + } else if (touchState.deltaX < -threshold) { + goToSlide(state.carouselIndex + 1); + } + } + touchState.deltaX = 0; + touchState.isSwiping = false; + }); + } + + /** + * Initialize keyboard navigation. + */ + function initKeyboardNavigation() { + timeline.addEventListener("keydown", function (e) { + // Only handle keyboard navigation when carousel is visible + if (state.currentView !== "horizontal") { + return; + } + + if (e.key === "ArrowLeft") { + e.preventDefault(); + goToSlide(state.carouselIndex - 1); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + goToSlide(state.carouselIndex + 1); + } + }); + + // View toggle keyboard navigation + elements.viewButtons.forEach(function (button) { + button.addEventListener("keydown", function (e) { + var buttons = Array.from(elements.viewButtons); + var currentIndex = buttons.indexOf(button); + + if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + var prevIndex = + (currentIndex - 1 + buttons.length) % buttons.length; + buttons[prevIndex].focus(); + buttons[prevIndex].click(); + } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + var nextIndex = (currentIndex + 1) % buttons.length; + buttons[nextIndex].focus(); + buttons[nextIndex].click(); + } + }); + }); + } + } +})(Drupal, once); diff --git a/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig b/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig new file mode 100644 index 000000000..af95f17ee --- /dev/null +++ b/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig @@ -0,0 +1,104 @@ +{# +/** + * @file + * Individual timeline card template. + * + * Available variables: + * - item: Timeline item with: + * - id: Unique identifier. + * - date: Date in Y-m-d format. + * - month: Month name for display. + * - title: Card title. + * - subtitle: Optional subtitle. + * - description: Card description text. + * - status: One of 'completed', 'current', 'upcoming', 'note'. + * - image: Optional image URL. + * - link: Optional link URL. + * - linkText: Optional link text. + * - accentColor: Optional 'pink' or 'blue' for color override. + */ +#} +{% set status_classes = { + completed: 'project-timeline-card--completed', + current: 'project-timeline-card--current', + upcoming: 'project-timeline-card--upcoming', + note: 'project-timeline-card--note', +} %} + +{% set status_labels = { + completed: '✓ Afsluttet'|t, + current: 'I gang'|t, + upcoming: 'Kommende'|t, + note: 'Note'|t, +} %} + +{% set accent_class = item.accentColor ? 'project-timeline-card--accent-' ~ item.accentColor : '' %} + +
+ {# Timeline connector dot #} +
+ +
+ + {# Card content #} +
+ {# Image (if present) - appears first for proper visual stacking #} + {% if item.image %} +
+ +
+ {% endif %} + + {# Status badge (absolutely positioned) #} + + {{ status_labels[item.status]|default(item.status) }} + + + {# Header with date #} +
+
+ +
+
+ + {# Body #} +
+

{{ item.title }}

+ + {% if item.subtitle %} +

{{ item.subtitle }}

+ {% endif %} + + {% if item.description %} +

{{ item.description }}

+ {% endif %} + + {% if item.link %} + + {% endif %} +
+ + {# Link (if present) #} + +
+
diff --git a/web/themes/custom/hoeringsportal/templates/components/project-timeline-legend.html.twig b/web/themes/custom/hoeringsportal/templates/components/project-timeline-legend.html.twig new file mode 100644 index 000000000..ec5754c15 --- /dev/null +++ b/web/themes/custom/hoeringsportal/templates/components/project-timeline-legend.html.twig @@ -0,0 +1,19 @@ +{# +/** + * @file + * Status legend for timeline. + * + * Available variables: + * - items: Array of legend items with: status, label. + */ +#} +
+
    + {% for item in items %} +
  • + + {{ item.label }} +
  • + {% endfor %} +
+
diff --git a/web/themes/custom/hoeringsportal/templates/components/project-timeline-mini-nav.html.twig b/web/themes/custom/hoeringsportal/templates/components/project-timeline-mini-nav.html.twig new file mode 100644 index 000000000..4e3d05eb1 --- /dev/null +++ b/web/themes/custom/hoeringsportal/templates/components/project-timeline-mini-nav.html.twig @@ -0,0 +1,36 @@ +{# +/** + * @file + * Mini navigation sidebar for vertical timeline view. + * + * Available variables: + * - items: Array of timeline items with: id, month, status, title. + */ +#} + diff --git a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig index aef50630b..6c543f522 100644 --- a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig +++ b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig @@ -1,101 +1,259 @@ -{% set data = { - "images": { - "logo": "https://deltag.aarhus.dk/themes/custom/hoeringsportal/static/images/AAK_02_venstrejusteret_sh.svg", - "logoWhite": "https://deltag.aarhus.dk/themes/custom/hoeringsportal/static/images/AAK_02_venstrejusteret_hvid.svg", - "hero": "https://deltag.aarhus.dk/sites/default/files/images/IMG_1251_cropped_0.jpg", - "gaatur": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg", - "workshop": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1227%20%281%29%20-%20cropped.jpg", - "popup": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Billede3.jpg", - "digital": "https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Kategorier_stor_0.png" - }, +{% set items = { "timelineData": [ { - "id": 1, - "date": "2023", - "month": "", - "title": "Udpegning", - "subtitle": "Strategisk Byrumsplan", - "description": "Hjortshøj Stationsplads udpeges som ét af 13 byrum i Aarhus Kommunes Strategiske Byrumsplan.", - "status": "completed", - "link": "https://aarhus.dk/nyt/teknik-og-miljoe/2023/august-2023/raadmand-vil-puste-nyt-liv-i-13-af-byens-rum", - "linkText": "Læs om byrumsplanen" + 'id' : 'item-1', + 'date' : '2023-08-01', + 'month' : 'August 2023', + 'title' : 'Udpegning', + 'subtitle' : 'Dialog', + 'description' : 'Hjortshøj Stationsplads udpeges som ét af 13 byrum i Aarhus Kommunes Strategiske Byrumsplan.', + 'status' : 'completed', + 'image' : NULL, + 'link' : 'https://aarhus.dk/nyt/teknik-og-miljoe/2023/august-2023/raadmand-vil-puste-nyt-liv-i-13-af-byens-rum', + 'linkText' : 'Læs om byrumsplanen', + 'accentColor' : NULL, }, { - "id": 2, - "date": "2025", - "month": "Forår", - "title": "Dialog med Fællesråd", - "subtitle": "Hjortshøj Landsbyforum", - "description": "Gåtur på Stationspladsen og efterfølgende møde om stedets identitet og potentialer med fællesrådet.", - "status": "completed", - "image": "gaatur", - "link": "https://deltag.aarhus.dk/public_meeting/1296", - "linkText": "Se detaljer om mødet" + 'id' : 'item-2', + 'date' : '2025-04-01', + 'month' : 'April 2025', + 'title' : 'Dialog med Fællesråd', + 'subtitle' : 'Dialog', + 'description' : 'Gåtur på Stationspladsen og efterfølgende møde om stedets identitet og potentialer med fællesrådet.', + 'status' : 'completed', + 'image' : 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg', + 'link' : '/public_meeting/1296', + 'linkText' : 'Se detaljer om mødet', + 'accentColor' : NULL, }, { - "id": 3, - "date": "2025", - "month": "August", - "title": "Workshop med Virupskolen", - "subtitle": "Børne- og ungeperspektiv", - "description": "Formiddag sammen med skoleelever fra Virupskolen om fremtidens stationsplads.", - "status": "completed", - "image": "workshop", - "link": "https://deltag.aarhus.dk/public_meeting/1298", - "linkText": "Se workshoppen", - "accentColor": "pink" + 'id' : 'item-3', + 'date' : '2025-08-01', + 'month' : 'August 2025', + 'title' : 'Workshop med Virupskolen', + 'subtitle' : 'Begivenhed', + 'description' : 'Formiddag sammen med skoleelever fra Virupskolen om fremtidens stationsplads.', + 'status' : 'completed', + 'image' : 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1227%20%281%29%20-%20cropped.jpg', + 'link' : '/public_meeting/1298', + 'linkText' : 'Se workshoppen', + 'accentColor' : 'pink', }, { - "id": 6, - "date": "2025", - "month": "Efterår", - "title": "Udarbejdelse af Forslag", - "subtitle": "Design & Planlægning", - "description": "De mange input danner fundament for udarbejdelse af et forslag til omdannelsen af Hjortshøj Stationsplads.", - "status": "current" + 'id' : 'item-4', + 'date' : '2025-08-15', + 'month' : 'August 2025', + 'title' : 'Pop-up Dialog', + 'subtitle' : 'Dialog', + 'description' : 'Pop-up og kaffe på Stationspladsen med borgere. Mange gode input fra det brede lokale borgerperspektiv.', + 'status' : 'completed', + 'image' : 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Kategorier_stor_0.png', + 'link' : '/public_meeting/1212', + 'linkText' : 'Se pop-up dialogen', + 'accentColor' : NULL, }, { - "id": 9, - "date": "Note", - "month": "", - "title": "Redaktionel bemærkning", - "subtitle": "Note", - "description": "Dette er en redaktionel note fra projektteamet med vigtig information om projektet.", - "status": "note", - "accentColor": "blue" + 'id' : 'item-5', + 'date' : '2025-09-01', + 'month' : 'September 2025', + 'title' : 'Digital Dialog', + 'subtitle' : 'Høring', + 'description' : 'Mulighed for at berige og kvalificere de identificerede temaer, samt komme med supplerende nye input.', + 'status' : 'completed', + 'image' : 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg', + 'link' : '/public_meeting/1299', + 'linkText' : 'Se den digitale dialog', + 'accentColor' : NULL, + }, + { + 'id' : 'item-6', + 'date' : '2025-10-01', + 'month' : 'Oktober 2025', + 'title' : 'Udarbejdelse af Forslag', + 'subtitle' : 'Dialog', + 'description' : 'De mange input danner fundament for udarbejdelse af et forslag til omdannelsen af Hjortshøj Stationsplads.', + 'status' : 'current', + 'image' : NULL, + 'link' : NULL, + 'linkText' : NULL, + 'accentColor' : NULL, + }, + { + 'id' : 'item-7', + 'date' : '2025-10-15', + 'month' : 'Oktober 2025', + 'title' : 'Redaktionel bemærkning', + 'subtitle' : 'Note', + 'description' : 'Dette er en redaktionel note fra projektteamet med vigtig information om projektet.', + 'status' : 'note', + 'image' : NULL, + 'link' : NULL, + 'linkText' : NULL, + 'accentColor' : 'blue', + }, + { + 'id' : 'item-8', + 'date' : '2026-01-01', + 'month' : 'Januar 2026', + 'title' : 'Offentlig Høring', + 'subtitle' : 'Høring', + 'description' : 'Forslaget til omdannelse af Stationspladsen sendes i offentlig høring.', + 'status' : 'upcoming', + 'image' : NULL, + 'link' : NULL, + 'linkText' : NULL, + 'accentColor' : NULL, + }, + { + 'id' : 'item-9', + 'date' : '2027-01-01', + 'month' : 'Januar 2027', + 'title' : 'Realisering', + 'subtitle' : 'Note', + 'description' : 'Omdannelsen af Hjortshøj Stationsplads realiseres.', + 'status' : 'upcoming', + 'image' : NULL, + 'link' : NULL, + 'linkText' : NULL, + 'accentColor' : NULL, } ] } %} -
-
-

Projektets tidslinje

- -
+{% set default_view = 'horizontal' %} -
-
+{# +/** + * @file + * Project timeline block template. + * + * Available variables: + * - items: Array of timeline items with: id, date, month, title, subtitle, + * description, status (completed|current|upcoming|note), image, link, + * linkText, accentColor (pink|blue|null). + * - legend_items: Array of legend items with: status, label, color. + * - default_view: Default view mode ('vertical' or 'horizontal'). + * - title: Optional timeline title (defaults to 'Projektets tidslinje'). + * - description: Optional timeline description. + */ +#} +
+ {# Header with title and view toggle - wrapped in container to match site layout #} +
+
+
+

{{ title|default('Projektets tidslinje'|t) }}

+ {% if description is not empty %} +

{{ description }}

+ {% else %} +

{{ 'Følg med i inddragelsesprocessen og se hvordan dit input former fremtidens projekt.'|t }}

+ {% endif %} +
+ + {# View mode toggle #} +
+ + +
+
+
- {% for item in data.timelineData %} -
-
- {% if item.image %} - {{ item.title }} - {% endif %} + {# Vertical view (default desktop) #} +
+
+ {# Mini navigation sidebar #} + {{ include(directory ~ '/templates/components/project-timeline-mini-nav.html.twig', {items: items.timelineData}) }} + {# Timeline cards #} +
+ {% for item in items.timelineData %} + {{ include(directory ~ '/templates/components/project-timeline-card.html.twig', {item: item}) }} + {% endfor %} +
+
+
-
- - {{ item.month }} {{ item.date }} - -

{{ item.title }}

-

{{ item.description }}

+ {# Horizontal view (carousel) #} +
+ {# Carousel navigation #} + - {% if item.link %} - {{ item.linkText }} → - {% endif %} + {# Carousel track #} + - {% endfor %} +
+ + {# Legend #} + {% if legend_items|length > 0 %} + {{ include(directory ~ '/templates/components/project-timeline-card.html.twig', {items: legend_items}) }} + {% endif %}
diff --git a/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig b/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig index 0207c6f77..c43cb092e 100755 --- a/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig +++ b/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig @@ -41,7 +41,6 @@
{{ include(directory ~ '/templates/components/timeline.html.twig') }} -
From 9a8a9a100f15c5c2258c28f17b2beb63a1d57be0 Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Thu, 22 Jan 2026 12:11:52 +0100 Subject: [PATCH 03/49] Refactored timeline CSS to follow project conventions - Replaced hardcoded hex colors with project color tokens - Removed font-family override to inherit from theme - Created SCSS mixins for status and accent variant styles - Used @for loop for z-index declarations - Replaced magic numbers with SCSS variables - Converted CSS comments to SCSS comments - Used SCSS nesting for better organization Co-Authored-By: Claude Opus 4.5 --- .../assets/css/module/_timeline.scss | 1310 ++++++++--------- 1 file changed, 635 insertions(+), 675 deletions(-) diff --git a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss index d29eb689c..4e523cc98 100644 --- a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss +++ b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss @@ -9,60 +9,160 @@ * - Fixed mini-nav on right side */ -/* ============================================================================= - CSS Custom Properties - ============================================================================= */ -.project-timeline { - /* Status colors */ - --timeline-status-completed: var(--primitive-petroleum-500, #008486); - --timeline-status-completed-bg: var(--primitive-petroleum-100, #e5eef0); - --timeline-status-current: var(--primitive-orange-500, #ff5f31); - --timeline-status-current-bg: var(--primitive-gray-100, #f6f6f6); - --timeline-status-upcoming: var(--primitive-gray-600, #9d9d9d); - --timeline-status-upcoming-bg: var(--primitive-gray-200, #eaeaea); - --timeline-status-note: #3661d8; - --timeline-status-note-bg: #c4d4f5; - - /* Accent colors */ - --timeline-accent-pink: #e91e63; - --timeline-accent-pink-bg: #fce4ec; - --timeline-accent-blue: #3661d8; - --timeline-accent-blue-bg: #c4d4f5; - - /* Layout */ - --timeline-max-width: 900px; - --timeline-card-gap: 32px; - --timeline-line-width: 2px; - --timeline-dot-size: 12px; - - /* Base styles */ - font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; -} - -/* ============================================================================= - Main Container - ============================================================================= */ +// ============================================================================= +// SCSS Variables & Mixins +// ============================================================================= + +// Layout variables +$timeline-max-width: 900px; +$timeline-card-gap: $spacer * 2; // 32px +$timeline-line-width: 2px; +$timeline-dot-size: 12px; +$timeline-card-padding: $spacer * 1.5; // 24px +$timeline-card-padding-sm: $spacer; // 16px + +// Status color mappings using project tokens +$timeline-statuses: ( + "completed": ( + "color": var(--primitive-petroleum-500), + "bg": var(--primitive-petroleum-100), + "hover": var(--primitive-petroleum-800), + ), + "current": ( + "color": var(--primitive-orange-500), + "bg": var(--primitive-gray-100), + "hover": var(--primitive-orange-500), + ), + "upcoming": ( + "color": var(--primitive-gray-600), + "bg": var(--primitive-gray-200), + "hover": var(--primitive-gray-700), + ), + "note": ( + "color": var(--primitive-blue-500), + "bg": var(--primitive-blue-200), + "hover": var(--primitive-navy-500), + ), +); + +// Accent color mappings using project tokens +$timeline-accents: ( + "pink": ( + "color": var(--primitive-pink-500), + "bg": var(--primitive-pink-100), + "hover": var(--primitive-pink-500), + ), + "blue": ( + "color": var(--primitive-blue-500), + "bg": var(--primitive-blue-200), + "hover": var(--primitive-navy-500), + ), +); + +// Mixin for generating status variant styles +@mixin timeline-card-status-variant($color, $bg, $hover) { + .project-timeline-card__dot { + background: $color; + } + + &::after { + background: $color; + } + + .project-timeline-card__content::before { + background: $color; + } + + .project-timeline-card__date-wrapper { + background: $bg; + } + + .project-timeline-card__status { + background: $color; + } + + .project-timeline-card__subtitle { + color: $color; + } + + .project-timeline-card__link { + background: $color; + + &:hover { + background: $hover; + color: var(--primitive-white); + } + } +} + +// Mixin for accent variants (includes date wrapper color) +@mixin timeline-card-accent-variant($color, $bg, $hover) { + @include timeline-card-status-variant($color, $bg, $hover); + + .project-timeline-card__date-wrapper { + color: $color; + } +} + +// Mixin for mini-nav status dot colors +@mixin timeline-mini-nav-status($color, $is-current: false) { + .project-timeline-mini-nav__dot { + @if $is-current { + background: var(--primitive-white); + border: 2px solid $color; + } @else { + background: $color; + } + } +} + +// ============================================================================= +// CSS Custom Properties +// ============================================================================= .project-timeline { + // Status colors + --timeline-status-completed: var(--primitive-petroleum-500); + --timeline-status-completed-bg: var(--primitive-petroleum-100); + --timeline-status-current: var(--primitive-orange-500); + --timeline-status-current-bg: var(--primitive-gray-100); + --timeline-status-upcoming: var(--primitive-gray-600); + --timeline-status-upcoming-bg: var(--primitive-gray-200); + --timeline-status-note: var(--primitive-blue-500); + --timeline-status-note-bg: var(--primitive-blue-200); + + // Accent colors + --timeline-accent-pink: var(--primitive-pink-500); + --timeline-accent-pink-bg: var(--primitive-pink-100); + --timeline-accent-blue: var(--primitive-blue-500); + --timeline-accent-blue-bg: var(--primitive-blue-200); + + // Layout + --timeline-max-width: #{$timeline-max-width}; + --timeline-card-gap: #{$timeline-card-gap}; + --timeline-line-width: #{$timeline-line-width}; + --timeline-dot-size: #{$timeline-dot-size}; + + // Main container styles position: relative; - padding: 1rem 0; + padding: $spacer 0; cursor: default; } -/* Override theme's grab cursor from old Vis.js timeline */ +// Override theme's grab cursor from old Vis.js timeline .project-timeline[data-project-timeline] div { cursor: default !important; } -/* ============================================================================= - Timeline Header (Title + Description + View Toggle) - ============================================================================= */ +// ============================================================================= +// Timeline Header (Title + Description + View Toggle) +// ============================================================================= .project-timeline__header { display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; - gap: 16px; - margin-bottom: 24px; + gap: $spacer; + margin-bottom: $timeline-card-padding; } .project-timeline__header-content { @@ -71,26 +171,26 @@ } .project-timeline__title { - margin: 0 0 8px; - font-size: 24px; - font-weight: 700; - color: var(--primitive-black, #333); + margin: 0 0 ($spacer * 0.5); + font-size: $h1-font-size; + font-weight: $headings-font-weight; + color: var(--text-primary); } .project-timeline__description { margin: 0; - font-size: 16px; - line-height: 1.5; - color: var(--primitive-gray-700, #858585); + font-size: $font-size-base; + line-height: $line-height-base; + color: var(--text-secondary); max-width: 600px; } -/* ============================================================================= - View Toggle - ============================================================================= */ +// ============================================================================= +// View Toggle +// ============================================================================= .project-timeline__view-toggle { display: inline-flex; - background: var(--primitive-gray-100, #f6f6f6); + background: var(--bg-secondary); padding: 4px; flex-shrink: 0; } @@ -100,14 +200,28 @@ align-items: center; justify-content: center; gap: 6px; - padding: 8px 16px; + padding: ($spacer * 0.5) $spacer; border: none; background: transparent; - color: var(--primitive-gray-800, #525252); + color: var(--text-primary); cursor: pointer; - font-size: 13px; - font-weight: 600; + font-size: $font-size-small; + font-weight: $font-weight-bold; transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + } + + &[aria-selected="true"] { + background: var(--interactive-primary); + color: var(--text-inverse); + } + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } } .project-timeline__view-icon { @@ -119,23 +233,9 @@ line-height: 1; } -.project-timeline__view-btn:hover { - color: var(--primitive-black, #333); -} - -.project-timeline__view-btn[aria-selected="true"] { - background: var(--primitive-petroleum-500, #008486); - color: var(--primitive-white, #fff); -} - -.project-timeline__view-btn:focus-visible { - outline: 2px solid var(--focus-ring-color, #008486); - outline-offset: 2px; -} - -/* ============================================================================= - Vertical View - Centered Timeline with Alternating Cards - ============================================================================= */ +// ============================================================================= +// Vertical View - Centered Timeline with Alternating Cards +// ============================================================================= .project-timeline__vertical { position: relative; } @@ -150,100 +250,74 @@ display: flex; flex-direction: column; position: relative; - padding: 40px 0 120px; + padding: ($spacer * 2.5) 0 ($spacer * 7.5); max-width: var(--timeline-max-width); margin: 0 auto; -} -/* Centered timeline vertical line */ -.project-timeline__cards::before { - content: ""; - position: absolute; - left: 50%; - top: 0; - bottom: 0; - width: var(--timeline-line-width); - background: linear-gradient( + // Centered timeline vertical line + &::before { + content: ""; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: var(--timeline-line-width); + background: linear-gradient( 180deg, - var(--primitive-gray-800, #525252) 0%, - var(--primitive-gray-800, #525252) 72%, - var(--primitive-gray-300, #e6e6e6) 80%, - var(--primitive-gray-300, #e6e6e6) 100% - ); - transform: translateX(-50%); + var(--primitive-gray-800) 0%, + var(--primitive-gray-800) 72%, + var(--border-default) 80%, + var(--border-default) 100% + ); + transform: translateX(-50%); + } } -/* ============================================================================= - Timeline Card - Alternating Layout - ============================================================================= */ +// ============================================================================= +// Timeline Card - Alternating Layout +// ============================================================================= .project-timeline-card { display: flex; position: relative; - margin-bottom: -60px; + margin-bottom: -($spacer * 3.75); // -60px overlap z-index: 1; -} -/* Alternating: odd cards align left, even cards align right */ -.project-timeline-card:nth-child(odd) { - justify-content: flex-start; -} + // Alternating: odd cards align left, even cards align right + &:nth-child(odd) { + justify-content: flex-start; + } -.project-timeline-card:nth-child(even) { - justify-content: flex-end; -} + &:nth-child(even) { + justify-content: flex-end; + } -/* Reset z-index for proper stacking */ -.project-timeline-card:nth-child(1) { - z-index: 10; -} -.project-timeline-card:nth-child(2) { - z-index: 9; -} -.project-timeline-card:nth-child(3) { - z-index: 8; -} -.project-timeline-card:nth-child(4) { - z-index: 7; -} -.project-timeline-card:nth-child(5) { - z-index: 6; -} -.project-timeline-card:nth-child(6) { - z-index: 5; -} -.project-timeline-card:nth-child(7) { - z-index: 4; -} -.project-timeline-card:nth-child(8) { - z-index: 3; -} -.project-timeline-card:nth-child(9) { - z-index: 2; -} -.project-timeline-card:nth-child(10) { - z-index: 1; -} + // Z-index stacking for overlapping cards (generated via loop) + @for $i from 1 through 10 { + &:nth-child(#{$i}) { + z-index: 11 - $i; + } + } -.project-timeline-card:last-child { - margin-bottom: 0; + &:last-child { + margin-bottom: 0; + } } -/* Timeline dot - centered, ensure circular shape */ +// Timeline dot - centered, ensure circular shape .project-timeline-card__connector { position: absolute; left: 50%; - top: 24px; + top: $timeline-card-padding; transform: translateX(-50%); z-index: 10; display: flex; align-items: center; justify-content: center; -} -/* Adjust dot position when card has image */ -.project-timeline-card:has(.project-timeline-card__image-wrapper) -.project-timeline-card__connector { - top: 60px; + // Adjust dot position when card has image + .project-timeline-card:has(.project-timeline-card__image-wrapper) & { + top: ($spacer * 3.75); // 60px + } } .project-timeline-card__dot { @@ -253,63 +327,71 @@ min-height: var(--timeline-dot-size); border-radius: 50%; background: var(--timeline-status-upcoming); - border: 3px solid var(--primitive-white, #fff); - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + border: 3px solid var(--bg-primary); + box-shadow: $shadow; flex-shrink: 0; } -/* Horizontal connector line from card to dot */ +// Horizontal connector line from card to dot .project-timeline-card::after { content: ""; position: absolute; top: 29px; - width: 32px; - height: 2px; + width: $timeline-card-gap; + height: $timeline-line-width; background: var(--timeline-status-upcoming); } -/* Adjust connector position when card has image */ +// Adjust connector position when card has image .project-timeline-card:has(.project-timeline-card__image-wrapper)::after { top: 65px; } -/* Connector position for odd cards (left side) */ +// Connector position for odd cards (left side) .project-timeline-card:nth-child(odd)::after { - left: calc(50% - 32px); + left: calc(50% - #{$timeline-card-gap}); } -/* Connector position for even cards (right side) */ +// Connector position for even cards (right side) .project-timeline-card:nth-child(even)::after { left: 50%; } -/* Card content container */ +// Card content container +$timeline-image-height: 140px; + .project-timeline-card__content { - width: calc(50% - 32px); - background: var(--primitive-white, #fff); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); - border: 1px solid var(--primitive-gray-300, #e6e6e6); + width: calc(50% - #{$timeline-card-gap}); + background: var(--card-bg); + box-shadow: $shadow; + border: 1px solid var(--card-border); transition: box-shadow 0.2s ease; position: relative; overflow: hidden; cursor: default; -} -.project-timeline-card__content:hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); -} + &:hover { + box-shadow: 0 4px 16px rgb(0 0 0 / 12%); + } -/* Accent bar on card edge */ -.project-timeline-card__content::before { - content: ""; - position: absolute; - top: 0; - width: 4px; - height: 100%; - background: var(--timeline-status-upcoming); + // Accent bar on card edge + &::before { + content: ""; + position: absolute; + top: 0; + width: 4px; + height: 100%; + background: var(--timeline-status-upcoming); + } + + // Move accent bar below image if present + &:has(.project-timeline-card__image-wrapper)::before { + top: $timeline-image-height; + height: calc(100% - #{$timeline-image-height}); + } } -/* Accent bar on right for odd cards, left for even */ +// Accent bar on right for odd cards, left for even .project-timeline-card:nth-child(odd) .project-timeline-card__content::before { right: 0; } @@ -318,20 +400,12 @@ left: 0; } -/* Move accent bar below image if present */ -.project-timeline-card__content:has( - .project-timeline-card__image-wrapper - )::before { - top: 140px; - height: calc(100% - 140px); -} - -/* Card header with date */ +// Card header with date .project-timeline-card__header { display: flex; justify-content: space-between; align-items: flex-start; - padding: 20px 24px 0; + padding: ($spacer * 1.25) $timeline-card-padding 0; } .project-timeline-card__date-wrapper { @@ -340,34 +414,39 @@ gap: 6px; padding: 4px 10px; background: var(--timeline-status-upcoming-bg); - font-size: 12px; - font-weight: 600; + font-size: $font-size-xs; + font-weight: $font-weight-bold; } .project-timeline-card__month { - color: var(--primitive-petroleum-800, #3d6d6d); + color: var(--primitive-petroleum-800); } -/* Status badge */ +// Status badge .project-timeline-card__status { position: absolute; - top: 8px; - right: 12px; - padding: 3px 8px; + top: ($spacer * 0.5); + right: ($spacer * 0.75); + padding: 3px ($spacer * 0.5); font-size: 10px; - font-weight: 600; + font-weight: $font-weight-bold; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--primitive-white, #fff); + color: var(--text-inverse); background: var(--timeline-status-upcoming); + + // When image is present, move status badge down + .project-timeline-card__content:has(.project-timeline-card__image-wrapper) & { + top: ($timeline-image-height + 8px); + } } -/* Image */ +// Image .project-timeline-card__image-wrapper { width: 100%; - height: 140px; + height: $timeline-image-height; overflow: hidden; - border-bottom: 1px solid var(--primitive-gray-200, #eaeaea); + border-bottom: 1px solid var(--border-subtle); } .project-timeline-card__image { @@ -376,29 +455,23 @@ object-fit: cover; } -/* When image is present, move status badge down */ -.project-timeline-card__content:has(.project-timeline-card__image-wrapper) -.project-timeline-card__status { - top: 148px; -} - -/* Card body */ +// Card body .project-timeline-card__body { - padding: 12px 24px 20px; + padding: ($spacer * 0.75) $timeline-card-padding ($spacer * 1.25); } .project-timeline-card__title { margin: 0 0 4px; - font-size: 17px; - font-weight: 700; - line-height: 1.3; - color: var(--primitive-black, #333); + font-size: $h3-font-size; + font-weight: $headings-font-weight; + line-height: $headings-line-height; + color: var(--text-primary); } .project-timeline-card__subtitle { - margin: 0 0 10px; - font-size: 12px; - font-weight: 600; + margin: 0 0 ($spacer * 0.625); + font-size: $font-size-xs; + font-weight: $font-weight-bold; color: var(--timeline-status-upcoming); text-transform: uppercase; letter-spacing: 0.03em; @@ -406,255 +479,135 @@ .project-timeline-card__description { margin: 0; - font-size: 14px; + font-size: $font-size-small; line-height: 1.55; - color: var(--primitive-gray-800, #525252); + color: var(--text-primary); } -/* Card footer with link */ +// Card footer with link .project-timeline-card__footer { - padding: 0 24px 20px; + padding: 0 $timeline-card-padding ($spacer * 1.25); } .project-timeline-card__link { display: inline-flex; align-items: center; gap: 6px; - padding: 8px 14px; + padding: ($spacer * 0.5) ($spacer * 0.875); background: var(--timeline-status-upcoming); - color: var(--primitive-white, #fff); - font-size: 13px; - font-weight: 600; + color: var(--text-inverse); + font-size: $font-size-small; + font-weight: $font-weight-bold; text-decoration: none; transition: background 0.2s ease; -} -.project-timeline-card__link:hover { - background: var(--primitive-gray-700, #858585); - color: var(--primitive-white, #fff); - text-decoration: none; + &:hover { + background: var(--primitive-gray-700); + color: var(--text-inverse); + text-decoration: none; + } } .project-timeline-card__link-icon { transition: transform 0.15s ease; -} - -.project-timeline-card__link:hover .project-timeline-card__link-icon { - transform: translateX(2px); -} - -/* ============================================================================= - Card Status Variants - ============================================================================= */ - -/* Completed */ -.project-timeline-card--completed .project-timeline-card__dot { - background: var(--timeline-status-completed); -} - -.project-timeline-card--completed::after { - background: var(--timeline-status-completed); -} - -.project-timeline-card--completed .project-timeline-card__content::before { - background: var(--timeline-status-completed); -} -.project-timeline-card--completed .project-timeline-card__date-wrapper { - background: var(--timeline-status-completed-bg); -} - -.project-timeline-card--completed .project-timeline-card__status { - background: var(--timeline-status-completed); -} - -.project-timeline-card--completed .project-timeline-card__subtitle { - color: var(--timeline-status-completed); -} - -.project-timeline-card--completed .project-timeline-card__link { - background: var(--timeline-status-completed); -} - -.project-timeline-card--completed .project-timeline-card__link:hover { - background: var(--primitive-petroleum-800, #3d6d6d); - color: var(--primitive-white, #fff); -} - -/* Current */ -.project-timeline-card--current .project-timeline-card__dot { - background: var(--primitive-white, #fff); - border: 2px solid var(--timeline-status-current); -} - -.project-timeline-card--current::after { - background: var(--timeline-status-current); -} - -.project-timeline-card--current .project-timeline-card__content::before { - background: var(--timeline-status-current); -} - -.project-timeline-card--current .project-timeline-card__date-wrapper { - background: var(--timeline-status-current-bg); -} - -.project-timeline-card--current .project-timeline-card__status { - background: var(--timeline-status-current); -} - -.project-timeline-card--current .project-timeline-card__subtitle { - color: var(--timeline-status-current); -} - -.project-timeline-card--current .project-timeline-card__link { - background: var(--timeline-status-current); -} - -.project-timeline-card--current .project-timeline-card__link:hover { - background: #e54a20; - color: var(--primitive-white, #fff); -} - -/* Note */ -.project-timeline-card--note .project-timeline-card__dot { - background: var(--timeline-status-note); -} - -.project-timeline-card--note::after { - background: var(--timeline-status-note); -} - -.project-timeline-card--note .project-timeline-card__content::before { - background: var(--timeline-status-note); + .project-timeline-card__link:hover & { + transform: translateX(2px); + } } -.project-timeline-card--note .project-timeline-card__date-wrapper { - background: var(--timeline-status-note-bg); -} +// ============================================================================= +// Card Status Variants +// ============================================================================= -.project-timeline-card--note .project-timeline-card__status { - background: var(--timeline-status-note); +// Completed +.project-timeline-card--completed { + @include timeline-card-status-variant( + var(--timeline-status-completed), + var(--timeline-status-completed-bg), + var(--primitive-petroleum-800) + ); } -.project-timeline-card--note .project-timeline-card__subtitle { - color: var(--timeline-status-note); -} +// Current - special case with hollow dot +.project-timeline-card--current { + @include timeline-card-status-variant( + var(--timeline-status-current), + var(--timeline-status-current-bg), + var(--primitive-orange-500) + ); -.project-timeline-card--note .project-timeline-card__link { - background: var(--timeline-status-note); + // Override dot to be hollow (current indicator) + .project-timeline-card__dot { + background: var(--bg-primary); + border: 2px solid var(--timeline-status-current); + } } -.project-timeline-card--note .project-timeline-card__link:hover { - background: #2a4db5; - color: var(--primitive-white, #fff); +// Note +.project-timeline-card--note { + @include timeline-card-status-variant( + var(--timeline-status-note), + var(--timeline-status-note-bg), + var(--primitive-navy-500) + ); } -/* Upcoming - lower opacity */ +// Upcoming - lower opacity .project-timeline-card--upcoming { opacity: 0.6; } -/* ============================================================================= - Card Accent Color Variants - ============================================================================= */ - -/* Pink accent */ -.project-timeline-card--accent-pink .project-timeline-card__dot { - background: var(--timeline-accent-pink); -} - -.project-timeline-card--accent-pink::after { - background: var(--timeline-accent-pink); -} - -.project-timeline-card--accent-pink .project-timeline-card__content::before { - background: var(--timeline-accent-pink); -} +// ============================================================================= +// Card Accent Color Variants +// ============================================================================= -.project-timeline-card--accent-pink .project-timeline-card__date-wrapper { - background: var(--timeline-accent-pink-bg); - color: var(--timeline-accent-pink); -} - -.project-timeline-card--accent-pink .project-timeline-card__status { - background: var(--timeline-accent-pink); -} - -.project-timeline-card--accent-pink .project-timeline-card__subtitle { - color: var(--timeline-accent-pink); -} - -.project-timeline-card--accent-pink .project-timeline-card__link { - background: var(--timeline-accent-pink); -} - -.project-timeline-card--accent-pink .project-timeline-card__link:hover { - background: #c2185b; - color: var(--primitive-white, #fff); -} - -/* Blue accent */ -.project-timeline-card--accent-blue .project-timeline-card__dot { - background: var(--timeline-accent-blue); -} - -.project-timeline-card--accent-blue::after { - background: var(--timeline-accent-blue); -} - -.project-timeline-card--accent-blue .project-timeline-card__content::before { - background: var(--timeline-accent-blue); -} - -.project-timeline-card--accent-blue .project-timeline-card__date-wrapper { - background: var(--timeline-accent-blue-bg); - color: var(--timeline-accent-blue); -} - -.project-timeline-card--accent-blue .project-timeline-card__status { - background: var(--timeline-accent-blue); +// Pink accent +.project-timeline-card--accent-pink { + @include timeline-card-accent-variant( + var(--timeline-accent-pink), + var(--timeline-accent-pink-bg), + var(--primitive-pink-500) + ); } -.project-timeline-card--accent-blue .project-timeline-card__subtitle { - color: var(--timeline-accent-blue); +// Blue accent +.project-timeline-card--accent-blue { + @include timeline-card-accent-variant( + var(--timeline-accent-blue), + var(--timeline-accent-blue-bg), + var(--primitive-navy-500) + ); } -.project-timeline-card--accent-blue .project-timeline-card__link { - background: var(--timeline-accent-blue); -} +// ============================================================================= +// Mini Navigation - Fixed on Right Side +// ============================================================================= +$mini-nav-dot-size: 10px; -.project-timeline-card--accent-blue .project-timeline-card__link:hover { - background: #2a4db5; - color: var(--primitive-white, #fff); -} - -/* ============================================================================= - Mini Navigation - Fixed on Right Side - ============================================================================= */ .project-timeline-mini-nav { position: fixed; - right: 20px; + right: ($spacer * 1.25); top: 50%; transform: translateY(-50%); z-index: 100; display: flex; flex-direction: column; align-items: center; - padding: 16px 10px; - background: var(--primitive-white, #fff); - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); - border: 1px solid var(--primitive-gray-300, #e6e6e6); + padding: $spacer ($spacer * 0.625); + background: var(--bg-primary); + box-shadow: $shadow; + border: 1px solid var(--border-default); } -/* Vertical "TIDSLINJE" header */ +// Vertical "TIDSLINJE" header .project-timeline-mini-nav__header { font-size: 9px; - font-weight: 600; - color: var(--primitive-petroleum-500, #008486); + font-weight: $font-weight-bold; + color: var(--interactive-primary); letter-spacing: 0.08em; text-transform: uppercase; - margin-bottom: 12px; + margin-bottom: ($spacer * 0.75); writing-mode: vertical-rl; transform: rotate(180deg); } @@ -673,14 +626,19 @@ display: flex; flex-direction: column; align-items: center; -} -/* Connecting line between dots */ -.project-timeline-mini-nav__item:not(:last-child)::after { - content: ""; - width: 2px; - height: 16px; - background: var(--primitive-gray-300, #e6e6e6); + // Connecting line between dots + &:not(:last-child)::after { + content: ""; + width: $timeline-line-width; + height: $spacer; + background: var(--border-default); + } + + // Connecting line colors based on progress + &:has(.project-timeline-mini-nav__link--completed)::after { + background: var(--primitive-gray-800); + } } .project-timeline-mini-nav__link { @@ -693,37 +651,43 @@ background: none; border: none; cursor: pointer; + + &:hover, + &.is-active { + .project-timeline-mini-nav__dot { + transform: scale(1.3); + } + } + + &.is-active .project-timeline-mini-nav__dot { + outline: 2px solid var(--primitive-petroleum-200); + outline-offset: 2px; + } + + &:hover .project-timeline-mini-nav__label { + opacity: 1; + } } .project-timeline-mini-nav__dot { - width: 10px; - height: 10px; - min-width: 10px; - min-height: 10px; + width: $mini-nav-dot-size; + height: $mini-nav-dot-size; + min-width: $mini-nav-dot-size; + min-height: $mini-nav-dot-size; border-radius: 50%; - background: var(--primitive-gray-400, #dfdfdf); + background: var(--primitive-gray-400); transition: all 0.2s ease; flex-shrink: 0; } -.project-timeline-mini-nav__link:hover .project-timeline-mini-nav__dot, -.project-timeline-mini-nav__link.is-active .project-timeline-mini-nav__dot { - transform: scale(1.3); -} - -.project-timeline-mini-nav__link.is-active .project-timeline-mini-nav__dot { - outline: 2px solid var(--primitive-petroleum-200, #cfe0e2); - outline-offset: 2px; -} - -/* Hide label by default, show on hover */ +// Hide label by default, show on hover .project-timeline-mini-nav__label { position: absolute; - right: 20px; + right: ($spacer * 1.25); top: 50%; transform: translateY(-50%); - background: var(--primitive-gray-900, #333); - color: var(--primitive-white, #fff); + background: var(--primitive-gray-900); + color: var(--text-inverse); padding: 6px 10px; font-size: 11px; font-weight: 500; @@ -733,38 +697,26 @@ transition: opacity 0.15s ease; } -.project-timeline-mini-nav__link:hover .project-timeline-mini-nav__label { - opacity: 1; +// Mini nav status colors +.project-timeline-mini-nav__link--completed { + @include timeline-mini-nav-status(var(--timeline-status-completed)); } -/* Mini nav status colors */ -.project-timeline-mini-nav__link--completed .project-timeline-mini-nav__dot { - background: var(--timeline-status-completed); +.project-timeline-mini-nav__link--current { + @include timeline-mini-nav-status(var(--timeline-status-current), true); } -.project-timeline-mini-nav__link--current .project-timeline-mini-nav__dot { - background: var(--primitive-white, #fff); - border: 2px solid var(--timeline-status-current); +.project-timeline-mini-nav__link--upcoming { + @include timeline-mini-nav-status(var(--timeline-status-upcoming)); } -.project-timeline-mini-nav__link--upcoming .project-timeline-mini-nav__dot { - background: var(--timeline-status-upcoming); +.project-timeline-mini-nav__link--note { + @include timeline-mini-nav-status(var(--timeline-status-note)); } -.project-timeline-mini-nav__link--note .project-timeline-mini-nav__dot { - background: var(--timeline-status-note); -} - -/* Connecting line colors based on progress */ -.project-timeline-mini-nav__item:has( - .project-timeline-mini-nav__link--completed - )::after { - background: var(--primitive-gray-800, #525252); -} - -/* "I dag" indicator at bottom */ +// "I dag" indicator at bottom .project-timeline-mini-nav__today { - margin-top: 12px; + margin-top: ($spacer * 0.75); display: flex; flex-direction: column; align-items: center; @@ -772,99 +724,102 @@ } .project-timeline-mini-nav__today-dot { - width: 10px; - height: 10px; - min-width: 10px; - min-height: 10px; + width: $mini-nav-dot-size; + height: $mini-nav-dot-size; + min-width: $mini-nav-dot-size; + min-height: $mini-nav-dot-size; border-radius: 50%; border: 2px solid var(--timeline-status-current); - background: var(--primitive-white, #fff); + background: var(--bg-primary); flex-shrink: 0; } .project-timeline-mini-nav__today-label { font-size: 9px; - color: var(--primitive-gray-700, #858585); + color: var(--text-secondary); text-align: center; } -/* ============================================================================= - Horizontal View (Carousel) - ============================================================================= */ +// ============================================================================= +// Horizontal View (Carousel) +// ============================================================================= +$carousel-min-height: 280px; +$carousel-padding-horizontal: ($spacer * 2.25); // 36px + .project-timeline__horizontal { position: relative; max-width: var(--timeline-max-width); margin: 0 auto; - padding: 24px 0; -} - -/* Progress bar at top */ -.project-timeline__horizontal::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: var(--primitive-gray-300, #e6e6e6); + padding: $timeline-card-padding 0; + + // Progress bar at top + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--border-default); + } } .project-timeline__carousel-controls { display: flex; align-items: center; justify-content: center; - gap: 16px; - margin-top: 28px; + gap: $spacer; + margin-top: ($spacer * 1.75); } .project-timeline__carousel-btn { display: flex; align-items: center; justify-content: center; - width: 48px; - height: 48px; + width: ($spacer * 3); + height: ($spacer * 3); padding: 0; border: none; - background: var(--primitive-petroleum-500, #008486); - color: var(--primitive-white, #fff); + background: var(--interactive-primary); + color: var(--text-inverse); cursor: pointer; - font-size: 20px; + font-size: $font-size-large; transition: all 0.2s ease; -} -.project-timeline__carousel-btn:hover:not(:disabled) { - background: var(--primitive-petroleum-800, #3d6d6d); - color: var(--primitive-white, #fff); -} + &:hover:not(:disabled) { + background: var(--interactive-primary-hover); + color: var(--text-inverse); + } -.project-timeline__carousel-btn:disabled { - background: var(--primitive-gray-200, #eaeaea); - color: var(--primitive-gray-600, #9d9d9d); - cursor: not-allowed; -} + &:disabled { + background: var(--bg-tertiary); + color: var(--text-muted); + cursor: not-allowed; + } -.project-timeline__carousel-btn:focus-visible { - outline: 2px solid var(--focus-ring-color, #008486); - outline-offset: 2px; + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 2px; + } } .project-timeline__carousel-indicator { display: flex; align-items: center; gap: 6px; - font-size: 14px; + font-size: $font-size-small; font-weight: 500; - color: var(--primitive-gray-700, #858585); -} + color: var(--text-secondary); -.project-timeline__carousel-indicator [data-carousel-current] { - font-weight: 700; - color: var(--primitive-black, #333); + [data-carousel-current] { + font-weight: $headings-font-weight; + color: var(--text-primary); + } } .project-timeline__carousel-viewport { overflow: hidden; - margin-top: 32px; + margin-top: $timeline-card-gap; } .project-timeline__carousel-track { @@ -874,162 +829,163 @@ .project-timeline__carousel-slide { flex: 0 0 100%; - padding: 0 8px; + padding: 0 ($spacer * 0.5); box-sizing: border-box; } -/* Carousel card styles - full width, side-by-side image */ -.project-timeline__horizontal .project-timeline-card { - margin-bottom: 0; - justify-content: center; -} - -.project-timeline__horizontal .project-timeline-card::after { - display: none; -} - -.project-timeline__horizontal .project-timeline-card__connector { - display: none; -} - -.project-timeline__horizontal .project-timeline-card__content { - width: 100%; - display: flex; - flex-direction: row; - min-height: 280px; -} +// Carousel card styles - full width, side-by-side image +.project-timeline__horizontal { + .project-timeline-card { + margin-bottom: 0; + justify-content: center; -.project-timeline__horizontal .project-timeline-card__content::before { - left: 0; - right: auto; -} + &::after { + display: none; + } + } -.project-timeline__horizontal .project-timeline-card__image-wrapper { - width: 40%; - height: auto; - min-height: 280px; - flex-shrink: 0; - border-bottom: none; - border-right: 1px solid var(--primitive-gray-200, #eaeaea); -} + .project-timeline-card__connector { + display: none; + } -.project-timeline__horizontal -.project-timeline-card__content:has( - .project-timeline-card__image-wrapper - )::before { - top: 0; - height: 100%; -} + .project-timeline-card__content { + width: 100%; + display: flex; + flex-direction: row; + min-height: $carousel-min-height; + + &::before { + left: 0; + right: auto; + } + + &:has(.project-timeline-card__image-wrapper) { + &::before { + top: 0; + height: 100%; + } + + .project-timeline-card__status { + top: $spacer; + } + } + } -.project-timeline__horizontal -.project-timeline-card__content:has(.project-timeline-card__image-wrapper) -.project-timeline-card__status { - top: 16px; -} + .project-timeline-card__image-wrapper { + width: 40%; + height: auto; + min-height: $carousel-min-height; + flex-shrink: 0; + border-bottom: none; + border-right: 1px solid var(--border-subtle); + } -.project-timeline__horizontal .project-timeline-card__header { - padding: 48px 36px 0; -} + .project-timeline-card__header { + padding: ($spacer * 3) $carousel-padding-horizontal 0; + } -.project-timeline__horizontal .project-timeline-card__body { - padding: 12px 36px 20px; - display: flex; - flex-direction: column; - justify-content: center; -} + .project-timeline-card__body { + padding: ($spacer * 0.75) $carousel-padding-horizontal ($spacer * 1.25); + display: flex; + flex-direction: column; + justify-content: center; + } -.project-timeline__horizontal .project-timeline-card__title { - font-size: 26px; -} + .project-timeline-card__title { + font-size: $font-size-teaser; + } -.project-timeline__horizontal .project-timeline-card__description { - font-size: 15px; - line-height: 1.6; -} + .project-timeline-card__description { + font-size: 15px; + line-height: 1.6; + } -.project-timeline__horizontal .project-timeline-card__footer { - padding: 32px 0; -} + .project-timeline-card__footer { + padding: $timeline-card-gap 0; + } -.project-timeline__horizontal .project-timeline-card__link { - padding: 10px 18px; - font-size: 14px; + .project-timeline-card__link { + padding: ($spacer * 0.625) ($spacer * 1.125); + font-size: $font-size-small; + } } -/* ============================================================================= - Legend - ============================================================================= */ +// ============================================================================= +// Legend +// ============================================================================= .project-timeline-legend { max-width: 500px; - margin: 32px auto 0; - padding: 20px 24px; - background: var(--primitive-white, #fff); - border: 1px solid var(--primitive-gray-300, #e6e6e6); + margin: $timeline-card-gap auto 0; + padding: ($spacer * 1.25) $timeline-card-padding; + background: var(--card-bg); + border: 1px solid var(--card-border); } .project-timeline-legend__list { display: flex; flex-wrap: wrap; - gap: 20px; + gap: ($spacer * 1.25); list-style: none; margin: 0; padding: 0; -} -.project-timeline-legend__list::before { - content: "Forklaring"; - display: block; - width: 100%; - margin-bottom: 14px; - font-size: 12px; - font-weight: 700; - color: var(--primitive-black, #333); - text-transform: uppercase; - letter-spacing: 0.06em; + &::before { + content: "Forklaring"; + display: block; + width: 100%; + margin-bottom: ($spacer * 0.875); + font-size: $font-size-xs; + font-weight: $headings-font-weight; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.06em; + } } .project-timeline-legend__item { display: flex; align-items: center; - gap: 8px; + gap: ($spacer * 0.5); + + // Status color variants + &--completed .project-timeline-legend__dot { + background: var(--timeline-status-completed); + } + + &--current .project-timeline-legend__dot { + background: var(--bg-primary); + border: 2px solid var(--timeline-status-current); + } + + &--upcoming .project-timeline-legend__dot { + background: var(--timeline-status-upcoming); + } + + &--note .project-timeline-legend__dot { + background: var(--timeline-status-note); + } } .project-timeline-legend__dot { - width: 12px; - height: 12px; - min-width: 12px; - min-height: 12px; + width: var(--timeline-dot-size); + height: var(--timeline-dot-size); + min-width: var(--timeline-dot-size); + min-height: var(--timeline-dot-size); border-radius: 50%; - background: var(--primitive-gray-400, #dfdfdf); + background: var(--primitive-gray-400); flex-shrink: 0; } .project-timeline-legend__label { - font-size: 13px; - color: var(--primitive-gray-800, #525252); -} - -/* Legend status colors */ -.project-timeline-legend__item--completed .project-timeline-legend__dot { - background: var(--timeline-status-completed); + font-size: $font-size-small; + color: var(--text-primary); } -.project-timeline-legend__item--current .project-timeline-legend__dot { - background: var(--primitive-white, #fff); - border: 2px solid var(--timeline-status-current); -} - -.project-timeline-legend__item--upcoming .project-timeline-legend__dot { - background: var(--timeline-status-upcoming); -} +// ============================================================================= +// Responsive: Mobile +// ============================================================================= +$mobile-image-height: 160px; -.project-timeline-legend__item--note .project-timeline-legend__dot { - background: var(--timeline-status-note); -} - -/* ============================================================================= - Responsive: Mobile - ============================================================================= */ @media (max-width: 767px) { .project-timeline__header { flex-direction: column; @@ -1045,82 +1001,86 @@ .project-timeline__horizontal { display: block !important; - } - .project-timeline__horizontal[hidden] { - display: block !important; + &[hidden] { + display: block !important; + } } .project-timeline-mini-nav { display: none; } - /* Mobile carousel card layout */ - .project-timeline__horizontal .project-timeline-card__content { - flex-direction: column; - min-height: auto; - } - - .project-timeline__horizontal .project-timeline-card__image-wrapper { - width: 100%; - height: 160px; - min-height: 160px; - border-right: none; - border-bottom: 1px solid var(--primitive-gray-200, #eaeaea); - } - - .project-timeline__horizontal .project-timeline-card__header { - padding: 16px; - } - - .project-timeline__horizontal .project-timeline-card__body { - padding: 8px 16px 16px; - } - - .project-timeline__horizontal .project-timeline-card__title { - font-size: 18px; - } - - .project-timeline__horizontal .project-timeline-card__description { - font-size: 13px; - } - - .project-timeline__horizontal .project-timeline-card__footer { - padding: 0 16px 16px; - } - - .project-timeline__horizontal .project-timeline-card__link { - padding: 8px 14px; - font-size: 13px; - } - - .project-timeline__horizontal - .project-timeline-card__content:has(.project-timeline-card__image-wrapper) - .project-timeline-card__status { - top: 168px; + // Mobile carousel card layout + .project-timeline__horizontal { + .project-timeline-card__content { + flex-direction: column; + min-height: auto; + + &:has(.project-timeline-card__image-wrapper) + .project-timeline-card__status { + top: ($mobile-image-height + 8px); + } + } + + .project-timeline-card__image-wrapper { + width: 100%; + height: $mobile-image-height; + min-height: $mobile-image-height; + border-right: none; + border-bottom: 1px solid var(--border-subtle); + } + + .project-timeline-card__header { + padding: $timeline-card-padding-sm; + } + + .project-timeline-card__body { + padding: ($spacer * 0.5) $timeline-card-padding-sm + $timeline-card-padding-sm; + } + + .project-timeline-card__title { + font-size: $h3-font-size; + } + + .project-timeline-card__description { + font-size: $font-size-small; + } + + .project-timeline-card__footer { + padding: 0 $timeline-card-padding-sm $timeline-card-padding-sm; + } + + .project-timeline-card__link { + padding: ($spacer * 0.5) ($spacer * 0.875); + font-size: $font-size-small; + } } } -/* ============================================================================= - Responsive: Tablet - Show vertical but with adjusted layout - ============================================================================= */ +// ============================================================================= +// Responsive: Tablet - Show vertical but with adjusted layout +// ============================================================================= +$tablet-card-gap: $timeline-card-padding; + @media (min-width: 768px) and (max-width: 991px) { .project-timeline-card__content { - width: calc(50% - 24px); + width: calc(50% - #{$tablet-card-gap}); } .project-timeline-card::after { - width: 24px; + width: $tablet-card-gap; } .project-timeline-card:nth-child(odd)::after { - left: calc(50% - 24px); + left: calc(50% - #{$tablet-card-gap}); } } -/* ============================================================================= - Reduced Motion - ============================================================================= */ +// ============================================================================= +// Reduced Motion +// ============================================================================= @media (prefers-reduced-motion: reduce) { .project-timeline-card__content, .project-timeline-card__link-icon, @@ -1134,9 +1094,9 @@ } } -/* ============================================================================= - Print Styles - ============================================================================= */ +// ============================================================================= +// Print Styles +// ============================================================================= @media print { .project-timeline__view-toggle, .project-timeline-mini-nav, @@ -1146,20 +1106,20 @@ .project-timeline__vertical { display: block !important; - } - .project-timeline__vertical[hidden] { - display: block !important; + &[hidden] { + display: block !important; + } } .project-timeline-card { - margin-bottom: 24px; + margin-bottom: $timeline-card-padding; opacity: 1 !important; } .project-timeline-card__content { break-inside: avoid; box-shadow: none; - border: 1px solid #000; + border: 1px solid var(--border-strong); } } From 20c205faffaf367eb7da4a72598b9fbb9e67bc5d Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Thu, 22 Jan 2026 12:26:06 +0100 Subject: [PATCH 04/49] Modernized timeline JavaScript to ES6+ syntax - Converted var declarations to const/let - Replaced function callbacks with arrow functions - Used template literals for string interpolation - Extracted magic numbers to named constants (SWIPE_THRESHOLD, etc.) - Applied optional chaining for null checks - Used destructuring for cleaner property access - Used shorthand method syntax in Drupal behavior Co-Authored-By: Claude Opus 4.5 --- .../hoeringsportal/assets/js/timeline.js | 125 +++++++++--------- 1 file changed, 63 insertions(+), 62 deletions(-) diff --git a/web/themes/custom/hoeringsportal/assets/js/timeline.js b/web/themes/custom/hoeringsportal/assets/js/timeline.js index a5a029fc1..89fb19d20 100644 --- a/web/themes/custom/hoeringsportal/assets/js/timeline.js +++ b/web/themes/custom/hoeringsportal/assets/js/timeline.js @@ -8,20 +8,25 @@ (function (Drupal, once) { "use strict"; + // Constants + const SWIPE_THRESHOLD = 50; + const OBSERVER_ROOT_MARGIN = "-30% 0px -30% 0px"; + const CAROUSEL_SLIDE_PERCENT = 100; + /** * Timeline component initialization. * * @type {Drupal~behavior} */ Drupal.behaviors.projectTimeline = { - attach: function (context) { - var timelines = once( + attach(context) { + const timelines = once( "project-timeline", "[data-project-timeline]", context, ); - timelines.forEach(function (timeline) { + timelines.forEach((timeline) => { initTimeline(timeline); }); }, @@ -34,7 +39,7 @@ * The timeline container element. */ function initTimeline(timeline) { - var state = { + const state = { currentView: timeline.dataset.defaultView || "vertical", carouselIndex: 0, carouselTotal: 0, @@ -42,7 +47,7 @@ }; // Cache DOM elements - var elements = { + const elements = { viewButtons: timeline.querySelectorAll("[data-view]"), verticalPanel: timeline.querySelector("#project-timeline-vertical"), horizontalPanel: timeline.querySelector("#project-timeline-horizontal"), @@ -70,9 +75,9 @@ * Initialize view mode toggle buttons. */ function initViewToggle() { - elements.viewButtons.forEach(function (button) { - button.addEventListener("click", function () { - var view = button.dataset.view; + elements.viewButtons.forEach((button) => { + button.addEventListener("click", () => { + const { view } = button.dataset; if (view !== state.currentView) { switchView(view); } @@ -90,18 +95,18 @@ state.currentView = view; // Update button states - elements.viewButtons.forEach(function (btn) { - var isSelected = btn.dataset.view === view; + elements.viewButtons.forEach((btn) => { + const isSelected = btn.dataset.view === view; btn.setAttribute("aria-selected", isSelected ? "true" : "false"); }); // Toggle panel visibility if (view === "vertical") { - elements.verticalPanel.removeAttribute("hidden"); - elements.horizontalPanel.setAttribute("hidden", ""); + elements.verticalPanel?.removeAttribute("hidden"); + elements.horizontalPanel?.setAttribute("hidden", ""); } else { - elements.horizontalPanel.removeAttribute("hidden"); - elements.verticalPanel.setAttribute("hidden", ""); + elements.horizontalPanel?.removeAttribute("hidden"); + elements.verticalPanel?.setAttribute("hidden", ""); updateCarouselPosition(); } } @@ -110,19 +115,17 @@ * Initialize mini navigation click handlers. */ function initMiniNavigation() { - elements.navLinks.forEach(function (link) { - link.addEventListener("click", function (e) { + elements.navLinks.forEach((link) => { + link.addEventListener("click", (e) => { e.preventDefault(); - var cardId = link.dataset.navLink; - var targetCard = timeline.querySelector( - '[data-card-id="' + cardId + '"]', + const { navLink: cardId } = link.dataset; + const targetCard = timeline.querySelector( + `[data-card-id="${cardId}"]`, ); - if (targetCard) { - targetCard.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - } + targetCard?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); }); }); } @@ -135,22 +138,22 @@ return; } - var observerOptions = { + const observerOptions = { root: null, - rootMargin: "-30% 0px -30% 0px", + rootMargin: OBSERVER_ROOT_MARGIN, threshold: 0, }; - state.observer = new IntersectionObserver(function (entries) { - entries.forEach(function (entry) { + state.observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { if (entry.isIntersecting) { - var cardId = entry.target.dataset.cardId; + const { cardId } = entry.target.dataset; updateActiveNavLink(cardId); } }); }, observerOptions); - elements.cards.forEach(function (card) { + elements.cards.forEach((card) => { state.observer.observe(card); }); } @@ -162,8 +165,8 @@ * The ID of the active card. */ function updateActiveNavLink(cardId) { - elements.navLinks.forEach(function (link) { - var isActive = link.dataset.navLink === cardId; + elements.navLinks.forEach((link) => { + const isActive = link.dataset.navLink === cardId; link.classList.toggle("is-active", isActive); }); } @@ -172,17 +175,13 @@ * Initialize carousel navigation. */ function initCarousel() { - if (elements.carouselPrev) { - elements.carouselPrev.addEventListener("click", function () { - goToSlide(state.carouselIndex - 1); - }); - } + elements.carouselPrev?.addEventListener("click", () => { + goToSlide(state.carouselIndex - 1); + }); - if (elements.carouselNext) { - elements.carouselNext.addEventListener("click", function () { - goToSlide(state.carouselIndex + 1); - }); - } + elements.carouselNext?.addEventListener("click", () => { + goToSlide(state.carouselIndex + 1); + }); // Touch/swipe support initTouchNavigation(); @@ -196,8 +195,11 @@ */ function goToSlide(index) { // Clamp index to valid range - index = Math.max(0, Math.min(index, state.carouselTotal - 1)); - state.carouselIndex = index; + const clampedIndex = Math.max( + 0, + Math.min(index, state.carouselTotal - 1), + ); + state.carouselIndex = clampedIndex; updateCarouselPosition(); } @@ -206,8 +208,8 @@ */ function updateCarouselPosition() { if (elements.carouselTrack) { - var offset = state.carouselIndex * -100; - elements.carouselTrack.style.transform = "translateX(" + offset + "%)"; + const offset = state.carouselIndex * -CAROUSEL_SLIDE_PERCENT; + elements.carouselTrack.style.transform = `translateX(${offset}%)`; } // Update indicator @@ -233,7 +235,7 @@ return; } - var touchState = { + const touchState = { startX: 0, startY: 0, deltaX: 0, @@ -242,7 +244,7 @@ elements.carouselTrack.addEventListener( "touchstart", - function (e) { + (e) => { touchState.startX = e.touches[0].clientX; touchState.startY = e.touches[0].clientY; touchState.isSwiping = false; @@ -252,9 +254,9 @@ elements.carouselTrack.addEventListener( "touchmove", - function (e) { + (e) => { touchState.deltaX = e.touches[0].clientX - touchState.startX; - var deltaY = e.touches[0].clientY - touchState.startY; + const deltaY = e.touches[0].clientY - touchState.startY; // Determine if horizontal swipe if ( @@ -267,12 +269,11 @@ { passive: true }, ); - elements.carouselTrack.addEventListener("touchend", function () { + elements.carouselTrack.addEventListener("touchend", () => { if (touchState.isSwiping) { - var threshold = 50; - if (touchState.deltaX > threshold) { + if (touchState.deltaX > SWIPE_THRESHOLD) { goToSlide(state.carouselIndex - 1); - } else if (touchState.deltaX < -threshold) { + } else if (touchState.deltaX < -SWIPE_THRESHOLD) { goToSlide(state.carouselIndex + 1); } } @@ -285,7 +286,7 @@ * Initialize keyboard navigation. */ function initKeyboardNavigation() { - timeline.addEventListener("keydown", function (e) { + timeline.addEventListener("keydown", (e) => { // Only handle keyboard navigation when carousel is visible if (state.currentView !== "horizontal") { return; @@ -301,20 +302,20 @@ }); // View toggle keyboard navigation - elements.viewButtons.forEach(function (button) { - button.addEventListener("keydown", function (e) { - var buttons = Array.from(elements.viewButtons); - var currentIndex = buttons.indexOf(button); + elements.viewButtons.forEach((button) => { + button.addEventListener("keydown", (e) => { + const buttons = Array.from(elements.viewButtons); + const currentIndex = buttons.indexOf(button); if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); - var prevIndex = + const prevIndex = (currentIndex - 1 + buttons.length) % buttons.length; buttons[prevIndex].focus(); buttons[prevIndex].click(); } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); - var nextIndex = (currentIndex + 1) % buttons.length; + const nextIndex = (currentIndex + 1) % buttons.length; buttons[nextIndex].focus(); buttons[nextIndex].click(); } From feed8f12c134a69d329f2c2378696bd6fe80136b Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Thu, 22 Jan 2026 14:10:42 +0100 Subject: [PATCH 05/49] Apply Prettier formatting to mini-timeline.js - Standardized quote style to double quotes Co-Authored-By: Claude Opus 4.5 --- .../hoeringsportal/assets/js/mini-timeline.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/web/themes/custom/hoeringsportal/assets/js/mini-timeline.js b/web/themes/custom/hoeringsportal/assets/js/mini-timeline.js index ef34bd016..d292af884 100644 --- a/web/themes/custom/hoeringsportal/assets/js/mini-timeline.js +++ b/web/themes/custom/hoeringsportal/assets/js/mini-timeline.js @@ -1,23 +1,23 @@ export function initMiniTimeline(data) { - const container = document.getElementById('mini-timeline'); + const container = document.getElementById("mini-timeline"); if (!container) return; data.forEach((item, index) => { - const btn = document.createElement('button'); - btn.className = 'mini-dot'; + const btn = document.createElement("button"); + btn.className = "mini-dot"; btn.title = item.title; // Simple color logic based on status - let color = '#9d9d9d'; // Default - if (item.status === 'completed') color = '#008486'; - if (item.status === 'current') color = '#ff5f31'; - if (item.accentColor === 'pink') color = '#e91e63'; + let color = "#9d9d9d"; // Default + if (item.status === "completed") color = "#008486"; + if (item.status === "current") color = "#ff5f31"; + if (item.accentColor === "pink") color = "#e91e63"; btn.style.backgroundColor = color; btn.onclick = () => { const element = document.getElementById(`item-${item.id}`); - element?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element?.scrollIntoView({ behavior: "smooth", block: "center" }); }; container.appendChild(btn); From 0fcadd1cc54f261de0989411f620a5f158671bee Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Fri, 23 Jan 2026 12:16:32 +0100 Subject: [PATCH 06/49] Use Bootstrap container widths instead of hardcoded px values Addresses PR review feedback: - $timeline-max-width: uses lg container (960px) - Description max-width: uses sm container (540px) - Legend max-width: uses sm container (540px) Co-Authored-By: Claude Opus 4.5 --- .../hoeringsportal/assets/css/module/_timeline.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss index 4e523cc98..57d7455cd 100644 --- a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss +++ b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss @@ -13,8 +13,8 @@ // SCSS Variables & Mixins // ============================================================================= -// Layout variables -$timeline-max-width: 900px; +// Layout variables - using Bootstrap container widths for consistency +$timeline-max-width: map-get($container-max-widths, lg); // 960px $timeline-card-gap: $spacer * 2; // 32px $timeline-line-width: 2px; $timeline-dot-size: 12px; @@ -182,7 +182,7 @@ $timeline-accents: ( font-size: $font-size-base; line-height: $line-height-base; color: var(--text-secondary); - max-width: 600px; + max-width: map-get($container-max-widths, sm); // 540px - text container } // ============================================================================= @@ -914,7 +914,7 @@ $carousel-padding-horizontal: ($spacer * 2.25); // 36px // Legend // ============================================================================= .project-timeline-legend { - max-width: 500px; + max-width: map-get($container-max-widths, sm); // 540px - text container margin: $timeline-card-gap auto 0; padding: ($spacer * 1.25) $timeline-card-padding; background: var(--card-bg); From 3ae66ee3fa78c6a2cbbbbed08234a92581b06bb6 Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Fri, 23 Jan 2026 12:41:54 +0100 Subject: [PATCH 07/49] Add implementation plan for timeline "I dag" card Planning document for replacing "I gang" with auto-generated "I dag" card that shows current project status from Drupal field. Co-Authored-By: Claude Opus 4.5 --- docs/timeline-today-card-plan.md | 135 +++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/timeline-today-card-plan.md diff --git a/docs/timeline-today-card-plan.md b/docs/timeline-today-card-plan.md new file mode 100644 index 000000000..02e479625 --- /dev/null +++ b/docs/timeline-today-card-plan.md @@ -0,0 +1,135 @@ +# Timeline "I dag" Card Implementation Plan + +## Overview + +Replace the current "I gang" (In progress) status card with an auto-generated "I dag" (Today) card that displays the current project status from a Drupal field. + +## Current Behavior + +- Timeline cards with `status: 'current'` display a badge labeled "I gang" +- The card content (title, description) is manually configured in the timeline data +- The orange styling indicates the current/active state + +## Proposed Behavior + +### "I dag" Card Features + +| Aspect | Implementation | +|--------|----------------| +| **Status badge** | "I dag" (replaces "I gang") | +| **Date display** | Auto-generated current month and year (e.g., "Januar 2026") | +| **Title** | "Projektstatus" | +| **Content** | Project status from Drupal field (e.g., "Åben for inddragelse") | +| **Card height** | Maintain minimum height to prevent overlap with adjacent cards | +| **Styling** | Keep existing orange/current styling | + +### Data Source + +The project status text will come from a Drupal field on the project content type. This allows editors to update the status without modifying timeline code. + +**Suggested field**: `field_project_status` (or existing field if available) + +**Example values**: +- "Åben for inddragelse" +- "Under behandling" +- "Afventer høring" +- "Afsluttet" + +## Technical Implementation + +### 1. Twig Template Changes + +**File**: `web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig` + +```twig +{% set status_labels = { + completed: '✓ Afsluttet'|t, + current: 'I dag'|t, {# Changed from 'I gang' #} + upcoming: 'Kommende'|t, + note: 'Note'|t, +} %} +``` + +### 2. Timeline Data Generation + +**File**: Module or preprocess function that generates timeline data + +The "I dag" card should be auto-generated with: + +```php +$today_card = [ + 'id' => 'today', + 'date' => date('Y-m-d'), + 'month' => format_date(time(), 'custom', 'F Y'), // "Januar 2026" + 'title' => t('Projektstatus'), + 'subtitle' => NULL, + 'description' => $node->get('field_project_status')->value, + 'status' => 'current', + 'image' => NULL, + 'link' => NULL, + 'linkText' => NULL, + 'accentColor' => NULL, +]; +``` + +### 3. Card Insertion Logic + +The "I dag" card should be inserted at the correct chronological position: + +1. Sort all timeline items by date +2. Find the position where today's date falls +3. Insert the "I dag" card at that position +4. Remove any manually configured `status: 'current'` items (or convert them to regular items) + +### 4. CSS Adjustments + +**File**: `web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss` + +Ensure minimum card height for the current/today card: + +```scss +.project-timeline-card--current { + .project-timeline-card__content { + min-height: 200px; // Adjust as needed to prevent overlap + } +} +``` + +## Migration Considerations + +1. **Existing data**: Any timeline items with `status: 'current'` should be reviewed + - Convert to `status: 'completed'` if in the past + - Convert to `status: 'upcoming'` if in the future + - Remove if they were placeholder "current" items + +2. **Field creation**: If `field_project_status` doesn't exist, it needs to be added to the project content type + +3. **Translation**: Ensure "I dag" and "Projektstatus" are added to translation files + +## Files to Modify + +| File | Changes | +|------|---------| +| `project-timeline-card.html.twig` | Update status label | +| `timeline.html.twig` or preprocess | Auto-generate today card | +| `_timeline.scss` | Add min-height for current card | +| Project content type | Add/configure status field | +| `hoeringsportal.module` or similar | Timeline data preprocessing | + +## Testing Checklist + +- [ ] "I dag" card displays with current month/year +- [ ] Project status text displays from Drupal field +- [ ] Card maintains proper height (no overlap) +- [ ] Card appears at correct chronological position +- [ ] Vertical view displays correctly +- [ ] Horizontal/carousel view displays correctly +- [ ] Mini-nav highlights today correctly +- [ ] Mobile responsive layout works +- [ ] Translation strings work + +## Open Questions + +1. What is the exact Drupal field name for project status? (or should we create a new one?) +2. Should there be a fallback text if the project status field is empty? +3. Should the "I dag" card be hidden if today's date is outside the project timeline range? From 7991cbf0dfb825bf9ab6e5e0c3c00624b438bff5 Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Fri, 23 Jan 2026 13:02:18 +0100 Subject: [PATCH 08/49] Update I dag card plan based on PR feedback - Field name: use placeholder (exact field name TBD) - Empty field behavior: card shown without status text - Edge cases: card appears as first/last item when outside range - Convert open questions to resolved questions Co-Authored-By: Claude Opus 4.5 --- docs/timeline-today-card-plan.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/timeline-today-card-plan.md b/docs/timeline-today-card-plan.md index 02e479625..36b6bf221 100644 --- a/docs/timeline-today-card-plan.md +++ b/docs/timeline-today-card-plan.md @@ -27,7 +27,7 @@ Replace the current "I gang" (In progress) status card with an auto-generated "I The project status text will come from a Drupal field on the project content type. This allows editors to update the status without modifying timeline code. -**Suggested field**: `field_project_status` (or existing field if available) +**Field**: `field_project_status` (placeholder - exact field name TBD) **Example values**: - "Åben for inddragelse" @@ -35,6 +35,8 @@ The project status text will come from a Drupal field on the project content typ - "Afventer høring" - "Afsluttet" +**Empty field behavior**: If the project status field is empty, the "I dag" card is still displayed but **without the status text**. + ## Technical Implementation ### 1. Twig Template Changes @@ -81,6 +83,11 @@ The "I dag" card should be inserted at the correct chronological position: 3. Insert the "I dag" card at that position 4. Remove any manually configured `status: 'current'` items (or convert them to regular items) +**Edge cases - today outside timeline range**: +- If today is **before** the first timeline item → show "I dag" card as the **first** item +- If today is **after** the last timeline item → show "I dag" card as the **last** item +- The "I dag" card is always shown (unless project status field is empty) + ### 4. CSS Adjustments **File**: `web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss` @@ -122,14 +129,22 @@ Ensure minimum card height for the current/today card: - [ ] Project status text displays from Drupal field - [ ] Card maintains proper height (no overlap) - [ ] Card appears at correct chronological position +- [ ] Card appears as **first** item when today is before timeline start +- [ ] Card appears as **last** item when today is after timeline end +- [ ] Card is shown **without status text** when project status field is empty - [ ] Vertical view displays correctly - [ ] Horizontal/carousel view displays correctly - [ ] Mini-nav highlights today correctly - [ ] Mobile responsive layout works - [ ] Translation strings work -## Open Questions +## Resolved Questions + +1. **What is the exact Drupal field name for project status?** + → Use placeholder `field_project_status` for now (exact field name TBD) + +2. **Should there be a fallback text if the project status field is empty?** + → No. The card is still shown, but without the status text. -1. What is the exact Drupal field name for project status? (or should we create a new one?) -2. Should there be a fallback text if the project status field is empty? -3. Should the "I dag" card be hidden if today's date is outside the project timeline range? +3. **Should the "I dag" card be hidden if today's date is outside the project timeline range?** + → No. The card should appear as the first or last item on the timeline. From 7f5de997dc3b591991ccf114cf4f3e20747adffa Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Fri, 23 Jan 2026 13:30:03 +0100 Subject: [PATCH 09/49] Implement auto-generated "I dag" (Today) card for timeline - Change status label from "I gang" to "I dag" for current cards - Auto-generate today card with current date in Danish format - Insert today card at correct chronological position in timeline - Determine item status (completed/upcoming) based on date comparison - Pass project_status field to timeline for status display - Add min-height (200px) to current card to prevent overlap - Remove implementation plan document (no longer needed) The today card displays: - Status badge: "I dag" - Date: Current month and year in Danish (e.g., "Januar 2026") - Title: "Projektstatus" - Description: Project status from Drupal field (if set) Co-Authored-By: Claude Opus 4.5 --- docs/timeline-today-card-plan.md | 150 --------- .../assets/css/module/_timeline.scss | 7 +- .../project-timeline-card.html.twig | 2 +- .../templates/components/timeline.html.twig | 291 +++++++++++------- .../node--project-main-page--full.html.twig | 4 +- 5 files changed, 182 insertions(+), 272 deletions(-) delete mode 100644 docs/timeline-today-card-plan.md diff --git a/docs/timeline-today-card-plan.md b/docs/timeline-today-card-plan.md deleted file mode 100644 index 36b6bf221..000000000 --- a/docs/timeline-today-card-plan.md +++ /dev/null @@ -1,150 +0,0 @@ -# Timeline "I dag" Card Implementation Plan - -## Overview - -Replace the current "I gang" (In progress) status card with an auto-generated "I dag" (Today) card that displays the current project status from a Drupal field. - -## Current Behavior - -- Timeline cards with `status: 'current'` display a badge labeled "I gang" -- The card content (title, description) is manually configured in the timeline data -- The orange styling indicates the current/active state - -## Proposed Behavior - -### "I dag" Card Features - -| Aspect | Implementation | -|--------|----------------| -| **Status badge** | "I dag" (replaces "I gang") | -| **Date display** | Auto-generated current month and year (e.g., "Januar 2026") | -| **Title** | "Projektstatus" | -| **Content** | Project status from Drupal field (e.g., "Åben for inddragelse") | -| **Card height** | Maintain minimum height to prevent overlap with adjacent cards | -| **Styling** | Keep existing orange/current styling | - -### Data Source - -The project status text will come from a Drupal field on the project content type. This allows editors to update the status without modifying timeline code. - -**Field**: `field_project_status` (placeholder - exact field name TBD) - -**Example values**: -- "Åben for inddragelse" -- "Under behandling" -- "Afventer høring" -- "Afsluttet" - -**Empty field behavior**: If the project status field is empty, the "I dag" card is still displayed but **without the status text**. - -## Technical Implementation - -### 1. Twig Template Changes - -**File**: `web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig` - -```twig -{% set status_labels = { - completed: '✓ Afsluttet'|t, - current: 'I dag'|t, {# Changed from 'I gang' #} - upcoming: 'Kommende'|t, - note: 'Note'|t, -} %} -``` - -### 2. Timeline Data Generation - -**File**: Module or preprocess function that generates timeline data - -The "I dag" card should be auto-generated with: - -```php -$today_card = [ - 'id' => 'today', - 'date' => date('Y-m-d'), - 'month' => format_date(time(), 'custom', 'F Y'), // "Januar 2026" - 'title' => t('Projektstatus'), - 'subtitle' => NULL, - 'description' => $node->get('field_project_status')->value, - 'status' => 'current', - 'image' => NULL, - 'link' => NULL, - 'linkText' => NULL, - 'accentColor' => NULL, -]; -``` - -### 3. Card Insertion Logic - -The "I dag" card should be inserted at the correct chronological position: - -1. Sort all timeline items by date -2. Find the position where today's date falls -3. Insert the "I dag" card at that position -4. Remove any manually configured `status: 'current'` items (or convert them to regular items) - -**Edge cases - today outside timeline range**: -- If today is **before** the first timeline item → show "I dag" card as the **first** item -- If today is **after** the last timeline item → show "I dag" card as the **last** item -- The "I dag" card is always shown (unless project status field is empty) - -### 4. CSS Adjustments - -**File**: `web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss` - -Ensure minimum card height for the current/today card: - -```scss -.project-timeline-card--current { - .project-timeline-card__content { - min-height: 200px; // Adjust as needed to prevent overlap - } -} -``` - -## Migration Considerations - -1. **Existing data**: Any timeline items with `status: 'current'` should be reviewed - - Convert to `status: 'completed'` if in the past - - Convert to `status: 'upcoming'` if in the future - - Remove if they were placeholder "current" items - -2. **Field creation**: If `field_project_status` doesn't exist, it needs to be added to the project content type - -3. **Translation**: Ensure "I dag" and "Projektstatus" are added to translation files - -## Files to Modify - -| File | Changes | -|------|---------| -| `project-timeline-card.html.twig` | Update status label | -| `timeline.html.twig` or preprocess | Auto-generate today card | -| `_timeline.scss` | Add min-height for current card | -| Project content type | Add/configure status field | -| `hoeringsportal.module` or similar | Timeline data preprocessing | - -## Testing Checklist - -- [ ] "I dag" card displays with current month/year -- [ ] Project status text displays from Drupal field -- [ ] Card maintains proper height (no overlap) -- [ ] Card appears at correct chronological position -- [ ] Card appears as **first** item when today is before timeline start -- [ ] Card appears as **last** item when today is after timeline end -- [ ] Card is shown **without status text** when project status field is empty -- [ ] Vertical view displays correctly -- [ ] Horizontal/carousel view displays correctly -- [ ] Mini-nav highlights today correctly -- [ ] Mobile responsive layout works -- [ ] Translation strings work - -## Resolved Questions - -1. **What is the exact Drupal field name for project status?** - → Use placeholder `field_project_status` for now (exact field name TBD) - -2. **Should there be a fallback text if the project status field is empty?** - → No. The card is still shown, but without the status text. - -3. **Should the "I dag" card be hidden if today's date is outside the project timeline range?** - → No. The card should appear as the first or last item on the timeline. diff --git a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss index 57d7455cd..ec6a5a985 100644 --- a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss +++ b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss @@ -529,7 +529,7 @@ $timeline-image-height: 140px; ); } -// Current - special case with hollow dot +// Current - special case with hollow dot (used for "I dag" card) .project-timeline-card--current { @include timeline-card-status-variant( var(--timeline-status-current), @@ -537,6 +537,11 @@ $timeline-image-height: 140px; var(--primitive-orange-500) ); + // Ensure minimum height to prevent overlap with adjacent cards + .project-timeline-card__content { + min-height: 200px; + } + // Override dot to be hollow (current indicator) .project-timeline-card__dot { background: var(--bg-primary); diff --git a/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig b/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig index af95f17ee..b62a43fe7 100644 --- a/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig +++ b/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig @@ -27,7 +27,7 @@ {% set status_labels = { completed: '✓ Afsluttet'|t, - current: 'I gang'|t, + current: 'I dag'|t, upcoming: 'Kommende'|t, note: 'Note'|t, } %} diff --git a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig index 6c543f522..4f2ad0d5a 100644 --- a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig +++ b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig @@ -1,123 +1,176 @@ +{# + Timeline sample data (without status: 'current' - that is auto-generated as "I dag" card). + Status for each item is determined by comparing item date to today's date. +#} +{% set today_date = 'now'|date('Y-m-d') %} +{% set today_month_year = 'now'|date('F Y') %} + +{# Danish month names mapping #} +{% set danish_months = { + January: 'Januar', + February: 'Februar', + March: 'Marts', + April: 'April', + May: 'Maj', + June: 'Juni', + July: 'Juli', + August: 'August', + September: 'September', + October: 'Oktober', + November: 'November', + December: 'December' +} %} + +{# Get Danish month name for today #} +{% set today_english_month = 'now'|date('F') %} +{% set today_danish_month = danish_months[today_english_month] ~ ' ' ~ 'now'|date('Y') %} + +{# Sample timeline items (these would normally come from a field) #} +{% set timeline_items = [ + { + id: 'item-1', + date: '2023-08-01', + month: 'August 2023', + title: 'Udpegning', + subtitle: 'Dialog', + description: 'Hjortshøj Stationsplads udpeges som ét af 13 byrum i Aarhus Kommunes Strategiske Byrumsplan.', + image: NULL, + link: 'https://aarhus.dk/nyt/teknik-og-miljoe/2023/august-2023/raadmand-vil-puste-nyt-liv-i-13-af-byens-rum', + linkText: 'Læs om byrumsplanen', + accentColor: NULL, + }, + { + id: 'item-2', + date: '2025-04-01', + month: 'April 2025', + title: 'Dialog med Fællesråd', + subtitle: 'Dialog', + description: 'Gåtur på Stationspladsen og efterfølgende møde om stedets identitet og potentialer med fællesrådet.', + image: 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg', + link: '/public_meeting/1296', + linkText: 'Se detaljer om mødet', + accentColor: NULL, + }, + { + id: 'item-3', + date: '2025-08-01', + month: 'August 2025', + title: 'Workshop med Virupskolen', + subtitle: 'Begivenhed', + description: 'Formiddag sammen med skoleelever fra Virupskolen om fremtidens stationsplads.', + image: 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1227%20%281%29%20-%20cropped.jpg', + link: '/public_meeting/1298', + linkText: 'Se workshoppen', + accentColor: 'pink', + }, + { + id: 'item-4', + date: '2025-08-15', + month: 'August 2025', + title: 'Pop-up Dialog', + subtitle: 'Dialog', + description: 'Pop-up og kaffe på Stationspladsen med borgere. Mange gode input fra det brede lokale borgerperspektiv.', + image: 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Kategorier_stor_0.png', + link: '/public_meeting/1212', + linkText: 'Se pop-up dialogen', + accentColor: NULL, + }, + { + id: 'item-5', + date: '2025-09-01', + month: 'September 2025', + title: 'Digital Dialog', + subtitle: 'Høring', + description: 'Mulighed for at berige og kvalificere de identificerede temaer, samt komme med supplerende nye input.', + image: 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg', + link: '/public_meeting/1299', + linkText: 'Se den digitale dialog', + accentColor: NULL, + }, + { + id: 'item-6', + date: '2025-10-15', + month: 'Oktober 2025', + title: 'Redaktionel bemærkning', + subtitle: 'Note', + description: 'Dette er en redaktionel note fra projektteamet med vigtig information om projektet.', + image: NULL, + link: NULL, + linkText: NULL, + accentColor: 'blue', + }, + { + id: 'item-7', + date: '2026-01-01', + month: 'Januar 2026', + title: 'Offentlig Høring', + subtitle: 'Høring', + description: 'Forslaget til omdannelse af Stationspladsen sendes i offentlig høring.', + image: NULL, + link: NULL, + linkText: NULL, + accentColor: NULL, + }, + { + id: 'item-8', + date: '2027-01-01', + month: 'Januar 2027', + title: 'Realisering', + subtitle: 'Note', + description: 'Omdannelsen af Hjortshøj Stationsplads realiseres.', + image: NULL, + link: NULL, + linkText: NULL, + accentColor: NULL, + } +] %} + +{# Auto-generate the "I dag" (Today) card #} +{% set today_card = { + id: 'today', + date: today_date, + month: today_danish_month, + title: 'Projektstatus'|t, + subtitle: NULL, + description: project_status|default(NULL), + status: 'current', + image: NULL, + link: NULL, + linkText: NULL, + accentColor: NULL, +} %} + +{# Build final timeline with status determined by date comparison and today card inserted #} +{% set final_items = [] %} +{% set today_inserted = false %} + +{% for item in timeline_items %} + {# Determine status based on date comparison #} + {% if item.accentColor == 'blue' %} + {% set item_status = 'note' %} + {% elseif item.date < today_date %} + {% set item_status = 'completed' %} + {% else %} + {% set item_status = 'upcoming' %} + {% endif %} + + {# Insert today card at correct chronological position #} + {% if not today_inserted and item.date >= today_date %} + {% set final_items = final_items|merge([today_card]) %} + {% set today_inserted = true %} + {% endif %} + + {# Add item with computed status #} + {% set final_items = final_items|merge([item|merge({status: item_status})]) %} +{% endfor %} + +{# If today is after all items, append today card at the end #} +{% if not today_inserted %} + {% set final_items = final_items|merge([today_card]) %} +{% endif %} + {% set items = { - "timelineData": [ - { - 'id' : 'item-1', - 'date' : '2023-08-01', - 'month' : 'August 2023', - 'title' : 'Udpegning', - 'subtitle' : 'Dialog', - 'description' : 'Hjortshøj Stationsplads udpeges som ét af 13 byrum i Aarhus Kommunes Strategiske Byrumsplan.', - 'status' : 'completed', - 'image' : NULL, - 'link' : 'https://aarhus.dk/nyt/teknik-og-miljoe/2023/august-2023/raadmand-vil-puste-nyt-liv-i-13-af-byens-rum', - 'linkText' : 'Læs om byrumsplanen', - 'accentColor' : NULL, - }, - { - 'id' : 'item-2', - 'date' : '2025-04-01', - 'month' : 'April 2025', - 'title' : 'Dialog med Fællesråd', - 'subtitle' : 'Dialog', - 'description' : 'Gåtur på Stationspladsen og efterfølgende møde om stedets identitet og potentialer med fællesrådet.', - 'status' : 'completed', - 'image' : 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg', - 'link' : '/public_meeting/1296', - 'linkText' : 'Se detaljer om mødet', - 'accentColor' : NULL, - }, - { - 'id' : 'item-3', - 'date' : '2025-08-01', - 'month' : 'August 2025', - 'title' : 'Workshop med Virupskolen', - 'subtitle' : 'Begivenhed', - 'description' : 'Formiddag sammen med skoleelever fra Virupskolen om fremtidens stationsplads.', - 'status' : 'completed', - 'image' : 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1227%20%281%29%20-%20cropped.jpg', - 'link' : '/public_meeting/1298', - 'linkText' : 'Se workshoppen', - 'accentColor' : 'pink', - }, - { - 'id' : 'item-4', - 'date' : '2025-08-15', - 'month' : 'August 2025', - 'title' : 'Pop-up Dialog', - 'subtitle' : 'Dialog', - 'description' : 'Pop-up og kaffe på Stationspladsen med borgere. Mange gode input fra det brede lokale borgerperspektiv.', - 'status' : 'completed', - 'image' : 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/Kategorier_stor_0.png', - 'link' : '/public_meeting/1212', - 'linkText' : 'Se pop-up dialogen', - 'accentColor' : NULL, - }, - { - 'id' : 'item-5', - 'date' : '2025-09-01', - 'month' : 'September 2025', - 'title' : 'Digital Dialog', - 'subtitle' : 'Høring', - 'description' : 'Mulighed for at berige og kvalificere de identificerede temaer, samt komme med supplerende nye input.', - 'status' : 'completed', - 'image' : 'https://deltag.aarhus.dk/sites/default/files/styles/responsive_small_teaser/public/images/IMG_1064_cropped.jpg', - 'link' : '/public_meeting/1299', - 'linkText' : 'Se den digitale dialog', - 'accentColor' : NULL, - }, - { - 'id' : 'item-6', - 'date' : '2025-10-01', - 'month' : 'Oktober 2025', - 'title' : 'Udarbejdelse af Forslag', - 'subtitle' : 'Dialog', - 'description' : 'De mange input danner fundament for udarbejdelse af et forslag til omdannelsen af Hjortshøj Stationsplads.', - 'status' : 'current', - 'image' : NULL, - 'link' : NULL, - 'linkText' : NULL, - 'accentColor' : NULL, - }, - { - 'id' : 'item-7', - 'date' : '2025-10-15', - 'month' : 'Oktober 2025', - 'title' : 'Redaktionel bemærkning', - 'subtitle' : 'Note', - 'description' : 'Dette er en redaktionel note fra projektteamet med vigtig information om projektet.', - 'status' : 'note', - 'image' : NULL, - 'link' : NULL, - 'linkText' : NULL, - 'accentColor' : 'blue', - }, - { - 'id' : 'item-8', - 'date' : '2026-01-01', - 'month' : 'Januar 2026', - 'title' : 'Offentlig Høring', - 'subtitle' : 'Høring', - 'description' : 'Forslaget til omdannelse af Stationspladsen sendes i offentlig høring.', - 'status' : 'upcoming', - 'image' : NULL, - 'link' : NULL, - 'linkText' : NULL, - 'accentColor' : NULL, - }, - { - 'id' : 'item-9', - 'date' : '2027-01-01', - 'month' : 'Januar 2027', - 'title' : 'Realisering', - 'subtitle' : 'Note', - 'description' : 'Omdannelsen af Hjortshøj Stationsplads realiseres.', - 'status' : 'upcoming', - 'image' : NULL, - 'link' : NULL, - 'linkText' : NULL, - 'accentColor' : NULL, - } - ] + timelineData: final_items } %} {% set default_view = 'horizontal' %} diff --git a/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig b/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig index c43cb092e..52dc6e87b 100755 --- a/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig +++ b/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig @@ -40,7 +40,9 @@
- {{ include(directory ~ '/templates/components/timeline.html.twig') }} + {{ include(directory ~ '/templates/components/timeline.html.twig', { + project_status: node.field_project_status.value + }) }}
From 5b1479a275156654bc6f6799d1301c679ce7599d Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Fri, 23 Jan 2026 13:43:08 +0100 Subject: [PATCH 10/49] Add demo status and hide empty title/description on today card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add demo status text "Åben for borgerindragelse" for prototype - Hide both title and description when project status is empty - Add conditional check for title in card template Co-Authored-By: Claude Opus 4.5 --- .../templates/components/project-timeline-card.html.twig | 4 +++- .../templates/components/timeline.html.twig | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig b/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig index b62a43fe7..0a3a85eae 100644 --- a/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig +++ b/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig @@ -76,7 +76,9 @@ {# Body #}
-

{{ item.title }}

+ {% if item.title %} +

{{ item.title }}

+ {% endif %} {% if item.subtitle %}

{{ item.subtitle }}

diff --git a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig index 4f2ad0d5a..1bd485a80 100644 --- a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig +++ b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig @@ -126,13 +126,18 @@ ] %} {# Auto-generate the "I dag" (Today) card #} +{# Demo: Use hardcoded status text until real field integration #} +{% set demo_status = 'Åben for borgerindragelse' %} +{% set effective_status = project_status|default(demo_status) %} + +{# If no status, hide both title and description #} {% set today_card = { id: 'today', date: today_date, month: today_danish_month, - title: 'Projektstatus'|t, + title: effective_status ? 'Projektstatus'|t : NULL, subtitle: NULL, - description: project_status|default(NULL), + description: effective_status, status: 'current', image: NULL, link: NULL, From af10963ada20e313b08e1d232de0b3b264880931 Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Fri, 23 Jan 2026 13:50:32 +0100 Subject: [PATCH 11/49] Fix default view and remove upcoming card opacity - Change default view from horizontal to vertical - Remove opacity (0.6) from upcoming cards to avoid contrast issues Co-Authored-By: Claude Opus 4.5 --- .../custom/hoeringsportal/assets/css/module/_timeline.scss | 5 +---- .../hoeringsportal/templates/components/timeline.html.twig | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss index ec6a5a985..1b8ac22ec 100644 --- a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss +++ b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss @@ -558,10 +558,7 @@ $timeline-image-height: 140px; ); } -// Upcoming - lower opacity -.project-timeline-card--upcoming { - opacity: 0.6; -} +// Upcoming - uses default styling (no opacity to avoid contrast issues) // ============================================================================= // Card Accent Color Variants diff --git a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig index 1bd485a80..d2beb2167 100644 --- a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig +++ b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig @@ -178,7 +178,7 @@ timelineData: final_items } %} -{% set default_view = 'horizontal' %} +{% set default_view = 'vertical' %} {# /** From b3722b490266013bad65565cb2e877305da7c8ee Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Fri, 23 Jan 2026 13:57:00 +0100 Subject: [PATCH 12/49] Add shadow variables and apply consistently - Add $timeline-shadow-default and $timeline-shadow-hover variables - Apply subtle default shadow (0 2px 4px rgb(0 0 0 / 6%)) - Apply prominent hover shadow (0 4px 12px rgb(0 0 0 / 12%)) - Use consistent shadows on: cards, mini-nav, and timeline dots Co-Authored-By: Claude Opus 4.5 --- .../hoeringsportal/assets/css/module/_timeline.scss | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss index 1b8ac22ec..f89e4dd61 100644 --- a/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss +++ b/web/themes/custom/hoeringsportal/assets/css/module/_timeline.scss @@ -21,6 +21,10 @@ $timeline-dot-size: 12px; $timeline-card-padding: $spacer * 1.5; // 24px $timeline-card-padding-sm: $spacer; // 16px +// Shadow variables - subtle default, prominent on hover +$timeline-shadow-default: 0 2px 4px rgb(0 0 0 / 6%); +$timeline-shadow-hover: 0 4px 12px rgb(0 0 0 / 12%); + // Status color mappings using project tokens $timeline-statuses: ( "completed": ( @@ -328,7 +332,7 @@ $timeline-accents: ( border-radius: 50%; background: var(--timeline-status-upcoming); border: 3px solid var(--bg-primary); - box-shadow: $shadow; + box-shadow: $timeline-shadow-default; flex-shrink: 0; } @@ -363,7 +367,7 @@ $timeline-image-height: 140px; .project-timeline-card__content { width: calc(50% - #{$timeline-card-gap}); background: var(--card-bg); - box-shadow: $shadow; + box-shadow: $timeline-shadow-default; border: 1px solid var(--card-border); transition: box-shadow 0.2s ease; position: relative; @@ -371,7 +375,7 @@ $timeline-image-height: 140px; cursor: default; &:hover { - box-shadow: 0 4px 16px rgb(0 0 0 / 12%); + box-shadow: $timeline-shadow-hover; } // Accent bar on card edge @@ -598,7 +602,7 @@ $mini-nav-dot-size: 10px; align-items: center; padding: $spacer ($spacer * 0.625); background: var(--bg-primary); - box-shadow: $shadow; + box-shadow: $timeline-shadow-default; border: 1px solid var(--border-default); } From 0993cbe40e3f3c4637b6e03cc21fcf46dfc948e0 Mon Sep 17 00:00:00 2001 From: martinyde Date: Fri, 23 Jan 2026 14:11:34 +0100 Subject: [PATCH 13/49] Added data collection from drupal --- .../hoeringsportal_project.info.yml | 6 + .../hoeringsportal_project.module | 24 +++ .../hoeringsportal_project.services.yml | 5 + .../src/Helper/ProjectHelper.php | 161 ++++++++++++++++++ .../templates/components/timeline.html.twig | 18 +- 5 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 web/modules/custom/hoeringsportal_project/hoeringsportal_project.info.yml create mode 100644 web/modules/custom/hoeringsportal_project/hoeringsportal_project.module create mode 100644 web/modules/custom/hoeringsportal_project/hoeringsportal_project.services.yml create mode 100644 web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php diff --git a/web/modules/custom/hoeringsportal_project/hoeringsportal_project.info.yml b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.info.yml new file mode 100644 index 000000000..7f4830401 --- /dev/null +++ b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.info.yml @@ -0,0 +1,6 @@ +name: Hoeringsportal project +type: module +description: "Provides project functionality for Hoeringsportal." +core_version_requirement: ^10 || ^11 +package: ITK + diff --git a/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module new file mode 100644 index 000000000..33efa76d2 --- /dev/null +++ b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module @@ -0,0 +1,24 @@ +projectPreprocess($variables); +} + +/** + * Implements hook_entity_presave(). + */ +#[LegacyHook] +function hoeringsportal_project_entity_presave(EntityInterface $entity): void { + if ($entity instanceof NodeInterface) { + Drupal::service(ProjectHelper::class)->nodePresave($entity); + } +} diff --git a/web/modules/custom/hoeringsportal_project/hoeringsportal_project.services.yml b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.services.yml new file mode 100644 index 000000000..3771eb37d --- /dev/null +++ b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.services.yml @@ -0,0 +1,5 @@ +services: + _defaults: + autowire: true + + Drupal\hoeringsportal_project\Helper\ProjectHelper: diff --git a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php new file mode 100644 index 000000000..644b28e7c --- /dev/null +++ b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php @@ -0,0 +1,161 @@ +bundle()) { + $nodeStorage = $this->entityTypeManagerInterface->getStorage('node'); + + $referenceQuery = $nodeStorage->getQuery(); + $dateFieldQuery = $referenceQuery->orConditionGroup() + ->exists('field_decision_date') + ->exists('field_start_date') + ->exists('field_first_meeting_time') + ->condition('type', 'dialogue', '='); + + $referenceQuery->accessCheck(); + $referenceQuery->exists('field_project_reference'); + $referenceQuery->condition('field_project_reference', $variables['node']->id()); + $referenceQuery->condition('field_hide_in_timeline', FALSE); + $referenceQuery->condition($dateFieldQuery); + $references = $referenceQuery->execute(); + + $nodes = $nodeStorage->loadMultiple($references); + + $now = new DateTimeImmutable(); + + /** @var NodeInterface $node */ + foreach ($nodes as $node) { + $date = $this->determineDate($node); + $image = $this->determineImage($node)?->getFileUri(); + + $variables['external_project_references'][] = [ + 'id' => $node->id(), + 'date' => $date->format('d-m-Y'), // change + 'month' => $date->format('F Y'), // change + 'title' => $node->getTitle(), + 'subtitle' => $node->type->entity->label(), + 'description' => $node->field_teaser->value, + 'status' => $this->determineStatus($node, $date, $now), + 'image' => ImageStyle::load('responsive_medium_default')->buildUrl($image), + 'link' => $this->urlGenerator->generateFromRoute('entity.node.canonical', ['node' => $node->id()]), + 'linkText' => $this->t('View @type', ['@type' => $node->type->entity->label()]), + 'accentColor' => $this->determineColor($node->bundle()), + ]; + } + } + } + + /** + * Implements hook_preprocess_node(). + */ + #[Hook('entity_presave')] + public function nodePresave(EntityInterface $entity): void { + if (!$entity->hasField('field_project_reference')) { + return; + } + + $newTargetId = (int) ($entity->get('field_project_reference')->target_id ?? 0); + + $originalEntity = $entity->original ?? null; + $oldTargetId = 0; + if ($originalEntity->hasField('field_project_reference')) { + $oldTargetId = (int) ($originalEntity->get('field_project_reference')->target_id ?? 0); + } + + // Only act if the reference actually changed. + if ($oldTargetId === $newTargetId) { + return; + } + + $idsToReset = []; + if ($oldTargetId > 0) { + $idsToReset[] = $oldTargetId; + } + if ($newTargetId > 0) { + $idsToReset[] = $newTargetId; + } + + if ($idsToReset === []) { + return; + } + + $idsToReset = array_values(array_unique($idsToReset)); + $this->entityTypeManagerInterface->getStorage('node')->resetCache($idsToReset); + } + + + private function determineDate(NodeInterface $node): ?DateTimeImmutable { + try { + switch (TRUE) { + case $node->hasField('field_decision_date'): + return new \DateTimeImmutable($node->field_decision_date->value); + case $node->hasField('field_start_date'): + return new \DateTimeImmutable($node->field_start_date->value); + case $node->hasField('field_first_meeting_time'): + return new \DateTimeImmutable($node->field_first_meeting_time->value); + case 'dialogue' === $node->getType(); + return new \DateTimeImmutable(strtotime($node->getCreatedTime())); + default: + return NULL; + } + } + catch (DateMalformedStringException $exception) { + return NULL; + } + + } + private function determineImage(NodeInterface $node): ?File { + switch (TRUE) { + case $node->hasField('field_media_image'): + return $node->field_media_image->entity->field_itk_media_image_upload->entity; + case $node->hasField('field_media_image_single'): + return $node->field_media_image_single->entity->field_itk_media_image_upload->entity; + case $node->hasField('field_top_images'): + return $node->field_top_images->entity->field_itk_media_image_upload->entity; + default: + return NULL; + } + } + + private function determineColor(string $bundle): string { + switch ($bundle) { + case 'public_meeting': + case 'course': + return '#e91e63'; + case 'dialogue': + case 'hearing': + case 'decision': + return '#008486'; + } + } + + private function determineStatus(NodeInterface $node, ?DateTimeImmutable $date, DateTimeImmutable $now): string { + if ($now > $date) { + return $this->t('Upcoming'); + } + return $this->t('Active'); + } +} diff --git a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig index 6c543f522..e5b8aa03c 100644 --- a/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig +++ b/web/themes/custom/hoeringsportal/templates/components/timeline.html.twig @@ -1,4 +1,6 @@ -{% set items = { +{% set default_view = 'horizontal' %} + +{# set items = { "timelineData": [ { 'id' : 'item-1', @@ -118,9 +120,7 @@ 'accentColor' : NULL, } ] -} %} - -{% set default_view = 'horizontal' %} +} #} {# /** @@ -193,10 +193,10 @@ >
{# Mini navigation sidebar #} - {{ include(directory ~ '/templates/components/project-timeline-mini-nav.html.twig', {items: items.timelineData}) }} + {{ include(directory ~ '/templates/components/project-timeline-mini-nav.html.twig', {items: external_project_references}) }} {# Timeline cards #}
- {% for item in items.timelineData %} + {% for item in external_project_references %} {{ include(directory ~ '/templates/components/project-timeline-card.html.twig', {item: item}) }} {% endfor %}
@@ -225,14 +225,14 @@ +
+ {% if item.is_today_marker|default(false) %} + {{ 'I dag'|t }} + {% else %} + {{ item.date|date('M') }} + {{ item.date|date('Y') }} + {% endif %} +
+
+ {% if not item.is_today_marker|default(false) %} + {% set slide_index = slide_index + 1 %} + {% endif %} + {% endfor %} +
+ + {# 2. Carousel viewport SECOND #} + + + {# 3. Carousel navigation LAST #} - - {# Carousel track #} - {# Legend #} - {% if legend_items|length > 0 %} - {{ include(directory ~ '/templates/components/project-timeline-card.html.twig', {items: legend_items}) }} + {% if legend_items|default([])|length > 0 %} +
+ {{ include(directory ~ '/templates/components/project-timeline-legend.html.twig', {items: legend_items}) }} +
{% endif %}
diff --git a/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig b/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig index 3eb2229f8..8a3b7b0ad 100755 --- a/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig +++ b/web/themes/custom/hoeringsportal/templates/content/node--project-main-page--full.html.twig @@ -26,7 +26,7 @@
- {{ content|without('field_project_image', 'field_short_description', 'field_aside_block', 'field_content_sections', 'field_project_category', 'field_area', 'published_at', 'field_project_status', 'field_department', 'field_type') }} + {{ content|without('field_project_image', 'field_short_description', 'field_aside_block', 'field_content_sections', 'field_project_category', 'field_area', 'published_at', 'field_project_status', 'field_department', 'field_type', 'field_show_timeline', 'field_timeline') }}
{{ content.field_aside_block }} From 2dbc818fd909b4e4180219599f3a651e01561409 Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Tue, 27 Jan 2026 08:50:39 +0100 Subject: [PATCH 19/49] Fix multiple mini-nav dots highlighted when scrolling fast - Assign unique IDs to timeline notes (note-{paragraph_id}) instead of empty strings, so each note can be individually tracked - Improve scroll tracking to ignore hidden horizontal view duplicates by checking element dimensions before processing intersection events - Change scoring algorithm to prefer cards that contain the viewport center, ensuring small cards like notes are properly highlighted Fixes #609 Co-Authored-By: Claude Opus 4.5 --- .../src/Helper/ProjectHelper.php | 2 +- .../hoeringsportal/assets/js/timeline.js | 60 ++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php index aa99af152..a6ac3bef8 100644 --- a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php +++ b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php @@ -156,7 +156,7 @@ private function addNoteAsTimelineItem(mixed $paragraph, $now): array { $image = $paragraph?->field_paragraph_image?->entity?->field_itk_media_image_upload?->entity?->getFileUri(); return [ - 'id' => '', + 'id' => 'note-' . $paragraph->id(), 'date' => $date->format('Y-m-d'), 'month' => $date->format('F Y'), 'title' => $paragraph->field_title->value, diff --git a/web/themes/custom/hoeringsportal/assets/js/timeline.js b/web/themes/custom/hoeringsportal/assets/js/timeline.js index 89fb19d20..21523a282 100644 --- a/web/themes/custom/hoeringsportal/assets/js/timeline.js +++ b/web/themes/custom/hoeringsportal/assets/js/timeline.js @@ -138,6 +138,9 @@ return; } + // Track all currently intersecting cards + const visibleCards = new Set(); + const observerOptions = { root: null, rootMargin: OBSERVER_ROOT_MARGIN, @@ -145,12 +148,65 @@ }; state.observer = new IntersectionObserver((entries) => { + // Update the visibility set with changed entries + // Only track cards that are actually visible (have dimensions) entries.forEach((entry) => { + const { cardId } = entry.target.dataset; + const rect = entry.boundingClientRect; + const isVisible = rect.height > 0 && rect.width > 0; + + // Only process events from visible cards (ignore hidden horizontal duplicates) + if (!isVisible) { + return; + } + if (entry.isIntersecting) { - const { cardId } = entry.target.dataset; - updateActiveNavLink(cardId); + visibleCards.add(cardId); + } else { + visibleCards.delete(cardId); + } + }); + + // Find the card that best overlaps the viewport center + // Prefer the topmost card whose bounds contain the center point + const viewportCenter = window.innerHeight / 2; + let bestCardId = null; + let bestScore = -Infinity; + + visibleCards.forEach((cardId) => { + // Find the visible card (not the hidden horizontal view duplicate) + const cards = timeline.querySelectorAll(`[data-card-id="${cardId}"]`); + const card = Array.from(cards).find( + (c) => c.getBoundingClientRect().height > 0, + ); + if (card) { + const rect = card.getBoundingClientRect(); + + // Score based on how well the card covers the viewport center + // Higher score = card contains or is closer to center + let score; + if (rect.top <= viewportCenter && rect.bottom >= viewportCenter) { + // Card contains the center point - highest priority + // Prefer cards where center is further from the edges (more centered) + const distFromTop = viewportCenter - rect.top; + const distFromBottom = rect.bottom - viewportCenter; + score = 1000 + Math.min(distFromTop, distFromBottom); + } else { + // Card doesn't contain center - score by distance + const cardCenter = rect.top + rect.height / 2; + score = -Math.abs(cardCenter - viewportCenter); + } + + if (score > bestScore) { + bestScore = score; + bestCardId = cardId; + } } }); + + if (bestCardId) { + updateActiveNavLink(bestCardId); + } }, observerOptions); elements.cards.forEach((card) => { From 679ffe494e0631ca36b5595ca4e9bbe340d4f032 Mon Sep 17 00:00:00 2001 From: Jesper Pedersen Date: Tue, 27 Jan 2026 09:19:09 +0100 Subject: [PATCH 20/49] Add CLAUDE.md with project documentation for Claude Code Add comprehensive documentation including project overview, development commands, architecture details, coding standards, and configuration. Includes core module descriptions and Docker service information. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..6536800d2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,160 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Høringsportal (deltag.aarhus.dk) is a Drupal 10 civic engagement platform for Aarhus municipality. +It enables citizens to participate in public hearings, submit citizen proposals, attend public meetings, +and engage with local projects. + +## Development Commands + +This project uses [Task](https://taskfile.dev/) for all development workflows. Run `task` to see available commands. + +### Essential Commands + +```bash +# Initial setup (resets database) +task site-install + +# Update existing installation +task site-update + +# Build theme assets +task assets-build + +# Watch for changes during development +task assets-watch + +# Load test fixtures +task fixtures:load + +# Run Drush commands +task drush -- + +# Access container +task compose -- exec phpfpm bash +``` + +### Code Quality + +```bash +# Apply all coding standards (PHP, JS, CSS, Twig, YAML, Markdown) +task coding-standards:apply + +# Check all coding standards +task coding-standards:check + +# Static analysis (PHPStan) +task code-analysis + +# Individual checks +task coding-standards:php:check +task coding-standards:twig:check +task coding-standards:javascript:check +task coding-standards:styles:check +``` + +### Testing + +Playwright tests are located in `playwright/` directory: + +```bash +docker compose --profile test run --rm playwright npx playwright test +``` + +## Architecture + +### Directory Structure + +- `web/modules/custom/` - Custom Drupal modules +- `web/themes/custom/hoeringsportal/` - Main theme (Bootstrap 5, Webpack Encore) +- `web/themes/custom/hoeringsportal_admin/` - Admin theme +- `config/sync/` - Drupal configuration + +### Core Custom Modules + +- **hoeringsportal_hearing** - Hearing (høring) content type and functionality +- **hoeringsportal_citizen_proposal** - Citizen proposal system with OpenID Connect authentication +- **hoeringsportal_public_meeting** - Public meetings with Pretix integration for ticketing +- **hoeringsportal_activity** - Extends public meetings to include other activity types +- **hoeringsportal_project** - Project content type for civic engagement projects +- **hoeringsportal_deskpro** - Deskpro helpdesk integration for hearing replies +- **hoeringsportal_data** - Data helpers and API endpoints +- **hoeringsportal_dialogue** - Dialogue/discussion features with comments +- **hoeringsportal_anonymous_edit** - Allow anonymous users to edit their submissions +- **hoeringsportal_openid_connect** - OpenID Connect authentication customizations + +### External Integrations + +- **Pretix** - Event ticketing for public meetings (docker-compose.pretix.yml) +- **Deskpro** - Helpdesk for hearing submissions +- **OpenID Connect** - Citizen authentication (docker-compose.oidc.yml) +- **ClamAV** - Virus scanning for uploads +- **Serviceplatformen** - Danish government services integration + +### Theme Architecture + +The theme uses Webpack Encore for asset building: + +```bash +# Theme location +web/themes/custom/hoeringsportal/ + +# Build commands +npm install --prefix web/themes/custom/hoeringsportal +npm run build --prefix web/themes/custom/hoeringsportal +``` + +CSS uses SCSS with Bootstrap 5 and CSS custom properties for color management. + +## Coding Standards + +- PHP follows Drupal coding standards (phpcs.xml.dist) +- PHPStan level 0 for custom modules, level 9 for hoeringsportal_audit_log +- Twig uses twig-cs-fixer +- JavaScript and CSS use Prettier +- YAML uses Prettier + +## Docker Services + +Primary services (docker-compose.yml): + +- phpfpm (PHP 8.3) +- nginx +- mariadb +- memcached +- mail (Mailpit) + +Optional profiles: + +- `pretix` - Event ticketing system +- `test` - Playwright testing +- `dev` - Code quality tools (markdownlint, prettier) + +Start optional services: + +```bash +PROFILES=pretix task compose -- up --detach +``` + +## Configuration + +Local settings go in `web/sites/default/settings.local.php`. +Environment variables can be set in `.env.local`. + +Key settings: + +- `TASK_DOCKER_COMPOSE_PROFILES` - Docker profiles to auto-start +- `TASK_ASSETS_SKIP_BUILD` - Skip asset building on site-update + +## Translations + +Import custom translations: + +```bash +task translations:import +``` + +Translation files are in `translations/` and managed via Drush locale commands. From eb73a9634154996110c0d873deef22cfa0b1c9e6 Mon Sep 17 00:00:00 2001 From: martinyde Date: Tue, 27 Jan 2026 09:43:26 +0100 Subject: [PATCH 21/49] Updated code comments --- .../src/Helper/ProjectHelper.php | 384 +++++++++++++----- 1 file changed, 272 insertions(+), 112 deletions(-) diff --git a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php index aa99af152..6c32d7428 100644 --- a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php +++ b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php @@ -7,25 +7,42 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Logger\LoggerChannelInterface; use Drupal\Core\Routing\UrlGeneratorInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\file\Entity\File; use Drupal\image\Entity\ImageStyle; use Drupal\node\NodeInterface; +use Drupal\paragraphs\ParagraphInterface; +use Drupal\Core\Logger\LoggerChannelFactoryInterface; +use Exception; class ProjectHelper { use StringTranslationTrait; + /** + * The logger channel. + * + * @var \Drupal\Core\Logger\LoggerChannelInterface + */ + protected LoggerChannelInterface $logger; + public function __construct( protected EntityTypeManagerInterface $entityTypeManagerInterface, protected UrlGeneratorInterface $urlGenerator, - ) {} + LoggerChannelFactoryInterface $loggerFactory, + ) { + $this->logger = $loggerFactory->get('hoeringsportal_project'); + } /** * Implements hook_preprocess_node(). + * + * @param array $variables + * The template variables array. */ #[Hook('preprocess')] - public function projectPreprocess(&$variables): void { + public function projectPreprocess(array &$variables): void { if ('full' === $variables['view_mode'] && 'project_main_page' === $variables['node']->bundle()) { if (!$variables['node']->field_show_timeline->value) { return; @@ -37,13 +54,19 @@ public function projectPreprocess(&$variables): void { $nodes = $this->getTimelineNodes($variables); foreach ($nodes as $node) { - $variables['timeline_items'][] = $this->addNodeAsTimelineItem($node, $now); + $item = $this->addNodeAsTimelineItem($node, $now); + if (!empty($item)) { + $variables['timeline_items'][] = $item; + } } $notes = $this->getTimelineNotes($variables); foreach ($notes as $note) { - $variables['timeline_items'][] = $this->addNoteAsTimelineItem($note, $now); + $item = $this->addNoteAsTimelineItem($note, $now); + if (!empty($item)) { + $variables['timeline_items'][] = $item; + } } $variables['timeline_items'][] = $this->addNowAsTimelineItem($now); @@ -51,9 +74,17 @@ public function projectPreprocess(&$variables): void { } } + /** + * Implements hook_FORMID_form_alter(). + * + * @param array $form + * The form array. + * @param FormStateInterface $form_state + * The form state object. + */ #[Hook('form_node_project_main_page_form_alter')] #[Hook('form_node_project_main_page_edit_form_alter')] - public function projectFormAlter(&$form, FormStateInterface $form_state): void { + public function projectFormAlter(array &$form, FormStateInterface $form_state): void { $timelineSelector = ':input[name="field_show_timeline[value]"]'; $form['field_timeline']['#states'] = [ @@ -65,111 +96,198 @@ public function projectFormAlter(&$form, FormStateInterface $form_state): void { /** * Implements hook_preprocess_node(). + * + * @param EntityInterface $entity + * The entity being saved. */ #[Hook('entity_presave')] public function nodePresave(EntityInterface $entity): void { - if (!$entity->hasField('field_project_reference')) { - return; - } + try { + if (!$entity->hasField('field_project_reference')) { + return; + } - $newTargetId = (int) ($entity->get('field_project_reference')->target_id ?? 0); + $newTargetId = (int) ($entity->get('field_project_reference')->target_id ?? 0); - $originalEntity = $entity->original ?? null; - $oldTargetId = 0; - if ($originalEntity?->hasField('field_project_reference')) { - $oldTargetId = (int) ($originalEntity->get('field_project_reference')->target_id ?? 0); - } + $originalEntity = $entity->original ?? null; + $oldTargetId = 0; + if ($originalEntity?->hasField('field_project_reference')) { + $oldTargetId = (int) ($originalEntity->get('field_project_reference')->target_id ?? 0); + } - // Only act if the reference actually changed. - if ($oldTargetId === $newTargetId) { - return; - } + // Only act if the reference actually changed. + if ($oldTargetId === $newTargetId) { + return; + } - $idsToReset = []; - if ($oldTargetId > 0) { - $idsToReset[] = $oldTargetId; - } - if ($newTargetId > 0) { - $idsToReset[] = $newTargetId; - } + $idsToReset = []; + if ($oldTargetId > 0) { + $idsToReset[] = $oldTargetId; + } + if ($newTargetId > 0) { + $idsToReset[] = $newTargetId; + } - if ($idsToReset === []) { - return; - } + if ($idsToReset === []) { + return; + } - $idsToReset = array_values(array_unique($idsToReset)); - $this->entityTypeManagerInterface->getStorage('node')->resetCache($idsToReset); + $idsToReset = array_values(array_unique($idsToReset)); + $this->entityTypeManagerInterface->getStorage('node')->resetCache($idsToReset); + } + catch (Exception $e) { + $this->logger->error('Error in node presave hook: @message', ['@message' => $e->getMessage()]); + } } - private function getTimelineNodes($variables) : ?array { - $nodeStorage = $this->entityTypeManagerInterface->getStorage('node'); - - $referenceQuery = $nodeStorage->getQuery(); - $dateFieldQuery = $referenceQuery->orConditionGroup() - ->exists('field_decision_date') - ->exists('field_start_date') - ->exists('field_last_meeting_time') - ->condition('type', 'dialogue', '='); - - $referenceQuery->accessCheck(); - $referenceQuery->exists('field_project_reference'); - $referenceQuery->condition('field_project_reference', $variables['node']->id()); - $referenceQuery->condition('field_hide_in_timeline', FALSE); - $referenceQuery->condition($dateFieldQuery); - $references = $referenceQuery->execute(); - - return $nodeStorage->loadMultiple($references); + /** + * Get timeline nodes. + * + * @param array $variables + * The template variables array. + * + * @return array|null + * Array of node entities or NULL. + */ + private function getTimelineNodes(array $variables) : ?array { + try { + $nodeStorage = $this->entityTypeManagerInterface->getStorage('node'); + + $referenceQuery = $nodeStorage->getQuery(); + $dateFieldQuery = $referenceQuery->orConditionGroup() + ->exists('field_decision_date') + ->exists('field_start_date') + ->exists('field_last_meeting_time') + ->condition('type', 'dialogue', '='); + + $referenceQuery->accessCheck(); + $referenceQuery->exists('field_project_reference'); + $referenceQuery->condition('field_project_reference', $variables['node']->id()); + $referenceQuery->condition('field_hide_in_timeline', FALSE); + $referenceQuery->condition($dateFieldQuery); + $references = $referenceQuery->execute(); + + return $nodeStorage->loadMultiple($references); + } + catch (Exception $e) { + $this->logger->error('Error getting timeline nodes: @message', ['@message' => $e->getMessage()]); + return []; + } } - private function getTimelineNotes($variables) : ?array { - $paragraphStorage = $this->entityTypeManagerInterface->getStorage('paragraph'); - $noteQuery = $paragraphStorage->getQuery(); - $noteQuery->accessCheck(); - $noteQuery->condition('parent_id', $variables['node']->id()); - $noteQuery->condition('type', 'timeline_note'); - $noteIds = $noteQuery->execute(); - - return $paragraphStorage->loadMultiple($noteIds); + /** + * Get timeline notes. + * + * @param array $variables + * The template variables array. + * + * @return array|null + * Array of paragraph entities or NULL. + */ + private function getTimelineNotes(array $variables) : ?array { + try { + $paragraphStorage = $this->entityTypeManagerInterface->getStorage('paragraph'); + $noteQuery = $paragraphStorage->getQuery(); + $noteQuery->accessCheck(); + $noteQuery->condition('parent_id', $variables['node']->id()); + $noteQuery->condition('type', 'timeline_note'); + $noteIds = $noteQuery->execute(); + + return $paragraphStorage->loadMultiple($noteIds); + } + catch (Exception $e) { + $this->logger->error('Error getting timeline notes: @message', ['@message' => $e->getMessage()]); + return []; + } } - private function addNodeAsTimelineItem(mixed $node, $now): array { - $date = $this->determineDate($node); - $image = $this->determineImage($node)?->getFileUri(); - - return [ - 'id' => $node->id(), - 'date' => $date->format('Y-m-d'), - 'month' => $date->format('F Y'), - 'title' => $node->getTitle(), - 'subtitle' => $node->type->entity->label(), - 'description' => $node->field_teaser->value, - 'status' => $this->determineStatus($node, $date->format('Y-m-d'), $now->format('Y-m-d')), - 'image' => $image ? ImageStyle::load('responsive_medium_default')->buildUrl($image) : NULL, - 'link' => $this->urlGenerator->generateFromRoute('entity.node.canonical', ['node' => $node->id()]), - 'linkText' => $this->t('View @type', ['@type' => $node->type->entity->label()]), - 'accentColor' => ($node->bundle() == 'course' || $node->bundle() == 'public_meeting') ? 'pink' : NULL, - ]; + /** + * Add node as timeline item. + * + * @param NodeInterface $node + * The node entity to add. + * @param DateTimeImmutable $now + * The current date and time. + * + * @return array + * The timeline item array. + */ + private function addNodeAsTimelineItem(NodeInterface $node, DateTimeImmutable $now): array { + try { + $date = $this->determineDate($node); + if (!$date) { + return []; + } + $image = $this->determineImage($node)?->getFileUri(); + + return [ + 'id' => $node->id(), + 'date' => $date->format('Y-m-d'), + 'month' => $date->format('F Y'), + 'title' => $node->getTitle(), + 'subtitle' => $node->type->entity->label(), + 'description' => $node->field_teaser->value, + 'status' => $this->determineStatus($node, $date->format('Y-m-d'), $now->format('Y-m-d')), + 'image' => $image ? ImageStyle::load('responsive_medium_default')->buildUrl($image) : NULL, + 'link' => $this->urlGenerator->generateFromRoute('entity.node.canonical', ['node' => $node->id()]), + 'linkText' => $this->t('View @type', ['@type' => $node->type->entity->label()]), + 'accentColor' => ($node->bundle() == 'course' || $node->bundle() == 'public_meeting') ? 'pink' : NULL, + ]; + } + catch (Exception $e) { + $this->logger->error('Error adding node as timeline item: @message', ['@message' => $e->getMessage()]); + return []; + } } - private function addNoteAsTimelineItem(mixed $paragraph, $now): array { - $date = $paragraph->field_date->date; - $image = $paragraph?->field_paragraph_image?->entity?->field_itk_media_image_upload?->entity?->getFileUri(); - - return [ - 'id' => '', - 'date' => $date->format('Y-m-d'), - 'month' => $date->format('F Y'), - 'title' => $paragraph->field_title->value, - 'subtitle' => $paragraph->field_subtitle->value, - 'description' => $paragraph->field_note->value, - 'status' => $this->determineStatus($paragraph, $date->format('Y-m-d'), $now->format('Y-m-d')), - 'image' => $image ? ImageStyle::load('responsive_medium_default')->buildUrl($image) : NULL, - 'link' => $paragraph?->field_external_link?->uri ?? '', - 'linkText' => $this->t('View more'), - ]; + /** + * Add note as timeline item. + * + * @param ParagraphInterface $paragraph + * The paragraph entity to add. + * @param DateTimeImmutable $now + * The current date and time. + * + * @return array + * The timeline item array. + */ + private function addNoteAsTimelineItem(ParagraphInterface $paragraph, DateTimeImmutable $now): array { + try { + $date = $paragraph->field_date->date; + if (!$date) { + return []; + } + $image = $paragraph?->field_paragraph_image?->entity?->field_itk_media_image_upload?->entity?->getFileUri(); + + return [ + 'id' => '', + 'date' => $date->format('Y-m-d'), + 'month' => $date->format('F Y'), + 'title' => $paragraph->field_title->value, + 'subtitle' => $paragraph->field_subtitle->value, + 'description' => $paragraph->field_note->value, + 'status' => $this->determineStatus($paragraph, $date->format('Y-m-d'), $now->format('Y-m-d')), + 'image' => $image ? ImageStyle::load('responsive_medium_default')->buildUrl($image) : NULL, + 'link' => $paragraph?->field_external_link?->uri ?? '', + 'linkText' => $this->t('View more'), + ]; + } + catch (Exception $e) { + $this->logger->error('Error adding note as timeline item: @message', ['@message' => $e->getMessage()]); + return []; + } } - private function addNowAsTimelineItem($now): array { + /** + * Add "today" timeline item. + * + * @param DateTimeImmutable $now + * The current date and time. + * + * @return array + * The timeline item array. + */ + private function addNowAsTimelineItem(DateTimeImmutable $now): array { return [ 'id' => 'today', 'date' => $now->format('Y-m-d'), @@ -184,33 +302,75 @@ private function addNowAsTimelineItem($now): array { ]; } + /** + * Determine date for timeline item. + * + * @param NodeInterface $node + * The node entity to extract the date from. + * + * @return DrupalDateTime|null + * The determined date or NULL if no date could be determined. + */ private function determineDate(NodeInterface $node): ?DrupalDateTime { - return match (TRUE) { - $node->hasField('field_decision_date') => new DrupalDateTime($node->field_decision_date->value), - $node->hasField('field_start_date') => new DrupalDateTime($node->field_start_date->value), - $node->hasField('field_last_meeting_time') => new DrupalDateTime($node->field_last_meeting_time->value), - 'dialogue' === $node->getType() => new DrupalDateTime(strtotime($node->getCreatedTime())), - default => NULL, - }; + try { + return match (TRUE) { + $node->hasField('field_decision_date') => new DrupalDateTime($node->field_decision_date->value), + $node->hasField('field_start_date') => new DrupalDateTime($node->field_start_date->value), + $node->hasField('field_last_meeting_time') => new DrupalDateTime($node->field_last_meeting_time->value), + 'dialogue' === $node->getType() => new DrupalDateTime(strtotime($node->getCreatedTime())), + default => NULL, + }; + } + catch (Exception $e) { + $this->logger->error('Error determining date for node @nid: @message', ['@nid' => $node->id(), '@message' => $e->getMessage()]); + return NULL; + } } + + /** + * Determine image for timeline item. + * + * @param NodeInterface $node + * The node entity to extract the image from. + * + * @return File|null + * The file entity or NULL if no image found. + */ private function determineImage(NodeInterface $node): ?File { - return match (TRUE) { - $node->hasField('field_media_image') => $node->field_media_image->entity->field_itk_media_image_upload->entity, - $node->hasField('field_media_image_single') => $node->field_media_image_single->entity->field_itk_media_image_upload->entity, - $node->hasField('field_top_images') => $node->field_top_images->entity->field_itk_media_image_upload->entity, - default => NULL, - }; + try { + return match (TRUE) { + $node->hasField('field_media_image') => $node->field_media_image->entity->field_itk_media_image_upload->entity, + $node->hasField('field_media_image_single') => $node->field_media_image_single->entity->field_itk_media_image_upload->entity, + $node->hasField('field_top_images') => $node->field_top_images->entity->field_itk_media_image_upload->entity, + default => NULL, + }; + } + catch (Exception $e) { + $this->logger->error('Error determining image for node @nid: @message', ['@nid' => $node->id(), '@message' => $e->getMessage()]); + return NULL; + } } + /** + * Determine status of timeline item. + * + * @param EntityInterface $entity + * The entity to determine status for. + * @param string $date + * The item date in Y-m-d format. + * @param string $now + * The current date in Y-m-d format. + * + * @return string + * The status string (upcoming, completed, or note). + */ private function determineStatus(EntityInterface $entity, string $date, string $now): string { - switch (TRUE) { - case $date > $now: - return 'upcoming'; - case $entity->getEntityTypeId() === 'node': - return 'completed'; - case $entity->getEntityTypeId() === 'paragraph': - return 'note'; - } + return match (TRUE) { + $date > $now => 'upcoming', + $entity->getEntityTypeId() === 'node' => 'completed', + $entity->getEntityTypeId() === 'paragraph' => 'note', + default => '', + }; } } From 200a8581662326e74f391001e57ad55292380ae1 Mon Sep 17 00:00:00 2001 From: martinyde Date: Tue, 27 Jan 2026 10:04:45 +0100 Subject: [PATCH 22/49] Added missing use statement --- .../custom/hoeringsportal_project/src/Helper/ProjectHelper.php | 1 + 1 file changed, 1 insertion(+) diff --git a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php index e7bb9eab0..6d7c8ec62 100644 --- a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php +++ b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php @@ -2,6 +2,7 @@ namespace Drupal\hoeringsportal_project\Helper; +use DateTimeImmutable; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; From 3aee5610d13ce7ceaa9f405f0fb0b8e0e2358514 Mon Sep 17 00:00:00 2001 From: martinyde Date: Tue, 27 Jan 2026 10:15:04 +0100 Subject: [PATCH 23/49] Appleid coding standards --- .../hoeringsportal_project.module | 1 + .../src/Helper/ProjectHelper.php | 60 ++++++++++--------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module index a715d97ec..8fe921e69 100644 --- a/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module +++ b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module @@ -2,6 +2,7 @@ /** * @file + * Hooks related to hoeringsportal_project module. */ use Drupal\Core\Entity\EntityInterface; diff --git a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php index 6d7c8ec62..a17b94ae6 100644 --- a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php +++ b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php @@ -2,7 +2,6 @@ namespace Drupal\hoeringsportal_project\Helper; -use DateTimeImmutable; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -15,10 +14,9 @@ use Drupal\node\NodeInterface; use Drupal\paragraphs\ParagraphInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; -use Exception; /** - * + * Helper class for project-related operations. */ class ProjectHelper { use StringTranslationTrait; @@ -89,7 +87,7 @@ public function projectPreprocess(array &$variables): void { * * @param array $form * The form array. - * @param FormStateInterface $form_state + * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state object. */ #[Hook('form_node_project_main_page_form_alter')] @@ -107,7 +105,7 @@ public function projectFormAlter(array &$form, FormStateInterface $form_state): /** * Implements hook_preprocess_node(). * - * @param EntityInterface $entity + * @param \Drupal\Core\Entity\EntityInterface $entity * The entity being saved. */ #[Hook('entity_presave')] @@ -119,7 +117,7 @@ public function nodePresave(EntityInterface $entity): void { $newTargetId = (int) ($entity->get('field_project_reference')->target_id ?? 0); - $originalEntity = $entity->original ?? null; + $originalEntity = $entity->original ?? NULL; $oldTargetId = 0; if ($originalEntity?->hasField('field_project_reference')) { $oldTargetId = (int) ($originalEntity->get('field_project_reference')->target_id ?? 0); @@ -145,7 +143,7 @@ public function nodePresave(EntityInterface $entity): void { $idsToReset = array_values(array_unique($idsToReset)); $this->entityTypeManagerInterface->getStorage('node')->resetCache($idsToReset); } - catch (Exception $e) { + catch (\Exception $e) { $this->logger->error('Error in node presave hook: @message', ['@message' => $e->getMessage()]); } } @@ -179,7 +177,7 @@ private function getTimelineNodes(array $variables) : ?array { return $nodeStorage->loadMultiple($references); } - catch (Exception $e) { + catch (\Exception $e) { $this->logger->error('Error getting timeline nodes: @message', ['@message' => $e->getMessage()]); return []; } @@ -205,7 +203,7 @@ private function getTimelineNotes(array $variables) : ?array { return $paragraphStorage->loadMultiple($noteIds); } - catch (Exception $e) { + catch (\Exception $e) { $this->logger->error('Error getting timeline notes: @message', ['@message' => $e->getMessage()]); return []; } @@ -214,15 +212,15 @@ private function getTimelineNotes(array $variables) : ?array { /** * Add node as timeline item. * - * @param NodeInterface $node + * @param \Drupal\node\NodeInterface $node * The node entity to add. - * @param DateTimeImmutable $now + * @param \DateTimeImmutable $now * The current date and time. * * @return array * The timeline item array. */ - private function addNodeAsTimelineItem(NodeInterface $node, DateTimeImmutable $now): array { + private function addNodeAsTimelineItem(NodeInterface $node, \DateTimeImmutable $now): array { try { $date = $this->determineDate($node); if (!$date) { @@ -244,7 +242,7 @@ private function addNodeAsTimelineItem(NodeInterface $node, DateTimeImmutable $n 'accentColor' => ($node->bundle() == 'course' || $node->bundle() == 'public_meeting') ? 'pink' : NULL, ]; } - catch (Exception $e) { + catch (\Exception $e) { $this->logger->error('Error adding node as timeline item: @message', ['@message' => $e->getMessage()]); return []; } @@ -253,15 +251,15 @@ private function addNodeAsTimelineItem(NodeInterface $node, DateTimeImmutable $n /** * Add note as timeline item. * - * @param ParagraphInterface $paragraph + * @param \Drupal\paragraphs\ParagraphInterface $paragraph * The paragraph entity to add. - * @param DateTimeImmutable $now + * @param \DateTimeImmutable $now * The current date and time. * * @return array * The timeline item array. */ - private function addNoteAsTimelineItem(ParagraphInterface $paragraph, DateTimeImmutable $now): array { + private function addNoteAsTimelineItem(ParagraphInterface $paragraph, \DateTimeImmutable $now): array { try { $date = $paragraph->field_date->date; if (!$date) { @@ -282,7 +280,7 @@ private function addNoteAsTimelineItem(ParagraphInterface $paragraph, DateTimeIm 'linkText' => $this->t('View more'), ]; } - catch (Exception $e) { + catch (\Exception $e) { $this->logger->error('Error adding note as timeline item: @message', ['@message' => $e->getMessage()]); return []; } @@ -291,13 +289,13 @@ private function addNoteAsTimelineItem(ParagraphInterface $paragraph, DateTimeIm /** * Add "today" timeline item. * - * @param DateTimeImmutable $now + * @param \DateTimeImmutable $now * The current date and time. * * @return array * The timeline item array. */ - private function addNowAsTimelineItem(DateTimeImmutable $now): array { + private function addNowAsTimelineItem(\DateTimeImmutable $now): array { return [ 'id' => 'today', 'date' => $now->format('Y-m-d'), @@ -316,10 +314,10 @@ private function addNowAsTimelineItem(DateTimeImmutable $now): array { /** * Determine date for timeline item. * - * @param NodeInterface $node + * @param \Drupal\node\NodeInterface $node * The node entity to extract the date from. * - * @return DrupalDateTime|null + * @return \Drupal\Core\Datetime\DrupalDateTime|null * The determined date or NULL if no date could be determined. */ private function determineDate(NodeInterface $node): ?DrupalDateTime { @@ -332,8 +330,11 @@ private function determineDate(NodeInterface $node): ?DrupalDateTime { default => NULL, }; } - catch (Exception $e) { - $this->logger->error('Error determining date for node @nid: @message', ['@nid' => $node->id(), '@message' => $e->getMessage()]); + catch (\Exception $e) { + $this->logger->error('Error determining date for node @nid: @message', [ + '@nid' => $node->id() + , '@message' => $e->getMessage(), + ]); return NULL; } } @@ -341,10 +342,10 @@ private function determineDate(NodeInterface $node): ?DrupalDateTime { /** * Determine image for timeline item. * - * @param NodeInterface $node + * @param \Drupal\node\NodeInterface $node * The node entity to extract the image from. * - * @return File|null + * @return \Drupal\file\Entity\File|null * The file entity or NULL if no image found. */ private function determineImage(NodeInterface $node): ?File { @@ -356,8 +357,11 @@ private function determineImage(NodeInterface $node): ?File { default => NULL, }; } - catch (Exception $e) { - $this->logger->error('Error determining image for node @nid: @message', ['@nid' => $node->id(), '@message' => $e->getMessage()]); + catch (\Exception $e) { + $this->logger->error('Error determining image for node @nid: @message', [ + '@nid' => $node->id(), + '@message' => $e->getMessage(), + ]); return NULL; } } @@ -365,7 +369,7 @@ private function determineImage(NodeInterface $node): ?File { /** * Determine status of timeline item. * - * @param EntityInterface $entity + * @param \Drupal\Core\Entity\EntityInterface $entity * The entity to determine status for. * @param string $date * The item date in Y-m-d format. From c82bb174105a55b6c6276ad383935aeed61dd621 Mon Sep 17 00:00:00 2001 From: martinyde Date: Tue, 27 Jan 2026 10:18:24 +0100 Subject: [PATCH 24/49] Updated npm package --- web/themes/custom/hoeringsportal/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/themes/custom/hoeringsportal/package-lock.json b/web/themes/custom/hoeringsportal/package-lock.json index 2015346e2..49469e1df 100644 --- a/web/themes/custom/hoeringsportal/package-lock.json +++ b/web/themes/custom/hoeringsportal/package-lock.json @@ -4513,9 +4513,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, From 5617c964c9e975c7137e6511853594d604b5cff9 Mon Sep 17 00:00:00 2001 From: martinyde Date: Tue, 27 Jan 2026 10:35:23 +0100 Subject: [PATCH 25/49] Applied code style --- Taskfile.yml | 4 ++-- .../hoeringsportal_project/hoeringsportal_project.module | 1 + .../hoeringsportal_project/src/Helper/ProjectHelper.php | 9 +++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 74b77f02e..757a0a2cd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -26,9 +26,9 @@ includes: - hoeringsportal_hearing # - hoeringsportal_misc # - hoeringsportal_openid_connect - # - hoeringsportal_project + - hoeringsportal_project - hoeringsportal_public_meeting - - hoeringsportal_quicklinks + # - hoeringsportal_quicklinks # - hoeringsportal_test_delta_sync_fixtures - itk_admin diff --git a/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module index 8fe921e69..ae5cfe71e 100644 --- a/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module +++ b/web/modules/custom/hoeringsportal_project/hoeringsportal_project.module @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\hoeringsportal_project\Helper\ProjectHelper; use Drupal\node\NodeInterface; +use Drupal\Core\Hook\Attribute\LegacyHook; /** * Implements hook_preprocess_node(). diff --git a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php index a17b94ae6..a27376757 100644 --- a/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php +++ b/web/modules/custom/hoeringsportal_project/src/Helper/ProjectHelper.php @@ -14,6 +14,7 @@ use Drupal\node\NodeInterface; use Drupal\paragraphs\ParagraphInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; +use Drupal\Core\Hook\Attribute\Hook; /** * Helper class for project-related operations. @@ -74,9 +75,9 @@ public function projectPreprocess(array &$variables): void { usort($variables['timeline_items'], static fn(array $a, array $b): int => $a['date'] <=> $b['date']); $variables['legend_items'] = [ - ['status' => 'completed', 'label' => $this->t('Afsluttet')], - ['status' => 'current', 'label' => $this->t('I gang nu')], - ['status' => 'upcoming', 'label' => $this->t('Kommende')], + ['status' => 'completed', 'label' => $this->t('Finished')], + ['status' => 'current', 'label' => $this->t('In progress')], + ['status' => 'upcoming', 'label' => $this->t('Upcoming')], ['status' => 'note', 'label' => $this->t('Note')], ]; } @@ -300,7 +301,7 @@ private function addNowAsTimelineItem(\DateTimeImmutable $now): array { 'id' => 'today', 'date' => $now->format('Y-m-d'), 'month' => $now->format('d-m-Y'), - 'title' => $this->t('Projektstatus'), + 'title' => $this->t('Project status'), 'subtitle' => NULL, 'description' => NULL, 'status' => 'current', From a7b67f4166e2943fd6cf3d3ccb8c09fc56dc9983 Mon Sep 17 00:00:00 2001 From: martinyde Date: Tue, 27 Jan 2026 10:45:49 +0100 Subject: [PATCH 26/49] Updated twig files to english, and added translation --- .../project-timeline-card.html.twig | 8 +-- .../project-timeline-mini-nav.html.twig | 8 +-- .../templates/components/timeline.html.twig | 10 ++-- .../translations/hoeringsportal.da.po | 56 ++++++++++++++++++- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig b/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig index 4dea65ae4..f8d446e3c 100644 --- a/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig +++ b/web/themes/custom/hoeringsportal/templates/components/project-timeline-card.html.twig @@ -27,9 +27,9 @@ } %} {% set status_labels = { - completed: 'now'|date('Y-m-d') == item.date ? 'I dag'|t : '✓ Afsluttet'|t, - current: 'I dag'|t, - upcoming: 'Kommende'|t, + completed: 'now'|date('Y-m-d') == item.date ? 'Today'|t : '✓ Finished'|t, + current: 'Today'|t, + upcoming: 'Upcoming'|t, note: 'Note'|t, } %} @@ -94,7 +94,7 @@ {% if item.link %}