mirror of
				https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
				synced 2025-10-29 17:15:31 +00:00 
			
		
		
		
	Start places overhaul
This commit is contained in:
		
							
								
								
									
										74
									
								
								views/routes/ort/components/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								views/routes/ort/components/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | ||||
| # Orte Components Documentation | ||||
|  | ||||
| ## Overview | ||||
| The Orte (Places) section has been updated to use an expandable list view instead of a card grid. This provides better usability on mobile devices and allows for lazy-loading of place details. | ||||
|  | ||||
| ## Architecture | ||||
|  | ||||
| ### Templates | ||||
| - `_place_expandable.gohtml` - Template for individual expandable place entries (currently unused, kept for reference) | ||||
| - `_place_details_fragment.gohtml` - HTMX fragment for place details (header + pieces list) | ||||
|  | ||||
| ### JavaScript Components | ||||
| - `ExpandablePlacesList` - Main web component for the expandable places list | ||||
| - No shadow DOM - full Tailwind CSS support | ||||
| - Property-based data passing | ||||
| - HTMX integration for lazy-loading details | ||||
|  | ||||
| ### Backend | ||||
| - `GetPlaceDetails()` - New controller handler for HTMX requests | ||||
| - Route: `/ort/{place}/details` | ||||
| - Returns place details fragment | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| ### Expandable Interaction | ||||
| - Click to expand/collapse place entries | ||||
| - Only one place can be expanded at a time | ||||
| - Smooth CSS transitions for expand/collapse | ||||
| - Keyboard navigation support (Enter/Space) | ||||
|  | ||||
| ### Data Loading | ||||
| - Initial places list loaded from server-side template | ||||
| - Place details loaded on-demand via HTMX | ||||
| - Loading states and error handling | ||||
| - Caching of loaded content | ||||
|  | ||||
| ### Accessibility | ||||
| - Proper ARIA attributes (`aria-expanded`, `aria-hidden`, `aria-controls`) | ||||
| - Keyboard navigation support | ||||
| - Screen reader friendly labels | ||||
| - Focus management | ||||
|  | ||||
| ### Preserved Functionality | ||||
| - `/ort/{id}` permalink URLs still work for direct access | ||||
| - Search filtering via existing `GenericFilter` component | ||||
| - External links (Wikipedia, Geonames, OpenStreetMap) in expanded view | ||||
| - Citation links to specific newspaper issues | ||||
|  | ||||
| ## Usage | ||||
|  | ||||
| The component is automatically initialized on page load with data from the Go template: | ||||
|  | ||||
| ```html | ||||
| <expandable-places-list id="places-list"></expandable-places-list> | ||||
| <script> | ||||
|   const placesList = document.getElementById('places-list'); | ||||
|   placesList.places = placesData; // Array of place objects | ||||
| </script> | ||||
| ``` | ||||
|  | ||||
| ## Data Structure | ||||
|  | ||||
| Each place object contains: | ||||
| - `ID`: Place identifier | ||||
| - `Names`: Array of place names | ||||
| - `Geo`: Geonames URL (optional) | ||||
| - `PieceCount`: Number of associated pieces | ||||
|  | ||||
| ## Technical Notes | ||||
|  | ||||
| - Uses CSS `max-height` transitions for smooth expand/collapse | ||||
| - HTMX events handled for loading states | ||||
| - Event delegation for dynamically created content | ||||
| - Compatible with existing search filtering system | ||||
							
								
								
									
										179
									
								
								views/routes/ort/components/_place_details_fragment.gohtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								views/routes/ort/components/_place_details_fragment.gohtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| {{- /* | ||||
|     Place Details Fragment | ||||
|     Used for HTMX requests to load place details into expandable content | ||||
|     Contains place header information and associated pieces | ||||
| */ -}} | ||||
|  | ||||
| {{- /* Place Header Information */ -}} | ||||
| <div class="mb-6"> | ||||
| 	{{ $geonames := GetGeonames .place.Place.Geo }} | ||||
|  | ||||
| 	{{- /* Name and external links */ -}} | ||||
| 	<div class="flex items-start justify-between gap-4 mb-4"> | ||||
| 		<div class="flex-1"> | ||||
| 			<h2 class="text-xl font-bold text-slate-800 mb-2"> | ||||
| 				{{ if .place.Place.Names }} | ||||
| 					{{ index .place.Place.Names 0 }} | ||||
| 				{{ else }} | ||||
| 					{{ .place.Place.ID }} | ||||
| 				{{ end }} | ||||
| 			</h2> | ||||
|  | ||||
| 			{{- /* Geographic Information from Geonames */ -}} | ||||
| 			{{ if ne $geonames nil }} | ||||
| 				<div class="text-sm text-slate-700 mb-2"> | ||||
| 					{{- /* Modern Country Info (only if not Germany) */ -}} | ||||
| 					{{ $mainPlaceName := "" }} | ||||
| 					{{ if .place.Place.Names }} | ||||
| 						{{ $mainPlaceName = index .place.Place.Names 0 }} | ||||
| 					{{ end }} | ||||
| 					{{ $fullInfo := GetFullPlaceInfo .place.Place.Geo $mainPlaceName }} | ||||
| 					{{ if ne $fullInfo "" }} | ||||
| 						<div class="mb-1">{{ $fullInfo }}</div> | ||||
| 					{{ end }} | ||||
|  | ||||
| 					{{- /* Coordinates */ -}} | ||||
| 					<div class="text-slate-600 text-sm space-y-1"> | ||||
| 						{{ if and (ne $geonames.Lat "") (ne $geonames.Lng "") }} | ||||
| 							<div> | ||||
| 								<i class="ri-map-pin-line mr-1"></i><a href="https://www.openstreetmap.org/?mlat={{ $geonames.Lat }}&mlon={{ $geonames.Lng }}&zoom=12" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-700 underline">{{ $geonames.Lat }}, {{ $geonames.Lng }}</a> | ||||
| 							</div> | ||||
| 						{{ end }} | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			{{ else }} | ||||
| 				{{- /* Fallback when no Geonames data */ -}} | ||||
| 				{{ if .place.Place.Geo }} | ||||
| 					<p class="text-slate-600 mb-2 text-sm"> | ||||
| 						<i class="ri-map-pin-line mr-1"></i> | ||||
| 						<a href="{{ .place.Place.Geo }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-700 underline"> | ||||
| 							Geonames | ||||
| 						</a> | ||||
| 					</p> | ||||
| 				{{ end }} | ||||
| 			{{ end }} | ||||
| 		</div> | ||||
|  | ||||
| 		{{- /* External link symbols on the right */ -}} | ||||
| 		<div class="flex gap-2 flex-shrink-0 items-center"> | ||||
| 			{{ if ne $geonames nil }} | ||||
| 				{{- /* Wikipedia link if available */ -}} | ||||
| 				{{ if ne $geonames.WikipediaURL "" }} | ||||
| 					<a href="https://{{ $geonames.WikipediaURL }}" target="_blank" class="hover:opacity-80 transition-opacity" title="Wikipedia"> | ||||
| 						<img src="/assets/wikipedia.png" alt="Wikipedia" class="w-5 h-5"> | ||||
| 					</a> | ||||
| 				{{ end }} | ||||
| 			{{ end }} | ||||
|  | ||||
| 			{{- /* Geonames link */ -}} | ||||
| 			{{ if .place.Place.Geo }} | ||||
| 				<a href="{{ .place.Place.Geo }}" target="_blank" class="hover:opacity-80 transition-opacity no-underline" title="Geonames"> | ||||
| 					<i class="ri-global-line text-lg text-blue-600"></i> | ||||
| 				</a> | ||||
| 			{{ end }} | ||||
|  | ||||
| 			{{- /* OpenStreetMap link */ -}} | ||||
| 			{{ if and (ne $geonames nil) (ne $geonames.Lat "") (ne $geonames.Lng "") }} | ||||
| 				<a href="https://www.openstreetmap.org/?mlat={{ $geonames.Lat }}&mlon={{ $geonames.Lng }}&zoom=12" target="_blank" class="hover:opacity-80 transition-opacity" title="OpenStreetMap"> | ||||
| 					<i class="ri-map-2-line text-lg text-green-600"></i> | ||||
| 				</a> | ||||
| 			{{ end }} | ||||
| 		</div> | ||||
| 	</div> | ||||
| </div> | ||||
|  | ||||
| {{- /* Associated Pieces */ -}} | ||||
| <div> | ||||
| 	<h3 class="text-lg font-semibold text-slate-800 mb-3"> | ||||
| 		<i class="ri-newspaper-line mr-2"></i>Verlinkte Beiträge ({{ len .place.Pieces }}) | ||||
| 	</h3> | ||||
|  | ||||
| 	{{ if .place.Pieces }} | ||||
| 		{{- /* Group pieces by year */ -}} | ||||
| 		{{- $piecesByYear := dict -}} | ||||
| 		{{- range $_, $p := .place.Pieces -}} | ||||
| 			{{- range $issueRef := $p.IssueRefs -}} | ||||
| 				{{- $year := printf "%d" $issueRef.When.Year -}} | ||||
| 				{{- $existing := index $piecesByYear $year -}} | ||||
| 				{{- if $existing -}} | ||||
| 					{{- $piecesByYear = merge $piecesByYear (dict $year (append $existing $p)) -}} | ||||
| 				{{- else -}} | ||||
| 					{{- $piecesByYear = merge $piecesByYear (dict $year (slice $p)) -}} | ||||
| 				{{- end -}} | ||||
| 			{{- end -}} | ||||
| 		{{- end -}} | ||||
|  | ||||
| 		{{- /* Get sorted years */ -}} | ||||
| 		{{- $sortedYears := slice -}} | ||||
| 		{{- range $year, $pieces := $piecesByYear -}} | ||||
| 			{{- $sortedYears = append $sortedYears $year -}} | ||||
| 		{{- end -}} | ||||
| 		{{- $sortedYears = sortStrings $sortedYears -}} | ||||
|  | ||||
| 		<div class="space-y-4 max-w-[85ch]"> | ||||
| 			{{- range $year := $sortedYears -}} | ||||
| 				{{- $yearPieces := index $piecesByYear $year -}} | ||||
|  | ||||
| 				{{- /* Year Header */ -}} | ||||
| 				<div> | ||||
| 					<h4 class="text-base font-bold font-serif text-slate-800 mb-2">{{ $year }}</h4> | ||||
|  | ||||
| 					<div class="space-y-1 text-sm"> | ||||
| 						{{- /* Group pieces by title within each year */ -}} | ||||
| 						{{- $groupedPieces := dict -}} | ||||
| 						{{- range $_, $p := $yearPieces -}} | ||||
| 							{{- $groupKey := "" -}} | ||||
| 							{{- if $p.Title -}} | ||||
| 								{{- $groupKey = index $p.Title 0 -}} | ||||
| 							{{- else if $p.Incipit -}} | ||||
| 								{{- $groupKey = index $p.Incipit 0 -}} | ||||
| 							{{- else -}} | ||||
| 								{{- $groupKey = printf "untitled-%s" $p.ID -}} | ||||
| 							{{- end -}} | ||||
|  | ||||
| 							{{- $existing := index $groupedPieces $groupKey -}} | ||||
| 							{{- if $existing -}} | ||||
| 								{{- $groupedPieces = merge $groupedPieces (dict $groupKey (append $existing $p)) -}} | ||||
| 							{{- else -}} | ||||
| 								{{- $groupedPieces = merge $groupedPieces (dict $groupKey (slice $p)) -}} | ||||
| 							{{- end -}} | ||||
| 						{{- end -}} | ||||
|  | ||||
| 						{{- range $groupKey, $groupedItems := $groupedPieces -}} | ||||
| 							<div class="pb-1 leading-relaxed"> | ||||
| 								{{- /* Use first piece for display text with colon format for places */ -}} | ||||
| 								{{ template "_unified_piece_entry" (dict "Piece" (index $groupedItems 0) "CurrentActorID" "" "DisplayMode" "place" "ShowPlaceTags" false "UseColonFormat" true "ShowContinuation" false) }} | ||||
|  | ||||
| 								{{- /* Show all citations from all pieces in this group inline with commas */ -}} | ||||
| 								{{ " " }}{{- range $groupIndex, $groupItem := $groupedItems -}} | ||||
| 									{{- range $issueIndex, $issue := $groupItem.IssueRefs -}} | ||||
| 										{{- /* Only show citations for the current year */ -}} | ||||
| 										{{- if eq (printf "%d" $issue.When.Year) $year -}} | ||||
| 											{{- if or (gt $groupIndex 0) (gt $issueIndex 0) }}, {{ end -}} | ||||
| 											<span class="text-blue-600 hover:text-blue-700 underline decoration-dotted hover:decoration-solid [&>a]:text-blue-600 [&>a:hover]:text-blue-700">{{- template "_citation" $issue -}}</span> | ||||
| 										{{- end -}} | ||||
| 									{{- end -}} | ||||
| 								{{- end -}} | ||||
|  | ||||
| 								{{- /* Add "Ganzer Beitrag" link if piece spans multiple issues */ -}} | ||||
| 								{{- $firstGroupItem := index $groupedItems 0 -}} | ||||
| 								{{- if gt (len $firstGroupItem.IssueRefs) 1 -}} | ||||
| 									{{ " " }}<span class="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 | ||||
| 									hover:bg-blue-100 text-blue-700 hover:text-blue-800 border border-blue-200 | ||||
| 									hover:border-blue-300 rounded text-xs font-medium transition-colors duration-200"> | ||||
| 										<i class="ri-file-copy-2-line text-xs"></i> | ||||
| 										<a href="{{ GetPieceURL $firstGroupItem.ID }}"> | ||||
| 											Ganzer Beitrag | ||||
| 										</a> | ||||
| 									</span> | ||||
| 								{{- end }} | ||||
| 							</div> | ||||
| 						{{- end -}} | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			{{- end -}} | ||||
| 		</div> | ||||
| 	{{ else }} | ||||
| 		<p class="text-slate-500 italic text-sm">Keine verlinkten Beiträge für diesen Ort gefunden.</p> | ||||
| 	{{ end }} | ||||
| </div> | ||||
							
								
								
									
										79
									
								
								views/routes/ort/components/_place_expandable.gohtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								views/routes/ort/components/_place_expandable.gohtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| {{- /* | ||||
|     Expandable Place Entry Template | ||||
|     This is used as a JavaScript template string in the ExpandablePlacesList component | ||||
|     Variables: place, geonames, mainPlaceName, modernName, fullInfo, pieceCount | ||||
| */ -}} | ||||
|  | ||||
| <div class="border border-slate-200 rounded-lg hover:border-slate-300 transition-colors duration-200" | ||||
|      data-place-name="${mainPlaceName}" | ||||
|      data-modern-name="${modernName}" | ||||
|      data-place-id="${place.ID}"> | ||||
|  | ||||
|   {{- /* Collapsed State - Always Visible */ -}} | ||||
|   <div class="p-4 cursor-pointer select-none" | ||||
|        data-toggle="collapse" | ||||
|        data-target="place-${place.ID}-content" | ||||
|        role="button" | ||||
|        tabindex="0" | ||||
|        aria-expanded="false" | ||||
|        aria-controls="place-${place.ID}-content"> | ||||
|  | ||||
|     <div class="flex items-center justify-between"> | ||||
|       <div class="flex-1"> | ||||
|         <div class="flex items-center gap-3"> | ||||
|           {{- /* Expand/Collapse Icon */ -}} | ||||
|           <div class="transition-transform duration-200" data-expand-icon> | ||||
|             <svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||||
|               <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> | ||||
|             </svg> | ||||
|           </div> | ||||
|  | ||||
|           {{- /* Place Name as clickable link */ -}} | ||||
|           <div> | ||||
|             <a href="/ort/${place.ID}" | ||||
|                class="text-lg font-medium text-slate-800 hover:text-slate-900 hover:underline" | ||||
|                onclick="event.stopPropagation()"> | ||||
|               ${mainPlaceName} | ||||
|             </a> | ||||
|  | ||||
|             {{- /* Geographic info if available */ -}} | ||||
|             \${fullInfo ? \` | ||||
|               <p class="text-sm text-slate-600 mt-1"> | ||||
|                 <i class="ri-map-pin-line mr-1"></i>\${fullInfo} | ||||
|               </p> | ||||
|             \` : ''} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {{- /* Piece count badge */ -}} | ||||
|       <div class="flex items-center gap-2 text-sm text-slate-500"> | ||||
|         <span>\${pieceCount} Beiträge</span> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   {{- /* Expandable Content Area */ -}} | ||||
|   <div id="place-${place.ID}-content" | ||||
|        class="overflow-hidden transition-all duration-300 ease-in-out max-h-0" | ||||
|        data-content-area | ||||
|        aria-hidden="true"> | ||||
|  | ||||
|     <div class="border-t border-slate-200 p-4 bg-slate-50"> | ||||
|       {{- /* Loading state */ -}} | ||||
|       <div class="flex items-center justify-center py-8" data-loading-state> | ||||
|         <div class="flex items-center gap-2 text-slate-500"> | ||||
|           <div class="animate-spin"> | ||||
|             <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||||
|               <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> | ||||
|             </svg> | ||||
|           </div> | ||||
|           <span>Lade Beiträge...</span> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {{- /* Content will be loaded here via HTMX */ -}} | ||||
|       <div data-place-details></div> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										2
									
								
								views/routes/ort/details/body.gohtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								views/routes/ort/details/body.gohtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| {{ template "_place_header" .place }} | ||||
| {{ template "_place_pieces" .place }} | ||||
							
								
								
									
										4
									
								
								views/routes/ort/fragment/body.gohtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								views/routes/ort/fragment/body.gohtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| {{- /* Fragment with specific class for HTMX selection */ -}} | ||||
| <div class="place-fragment-content p-4 border-t border-slate-200 bg-slate-50"> | ||||
| 	{{ template "_place_pieces" .model.SelectedPlace }} | ||||
| </div> | ||||
							
								
								
									
										1
									
								
								views/routes/ort/fragment/head.gohtml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								views/routes/ort/fragment/head.gohtml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| {{- /* Empty head for fragment */ -}} | ||||
| @@ -1,13 +1,53 @@ | ||||
| {{- /* Places overview page body */ -}} | ||||
| <div class="grid grid-cols-1 lg:grid-cols-4 gap-6"> | ||||
| 	{{- /* Main content */ -}} | ||||
| 	{{- /* Main content - Places list */ -}} | ||||
| 	<div class="lg:col-span-3"> | ||||
| 		{{- /* Places grid */ -}} | ||||
| 		{{- /* Places list */ -}} | ||||
| 		{{ if .model.Places }} | ||||
| 			<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-6"> | ||||
| 			<div class="bg-white border border-slate-200 rounded-lg mt-6"> | ||||
| 				{{ range $placeID := .model.Sorted }} | ||||
| 					{{ $place := index $.model.Places $placeID }} | ||||
| 					{{ template "_place_card" $place }} | ||||
| 					{{ $pieceCount := index $.model.PlacePieceCounts $placeID }} | ||||
| 					{{ $geonames := GetGeonames $place.Geo }} | ||||
| 					{{ $mainPlaceName := "" }} | ||||
| 					{{ if $place.Names }} | ||||
| 						{{ $mainPlaceName = index $place.Names 0 }} | ||||
| 					{{ else }} | ||||
| 						{{ $mainPlaceName = $place.ID }} | ||||
| 					{{ end }} | ||||
| 					{{ $modernName := GetModernPlaceName $place.Geo $mainPlaceName }} | ||||
| 					<place-accordion class="border-b border-slate-100 last:border-b-0" data-place-id="{{ $place.ID }}" data-place-name="{{ $mainPlaceName }}" data-modern-name="{{ $modernName }}"> | ||||
| 						<div class="block p-4 hover:bg-slate-50 transition-colors duration-200 cursor-pointer"> | ||||
| 							<div class="flex items-start justify-between gap-2"> | ||||
| 								<div class="flex-1 min-w-0"> | ||||
| 									<h3 class="font-medium text-slate-800 mb-1 truncate"> | ||||
| 										{{ if $place.Names }} | ||||
| 											{{ index $place.Names 0 }} | ||||
| 										{{ else }} | ||||
| 											{{ $place.ID }} | ||||
| 										{{ end }} | ||||
| 									</h3> | ||||
| 									{{ if ne $geonames nil }} | ||||
| 										{{ $fullInfo := GetFullPlaceInfo $place.Geo $mainPlaceName }} | ||||
| 										{{ if ne $fullInfo "" }} | ||||
| 											<p class="text-sm text-slate-600 mb-1 truncate"> | ||||
| 												<i class="ri-map-pin-line mr-1"></i>{{ $fullInfo }} | ||||
| 											</p> | ||||
| 										{{ end }} | ||||
| 									{{ else if $place.Geo }} | ||||
| 										<p class="text-sm text-slate-600 truncate"> | ||||
| 											<i class="ri-map-pin-line mr-1"></i>Geonames verfügbar | ||||
| 										</p> | ||||
| 									{{ end }} | ||||
| 								</div> | ||||
| 								<div class="flex-shrink-0 flex items-center gap-2"> | ||||
| 									<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-slate-100 text-slate-700 rounded"> | ||||
| 										{{ $pieceCount }} | ||||
| 									</span> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</place-accordion> | ||||
| 				{{ end }} | ||||
| 			</div> | ||||
| 		{{ else }} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Simon Martens
					Simon Martens