mirror of
https://github.com/dedicatedcode/reitti.git
synced 2026-01-09 09:27:58 -05:00
313 feature request add memories (#351)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -26,7 +26,7 @@ replay_pid*
|
||||
.aider*
|
||||
/target/
|
||||
/.idea/
|
||||
|
||||
/data
|
||||
*secrets.properties
|
||||
*oidc.properties
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -450,18 +450,19 @@ To enable PKCE for the OIDC Client, you need to set `OIDC_AUTHENTICATION_METHOD`
|
||||
|
||||
## Backup & Data Persistence
|
||||
|
||||
Reitti is designed to be mostly stateless, with all important data stored in the PostgreSQL database (with PostGIS extensions). To ensure you do not lose any critical data:
|
||||
|
||||
- **Backup Requirement:** Only the PostGIS database needs to be backed up regularly. This database contains all user location data, analysis results, and other persistent information.
|
||||
- **Stateless Services:** All other components (RabbitMQ, Redis, Reitti application, etc.) are stateless and do not store any important data. These can be redeployed or restarted without risk of data loss.
|
||||
- **Backup Requirements:**
|
||||
- The PostGIS database needs to be backed up regularly. This database contains all user location data, analysis results, and other persistent information.
|
||||
- The storage path used by Reitti needs to be backed up regularly. This contains uploaded files.
|
||||
- **Stateless Services:** All other components (RabbitMQ, Redis, Photon, etc.) are stateless and do not store any important data. These can be redeployed or restarted without risk of data loss.
|
||||
|
||||
**Recommended Backup Strategy:**
|
||||
- Use standard PostgreSQL backup tools (such as `pg_dump` or physical volume snapshots) to back up your database.
|
||||
- Back up the entire storage directory/volume used by Reitti for file storage.
|
||||
- Ensure backups are performed regularly and stored securely.
|
||||
- No backup is needed for RabbitMQ, Redis, or the Reitti application itself.
|
||||
- No backup is needed for RabbitMQ, Redis, Photon.
|
||||
|
||||
**Restore:**
|
||||
- In case of disaster recovery, restoring the PostGIS database is sufficient to recover all user data and history.
|
||||
- In case of disaster recovery, restore both the PostGIS database and the storage path to recover all user data and history.
|
||||
|
||||
For more details, see the [Reitti backup documentation](https://www.dedicatedcode.com/projects/reitti/backup/).
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
photon:
|
||||
image: rtuszik/photon-docker:1.0.0
|
||||
image: rtuszik/photon-docker:1.2.1
|
||||
environment:
|
||||
- UPDATE_STRATEGY=PARALLEL
|
||||
- REGION=de
|
||||
|
||||
@@ -11,8 +11,13 @@ services:
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- reitti-data:/data/
|
||||
environment:
|
||||
PHOTON_BASE_URL: http://photon:2322
|
||||
POSTGRES_USER: reitti
|
||||
POSTGRES_PASSWORD: reitti
|
||||
POSTGRES_DB: reittidb
|
||||
postgis:
|
||||
image: postgis/postgis:17-3.5-alpine
|
||||
environment:
|
||||
@@ -48,7 +53,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
photon:
|
||||
image: rtuszik/photon-docker:1.0.0
|
||||
image: rtuszik/photon-docker:1.2.1
|
||||
environment:
|
||||
- UPDATE_STRATEGY=PARALLEL
|
||||
- REGION=de #set your main country code here to save space or drop this line to fetch the whole index.
|
||||
@@ -59,3 +64,4 @@ volumes:
|
||||
rabbitmq-data:
|
||||
redis-data:
|
||||
photon-data:
|
||||
reitti-data:
|
||||
|
||||
65
generate-memory-blocks.md
Normal file
65
generate-memory-blocks.md
Normal file
@@ -0,0 +1,65 @@
|
||||
## Step 1: Data Pre-processing & Filtering 🧹
|
||||
|
||||
The goal here is to remove data points that are not actual, intentional visits.
|
||||
|
||||
Remove Accommodation Stays: The first step is to filter out all visits to the known accommodation. This location serves as your base reference point, not a tourist activity.
|
||||
|
||||
Filter by Duration: Remove very short visits. Stops under 10-15 minutes are often just traffic lights, brief errands (like an ATM), or GPS drift. Set a minimum duration threshold to focus on meaningful stays.
|
||||
|
||||
Consolidate Micro-Visits: If your app generates multiple separate "visits" for wandering around a single large area (e.g., a park or a market), you may need to merge these into one continuous visit before proceeding.
|
||||
|
||||
## Step 2: Data Enrichment with Context 🗺️
|
||||
|
||||
Raw coordinates are not useful for a travel log. You need to understand what these places are.
|
||||
|
||||
Reverse Geocoding: Convert each visit's latitude and longitude into a human-readable address.
|
||||
|
||||
Point of Interest (POI) Matching: This is the most crucial step. Use a service like the Google Places API, Foursquare API, or OpenStreetMap to match the coordinates to a named place. This will give you a name (e.g., "Louvre Museum"), a category (e.g., "museum"), and other details.
|
||||
|
||||
Your data will transform from this:
|
||||
{lat: 48.8606, lon: 2.3376, start: '14:30', end: '17:00'}
|
||||
|
||||
To this:
|
||||
{name: 'Louvre Museum', category: 'museum', address: 'Rue de Rivoli, 75001 Paris', ...}
|
||||
|
||||
## Step 3: Scoring & Identifying "Interesting" Visits ✨
|
||||
|
||||
Now you can define what makes a visit "interesting" by calculating an interest score. This helps prioritize the highlights of the day.
|
||||
|
||||
Combine several factors into a weighted score:
|
||||
|
||||
Duration: Longer stays are generally more significant. A 3-hour museum visit is more important than a 20-minute coffee stop.
|
||||
|
||||
Distance from Accommodation: Visits far from where you're staying are likely planned day trips or major excursions and should be scored higher. This is a very strong signal of intent.
|
||||
|
||||
Place Category: This is key. Use the POI data from Step 2 to assign a weight to each category.
|
||||
|
||||
High Interest: museum, landmark, park, tourist_attraction, historic_site.
|
||||
|
||||
Medium Interest: restaurant, cafe, shopping_mall.
|
||||
|
||||
Low Interest: grocery_store, pharmacy, gas_station.
|
||||
|
||||
Novelty: A place visited only once on the trip is typically more notable for a travel log than a coffee shop visited every morning.
|
||||
|
||||
You can create a simple scoring formula, for instance:
|
||||
Score=(wd⋅Duration)+(wx⋅Distance)+(wc⋅CategoryWeight)
|
||||
|
||||
|
||||
Where wd, wx, and wc are the weights you assign to duration, distance, and category, respectively.
|
||||
|
||||
## Step 4: Clustering & Creating a Narrative ✍️
|
||||
|
||||
A simple list of interesting places is good, but a great travel log groups them into a story.
|
||||
|
||||
Spatio-Temporal Clustering: Group visits that are close in both location and time. For example, a visit to a museum, followed by a visit to a café next door 15 minutes later, should be part of the same event.
|
||||
|
||||
Algorithm Choice: An algorithm like DBSCAN (Density-Based Spatial Clustering of Applications with Noise) is excellent for this. You can define a "neighborhood" in terms of time (e.g., within 2 hours of each other) and space (e.g., within 500 meters of each other) to automatically find these groups.
|
||||
|
||||
Summarize the Cluster: Once you have a cluster of visits, create a single travel log entry for it.
|
||||
|
||||
Title: Name the event after the highest-scoring visit within the cluster (e.g., "Visit to the Eiffel Tower and Champ de Mars").
|
||||
|
||||
Timeframe: Use the start time of the first visit and the end time of the last visit in the cluster.
|
||||
|
||||
Content: List the significant places visited within that cluster.
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.dedicatedcode.reitti.config;
|
||||
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
|
||||
import com.dedicatedcode.reitti.model.security.TokenUser;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.MagicLinkJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserJdbcService;
|
||||
@@ -17,6 +19,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
@@ -34,7 +37,12 @@ public class MagicLinkAuthenticationFilter extends OncePerRequestFilter {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
if (!"/access".equals(request.getRequestURI()) || request.getParameter("mt") == null) {
|
||||
if (request.getParameter("mt") == null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
boolean isMemoryRequest = request.getRequestURI().startsWith("/memories/");
|
||||
if (!("/access".equals(request.getRequestURI()) || isMemoryRequest)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
@@ -55,6 +63,18 @@ public class MagicLinkAuthenticationFilter extends OncePerRequestFilter {
|
||||
response.sendRedirect("/error/magic-link?error=expired");
|
||||
return;
|
||||
}
|
||||
Long resourceId = null;
|
||||
if (isMemoryRequest) {
|
||||
try {
|
||||
resourceId = Long.parseLong(request.getRequestURI().substring("/memories/".length()));
|
||||
} catch (NumberFormatException e) {
|
||||
//ignored
|
||||
}
|
||||
if (linkToken.getResourceType() != MagicLinkResourceType.MEMORY || resourceId == null || resourceId.longValue() != linkToken.getResourceId()) {
|
||||
response.sendRedirect("/error/magic-link?error=invalid");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<User> user = magicLinkJdbcService.findUserIdByToken(linkToken.getId()).flatMap(userJdbcService::findById);
|
||||
|
||||
@@ -67,7 +87,7 @@ public class MagicLinkAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
String specialRole = "ROLE_MAGIC_LINK_" + linkToken.getAccessLevel().name();
|
||||
MagicLinkAuthenticationToken authentication = new MagicLinkAuthenticationToken(
|
||||
user.get(),
|
||||
new TokenUser(user.get(), linkToken.getResourceType(), resourceId, List.of(specialRole)),
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority(specialRole)),
|
||||
linkToken.getId()
|
||||
@@ -77,7 +97,11 @@ public class MagicLinkAuthenticationFilter extends OncePerRequestFilter {
|
||||
request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
|
||||
SecurityContextHolder.getContext());
|
||||
|
||||
response.sendRedirect("/");
|
||||
if (linkToken.getResourceType() != MagicLinkResourceType.MEMORY) {
|
||||
response.sendRedirect("/");
|
||||
} else {
|
||||
response.sendRedirect("/memories/" + linkToken.getResourceId());
|
||||
}
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
response.sendRedirect("/error/magic-link?error=processing");
|
||||
|
||||
@@ -43,7 +43,9 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers("/login", "/access", "/error").permitAll()
|
||||
.requestMatchers("/settings/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name())
|
||||
.requestMatchers("/api/v1/photos/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name(), "MAGIC_LINK_FULL_ACCESS", "MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS")
|
||||
.requestMatchers("/api/v1/photos/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name(), "MAGIC_LINK_FULL_ACCESS", "MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS", "MAGIC_LINK_MEMORY_VIEW_ONLY", "MAGIC_LINK_MEMORY_EDIT_ACCESS")
|
||||
.requestMatchers("/memories/*/**").hasAnyRole(Role.ADMIN.name(), Role.USER.name(), "MAGIC_LINK_MEMORY_VIEW_ONLY", "MAGIC_LINK_MEMORY_EDIT_ACCESS")
|
||||
.requestMatchers("/memories").hasAnyRole(Role.ADMIN.name(), Role.USER.name())
|
||||
.requestMatchers("/css/**", "/js/**", "/images/**", "/img/**", "/error/magic-link/**", "/setup/**").permitAll()
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.requestMatchers("/api/v1/reitti-integration/notify/**").permitAll()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.dedicatedcode.reitti.controller;
|
||||
|
||||
import com.dedicatedcode.reitti.controller.error.PageNotFoundException;
|
||||
import jakarta.servlet.RequestDispatcher;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
@@ -9,6 +10,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -143,4 +145,13 @@ public class CustomErrorController implements ErrorController {
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@ExceptionHandler(PageNotFoundException.class)
|
||||
public String handlePageNotFound(HttpServletRequest request, Model model) {
|
||||
String requestUri = (String) request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
|
||||
model.addAttribute("status", 404);
|
||||
model.addAttribute("error", "Not Found");
|
||||
model.addAttribute("message", "The page you are looking for could not be found.");
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
package com.dedicatedcode.reitti.controller;
|
||||
|
||||
import com.dedicatedcode.reitti.dto.PhotoResponse;
|
||||
import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
|
||||
import com.dedicatedcode.reitti.model.memory.*;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
|
||||
import com.dedicatedcode.reitti.model.security.TokenUser;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.TripJdbcService;
|
||||
import com.dedicatedcode.reitti.service.MemoryService;
|
||||
import com.dedicatedcode.reitti.service.StorageService;
|
||||
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static com.dedicatedcode.reitti.model.Role.ADMIN;
|
||||
import static com.dedicatedcode.reitti.model.Role.USER;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/memories/{memoryId}/blocks")
|
||||
public class MemoryBlockController {
|
||||
|
||||
private final MemoryService memoryService;
|
||||
private final ImmichIntegrationService immichIntegrationService;
|
||||
private final TripJdbcService tripJdbcService;
|
||||
private final ProcessedVisitJdbcService processedVisitJdbcService;
|
||||
private final StorageService storageService;
|
||||
|
||||
public MemoryBlockController(MemoryService memoryService, ImmichIntegrationService immichIntegrationService, TripJdbcService tripJdbcService, ProcessedVisitJdbcService processedVisitJdbcService, StorageService storageService) {
|
||||
this.memoryService = memoryService;
|
||||
this.immichIntegrationService = immichIntegrationService;
|
||||
this.tripJdbcService = tripJdbcService;
|
||||
this.processedVisitJdbcService = processedVisitJdbcService;
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
@DeleteMapping("/{blockId}")
|
||||
public String deleteBlock(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@PathVariable Long blockId,
|
||||
@RequestHeader(value = "HX-Request", required = false) String hxRequest) {
|
||||
|
||||
memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
memoryService.deleteBlock(blockId);
|
||||
|
||||
if (hxRequest != null) {
|
||||
return "memories/fragments :: empty";
|
||||
}
|
||||
|
||||
return "redirect:/memories/" + memoryId;
|
||||
}
|
||||
|
||||
@GetMapping("/{blockId}/edit")
|
||||
public String editBlockForm(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@PathVariable Long blockId,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
MemoryBlock block = memoryService.getBlockById(user, blockId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Block not found"));
|
||||
|
||||
model.addAttribute("memoryId", memoryId);
|
||||
model.addAttribute("block", block);
|
||||
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
|
||||
switch (block.getBlockType()) {
|
||||
case IMAGE_GALLERY:
|
||||
boolean immichEnabled = immichIntegrationService.getIntegrationForUser(user)
|
||||
.map(ImmichIntegration::isEnabled)
|
||||
.orElse(false);
|
||||
model.addAttribute("immichEnabled", immichEnabled);
|
||||
model.addAttribute("imageBlock", memoryService.getBlock(user, timezone, memoryId, blockId).orElseThrow(() -> new IllegalArgumentException("Block not found")) );
|
||||
return "memories/blocks/edit :: edit-image-gallery-block";
|
||||
case CLUSTER_VISIT:
|
||||
memoryService.getBlock(user, timezone, memoryId, blockId).ifPresent(b ->
|
||||
model.addAttribute("clusterVisitBlock", b));
|
||||
model.addAttribute("availableVisits", this.processedVisitJdbcService.findByUserAndTimeOverlap(user, memory.getStartDate(), memory.getEndDate()));
|
||||
return "memories/blocks/edit :: edit-cluster-visit-block";
|
||||
case TEXT:
|
||||
memoryService.getBlock(user, timezone, memoryId, blockId).ifPresent(text ->
|
||||
model.addAttribute("textBlock", text));
|
||||
return "memories/blocks/edit :: edit-text-block";
|
||||
case CLUSTER_TRIP:
|
||||
memoryService.getBlock(user, timezone, memoryId, blockId).ifPresent(b ->
|
||||
model.addAttribute("clusterTripBlock", b));
|
||||
model.addAttribute("availableTrips", this.tripJdbcService.findByUserAndTimeOverlap(user, memory.getStartDate(), memory.getEndDate()));
|
||||
return "memories/blocks/edit :: edit-cluster-trip-block";
|
||||
}
|
||||
|
||||
return "memories/blocks/edit";
|
||||
}
|
||||
|
||||
@GetMapping("/{blockId}/view")
|
||||
public String viewBlock(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@PathVariable Long blockId,
|
||||
@RequestParam(required = false, defaultValue = "UTC" ) ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, blockId).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
|
||||
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
|
||||
return "memories/view :: view-block";
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/{blockId}/cluster")
|
||||
public String updateClusterBlock(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@PathVariable Long blockId,
|
||||
@RequestParam(name = "selectedParts") List<Long> selectedParts,
|
||||
@RequestParam(required = false) String title,
|
||||
@RequestParam(required = false, defaultValue = "UTC" ) ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
MemoryClusterBlock block = memoryService.getClusterBlock(user, blockId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Cluster block not found"));
|
||||
|
||||
MemoryClusterBlock updated = block.withPartIds(selectedParts).withTitle(title);
|
||||
memoryService.updateClusterBlock(user, updated);
|
||||
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, blockId).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
|
||||
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
return "memories/view :: view-block";
|
||||
}
|
||||
|
||||
@PostMapping("/cluster")
|
||||
public String createClusterBlock(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@RequestParam(required = false, defaultValue = "-1") int position,
|
||||
@RequestParam(name = "selectedParts") List<Long> selectedParts,
|
||||
@RequestParam BlockType type,
|
||||
@RequestParam(required = false) String title,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
MemoryBlock block = memoryService.addBlock(user, memoryId, position, type);
|
||||
MemoryClusterBlock clusterBlock = new MemoryClusterBlock(block.getId(), selectedParts, title, null, type);
|
||||
memoryService.createClusterBlock(user, clusterBlock);
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, block.getId()).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
return "memories/view :: view-block";
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/reorder")
|
||||
public String reorderBlocks(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@RequestParam List<Long> blockIds) {
|
||||
|
||||
memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
memoryService.reorderBlocks(user, memoryId, blockIds);
|
||||
return "redirect:/memories/" + memoryId;
|
||||
}
|
||||
|
||||
@PostMapping("/text")
|
||||
public String createTextBlock(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@RequestParam(required = false, defaultValue = "-1") int position,
|
||||
@RequestParam(required = false) String headline,
|
||||
@RequestParam(required = false) String content,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
MemoryBlock block = memoryService.addBlock(user, memoryId, position, BlockType.TEXT);
|
||||
memoryService.addTextBlock(block.getId(), headline, content);
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("blocks", List.of(this.memoryService.getBlock(user, timezone, memoryId, block.getId()).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
return "memories/view :: view-block";
|
||||
}
|
||||
|
||||
@PostMapping("/{blockId}/text")
|
||||
public String updateTextBlock(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@PathVariable Long blockId,
|
||||
@RequestParam String headline,
|
||||
@RequestParam String content,
|
||||
@RequestParam(required = false, defaultValue = "UTC" ) ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
MemoryBlockText textBlock = memoryService.getTextBlock(blockId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Text block not found"));
|
||||
|
||||
MemoryBlockText updated = textBlock.withHeadline(headline).withContent(content);
|
||||
memoryService.updateTextBlock(user, updated);
|
||||
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("blocks", List.of(updated));
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
return "memories/view :: view-block";
|
||||
}
|
||||
|
||||
@PostMapping("/image-gallery")
|
||||
public String createImageGalleryBlock(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@RequestParam(required = false, defaultValue = "-1") int position,
|
||||
@RequestParam(required = false) List<String> uploadedUrls,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
MemoryBlock block = memoryService.addBlock(user, memoryId, position, BlockType.IMAGE_GALLERY);
|
||||
List<MemoryBlockImageGallery.GalleryImage> imageBlocks = new ArrayList<>();
|
||||
|
||||
if (uploadedUrls != null) {
|
||||
for (String url : uploadedUrls) {
|
||||
imageBlocks.add(new MemoryBlockImageGallery.GalleryImage(url, null, "upload", null));
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBlocks.isEmpty()) {
|
||||
throw new IllegalArgumentException("No images selected");
|
||||
}
|
||||
|
||||
memoryService.addImageGalleryBlock(block.getId(), imageBlocks);
|
||||
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("blocks", List.of(memoryService.getBlock(user, timezone, memoryId, block.getId()).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
return "memories/view :: view-block";
|
||||
}
|
||||
|
||||
@PostMapping("/{blockId}/image-gallery")
|
||||
public String updateImageGalleryBlock(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@PathVariable Long blockId,
|
||||
@RequestParam(required = false) List<String> uploadedUrls,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
MemoryBlockImageGallery imageBlock = memoryService.getImagesForBlock(blockId);
|
||||
List<MemoryBlockImageGallery.GalleryImage> imageBlocks = new ArrayList<>();
|
||||
|
||||
if (uploadedUrls != null) {
|
||||
for (String url : uploadedUrls) {
|
||||
imageBlocks.add(new MemoryBlockImageGallery.GalleryImage(url, null, "upload", null));
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBlocks.isEmpty()) {
|
||||
throw new IllegalArgumentException("No images selected");
|
||||
}
|
||||
|
||||
this.memoryService.updateImageBlock(user, imageBlock.withImages(imageBlocks));
|
||||
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("blocks", List.of(memoryService.getBlock(user, timezone, memoryId, blockId).orElseThrow(() -> new IllegalArgumentException("Block not found"))));
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
return "memories/view :: view-block";
|
||||
}
|
||||
|
||||
@PostMapping("/upload-image")
|
||||
public String uploadImage(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@RequestParam("files") List<MultipartFile> files,
|
||||
Model model) {
|
||||
|
||||
memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
List<String> urls = new ArrayList<>();
|
||||
|
||||
for (MultipartFile file : files) {
|
||||
if (file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = "";
|
||||
if (originalFilename != null && originalFilename.contains(".")) {
|
||||
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
}
|
||||
String filename = UUID.randomUUID() + extension;
|
||||
|
||||
storageService.store("memories/" + memoryId + "/" + filename, file.getInputStream(), file.getSize(), file.getContentType());
|
||||
|
||||
String fileUrl = "/api/v1/photos/reitti/memories/" + memoryId + "/" + filename;
|
||||
urls.add(fileUrl);
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to upload file", e);
|
||||
}
|
||||
}
|
||||
|
||||
model.addAttribute("urls", urls);
|
||||
return "memories/fragments :: uploaded-photos";
|
||||
}
|
||||
|
||||
@PostMapping("/fetch-immich-photo")
|
||||
public String fetchImmichPhoto(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@RequestParam String assetId,
|
||||
Model model) {
|
||||
|
||||
memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
String imageUrl;
|
||||
if (storageService.exists("memories/" + memoryId + "/" + assetId)) {
|
||||
imageUrl = "/api/v1/photos/reitti/memories/" + memoryId + "/" + assetId;
|
||||
} else {
|
||||
String filename = this.immichIntegrationService.downloadImage(user, assetId, "memories/" + memoryId);
|
||||
imageUrl = "/api/v1/photos/reitti/memories/" + memoryId + "/" + filename;
|
||||
}
|
||||
|
||||
model.addAttribute("urls", List.of(imageUrl));
|
||||
|
||||
return "memories/fragments :: uploaded-photos";
|
||||
}
|
||||
|
||||
@GetMapping("/immich-photos")
|
||||
public String getImmichPhotos(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long memoryId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, memoryId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
ZoneId zoneId = ZoneId.of(timezone);
|
||||
LocalDate startDate = memory.getStartDate().atZone(zoneId).toLocalDate();
|
||||
LocalDate endDate = memory.getEndDate().atZone(zoneId).toLocalDate();
|
||||
|
||||
List<PhotoResponse> allPhotos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone);
|
||||
|
||||
int pageSize = 12;
|
||||
int totalPages = (int) Math.ceil((double) allPhotos.size() / pageSize);
|
||||
int startIndex = page * pageSize;
|
||||
int endIndex = Math.min(startIndex + pageSize, allPhotos.size());
|
||||
|
||||
List<PhotoResponse> pagePhotos = allPhotos.subList(startIndex, endIndex);
|
||||
|
||||
model.addAttribute("photos", pagePhotos);
|
||||
model.addAttribute("currentPage", page);
|
||||
model.addAttribute("totalPages", totalPages);
|
||||
model.addAttribute("memoryId", memoryId);
|
||||
|
||||
return "memories/fragments :: immich-photos-grid";
|
||||
}
|
||||
|
||||
|
||||
private boolean isOwner(Memory memory, User user) {
|
||||
if (user.getAuthorities().contains(ADMIN.asAuthority()) || user.getAuthorities().contains(USER.asAuthority())) {
|
||||
return this.memoryService.getOwnerId(memory) == user.getId();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canEdit(Memory memory, User user) {
|
||||
if (user.getAuthorities().contains(ADMIN.asAuthority()) || user.getAuthorities().contains(USER.asAuthority())) {
|
||||
return this.memoryService.getOwnerId(memory) == user.getId();
|
||||
} else {
|
||||
//assume the user is of type TokenUser
|
||||
TokenUser tokenUser = (TokenUser) user;
|
||||
return user.getAuthorities().contains(MagicLinkAccessLevel.MEMORY_EDIT_ACCESS.asAuthority()) && tokenUser.grantsAccessTo(MagicLinkResourceType.MEMORY, memory.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package com.dedicatedcode.reitti.controller;
|
||||
|
||||
import com.dedicatedcode.reitti.controller.error.ForbiddenException;
|
||||
import com.dedicatedcode.reitti.controller.error.PageNotFoundException;
|
||||
import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
|
||||
import com.dedicatedcode.reitti.model.memory.HeaderType;
|
||||
import com.dedicatedcode.reitti.model.memory.Memory;
|
||||
import com.dedicatedcode.reitti.model.memory.MemoryBlockPart;
|
||||
import com.dedicatedcode.reitti.model.memory.MemoryOverviewDTO;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
|
||||
import com.dedicatedcode.reitti.model.security.TokenUser;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.TripJdbcService;
|
||||
import com.dedicatedcode.reitti.service.MagicLinkTokenService;
|
||||
import com.dedicatedcode.reitti.service.MemoryService;
|
||||
import com.dedicatedcode.reitti.service.RequestHelper;
|
||||
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static com.dedicatedcode.reitti.model.Role.ADMIN;
|
||||
import static com.dedicatedcode.reitti.model.Role.USER;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/memories")
|
||||
public class MemoryController {
|
||||
|
||||
private final MemoryService memoryService;
|
||||
private final TripJdbcService tripJdbcService;
|
||||
private final ProcessedVisitJdbcService processedVisitJdbcService;
|
||||
private final ImmichIntegrationService immichIntegrationService;
|
||||
private final MagicLinkTokenService magicLinkTokenService;
|
||||
|
||||
public MemoryController(MemoryService memoryService,
|
||||
TripJdbcService tripJdbcService,
|
||||
ProcessedVisitJdbcService processedVisitJdbcService,
|
||||
ImmichIntegrationService immichIntegrationService,
|
||||
MagicLinkTokenService magicLinkTokenService) {
|
||||
this.memoryService = memoryService;
|
||||
this.tripJdbcService = tripJdbcService;
|
||||
this.processedVisitJdbcService = processedVisitJdbcService;
|
||||
this.immichIntegrationService = immichIntegrationService;
|
||||
this.magicLinkTokenService = magicLinkTokenService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String get() {
|
||||
return "memories/list";
|
||||
}
|
||||
|
||||
@GetMapping("/years-navigation")
|
||||
public String listMemories(@AuthenticationPrincipal User user, Model model) {
|
||||
model.addAttribute("years", memoryService.getAvailableYears(user));
|
||||
return "memories/fragments :: years-navigation";
|
||||
}
|
||||
|
||||
@GetMapping("/all")
|
||||
public String getAll(@AuthenticationPrincipal User user, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, Model model) {
|
||||
model.addAttribute("memories", this.memoryService.getMemoriesForUser(user).stream().map(m -> {
|
||||
String startDateLocal = m.getStartDate().atZone(timezone).toLocalDate().toString();
|
||||
String endDateLocal = m.getEndDate().atZone(timezone).toLocalDate().toString();
|
||||
|
||||
String rawLocationUrl = "/api/v1/raw-location-points?startDate=" + startDateLocal + "&endDate=" + endDateLocal;
|
||||
return new MemoryOverviewDTO(m, rawLocationUrl);
|
||||
}).toList());
|
||||
model.addAttribute("year", "all");
|
||||
return "memories/fragments :: memories-list";
|
||||
}
|
||||
|
||||
@GetMapping("/year/{year}")
|
||||
public String getYear(@AuthenticationPrincipal User user, @PathVariable int year, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone, Model model) {
|
||||
model.addAttribute("memories", this.memoryService.getMemoriesForUserAndYear(user, year)
|
||||
.stream().map(m -> {
|
||||
String startDateLocal = m.getStartDate().atZone(timezone).toLocalDate().toString();
|
||||
String endDateLocal = m.getEndDate().atZone(timezone).toLocalDate().toString();
|
||||
|
||||
String rawLocationUrl = "/api/v1/raw-location-points?startDate=" + startDateLocal + "&endDate=" + endDateLocal;
|
||||
return new MemoryOverviewDTO(m, rawLocationUrl);
|
||||
}).toList());
|
||||
model.addAttribute("year", year);
|
||||
return "memories/fragments :: memories-list";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public String viewMemory(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long id,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
Memory memory = memoryService.getMemoryById(user, id)
|
||||
.orElseThrow(() -> new PageNotFoundException("Memory not found"));
|
||||
|
||||
model.addAttribute("memory", memory);
|
||||
|
||||
List<MemoryBlockPart> blocks = memoryService.getBlockPartsForMemory(user, id, timezone);
|
||||
model.addAttribute("blocks", blocks);
|
||||
|
||||
String startDateLocal = memory.getStartDate().atZone(timezone).toLocalDate().toString();
|
||||
String endDateLocal = memory.getEndDate().atZone(timezone).toLocalDate().toString();
|
||||
|
||||
String rawLocationUrl = "/api/v1/raw-location-points?startDate=" + startDateLocal + "&endDate=" + endDateLocal;
|
||||
model.addAttribute("rawLocationUrl", rawLocationUrl);
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
return "memories/view";
|
||||
}
|
||||
|
||||
private boolean isOwner(Memory memory, User user) {
|
||||
if (user.getAuthorities().contains(ADMIN.asAuthority()) || user.getAuthorities().contains(USER.asAuthority())) {
|
||||
return this.memoryService.getOwnerId(memory) == user.getId();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canEdit(Memory memory, User user) {
|
||||
if (user.getAuthorities().contains(ADMIN.asAuthority()) || user.getAuthorities().contains(USER.asAuthority())) {
|
||||
return this.memoryService.getOwnerId(memory) == user.getId();
|
||||
} else {
|
||||
//assume the user is of type TokenUser
|
||||
TokenUser tokenUser = (TokenUser) user;
|
||||
return user.getAuthorities().contains(MagicLinkAccessLevel.MEMORY_EDIT_ACCESS.asAuthority()) && tokenUser.grantsAccessTo(MagicLinkResourceType.MEMORY, memory.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/new")
|
||||
public String newMemoryForm(
|
||||
@AuthenticationPrincipal User user,
|
||||
@RequestParam(required = false) String startDate,
|
||||
@RequestParam(required = false) String endDate,
|
||||
@RequestParam(required = false) String year,
|
||||
Model model) {
|
||||
model.addAttribute("startDate", startDate);
|
||||
model.addAttribute("endDate", endDate);
|
||||
model.addAttribute("year", year);
|
||||
return "memories/new :: new-memory";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String createMemory(
|
||||
@AuthenticationPrincipal User user,
|
||||
@RequestParam String title,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam LocalDate startDate,
|
||||
@RequestParam LocalDate endDate,
|
||||
@RequestParam(required = false) String headerImageUrl,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
if (title == null || title.trim().isEmpty()) {
|
||||
model.addAttribute("error", "memory.validation.title.required");
|
||||
model.addAttribute("title", title);
|
||||
model.addAttribute("description", description);
|
||||
model.addAttribute("startDate", startDate);
|
||||
model.addAttribute("endDate", endDate);
|
||||
model.addAttribute("headerImageUrl", headerImageUrl);
|
||||
return "memories/new :: new-memory";
|
||||
}
|
||||
|
||||
try {
|
||||
Instant start = ZonedDateTime.of(startDate.atStartOfDay(), timezone).toInstant();
|
||||
Instant end = ZonedDateTime.of(endDate.plusDays(1).atStartOfDay().minusNanos(1), timezone).toInstant();
|
||||
Instant today = Instant.now();
|
||||
|
||||
// Validate dates are not in the future
|
||||
if (start.isAfter(today) || end.isAfter(today)) {
|
||||
model.addAttribute("error", "memory.validation.date.future");
|
||||
model.addAttribute("title", title);
|
||||
model.addAttribute("description", description);
|
||||
model.addAttribute("startDate", startDate);
|
||||
model.addAttribute("endDate", endDate);
|
||||
model.addAttribute("headerImageUrl", headerImageUrl);
|
||||
return "memories/new :: new-memory";
|
||||
}
|
||||
|
||||
// Validate end date is not before start date
|
||||
if (end.isBefore(start)) {
|
||||
model.addAttribute("error", "memory.validation.end.date.before.start");
|
||||
model.addAttribute("title", title);
|
||||
model.addAttribute("description", description);
|
||||
model.addAttribute("startDate", startDate);
|
||||
model.addAttribute("endDate", endDate);
|
||||
model.addAttribute("headerImageUrl", headerImageUrl);
|
||||
return "memories/new :: new-memory";
|
||||
}
|
||||
|
||||
Memory memory = new Memory(
|
||||
title.trim(),
|
||||
description != null ? description.trim() : null,
|
||||
start,
|
||||
end,
|
||||
HeaderType.MAP,
|
||||
headerImageUrl
|
||||
);
|
||||
|
||||
Memory created = memoryService.createMemory(user, memory);
|
||||
this.memoryService.recalculateMemory(user, created.getId(), timezone);
|
||||
|
||||
return "redirect:/memories/" + created.getId();
|
||||
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("error", "memory.validation.start.date.required");
|
||||
model.addAttribute("title", title);
|
||||
model.addAttribute("description", description);
|
||||
model.addAttribute("startDate", startDate);
|
||||
model.addAttribute("endDate", endDate);
|
||||
model.addAttribute("headerImageUrl", headerImageUrl);
|
||||
return "memories/new :: new-memory";
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/edit")
|
||||
public String editMemoryForm(@AuthenticationPrincipal User user, @PathVariable Long id, @RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
Memory memory = memoryService.getMemoryById(user, id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
if (!canEdit(memory, user)) {
|
||||
throw new ForbiddenException("You are not allowed to edit this memory");
|
||||
}
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("startDate", memory.getStartDate().atZone(timezone).toLocalDate());
|
||||
model.addAttribute("endDate", memory.getEndDate().atZone(timezone).toLocalDate());
|
||||
return "memories/edit :: edit-memory";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}")
|
||||
public String updateMemory(
|
||||
@AuthenticationPrincipal User user,
|
||||
@PathVariable Long id,
|
||||
@RequestParam String title,
|
||||
@RequestParam(required = false) String description,
|
||||
@RequestParam LocalDate startDate,
|
||||
@RequestParam LocalDate endDate,
|
||||
@RequestParam HeaderType headerType,
|
||||
@RequestParam(required = false) String headerImageUrl,
|
||||
@RequestParam Long version,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
if (!canEdit(memory, user)) {
|
||||
throw new ForbiddenException("You are not allowed to edit this memory");
|
||||
}
|
||||
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
|
||||
// Add validation similar to create method
|
||||
if (title == null || title.trim().isEmpty()) {
|
||||
model.addAttribute("error", "memory.validation.title.required");
|
||||
model.addAttribute("memory", memory);
|
||||
|
||||
model.addAttribute("cancelEndpoint", "/memories/" + id);
|
||||
model.addAttribute("cancelTarget", ".memory-header");
|
||||
model.addAttribute("formTarget", ".memory-header");
|
||||
return "memories/edit :: edit-memory";
|
||||
}
|
||||
|
||||
try {
|
||||
Instant start = ZonedDateTime.of(startDate.atStartOfDay(), timezone).toInstant();
|
||||
Instant end = ZonedDateTime.of(endDate.plusDays(1).atStartOfDay().minusSeconds(1), timezone).toInstant();
|
||||
Instant today = Instant.now();
|
||||
|
||||
if (start.isAfter(today) || end.isAfter(today)) {
|
||||
model.addAttribute("error", "memory.validation.date.future");
|
||||
model.addAttribute("memory", memory);
|
||||
|
||||
model.addAttribute("cancelEndpoint", "/memories/" + id);
|
||||
model.addAttribute("cancelTarget", ".memory-header");
|
||||
model.addAttribute("formTarget", ".memory-header");
|
||||
return "memories/edit :: edit-memory";
|
||||
}
|
||||
|
||||
if (end.isBefore(start)) {
|
||||
model.addAttribute("error", "memory.validation.end.date.before.start");
|
||||
model.addAttribute("memory", memory);
|
||||
|
||||
model.addAttribute("cancelEndpoint", "/memories/" + id);
|
||||
model.addAttribute("cancelTarget", ".memory-header");
|
||||
model.addAttribute("formTarget", ".memory-header");
|
||||
return "memories/edit :: edit-memory";
|
||||
}
|
||||
|
||||
Memory updated = memory
|
||||
.withTitle(title.trim())
|
||||
.withDescription(description != null ? description.trim() : null)
|
||||
.withStartDate(start)
|
||||
.withEndDate(end)
|
||||
.withHeaderType(headerType)
|
||||
.withHeaderImageUrl(headerImageUrl)
|
||||
.withVersion(version);
|
||||
|
||||
Memory savedMemory = memoryService.updateMemory(user, updated);
|
||||
model.addAttribute("memory", savedMemory);
|
||||
|
||||
return "memories/view :: memory-header";
|
||||
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("error", "memory.validation.start.date.required");
|
||||
model.addAttribute("memory", memory);
|
||||
|
||||
model.addAttribute("cancelEndpoint", "/memories/" + id);
|
||||
model.addAttribute("cancelTarget", ".memory-header");
|
||||
model.addAttribute("formTarget", ".memory-header");
|
||||
return "memories/edit :: edit-memory";
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public String deleteMemory(@AuthenticationPrincipal User user, @PathVariable Long id) {
|
||||
Memory memory = this.memoryService.getMemoryById(user, id).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
if (!isOwner(memory, user)) {
|
||||
throw new ForbiddenException("You are not allowed to delete this memory");
|
||||
}
|
||||
memoryService.deleteMemory(user, id);
|
||||
return "redirect:/memories";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/blocks/select-type")
|
||||
public String selectBlockType(@AuthenticationPrincipal User user, @PathVariable Long id, @RequestParam(defaultValue = "-1") int position, Model model) {
|
||||
model.addAttribute("memoryId", id);
|
||||
model.addAttribute("position", position);
|
||||
return "memories/fragments :: block-type-selection";
|
||||
}
|
||||
|
||||
@GetMapping("/fragments/empty")
|
||||
public String emptyFragment() {
|
||||
return "memories/fragments :: empty";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/recalculate")
|
||||
@ResponseBody
|
||||
public String recalculateMemory(@AuthenticationPrincipal User user, @PathVariable Long id,
|
||||
@RequestParam(required = false, defaultValue = "UTC") ZoneId timezone,
|
||||
HttpServletResponse httpResponse) {
|
||||
Memory memory = memoryService.getMemoryById(user, id).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
if (!isOwner(memory, user)) {
|
||||
throw new ForbiddenException("You are not allowed execute this action. Only the owner of the memory can do this.");
|
||||
}
|
||||
memoryService.recalculateMemory(user, id, timezone);
|
||||
httpResponse.setHeader("HX-Redirect", "/memories/" + id + "?timezone=" + timezone.getId());
|
||||
return "Ok";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/blocks/new")
|
||||
public String newBlockForm(@AuthenticationPrincipal User user,
|
||||
@PathVariable Long id,
|
||||
@RequestParam String type,
|
||||
@RequestParam(defaultValue = "-1") int position, Model model) {
|
||||
|
||||
Memory memory = memoryService.getMemoryById(user, id).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
model.addAttribute("memoryId", id);
|
||||
model.addAttribute("position", position);
|
||||
model.addAttribute("blockType", type);
|
||||
|
||||
model.addAttribute("isOwner", isOwner(memory, user));
|
||||
model.addAttribute("canEdit", canEdit(memory, user));
|
||||
|
||||
switch (type) {
|
||||
case "TEXT":
|
||||
return "memories/fragments :: text-block-form";
|
||||
case "TRIP_CLUSTER":
|
||||
model.addAttribute("availableTrips", this.tripJdbcService.findByUserAndTimeOverlap(user, memory.getStartDate(), memory.getEndDate()));
|
||||
return "memories/fragments :: trip-block-form";
|
||||
case "VISIT_CLUSTER":
|
||||
model.addAttribute("availableVisits", this.processedVisitJdbcService.findByUserAndTimeOverlap(user, memory.getStartDate(), memory.getEndDate()));
|
||||
return "memories/fragments :: visit-block-form";
|
||||
case "IMAGE_GALLERY":
|
||||
boolean immichEnabled = immichIntegrationService.getIntegrationForUser(user)
|
||||
.map(ImmichIntegration::isEnabled)
|
||||
.orElse(false);
|
||||
model.addAttribute("immichEnabled", immichEnabled);
|
||||
return "memories/fragments :: image-gallery-block-form";
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown block type: " + type);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/share")
|
||||
public String shareMemoryOverlay(@AuthenticationPrincipal User user, @PathVariable Long id, Model model) {
|
||||
Memory memory = memoryService.getMemoryById(user, id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
model.addAttribute("memory", memory);
|
||||
return "memories/fragments :: share-overlay";
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/share/form")
|
||||
public String shareMemoryForm(@AuthenticationPrincipal User user, @PathVariable Long id,
|
||||
@RequestParam MagicLinkAccessLevel accessLevel, Model model) {
|
||||
Memory memory = memoryService.getMemoryById(user, id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("accessLevel", accessLevel);
|
||||
return "memories/fragments :: share-form";
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/share")
|
||||
public String createShareLink(@AuthenticationPrincipal User user,
|
||||
@PathVariable Long id,
|
||||
@RequestParam MagicLinkAccessLevel accessLevel,
|
||||
@RequestParam(defaultValue = "30") int validDays,
|
||||
HttpServletRequest request,
|
||||
Model model) {
|
||||
Memory memory = memoryService.getMemoryById(user, id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
String token = magicLinkTokenService.createMemoryShareToken(user, id, accessLevel, validDays);
|
||||
String baseUrl = RequestHelper.getBaseUrl(request);
|
||||
String shareUrl = baseUrl + "/memories/" + id + "?mt=" + token;
|
||||
|
||||
model.addAttribute("shareUrl", shareUrl);
|
||||
model.addAttribute("memory", memory);
|
||||
model.addAttribute("accessLevel", accessLevel);
|
||||
return "memories/fragments :: share-result";
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ public class UserSettingsControllerAdvice {
|
||||
DEFAULT_HOME_LONGITUDE,
|
||||
tilesCustomizationProvider.getTilesConfiguration(),
|
||||
UserSettingsDTO.UIMode.FULL,
|
||||
UserSettingsDTO.PhotoMode.DISABLED,
|
||||
TimeDisplayMode.DEFAULT,
|
||||
null,
|
||||
null);
|
||||
@@ -57,6 +58,7 @@ public class UserSettingsControllerAdvice {
|
||||
String username = authentication.getName();
|
||||
Optional<User> userOptional = userJdbcService.findByUsername(username);
|
||||
UserSettingsDTO.UIMode uiMode = mapUserToUiMode(authentication);
|
||||
UserSettingsDTO.PhotoMode photoMode = mapUserToPhotoMode(authentication);
|
||||
if (userOptional.isPresent()) {
|
||||
User user = userOptional.get();
|
||||
UserSettings dbSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
@@ -68,6 +70,7 @@ public class UserSettingsControllerAdvice {
|
||||
dbSettings.getHomeLongitude(),
|
||||
tilesCustomizationProvider.getTilesConfiguration(),
|
||||
uiMode,
|
||||
photoMode,
|
||||
dbSettings.getTimeDisplayMode(),
|
||||
dbSettings.getTimeZoneOverride(),
|
||||
dbSettings.getCustomCss() !=null ? "/user-css/" + user.getId() : null);
|
||||
@@ -81,6 +84,7 @@ public class UserSettingsControllerAdvice {
|
||||
DEFAULT_HOME_LONGITUDE,
|
||||
tilesCustomizationProvider.getTilesConfiguration(),
|
||||
uiMode,
|
||||
photoMode,
|
||||
TimeDisplayMode.DEFAULT,
|
||||
null,
|
||||
null);
|
||||
@@ -94,9 +98,23 @@ public class UserSettingsControllerAdvice {
|
||||
return UserSettingsDTO.UIMode.SHARED_FULL;
|
||||
} else if (grantedRoles.contains("ROLE_MAGIC_LINK_ONLY_LIVE") || grantedRoles.contains("ROLE_MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS")) {
|
||||
return UserSettingsDTO.UIMode.SHARED_LIVE_MODE_ONLY;
|
||||
} else if (grantedRoles.contains("ROLE_MAGIC_LINK_MEMORY_VIEW_ONLY") || grantedRoles.contains("ROLE_MAGIC_LINK_MEMORY_EDIT_ACCESS")) {
|
||||
return UserSettingsDTO.UIMode.VIEW_MEMORIES;
|
||||
} else {
|
||||
throw new IllegalStateException("Invalid user authentication mode detected [" + grantedRoles + "]");
|
||||
}
|
||||
}
|
||||
private UserSettingsDTO.PhotoMode mapUserToPhotoMode(Authentication authentication) {
|
||||
List<String> grantedRoles = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
if (grantedRoles.contains("ROLE_ADMIN") ||
|
||||
grantedRoles.contains("ROLE_USER") ||
|
||||
grantedRoles.contains("MAGIC_LINK_MEMORY_VIEW_ONLY") ||
|
||||
grantedRoles.contains("MAGIC_LINK_MEMORY_EDIT_ACCESS") ||
|
||||
grantedRoles.contains("ROLE_MAGIC_LINK_ONLY_LIVE_WITH_PHOTOS")) {
|
||||
return UserSettingsDTO.PhotoMode.ENABLED;
|
||||
} else {
|
||||
return UserSettingsDTO.PhotoMode.DISABLED;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.dedicatedcode.reitti.controller.api;
|
||||
|
||||
import com.dedicatedcode.reitti.dto.PhotoResponse;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/photos/immich")
|
||||
public class ImmichPhotoApiController {
|
||||
|
||||
private final ImmichIntegrationService immichIntegrationService;
|
||||
|
||||
public ImmichPhotoApiController(ImmichIntegrationService immichIntegrationService) {
|
||||
this.immichIntegrationService = immichIntegrationService;
|
||||
}
|
||||
|
||||
@GetMapping("/range")
|
||||
public ResponseEntity<List<PhotoResponse>> getPhotosForRange(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone,
|
||||
@AuthenticationPrincipal User user) {
|
||||
|
||||
List<PhotoResponse> photos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone);
|
||||
return ResponseEntity.ok(photos);
|
||||
}
|
||||
|
||||
@GetMapping("/proxy/{assetId}/thumbnail")
|
||||
public ResponseEntity<byte[]> getPhotoThumbnail(
|
||||
@PathVariable String assetId,
|
||||
@AuthenticationPrincipal User user) {
|
||||
|
||||
return immichIntegrationService.proxyImageRequest(user, assetId, "thumbnail");
|
||||
}
|
||||
|
||||
@GetMapping("/proxy/{assetId}/original")
|
||||
public ResponseEntity<byte[]> getPhotoOriginal(
|
||||
@PathVariable String assetId,
|
||||
@AuthenticationPrincipal User user) {
|
||||
|
||||
return immichIntegrationService.proxyImageRequest(user, assetId, "fullsize");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package com.dedicatedcode.reitti.controller.api;
|
||||
import com.dedicatedcode.reitti.dto.LocationPoint;
|
||||
import com.dedicatedcode.reitti.dto.RawLocationDataResponse;
|
||||
import com.dedicatedcode.reitti.model.geo.RawLocationPoint;
|
||||
import com.dedicatedcode.reitti.model.geo.Trip;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.TripJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserJdbcService;
|
||||
import com.dedicatedcode.reitti.service.LocationPointsSimplificationService;
|
||||
import org.slf4j.Logger;
|
||||
@@ -17,9 +19,13 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
@@ -28,14 +34,16 @@ public class LocationDataApiController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(LocationDataApiController.class);
|
||||
|
||||
private final RawLocationPointJdbcService rawLocationPointJdbcService;
|
||||
private final TripJdbcService tripJdbcService;
|
||||
private final LocationPointsSimplificationService simplificationService;
|
||||
private final UserJdbcService userJdbcService;
|
||||
|
||||
@Autowired
|
||||
public LocationDataApiController(RawLocationPointJdbcService rawLocationPointJdbcService,
|
||||
public LocationDataApiController(RawLocationPointJdbcService rawLocationPointJdbcService, TripJdbcService tripJdbcService,
|
||||
LocationPointsSimplificationService simplificationService,
|
||||
UserJdbcService userJdbcService) {
|
||||
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
|
||||
this.tripJdbcService = tripJdbcService;
|
||||
this.simplificationService = simplificationService;
|
||||
this.userJdbcService = userJdbcService;
|
||||
}
|
||||
@@ -49,6 +57,30 @@ public class LocationDataApiController {
|
||||
return p;
|
||||
}
|
||||
|
||||
@GetMapping("/raw-location-points/trips")
|
||||
public ResponseEntity<?> getRawLocationPointsTrips(@AuthenticationPrincipal User user,
|
||||
@RequestParam List<Long> trips,
|
||||
@RequestParam(required = false) Integer zoom,
|
||||
@RequestParam(required = false) Double minLat,
|
||||
@RequestParam(required = false) Double maxLat,
|
||||
@RequestParam(required = false) Double minLng,
|
||||
@RequestParam(required = false) Double maxLng) {
|
||||
List<List<LocationPoint>> tmp = new ArrayList<>();
|
||||
List<Trip> loadedTrips = this.tripJdbcService.findByIds(user, trips);
|
||||
for (Trip trip : loadedTrips) {
|
||||
Instant startOfRange = trip.getStartTime();
|
||||
Instant endOfRange = trip.getEndTime();
|
||||
List<List<LocationPoint>> segments = loadSegmentsInBoundingBoxAndTime(user, minLat, maxLat, minLng, maxLng, startOfRange, endOfRange);
|
||||
tmp.addAll(segments);
|
||||
}
|
||||
Optional<RawLocationPoint> latest = this.rawLocationPointJdbcService.findLatest(user);
|
||||
RawLocationDataResponse result = new RawLocationDataResponse(tmp.stream().map(s -> {
|
||||
List<LocationPoint> simplifiedPoints = simplificationService.simplifyPoints(s, zoom);
|
||||
return new RawLocationDataResponse.Segment(simplifiedPoints);
|
||||
}).toList(), latest.map(LocationDataApiController::toLocationPoint).orElse(null));
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/raw-location-points")
|
||||
public ResponseEntity<?> getRawLocationPointsForCurrentUser(@AuthenticationPrincipal User user,
|
||||
@RequestParam(required = false) String date,
|
||||
@@ -76,21 +108,30 @@ public class LocationDataApiController {
|
||||
@RequestParam(required = false) Double maxLng) {
|
||||
try {
|
||||
ZoneId userTimezone = ZoneId.of(timezone);
|
||||
Instant startOfRange;
|
||||
Instant endOfRange;
|
||||
Instant startOfRange = null;
|
||||
Instant endOfRange = null;
|
||||
|
||||
// Support both single date and date range
|
||||
if (startDate != null && endDate != null) {
|
||||
// Date range mode
|
||||
LocalDate selectedStartDate = LocalDate.parse(startDate);
|
||||
LocalDate selectedEndDate = LocalDate.parse(endDate);
|
||||
//first try to parse them as date time
|
||||
|
||||
startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant();
|
||||
endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
try {
|
||||
LocalDateTime startTimestamp = LocalDateTime.parse(startDate);
|
||||
LocalDateTime endTimestamp = LocalDateTime.parse(endDate);
|
||||
startOfRange = startTimestamp.atZone(userTimezone).toInstant();
|
||||
endOfRange = endTimestamp.atZone(userTimezone).toInstant();
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
|
||||
if (startOfRange == null && endOfRange == null) {
|
||||
LocalDate selectedStartDate = LocalDate.parse(startDate);
|
||||
LocalDate selectedEndDate = LocalDate.parse(endDate);
|
||||
startOfRange = selectedStartDate.atStartOfDay(userTimezone).toInstant();
|
||||
endOfRange = selectedEndDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
}
|
||||
} else if (date != null) {
|
||||
// Single date mode (backward compatibility)
|
||||
LocalDate selectedDate = LocalDate.parse(date);
|
||||
|
||||
startOfRange = selectedDate.atStartOfDay(userTimezone).toInstant();
|
||||
endOfRange = selectedDate.plusDays(1).atStartOfDay(userTimezone).toInstant().minusMillis(1);
|
||||
} else {
|
||||
@@ -103,13 +144,7 @@ public class LocationDataApiController {
|
||||
User user = userJdbcService.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
List<RawLocationPoint> pointsInBoxWithNeighbors;
|
||||
if (minLat == null || maxLat == null || minLng == null || maxLng == null) {
|
||||
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange,endOfRange);
|
||||
} else {
|
||||
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findPointsInBoxWithNeighbors(user, startOfRange, endOfRange, minLat, maxLat, minLng, maxLng);
|
||||
}
|
||||
List<List<LocationPoint>> segments = extractPathSegments(pointsInBoxWithNeighbors, minLat, maxLat, minLng, maxLng);
|
||||
List<List<LocationPoint>> segments = loadSegmentsInBoundingBoxAndTime(user, minLat, maxLat, minLng, maxLng, startOfRange, endOfRange);
|
||||
List<RawLocationDataResponse.Segment> result = segments.stream().map(s -> {
|
||||
List<LocationPoint> simplifiedPoints = simplificationService.simplifyPoints(s, zoom);
|
||||
return new RawLocationDataResponse.Segment(simplifiedPoints);
|
||||
@@ -164,6 +199,15 @@ public class LocationDataApiController {
|
||||
}
|
||||
}
|
||||
|
||||
private List<List<LocationPoint>> loadSegmentsInBoundingBoxAndTime(User user, Double minLat, Double maxLat, Double minLng, Double maxLng, Instant startOfRange, Instant endOfRange) {
|
||||
List<RawLocationPoint> pointsInBoxWithNeighbors;
|
||||
if (minLat == null || maxLat == null || minLng == null || maxLng == null) {
|
||||
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findByUserAndTimestampBetweenOrderByTimestampAsc(user, startOfRange, endOfRange);
|
||||
} else {
|
||||
pointsInBoxWithNeighbors = this.rawLocationPointJdbcService.findPointsInBoxWithNeighbors(user, startOfRange, endOfRange, minLat, maxLat, minLng, maxLng);
|
||||
}
|
||||
return extractPathSegments(pointsInBoxWithNeighbors, minLat, maxLat, minLng, maxLng);
|
||||
}
|
||||
|
||||
private List<List<LocationPoint>> extractPathSegments(List<RawLocationPoint> points, Double minLat, Double maxLat, Double minLng, Double maxLng) {
|
||||
List<List<LocationPoint>> segments = new ArrayList<>();
|
||||
@@ -209,4 +253,5 @@ public class LocationDataApiController {
|
||||
point.getLongitude() <= maxLng;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.dedicatedcode.reitti.controller.api;
|
||||
|
||||
import com.dedicatedcode.reitti.dto.PhotoResponse;
|
||||
import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/photos")
|
||||
public class PhotoApiController {
|
||||
|
||||
private final ImmichIntegrationService immichIntegrationService;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public PhotoApiController(ImmichIntegrationService immichIntegrationService, RestTemplate restTemplate) {
|
||||
this.immichIntegrationService = immichIntegrationService;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@GetMapping("/range")
|
||||
public ResponseEntity<List<PhotoResponse>> getPhotosForRange(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(required = false, defaultValue = "UTC") String timezone,
|
||||
@AuthenticationPrincipal User user) {
|
||||
|
||||
List<PhotoResponse> photos = immichIntegrationService.searchPhotosForRange(user, startDate, endDate, timezone);
|
||||
return ResponseEntity.ok(photos);
|
||||
}
|
||||
|
||||
@GetMapping("/proxy/{assetId}/thumbnail")
|
||||
public ResponseEntity<byte[]> getPhotoThumbnail(
|
||||
@PathVariable String assetId,
|
||||
@AuthenticationPrincipal User user) {
|
||||
|
||||
return proxyImageRequest(user, assetId, "thumbnail");
|
||||
}
|
||||
|
||||
@GetMapping("/proxy/{assetId}/original")
|
||||
public ResponseEntity<byte[]> getPhotoOriginal(
|
||||
@PathVariable String assetId,
|
||||
@AuthenticationPrincipal User user) {
|
||||
|
||||
return proxyImageRequest(user, assetId, "fullsize");
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> proxyImageRequest(User user, String assetId, String size) {
|
||||
Optional<ImmichIntegration> integrationOpt = immichIntegrationService.getIntegrationForUser(user);
|
||||
|
||||
if (integrationOpt.isEmpty() || !integrationOpt.get().isEnabled()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
ImmichIntegration integration = integrationOpt.get();
|
||||
|
||||
try {
|
||||
String baseUrl = integration.getServerUrl().endsWith("/") ?
|
||||
integration.getServerUrl() : integration.getServerUrl() + "/";
|
||||
String imageUrl = baseUrl + "api/assets/" + assetId + "/thumbnail?size=" + size;
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("x-api-key", integration.getApiToken());
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<byte[]> response = restTemplate.exchange(
|
||||
imageUrl,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
byte[].class
|
||||
);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
|
||||
// Copy content type from Immich response if available
|
||||
if (response.getHeaders().getContentType() != null) {
|
||||
responseHeaders.setContentType(response.getHeaders().getContentType());
|
||||
} else {
|
||||
// Default to JPEG for images
|
||||
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
|
||||
}
|
||||
|
||||
// Set cache headers for better performance
|
||||
responseHeaders.setCacheControl("public, max-age=3600");
|
||||
|
||||
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
// Log error but don't expose details
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.dedicatedcode.reitti.controller.api;
|
||||
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.service.StorageService;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/photos/reitti")
|
||||
public class ReittiPhotoApiController {
|
||||
|
||||
private final StorageService storageService;
|
||||
|
||||
public ReittiPhotoApiController(StorageService storageService) {
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
@GetMapping("/{filename}")
|
||||
public ResponseEntity<InputStreamResource> getPhoto(@PathVariable String filename, @AuthenticationPrincipal User user) {
|
||||
try {
|
||||
StorageService.StorageContent result = this.storageService.read("images/" + filename);
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
responseHeaders.setContentType(MediaType.valueOf(result.getContentType()));
|
||||
responseHeaders.setContentLength(result.getContentLength());
|
||||
responseHeaders.setCacheControl("public, max-age=3600");
|
||||
return ResponseEntity.ok()
|
||||
.headers(responseHeaders)
|
||||
.body(new InputStreamResource(result.getInputStream()));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/memories/{memoryId}/{filename}")
|
||||
public ResponseEntity<InputStreamResource> getPhotoForMemory(@PathVariable String memoryId,
|
||||
@PathVariable String filename,
|
||||
@AuthenticationPrincipal User user) {
|
||||
try {
|
||||
StorageService.StorageContent result = this.storageService.read("memories/" + memoryId + "/" + filename);
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
responseHeaders.setContentType(MediaType.valueOf(result.getContentType()));
|
||||
responseHeaders.setContentLength(result.getContentLength());
|
||||
responseHeaders.setCacheControl("public, max-age=3600");
|
||||
return ResponseEntity.ok()
|
||||
.headers(responseHeaders)
|
||||
.body(new InputStreamResource(result.getInputStream()));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.dedicatedcode.reitti.controller.error;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public class ForbiddenException extends RuntimeException {
|
||||
public ForbiddenException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.dedicatedcode.reitti.controller.error;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public class PageNotFoundException extends RuntimeException {
|
||||
public PageNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,16 @@ import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.model.security.UserSharing;
|
||||
import com.dedicatedcode.reitti.repository.MagicLinkJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.UserSharingJdbcService;
|
||||
import com.dedicatedcode.reitti.service.AvatarService;
|
||||
import com.dedicatedcode.reitti.service.I18nService;
|
||||
import com.dedicatedcode.reitti.service.MagicLinkTokenService;
|
||||
import com.dedicatedcode.reitti.service.RequestHelper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.i18n.LocaleContextHolder;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -23,43 +23,41 @@ import org.springframework.web.bind.annotation.*;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/settings/share-access")
|
||||
public class ShareAccessController {
|
||||
|
||||
private final MagicLinkJdbcService magicLinkJdbcService;
|
||||
private final MagicLinkTokenService magicLinkTokenService;
|
||||
private final UserJdbcService userJdbcService;
|
||||
private final UserSharingJdbcService userSharingJdbcService;
|
||||
private final AvatarService avatarService;
|
||||
private final MessageSource messageSource;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final boolean dataManagementEnabled;
|
||||
private final I18nService i18n;
|
||||
|
||||
public ShareAccessController(MagicLinkJdbcService magicLinkJdbcService,
|
||||
public ShareAccessController(MagicLinkTokenService magicLinkTokenService,
|
||||
UserJdbcService userJdbcService,
|
||||
UserSharingJdbcService userSharingJdbcService, AvatarService avatarService,
|
||||
MessageSource messageSource,
|
||||
PasswordEncoder passwordEncoder,
|
||||
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled) {
|
||||
this.magicLinkJdbcService = magicLinkJdbcService;
|
||||
UserSharingJdbcService userSharingJdbcService,
|
||||
AvatarService avatarService,
|
||||
@Value("${reitti.data-management.enabled:false}") boolean dataManagementEnabled,
|
||||
I18nService i18nService) {
|
||||
this.magicLinkTokenService = magicLinkTokenService;
|
||||
this.userJdbcService = userJdbcService;
|
||||
this.userSharingJdbcService = userSharingJdbcService;
|
||||
this.avatarService = avatarService;
|
||||
this.messageSource = messageSource;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.dataManagementEnabled = dataManagementEnabled;
|
||||
this.i18n = i18nService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String magicLinksContent(@AuthenticationPrincipal User user, Model model) {
|
||||
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
|
||||
List<MagicLinkToken> tokens = magicLinkTokenService.getTokensForUser(user);
|
||||
model.addAttribute("tokens", tokens);
|
||||
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
|
||||
model.addAttribute("accessLevels", List.of(MagicLinkAccessLevel.FULL_ACCESS, MagicLinkAccessLevel.ONLY_LIVE, MagicLinkAccessLevel.ONLY_LIVE_WITH_PHOTOS));
|
||||
model.addAttribute("activeSection", "sharing");
|
||||
model.addAttribute("isAdmin", user.getRole() == Role.ADMIN);
|
||||
model.addAttribute("dataManagementEnabled", dataManagementEnabled);
|
||||
@@ -84,9 +82,6 @@ public class ShareAccessController {
|
||||
HttpServletRequest request,
|
||||
Model model) {
|
||||
try {
|
||||
String rawToken = UUID.randomUUID().toString();
|
||||
String tokenHash = passwordEncoder.encode(rawToken);
|
||||
|
||||
// Calculate expiry date
|
||||
Instant expiryInstant = null;
|
||||
if (expiryDate != null && !expiryDate.trim().isEmpty()) {
|
||||
@@ -94,8 +89,8 @@ public class ShareAccessController {
|
||||
LocalDate localDate = LocalDate.parse(expiryDate);
|
||||
expiryInstant = localDate.atStartOfDay(ZoneId.systemDefault()).toInstant();
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("errorMessage", getMessage("magic.links.invalid.date"));
|
||||
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
|
||||
model.addAttribute("errorMessage", i18n.translate("magic.links.invalid.date"));
|
||||
List<MagicLinkToken> tokens = magicLinkTokenService.getTokensForUser(user);
|
||||
model.addAttribute("tokens", tokens);
|
||||
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
|
||||
return "settings/share-access :: magic-links-content";
|
||||
@@ -103,27 +98,24 @@ public class ShareAccessController {
|
||||
}
|
||||
|
||||
// Create token object
|
||||
MagicLinkToken token = new MagicLinkToken(null, name, tokenHash, accessLevel, expiryInstant, null, null, false);
|
||||
magicLinkJdbcService.create(user, token);
|
||||
String rawToken = magicLinkTokenService.createMapShareToken(user, name, accessLevel, expiryInstant);
|
||||
|
||||
// Build the full magic link URL
|
||||
String baseUrl = getBaseUrl(request);
|
||||
String baseUrl = RequestHelper.getBaseUrl(request);
|
||||
String magicLinkUrl = baseUrl + "/access?mt=" + rawToken;
|
||||
|
||||
model.addAttribute("newTokenName", name);
|
||||
model.addAttribute("magicLinkUrl", magicLinkUrl);
|
||||
|
||||
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
|
||||
model.addAttribute("tokens", tokens);
|
||||
model.addAttribute("tokens", magicLinkTokenService.getTokensForUser(user));
|
||||
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
|
||||
|
||||
return "settings/share-access :: magic-links-content";
|
||||
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("errorMessage", getMessage("magic.links.create.error", e.getMessage()));
|
||||
|
||||
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
|
||||
model.addAttribute("tokens", tokens);
|
||||
model.addAttribute("errorMessage", i18n.translate("magic.links.create.error", e.getMessage()));
|
||||
|
||||
model.addAttribute("tokens", magicLinkTokenService.getTokensForUser(user));
|
||||
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
|
||||
|
||||
return "settings/share-access :: magic-links-content";
|
||||
@@ -135,36 +127,18 @@ public class ShareAccessController {
|
||||
@AuthenticationPrincipal User user,
|
||||
Model model) {
|
||||
try {
|
||||
magicLinkJdbcService.delete(id);
|
||||
model.addAttribute("successMessage", getMessage("magic.links.deleted.success"));
|
||||
magicLinkTokenService.deleteToken(id);
|
||||
model.addAttribute("successMessage", i18n.translate("magic.links.deleted.success"));
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("errorMessage", getMessage("magic.links.delete.error", e.getMessage()));
|
||||
model.addAttribute("errorMessage", i18n.translate("magic.links.delete.error", e.getMessage()));
|
||||
}
|
||||
|
||||
List<MagicLinkToken> tokens = magicLinkJdbcService.findByUser(user);
|
||||
model.addAttribute("tokens", tokens);
|
||||
model.addAttribute("tokens", magicLinkTokenService.getTokensForUser(user));
|
||||
model.addAttribute("accessLevels", MagicLinkAccessLevel.values());
|
||||
|
||||
return "settings/share-access :: magic-links-content";
|
||||
}
|
||||
|
||||
private String getBaseUrl(HttpServletRequest request) {
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
StringBuilder url = new StringBuilder();
|
||||
url.append(scheme).append("://").append(serverName);
|
||||
|
||||
if ((scheme.equals("http") && serverPort != 80) || (scheme.equals("https") && serverPort != 443)) {
|
||||
url.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
url.append(contextPath);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@PostMapping("/users")
|
||||
public String updateUserSharing(@AuthenticationPrincipal User user,
|
||||
@RequestParam(value = "sharedUserIds", required = false) List<Long> sharedUserIds,
|
||||
@@ -184,9 +158,9 @@ public class ShareAccessController {
|
||||
.collect(Collectors.toSet());
|
||||
this.userSharingJdbcService.delete(toDelete);
|
||||
this.userSharingJdbcService.create(user, toCreate);
|
||||
model.addAttribute("shareSuccessMessage", getMessage("share-with.updated.success"));
|
||||
model.addAttribute("shareSuccessMessage", i18n.translate("share-with.updated.success"));
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("shareErrorMessage", getMessage("share-with.update.error", e.getMessage()));
|
||||
model.addAttribute("shareErrorMessage", i18n.translate("share-with.update.error", e.getMessage()));
|
||||
}
|
||||
|
||||
List<UserDto> availableUsers = loadAvailableUsers(user);
|
||||
@@ -200,10 +174,6 @@ public class ShareAccessController {
|
||||
return "settings/share-access :: share-with-content";
|
||||
}
|
||||
|
||||
private String getMessage(String key, Object... args) {
|
||||
return messageSource.getMessage(key, args, LocaleContextHolder.getLocale());
|
||||
}
|
||||
|
||||
private List<UserDto> loadAvailableUsers(User user) {
|
||||
return userJdbcService.getAllUsers().stream()
|
||||
.filter(u -> !u.getId().equals(user.getId()))
|
||||
@@ -220,9 +190,9 @@ public class ShareAccessController {
|
||||
Model model) {
|
||||
try {
|
||||
userSharingJdbcService.dismissSharedAccess(id, user.getId());
|
||||
model.addAttribute("shareSuccessMessage", getMessage("shared-with-me.dismissed.success"));
|
||||
model.addAttribute("shareSuccessMessage", i18n.translate("shared-with-me.dismissed.success"));
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("shareErrorMessage", getMessage("shared-with-me.dismiss.error", e.getMessage()));
|
||||
model.addAttribute("shareErrorMessage", i18n.translate("shared-with-me.dismiss.error", e.getMessage()));
|
||||
}
|
||||
|
||||
// Reload data for the fragment
|
||||
|
||||
@@ -15,6 +15,7 @@ public record UserSettingsDTO(
|
||||
Double homeLongitude,
|
||||
TilesCustomizationDTO tiles,
|
||||
UIMode uiMode,
|
||||
PhotoMode photoMode,
|
||||
TimeDisplayMode displayMode,
|
||||
ZoneId timezoneOverride,
|
||||
String customCssUrl
|
||||
@@ -23,8 +24,13 @@ public record UserSettingsDTO(
|
||||
public enum UIMode {
|
||||
FULL,
|
||||
SHARED_FULL,
|
||||
VIEW_MEMORIES,
|
||||
SHARED_LIVE_MODE_ONLY
|
||||
}
|
||||
public enum PhotoMode {
|
||||
ENABLED,
|
||||
DISABLED
|
||||
}
|
||||
|
||||
public record TilesCustomizationDTO(String service, String attribution){}
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package com.dedicatedcode.reitti.model;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
public enum Role {
|
||||
ADMIN,
|
||||
USER,
|
||||
API_ACCESS
|
||||
API_ACCESS;
|
||||
|
||||
public GrantedAuthority asAuthority() {
|
||||
return new SimpleGrantedAuthority( "ROLE_" + name());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.dedicatedcode.reitti.model.geo;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public class ProcessedVisit {
|
||||
|
||||
@@ -67,4 +68,16 @@ public class ProcessedVisit {
|
||||
", version=" + version +
|
||||
'}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ProcessedVisit that = (ProcessedVisit) o;
|
||||
return Objects.equals(id, that.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ public class SignificantPlace implements Serializable {
|
||||
private final Long id;
|
||||
private final String name;
|
||||
private final String address;
|
||||
private final String city;
|
||||
private final String countryCode;
|
||||
private final Double latitudeCentroid;
|
||||
private final Double longitudeCentroid;
|
||||
@@ -18,12 +19,13 @@ public class SignificantPlace implements Serializable {
|
||||
private final Long version;
|
||||
|
||||
public static SignificantPlace create(Double latitude, Double longitude) {
|
||||
return new SignificantPlace(null, null, null, null, latitude, longitude, PlaceType.OTHER, ZoneId.systemDefault(), false, 1L);
|
||||
return new SignificantPlace(null, null, null, null,null, latitude, longitude, PlaceType.OTHER, ZoneId.systemDefault(), false, 1L);
|
||||
}
|
||||
|
||||
public SignificantPlace(Long id,
|
||||
String name,
|
||||
String address,
|
||||
String city,
|
||||
String countryCode,
|
||||
Double latitudeCentroid,
|
||||
Double longitudeCentroid,
|
||||
@@ -33,6 +35,7 @@ public class SignificantPlace implements Serializable {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.address = address;
|
||||
this.city = city;
|
||||
this.countryCode = countryCode;
|
||||
this.latitudeCentroid = latitudeCentroid;
|
||||
this.longitudeCentroid = longitudeCentroid;
|
||||
@@ -84,31 +87,35 @@ public class SignificantPlace implements Serializable {
|
||||
|
||||
// Wither methods
|
||||
public SignificantPlace withGeocoded(boolean geocoded) {
|
||||
return new SignificantPlace(this.id, this.name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, geocoded, this.version);
|
||||
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, geocoded, this.version);
|
||||
}
|
||||
|
||||
public SignificantPlace withName(String name) {
|
||||
return new SignificantPlace(this.id, name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
return new SignificantPlace(this.id, name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
}
|
||||
|
||||
public SignificantPlace withAddress(String address) {
|
||||
return new SignificantPlace(this.id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
}
|
||||
|
||||
public SignificantPlace withCountryCode(String countryCode) {
|
||||
return new SignificantPlace(this.id, this.name, this.address, countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
return new SignificantPlace(this.id, this.name, this.address, city, countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
}
|
||||
|
||||
public SignificantPlace withType(PlaceType type) {
|
||||
return new SignificantPlace(this.id, this.name, this.address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, type, timezone, this.geocoded, this.version);
|
||||
return new SignificantPlace(this.id, this.name, this.address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, type, timezone, this.geocoded, this.version);
|
||||
}
|
||||
|
||||
public SignificantPlace withId(Long id) {
|
||||
return new SignificantPlace(id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
return new SignificantPlace(id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
}
|
||||
|
||||
public SignificantPlace withTimezone(ZoneId timezone) {
|
||||
return new SignificantPlace(this.id, this.name, address, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
}
|
||||
|
||||
public SignificantPlace withCity(String city) {
|
||||
return new SignificantPlace(this.id, this.name, address, city, this.countryCode, this.latitudeCentroid, this.longitudeCentroid, this.type, timezone, this.geocoded, this.version);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -131,6 +138,9 @@ public class SignificantPlace implements Serializable {
|
||||
'}';
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return this.city;
|
||||
}
|
||||
|
||||
public enum PlaceType {
|
||||
RESTAURANT("lni-restaurant", "place.type.restaurant"),
|
||||
@@ -151,6 +161,17 @@ public class SignificantPlace implements Serializable {
|
||||
CHURCH("lni-church", "place.type.church"),
|
||||
CINEMA("lni-camera", "place.type.cinema"),
|
||||
CAFE("lni-coffee-cup", "place.type.cafe"),
|
||||
MUSEUM("lni-museum", "place.type.museum"),
|
||||
LANDMARK("lni-landmark", "place.type.landmark"),
|
||||
TOURIST_ATTRACTION("lni-map", "place.type.tourist_attraction"),
|
||||
HISTORIC_SITE("lni-history", "place.type.historic_site"),
|
||||
MONUMENT("lni-monument", "place.type.monument"),
|
||||
SHOPPING_MALL("lni-shopping-basket", "place.type.shopping_mall"),
|
||||
MARKET("lni-store", "place.type.market"),
|
||||
GALLERY("lni-gallery", "place.type.gallery"),
|
||||
THEATER("lni-theater", "place.type.theater"),
|
||||
GROCERY_STORE("lni-cart", "place.type.grocery_store"),
|
||||
ATM("lni-money-location", "place.type.atm"),
|
||||
OTHER("lni-map-marker", "place.type.other");
|
||||
|
||||
private final String iconClass;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.dedicatedcode.reitti.model.geo;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public class Trip {
|
||||
|
||||
@@ -75,4 +76,16 @@ public class Trip {
|
||||
public Trip withId(Long id) {
|
||||
return new Trip(id, this.startTime, this.endTime, this.durationSeconds, this.estimatedDistanceMeters, this.travelledDistanceMeters, this.transportModeInferred, this.startVisit, this.endVisit, this.version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
Trip trip = (Trip) o;
|
||||
return Objects.equals(id, trip.id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hashCode(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
public enum BlockType {
|
||||
TEXT,
|
||||
IMAGE_GALLERY,
|
||||
CLUSTER_TRIP,
|
||||
CLUSTER_VISIT,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
public enum HeaderType {
|
||||
IMAGE,
|
||||
MAP
|
||||
}
|
||||
139
src/main/java/com/dedicatedcode/reitti/model/memory/Memory.java
Normal file
139
src/main/java/com/dedicatedcode/reitti/model/memory/Memory.java
Normal file
@@ -0,0 +1,139 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public class Memory implements Serializable {
|
||||
|
||||
private final Long id;
|
||||
private final String title;
|
||||
private final String description;
|
||||
private final Instant startDate;
|
||||
private final Instant endDate;
|
||||
private final HeaderType headerType;
|
||||
private final String headerImageUrl;
|
||||
private final Instant createdAt;
|
||||
private final Instant updatedAt;
|
||||
private final Long version;
|
||||
|
||||
public Memory(String title, String description, Instant startDate, Instant endDate, HeaderType headerType, String headerImageUrl) {
|
||||
this(null, title, description, startDate, endDate, headerType, headerImageUrl, Instant.now(), Instant.now(), 1L);
|
||||
}
|
||||
|
||||
public Memory(Long id, String title, String description, Instant startDate, Instant endDate, HeaderType headerType, String headerImageUrl, Instant createdAt, Instant updatedAt, Long version) {
|
||||
this.id = id;
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
this.headerType = headerType;
|
||||
this.headerImageUrl = headerImageUrl;
|
||||
this.createdAt = createdAt;
|
||||
this.updatedAt = updatedAt;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public Instant getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public Instant getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public HeaderType getHeaderType() {
|
||||
return headerType;
|
||||
}
|
||||
|
||||
public String getHeaderImageUrl() {
|
||||
return headerImageUrl;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public Long getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public Memory withId(Long id) {
|
||||
return new Memory(id, this.title, this.description, this.startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, this.updatedAt, this.version);
|
||||
}
|
||||
|
||||
public Memory withTitle(String title) {
|
||||
return new Memory(this.id, title, this.description, this.startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
|
||||
}
|
||||
|
||||
public Memory withDescription(String description) {
|
||||
return new Memory(this.id, this.title, description, this.startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
|
||||
}
|
||||
|
||||
public Memory withStartDate(Instant startDate) {
|
||||
return new Memory(this.id, this.title, this.description, startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
|
||||
}
|
||||
|
||||
public Memory withEndDate(Instant endDate) {
|
||||
return new Memory(this.id, this.title, this.description, this.startDate, endDate, this.headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
|
||||
}
|
||||
|
||||
public Memory withHeaderType(HeaderType headerType) {
|
||||
return new Memory(this.id, this.title, this.description, this.startDate, this.endDate, headerType, this.headerImageUrl, this.createdAt, Instant.now(), this.version);
|
||||
}
|
||||
|
||||
public Memory withHeaderImageUrl(String headerImageUrl) {
|
||||
return new Memory(this.id, this.title, this.description, this.startDate, this.endDate, this.headerType, headerImageUrl, this.createdAt, Instant.now(), this.version);
|
||||
}
|
||||
|
||||
public Memory withVersion(Long version) {
|
||||
return new Memory(this.id, this.title, this.description, this.startDate, this.endDate, this.headerType, this.headerImageUrl, this.createdAt, this.updatedAt, version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
Memory memory = (Memory) o;
|
||||
|
||||
return id != null ? id.equals(memory.id) : memory.id == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return id != null ? id.hashCode() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Memory{" +
|
||||
"id=" + id +
|
||||
", title='" + title + '\'' +
|
||||
", description='" + description + '\'' +
|
||||
", startDate=" + startDate +
|
||||
", endDate=" + endDate +
|
||||
", headerType=" + headerType +
|
||||
", headerImageUrl='" + headerImageUrl + '\'' +
|
||||
", createdAt=" + createdAt +
|
||||
", updatedAt=" + updatedAt +
|
||||
", version=" + version +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public class MemoryBlock implements Serializable {
|
||||
|
||||
private final Long id;
|
||||
private final Long memoryId;
|
||||
private final BlockType blockType;
|
||||
private final Integer position;
|
||||
private final Long version;
|
||||
|
||||
public MemoryBlock(Long memoryId, BlockType blockType, Integer position) {
|
||||
this(null, memoryId, blockType, position, 1L);
|
||||
}
|
||||
|
||||
public MemoryBlock(Long id, Long memoryId, BlockType blockType, Integer position, Long version) {
|
||||
this.id = id;
|
||||
this.memoryId = memoryId;
|
||||
this.blockType = blockType;
|
||||
this.position = position;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getMemoryId() {
|
||||
return memoryId;
|
||||
}
|
||||
|
||||
public BlockType getBlockType() {
|
||||
return blockType;
|
||||
}
|
||||
|
||||
public Integer getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public Long getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public MemoryBlock withId(Long id) {
|
||||
return new MemoryBlock(id, this.memoryId, this.blockType, this.position, this.version);
|
||||
}
|
||||
|
||||
public MemoryBlock withPosition(Integer position) {
|
||||
return new MemoryBlock(this.id, this.memoryId, this.blockType, position, this.version);
|
||||
}
|
||||
|
||||
public MemoryBlock withVersion(Long version) {
|
||||
return new MemoryBlock(this.id, this.memoryId, this.blockType, this.position, version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
MemoryBlock that = (MemoryBlock) o;
|
||||
|
||||
return id != null ? id.equals(that.id) : that.id == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return id != null ? id.hashCode() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MemoryBlock{" +
|
||||
"id=" + id +
|
||||
", memoryId=" + memoryId +
|
||||
", blockType=" + blockType +
|
||||
", position=" + position +
|
||||
", version=" + version +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MemoryBlockImageGallery implements MemoryBlockPart, Serializable {
|
||||
|
||||
private final Long blockId;
|
||||
private final List<GalleryImage> images;
|
||||
|
||||
public MemoryBlockImageGallery(Long blockId, List<GalleryImage> images) {
|
||||
this.blockId = blockId;
|
||||
this.images = images != null ? List.copyOf(images) : List.of();
|
||||
}
|
||||
|
||||
public Long getBlockId() {
|
||||
return blockId;
|
||||
}
|
||||
|
||||
public List<GalleryImage> getImages() {
|
||||
return images;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockType getType() {
|
||||
return BlockType.IMAGE_GALLERY;
|
||||
}
|
||||
|
||||
public MemoryBlockImageGallery withImages(List<GalleryImage> images) {
|
||||
return new MemoryBlockImageGallery(this.blockId, images);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
MemoryBlockImageGallery that = (MemoryBlockImageGallery) o;
|
||||
|
||||
return Objects.equals(blockId, that.blockId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return blockId != null ? blockId.hashCode() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MemoryBlockImageGallery{" +
|
||||
"blockId=" + blockId +
|
||||
", images=" + images +
|
||||
'}';
|
||||
}
|
||||
|
||||
public static class GalleryImage implements Serializable {
|
||||
private final String imageUrl;
|
||||
private final String integration;
|
||||
private final String integrationId;
|
||||
private final String caption;
|
||||
|
||||
public GalleryImage(String imageUrl, String caption, String integration, String integrationId) {
|
||||
this.imageUrl = imageUrl;
|
||||
this.caption = caption;
|
||||
this.integration = integration;
|
||||
this.integrationId = integrationId;
|
||||
}
|
||||
|
||||
public String getImageUrl() {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
public String getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
public String getIntegration() {
|
||||
return integration;
|
||||
}
|
||||
|
||||
public String getIntegrationId() {
|
||||
return integrationId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
GalleryImage that = (GalleryImage) o;
|
||||
return Objects.equals(imageUrl, that.imageUrl) && Objects.equals(caption, that.caption);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(imageUrl, caption);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "GalleryImage{" +
|
||||
"imageUrl='" + imageUrl + '\'' +
|
||||
", caption='" + caption + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
public interface MemoryBlockPart {
|
||||
BlockType getType();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public class MemoryBlockText implements MemoryBlockPart, Serializable {
|
||||
|
||||
private final Long blockId;
|
||||
private final String headline;
|
||||
private final String content;
|
||||
|
||||
public MemoryBlockText(Long blockId, String headline, String content) {
|
||||
this.blockId = blockId;
|
||||
this.headline = headline;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Long getBlockId() {
|
||||
return blockId;
|
||||
}
|
||||
|
||||
public String getHeadline() {
|
||||
return headline;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockType getType() {
|
||||
return BlockType.TEXT;
|
||||
}
|
||||
|
||||
public MemoryBlockText withHeadline(String headline) {
|
||||
return new MemoryBlockText(this.blockId, headline, this.content);
|
||||
}
|
||||
|
||||
public MemoryBlockText withContent(String content) {
|
||||
return new MemoryBlockText(this.blockId, this.headline, content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
MemoryBlockText that = (MemoryBlockText) o;
|
||||
|
||||
return blockId != null ? blockId.equals(that.blockId) : that.blockId == null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return blockId != null ? blockId.hashCode() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MemoryBlockText{" +
|
||||
"blockId=" + blockId +
|
||||
", headline='" + headline + '\'' +
|
||||
", content='" + content + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MemoryClusterBlock implements MemoryBlockPart, Serializable {
|
||||
|
||||
private final Long blockId;
|
||||
private final List<Long> partIds;
|
||||
private final String title;
|
||||
private final String description;
|
||||
private final BlockType type;
|
||||
|
||||
public MemoryClusterBlock(Long blockId, List<Long> partIds, String title, String description, BlockType type) {
|
||||
this.blockId = blockId;
|
||||
this.partIds = partIds != null ? List.copyOf(partIds) : List.of();
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Long getBlockId() {
|
||||
return blockId;
|
||||
}
|
||||
|
||||
public List<Long> getPartIds() {
|
||||
return partIds;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockType getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
public MemoryClusterBlock withPartIds(List<Long> partIds) {
|
||||
return new MemoryClusterBlock(this.blockId, partIds, this.title, this.description, this.type);
|
||||
}
|
||||
|
||||
public MemoryClusterBlock withTitle(String title) {
|
||||
return new MemoryClusterBlock(this.blockId, this.partIds, title, this.description, this.type);
|
||||
}
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
MemoryClusterBlock that = (MemoryClusterBlock) o;
|
||||
return Objects.equals(blockId, that.blockId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return blockId != null ? blockId.hashCode() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MemoryClusterBlock{" +
|
||||
"blockId=" + blockId +
|
||||
", tripIds=" + partIds +
|
||||
", title='" + title + '\'' +
|
||||
", description='" + description + '\'' +
|
||||
'}';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
public class MemoryOverviewDTO {
|
||||
private final Memory memory;
|
||||
private final String rawLocationUrl;
|
||||
|
||||
public MemoryOverviewDTO(Memory memory, String rawLocationUrl) {
|
||||
this.memory = memory;
|
||||
this.rawLocationUrl = rawLocationUrl;
|
||||
}
|
||||
|
||||
public Memory getMemory() {
|
||||
return memory;
|
||||
}
|
||||
|
||||
public String getRawLocationUrl() {
|
||||
return rawLocationUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
import com.dedicatedcode.reitti.model.geo.Trip;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MemoryTripClusterBlockDTO implements MemoryBlockPart, Serializable {
|
||||
|
||||
private final MemoryClusterBlock clusterBlock;
|
||||
private final List<Trip> trips;
|
||||
private final String rawLocationPointsUrl;
|
||||
private final LocalDateTime adjustedStartTime;
|
||||
private final LocalDateTime adjustedEndTime;
|
||||
private final Long completeDuration;
|
||||
private final Long movingDuration;
|
||||
|
||||
public MemoryTripClusterBlockDTO(MemoryClusterBlock clusterBlock, List<Trip> trips, String rawLocationPointsUrl, LocalDateTime adjustedStartTime, LocalDateTime adjustedEndTime, Long completeDuration, Long movingDuration) {
|
||||
this.clusterBlock = clusterBlock;
|
||||
this.trips = trips != null ? List.copyOf(trips) : List.of();
|
||||
this.rawLocationPointsUrl = rawLocationPointsUrl;
|
||||
this.adjustedStartTime = adjustedStartTime;
|
||||
this.adjustedEndTime = adjustedEndTime;
|
||||
this.completeDuration = completeDuration;
|
||||
this.movingDuration = movingDuration;
|
||||
}
|
||||
|
||||
public MemoryClusterBlock getClusterBlock() {
|
||||
return clusterBlock;
|
||||
}
|
||||
|
||||
public List<Trip> getTrips() {
|
||||
return trips;
|
||||
}
|
||||
|
||||
// Delegate to clusterBlock for common methods
|
||||
public Long getBlockId() {
|
||||
return clusterBlock.getBlockId();
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return clusterBlock.getTitle();
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return clusterBlock.getDescription();
|
||||
}
|
||||
|
||||
public Long getCompleteDuration() {
|
||||
return completeDuration;
|
||||
}
|
||||
|
||||
public Long getMovingDuration() {
|
||||
return movingDuration;
|
||||
}
|
||||
|
||||
// Combined info methods
|
||||
public Instant getCombinedStartTime() {
|
||||
if (trips == null || trips.isEmpty()) return null;
|
||||
return trips.stream()
|
||||
.map(Trip::getStartTime)
|
||||
.min(Instant::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Instant getCombinedEndTime() {
|
||||
if (trips == null || trips.isEmpty()) return null;
|
||||
return trips.stream()
|
||||
.map(Trip::getEndTime)
|
||||
.max(Instant::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Long getCombinedDurationSeconds() {
|
||||
if (trips == null || trips.isEmpty()) return 0L;
|
||||
return trips.stream()
|
||||
.mapToLong(Trip::getDurationSeconds)
|
||||
.sum();
|
||||
}
|
||||
|
||||
public List<String> getCombinedStartPlaces() {
|
||||
if (trips == null || trips.isEmpty()) return List.of();
|
||||
return trips.stream()
|
||||
.map(trip -> trip.getStartVisit() != null && trip.getStartVisit().getPlace() != null ? trip.getStartVisit().getPlace().getName() : null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<String> getCombinedEndPlaces() {
|
||||
if (trips == null || trips.isEmpty()) return List.of();
|
||||
return trips.stream()
|
||||
.map(trip -> trip.getEndVisit() != null && trip.getEndVisit().getPlace() != null ? trip.getEndVisit().getPlace().getName() : null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public String getRawLocationPointsUrl() {
|
||||
return rawLocationPointsUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getAdjustedEndTime() {
|
||||
return adjustedEndTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getAdjustedStartTime() {
|
||||
return adjustedStartTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockType getType() {
|
||||
return BlockType.CLUSTER_TRIP;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
MemoryTripClusterBlockDTO that = (MemoryTripClusterBlockDTO) o;
|
||||
return Objects.equals(clusterBlock, that.clusterBlock);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return clusterBlock != null ? clusterBlock.hashCode() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MemoryClusterBlockDTO{" +
|
||||
"clusterBlock=" + clusterBlock +
|
||||
", trips=" + trips +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.dedicatedcode.reitti.model.memory;
|
||||
|
||||
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
|
||||
import com.dedicatedcode.reitti.model.geo.Visit;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MemoryVisitClusterBlockDTO implements MemoryBlockPart, Serializable {
|
||||
|
||||
private final MemoryClusterBlock clusterBlock;
|
||||
private final List<ProcessedVisit> visits;
|
||||
private final String rawLocationPointsUrl;
|
||||
private final LocalDateTime adjustedStartTime;
|
||||
private final LocalDateTime adjustedEndTime;
|
||||
private final Long completeDuration;
|
||||
|
||||
public MemoryVisitClusterBlockDTO(MemoryClusterBlock clusterBlock, List<ProcessedVisit> visits, String rawLocationPointsUrl, LocalDateTime adjustedStartTime, LocalDateTime adjustedEndTime, Long completeDuration) {
|
||||
this.clusterBlock = clusterBlock;
|
||||
this.visits = visits != null ? List.copyOf(visits) : List.of();
|
||||
this.rawLocationPointsUrl = rawLocationPointsUrl;
|
||||
this.adjustedStartTime = adjustedStartTime;
|
||||
this.adjustedEndTime = adjustedEndTime;
|
||||
this.completeDuration = completeDuration;
|
||||
}
|
||||
|
||||
public MemoryClusterBlock getClusterBlock() {
|
||||
return clusterBlock;
|
||||
}
|
||||
|
||||
public List<ProcessedVisit> getVisits() {
|
||||
return visits;
|
||||
}
|
||||
|
||||
// Delegate to clusterBlock for common methods
|
||||
public Long getBlockId() {
|
||||
return clusterBlock.getBlockId();
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return clusterBlock.getTitle();
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return clusterBlock.getDescription();
|
||||
}
|
||||
|
||||
public Long getCompleteDuration() {
|
||||
return completeDuration;
|
||||
}
|
||||
|
||||
// Combined info methods
|
||||
public Instant getCombinedStartTime() {
|
||||
if (visits == null || visits.isEmpty()) return null;
|
||||
return visits.stream()
|
||||
.map(ProcessedVisit::getStartTime)
|
||||
.min(Instant::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Instant getCombinedEndTime() {
|
||||
if (visits == null || visits.isEmpty()) return null;
|
||||
return visits.stream()
|
||||
.map(ProcessedVisit::getEndTime)
|
||||
.max(Instant::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Long getCombinedDurationSeconds() {
|
||||
if (visits == null || visits.isEmpty()) return 0L;
|
||||
return visits.stream()
|
||||
.mapToLong(ProcessedVisit::getDurationSeconds)
|
||||
.sum();
|
||||
}
|
||||
|
||||
public String getRawLocationPointsUrl() {
|
||||
return rawLocationPointsUrl;
|
||||
}
|
||||
|
||||
public LocalDateTime getAdjustedEndTime() {
|
||||
return adjustedEndTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getAdjustedStartTime() {
|
||||
return adjustedStartTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockType getType() {
|
||||
return BlockType.CLUSTER_VISIT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
MemoryVisitClusterBlockDTO that = (MemoryVisitClusterBlockDTO) o;
|
||||
return Objects.equals(clusterBlock, that.clusterBlock);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return clusterBlock != null ? clusterBlock.hashCode() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MemoryClusterBlockDTO{" +
|
||||
"clusterBlock=" + clusterBlock +
|
||||
", Visits=" + visits +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
package com.dedicatedcode.reitti.model.security;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
public enum MagicLinkAccessLevel {
|
||||
FULL_ACCESS,
|
||||
ONLY_LIVE,
|
||||
ONLY_LIVE_WITH_PHOTOS
|
||||
ONLY_LIVE_WITH_PHOTOS,
|
||||
MEMORY_VIEW_ONLY,
|
||||
MEMORY_EDIT_ACCESS;
|
||||
|
||||
|
||||
public GrantedAuthority asAuthority() {
|
||||
return new SimpleGrantedAuthority( "ROLE_MAGIC_LINK_" + name());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.dedicatedcode.reitti.model.security;
|
||||
|
||||
public enum MagicLinkResourceType {
|
||||
MAP,
|
||||
MEMORY
|
||||
}
|
||||
@@ -11,14 +11,21 @@ public class MagicLinkToken implements Serializable {
|
||||
private final Instant expiryDate;
|
||||
private final Instant createdAt;
|
||||
private final Instant lastUsed;
|
||||
private final MagicLinkResourceType resourceType;
|
||||
private final Long resourceId;
|
||||
private final boolean isUsed; // To enforce one-time use if needed
|
||||
|
||||
public MagicLinkToken(Long id, String name, String tokenHash, MagicLinkAccessLevel accessLevel, Instant expiryDate, Instant createdAt, Instant lastUsed, boolean isUsed) {
|
||||
this(id, name, tokenHash, accessLevel, expiryDate, MagicLinkResourceType.MAP, null, createdAt, lastUsed, isUsed);
|
||||
}
|
||||
public MagicLinkToken(Long id, String name, String tokenHash, MagicLinkAccessLevel accessLevel, Instant expiryDate, MagicLinkResourceType resourceType, Long resourceId, Instant createdAt, Instant lastUsed, boolean isUsed) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.tokenHash = tokenHash;
|
||||
this.accessLevel = accessLevel;
|
||||
this.expiryDate = expiryDate;
|
||||
this.resourceType = resourceType;
|
||||
this.resourceId = resourceId;
|
||||
this.createdAt = createdAt;
|
||||
this.lastUsed = lastUsed;
|
||||
this.isUsed = isUsed;
|
||||
@@ -55,4 +62,12 @@ public class MagicLinkToken implements Serializable {
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public MagicLinkResourceType getResourceType() {
|
||||
return resourceType;
|
||||
}
|
||||
|
||||
public Long getResourceId() {
|
||||
return resourceId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.dedicatedcode.reitti.model.security;
|
||||
|
||||
import com.dedicatedcode.reitti.model.Role;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public class TokenUser extends User {
|
||||
private final List<GrantedAuthority> authorities = new ArrayList<>();
|
||||
private final User user;
|
||||
private final MagicLinkResourceType type;
|
||||
private final Long resourceId;
|
||||
|
||||
public TokenUser(User user, MagicLinkResourceType type, Long resourceId, List<String> additionalAuthorities) {
|
||||
this.user = user;
|
||||
this.type = type;
|
||||
this.resourceId = resourceId;
|
||||
this.authorities.addAll(additionalAuthorities.stream().map(SimpleGrantedAuthority::new).toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return authorities;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getId() {
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return user.getUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return user.getDisplayName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
throw new UnsupportedOperationException("TokenUser is not a password user");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Role getRole() {
|
||||
return Role.API_ACCESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProfileUrl() {
|
||||
return user.getProfileUrl();
|
||||
}
|
||||
|
||||
public boolean grantsAccessTo(MagicLinkResourceType type, Long resourceId){
|
||||
return this.type.equals(type) && (this.resourceId == null || this.resourceId.equals(resourceId));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -36,8 +37,8 @@ public class MagicLinkJdbcService {
|
||||
@CacheEvict(value = "magic-links", allEntries = true)
|
||||
public MagicLinkToken create(User user, MagicLinkToken token) {
|
||||
String sql = """
|
||||
INSERT INTO magic_link_tokens (user_id, name, token_hash, access_level, expiry_date, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO magic_link_tokens (user_id, name, token_hash, access_level, expiry_date, created_at, resource_type, resource_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""";
|
||||
|
||||
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||
@@ -51,11 +52,17 @@ public class MagicLinkJdbcService {
|
||||
ps.setString(4, token.getAccessLevel().name());
|
||||
ps.setTimestamp(5, token.getExpiryDate() != null ? Timestamp.from(token.getExpiryDate()) : null);
|
||||
ps.setTimestamp(6, Timestamp.from(now));
|
||||
ps.setString(7, token.getResourceType().name());
|
||||
if (token.getResourceId() == null) {
|
||||
ps.setNull(8, java.sql.Types.BIGINT);
|
||||
} else {
|
||||
ps.setLong(8, token.getResourceId());
|
||||
}
|
||||
return ps;
|
||||
}, keyHolder);
|
||||
|
||||
long id = keyHolder.getKey().longValue();
|
||||
return new MagicLinkToken(id, token.getName(), token.getTokenHash(), token.getAccessLevel(), token.getExpiryDate(), now, null, false);
|
||||
return new MagicLinkToken(id, token.getName(), token.getTokenHash(), token.getAccessLevel(), token.getExpiryDate(), token.getResourceType(), token.getResourceId(), now, null, false);
|
||||
}
|
||||
|
||||
@CacheEvict(value = "magic-links", allEntries = true)
|
||||
@@ -83,7 +90,7 @@ public class MagicLinkJdbcService {
|
||||
@Cacheable(value = "magic-links", key = "#id")
|
||||
public Optional<MagicLinkToken> findById(long id) {
|
||||
String sql = """
|
||||
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at
|
||||
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at, resource_type, resource_id
|
||||
FROM magic_link_tokens
|
||||
WHERE id = ?
|
||||
""";
|
||||
@@ -96,7 +103,7 @@ public class MagicLinkJdbcService {
|
||||
@Cacheable(value = "magic-links", key = "'hash:' + #tokenHash")
|
||||
public Optional<MagicLinkToken> findByTokenHash(String tokenHash) {
|
||||
String sql = """
|
||||
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at
|
||||
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at, resource_type, resource_id
|
||||
FROM magic_link_tokens
|
||||
WHERE token_hash = ?
|
||||
""";
|
||||
@@ -108,7 +115,7 @@ public class MagicLinkJdbcService {
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<MagicLinkToken> findByRawToken(String rawToken) {
|
||||
String sql = """
|
||||
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at
|
||||
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at, resource_type, resource_id
|
||||
FROM magic_link_tokens
|
||||
WHERE expiry_date IS NULL OR expiry_date > ?
|
||||
""";
|
||||
@@ -147,7 +154,7 @@ public class MagicLinkJdbcService {
|
||||
@Cacheable(value = "magic-links", key = "'user:' + #user.id")
|
||||
public List<MagicLinkToken> findByUser(User user) {
|
||||
String sql = """
|
||||
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at
|
||||
SELECT id, name, token_hash, access_level, expiry_date, created_at, last_used_at, resource_type, resource_id
|
||||
FROM magic_link_tokens
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
@@ -178,7 +185,9 @@ public class MagicLinkJdbcService {
|
||||
|
||||
boolean isUsed = lastUsed != null;
|
||||
|
||||
return new MagicLinkToken(id, name, tokenHash, accessLevel, expiryDate, createdAt, lastUsed, isUsed);
|
||||
MagicLinkResourceType resourceTyp = MagicLinkResourceType.valueOf(rs.getString("resource_type"));
|
||||
Long resourceId = rs.getLong("resource_id") == 0 ? null : rs.getLong("resource_id");
|
||||
return new MagicLinkToken(id, name, tokenHash, accessLevel, expiryDate, resourceTyp, resourceId, createdAt, lastUsed, isUsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.memory.MemoryBlockImageGallery;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class MemoryBlockImageGalleryJdbcService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MemoryBlockImageGalleryJdbcService(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
private final RowMapper<MemoryBlockImageGallery> MEMORY_BLOCK_IMAGE_GALLERY_ROW_MAPPER = new RowMapper<>() {
|
||||
@Override
|
||||
public MemoryBlockImageGallery mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||
Long blockId = rs.getLong("block_id");
|
||||
String imagesJson = rs.getString("images");
|
||||
List<MemoryBlockImageGallery.GalleryImage> images = null;
|
||||
try {
|
||||
images = objectMapper.readValue(imagesJson, new TypeReference<List<MemoryBlockImageGallery.GalleryImage>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new SQLException("Failed to parse images JSON", e);
|
||||
}
|
||||
return new MemoryBlockImageGallery(blockId, images);
|
||||
}
|
||||
};
|
||||
|
||||
public MemoryBlockImageGallery create(MemoryBlockImageGallery gallery) {
|
||||
try {
|
||||
String imagesJson = objectMapper.writeValueAsString(gallery.getImages());
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO memory_block_image_gallery (block_id, images) VALUES (?, ?::jsonb)",
|
||||
gallery.getBlockId(),
|
||||
imagesJson
|
||||
);
|
||||
return gallery;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to create MemoryBlockImageGallery", e);
|
||||
}
|
||||
}
|
||||
|
||||
public MemoryBlockImageGallery update(MemoryBlockImageGallery gallery) {
|
||||
try {
|
||||
String imagesJson = objectMapper.writeValueAsString(gallery.getImages());
|
||||
jdbcTemplate.update(
|
||||
"UPDATE memory_block_image_gallery SET images = ?::jsonb WHERE block_id = ?",
|
||||
imagesJson,
|
||||
gallery.getBlockId()
|
||||
);
|
||||
return gallery;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to update MemoryBlockImageGallery", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void delete(Long blockId) {
|
||||
jdbcTemplate.update("DELETE FROM memory_block_image_gallery WHERE block_id = ?", blockId);
|
||||
}
|
||||
|
||||
public void deleteByBlockId(Long blockId) {
|
||||
jdbcTemplate.update("DELETE FROM memory_block_image_gallery WHERE block_id = ?", blockId);
|
||||
}
|
||||
|
||||
public Optional<MemoryBlockImageGallery> findById(Long blockId) {
|
||||
List<MemoryBlockImageGallery> results = jdbcTemplate.query(
|
||||
"SELECT * FROM memory_block_image_gallery WHERE block_id = ?",
|
||||
MEMORY_BLOCK_IMAGE_GALLERY_ROW_MAPPER,
|
||||
blockId
|
||||
);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
public Optional<MemoryBlockImageGallery> findByBlockId(Long blockId) {
|
||||
List<MemoryBlockImageGallery> results = jdbcTemplate.query(
|
||||
"SELECT * FROM memory_block_image_gallery WHERE block_id = ?",
|
||||
MEMORY_BLOCK_IMAGE_GALLERY_ROW_MAPPER,
|
||||
blockId
|
||||
);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.memory.BlockType;
|
||||
import com.dedicatedcode.reitti.model.memory.MemoryBlock;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class MemoryBlockJdbcService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public MemoryBlockJdbcService(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
private static final RowMapper<MemoryBlock> MEMORY_BLOCK_ROW_MAPPER = (rs, rowNum) -> new MemoryBlock(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("memory_id"),
|
||||
BlockType.valueOf(rs.getString("block_type")),
|
||||
rs.getInt("position"),
|
||||
rs.getLong("version")
|
||||
);
|
||||
|
||||
public MemoryBlock create(MemoryBlock block) {
|
||||
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement ps = connection.prepareStatement(
|
||||
"INSERT INTO memory_block (memory_id, block_type, position, version) " +
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
Statement.RETURN_GENERATED_KEYS
|
||||
);
|
||||
ps.setLong(1, block.getMemoryId());
|
||||
ps.setString(2, block.getBlockType().name());
|
||||
ps.setInt(3, block.getPosition());
|
||||
ps.setLong(4, block.getVersion());
|
||||
return ps;
|
||||
}, keyHolder);
|
||||
|
||||
Long id = (Long) keyHolder.getKeys().get("id");
|
||||
return block.withId(id);
|
||||
}
|
||||
|
||||
public MemoryBlock update(MemoryBlock block) {
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE memory_block " +
|
||||
"SET position = ?, version = version + 1 " +
|
||||
"WHERE id = ? AND version = ?",
|
||||
block.getPosition(),
|
||||
block.getId(),
|
||||
block.getVersion()
|
||||
);
|
||||
|
||||
if (updated == 0) {
|
||||
throw new IllegalStateException("Memory block not found or version mismatch");
|
||||
}
|
||||
|
||||
return block.withVersion(block.getVersion() + 1);
|
||||
}
|
||||
|
||||
public void delete(Long blockId) {
|
||||
jdbcTemplate.update("DELETE FROM memory_block WHERE id = ?", blockId);
|
||||
}
|
||||
|
||||
public Optional<MemoryBlock> findById(User user, Long id) {
|
||||
List<MemoryBlock> results = jdbcTemplate.query(
|
||||
"SELECT * FROM memory_block LEFT JOIN memory ON memory_block.memory_id = memory.id WHERE memory_block.id = ? AND memory.user_id = ?",
|
||||
MEMORY_BLOCK_ROW_MAPPER,
|
||||
id,
|
||||
user.getId()
|
||||
);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
public List<MemoryBlock> findByMemoryId(Long memoryId) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT * FROM memory_block WHERE memory_id = ? ORDER BY position",
|
||||
MEMORY_BLOCK_ROW_MAPPER,
|
||||
memoryId
|
||||
);
|
||||
}
|
||||
|
||||
public int getMaxPosition(Long memoryId) {
|
||||
Integer maxPosition = jdbcTemplate.queryForObject(
|
||||
"SELECT COALESCE(MAX(position), -1) FROM memory_block WHERE memory_id = ?",
|
||||
Integer.class,
|
||||
memoryId
|
||||
);
|
||||
return maxPosition != null ? maxPosition : -1;
|
||||
}
|
||||
|
||||
|
||||
public void deleteByMemoryId(Long memoryId) {
|
||||
this.jdbcTemplate.update("DELETE FROM memory_block WHERE memory_id = ?", memoryId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.memory.MemoryBlockText;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class MemoryBlockTextJdbcService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public MemoryBlockTextJdbcService(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
private static final RowMapper<MemoryBlockText> MEMORY_BLOCK_TEXT_ROW_MAPPER = (rs, rowNum) -> new MemoryBlockText(
|
||||
rs.getLong("block_id"),
|
||||
rs.getString("headline"),
|
||||
rs.getString("content")
|
||||
);
|
||||
|
||||
public MemoryBlockText create(MemoryBlockText blockText) {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO memory_block_text (block_id, headline, content) VALUES (?, ?, ?)",
|
||||
blockText.getBlockId(),
|
||||
blockText.getHeadline(),
|
||||
blockText.getContent()
|
||||
);
|
||||
return blockText;
|
||||
}
|
||||
|
||||
public MemoryBlockText update(MemoryBlockText blockText) {
|
||||
jdbcTemplate.update(
|
||||
"UPDATE memory_block_text SET headline = ?, content = ? WHERE block_id = ?",
|
||||
blockText.getHeadline(),
|
||||
blockText.getContent(),
|
||||
blockText.getBlockId()
|
||||
);
|
||||
return blockText;
|
||||
}
|
||||
|
||||
public Optional<MemoryBlockText> findByBlockId(Long blockId) {
|
||||
List<MemoryBlockText> results = jdbcTemplate.query(
|
||||
"SELECT * FROM memory_block_text WHERE block_id = ?",
|
||||
MEMORY_BLOCK_TEXT_ROW_MAPPER,
|
||||
blockId
|
||||
);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
public void delete(Long blockId) {
|
||||
jdbcTemplate.update("DELETE FROM memory_block_text WHERE block_id = ?", blockId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.memory.BlockType;
|
||||
import com.dedicatedcode.reitti.model.memory.MemoryBlockText;
|
||||
import com.dedicatedcode.reitti.model.memory.MemoryClusterBlock;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class MemoryClusterBlockRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MemoryClusterBlockRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public void save(User user, MemoryClusterBlock cluster) {
|
||||
String sql = "INSERT INTO memory_block_cluster (block_id, part_ids, user_id, title, description, type) VALUES (?, ?::jsonb, ?, ?, ?, ?) " +
|
||||
"ON CONFLICT (block_id) DO UPDATE SET part_ids = EXCLUDED.part_ids, title = EXCLUDED.title, description = EXCLUDED.description, type = EXCLUDED.type";
|
||||
try {
|
||||
String tripIdsJson = objectMapper.writeValueAsString(cluster.getPartIds());
|
||||
jdbcTemplate.update(sql, cluster.getBlockId(), tripIdsJson, user.getId(), cluster.getTitle(), cluster.getDescription(), cluster.getType().name());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to save MemoryClusterBlock", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<MemoryClusterBlock> findByBlockId(User user, Long blockId) {
|
||||
String sql = "SELECT block_id, part_ids, title, description, type FROM memory_block_cluster WHERE block_id = ? AND user_id = ?";
|
||||
List<MemoryClusterBlock> results = jdbcTemplate.query(sql, new MemoryClusterBlockRowMapper(), blockId, user.getId());
|
||||
return results.stream().findFirst();
|
||||
}
|
||||
|
||||
public void deleteByBlockId(User user, Long blockId) {
|
||||
String sql = "DELETE FROM memory_block_cluster WHERE block_id = ? AND user_id = ?";
|
||||
jdbcTemplate.update(sql, blockId, user.getId());
|
||||
}
|
||||
|
||||
public MemoryClusterBlock update(User user, MemoryClusterBlock cluster) {
|
||||
String sql = "UPDATE memory_block_cluster SET part_ids = ?::jsonb, title = ?, description = ?, type = ? WHERE block_id = ? AND user_id = ?";
|
||||
try {
|
||||
String tripIdsJson = objectMapper.writeValueAsString(cluster.getPartIds());
|
||||
this.jdbcTemplate.update(sql, tripIdsJson, cluster.getTitle(), cluster.getDescription(), cluster.getType().name(), cluster.getBlockId(), user.getId());
|
||||
return cluster;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to save MemoryClusterBlock", e);
|
||||
}
|
||||
}
|
||||
|
||||
private class MemoryClusterBlockRowMapper implements RowMapper<MemoryClusterBlock> {
|
||||
@Override
|
||||
public MemoryClusterBlock mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||
Long blockId = rs.getLong("block_id");
|
||||
String tripIdsJson = rs.getString("part_ids");
|
||||
List<Long> tripIds;
|
||||
try {
|
||||
tripIds = objectMapper.readValue(tripIdsJson, new TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
throw new SQLException("Failed to parse trip_ids JSON", e);
|
||||
}
|
||||
String title = rs.getString("title");
|
||||
String description = rs.getString("description");
|
||||
BlockType type = BlockType.valueOf(rs.getString("type"));
|
||||
return new MemoryClusterBlock(blockId, tripIds, title, description, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.model.memory.HeaderType;
|
||||
import com.dedicatedcode.reitti.model.memory.Memory;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class MemoryJdbcService {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public MemoryJdbcService(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
private static final RowMapper<Memory> MEMORY_ROW_MAPPER = (rs, rowNum) -> new Memory(
|
||||
rs.getLong("id"),
|
||||
rs.getString("title"),
|
||||
rs.getString("description"),
|
||||
rs.getTimestamp("start_date").toInstant(),
|
||||
rs.getTimestamp("end_date").toInstant(),
|
||||
HeaderType.valueOf(rs.getString("header_type")),
|
||||
rs.getString("header_image_url"),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
rs.getTimestamp("updated_at").toInstant(),
|
||||
rs.getLong("version")
|
||||
);
|
||||
|
||||
public Memory create(User user, Memory memory) {
|
||||
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement ps = connection.prepareStatement(
|
||||
"INSERT INTO memory (user_id, title, description, start_date, end_date, header_type, header_image_url, created_at, updated_at, version) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
Statement.RETURN_GENERATED_KEYS
|
||||
);
|
||||
ps.setLong(1, user.getId());
|
||||
ps.setString(2, memory.getTitle());
|
||||
ps.setString(3, memory.getDescription());
|
||||
ps.setObject(4, Timestamp.from(memory.getStartDate()));
|
||||
ps.setObject(5, Timestamp.from(memory.getEndDate()));
|
||||
ps.setString(6, memory.getHeaderType().name());
|
||||
ps.setString(7, memory.getHeaderImageUrl());
|
||||
ps.setTimestamp(8, Timestamp.from(memory.getCreatedAt()));
|
||||
ps.setTimestamp(9, Timestamp.from(memory.getUpdatedAt()));
|
||||
ps.setLong(10, memory.getVersion());
|
||||
return ps;
|
||||
}, keyHolder);
|
||||
|
||||
Long id = (Long) keyHolder.getKeys().get("id");
|
||||
return memory.withId(id);
|
||||
}
|
||||
|
||||
public Memory update(User user, Memory memory) {
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE memory " +
|
||||
"SET title = ?, description = ?, start_date = ?, end_date = ?, header_type = ?, header_image_url = ?, updated_at = ?, version = version + 1 " +
|
||||
"WHERE id = ? AND user_id = ? AND version = ?",
|
||||
memory.getTitle(),
|
||||
memory.getDescription(),
|
||||
Timestamp.from(memory.getStartDate()),
|
||||
Timestamp.from(memory.getEndDate()),
|
||||
memory.getHeaderType().name(),
|
||||
memory.getHeaderImageUrl(),
|
||||
Timestamp.from(memory.getUpdatedAt()),
|
||||
memory.getId(),
|
||||
user.getId(),
|
||||
memory.getVersion()
|
||||
);
|
||||
|
||||
if (updated == 0) {
|
||||
throw new IllegalStateException("Memory not found or version mismatch");
|
||||
}
|
||||
|
||||
return memory.withVersion(memory.getVersion() + 1);
|
||||
}
|
||||
|
||||
public void delete(User user, Long memoryId) {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM memory WHERE id = ? AND user_id = ?",
|
||||
memoryId,
|
||||
user.getId()
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<Memory> findById(User user, Long id) {
|
||||
List<Memory> results = jdbcTemplate.query(
|
||||
"SELECT * FROM memory WHERE id = ? AND user_id = ?",
|
||||
MEMORY_ROW_MAPPER,
|
||||
id,
|
||||
user.getId()
|
||||
);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.getFirst());
|
||||
}
|
||||
|
||||
public List<Memory> findAllByUser(User user) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT * FROM memory WHERE user_id = ? ORDER BY created_at DESC",
|
||||
MEMORY_ROW_MAPPER,
|
||||
user.getId()
|
||||
);
|
||||
}
|
||||
|
||||
public List<Memory> findAllByUserAndYear(User user, int year) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT * FROM memory WHERE user_id = ? AND (extract(YEAR FROM start_date) = ? OR extract(YEAR FROM end_date) = ?) ORDER BY created_at DESC",
|
||||
MEMORY_ROW_MAPPER,
|
||||
user.getId(), year, year
|
||||
);
|
||||
}
|
||||
|
||||
public List<Memory> findByDateRange(User user, Instant startDate, Instant endDate) {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT * FROM memory " +
|
||||
"WHERE user_id = ? " +
|
||||
"AND (end_date <= ? AND start_date >= ?) " +
|
||||
"ORDER BY start_date DESC",
|
||||
MEMORY_ROW_MAPPER,
|
||||
user.getId(),
|
||||
endDate,
|
||||
startDate
|
||||
);
|
||||
}
|
||||
|
||||
public List<Integer> findDistinctYears(User user) {
|
||||
String sql = "SELECT DISTINCT EXTRACT(YEAR FROM start_date) " +
|
||||
"FROM memory " +
|
||||
"WHERE user_id = ? " +
|
||||
"ORDER BY EXTRACT(YEAR FROM start_date) DESC";
|
||||
return jdbcTemplate.queryForList(sql, Integer.class, user.getId());
|
||||
}
|
||||
|
||||
public Optional<Long> getOwnerId(Memory memory) {
|
||||
return Optional.ofNullable(this.jdbcTemplate.queryForObject("SELECT user_id FROM memory WHERE id = ?", Long.class, memory.getId()));
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,20 @@ public class ProcessedVisitJdbcService {
|
||||
jdbcTemplate.update(sql, ids.toArray());
|
||||
}
|
||||
|
||||
|
||||
public List<ProcessedVisit> findByIds(User user, List<Long> ids) {
|
||||
String placeholders = String.join(",", ids.stream().map(id -> "?").toList());
|
||||
String sql = "SELECT pv.* " +
|
||||
"FROM processed_visits pv " +
|
||||
"WHERE pv.user_id = ? AND pv.id IN (" + placeholders + ")";
|
||||
Object[] params = new Object[ids.size() + 1];
|
||||
params[0] = user.getId();
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
params[i + 1] = ids.get(i);
|
||||
}
|
||||
return jdbcTemplate.query(sql, PROCESSED_VISIT_ROW_MAPPER, params);
|
||||
}
|
||||
|
||||
public Optional<ProcessedVisit> findByUserAndStartTimeAndEndTimeAndPlace(User user, Instant startTime, Instant endTime, SignificantPlace place) {
|
||||
String sql = "SELECT pv.* " +
|
||||
"FROM processed_visits pv " +
|
||||
@@ -217,4 +231,5 @@ public class ProcessedVisitJdbcService {
|
||||
public void deleteAllForUserAfter(User user, Instant start) {
|
||||
jdbcTemplate.update("DELETE FROM processed_visits WHERE user_id = ? AND end_time >= ?", user.getId(), Timestamp.from(start));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ public class SignificantPlaceJdbcService {
|
||||
rs.getLong("id"),
|
||||
rs.getString("name"),
|
||||
rs.getString("address"),
|
||||
rs.getString("city"),
|
||||
rs.getString("country_code"),
|
||||
rs.getDouble("latitude_centroid"),
|
||||
rs.getDouble("longitude_centroid"),
|
||||
@@ -44,7 +45,7 @@ public class SignificantPlaceJdbcService {
|
||||
String countSql = "SELECT COUNT(*) FROM significant_places WHERE user_id = ?";
|
||||
Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, user.getId());
|
||||
|
||||
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" +
|
||||
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version" +
|
||||
" FROM significant_places sp " +
|
||||
"WHERE sp.user_id = ? ORDER BY sp.id " +
|
||||
"LIMIT ? OFFSET ? ";
|
||||
@@ -55,7 +56,7 @@ public class SignificantPlaceJdbcService {
|
||||
}
|
||||
|
||||
public List<SignificantPlace> findNearbyPlaces(Long userId, Point point, double distanceInMeters) {
|
||||
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.city, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
"FROM significant_places sp " +
|
||||
"WHERE sp.user_id = ? " +
|
||||
"AND ST_DWithin(sp.geom, ST_GeomFromText(?, '4326'), ?)";
|
||||
@@ -79,10 +80,11 @@ public class SignificantPlaceJdbcService {
|
||||
|
||||
@CacheEvict(cacheNames = "significant-places", key = "#place.id")
|
||||
public SignificantPlace update(SignificantPlace place) {
|
||||
String sql = "UPDATE significant_places SET name = ?, address = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?";
|
||||
String sql = "UPDATE significant_places SET name = ?, address = ?, city = ?, country_code = ?, type = ?, latitude_centroid = ?, longitude_centroid = ?, geom = ST_GeomFromText(?, '4326'), timezone = ?, geocoded = ? WHERE id = ?";
|
||||
jdbcTemplate.update(sql,
|
||||
place.getName(),
|
||||
place.getAddress(),
|
||||
place.getCity(),
|
||||
place.getCountryCode(),
|
||||
place.getType().name(),
|
||||
place.getLatitudeCentroid(),
|
||||
@@ -97,7 +99,7 @@ public class SignificantPlaceJdbcService {
|
||||
|
||||
@Cacheable("significant-places")
|
||||
public Optional<SignificantPlace> findById(Long id) {
|
||||
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
"FROM significant_places sp " +
|
||||
"WHERE sp.id = ?";
|
||||
List<SignificantPlace> results = jdbcTemplate.query(sql, significantPlaceRowMapper, id);
|
||||
@@ -109,7 +111,7 @@ public class SignificantPlaceJdbcService {
|
||||
}
|
||||
|
||||
public List<SignificantPlace> findNonGeocodedByUser(User user) {
|
||||
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
"FROM significant_places sp " +
|
||||
"WHERE sp.user_id = ? AND sp.geocoded = false " +
|
||||
"ORDER BY sp.id";
|
||||
@@ -117,7 +119,7 @@ public class SignificantPlaceJdbcService {
|
||||
}
|
||||
|
||||
public List<SignificantPlace> findAllByUser(User user) {
|
||||
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
"FROM significant_places sp " +
|
||||
"WHERE sp.user_id = ? " +
|
||||
"ORDER BY sp.id";
|
||||
@@ -125,7 +127,7 @@ public class SignificantPlaceJdbcService {
|
||||
}
|
||||
|
||||
public List<SignificantPlace> findWithMissingTimezone() {
|
||||
String sql = "SELECT sp.id, sp.address, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
String sql = "SELECT sp.id, sp.address, sp.city, sp.country_code, sp.type, sp.latitude_centroid, sp.longitude_centroid, sp.name, sp.user_id, ST_AsText(sp.geom) as geom, sp.timezone, sp.geocoded, sp.version " +
|
||||
"FROM significant_places sp " +
|
||||
"WHERE sp.timezone IS NULL " +
|
||||
"ORDER BY sp.id";
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -47,6 +48,20 @@ public class TripJdbcService {
|
||||
}
|
||||
};
|
||||
|
||||
public List<Trip> findByIds(User user, List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
String placeholders = String.join(",", Collections.nCopies(ids.size(), "?"));
|
||||
String sql = "SELECT t.* FROM trips t WHERE t.user_id = ? AND t.id IN (" + placeholders + ")";
|
||||
Object[] params = new Object[ids.size() + 1];
|
||||
params[0] = user.getId();
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
params[i + 1] = ids.get(i);
|
||||
}
|
||||
return jdbcTemplate.query(sql, TRIP_ROW_MAPPER, params);
|
||||
}
|
||||
|
||||
public List<Trip> findByUser(User user) {
|
||||
String sql = "SELECT t.*" +
|
||||
"FROM trips t " +
|
||||
@@ -54,6 +69,13 @@ public class TripJdbcService {
|
||||
return jdbcTemplate.query(sql, TRIP_ROW_MAPPER, user.getId());
|
||||
}
|
||||
|
||||
public Optional<Trip> findByUserAndId(User user, Long id) {
|
||||
String sql = "SELECT t.*" +
|
||||
"FROM trips t " +
|
||||
"WHERE t.user_id = ? AND id = ?";
|
||||
return jdbcTemplate.query(sql, TRIP_ROW_MAPPER, user.getId(), id).stream().findFirst();
|
||||
}
|
||||
|
||||
public List<Trip> findByUserAndTimeOverlap(User user, Instant startTime, Instant endTime) {
|
||||
String sql = "SELECT t.* " +
|
||||
"FROM trips t " +
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.i18n.LocaleContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.text.MessageFormat;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
@Service
|
||||
public class I18nService {
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public I18nService(MessageSource messageSource) {
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
public String humanizeDuration(long seconds) {
|
||||
Duration duration = Duration.ofSeconds(seconds);
|
||||
return humanizeDuration(duration);
|
||||
}
|
||||
|
||||
public String humanizeDuration(Duration duration) {
|
||||
long hours = duration.toHours();
|
||||
long minutesPart = duration.toMinutesPart();
|
||||
|
||||
Locale locale = LocaleContextHolder.getLocale();
|
||||
ResourceBundle bundle = ResourceBundle.getBundle("messages", locale);
|
||||
|
||||
if (hours > 0) {
|
||||
// Format for hours and minutes (e.g., "2 hours and 5 minutes")
|
||||
String pattern = bundle.getString("format.hours_minutes");
|
||||
// Note: The MessageFormat pattern is designed to handle the pluralization
|
||||
// and the exclusion of the minutes if minutesPart is 0.
|
||||
return new MessageFormat(pattern, locale).format(new Object[]{hours, minutesPart});
|
||||
|
||||
} else {
|
||||
// Format for minutes only (e.g., "65 minutes")
|
||||
long totalMinutes = duration.toMinutes();
|
||||
if (totalMinutes == 0) {
|
||||
return "Less than a minute"; // Handle very short durations
|
||||
}
|
||||
String pattern = bundle.getString("format.minutes_only");
|
||||
return new MessageFormat(pattern, locale).format(new Object[]{totalMinutes});
|
||||
}
|
||||
}
|
||||
|
||||
public String translate(String messageKey) {
|
||||
return messageSource.getMessage(messageKey, null, LocaleContextHolder.getLocale());
|
||||
}
|
||||
|
||||
public String translateWithDefault(String messageKey, String defaultMessage) {
|
||||
return messageSource.getMessage(messageKey, null, defaultMessage, LocaleContextHolder.getLocale());
|
||||
}
|
||||
|
||||
public String translate(String messageKey, Object... args) {
|
||||
return messageSource.getMessage(messageKey, args, LocaleContextHolder.getLocale());
|
||||
}
|
||||
|
||||
public String translateWithDefault(String messageKey, String defaultMessage, Object... args) {
|
||||
return messageSource.getMessage(messageKey, args, defaultMessage, LocaleContextHolder.getLocale());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkResourceType;
|
||||
import com.dedicatedcode.reitti.model.security.MagicLinkToken;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.MagicLinkJdbcService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class MagicLinkTokenService {
|
||||
|
||||
private final MagicLinkJdbcService magicLinkJdbcService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public MagicLinkTokenService(MagicLinkJdbcService magicLinkJdbcService, PasswordEncoder passwordEncoder) {
|
||||
this.magicLinkJdbcService = magicLinkJdbcService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
}
|
||||
|
||||
public String createMapShareToken(User user, String name, MagicLinkAccessLevel accessLevel, Instant expiryInstant) {
|
||||
byte[] tokenBytes = new byte[32];
|
||||
secureRandom.nextBytes(tokenBytes);
|
||||
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
|
||||
String tokenHash = passwordEncoder.encode(rawToken);
|
||||
|
||||
MagicLinkToken token = new MagicLinkToken(null, name, tokenHash, accessLevel, expiryInstant, null, null, false);
|
||||
magicLinkJdbcService.create(user, token);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
public String createMemoryShareToken(User user, Long memoryId, MagicLinkAccessLevel accessLevel, int validDays) {
|
||||
// Generate a secure random token
|
||||
byte[] tokenBytes = new byte[32];
|
||||
secureRandom.nextBytes(tokenBytes);
|
||||
String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
|
||||
|
||||
// Hash the token for storage
|
||||
String tokenHash = passwordEncoder.encode(rawToken);
|
||||
|
||||
String tokenName = "Memory " + memoryId + " - " + (accessLevel == MagicLinkAccessLevel.MEMORY_VIEW_ONLY ? "View Only" : "Edit Access");
|
||||
Instant expiryDate = validDays > 0 ? Instant.now().plus(validDays, ChronoUnit.DAYS) : null;
|
||||
|
||||
MagicLinkToken token = new MagicLinkToken(
|
||||
null,
|
||||
tokenName,
|
||||
tokenHash,
|
||||
accessLevel,
|
||||
expiryDate,
|
||||
MagicLinkResourceType.MEMORY,
|
||||
memoryId,
|
||||
Instant.now(),
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
magicLinkJdbcService.create(user, token);
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
public List<MagicLinkToken> getTokensForUser(User user) {
|
||||
return magicLinkJdbcService.findByUser(user);
|
||||
}
|
||||
|
||||
public Optional<MagicLinkToken> validateToken(String rawToken) {
|
||||
return magicLinkJdbcService.findByRawToken(rawToken);
|
||||
}
|
||||
|
||||
public void markTokenAsUsed(long tokenId) {
|
||||
magicLinkJdbcService.updateLastUsed(tokenId);
|
||||
}
|
||||
|
||||
public void deleteToken(long tokenId) {
|
||||
magicLinkJdbcService.delete(tokenId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import com.dedicatedcode.reitti.dto.PhotoResponse;
|
||||
import com.dedicatedcode.reitti.model.geo.GeoUtils;
|
||||
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
|
||||
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
|
||||
import com.dedicatedcode.reitti.model.geo.Trip;
|
||||
import com.dedicatedcode.reitti.model.memory.*;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.ProcessedVisitJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.TripJdbcService;
|
||||
import com.dedicatedcode.reitti.service.integration.ImmichIntegrationService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.i18n.LocaleContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.FormatStyle;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class MemoryBlockGenerationService {
|
||||
private static final Logger log = LoggerFactory.getLogger(MemoryBlockGenerationService.class);
|
||||
|
||||
private static final DateTimeFormatter FULL_DATE_FORMATTER = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG);
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd MMM");
|
||||
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("dd MMM, HH:mm");
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
private static final long MIN_VISIT_DURATION_SECONDS = 600;
|
||||
|
||||
private static final double WEIGHT_DURATION = 1.0;
|
||||
private static final double WEIGHT_DISTANCE = 2.0;
|
||||
private static final double WEIGHT_CATEGORY = 3.0;
|
||||
private static final double WEIGHT_NOVELTY = 1.5;
|
||||
|
||||
private static final long CLUSTER_TIME_THRESHOLD_SECONDS = 7200;
|
||||
private static final double CLUSTER_DISTANCE_THRESHOLD_METERS = 1000;
|
||||
|
||||
private final ProcessedVisitJdbcService processedVisitJdbcService;
|
||||
private final TripJdbcService tripJdbcService;
|
||||
private final I18nService i18n;
|
||||
private final ImmichIntegrationService immichIntegrationService;
|
||||
private final StorageService storageService;
|
||||
|
||||
public MemoryBlockGenerationService(ProcessedVisitJdbcService processedVisitJdbcService,
|
||||
TripJdbcService tripJdbcService,
|
||||
I18nService i18nService,
|
||||
ImmichIntegrationService immichIntegrationService,
|
||||
StorageService storageService) {
|
||||
this.processedVisitJdbcService = processedVisitJdbcService;
|
||||
this.tripJdbcService = tripJdbcService;
|
||||
this.i18n = i18nService;
|
||||
this.immichIntegrationService = immichIntegrationService;
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
public List<MemoryBlockPart> generate(User user, Memory memory, ZoneId timeZone) {
|
||||
Instant startDate = memory.getStartDate();
|
||||
Instant endDate = memory.getEndDate();
|
||||
|
||||
List<ProcessedVisit> allVisitsInRange = this.processedVisitJdbcService.findByUserAndTimeOverlap(user, startDate, endDate);
|
||||
List<Trip> allTripsInRange = this.tripJdbcService.findByUserAndTimeOverlap(user, startDate, endDate);
|
||||
|
||||
// Step 1: Data Pre-processing & Filtering
|
||||
Optional<ProcessedVisit> accommodation = findAccommodation(allVisitsInRange);
|
||||
Optional<ProcessedVisit> home = findHome(allVisitsInRange);
|
||||
|
||||
// Find first and last accommodation visits
|
||||
Instant firstAccommodationArrival = accommodation.flatMap(p -> allVisitsInRange.stream()
|
||||
.filter(visit -> visit.getPlace().getId().equals(p.getPlace().getId()))
|
||||
.min(Comparator.comparing(ProcessedVisit::getStartTime)))
|
||||
.map(ProcessedVisit::getStartTime).orElse(null);
|
||||
Instant lastAccommodationDeparture = accommodation.flatMap(p -> allVisitsInRange.stream()
|
||||
.filter(visit -> visit.getPlace().getId().equals(p.getPlace().getId()))
|
||||
.max(Comparator.comparing(ProcessedVisit::getStartTime)))
|
||||
.map(ProcessedVisit::getStartTime).orElse(null);
|
||||
|
||||
List<ProcessedVisit> filteredVisits = accommodation.map(a -> filterVisits(allVisitsInRange, a)).orElse(allVisitsInRange);
|
||||
|
||||
log.info("Found {} visits after filtering (accommodation: {})", filteredVisits.size(), accommodation.map(a -> a.getPlace().getName()).orElse("none"));
|
||||
|
||||
// Step 3: Scoring & Identifying "Interesting" Visits
|
||||
List<ScoredVisit> scoredVisits = scoreVisits(filteredVisits, accommodation.orElse(null));
|
||||
|
||||
// Sort by score descending
|
||||
scoredVisits.sort(Comparator.comparingDouble(ScoredVisit::score).reversed());
|
||||
|
||||
log.info("Scored {} visits, top score: {}", scoredVisits.size(),
|
||||
scoredVisits.isEmpty() ? 0 : scoredVisits.getFirst().score());
|
||||
|
||||
// Step 4: Clustering & Creating a Narrative
|
||||
List<VisitCluster> clusters = clusterVisits(scoredVisits);
|
||||
|
||||
log.info("Created {} clusters from visits", clusters.size());
|
||||
|
||||
// Generate memory block parts from clusters
|
||||
List<MemoryBlockPart> blockParts = new ArrayList<>();
|
||||
|
||||
// Add introduction text block
|
||||
if (!clusters.isEmpty() && accommodation.isPresent() && home.isPresent() && startDate != null && endDate != null) {
|
||||
String introText = generateIntroductionText(memory, clusters, accommodation.orElse(null), home.orElse(null), startDate, endDate, timeZone);
|
||||
MemoryBlockText introBlock = new MemoryBlockText(null, i18n.translate("memory.generator.headline.text"), introText);
|
||||
blockParts.add(introBlock);
|
||||
}
|
||||
|
||||
// Add travel to the accommodation section
|
||||
if (firstAccommodationArrival != null) {
|
||||
List<Trip> tripsToAccommodation = allTripsInRange.stream()
|
||||
.filter(trip -> trip.getEndTime() != null && !trip.getEndTime().isAfter(firstAccommodationArrival))
|
||||
.filter(trip -> trip.getStartTime() != null && !trip.getStartTime().isBefore(startDate))
|
||||
.sorted(Comparator.comparing(Trip::getStartTime))
|
||||
.toList();
|
||||
|
||||
String formattedStartDate = formatTime(tripsToAccommodation.getFirst().getStartTime(), timeZone, false);
|
||||
String formattedEndDate = formatTime(tripsToAccommodation.getLast().getEndTime(), timeZone, false);
|
||||
if (!tripsToAccommodation.isEmpty()) {
|
||||
String text = i18n.translate("memory.generator.travel_to_accommodation.text",
|
||||
home.map(h ->h.getPlace().getCity()).orElse(""),
|
||||
formattedStartDate,
|
||||
accommodation.map(a -> a.getPlace().getCity()).orElse(""),
|
||||
formattedEndDate,
|
||||
i18n.humanizeDuration(Duration.between(tripsToAccommodation.getFirst().getStartTime(), tripsToAccommodation.getLast().getEndTime())),
|
||||
i18n.humanizeDuration(tripsToAccommodation.stream().map(Trip::getDurationSeconds).reduce(0L, Long::sum))
|
||||
);
|
||||
|
||||
MemoryBlockText accommodationPreRoll = new MemoryBlockText(null, null, text);
|
||||
blockParts.add(accommodationPreRoll);
|
||||
}
|
||||
|
||||
MemoryClusterBlock clusterBlock = convertToTripCluster(tripsToAccommodation, "Journey to " + accommodation.get().getPlace().getCity());
|
||||
blockParts.add(clusterBlock);
|
||||
}
|
||||
|
||||
Map<LocalDate, List<PhotoResponse>> imagesByDay = loadImagesFromIntegrations(user, startDate, endDate);
|
||||
|
||||
accommodation.ifPresent(a -> {
|
||||
MemoryBlockText intro = new MemoryBlockText(null,
|
||||
i18n.translate("memory.generator.intro_accommodation.headline",a.getPlace().getName()),
|
||||
i18n.translate("memory.generator.intro_accommodation.text"));
|
||||
blockParts.add(intro);
|
||||
MemoryClusterBlock clusterBlock = new MemoryClusterBlock(null, List.of(a.getId()), null, null, BlockType.CLUSTER_VISIT);
|
||||
blockParts.add(clusterBlock);
|
||||
LocalDate dayOfAccommodation = a.getStartTime().atZone(ZoneId.of("UTC")).toLocalDate();
|
||||
List<PhotoResponse> images = imagesByDay.get(dayOfAccommodation);
|
||||
if (images != null && !images.isEmpty()) {
|
||||
MemoryBlockImageGallery imageGallery = new MemoryBlockImageGallery(null, fetchImagesFromImmich(user, memory, images));
|
||||
blockParts.add(imageGallery);
|
||||
imagesByDay.remove(dayOfAccommodation);
|
||||
}
|
||||
});
|
||||
|
||||
Set<LocalDate> handledDays = new HashSet<>();
|
||||
|
||||
// Process each cluster
|
||||
ProcessedVisit previousVisit = accommodation.orElse(null);
|
||||
boolean firstOfDay;
|
||||
for (int i = 0; i < clusters.size(); i++) {
|
||||
|
||||
VisitCluster cluster = clusters.get(i);
|
||||
LocalDate today = cluster.getStartTime().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
|
||||
|
||||
//filter out visits before the first stay at accommodation
|
||||
if (firstAccommodationArrival != null && cluster.getEndTime() != null && cluster.getEndTime().isBefore(firstAccommodationArrival)) {
|
||||
continue;
|
||||
}
|
||||
//filter out visits after the last stay at accommodation
|
||||
if (lastAccommodationDeparture != null && cluster.getStartTime() != null && cluster.getStartTime().isAfter(lastAccommodationDeparture)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!handledDays.contains(today)) {
|
||||
blockParts.add(new MemoryBlockText(null, i18n.translate("memory.generator.day.text",
|
||||
Duration.between(startDate.truncatedTo(ChronoUnit.DAYS), cluster.getStartTime().truncatedTo(ChronoUnit.DAYS)).toDays(), cluster.getHighestScoredVisit().visit.getPlace().getCity()), null));
|
||||
handledDays.add(today);
|
||||
firstOfDay = true;
|
||||
} else {
|
||||
firstOfDay = false;
|
||||
}
|
||||
if (previousVisit != null) {
|
||||
ProcessedVisit finalPreviousVisit = previousVisit;
|
||||
List<Trip> tripsBetweenVisits = allTripsInRange.stream()
|
||||
.filter(trip -> trip.getStartTime() != null && (trip.getStartTime().equals(finalPreviousVisit.getEndTime()) || trip.getStartTime().isAfter(finalPreviousVisit.getEndTime())))
|
||||
.filter(trip -> trip.getEndTime() != null && (trip.getEndTime().equals(cluster.getStartTime())))
|
||||
.sorted(Comparator.comparing(Trip::getEndTime))
|
||||
.toList();
|
||||
|
||||
if (Duration.between(tripsBetweenVisits.getFirst().getStartTime(), tripsBetweenVisits.getLast().getEndTime()).toMinutes() > 30) {
|
||||
MemoryClusterBlock clusterBlock = convertToTripCluster(tripsBetweenVisits, "Journey to " + cluster.getHighestScoredVisit().visit().getPlace().getCity());
|
||||
blockParts.add(clusterBlock);
|
||||
}
|
||||
previousVisit = cluster.getVisits().stream().map(ScoredVisit::visit).max(Comparator.comparing(ProcessedVisit::getEndTime)).orElse(null);
|
||||
}
|
||||
|
||||
// Add a text block describing the cluster
|
||||
String clusterHeadline = firstOfDay ? null : generateClusterHeadline(cluster, i + 1);
|
||||
MemoryBlockText clusterTextBlock = new MemoryBlockText(null, clusterHeadline, null);
|
||||
blockParts.add(clusterTextBlock);
|
||||
|
||||
|
||||
MemoryClusterBlock clusterBlock = new MemoryClusterBlock(null, cluster.getVisits().stream().map(ScoredVisit::visit)
|
||||
.map(ProcessedVisit::getId).toList(), null, null, BlockType.CLUSTER_VISIT);
|
||||
blockParts.add(clusterBlock);
|
||||
|
||||
List<PhotoResponse> todaysImages = imagesByDay.getOrDefault(today, Collections.emptyList());
|
||||
if (!todaysImages.isEmpty()) {
|
||||
MemoryBlockImageGallery imageGallery = new MemoryBlockImageGallery(null, fetchImagesFromImmich(user, memory, todaysImages));
|
||||
blockParts.add(imageGallery);
|
||||
}
|
||||
imagesByDay.remove(today);
|
||||
}
|
||||
|
||||
if (lastAccommodationDeparture != null) {
|
||||
List<Trip> tripsFromAccommodation = allTripsInRange.stream()
|
||||
.filter(trip -> trip.getStartTime() != null && !trip.getStartTime().isBefore(lastAccommodationDeparture))
|
||||
.filter(trip -> trip.getEndTime() != null && !trip.getEndTime().isAfter(endDate))
|
||||
.sorted(Comparator.comparing(Trip::getStartTime))
|
||||
.toList();
|
||||
|
||||
|
||||
if (!tripsFromAccommodation.isEmpty()) {
|
||||
String formattedStartDate = formatTime(tripsFromAccommodation.getFirst().getStartTime(), timeZone, false);
|
||||
String formattedEndDate = formatTime(tripsFromAccommodation.getLast().getEndTime(), timeZone, false);
|
||||
String text = i18n.translate("memory.generator.travel_from_accommodation.text",
|
||||
accommodation.map(a -> a.getPlace().getCity()).orElse(""),
|
||||
formattedStartDate,
|
||||
home.map(h -> h.getPlace().getCity()).orElse(""),
|
||||
formattedEndDate,
|
||||
i18n.humanizeDuration(Duration.between(tripsFromAccommodation.getFirst().getStartTime(), tripsFromAccommodation.getLast().getEndTime())),
|
||||
i18n.humanizeDuration(tripsFromAccommodation.stream().map(Trip::getDurationSeconds).reduce(0L, Long::sum))
|
||||
);
|
||||
|
||||
MemoryBlockText accommodationPreRoll = new MemoryBlockText(null, null, text);
|
||||
blockParts.add(accommodationPreRoll);
|
||||
}
|
||||
|
||||
|
||||
MemoryClusterBlock clusterBlock = convertToTripCluster(tripsFromAccommodation, "Journey to " + home.get().getPlace().getCity());
|
||||
|
||||
blockParts.add(clusterBlock);
|
||||
}
|
||||
|
||||
log.info("Generated {} memory block parts", blockParts.size());
|
||||
|
||||
return blockParts;
|
||||
}
|
||||
|
||||
private List<MemoryBlockImageGallery.GalleryImage> fetchImagesFromImmich(User user, Memory memory, List<PhotoResponse> todaysImages) {
|
||||
return todaysImages.stream()
|
||||
.map(s -> {
|
||||
if (storageService.exists("memories/" + memory.getId() + "/" + s + "**")) {
|
||||
return new MemoryBlockImageGallery.GalleryImage("/api/v1/photos/reitti/memories/" + memory.getId() + "/" + s.getFileName(), null, "immich", s.getId());
|
||||
} else {
|
||||
String filename = this.immichIntegrationService.downloadImage(user, s.getId(), "memories/" + memory.getId());
|
||||
String imageUrl = "/api/v1/photos/reitti/memories/" + memory.getId() + "/" + filename;
|
||||
return new MemoryBlockImageGallery.GalleryImage(imageUrl, null, "immich", s.getId());
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private Map<LocalDate, List<PhotoResponse>> loadImagesFromIntegrations(User user, Instant startDate, Instant endDate) {
|
||||
Map<LocalDate, List<PhotoResponse>> map = new HashMap<>();
|
||||
LocalDate currentStart = startDate.atZone(ZoneId.of("UTC")).toLocalDate();
|
||||
LocalDate currentEnd = startDate.plus(1, ChronoUnit.DAYS).atZone(ZoneId.of("UTC")).toLocalDate();
|
||||
LocalDate end = endDate.atZone(ZoneId.of("UTC")).toLocalDate();
|
||||
while (!currentEnd.isAfter(end)) {
|
||||
map.put(currentStart, this.immichIntegrationService.searchPhotosForRange(user, currentStart, currentStart, "UTC")
|
||||
.stream().sorted(Comparator.comparing(PhotoResponse::getDateTime)).toList());
|
||||
|
||||
currentStart = currentEnd;
|
||||
currentEnd = currentEnd.plusDays(1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Optional<ProcessedVisit> findHome(List<ProcessedVisit> allVisitsInRange) {
|
||||
if (allVisitsInRange.stream().findFirst().isPresent()) {
|
||||
boolean homeDetected = allVisitsInRange.stream().findFirst().get().getPlace().equals(allVisitsInRange.getLast().getPlace());
|
||||
if (homeDetected) {
|
||||
return allVisitsInRange.stream().findFirst();
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private MemoryClusterBlock convertToTripCluster(List<Trip> trips, String title) {
|
||||
return new MemoryClusterBlock(null, trips.stream().map(Trip::getId).toList(),
|
||||
title, null, BlockType.CLUSTER_TRIP);
|
||||
}
|
||||
|
||||
private String generateIntroductionText(Memory memory, List<VisitCluster> clusters, ProcessedVisit accommodation, ProcessedVisit homePlace,
|
||||
Instant startDate, Instant endDate, ZoneId timeZone) {
|
||||
long totalDays = Duration.between(memory.getStartDate(), memory.getEndDate()).toDays() + 1;
|
||||
int totalVisits = clusters.stream().mapToInt(c -> c.getVisits().size()).sum();
|
||||
SignificantPlace accommodationPlace = accommodation.getPlace();
|
||||
String country;
|
||||
if (accommodationPlace.getCountryCode() != null) {
|
||||
country = i18n.translateWithDefault("country." + accommodationPlace.getCountryCode() + ".label", accommodation.getPlace().getCountryCode().toLowerCase());
|
||||
} else {
|
||||
country = i18n.translate("country.unknown.label");
|
||||
}
|
||||
String formattedStartDate = formatDate(startDate, timeZone, true);
|
||||
String formattedEndDate = formatDate(endDate, timeZone, false);
|
||||
|
||||
return i18n.translate("memory.generator.introductory.text",
|
||||
formattedStartDate,
|
||||
homePlace.getPlace().getCity(),
|
||||
totalDays,
|
||||
accommodationPlace.getCity(),
|
||||
country,
|
||||
totalVisits,
|
||||
clusters.size(),
|
||||
formattedEndDate);
|
||||
}
|
||||
|
||||
private static String formatDate(Instant date, ZoneId timeZone, boolean withYear) {
|
||||
LocalDate localEnd = date.atZone(timeZone).toLocalDate();
|
||||
return withYear ? localEnd.format(FULL_DATE_FORMATTER.withLocale(LocaleContextHolder.getLocale())) : localEnd.format(DATE_FORMATTER.withLocale(LocaleContextHolder.getLocale()));
|
||||
}
|
||||
|
||||
private static String formatTime(Instant date, ZoneId timeZone, boolean withDay) {
|
||||
LocalDateTime localEnd = date.atZone(timeZone).toLocalDateTime();
|
||||
return withDay ? localEnd.format(DATETIME_FORMATTER.withLocale(LocaleContextHolder.getLocale())) : localEnd.format(TIME_FORMATTER.withLocale(LocaleContextHolder.getLocale()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a headline for a visit cluster
|
||||
*/
|
||||
private String generateClusterHeadline(VisitCluster cluster, int clusterNumber) {
|
||||
ScoredVisit topVisit = cluster.getHighestScoredVisit();
|
||||
if (topVisit != null && topVisit.visit().getPlace().getName() != null) {
|
||||
return topVisit.visit().getPlace().getName();
|
||||
}
|
||||
return "Location " + clusterNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Find accommodation by analyzing visits during sleeping hours (22:00 - 06:00)
|
||||
*/
|
||||
private Optional<ProcessedVisit> findAccommodation(List<ProcessedVisit> visits) {
|
||||
Map<Long, Long> sleepingHoursDuration = new HashMap<>();
|
||||
|
||||
for (ProcessedVisit visit : visits) {
|
||||
long durationInSleepingHours = calculateSleepingHoursDuration(visit);
|
||||
if (durationInSleepingHours > 0) {
|
||||
sleepingHoursDuration.merge(visit.getPlace().getId(), durationInSleepingHours, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
return sleepingHoursDuration.entrySet().stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.flatMap(entry -> visits.stream()
|
||||
.filter(visit -> visit.getPlace().getId().equals(entry.getKey()))
|
||||
.findFirst());
|
||||
}
|
||||
|
||||
private long calculateSleepingHoursDuration(ProcessedVisit visit) {
|
||||
ZoneId timeZone = visit.getPlace().getTimezone();
|
||||
if (timeZone == null) {
|
||||
timeZone = ZoneId.systemDefault();
|
||||
}
|
||||
|
||||
var startLocal = visit.getStartTime().atZone(timeZone);
|
||||
var endLocal = visit.getEndTime().atZone(timeZone);
|
||||
|
||||
long totalSleepingDuration = 0;
|
||||
|
||||
var currentDay = startLocal.toLocalDate();
|
||||
var lastDay = endLocal.toLocalDate();
|
||||
|
||||
while (!currentDay.isAfter(lastDay)) {
|
||||
var sleepStart = currentDay.atTime(22, 0).atZone(timeZone);
|
||||
var sleepEnd = currentDay.plusDays(1).atTime(6, 0).atZone(timeZone);
|
||||
|
||||
var overlapStart = sleepStart.isAfter(startLocal) ? sleepStart : startLocal;
|
||||
var overlapEnd = sleepEnd.isBefore(endLocal) ? sleepEnd : endLocal;
|
||||
|
||||
if (overlapStart.isBefore(overlapEnd)) {
|
||||
totalSleepingDuration += Duration.between(overlapStart, overlapEnd).getSeconds();
|
||||
}
|
||||
|
||||
currentDay = currentDay.plusDays(1);
|
||||
}
|
||||
|
||||
return totalSleepingDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: Filter visits - remove accommodation and short visits
|
||||
*/
|
||||
private List<ProcessedVisit> filterVisits(List<ProcessedVisit> visits, ProcessedVisit accommodation) {
|
||||
Long accommodationPlaceId = accommodation != null ? accommodation.getPlace().getId() : null;
|
||||
|
||||
return visits.stream()
|
||||
.filter(visit -> {
|
||||
// Remove accommodation stays
|
||||
if (visit.getPlace().getId().equals(accommodationPlaceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return visit.getDurationSeconds() >= MIN_VISIT_DURATION_SECONDS;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Score visits based on duration, distance from accommodation, category, and novelty
|
||||
*/
|
||||
private List<ScoredVisit> scoreVisits(List<ProcessedVisit> visits, ProcessedVisit accommodation) {
|
||||
// Count visit frequency for novelty calculation
|
||||
Map<Long, Long> visitCounts = visits.stream()
|
||||
.collect(Collectors.groupingBy(v -> v.getPlace().getId(), Collectors.counting()));
|
||||
|
||||
// Calculate max duration for normalization
|
||||
long maxDuration = visits.stream()
|
||||
.mapToLong(ProcessedVisit::getDurationSeconds)
|
||||
.max()
|
||||
.orElse(1);
|
||||
|
||||
return visits.stream()
|
||||
.map(visit -> {
|
||||
double score = 0.0;
|
||||
|
||||
// Duration score (normalized 0-1)
|
||||
double durationScore = (double) visit.getDurationSeconds() / maxDuration;
|
||||
score += WEIGHT_DURATION * durationScore;
|
||||
|
||||
// Distance from accommodation score
|
||||
if (accommodation != null) {
|
||||
double distance = GeoUtils.distanceInMeters(
|
||||
visit.getPlace().getLatitudeCentroid(),
|
||||
visit.getPlace().getLongitudeCentroid(),
|
||||
accommodation.getPlace().getLatitudeCentroid(),
|
||||
accommodation.getPlace().getLongitudeCentroid()
|
||||
);
|
||||
// Normalize distance (assume max interesting distance is 50km)
|
||||
double distanceScore = Math.min(distance / 50000.0, 1.0);
|
||||
score += WEIGHT_DISTANCE * distanceScore;
|
||||
}
|
||||
|
||||
// Category score
|
||||
double categoryScore = getCategoryWeight(visit.getPlace().getType());
|
||||
score += WEIGHT_CATEGORY * categoryScore;
|
||||
|
||||
// Novelty score (inverse of visit count, normalized)
|
||||
long visitCount = visitCounts.get(visit.getPlace().getId());
|
||||
double noveltyScore = 1.0 / visitCount;
|
||||
score += WEIGHT_NOVELTY * noveltyScore;
|
||||
|
||||
return new ScoredVisit(visit, score);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category weight based on place type
|
||||
*/
|
||||
private double getCategoryWeight(SignificantPlace.PlaceType placeType) {
|
||||
if (placeType == null) {
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
// High interest categories
|
||||
return switch (placeType) {
|
||||
case MUSEUM, LANDMARK, PARK, TOURIST_ATTRACTION, HISTORIC_SITE, MONUMENT -> 1.0;
|
||||
// Medium interest categories
|
||||
case RESTAURANT, CAFE, SHOPPING_MALL, MARKET, GALLERY, THEATER, CINEMA -> 0.6;
|
||||
// Low interest categories
|
||||
case GROCERY_STORE, PHARMACY, GAS_STATION, ATM, BANK -> 0.2;
|
||||
// Default medium-low for all other types
|
||||
default -> 0.4;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4: Cluster visits using spatio-temporal proximity (simplified DBSCAN-like approach)
|
||||
*/
|
||||
private List<VisitCluster> clusterVisits(List<ScoredVisit> scoredVisits) {
|
||||
if (scoredVisits.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
List<ScoredVisit> sortedVisits = new ArrayList<>(scoredVisits);
|
||||
sortedVisits.sort(Comparator.comparing(sv -> sv.visit().getStartTime()));
|
||||
|
||||
List<VisitCluster> clusters = new ArrayList<>();
|
||||
VisitCluster currentCluster = new VisitCluster();
|
||||
currentCluster.addVisit(sortedVisits.getFirst());
|
||||
|
||||
for (int i = 1; i < sortedVisits.size(); i++) {
|
||||
ScoredVisit current = sortedVisits.get(i);
|
||||
ScoredVisit previous = sortedVisits.get(i - 1);
|
||||
|
||||
long timeDiff = Duration.between(
|
||||
previous.visit().getEndTime(),
|
||||
current.visit().getStartTime()
|
||||
).getSeconds();
|
||||
|
||||
double distance = GeoUtils.distanceInMeters(
|
||||
previous.visit().getPlace().getLatitudeCentroid(),
|
||||
previous.visit().getPlace().getLongitudeCentroid(),
|
||||
current.visit().getPlace().getLatitudeCentroid(),
|
||||
current.visit().getPlace().getLongitudeCentroid()
|
||||
);
|
||||
|
||||
// Check if current visit should be added to current cluster
|
||||
if (timeDiff <= CLUSTER_TIME_THRESHOLD_SECONDS && distance <= CLUSTER_DISTANCE_THRESHOLD_METERS) {
|
||||
currentCluster.addVisit(current);
|
||||
} else {
|
||||
// Start new cluster
|
||||
clusters.add(currentCluster);
|
||||
currentCluster = new VisitCluster();
|
||||
currentCluster.addVisit(current);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last cluster
|
||||
if (!currentCluster.getVisits().isEmpty()) {
|
||||
clusters.add(currentCluster);
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
private record ScoredVisit(ProcessedVisit visit, double score) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to represent a cluster of visits
|
||||
*/
|
||||
private static class VisitCluster {
|
||||
private final List<ScoredVisit> visits = new ArrayList<>();
|
||||
|
||||
public void addVisit(ScoredVisit visit) {
|
||||
visits.add(visit);
|
||||
}
|
||||
|
||||
public List<ScoredVisit> getVisits() {
|
||||
return visits;
|
||||
}
|
||||
|
||||
public ScoredVisit getHighestScoredVisit() {
|
||||
return visits.stream()
|
||||
.max(Comparator.comparingDouble(ScoredVisit::score))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Instant getStartTime() {
|
||||
return visits.stream()
|
||||
.map(sv -> sv.visit().getStartTime())
|
||||
.min(Instant::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Instant getEndTime() {
|
||||
return visits.stream()
|
||||
.map(sv -> sv.visit().getEndTime())
|
||||
.max(Instant::compareTo)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import com.dedicatedcode.reitti.controller.error.PageNotFoundException;
|
||||
import com.dedicatedcode.reitti.model.TimeDisplayMode;
|
||||
import com.dedicatedcode.reitti.model.geo.ProcessedVisit;
|
||||
import com.dedicatedcode.reitti.model.geo.SignificantPlace;
|
||||
import com.dedicatedcode.reitti.model.geo.Trip;
|
||||
import com.dedicatedcode.reitti.model.memory.*;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.model.security.UserSettings;
|
||||
import com.dedicatedcode.reitti.repository.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
public class MemoryService {
|
||||
private static final Logger log = LoggerFactory.getLogger(MemoryService.class);
|
||||
|
||||
private final MemoryJdbcService memoryJdbcService;
|
||||
private final MemoryBlockJdbcService memoryBlockJdbcService;
|
||||
private final MemoryBlockTextJdbcService memoryBlockTextJdbcService;
|
||||
private final MemoryBlockImageGalleryJdbcService memoryBlockImageGalleryJdbcService;
|
||||
private final MemoryClusterBlockRepository memoryClusterBlockRepository;
|
||||
private final MemoryBlockGenerationService blockGenerationService;
|
||||
private final ProcessedVisitJdbcService processedVisitJdbcService;
|
||||
private final TripJdbcService tripJdbcService;
|
||||
private final UserSettingsJdbcService userSettingsJdbcService;
|
||||
|
||||
public MemoryService(
|
||||
MemoryJdbcService memoryJdbcService,
|
||||
MemoryBlockJdbcService memoryBlockJdbcService,
|
||||
MemoryBlockTextJdbcService memoryBlockTextJdbcService,
|
||||
MemoryBlockImageGalleryJdbcService memoryBlockImageGalleryJdbcService,
|
||||
MemoryClusterBlockRepository memoryClusterBlockRepository,
|
||||
MemoryBlockGenerationService blockGenerationService,
|
||||
ProcessedVisitJdbcService processedVisitJdbcService,
|
||||
TripJdbcService tripJdbcService,
|
||||
UserSettingsJdbcService userSettingsJdbcService) {
|
||||
this.memoryJdbcService = memoryJdbcService;
|
||||
this.memoryBlockJdbcService = memoryBlockJdbcService;
|
||||
this.memoryBlockTextJdbcService = memoryBlockTextJdbcService;
|
||||
this.memoryBlockImageGalleryJdbcService = memoryBlockImageGalleryJdbcService;
|
||||
this.memoryClusterBlockRepository = memoryClusterBlockRepository;
|
||||
this.blockGenerationService = blockGenerationService;
|
||||
this.processedVisitJdbcService = processedVisitJdbcService;
|
||||
this.tripJdbcService = tripJdbcService;
|
||||
this.userSettingsJdbcService = userSettingsJdbcService;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Memory createMemory(User user, Memory memory) {
|
||||
return memoryJdbcService.create(user, memory);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Memory updateMemory(User user, Memory memory) {
|
||||
return memoryJdbcService.update(user, memory);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteMemory(User user, Long memoryId) {
|
||||
memoryJdbcService.delete(user, memoryId);
|
||||
}
|
||||
|
||||
public Optional<Memory> getMemoryById(User user, Long id) {
|
||||
return memoryJdbcService.findById(user, id);
|
||||
}
|
||||
|
||||
public List<Memory> getMemoriesForUser(User user) {
|
||||
return memoryJdbcService.findAllByUser(user);
|
||||
}
|
||||
|
||||
public List<Memory> getMemoriesForUserAndYear(User user, int year) {
|
||||
return memoryJdbcService.findAllByUserAndYear(user, year);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemoryBlock addBlock(User user, Long memoryId, int position, BlockType blockType) {
|
||||
this.memoryJdbcService.findById(user, memoryId).orElseThrow(() -> new PageNotFoundException("Unable to find memory with id [" + memoryId + "]"));
|
||||
int maxPosition = memoryBlockJdbcService.getMaxPosition(memoryId);
|
||||
|
||||
MemoryBlock block = new MemoryBlock(memoryId, blockType, maxPosition + 1);
|
||||
block = memoryBlockJdbcService.create(block);
|
||||
if (position > -1) {
|
||||
List<MemoryBlock> list = memoryBlockJdbcService.findByMemoryId(memoryId);
|
||||
MemoryBlock lastBlock = list.removeLast();
|
||||
list.add(position, lastBlock);
|
||||
reorderBlocks(user, memoryId, list.stream().map(MemoryBlock::getId).toList());
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteBlock(Long blockId) {
|
||||
memoryBlockJdbcService.delete(blockId);
|
||||
}
|
||||
|
||||
public List<MemoryBlockPart> getBlockPartsForMemory(User user, Long memoryId, ZoneId timezone) {
|
||||
UserSettings settings = this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
List<MemoryBlock> blocks = memoryBlockJdbcService.findByMemoryId(memoryId);
|
||||
List<MemoryBlockPart> blockParts = new ArrayList<>();
|
||||
|
||||
for (MemoryBlock block : blocks) {
|
||||
Optional<? extends MemoryBlockPart> part = loadAndConvertBlockInstance(user, timezone, block, settings);
|
||||
part.ifPresent(blockParts::add);
|
||||
}
|
||||
return blockParts;
|
||||
}
|
||||
|
||||
public Optional<? extends MemoryBlockPart> getBlock(User user, ZoneId timezone, long memoryId, long blockId) {
|
||||
UserSettings settings = this.userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
|
||||
Optional<MemoryBlock> blockOpt = memoryBlockJdbcService.findById(user, blockId);
|
||||
if (blockOpt.isPresent()) {
|
||||
MemoryBlock block = blockOpt.get();
|
||||
if (!block.getMemoryId().equals(memoryId)) {
|
||||
throw new IllegalArgumentException("Block does not belong to this memory");
|
||||
}
|
||||
return loadAndConvertBlockInstance(user, timezone, block, settings);
|
||||
} else {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
private Optional<? extends MemoryBlockPart> loadAndConvertBlockInstance(User user, ZoneId timezone, MemoryBlock block, UserSettings settings) {
|
||||
return switch (block.getBlockType()) {
|
||||
case TEXT -> memoryBlockTextJdbcService.findByBlockId(block.getId());
|
||||
case IMAGE_GALLERY -> memoryBlockImageGalleryJdbcService.findByBlockId(block.getId());
|
||||
case CLUSTER_TRIP -> getClusterTripBlock(user, timezone, block, settings);
|
||||
case CLUSTER_VISIT -> getClusterVisitBlock(user, timezone, block, settings);
|
||||
};
|
||||
}
|
||||
|
||||
public Optional<MemoryBlock> getBlockById(User user, Long blockId) {
|
||||
return memoryBlockJdbcService.findById(user, blockId);
|
||||
}
|
||||
|
||||
public Optional<MemoryClusterBlock> getClusterBlock(User user, Long blockId) {
|
||||
return memoryClusterBlockRepository.findByBlockId(user, blockId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemoryBlockText addTextBlock(Long blockId, String headline, String content) {
|
||||
MemoryBlockText blockText = new MemoryBlockText(blockId, headline, content);
|
||||
return memoryBlockTextJdbcService.create(blockText);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemoryBlockText updateTextBlock(User user, MemoryBlockText blockText) {
|
||||
return memoryBlockTextJdbcService.update(blockText);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemoryBlockImageGallery updateImageBlock(User user, MemoryBlockImageGallery blockText) {
|
||||
return memoryBlockImageGalleryJdbcService.update(blockText);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void createClusterBlock(User user, MemoryClusterBlock clusterBlock) {
|
||||
memoryClusterBlockRepository.save(user, clusterBlock);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemoryClusterBlock updateClusterBlock(User user, MemoryClusterBlock clusterBlock) {
|
||||
return memoryClusterBlockRepository.update(user, clusterBlock);
|
||||
}
|
||||
|
||||
public Optional<MemoryBlockText> getTextBlock(Long blockId) {
|
||||
return memoryBlockTextJdbcService.findByBlockId(blockId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MemoryBlockImageGallery addImageGalleryBlock(Long blockId, List<MemoryBlockImageGallery.GalleryImage> images) {
|
||||
return this.memoryBlockImageGalleryJdbcService.create(new MemoryBlockImageGallery(blockId, images));
|
||||
|
||||
}
|
||||
@Transactional
|
||||
public void deleteImageFromGallery(Long imageId) {
|
||||
memoryBlockImageGalleryJdbcService.delete(imageId);
|
||||
}
|
||||
|
||||
public MemoryBlockImageGallery getImagesForBlock(Long blockId) {
|
||||
return memoryBlockImageGalleryJdbcService.findByBlockId(blockId).orElseThrow(() -> new IllegalArgumentException("Block not found"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void reorderBlocks(User user, Long memoryId, List<Long> blockIds) {
|
||||
// First, temporarily shift all positions to avoid unique constraint violations
|
||||
List<MemoryBlock> allBlocks = memoryBlockJdbcService.findByMemoryId(memoryId);
|
||||
int offset = blockIds.size() + 2; // Use an offset larger than the number of blocks
|
||||
for (MemoryBlock block : allBlocks) {
|
||||
memoryBlockJdbcService.update(block.withPosition(block.getPosition() + offset));
|
||||
}
|
||||
|
||||
// Now, set the correct positions
|
||||
for (int i = 0; i < blockIds.size(); i++) {
|
||||
Long blockId = blockIds.get(i);
|
||||
Optional<MemoryBlock> blockOpt = memoryBlockJdbcService.findById(user, blockId);
|
||||
|
||||
if (blockOpt.isPresent()) {
|
||||
MemoryBlock block = blockOpt.get();
|
||||
if (!block.getMemoryId().equals(memoryId)) {
|
||||
throw new IllegalArgumentException("Block does not belong to this memory");
|
||||
}
|
||||
if (!block.getPosition().equals(i)) {
|
||||
memoryBlockJdbcService.update(block.withPosition(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void recalculateMemory(User user, Long memoryId, ZoneId timezone) {
|
||||
Memory memory = memoryJdbcService.findById(user, memoryId).orElseThrow(() -> new IllegalArgumentException("Memory not found"));
|
||||
|
||||
// Delete all existing blocks
|
||||
memoryBlockJdbcService.deleteByMemoryId(memoryId);
|
||||
|
||||
// Generate new blocks
|
||||
List<MemoryBlockPart> autoGeneratedBlocks = blockGenerationService.generate(user, memory, timezone);
|
||||
|
||||
// Save the generated blocks
|
||||
for (MemoryBlockPart autoGeneratedBlock : autoGeneratedBlocks) {
|
||||
if (autoGeneratedBlock instanceof MemoryBlockText textBlock) {
|
||||
MemoryBlock memoryBlock = addBlock(user, memoryId, -1, BlockType.TEXT);
|
||||
memoryBlockTextJdbcService.create(new MemoryBlockText(memoryBlock.getId(), textBlock.getHeadline(), textBlock.getContent()));
|
||||
} else if (autoGeneratedBlock instanceof MemoryBlockImageGallery imageGalleryBlock) {
|
||||
MemoryBlock memoryBlock = addBlock(user, memoryId, -1, BlockType.IMAGE_GALLERY);
|
||||
memoryBlockImageGalleryJdbcService.create(new MemoryBlockImageGallery(memoryBlock.getId(), imageGalleryBlock.getImages()));
|
||||
} else if (autoGeneratedBlock instanceof MemoryClusterBlock clusterBlock) {
|
||||
MemoryBlock memoryBlock = addBlock(user, memoryId, -1, clusterBlock.getType());
|
||||
memoryClusterBlockRepository.save(user, new MemoryClusterBlock(memoryBlock.getId(),
|
||||
clusterBlock.getPartIds(),
|
||||
clusterBlock.getTitle(),
|
||||
clusterBlock.getDescription(), clusterBlock.getType()));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Recalculated memory {} with {} blocks", memoryId, autoGeneratedBlocks.size());
|
||||
}
|
||||
|
||||
|
||||
private Optional<? extends MemoryBlockPart> getClusterTripBlock(User user, ZoneId timezone, MemoryBlock block, UserSettings settings) {
|
||||
Optional<? extends MemoryBlockPart> part;
|
||||
Optional<MemoryClusterBlock> clusterBlockOpt = memoryClusterBlockRepository.findByBlockId(user, block.getId());
|
||||
part = clusterBlockOpt.map(memoryClusterBlock -> {
|
||||
List<Trip> trips = tripJdbcService.findByIds(user, memoryClusterBlock.getPartIds());
|
||||
Optional<Trip> first = trips.stream().findFirst();
|
||||
Optional<Trip> lastTrip = trips.stream().max(Comparator.comparing(Trip::getEndTime));
|
||||
|
||||
long movingTime = trips.stream().mapToLong(Trip::getDurationSeconds).sum();
|
||||
long completeTime = first.map(trip -> Duration.between(trip.getStartTime(), lastTrip.get().getEndTime()).toSeconds()).orElse(0L);
|
||||
LocalDateTime adjustedStartTime = first.map(t -> adjustTime(settings, t.getStartTime(), t.getStartVisit().getPlace(), timezone)).orElse(null);
|
||||
LocalDateTime adjustedEndTime = lastTrip.map(t -> adjustTime(settings, t.getEndTime(), t.getEndVisit().getPlace(), timezone)).orElse(null);
|
||||
return new MemoryTripClusterBlockDTO(
|
||||
memoryClusterBlock,
|
||||
trips,
|
||||
"/api/v1/raw-location-points/trips?trips=" + String.join(",", memoryClusterBlock.getPartIds().stream().map(Objects::toString).toList()),
|
||||
adjustedStartTime,
|
||||
adjustedEndTime,
|
||||
completeTime,
|
||||
movingTime);
|
||||
});
|
||||
return part;
|
||||
}
|
||||
|
||||
private Optional<? extends MemoryBlockPart> getClusterVisitBlock(User user, ZoneId timezone, MemoryBlock block, UserSettings settings) {
|
||||
Optional<? extends MemoryBlockPart> part;
|
||||
Optional<MemoryClusterBlock> clusterVisitBlockOpt = memoryClusterBlockRepository.findByBlockId(user, block.getId());
|
||||
part = clusterVisitBlockOpt.map(memoryClusterBlock -> {
|
||||
List<ProcessedVisit> visits = processedVisitJdbcService.findByIds(user, memoryClusterBlock.getPartIds());
|
||||
Optional<ProcessedVisit> first = visits.stream().findFirst();
|
||||
Optional<ProcessedVisit> last = visits.stream().max(Comparator.comparing(ProcessedVisit::getEndTime));
|
||||
|
||||
LocalDateTime adjustedStartTime = first.map(t -> adjustTime(settings, t.getStartTime(), t.getPlace(), timezone)).orElse(null);
|
||||
LocalDateTime adjustedEndTime = last.map(t -> adjustTime(settings, t.getEndTime(), t.getPlace(), timezone)).orElse(null);
|
||||
Long completeDuration = 0L;
|
||||
return new MemoryVisitClusterBlockDTO(
|
||||
memoryClusterBlock,
|
||||
visits,
|
||||
"/api/v1/raw-location-points?startDate=" + first.get().getStartTime().atZone(timezone).toLocalDateTime() + "&endDate=" + last.get().getEndTime().atZone(timezone).toLocalDateTime() + "&timezone=" + timezone,
|
||||
adjustedStartTime,
|
||||
adjustedEndTime,
|
||||
completeDuration);
|
||||
});
|
||||
return part;
|
||||
}
|
||||
|
||||
private LocalDateTime adjustTime(UserSettings settings, Instant startTime, SignificantPlace place, ZoneId timezone) {
|
||||
if (settings.getTimeDisplayMode() == TimeDisplayMode.DEFAULT) {
|
||||
return startTime.atZone(timezone).toLocalDateTime();
|
||||
} else {
|
||||
return startTime.atZone(place.getTimezone()).toLocalDateTime();
|
||||
}
|
||||
}
|
||||
|
||||
public List<Integer> getAvailableYears(User user) {
|
||||
return this.memoryJdbcService.findDistinctYears(user);
|
||||
}
|
||||
|
||||
public long getOwnerId(Memory memory) {
|
||||
return this.memoryJdbcService.getOwnerId(memory).orElseThrow(() -> new PageNotFoundException("Memory not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
public final class RequestHelper {
|
||||
private RequestHelper() {
|
||||
}
|
||||
|
||||
public static String getBaseUrl(HttpServletRequest request) {
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
StringBuilder url = new StringBuilder();
|
||||
url.append(scheme).append("://").append(serverName);
|
||||
|
||||
if ((scheme.equals("http") && serverPort != 80) || (scheme.equals("https") && serverPort != 443)) {
|
||||
url.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
url.append(contextPath);
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.dedicatedcode.reitti.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
public class StorageService {
|
||||
private final String storagePath;
|
||||
|
||||
public StorageService(@Value("${reitti.storage.path}") String storagePath) {
|
||||
this.storagePath = storagePath;
|
||||
Path path = Paths.get(storagePath);
|
||||
if (!Files.isWritable(path)) {
|
||||
throw new RuntimeException("Storage path '" + storagePath + "' is not writable. Please ensure the directory exists and the application has write permissions.");
|
||||
}
|
||||
}
|
||||
|
||||
public void store(String itemName, InputStream content, long contentLength, String contentType) {
|
||||
Path filePath = Paths.get(storagePath, itemName);
|
||||
try {
|
||||
Files.createDirectories(filePath.getParent());
|
||||
Files.copy(content, filePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to store item '" + itemName + "': " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public StorageContent read(String itemName) {
|
||||
Path filePath = Paths.get(storagePath, itemName);
|
||||
try {
|
||||
InputStream inputStream = Files.newInputStream(filePath);
|
||||
String contentType = Files.probeContentType(filePath);
|
||||
long contentLength = Files.size(filePath);
|
||||
return new StorageContent(inputStream, contentType, contentLength);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Failed to read item '" + itemName + "': " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean exists(String itemName) {
|
||||
Path basePath = Paths.get(storagePath);
|
||||
PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + itemName);
|
||||
try (Stream<Path> paths = Files.walk(basePath)) {
|
||||
return paths
|
||||
.map(basePath::relativize)
|
||||
.anyMatch(matcher::matches);
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static class StorageContent {
|
||||
private final InputStream inputStream;
|
||||
private final String contentType;
|
||||
private final Long contentLength;
|
||||
|
||||
public StorageContent(InputStream inputStream, String contentType, Long contentLength) {
|
||||
this.inputStream = inputStream;
|
||||
this.contentType = contentType;
|
||||
this.contentLength = contentLength;
|
||||
}
|
||||
|
||||
public InputStream getInputStream() {
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public Long getContentLength() {
|
||||
return contentLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,10 @@ public class ReverseGeocodingListener {
|
||||
} else {
|
||||
place = place.withName(street).withAddress(address);
|
||||
}
|
||||
place = place.withType(placeType).withCountryCode(countryCode);
|
||||
place = place
|
||||
.withType(placeType)
|
||||
.withCity(city)
|
||||
.withCountryCode(countryCode);
|
||||
|
||||
significantPlaceJdbcService.update(place.withGeocoded(true));
|
||||
logger.info("Updated place ID: {} with geocoding data: {}", place.getId(), label);
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.dedicatedcode.reitti.model.integration.ImmichIntegration;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.dedicatedcode.reitti.repository.ImmichIntegrationJdbcService;
|
||||
import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService;
|
||||
import com.dedicatedcode.reitti.service.StorageService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.*;
|
||||
@@ -34,13 +35,16 @@ public class ImmichIntegrationService {
|
||||
private final ImmichIntegrationJdbcService immichIntegrationJdbcService;
|
||||
private final RawLocationPointJdbcService rawLocationPointJdbcService;
|
||||
private final RestTemplate restTemplate;
|
||||
private final StorageService storageService;
|
||||
|
||||
public ImmichIntegrationService(ImmichIntegrationJdbcService immichIntegrationJdbcService,
|
||||
RawLocationPointJdbcService rawLocationPointJdbcService,
|
||||
RestTemplate restTemplate) {
|
||||
RestTemplate restTemplate,
|
||||
StorageService storageService) {
|
||||
this.immichIntegrationJdbcService = immichIntegrationJdbcService;
|
||||
this.rawLocationPointJdbcService = rawLocationPointJdbcService;
|
||||
this.restTemplate = restTemplate;
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
public Optional<ImmichIntegration> getIntegrationForUser(User user) {
|
||||
@@ -148,9 +152,8 @@ public class ImmichIntegrationService {
|
||||
|
||||
if (searchResponse.getAssets() != null && searchResponse.getAssets().getItems() != null) {
|
||||
for (ImmichAsset asset : searchResponse.getAssets().getItems()) {
|
||||
// Use proxy URLs instead of direct Immich URLs
|
||||
String thumbnailUrl = "/api/v1/photos/proxy/" + asset.getId() + "/thumbnail";
|
||||
String fullImageUrl = "/api/v1/photos/proxy/" + asset.getId() + "/original";
|
||||
String thumbnailUrl = "/api/v1/photos/immich/proxy/" + asset.getId() + "/thumbnail";
|
||||
String fullImageUrl = "/api/v1/photos/immich/proxy/" + asset.getId() + "/original";
|
||||
|
||||
Double latitude = null;
|
||||
Double longitude = null;
|
||||
@@ -193,4 +196,82 @@ public class ImmichIntegrationService {
|
||||
|
||||
return photos;
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> proxyImageRequest(User user, String assetId, String size) {
|
||||
Optional<ImmichIntegration> integrationOpt = getIntegrationForUser(user);
|
||||
|
||||
if (integrationOpt.isEmpty() || !integrationOpt.get().isEnabled()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
ImmichIntegration integration = integrationOpt.get();
|
||||
|
||||
try {
|
||||
String baseUrl = integration.getServerUrl().endsWith("/") ?
|
||||
integration.getServerUrl() : integration.getServerUrl() + "/";
|
||||
String imageUrl = baseUrl + "api/assets/" + assetId + "/thumbnail?size=" + size;
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("x-api-key", integration.getApiToken());
|
||||
|
||||
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<byte[]> response = restTemplate.exchange(
|
||||
imageUrl,
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
byte[].class
|
||||
);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
||||
HttpHeaders responseHeaders = new HttpHeaders();
|
||||
|
||||
// Copy content type from Immich response if available
|
||||
if (response.getHeaders().getContentType() != null) {
|
||||
responseHeaders.setContentType(response.getHeaders().getContentType());
|
||||
} else {
|
||||
// Default to JPEG for images
|
||||
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
|
||||
}
|
||||
|
||||
// Set cache headers for better performance
|
||||
responseHeaders.setCacheControl("public, max-age=3600");
|
||||
|
||||
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
// Log error but don't expose details
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
public String downloadImage(User user, String assetId, String targetPath) {
|
||||
ResponseEntity<byte[]> response = proxyImageRequest(user, assetId, "fullsize");
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
byte[] imageData = response.getBody();
|
||||
if (imageData != null) {
|
||||
String contentType = response.getHeaders().getContentType() != null ? response.getHeaders().getContentType().toString() : "image/jpeg";
|
||||
long contentLength = imageData.length;
|
||||
String filename = assetId + getExtensionFromContentType(contentType);
|
||||
storageService.store(targetPath + "/" + filename, new java.io.ByteArrayInputStream(imageData), contentLength, contentType);
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Unable to download image from Immich");
|
||||
}
|
||||
|
||||
|
||||
private String getExtensionFromContentType(String contentType) {
|
||||
return switch (contentType) {
|
||||
case "image/jpeg" -> ".jpg";
|
||||
case "image/png" -> ".png";
|
||||
case "image/gif" -> ".gif";
|
||||
case "image/webp" -> ".webp";
|
||||
case null, default -> ".jpg"; // default
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,5 @@ spring.thymeleaf.cache=false
|
||||
reitti.server.advertise-uri=http://localhost:8080
|
||||
|
||||
reitti.security.local-login.disable=false
|
||||
|
||||
reitti.storage.path=data/
|
||||
|
||||
@@ -44,4 +44,6 @@ reitti.ui.tiles.custom.attribution=${CUSTOM_TILES_ATTRIBUTION:}
|
||||
reitti.import.batch-size=${PROCESSING_BATCH_SIZE:1000}
|
||||
reitti.events.concurrency=${PROCESSING_WORKERS_PER_QUEUE:4-16}
|
||||
|
||||
reitti.storage.path=/data/
|
||||
|
||||
logging.level.root = INFO
|
||||
|
||||
@@ -96,6 +96,9 @@ reitti.ui.tiles.default.attribution=© <a href="https://www.openstreetmap.or
|
||||
reitti.data-management.enabled=false
|
||||
reitti.data-management.preview-cleanup.cron=0 0 4 * * *
|
||||
|
||||
reitti.storage.path=data/
|
||||
reitti.storage.cleanup.cron=0 0 4 * * *
|
||||
|
||||
# For OIDC security configuration, create a separate oidc.properties file instead of configuring OIDC settings directly in this file. See the oidc.properties.example for the needed properties.
|
||||
spring.config.import=optional:oidc.properties
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Create memory table
|
||||
CREATE TABLE memory (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
header_type VARCHAR(50) NOT NULL,
|
||||
header_image_url TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
CONSTRAINT memory_date_range_check CHECK (end_date >= start_date)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_user_id ON memory(user_id);
|
||||
CREATE INDEX idx_memory_date_range ON memory(start_date, end_date);
|
||||
CREATE INDEX idx_memory_created_at ON memory(created_at DESC);
|
||||
|
||||
-- Create memory_block table
|
||||
CREATE TABLE memory_block (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
memory_id BIGINT NOT NULL REFERENCES memory(id) ON DELETE CASCADE,
|
||||
block_type VARCHAR(50) NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
CONSTRAINT memory_block_position_check CHECK (position >= 0),
|
||||
CONSTRAINT memory_block_unique_position UNIQUE (memory_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_block_memory_id ON memory_block(memory_id);
|
||||
CREATE INDEX idx_memory_block_position ON memory_block(memory_id, position);
|
||||
|
||||
-- Create memory_block_visit table
|
||||
CREATE TABLE memory_block_visit (
|
||||
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
|
||||
visit_id BIGINT NOT NULL REFERENCES processed_visits(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_block_visit_visit_id ON memory_block_visit(visit_id);
|
||||
|
||||
-- Create memory_block_trip table
|
||||
CREATE TABLE memory_block_trip (
|
||||
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
|
||||
trip_id BIGINT NOT NULL REFERENCES trips(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_block_trip_trip_id ON memory_block_trip(trip_id);
|
||||
|
||||
-- Create memory_block_text table
|
||||
CREATE TABLE memory_block_text (
|
||||
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
|
||||
headline VARCHAR(255),
|
||||
content TEXT
|
||||
);
|
||||
|
||||
-- Create memory_block_image_gallery table
|
||||
CREATE TABLE memory_block_image_gallery (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
block_id BIGINT NOT NULL REFERENCES memory_block(id) ON DELETE CASCADE,
|
||||
image_url TEXT NOT NULL,
|
||||
caption TEXT,
|
||||
position INTEGER NOT NULL,
|
||||
CONSTRAINT memory_block_image_gallery_position_check CHECK (position >= 0),
|
||||
CONSTRAINT memory_block_image_gallery_unique_position UNIQUE (block_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_block_image_gallery_block_id ON memory_block_image_gallery(block_id);
|
||||
CREATE INDEX idx_memory_block_image_gallery_position ON memory_block_image_gallery(block_id, position);
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Drop existing foreign key constraints and recreate tables with embedded data
|
||||
|
||||
-- Drop old tables
|
||||
DROP TABLE IF EXISTS memory_block_visit;
|
||||
DROP TABLE IF EXISTS memory_block_trip;
|
||||
|
||||
-- Recreate memory_block_visit with embedded data
|
||||
CREATE TABLE memory_block_visit (
|
||||
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
|
||||
original_processed_visit_id BIGINT REFERENCES processed_visits(id) ON DELETE SET NULL,
|
||||
place_name VARCHAR(255),
|
||||
place_address TEXT,
|
||||
latitude DOUBLE PRECISION,
|
||||
longitude DOUBLE PRECISION,
|
||||
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
duration_seconds BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_block_visit_original_id ON memory_block_visit(original_processed_visit_id);
|
||||
CREATE INDEX idx_memory_block_visit_start_time ON memory_block_visit(start_time);
|
||||
|
||||
-- Recreate memory_block_trip with embedded data
|
||||
CREATE TABLE memory_block_trip (
|
||||
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
|
||||
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
duration_seconds BIGINT NOT NULL,
|
||||
estimated_distance_meters DOUBLE PRECISION,
|
||||
travelled_distance_meters DOUBLE PRECISION,
|
||||
transport_mode_inferred VARCHAR(50),
|
||||
start_place_name VARCHAR(255),
|
||||
start_latitude DOUBLE PRECISION,
|
||||
start_longitude DOUBLE PRECISION,
|
||||
end_place_name VARCHAR(255),
|
||||
end_latitude DOUBLE PRECISION,
|
||||
end_longitude DOUBLE PRECISION
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_block_trip_start_time ON memory_block_trip(start_time);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE memory_block_cluster (
|
||||
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
trip_ids JSONB NOT NULL, -- JSON array of trip block IDs (e.g., [1, 2, 3])
|
||||
title VARCHAR(255),
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_block_cluster_block_id ON memory_block_cluster(block_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE significant_places ADD COLUMN city VARCHAR(255);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Refactor memory_block_image_gallery to store multiple images per block
|
||||
-- Drop the old table and create a new one with JSONB for images
|
||||
|
||||
DROP TABLE IF EXISTS memory_block_image_gallery;
|
||||
|
||||
CREATE TABLE memory_block_image_gallery (
|
||||
block_id BIGINT PRIMARY KEY REFERENCES memory_block(id) ON DELETE CASCADE,
|
||||
images JSONB NOT NULL DEFAULT '[]'::jsonb
|
||||
);
|
||||
|
||||
CREATE INDEX idx_memory_block_image_gallery_block_id ON memory_block_image_gallery(block_id);
|
||||
|
||||
COMMENT ON TABLE memory_block_image_gallery IS 'Stores image galleries for memory blocks with multiple images per gallery';
|
||||
COMMENT ON COLUMN memory_block_image_gallery.images IS 'JSON array of image objects with imageUrl and caption fields';
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE memory_block_cluster RENAME COLUMN trip_ids TO part_ids;
|
||||
ALTER TABLE memory_block_cluster ADD COLUMN type VARCHAR(255) DEFAULT 'CLUSTER_TRIP';
|
||||
ALTER TABLE memory_block_cluster ALTER COLUMN type DROP DEFAULT;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE memory_block_trip;
|
||||
DROP TABLE memory_block_Visit;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE magic_link_tokens ADD COLUMN resource_type VARCHAR(255) DEFAULT 'MAP';
|
||||
ALTER TABLE magic_link_tokens ADD COLUMN resource_id BIGINT;
|
||||
|
||||
ALTER TABLE magic_link_tokens ALTER COLUMN resource_type DROP DEFAULT;
|
||||
@@ -5,6 +5,7 @@ statistics.page.title=Statistics - Reitti
|
||||
# Navigation
|
||||
nav.timeline=Timeline
|
||||
nav.statistics=Statistics
|
||||
nav.memories=Memories
|
||||
nav.settings=Settings
|
||||
nav.logout=Logout
|
||||
nav.settings.tooltip=Open settings\u2026
|
||||
@@ -55,6 +56,261 @@ settings.job.status=Job Status
|
||||
settings.import.data=Import Data
|
||||
settings.share.access=Share Access
|
||||
|
||||
# Countries
|
||||
|
||||
country.af.label=Afghanistan
|
||||
country.ax.label=\u00C5land Islands
|
||||
country.al.label=Albania
|
||||
country.dz.label=Algeria
|
||||
country.as.label=American Samoa
|
||||
country.ad.label=Andorra
|
||||
country.ao.label=Angola
|
||||
country.ai.label=Anguilla
|
||||
country.aq.label=Antarctica
|
||||
country.ag.label=Antigua and Barbuda
|
||||
country.ar.label=Argentina
|
||||
country.am.label=Armenia
|
||||
country.aw.label=Aruba
|
||||
country.au.label=Australia
|
||||
country.at.label=Austria
|
||||
country.az.label=Azerbaijan
|
||||
country.bs.label=Bahamas
|
||||
country.bh.label=Bahrain
|
||||
country.bd.label=Bangladesh
|
||||
country.bb.label=Barbados
|
||||
country.by.label=Belarus
|
||||
country.be.label=Belgium
|
||||
country.bz.label=Belize
|
||||
country.bj.label=Benin
|
||||
country.bm.label=Bermuda
|
||||
country.bt.label=Bhutan
|
||||
country.bo.label=Bolivia
|
||||
country.bq.label=Bonaire, Sint Eustatius and Saba
|
||||
country.ba.label=Bosnia and Herzegovina
|
||||
country.bw.label=Botswana
|
||||
country.br.label=Brazil
|
||||
country.io.label=British Indian Ocean Territory
|
||||
country.bn.label=Brunei Darussalam
|
||||
country.bg.label=Bulgaria
|
||||
country.bf.label=Burkina Faso
|
||||
country.bi.label=Burundi
|
||||
country.cv.label=Cabo Verde
|
||||
country.kh.label=Cambodia
|
||||
country.cm.label=Cameroon
|
||||
country.ca.label=Canada
|
||||
country.ky.label=Cayman Islands
|
||||
country.cf.label=Central African Republic
|
||||
country.td.label=Chad
|
||||
country.cl.label=Chile
|
||||
country.cn.label=China
|
||||
country.cx.label=Christmas Island
|
||||
country.cc.label=Cocos (Keeling) Islands
|
||||
country.co.label=Colombia
|
||||
country.km.label=Comoros
|
||||
country.cg.label=Congo
|
||||
country.cd.label=Congo (Democratic Republic of the)
|
||||
country.ck.label=Cook Islands
|
||||
country.cr.label=Costa Rica
|
||||
country.ci.label=C\u00F4te d'Ivoire
|
||||
country.hr.label=Croatia
|
||||
country.cu.label=Cuba
|
||||
country.cw.label=Cura\u00E7ao
|
||||
country.cy.label=Cyprus
|
||||
country.cz.label=Czechia
|
||||
country.dk.label=Denmark
|
||||
country.dj.label=Djibouti
|
||||
country.dm.label=Dominica
|
||||
country.do.label=Dominican Republic
|
||||
country.ec.label=Ecuador
|
||||
country.eg.label=Egypt
|
||||
country.sv.label=El Salvador
|
||||
country.gq.label=Equatorial Guinea
|
||||
country.er.label=Eritrea
|
||||
country.ee.label=Estonia
|
||||
country.sz.label=Eswatini
|
||||
country.et.label=Ethiopia
|
||||
country.fk.label=Falkland Islands (Malvinas)
|
||||
country.fo.label=Faroe Islands
|
||||
country.fj.label=Fiji
|
||||
country.fi.label=Finland
|
||||
country.fr.label=France
|
||||
country.gf.label=French Guiana
|
||||
country.pf.label=French Polynesia
|
||||
country.tf.label=French Southern Territories
|
||||
country.ga.label=Gabon
|
||||
country.gm.label=Gambia
|
||||
country.ge.label=Georgia
|
||||
country.de.label=Germany
|
||||
country.gh.label=Ghana
|
||||
country.gi.label=Gibraltar
|
||||
country.gr.label=Greece
|
||||
country.gl.label=Greenland
|
||||
country.gd.label=Grenada
|
||||
country.gp.label=Guadeloupe
|
||||
country.gu.label=Guam
|
||||
country.gt.label=Guatemala
|
||||
country.gg.label=Guernsey
|
||||
country.gn.label=Guinea
|
||||
country.gw.label=Guinea-Bissau
|
||||
country.gy.label=Guyana
|
||||
country.ht.label=Haiti
|
||||
country.va.label=Holy See
|
||||
country.hn.label=Honduras
|
||||
country.hk.label=Hong Kong
|
||||
country.hu.label=Hungary
|
||||
country.is.label=Iceland
|
||||
country.in.label=India
|
||||
country.id.label=Indonesia
|
||||
country.ir.label=Iran
|
||||
country.iq.label=Iraq
|
||||
country.ie.label=Ireland
|
||||
country.im.label=Isle of Man
|
||||
country.il.label=Israel
|
||||
country.it.label=Italy
|
||||
country.jm.label=Jamaica
|
||||
country.jp.label=Japan
|
||||
country.je.label=Jersey
|
||||
country.jo.label=Jordan
|
||||
country.kz.label=Kazakhstan
|
||||
country.ke.label=Kenya
|
||||
country.ki.label=Kiribati
|
||||
country.kp.label=Korea (North)
|
||||
country.kr.label=Korea (South)
|
||||
country.kw.label=Kuwait
|
||||
country.kg.label=Kyrgyzstan
|
||||
country.la.label=Laos
|
||||
country.lv.label=Latvia
|
||||
country.lb.label=Lebanon
|
||||
country.ls.label=Lesotho
|
||||
country.lr.label=Liberia
|
||||
country.ly.label=Libya
|
||||
country.li.label=Liechtenstein
|
||||
country.lt.label=Lithuania
|
||||
country.lu.label=Luxembourg
|
||||
country.mo.label=Macao
|
||||
country.mg.label=Madagascar
|
||||
country.mw.label=Malawi
|
||||
country.my.label=Malaysia
|
||||
country.mv.label=Maldives
|
||||
country.ml.label=Mali
|
||||
country.mt.label=Malta
|
||||
country.mh.label=Marshall Islands
|
||||
country.mq.label=Martinique
|
||||
country.mr.label=Mauritania
|
||||
country.mu.label=Mauritius
|
||||
country.yt.label=Mayotte
|
||||
country.mx.label=Mexico
|
||||
country.fm.label=Micronesia
|
||||
country.md.label=Moldova
|
||||
country.mc.label=Monaco
|
||||
country.mn.label=Mongolia
|
||||
country.me.label=Montenegro
|
||||
country.ms.label=Montserrat
|
||||
country.ma.label=Morocco
|
||||
country.mz.label=Mozambique
|
||||
country.mm.label=Myanmar
|
||||
country.na.label=Namibia
|
||||
country.nr.label=Nauru
|
||||
country.np.label=Nepal
|
||||
country.nl.label=Netherlands
|
||||
country.nc.label=New Caledonia
|
||||
country.nz.label=New Zealand
|
||||
country.ni.label=Nicaragua
|
||||
country.ne.label=Niger
|
||||
country.ng.label=Nigeria
|
||||
country.nu.label=Niue
|
||||
country.nf.label=Norfolk Island
|
||||
country.mp.label=Northern Mariana Islands
|
||||
country.mk.label=North Macedonia
|
||||
country.no.label=Norway
|
||||
country.om.label=Oman
|
||||
country.pk.label=Pakistan
|
||||
country.pw.label=Palau
|
||||
country.ps.label=Palestine, State of
|
||||
country.pa.label=Panama
|
||||
country.pg.label=Papua New Guinea
|
||||
country.py.label=Paraguay
|
||||
country.pe.label=Peru
|
||||
country.ph.label=Philippines
|
||||
country.pn.label=Pitcairn
|
||||
country.pl.label=Poland
|
||||
country.pt.label=Portugal
|
||||
country.pr.label=Puerto Rico
|
||||
country.qa.label=Qatar
|
||||
country.re.label=R\u00E9union
|
||||
country.ro.label=Romania
|
||||
country.ru.label=Russian Federation
|
||||
country.rw.label=Rwanda
|
||||
country.bl.label=Saint Barth\u00E9lemy
|
||||
country.sh.label=Saint Helena, Ascension and Tristan da Cunha
|
||||
country.kn.label=Saint Kitts and Nevis
|
||||
country.lc.label=Saint Lucia
|
||||
country.mf.label=Saint Martin (French part)
|
||||
country.pm.label=Saint Pierre and Miquelon
|
||||
country.vc.label=Saint Vincent and the Grenadines
|
||||
country.ws.label=Samoa
|
||||
country.sm.label=San Marino
|
||||
country.st.label=Sao Tome and Principe
|
||||
country.sa.label=Saudi Arabia
|
||||
country.sn.label=Senegal
|
||||
country.rs.label=Serbia
|
||||
country.sc.label=Seychelles
|
||||
country.sl.label=Sierra Leone
|
||||
country.sg.label=Singapore
|
||||
country.sx.label=Sint Maarten (Dutch part)
|
||||
country.sk.label=Slovakia
|
||||
country.si.label=Slovenia
|
||||
country.sb.label=Solomon Islands
|
||||
country.so.label=Somalia
|
||||
country.za.label=South Africa
|
||||
country.gs.label=South Georgia and the South Sandwich Islands
|
||||
country.ss.label=South Sudan
|
||||
country.es.label=Spain
|
||||
country.lk.label=Sri Lanka
|
||||
country.sd.label=Sudan
|
||||
country.sr.label=Suriname
|
||||
country.sj.label=Svalbard and Jan Mayen
|
||||
country.se.label=Sweden
|
||||
country.ch.label=Switzerland
|
||||
country.sy.label=Syrian Arab Republic
|
||||
country.tw.label=Taiwan
|
||||
country.tj.label=Tajikistan
|
||||
country.tz.label=Tanzania
|
||||
country.th.label=Thailand
|
||||
country.tl.label=Timor-Leste
|
||||
country.tg.label=Togo
|
||||
country.tk.label=Tokelau
|
||||
country.to.label=Tonga
|
||||
country.tt.label=Trinidad and Tobago
|
||||
country.tn.label=Tunisia
|
||||
country.tr.label=Turkey
|
||||
country.tm.label=Turkmenistan
|
||||
country.tc.label=Turks and Caicos Islands
|
||||
country.tv.label=Tuvalu
|
||||
country.ug.label=Uganda
|
||||
country.ua.label=Ukraine
|
||||
country.ae.label=United Arab Emirates
|
||||
country.gb.label=United Kingdom
|
||||
country.us.label=United States
|
||||
country.um.label=United States Minor Outlying Islands
|
||||
country.uy.label=Uruguay
|
||||
country.uz.label=Uzbekistan
|
||||
country.vu.label=Vanuatu
|
||||
country.ve.label=Venezuela
|
||||
country.vn.label=Viet Nam
|
||||
country.vg.label=Virgin Islands (British)
|
||||
country.vi.label=Virgin Islands (U.S.)
|
||||
country.wf.label=Wallis and Futuna
|
||||
country.eh.label=Western Sahara
|
||||
country.ye.label=Yemen
|
||||
country.zm.label=Zambia
|
||||
country.zw.label=Zimbabwe
|
||||
country.unknown.label=Unknown
|
||||
|
||||
#Formats
|
||||
format.hours_minutes={0,choice,0#|1#{0} hour|1<{0} hours} {1,choice,0#|1#and {1} minute|1<and {1} minutes}
|
||||
format.minutes_only={0,choice,1#{0} minute|1<{0} minutes}
|
||||
|
||||
# Navigation
|
||||
nav.back.to.timeline=Back to Timeline
|
||||
|
||||
@@ -171,6 +427,8 @@ places.edit.visit.stats.title=Visit Statistics
|
||||
places.edit.visit.summary=You visited {0} {1} times.
|
||||
places.edit.visit.complete=You visited {0} {1} times. Your first visit was on {2} and your most recent visit was on {3}.
|
||||
places.edit.no.visits=No visits recorded for this place yet.
|
||||
place.type.train_station=Train Station
|
||||
place.type.gas_station=Gas Station
|
||||
place.type.restaurant=Restaurant
|
||||
place.type.park=Park
|
||||
place.type.shop=Shop
|
||||
@@ -179,8 +437,6 @@ place.type.work=Work
|
||||
place.type.hospital=Hospital
|
||||
place.type.school=School
|
||||
place.type.airport=Airport
|
||||
place.type.train_station=Train Station
|
||||
place.type.gas_station=Gas Station
|
||||
place.type.hotel=Hotel
|
||||
place.type.bank=Bank
|
||||
place.type.pharmacy=Pharmacy
|
||||
@@ -189,12 +445,25 @@ place.type.library=Library
|
||||
place.type.church=Church
|
||||
place.type.cinema=Cinema
|
||||
place.type.cafe=Cafe
|
||||
place.type.museum=Museum
|
||||
place.type.landmark=Landmark
|
||||
place.type.tourist_attraction=Tourist Attraction
|
||||
place.type.historic_site=Historic Site
|
||||
place.type.monument=Monument
|
||||
place.type.shopping_mall=Shopping Mall
|
||||
place.type.market=Market
|
||||
place.type.gallery=Gallery
|
||||
place.type.theater=Theater
|
||||
place.type.grocery_store=Grocery Store
|
||||
place.type.atm=ATM
|
||||
place.type.other=Other
|
||||
# Forms
|
||||
form.create=Create
|
||||
form.update=Update
|
||||
form.delete=Delete
|
||||
form.cancel=Cancel
|
||||
form.save.changes=Save Changes
|
||||
form.save=Save
|
||||
form.previous=Previous
|
||||
form.next=Next
|
||||
form.refresh=Refresh
|
||||
@@ -296,7 +565,7 @@ integrations.owntracks.recorder.auth.optional=Leave empty if no authentication i
|
||||
integrations.owntracks.recorder.enabled=Enable Integration
|
||||
integrations.owntracks.recorder.save=Save Configuration
|
||||
integrations.owntracks.recorder.test.connection=Test Connection
|
||||
integrations.owntracks.recorder.connection.success=Connection successful
|
||||
integrations.owntracks.recorder.connection.success=The connection was successful.
|
||||
integrations.owntracks.recorder.connection.failed=Connection failed: {0}
|
||||
integrations.owntracks.recorder.config.saved=OwnTracks Recorder configuration saved successfully
|
||||
integrations.owntracks.recorder.config.error=Error saving configuration: {0}
|
||||
@@ -540,6 +809,13 @@ error.action.home=Go Home
|
||||
error.action.back=Go Back
|
||||
error.action.retry=Try Again
|
||||
|
||||
# Memory validation
|
||||
memory.validation.start.date.required=Start date is required
|
||||
memory.validation.end.date.required=End date is required
|
||||
memory.validation.end.date.before.start=End date cannot be before start date
|
||||
memory.validation.title.required=Title is required
|
||||
memory.validation.date.future=Dates cannot be in the future
|
||||
|
||||
|
||||
share-access.title=Share Access
|
||||
magic.links.title=Magic Links
|
||||
@@ -562,6 +838,8 @@ magic.links.access.level.label=Access Level
|
||||
magic.links.access.level.full_access=Full Access
|
||||
magic.links.access.level.only_live=Live Data Only
|
||||
magic.links.access.level.only_live_with_photos=Live Data Only + Photos
|
||||
magic.links.access.level.memory_view_only=View Memory
|
||||
magic.links.access.level.memory_edit_access=View and Edit Memory
|
||||
magic.links.expiry.days.label=Expiry (Days)
|
||||
magic.links.expiry.days.placeholder=e.g., 30
|
||||
magic.links.expiry.days.help=Leave empty for no expiration
|
||||
@@ -626,6 +904,7 @@ visit.sensitivity.recalculation.error=Error starting recalculation: {0}
|
||||
# Visit Sensitivity Validation Messages
|
||||
visit.sensitivity.validation.date.duplicate=A configuration already exists for this date. Please choose a different date.
|
||||
visit.sensitivity.validation.save.error=Error saving configuration: {0}
|
||||
|
||||
magic.links.info.title=About Magic Links
|
||||
magic.links.info.description=Magic links allow you to share your location data with others without requiring them to create an account. Anyone with the link can access your data according to the permissions you set.
|
||||
magic.links.info.security.title=Security Considerations
|
||||
@@ -766,3 +1045,149 @@ settings.geocoding.description=Configure geocoding services to convert coordinat
|
||||
settings.manage.data.description=Manually trigger data processing and manage your location data
|
||||
settings.integrations.description=Connect external services and mobile apps to automatically import location data
|
||||
settings.about.description=View application version and build information
|
||||
|
||||
memory.new.page.title=New Memory - Reitti
|
||||
memory.new.title=New Memory
|
||||
memory.new.back.to.memories=Back to Memories
|
||||
memory.form.title.label=Title *
|
||||
memory.form.title.placeholder=Give your memory a title
|
||||
memory.form.description.label=Description
|
||||
memory.form.description.placeholder=Add a description (optional)
|
||||
memory.form.start.date.label=Start Date *
|
||||
memory.form.end.date.label=End Date *
|
||||
memory.form.header.type.label=Header Type *
|
||||
memory.form.header.type.map=Map
|
||||
memory.form.header.type.image=Image
|
||||
memory.form.header.image.url.label=Header Image URL
|
||||
memory.form.header.image.url.placeholder=https://example.com/image.jpg
|
||||
memory.form.cancel=Cancel
|
||||
memory.form.create=Create Memory
|
||||
memory.form.creating=Creating...
|
||||
|
||||
memory.view.edit=Edit
|
||||
memory.view.back=Back
|
||||
memory.view.recalculate=Recalculate
|
||||
memory.view.add.block=Add Block after
|
||||
memory.view.add.first.block=Add your first Block
|
||||
memory.view.no.blocks=No blocks yet. Add your first block to start building your memory.
|
||||
memory.view.block.text.title=Text Block
|
||||
memory.view.block.text.content=Content will be loaded here
|
||||
memory.view.block.visit.content=Visit block
|
||||
memory.view.block.trip.content=Trip block
|
||||
memory.view.block.gallery.content=Image gallery
|
||||
memory.view.block.cluster.duration=Took {0} hours {1} minutes. {2} hours and {3} moving.
|
||||
memory.view.block.cluster_visit.duration=Spent {0} hours {1} minutes.
|
||||
|
||||
memory.generator.day.text=Day {0}: {1}
|
||||
memory.generator.headline.text=Our Journey
|
||||
|
||||
memory.generator.introductory.text=What an unforgettable adventure we had! Our journey began on {0} as we set out from {1}, and for the \
|
||||
next {2} days, we made {3}, {4} our wonderful home base. \
|
||||
From there, we explored the heart of the region, filling our days with {5} memorable visits across {6} beautiful locations. \
|
||||
This is the story of our time together, the places we saw, and the memories we created before returning home on {7}.
|
||||
memory.generator.travel_to_accommodation.text=We set off from {0} at {1} and arrived in {2} at {3}. \
|
||||
The total time for this part of our trip was {4}, with {5} of that spent actively traveling. Now it's time for us to relax, unpack, and prepare for what's next.
|
||||
memory.generator.travel_from_accommodation.text=We set off from {0} at {1} and arrived back home in {2} at {3}. \
|
||||
The total time for this final part of our trip was {4}, with {5} of that spent actively traveling. \
|
||||
Our journey has concluded, and now we can look back on all the memories we've made.
|
||||
memory.generator.intro_accommodation.headline=Welcome to {0}
|
||||
memory.generator.intro_accommodation.text=We are officially checked in! We took a moment to appreciate the atmosphere before dealing with our luggage. \
|
||||
It feels good to be here, and we're looking forward to exploring the immediate surroundings. This place is going to be a great home base for our trip.
|
||||
|
||||
memory.list.all=All
|
||||
|
||||
memory.block.select.type=Select Block Type
|
||||
memory.block.type.text=Text
|
||||
memory.block.type.text.description=Add text content with headlines and paragraphs
|
||||
memory.block.type.visit=Visit
|
||||
memory.block.type.visit.description=Add a location you visited during this memory
|
||||
memory.block.type.trip=Trip
|
||||
memory.block.type.trip.description=Add a journey or route from this memory
|
||||
memory.block.type.gallery=Image Gallery
|
||||
memory.block.type.gallery.description=Add a collection of photos from this memory
|
||||
memory.block.cancel=Cancel
|
||||
memory.block.text.new=New Text Block
|
||||
memory.block.text.headline=Headline
|
||||
memory.block.text.headline.placeholder=Enter headline
|
||||
memory.block.text.content=Content
|
||||
memory.block.text.content.placeholder=Enter your text content
|
||||
memory.block.create=Create Block
|
||||
memory.block.visit.new=New Visit Block
|
||||
memory.block.visit.select=Select Visit
|
||||
memory.block.visit.select.placeholder=Choose a visit...
|
||||
memory.block.trip.new=New Trip Block
|
||||
memory.block.trip.select=Select Trip
|
||||
memory.block.trip.select.placeholder=Choose a trip...
|
||||
memory.block.gallery.new=New Image Gallery Block
|
||||
memory.block.gallery.edit=Edit Image Gallery Block
|
||||
memory.block.gallery.immich.title=Select from Immich
|
||||
memory.block.gallery.loading=Loading photos...
|
||||
memory.block.gallery.selected.title=Selected Photos
|
||||
memory.block.gallery.upload.title=Upload Images
|
||||
memory.block.gallery.upload.choose=Choose a file or drag here
|
||||
memory.block.gallery.immich.no.photos=No photos found for this date range
|
||||
memory.block.gallery.pagination.previous=Previous
|
||||
memory.block.gallery.pagination.next=Next
|
||||
memory.block.gallery.error.no.images=Please select or upload at least one image
|
||||
memory.block.gallery.error.create=Failed to create a gallery block
|
||||
memory.block.gallery.remove=Remove image
|
||||
|
||||
memory.edit.block.title=Title
|
||||
memory.edit.block.title.placeholder=Enter title
|
||||
memory.edit.block.cluster.trip.title = Edit Trips Block
|
||||
memory.edit.block.cluster.trip.select.trips = Select Trips
|
||||
memory.edit.block.cluster.trip.selected = Selected
|
||||
memory.edit.block.cluster.trip.trip = Trip
|
||||
|
||||
memory.edit.block.cluster.visit.title = Edit Visit Block
|
||||
memory.edit.block.cluster.visit.select.visits = Select Visits
|
||||
memory.edit.block.cluster.visit.selected = Selected
|
||||
memory.edit.block.cluster.visit.visit = Visit
|
||||
|
||||
memory.form.date.error.end.before.start=The end date must be equal or after the start date.
|
||||
|
||||
|
||||
|
||||
# Memory sharing functionality
|
||||
memory.share.title=Share Memory
|
||||
memory.share.what.title=What will be shared?
|
||||
memory.share.what.content=The complete memory with all its content blocks
|
||||
memory.share.what.location=Location data and maps for the memory period
|
||||
memory.share.what.photos=Photos and text content within the memory
|
||||
memory.share.what.trips=Trip and visit information during this time period
|
||||
memory.share.permissions.title=Choose sharing permissions:
|
||||
memory.share.view.title=View Only
|
||||
memory.share.view.description=Recipients can view the memory but cannot make any changes
|
||||
memory.share.edit.title=Edit Access
|
||||
memory.share.edit.description=Recipients can view and edit the memory, add blocks, and modify content
|
||||
|
||||
# Share configuration
|
||||
memory.share.configure.title=Configure Share Link
|
||||
memory.share.configure.sharing=Sharing
|
||||
memory.share.access.view=View Only Access
|
||||
memory.share.access.edit=Edit Access
|
||||
memory.share.expires.label=Link expires after:
|
||||
memory.share.expires.7days=7 days
|
||||
memory.share.expires.30days=30 days
|
||||
memory.share.expires.90days=90 days
|
||||
memory.share.expires.never=Never expires
|
||||
memory.share.expires.help=Choose how long the share link should remain valid
|
||||
memory.share.create.button=Create Share Link
|
||||
memory.share.back.button=Back
|
||||
|
||||
# Share result
|
||||
memory.share.result.title=Share Link Created
|
||||
memory.share.result.success=Share Link Created Successfully!
|
||||
memory.share.result.memory=Memory:
|
||||
memory.share.result.access=Access Level:
|
||||
memory.share.result.link.label=Share this link:
|
||||
memory.share.result.copy=Copy
|
||||
memory.share.result.copied=Copied!
|
||||
memory.share.result.instructions.title=How to share:
|
||||
memory.share.result.instructions.copy=Copy the link above and send it to anyone you want to share with
|
||||
memory.share.result.instructions.account=Recipients don't need an account to access the memory
|
||||
memory.share.result.instructions.permissions=The link will work according to the permissions you've set
|
||||
memory.share.result.instructions.view=Recipients can view but not edit the memory
|
||||
memory.share.result.instructions.edit=Recipients can view and edit the memory
|
||||
memory.share.result.done=Done
|
||||
memory.share.result.another=Create Another Link
|
||||
321
src/main/resources/static/css/gallery-block.css
Normal file
321
src/main/resources/static/css/gallery-block.css
Normal file
@@ -0,0 +1,321 @@
|
||||
/* Gallery Block Form Styles */
|
||||
.gallery-block-form {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.gallery-panels {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.gallery-panels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Immich Photos Container */
|
||||
.immich-photos-container {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.immich-photos-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.immich-photo-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-highlight);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.immich-photo-item:hover {
|
||||
border: 1px solid var(--color-highlight);
|
||||
color: var(--color-text-white);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3); transform: scale(1.02);
|
||||
}
|
||||
|
||||
.immich-photo-item.selected {
|
||||
border: 1px solid var(--color-highlight);
|
||||
color: var(--color-text-white);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
|
||||
}
|
||||
|
||||
.immich-photo-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.immich-photo-item:hover .photo-overlay,
|
||||
.immich-photo-item.selected .photo-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-overlay i {
|
||||
font-size: 2rem;
|
||||
color: #6c6c6c;
|
||||
}
|
||||
|
||||
.immich-photo-item.selected .photo-overlay i {
|
||||
border: 1px solid var(--color-highlight);
|
||||
color: var(--color-text-white);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
|
||||
background: #6c6c6c;
|
||||
border-radius: 50%;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-secondary, #757575);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* No Photos State */
|
||||
.no-photos {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
color: var(--text-secondary, #757575);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Pagination Controls */
|
||||
.immich-pagination {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
grid-column: 1 / span 3;
|
||||
}
|
||||
|
||||
|
||||
.pagination-controls button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.gallery-images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
grid-gap: 8px;
|
||||
}
|
||||
|
||||
/* Selected Photos List */
|
||||
.selected-photos-combined {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(256px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gallery-image-item,
|
||||
.selected-photo-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-highlight);
|
||||
color: var(--color-text-white);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.gallery-image-item:hover {
|
||||
border: 1px solid var(--color-highlight);
|
||||
color: var(--color-text-white);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
|
||||
transform: scale(1.02);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.gallery-image-item img,
|
||||
.selected-photo-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selected-photo-item .btn-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.selected-photo-item .btn-remove:hover {
|
||||
background: rgba(211, 47, 47, 0.9);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.selected-photo-item .btn-remove i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Upload Panel */
|
||||
.upload-area {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
border: 1px solid var(--color-highlight);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-label:hover {
|
||||
border: 1px solid var(--color-highlight);
|
||||
color: var(--color-text-white);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3); transform: scale(1.02);
|
||||
}
|
||||
|
||||
.upload-label i {
|
||||
font-size: 3rem;
|
||||
color: var(--color-highlight);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upload-label span {
|
||||
color: var(--color-highlight);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
#fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Uploaded Files List */
|
||||
.uploaded-files-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.uploaded-file-item {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-color, #ffffff);
|
||||
}
|
||||
|
||||
.uploaded-file-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.uploaded-file-item span {
|
||||
display: block;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #757575);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: var(--surface-variant, #f5f5f5);
|
||||
}
|
||||
|
||||
.uploaded-file-item .btn-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.uploaded-file-item .btn-remove:hover {
|
||||
background: rgba(211, 47, 47, 0.9);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.uploaded-file-item .btn-remove i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.immich-photos-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.selected-photos-combined {
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
}
|
||||
|
||||
.uploaded-files-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
--color-background-dark-light: #5c5c5c;
|
||||
--color-text-white: #e3e3e3;
|
||||
--serif-font: "Fraunces";
|
||||
--memories-header-height: 550px;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -57,6 +58,7 @@ body {
|
||||
}
|
||||
|
||||
.navbar .nav-link {
|
||||
background: transparent;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
@@ -64,6 +66,10 @@ body {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.navbar .nav-link.active {
|
||||
color: var(--color-highlight)
|
||||
}
|
||||
|
||||
.navbar .nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -121,9 +127,15 @@ header {
|
||||
|
||||
/* Elements */
|
||||
|
||||
.btn {
|
||||
background-color: var(--color-background-dark);
|
||||
font-family: var(--sans-font);
|
||||
}
|
||||
|
||||
.btn.btn-block {
|
||||
width: 100%;
|
||||
}
|
||||
display: inline-block;
|
||||
box-sizing: border-box;}
|
||||
|
||||
.btn.btn-danger {
|
||||
background: #995353;
|
||||
@@ -190,7 +202,7 @@ label {
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2);
|
||||
}
|
||||
|
||||
input[type="text"], input[type="password"], input[type="url"], input[type="number"], input[type="date"], select {
|
||||
input[type="text"], input[type="password"], input[type="url"], input[type="number"], input[type="date"], select, textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
@@ -201,6 +213,20 @@ input[type="text"], input[type="password"], input[type="url"], input[type="numbe
|
||||
}
|
||||
|
||||
|
||||
select:active,
|
||||
textarea:active,
|
||||
input:active,
|
||||
select:focus,
|
||||
textarea:focus,
|
||||
input:focus {
|
||||
border-color: var(--color-highlight);
|
||||
}
|
||||
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23e3e3e3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
@@ -488,15 +514,18 @@ tr:hover {
|
||||
height: initial;
|
||||
}
|
||||
|
||||
a.btn,
|
||||
button {
|
||||
background-color: transparent;
|
||||
font-size: 14px;
|
||||
color: var(--color-highlight);
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.btn:hover,
|
||||
button:hover {
|
||||
background-color: #959595;
|
||||
}
|
||||
@@ -712,7 +741,7 @@ input[type=file]::file-selector-button:hover {
|
||||
.statistics-content {
|
||||
margin-left: 30rem;
|
||||
color: var(--color-text-white);
|
||||
font-family: "Fraunces", serif;
|
||||
font-family: var(--serif-font);
|
||||
background: transparent;
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
@@ -987,7 +1016,7 @@ input[type=file]::file-selector-button:hover {
|
||||
background-color: var(--color-background-dark);
|
||||
color: var(--color-text-white);
|
||||
min-height: 100vh;
|
||||
font-family: "Fraunces", serif;
|
||||
font-family: var(--serif-font);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@@ -1099,7 +1128,7 @@ input[type=file]::file-selector-button:hover {
|
||||
.settings-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.settings-page .settings-nav {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -1108,32 +1137,33 @@ input[type=file]::file-selector-button:hover {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-highlight);
|
||||
}
|
||||
|
||||
|
||||
.settings-page .settings-nav-item {
|
||||
white-space: nowrap;
|
||||
border-left: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
padding: 18px 8px;
|
||||
}
|
||||
|
||||
|
||||
.settings-page .settings-nav-item.active {
|
||||
border-left: none;
|
||||
border-bottom-color: var(--color-highlight);
|
||||
}
|
||||
|
||||
|
||||
.settings-content-area {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
|
||||
.settings-header {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
|
||||
.settings-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
a.btn:disabled,
|
||||
button:disabled {
|
||||
background: #9b9b9b;
|
||||
cursor: not-allowed;
|
||||
@@ -1345,7 +1375,7 @@ button:disabled {
|
||||
.user-header {
|
||||
pointer-events: all;
|
||||
color: white;
|
||||
font-family: "Fraunces", serif;
|
||||
font-family: var(--serif-font);
|
||||
font-weight: normal;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
@@ -1421,7 +1451,7 @@ button:disabled {
|
||||
.avatar::after {
|
||||
content: attr(alt);
|
||||
font-size: 36px;
|
||||
font-family: "Fraunces", serif;
|
||||
font-family: var(--serif-font);
|
||||
font-weight: lighter;
|
||||
color: white;
|
||||
position: absolute;
|
||||
@@ -1596,7 +1626,7 @@ button:disabled {
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
font-family: "Fraunces", serif;
|
||||
font-family: var(--serif-font);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1661,8 +1691,50 @@ button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Create Memory FAB */
|
||||
.create-memory-fab {
|
||||
position: fixed;
|
||||
bottom: 320px;
|
||||
right: 2rem;
|
||||
z-index: 999;
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.fab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background-color: #ff6c00;
|
||||
color: white;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 12px rgba(255, 108, 0, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.fab-button:hover {
|
||||
background-color: #e66100;
|
||||
box-shadow: 0 6px 16px rgba(255, 108, 0, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.fab-button i {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home-location-inputs {
|
||||
@@ -1674,3 +1746,396 @@ button:disabled {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.memories-overview {
|
||||
margin-left: 30rem;
|
||||
color: var(--color-text-white);
|
||||
font-family: var(--serif-font);
|
||||
background: transparent;
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
max-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Memories */
|
||||
.memories-page {
|
||||
background-color: var(--color-background-dark);
|
||||
color: var(--color-text-white);
|
||||
min-height: 100vh;
|
||||
font-family: var(--serif-font);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.memories-page .btn {
|
||||
border: 1px solid var(--color-highlight);
|
||||
}
|
||||
|
||||
.memories-page .btn i {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
.memories-page .settings-nav-item {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
padding: 15px 25px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
border-bottom: none;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text-white);
|
||||
}
|
||||
|
||||
.memories-page .settings-nav-item:hover {
|
||||
background-color: var(--color-background-dark);
|
||||
border-left-color: var(--color-highlight);
|
||||
}
|
||||
|
||||
.memories-page .settings-nav-item.active {
|
||||
border-bottom: none;
|
||||
font-weight: bold;
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
.memory-header #edit-fragment {
|
||||
margin: var(--memories-header-height) auto;
|
||||
margin-top: 0;
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.memories-page .memories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
|
||||
.memories-page .memory-card {
|
||||
background: var(--color-background-dark);
|
||||
border: 1px solid var(--color-highlight);
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memories-page .memory-card .memory-card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 180px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.memories-page .memory-card .memory-card-info {
|
||||
margin: 15px 0;
|
||||
font-size: 0.9em;
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
.memories-page .memory-card .memory-card-info div {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.memories-page .memory-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2);
|
||||
}
|
||||
|
||||
.memories-page .memory-card-map-container {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.memories-page .settings-content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-family: initial;
|
||||
}
|
||||
|
||||
.memories-page .memory-header {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: var(--memories-header-height);
|
||||
}
|
||||
|
||||
.memories-page .memory-header .action-bar {
|
||||
z-index: 500;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.memories-page .memory-header:hover .action-bar {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.memories-page .memory-header .action-bar div:first-child {
|
||||
flex-grow: 2;
|
||||
}
|
||||
|
||||
.memories-page .memory-details {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
font-family: var(--serif-font);
|
||||
color: #fbe7c3;
|
||||
font-size: 2em;
|
||||
text-shadow: -1px -1px 9px wheat, 1px -1px 9px #6f560d, -1px 1px 5px #24201c, 1px 1px 0 #171414;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.memories-page .memory-map {
|
||||
position: absolute;
|
||||
height: var(--memories-header-height);
|
||||
width: 100%;
|
||||
top: 0;
|
||||
z-index: 0;
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2);
|
||||
}
|
||||
|
||||
.memories-page .memory-blocks-container {
|
||||
padding: 30px;
|
||||
margin: var(--memories-header-height) auto 0;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.memories-page .memory-block {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.memories-page .memory-block .block-actions {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
z-index: 5000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.memories-page .memory-block:hover .block-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.memories-page .memory-block .time::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 30%;
|
||||
height: 1px;
|
||||
background-color: #ccc;
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.memories-page .text-block {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.memories-page .visit-address,
|
||||
.memories-page .cluster-block .cluster-duration {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.memories-page .cluster-block .cluster-duration {
|
||||
}
|
||||
|
||||
.memories-page .cluster-block .block-header,
|
||||
.memories-page .visit-block .block-header {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.memories-page .cluster-block .cluster-map {
|
||||
height: 550px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
|
||||
}
|
||||
|
||||
.memories-page .visit-block .visit-map {
|
||||
height: 200px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
|
||||
}
|
||||
|
||||
.memories-page .block-header .time {
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
font-weight: 300;
|
||||
font-family: var(--serif-font);
|
||||
color: lightgray;
|
||||
}
|
||||
|
||||
.memories-page .block-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.memories-page .add-block-spacer {
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease-in-out;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
pointer-events: none;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.memories-page .empty-blocks .add-block-spacer,
|
||||
.memories-page .memory-block:hover .add-block-spacer {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.memories-page .block-header .title {
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
text-align: center;
|
||||
color: var(--color-highlight);
|
||||
text-shadow: -1px -1px 9px wheat, 1px -1px 9px #6f560d, -1px 1px 5px #24201c, 1px 1px 0 #171414;
|
||||
}
|
||||
|
||||
.memories-page .block-header .visit-title {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
font-family: var(--serif-font);
|
||||
color: #fbe7c3;
|
||||
font-size: 1.8em;
|
||||
text-shadow: -1px -1px 9px wheat, 1px -1px 9px #6f560d, -1px 1px 5px #24201c, 1px 1px 0 #171414;
|
||||
z-index: 500;
|
||||
right: 10px;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.block-type-selection {
|
||||
background-color: var(--color-background-dark);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
border: 1px solid var(--color-highlight);
|
||||
color: var(--color-text-white);
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.block-type-selection:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.2);
|
||||
}
|
||||
|
||||
.block-type-selection h3 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.block-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.block-type-option {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.block-type-option:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.block-type-option span {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.block-type-option p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.empty-blocks {
|
||||
text-align: center;
|
||||
font-size: 1.4rem;
|
||||
color: var(--color-highlight);
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Lazy loading styles */
|
||||
.lazy-image {
|
||||
transition: opacity 0.3s ease;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.lazy-image.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Placeholder styling for lazy images */
|
||||
.gallery-image-item {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-image-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Loading spinner for images */
|
||||
.photo-loading-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Hide spinner when image is loaded */
|
||||
.gallery-image-item img.loaded + .photo-loading-spinner {
|
||||
display: none;
|
||||
}
|
||||
282
src/main/resources/static/css/share-overlay.css
Normal file
282
src/main/resources/static/css/share-overlay.css
Normal file
@@ -0,0 +1,282 @@
|
||||
.share-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.share-overlay-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.share-overlay-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
color: var(--color-text-white);
|
||||
background: var(--color-highlight);
|
||||
}
|
||||
|
||||
.share-overlay-header h2 {
|
||||
margin: 0;
|
||||
color: var(--color-background-dark);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.share-overlay-body {
|
||||
padding: 24px;
|
||||
color: var(--color-text-white);
|
||||
background: var(--color-background-dark);
|
||||
}
|
||||
|
||||
.memory-info {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-highlight);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
|
||||
}
|
||||
|
||||
.memory-info h3 {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.memory-date-range {
|
||||
margin: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-description {
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.sharing-explanation {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sharing-explanation h4 {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.sharing-explanation ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.sharing-explanation li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sharing-options h4 {
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.sharing-option {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sharing-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sharing-option-icon {
|
||||
font-size: 2rem;
|
||||
margin-right: 16px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.sharing-option-details h5 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.sharing-option-details p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.share-config-summary {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-highlight);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
|
||||
}
|
||||
|
||||
.share-config-summary h3 {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.access-level-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-config-form .form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.share-config-form label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-config-form .form-control {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-highlight);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.share-config-form .form-text {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.share-success {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 3rem;
|
||||
color: #28a745;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.share-success h3 {
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
.share-details {
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.share-detail-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.share-link-section {
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.share-link-section label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-link-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.share-url-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-highlight);
|
||||
color: var(--color-text-white);
|
||||
box-shadow: 0 8px 25px rgba(245, 222, 179, 0.3);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.share-instructions {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.share-instructions h4 {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.share-instructions ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.share-instructions li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.share-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.share-overlay-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.share-overlay-header,
|
||||
.share-overlay-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sharing-option-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sharing-option-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-right: 12px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.share-link-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
class PhotoClient {
|
||||
constructor(map) {
|
||||
constructor(map, enabled) {
|
||||
this.map = map;
|
||||
this.photoMarkers = [];
|
||||
this.photos = [];
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
async updatePhotosForRange(start, end, timezone) {
|
||||
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/v1/photos/range?timezone=${timezone}&startDate=${start}&endDate=${end}`);
|
||||
const response = await fetch(`/api/v1/photos/immich/range?timezone=${timezone}&startDate=${start}&endDate=${end}`);
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch photos for date:', date);
|
||||
this.photos = [];
|
||||
|
||||
319
src/main/resources/static/js/raw-location-loader.js
Normal file
319
src/main/resources/static/js/raw-location-loader.js
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* RawLocationLoader - Handles loading and displaying raw location data on the map
|
||||
*/
|
||||
class RawLocationLoader {
|
||||
constructor(map, userSettings, fitToBoundsConfig = null) {
|
||||
this.map = map;
|
||||
this.userSettings = userSettings;
|
||||
this.rawPointPaths = [];
|
||||
this.pulsatingMarkers = [];
|
||||
this.currentZoomLevel = null;
|
||||
this.userConfigs = [];
|
||||
this.isFittingBounds = false;
|
||||
// Configuration for map bounds fitting
|
||||
this.fitToBoundsConfig = fitToBoundsConfig || {};
|
||||
// Listen for map events
|
||||
this.setupMapEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with user configurations
|
||||
* @param {Array} userConfigs - Array of user configuration objects
|
||||
* Each config should contain: { url, color, avatarUrl, avatarFallback, displayName }
|
||||
*/
|
||||
init(userConfigs) {
|
||||
this.userConfigs = userConfigs || [];
|
||||
}
|
||||
|
||||
setupMapEventListeners() {
|
||||
// Listen for zoom end events to reload raw location points
|
||||
this.map.on('zoomend', () => {
|
||||
if (this.isFittingBounds) return;
|
||||
const newZoomLevel = Math.round(this.map.getZoom());
|
||||
|
||||
// Only reload if zoom level actually changed
|
||||
if (this.currentZoomLevel !== null && this.currentZoomLevel !== newZoomLevel) {
|
||||
console.log('Zoom level changed from', this.currentZoomLevel, 'to', newZoomLevel, '- reloading raw location points');
|
||||
this.currentZoomLevel = newZoomLevel;
|
||||
this.reloadForCurrentView();
|
||||
} else if (this.currentZoomLevel === null) {
|
||||
this.currentZoomLevel = newZoomLevel;
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for move end events to reload raw location points
|
||||
this.map.on('moveend', () => {
|
||||
if (this.isFittingBounds) return;
|
||||
this.reloadForCurrentView();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounding box parameters for the current map view with buffer
|
||||
*/
|
||||
getBoundingBoxParams() {
|
||||
const bounds = this.map.getBounds();
|
||||
const southWest = bounds.getSouthWest();
|
||||
const northEast = bounds.getNorthEast();
|
||||
|
||||
// Calculate buffer as 20% of the current viewport size
|
||||
const latBuffer = (northEast.lat - southWest.lat) * 0.2;
|
||||
const lngBuffer = (northEast.lng - southWest.lng) * 0.2;
|
||||
|
||||
return {
|
||||
minLat: southWest.lat - latBuffer,
|
||||
minLng: southWest.lng - lngBuffer,
|
||||
maxLat: northEast.lat + latBuffer,
|
||||
maxLng: northEast.lng + lngBuffer
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load raw location data for a specific date range
|
||||
*/
|
||||
loadForDateRange(autoUpdateMode = false, withBounds = true) {
|
||||
// Remove pulsating markers when loading new data
|
||||
this.removePulsatingMarkers();
|
||||
|
||||
// Clear existing paths
|
||||
this.clearPaths();
|
||||
|
||||
let bounds = L.latLngBounds();
|
||||
const fetchPromises = [];
|
||||
|
||||
for (let i = 0; i < this.userConfigs.length; i++) {
|
||||
const config = this.userConfigs[i];
|
||||
if (config.url) {
|
||||
// Get current zoom level
|
||||
const currentZoom = Math.round(this.map.getZoom());
|
||||
|
||||
// Get bounding box parameters
|
||||
const bbox = this.getBoundingBoxParams();
|
||||
|
||||
// Build URL with zoom and bounding box parameters
|
||||
const separator = config.url.includes('?') ? '&' : '?';
|
||||
let urlWithParams = config.url + separator +
|
||||
'zoom=' + currentZoom;
|
||||
if (config.respectBounds && withBounds) {
|
||||
urlWithParams +=
|
||||
'&minLat=' + bbox.minLat +
|
||||
'&minLng=' + bbox.minLng +
|
||||
'&maxLat=' + bbox.maxLat +
|
||||
'&maxLng=' + bbox.maxLng;
|
||||
}
|
||||
|
||||
// Create fetch promise for raw location points with index to maintain order
|
||||
const fetchPromise = fetch(urlWithParams).then(response => {
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch raw location points');
|
||||
return { points: [], index: i, config: config };
|
||||
}
|
||||
return response.json();
|
||||
}).then(rawPointsData => {
|
||||
return { ...rawPointsData, index: i, config: config };
|
||||
}).catch(error => {
|
||||
console.warn('Error fetching raw location points:', error);
|
||||
return { points: [], index: i, config: config };
|
||||
});
|
||||
|
||||
fetchPromises.push(fetchPromise);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all fetch operations to complete, then update map in correct order
|
||||
Promise.all(fetchPromises).then(results => {
|
||||
// Sort results by original index to maintain order
|
||||
results.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Process results in order
|
||||
results.forEach(result => {
|
||||
const fetchBounds = this.updateMapWithRawPoints(result, result.config.color, autoUpdateMode);
|
||||
if (fetchBounds.isValid()) {
|
||||
bounds.extend(fetchBounds);
|
||||
}
|
||||
});
|
||||
|
||||
// Update map bounds after all fetch operations are complete
|
||||
if (bounds.isValid()) {
|
||||
window.originalBounds = bounds;
|
||||
|
||||
this.isFittingBounds = true;
|
||||
this.map.fitBounds(bounds, this.fitToBoundsConfig);
|
||||
this.isFittingBounds = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload raw location points for the current map view
|
||||
*/
|
||||
reloadForCurrentView(withBounds = true) {
|
||||
let bounds = L.latLngBounds();
|
||||
const fetchPromises = [];
|
||||
|
||||
for (let i = 0; i < this.userConfigs.length; i++) {
|
||||
const config = this.userConfigs[i];
|
||||
if (config.url) {
|
||||
// Get current zoom level
|
||||
const currentZoom = Math.round(this.map.getZoom());
|
||||
|
||||
// Get bounding box parameters
|
||||
const bbox = this.getBoundingBoxParams();
|
||||
|
||||
// Build URL with zoom and bounding box parameters
|
||||
const separator = config.url.includes('?') ? '&' : '?';
|
||||
const urlWithParams = config.url + separator +
|
||||
'zoom=' + currentZoom + (config.respectBounds && withBounds ? ('&minLat=' + bbox.minLat +'&minLng=' + bbox.minLng +'&maxLat=' + bbox.maxLat +'&maxLng=' + bbox.maxLng) : '');
|
||||
|
||||
// Create fetch promise for raw location points with index to maintain order
|
||||
const fetchPromise = fetch(urlWithParams).then(response => {
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch raw location points');
|
||||
return { points: [], index: i, config: config };
|
||||
}
|
||||
return response.json();
|
||||
}).then(rawPointsData => {
|
||||
return { ...rawPointsData, index: i, config: config };
|
||||
}).catch(error => {
|
||||
console.warn('Error fetching raw location points:', error);
|
||||
return { points: [], index: i, config: config };
|
||||
});
|
||||
|
||||
fetchPromises.push(fetchPromise);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all fetch operations to complete, then update map in correct order
|
||||
Promise.all(fetchPromises).then(results => {
|
||||
this.clearPaths();
|
||||
|
||||
results.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Process results in order
|
||||
results.forEach(result => {
|
||||
const fetchBounds = this.updateMapWithRawPoints(result, result.config.color);
|
||||
if (fetchBounds.isValid()) {
|
||||
bounds.extend(fetchBounds);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map with raw location points data
|
||||
*/
|
||||
updateMapWithRawPoints(rawPointsData, color, autoUpdateMode = false) {
|
||||
const bounds = L.latLngBounds();
|
||||
|
||||
if (rawPointsData && rawPointsData.segments && rawPointsData.segments.length > 0) {
|
||||
for (const segment of rawPointsData.segments) {
|
||||
const rawPointsPath = L.geodesic([], {
|
||||
color: color == null ? '#f1ba63' : color,
|
||||
weight: 6,
|
||||
opacity: 0.9,
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
steps: 2
|
||||
});
|
||||
const rawPointsCoords = segment.points.map(point => [point.latitude, point.longitude]);
|
||||
bounds.extend(rawPointsCoords)
|
||||
rawPointsPath.setLatLngs(rawPointsCoords);
|
||||
rawPointsPath.addTo(this.map);
|
||||
this.rawPointPaths.push(rawPointsPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Add avatar marker for the latest point if in auto-update mode and today is selected
|
||||
if (autoUpdateMode && this.isSelectedDateToday() && rawPointsData.latest) {
|
||||
const latestPoint = rawPointsData.latest;
|
||||
const config = rawPointsData.config;
|
||||
if (config) {
|
||||
const userData = {
|
||||
avatarUrl: config.avatarUrl,
|
||||
avatarFallback: config.avatarFallback,
|
||||
displayName: config.displayName
|
||||
};
|
||||
this.addAvatarMarker(latestPoint.latitude, latestPoint.longitude, userData);
|
||||
}
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all raw location paths from the map
|
||||
*/
|
||||
clearPaths() {
|
||||
for (const path of this.rawPointPaths) {
|
||||
path.remove();
|
||||
}
|
||||
this.rawPointPaths.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an avatar marker at the specified coordinates
|
||||
*/
|
||||
addAvatarMarker(lat, lng, userData) {
|
||||
// Create avatar marker with user's avatar
|
||||
const avatarHtml = `
|
||||
<div class="avatar-marker">
|
||||
<img src="${userData.avatarUrl}"
|
||||
alt="${userData.avatarFallback}"
|
||||
class="avatar-marker-img">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const avatarIcon = L.divIcon({
|
||||
className: 'avatar-marker-icon',
|
||||
html: avatarHtml,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20]
|
||||
});
|
||||
|
||||
const avatarMarker = L.marker([lat, lng], {icon: avatarIcon}).addTo(this.map);
|
||||
|
||||
// Add tooltip
|
||||
avatarMarker.bindTooltip(`${window.locale.autoupdate.latestLocation} - ${userData.displayName}`, {
|
||||
permanent: false,
|
||||
direction: 'top'
|
||||
});
|
||||
|
||||
// Store the marker for cleanup later
|
||||
this.pulsatingMarkers.push(avatarMarker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all pulsating markers from the map
|
||||
*/
|
||||
removePulsatingMarkers() {
|
||||
this.pulsatingMarkers.forEach(marker => {
|
||||
if (marker) {
|
||||
this.map.removeLayer(marker);
|
||||
}
|
||||
});
|
||||
this.pulsatingMarkers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the selected date is today
|
||||
*/
|
||||
isSelectedDateToday() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const selectedDate = this.getSelectedDate();
|
||||
return selectedDate === today;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently selected date
|
||||
*/
|
||||
getSelectedDate() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('date')) {
|
||||
return urlParams.get('date');
|
||||
} else if (window.userSettings.newestData) {
|
||||
return window.userSettings.newestData.split('T')[0];
|
||||
} else {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
|
||||
<body>
|
||||
<div th:fragment="settings-nav(activeSection, dataManagementEnabled, isAdmin)" class="settings-nav">
|
||||
<div class="navbar">
|
||||
<a th:href="@{/}"><img class="logo" th:src="@{/img/logo.svg}" alt="reitti logo" title="reitti" src="/img/logo.svg"></a>
|
||||
<a href="/" class="nav-link" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
|
||||
<a href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/memories" class="nav-link" th:title="#{nav.memories}"><i class="lni lni-agenda"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/settings" class="nav-link active" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
|
||||
<form th:action="@{/logout}" method="post" >
|
||||
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i
|
||||
class="lni lni-exit"></i>
|
||||
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i class="lni lni-exit"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<script src="/js/horizontal-date-picker.js"></script>
|
||||
<script src="/js/timeline-scroll-indicator.js"></script>
|
||||
<script src="/js/photo-client.js"></script>
|
||||
<script src="/js/raw-location-loader.js"></script>
|
||||
<script src="/js/htmx.min.js"></script>
|
||||
<script src="/js/leaflet.js"></script>
|
||||
<script src="/js/TileLayer.Grayscale.js"></script>
|
||||
@@ -45,6 +46,8 @@
|
||||
<div class="timeline">
|
||||
<div class="navbar">
|
||||
<span><img class="logo" th:src="@{/img/logo.svg}" alt="reitti logo" title="reitti" src="/img/logo.svg"></span>
|
||||
<a href="/" class="nav-link active" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/memories" class="nav-link" th:title="#{nav.memories}"><i class="lni lni-agenda"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
|
||||
<a type="button" class="nav-link" id="auto-update-btn" onclick="toggleAutoUpdate()" title="Auto Update"><i class="lni lni-play"></i></a>
|
||||
@@ -116,13 +119,13 @@
|
||||
|
||||
// Store current date range selection
|
||||
let currentDateRange = null;
|
||||
|
||||
// Store current zoom level
|
||||
let currentZoomLevel = null;
|
||||
|
||||
// Initialize the map
|
||||
const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([window.userSettings.homeLatitude, window.userSettings.homeLongitude], 12);
|
||||
|
||||
// Initialize raw location loader
|
||||
let rawLocationLoader;
|
||||
|
||||
function getSelectedDate() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('date')) {
|
||||
@@ -162,22 +165,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getBoundingBoxParams() {
|
||||
const bounds = map.getBounds();
|
||||
const southWest = bounds.getSouthWest();
|
||||
const northEast = bounds.getNorthEast();
|
||||
|
||||
// Calculate buffer as 20% of the current viewport size
|
||||
const latBuffer = (northEast.lat - southWest.lat) * 0.2;
|
||||
const lngBuffer = (northEast.lng - southWest.lng) * 0.2;
|
||||
|
||||
return {
|
||||
minLat: southWest.lat - latBuffer,
|
||||
minLng: southWest.lng - lngBuffer,
|
||||
maxLat: northEast.lat + latBuffer,
|
||||
maxLng: northEast.lng + lngBuffer
|
||||
};
|
||||
}
|
||||
|
||||
function selectUser(userHeader) {
|
||||
// Remove active class from all user headers
|
||||
@@ -203,14 +190,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
const fitToBoundsConfig = {
|
||||
paddingTopLeft: [100,0],
|
||||
paddingBottomRight: [100, 300],
|
||||
zoomSnap: 0.1
|
||||
};
|
||||
|
||||
let pulsatingMarkers = [];
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Check if date is in URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -251,96 +233,22 @@
|
||||
.addTo(map)
|
||||
|
||||
// Initialize photo client
|
||||
const photoClient = new PhotoClient(map);
|
||||
const photoClient = new PhotoClient(map, window.userSettings.photoMode === 'ENABLED');
|
||||
|
||||
// Initialize raw location loader
|
||||
rawLocationLoader = new RawLocationLoader(map, window.userSettings, {
|
||||
paddingTopLeft: [100,0],
|
||||
paddingBottomRight: [100, 300],
|
||||
zoomSnap: 0.1
|
||||
});
|
||||
|
||||
// Initialize raw location loader with user configurations
|
||||
initializeRawLocationLoader();
|
||||
|
||||
// Listen for map move/zoom events to update photo markers
|
||||
map.on('moveend zoomend', () => {
|
||||
photoClient.onMapMoveEnd();
|
||||
});
|
||||
|
||||
// Listen for zoom end events to reload raw location points
|
||||
map.on('zoomend', () => {
|
||||
const newZoomLevel = Math.round(map.getZoom());
|
||||
|
||||
// Only reload if zoom level actually changed
|
||||
if (currentZoomLevel !== null && currentZoomLevel !== newZoomLevel) {
|
||||
console.log('Zoom level changed from', currentZoomLevel, 'to', newZoomLevel, '- reloading raw location points');
|
||||
currentZoomLevel = newZoomLevel;
|
||||
reloadRawLocationPoints();
|
||||
} else if (currentZoomLevel === null) {
|
||||
currentZoomLevel = newZoomLevel;
|
||||
}
|
||||
});
|
||||
// Listen for zoom end events to reload raw location points
|
||||
map.on('moveend', () => {
|
||||
reloadRawLocationPoints();
|
||||
});
|
||||
|
||||
function reloadRawLocationPoints() {
|
||||
removePulsatingMarkers();
|
||||
|
||||
// Reload raw location points with new zoom level
|
||||
const timelineContainer = document.querySelectorAll('.user-timeline-section');
|
||||
let bounds = L.latLngBounds();
|
||||
const fetchPromises = [];
|
||||
|
||||
for (let i = 0; i < timelineContainer.length; i++) {
|
||||
const element = timelineContainer[i];
|
||||
const rawLocationPointsUrl = element?.dataset.rawLocationPointsUrl;
|
||||
const color = element?.dataset.baseColor;
|
||||
if (rawLocationPointsUrl) {
|
||||
// Get current zoom level
|
||||
const currentZoom = Math.round(map.getZoom());
|
||||
|
||||
// Get bounding box parameters
|
||||
const bbox = getBoundingBoxParams();
|
||||
|
||||
// Build URL with zoom and bounding box parameters
|
||||
const separator = rawLocationPointsUrl.includes('?') ? '&' : '?';
|
||||
const urlWithParams = rawLocationPointsUrl + separator +
|
||||
'zoom=' + currentZoom +
|
||||
'&minLat=' + bbox.minLat +
|
||||
'&minLng=' + bbox.minLng +
|
||||
'&maxLat=' + bbox.maxLat +
|
||||
'&maxLng=' + bbox.maxLng;
|
||||
|
||||
// Create fetch promise for raw location points with index to maintain order
|
||||
const fetchPromise = fetch(urlWithParams).then(response => {
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch raw location points');
|
||||
return { points: [], index: i, color: color };
|
||||
}
|
||||
return response.json();
|
||||
}).then(rawPointsData => {
|
||||
return { ...rawPointsData, index: i, color: color };
|
||||
}).catch(error => {
|
||||
console.warn('Error fetching raw location points:', error);
|
||||
return { points: [], index: i, color: color };
|
||||
});
|
||||
|
||||
fetchPromises.push(fetchPromise);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all fetch operations to complete, then update map in correct order
|
||||
Promise.all(fetchPromises).then(results => {
|
||||
for (const path of rawPointPaths) {
|
||||
path.remove();
|
||||
}
|
||||
rawPointPaths.length = 0;
|
||||
|
||||
|
||||
results.sort((a, b) => a.index - b.index);
|
||||
|
||||
// Process results in order
|
||||
results.forEach(result => {
|
||||
const fetchBounds = updateMapWithRawPoints(result, result.color);
|
||||
if (fetchBounds.isValid()) {
|
||||
bounds.extend(fetchBounds);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadTimelineData(startDate, endDate) {
|
||||
if (startDate && endDate && startDate !== endDate) {
|
||||
@@ -351,101 +259,8 @@
|
||||
photoClient.updatePhotosForRange(date, date, getUserTimezone());
|
||||
}
|
||||
|
||||
removePulsatingMarkers();
|
||||
|
||||
const timelineContainer = document.querySelectorAll('.user-timeline-section');
|
||||
for (const path of rawPointPaths) {
|
||||
path.remove();
|
||||
}
|
||||
|
||||
let bounds = L.latLngBounds();
|
||||
const fetchPromises = [];
|
||||
|
||||
for (let i = 0; i < timelineContainer.length; i++) {
|
||||
const element = timelineContainer[i];
|
||||
const rawLocationPointsUrl = element?.dataset.rawLocationPointsUrl;
|
||||
const color = element?.dataset.baseColor;
|
||||
if (rawLocationPointsUrl) {
|
||||
const currentZoom = Math.round(map.getZoom());
|
||||
const separator = rawLocationPointsUrl.includes('?') ? '&' : '?';
|
||||
const urlWithParams = rawLocationPointsUrl + separator +
|
||||
'zoom=' + currentZoom;
|
||||
|
||||
const fetchPromise = fetch(urlWithParams).then(response => {
|
||||
if (!response.ok) {
|
||||
console.warn('Could not fetch raw location points');
|
||||
return { points: [], index: i, color: color };
|
||||
}
|
||||
return response.json();
|
||||
}).then(rawPointsData => {
|
||||
return { ...rawPointsData, index: i, color: color };
|
||||
}).catch(error => {
|
||||
console.warn('Error fetching raw location points:', error);
|
||||
return { points: [], index: i, color: color };
|
||||
});
|
||||
|
||||
fetchPromises.push(fetchPromise);
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(fetchPromises).then(results => {
|
||||
results.sort((a, b) => a.index - b.index);
|
||||
|
||||
results.forEach(result => {
|
||||
const fetchBounds = updateMapWithRawPoints(result, result.color);
|
||||
if (fetchBounds.isValid()) {
|
||||
bounds.extend(fetchBounds);
|
||||
}
|
||||
});
|
||||
|
||||
if (bounds.isValid()) {
|
||||
window.originalBounds = bounds;
|
||||
map.fitBounds(bounds, fitToBoundsConfig);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Function to update map with raw location points
|
||||
function updateMapWithRawPoints(rawPointsData, color) {
|
||||
const bounds = L.latLngBounds();
|
||||
|
||||
if (rawPointsData && rawPointsData.segments && rawPointsData.segments.length > 0) {
|
||||
|
||||
for (const segment of rawPointsData.segments) {
|
||||
const rawPointsPath = L.geodesic([], {
|
||||
color: color == null ? '#f1ba63' : color,
|
||||
weight: 6,
|
||||
opacity: 0.9,
|
||||
lineJoin: 'round',
|
||||
lineCap: 'round',
|
||||
steps: 2
|
||||
});
|
||||
const rawPointsCoords = segment.points.map(point => [point.latitude, point.longitude]);
|
||||
bounds.extend(rawPointsCoords)
|
||||
rawPointsPath.setLatLngs(rawPointsCoords);
|
||||
rawPointsPath.addTo(map);
|
||||
rawPointPaths.push(rawPointsPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add avatar marker for the latest point if in auto-update mode and today is selected
|
||||
if (autoUpdateMode && isSelectedDateToday() && rawPointsData.latest) {
|
||||
const latestPoint = rawPointsData.latest;
|
||||
// Find the corresponding timeline section to get user data
|
||||
const timelineContainers = document.querySelectorAll('.user-timeline-section');
|
||||
const timelineSection = timelineContainers[rawPointsData.index];
|
||||
if (timelineSection) {
|
||||
const userData = {
|
||||
avatarUrl: timelineSection.dataset.userAvatarUrl,
|
||||
avatarFallback: timelineSection.dataset.avatarFallback,
|
||||
displayName: timelineSection.dataset.displayName
|
||||
};
|
||||
addAvatarMarker(latestPoint.latitude, latestPoint.longitude, userData);
|
||||
}
|
||||
}
|
||||
|
||||
return bounds;
|
||||
// Load raw location data using the loader
|
||||
rawLocationLoader.loadForDateRange(autoUpdateMode, false);
|
||||
}
|
||||
|
||||
const selectedPath = L.geodesic([], {
|
||||
@@ -458,11 +273,33 @@
|
||||
|
||||
});
|
||||
|
||||
const rawPointPaths = [];
|
||||
|
||||
// Function to initialize raw location loader with user configurations
|
||||
function initializeRawLocationLoader() {
|
||||
const userConfigs = [];
|
||||
const timelineContainers = document.querySelectorAll('.user-timeline-section');
|
||||
|
||||
timelineContainers.forEach(container => {
|
||||
const config = {
|
||||
respectBounds: true,
|
||||
url: container.dataset.rawLocationPointsUrl,
|
||||
color: container.dataset.baseColor,
|
||||
avatarUrl: container.dataset.userAvatarUrl,
|
||||
avatarFallback: container.dataset.avatarFallback,
|
||||
displayName: container.dataset.displayName
|
||||
};
|
||||
userConfigs.push(config);
|
||||
});
|
||||
|
||||
rawLocationLoader.init(userConfigs);
|
||||
}
|
||||
|
||||
// Add HTMX event handlers for timeline updates
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.classList.contains('timeline-container')) {
|
||||
// Re-initialize raw location loader with updated user configurations
|
||||
initializeRawLocationLoader();
|
||||
|
||||
// Timeline content has been updated, update map markers
|
||||
const params = getTimelineParams();
|
||||
loadTimelineData(params.startDate, params.endDate);
|
||||
@@ -618,7 +455,7 @@
|
||||
if (isCurrentlyActive) {
|
||||
// Deselection: zoom back to original bounds showing all data
|
||||
if (window.originalBounds && window.originalBounds.isValid()) {
|
||||
map.flyToBounds(window.originalBounds, fitToBoundsConfig);
|
||||
map.flyToBounds(window.originalBounds, rawLocationLoader.fitToBoundsConfig);
|
||||
}
|
||||
} else {
|
||||
// Selection: zoom to specific entry
|
||||
@@ -643,13 +480,11 @@
|
||||
}
|
||||
|
||||
if (newBounds.isValid()) {
|
||||
map.flyToBounds(newBounds, fitToBoundsConfig);
|
||||
map.flyToBounds(newBounds, rawLocationLoader.fitToBoundsConfig);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load initial timeline data via HTMX (will be triggered by the hx-trigger="load")
|
||||
// Also load photos and raw points for the initial date
|
||||
loadTimelineData(formattedDate, formattedDate);
|
||||
|
||||
// Parse the initial date properly to ensure correct date picker initialization
|
||||
@@ -777,8 +612,7 @@
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
// Remove pulsating markers when auto-update is disabled
|
||||
removePulsatingMarkers();
|
||||
rawLocationLoader.removePulsatingMarkers();
|
||||
|
||||
// Hide auto-update overlay
|
||||
hideAutoUpdateOverlay();
|
||||
@@ -823,41 +657,6 @@
|
||||
return selectedDate === today;
|
||||
}
|
||||
|
||||
function addAvatarMarker(lat, lng, userData) {
|
||||
// Create avatar marker with user's avatar
|
||||
const avatarHtml = `
|
||||
<div class="avatar-marker">
|
||||
<img src="${userData.avatarUrl}"
|
||||
alt="${userData.avatarFallback}"
|
||||
class="avatar-marker-img">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const avatarIcon = L.divIcon({
|
||||
className: 'avatar-marker-icon',
|
||||
html: avatarHtml,
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20]
|
||||
});
|
||||
|
||||
const avatarMarker = L.marker([lat, lng], {icon: avatarIcon}).addTo(map);
|
||||
|
||||
// Add tooltip
|
||||
avatarMarker.bindTooltip(`${window.locale.autoupdate.latestLocation} - ${userData.displayName}`, {
|
||||
permanent: false,
|
||||
direction: 'top'
|
||||
});
|
||||
|
||||
// Store the marker for cleanup later
|
||||
pulsatingMarkers.push(avatarMarker);
|
||||
}
|
||||
|
||||
function removePulsatingMarkers() {
|
||||
pulsatingMarkers.forEach(marker => {
|
||||
map.removeLayer(marker);
|
||||
});
|
||||
pulsatingMarkers = [];
|
||||
}
|
||||
|
||||
function scheduleTimelineReload(eventData) {
|
||||
// Add event to pending events
|
||||
@@ -986,7 +785,7 @@
|
||||
function hideUIElements() {
|
||||
const timeline = document.querySelector('.timeline');
|
||||
const datePicker = document.getElementById('horizontal-date-picker-container');
|
||||
|
||||
|
||||
timeline.classList.add('hidden');
|
||||
datePicker.classList.add('hidden');
|
||||
}
|
||||
|
||||
203
src/main/resources/templates/memories/blocks/edit.html
Normal file
203
src/main/resources/templates/memories/blocks/edit.html
Normal file
@@ -0,0 +1,203 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edit Block - Reitti</title>
|
||||
<link rel="icon" th:href="@{/img/logo.svg}">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/memories.css">
|
||||
<link rel="stylesheet" href="/css/lineicons.css">
|
||||
</head>
|
||||
<body class="memories-page">
|
||||
<div class="settings-container">
|
||||
<nav class="settings-nav">
|
||||
<div class="settings-header">
|
||||
<a th:href="@{/memories/{id}(id=${memoryId})}" class="back-link">
|
||||
<i class="lni lni-arrow-left"></i>
|
||||
<span>Back to Memory</span>
|
||||
</a>
|
||||
<h1>Edit Block</h1>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="settings-content-area">
|
||||
<div th:fragment="edit-text-block" th:if="${block.blockType.name() == 'TEXT'}" class="memory-form">
|
||||
<h2>Edit Text Block</h2>
|
||||
<form th:attr="hx-post=@{/memories/{memoryId}/blocks/{blockId}/text(memoryId=${memoryId}, blockId=${block.id})}" hx-target="closest .memory-block" hx-swap="innerHTML" hx-vals="js:{timezone: getUserTimezone()}">
|
||||
<div class="form-group">
|
||||
<label for="headline">Headline</label>
|
||||
<input type="text" id="headline" name="headline" class="form-control"
|
||||
th:value="${textBlock?.headline}" placeholder="Enter headline">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">Content</label>
|
||||
<textarea id="content" name="content" class="form-control" rows="10"
|
||||
placeholder="Enter content" th:text="${textBlock?.content}"></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/view(memoryId=${memoryId}, blockId=${block.id})}"
|
||||
hx-target="closest .memory-block"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div th:fragment="edit-cluster-trip-block" th:if="${block.blockType.name() == 'CLUSTER_TRIP'}" class="memory-form">
|
||||
<h2 th:text="#{memory.edit.block.cluster.trip.title}">Edit Trips Block</h2>
|
||||
<form th:attr="hx-post=@{/memories/{memoryId}/blocks/{blockId}/cluster(type='CLUSTER_TRIP', memoryId=${memoryId}, blockId=${block.id})}" hx-target="closest .memory-block" hx-swap="innerHTML" hx-vals="js:{timezone: getUserTimezone()}">
|
||||
<div class="form-group">
|
||||
<label th:text="#{memory.edit.block.title}" for="title">Title</label>
|
||||
<input type="text" id="title" name="title" class="form-control"
|
||||
th:value="${clusterTripBlock?.title}" th:placeholder="#{memory.edit.block.title.placeholder}" placeholder="Enter title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{memory.edit.block.cluster.trip.select.trips}">Select Trips</label>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:text="#{memory.edit.block.cluster.trip.selected}">Selected</th>
|
||||
<th th:text="#{memory.edit.block.cluster.trip.trip}">Trip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="trip : ${availableTrips}">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
name="selectedParts"
|
||||
th:value="${trip.id}"
|
||||
th:checked="${clusterTripBlock.trips != null and clusterTripBlock.trips.contains(trip)}">
|
||||
</td>
|
||||
<td th:text="${#temporals.format(trip.startTime, 'MMM d, yyyy HH:mm')} + ' (' + ${trip.startVisit != null and trip.startVisit.place != null ? trip.startVisit.place.name : 'Unknown'} + ') -> ' + ${#temporals.format(trip.endTime, 'HH:mm')} + ' (' + ${trip.endVisit != null and trip.endVisit.place != null ? trip.endVisit.place.name : 'Unknown'} + ')'">Trip details</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/view(memoryId=${memoryId}, blockId=${block.id})}"
|
||||
hx-target="closest .memory-block"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary" th:text="#{form.cancel}">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" th:text="#{form.save.changes}">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div th:fragment="edit-cluster-visit-block" th:if="${block.blockType.name() == 'CLUSTER_VISIT'}" class="memory-form">
|
||||
<h2 th:text="#{memory.edit.block.cluster.visit.title}">Edit Visits Block</h2>
|
||||
<form th:attr="hx-post=@{/memories/{memoryId}/blocks/{blockId}/cluster(type='CLUSTER_VISIT', memoryId=${memoryId}, blockId=${block.id})}" hx-target="closest .memory-block" hx-swap="innerHTML" hx-vals="js:{timezone: getUserTimezone()}">
|
||||
<div class="form-group">
|
||||
<label th:text="#{memory.edit.block.title}" for="visit_title">Title</label>
|
||||
<input type="text" id="visit_title" name="title" class="form-control"
|
||||
th:value="${clusterVisitBlock?.title}" th:placeholder="#{memory.edit.block.title.placeholder}" placeholder="Enter title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{memory.edit.block.cluster.visit.select.visits}">Select Trips</label>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:text="#{memory.edit.block.cluster.visit.selected}">Selected</th>
|
||||
<th th:text="#{memory.edit.block.cluster.visit.visit}">Visit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="visit : ${availableVisits}">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
name="selectedParts"
|
||||
th:value="${visit.id}"
|
||||
th:checked="${clusterVisitBlock.visits != null and clusterVisitBlock.visits.contains(visit)}">
|
||||
</td>
|
||||
<td th:text="${visit.place.name} + ' (' + ${#temporals.format(visit.startTime, 'MMM d, yyyy HH:mm')} + ' -> ' + ${#temporals.format(visit.endTime, 'HH:mm')} + ')'">Visit details</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/view(memoryId=${memoryId}, blockId=${block.id})}"
|
||||
hx-target="closest .memory-block"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary" th:text="#{form.cancel}">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" th:text="#{form.save.changes}">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div th:fragment="edit-image-gallery-block" th:if="${block.blockType.name() == 'IMAGE_GALLERY'}" class="memory-form">
|
||||
<h2 th:text="#{memory.block.gallery.edit}">New Image Gallery Block</h2>
|
||||
<form th:attr="hx-post=@{/memories/{id}/blocks/{blockId}/image-gallery(id=${memoryId}, blockId=${block.id})}" hx-target="closest .memory-block" hx-swap="innerHTML" hx-vals="js:{timezone: getUserTimezone()}">
|
||||
<div class="gallery-panels">
|
||||
<!-- Immich Panel -->
|
||||
<div class="gallery-panel immich-panel" th:if="${immichEnabled && isOwner}">
|
||||
<h4 th:text="#{memory.block.gallery.immich.title}">Select from Immich</h4>
|
||||
<div class="immich-photos-container">
|
||||
<div class="immich-photos-grid"
|
||||
id="immichPhotosGrid"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/immich-photos(page=0, id=${memoryId})}"
|
||||
hx-target=".immich-photos-container"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading" th:text="#{memory.block.gallery.loading}">Loading photos...</div>
|
||||
</div>
|
||||
<div class="immich-pagination" id="immichPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Panel -->
|
||||
<div class="gallery-panel upload-panel">
|
||||
<h4 th:text="#{memory.block.gallery.upload.title}">Upload Images</h4>
|
||||
<div class="upload-area">
|
||||
<input type="file"
|
||||
id="fileInput"
|
||||
name="files"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="form-control"
|
||||
th:attr="hx-post=@{/memories/{id}/blocks/upload-image(id=${memoryId})}"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#selectedPhotos"
|
||||
hx-swap="beforeend">
|
||||
<label for="fileInput" class="upload-label">
|
||||
<i class="lni lni-cloud-upload"></i>
|
||||
<span th:text="#{memory.block.gallery.upload.choose}">Choose files or drag here</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Combined Selected Photos Section -->
|
||||
<div class="selected-photos-section">
|
||||
<h4 th:text="#{memory.block.gallery.selected.title}">Selected Photos</h4>
|
||||
<div class="selected-photos-combined" id="selectedPhotos">
|
||||
<div th:each="image : ${imageBlock.images}" class="selected-photo-item">
|
||||
<img th:src="${image.imageUrl}" alt="Uploaded image">
|
||||
<input type="hidden" name="uploadedUrls" th:value="${image.imageUrl}">
|
||||
<button type="button" class="btn-remove"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target="closest .selected-photo-item"
|
||||
hx-swap="delete"
|
||||
th:title="#{memory.block.gallery.remove}">
|
||||
<i class="lni lni-trash-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/view(memoryId=${memoryId}, blockId=${block.id})}"
|
||||
hx-target="closest .memory-block"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary" th:text="#{form.cancel}">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" th:text="#{form.save.changes}">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
121
src/main/resources/templates/memories/edit.html
Normal file
121
src/main/resources/templates/memories/edit.html
Normal file
@@ -0,0 +1,121 @@
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Memories - Reitti</title>
|
||||
<link rel="icon" th:href="@{/img/logo.svg}">
|
||||
<link rel="stylesheet" th:href="@{/css/main.css}" href="../../static/css/main.css">
|
||||
<link rel="stylesheet" th:href="@{/css/lineicons.css}" href="../../static/css/lineicons.css">
|
||||
<script src="/js/htmx.min.js"></script>
|
||||
</head>
|
||||
<body class="memories-page">
|
||||
<div class="settings-container">
|
||||
<div class="settings-nav">
|
||||
<div class="navbar">
|
||||
<a th:href="@{/}" href="../index.html"><img class="logo" th:src="@{/img/logo.svg}"
|
||||
src="../../static/img/logo.svg" alt="reitti logo"
|
||||
title="reitti"></a>
|
||||
<a href="/" class="nav-link" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
|
||||
<a href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
|
||||
<form th:action="@{/logout}" method="post">
|
||||
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i
|
||||
class="lni lni-exit"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-content-area">
|
||||
<div id="edit-fragment" class="memory-header" th:fragment="edit-memory">
|
||||
<div th:if="${error}" class="alert alert-error">
|
||||
<i class="lni lni-warning"></i>
|
||||
<span th:text="#{${error}}">Error message</span>
|
||||
</div>
|
||||
|
||||
<form th:attr="hx-post=@{/memories/{id}(id=${memory.id})}" hx-vals="js:{timezone: getUserTimezone()}" method="post" class="memory-form" hx-swap="outerHTML" hx-target=".memory-header">
|
||||
<input type="hidden" name="version" th:value="${memory.version}">
|
||||
<div class="form-group">
|
||||
<label for="title">Title *</label>
|
||||
<input type="text" id="title" name="title" required class="form-control"
|
||||
th:value="${memory.title}" placeholder="Give your memory a title">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="4"
|
||||
placeholder="Add a description (optional)" th:text="${memory.description}"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="startDate">Start Date *</label>
|
||||
<input type="date" id="startDate" name="startDate" required class="form-control"
|
||||
th:value="${startDate}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endDate">End Date *</label>
|
||||
<input type="date" id="endDate" name="endDate" required class="form-control"
|
||||
th:value="${endDate}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Header Type *</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="headerType" value="MAP"
|
||||
th:checked="${memory.headerType.name() == 'MAP'}">
|
||||
<span><i class="lni lni-map"></i> Map</span>
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="headerType" value="IMAGE"
|
||||
th:checked="${memory.headerType.name() == 'IMAGE'}">
|
||||
<span><i class="lni lni-image"></i> Image</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="imageUrlGroup"
|
||||
th:style="${memory.headerType.name() == 'IMAGE'} ? 'display: block;' : 'display: none;'">
|
||||
<label for="headerImageUrl">Header Image URL</label>
|
||||
<input type="url" id="headerImageUrl" name="headerImageUrl" class="form-control"
|
||||
th:value="${memory.headerImageUrl}" placeholder="https://example.com/image.jpg">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="btn btn-secondary"
|
||||
th:href="@{/memories/{id}(id=${memory.id})}">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
// User settings for timezone handling
|
||||
window.userSettings = /*[[${userSettings}]]*/ {};
|
||||
|
||||
function getUserTimezone() {
|
||||
if (window.userSettings.timeZoneOverride) {
|
||||
return window.userSettings.timeZoneOverride;
|
||||
} else {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
}
|
||||
// Show/hide image URL field based on header type selection
|
||||
document.querySelectorAll('input[name="headerType"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const imageUrlGroup = document.getElementById('imageUrlGroup');
|
||||
if (this.value === 'IMAGE') {
|
||||
imageUrlGroup.style.display = 'block';
|
||||
} else {
|
||||
imageUrlGroup.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
614
src/main/resources/templates/memories/fragments.html
Normal file
614
src/main/resources/templates/memories/fragments.html
Normal file
@@ -0,0 +1,614 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Block type selection fragment -->
|
||||
<div th:fragment="block-type-selection" class="block-type-selection">
|
||||
<h3 th:text="#{memory.block.select.type}">Select Block Type</h3>
|
||||
<div class="block-type-grid">
|
||||
<button type="button"
|
||||
class="block-type-option"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/new(id=${memoryId}, type='TEXT', position=${position})}"
|
||||
hx-target=".block-type-selection"
|
||||
hx-swap="outerHTML">
|
||||
<span th:text="#{memory.block.type.text}">Text Block</span>
|
||||
<p th:text="#{memory.block.type.text.description}">Add text content with headlines and paragraphs</p>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="block-type-option"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/new(id=${memoryId}, type='VISIT_CLUSTER', position=${position})}"
|
||||
hx-target=".block-type-selection"
|
||||
hx-swap="outerHTML">
|
||||
<span th:text="#{memory.block.type.visit}">Visit Block</span>
|
||||
<p th:text="#{memory.block.type.visit.description}">Add a location you visited during this memory</p>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="block-type-option"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/new(id=${memoryId}, type='TRIP_CLUSTER', position=${position})}"
|
||||
hx-target=".block-type-selection"
|
||||
hx-swap="outerHTML">
|
||||
<span th:text="#{memory.block.type.trip}">Trip Block</span>
|
||||
<p th:text="#{memory.block.type.trip.description}">Add a journey or route from this memory</p>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="block-type-option"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/new(id=${memoryId}, type='IMAGE_GALLERY', position=${position})}"
|
||||
hx-target=".block-type-selection"
|
||||
hx-swap="outerHTML">
|
||||
<span th:text="#{memory.block.type.gallery}">Image Gallery</span>
|
||||
<p th:text="#{memory.block.type.gallery.description}">Add a collection of photos from this memory</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
class="btn btn-secondary"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target=".block-type-selection"
|
||||
hx-swap="delete"
|
||||
th:text="#{memory.block.cancel}">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty fragment for clearing content -->
|
||||
<div th:fragment="empty"></div>
|
||||
|
||||
<!-- Text block form -->
|
||||
<div th:fragment="text-block-form" class="block-form">
|
||||
<h3 th:text="#{memory.block.text.new}">Add Text Block</h3>
|
||||
<form th:attr="hx-post=@{/memories/{id}/blocks/text(id=${memoryId}, position=${position})}" method="post" hx-target=".block-form" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="headline" th:text="#{memory.block.text.headline}">Headline</label>
|
||||
<input type="text" id="headline" name="headline" class="form-control" th:placeholder="#{memory.block.text.headline.placeholder}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content" th:text="#{memory.block.text.content}">Content</label>
|
||||
<textarea id="content" name="content" class="form-control" rows="6" th:placeholder="#{memory.block.text.content.placeholder}"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
class="btn btn-secondary"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target=".block-form"
|
||||
hx-swap="delete"
|
||||
th:text="#{memory.block.cancel}">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" th:text="#{memory.block.create}">Save Block</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Visit block form -->
|
||||
<div th:fragment="visit-block-form" class="block-form">
|
||||
<h2 th:text="#{memory.block.visit.new}">Add Visit Block</h2>
|
||||
<form th:attr="hx-post=@{/memories/{id}/blocks/cluster(id=${memoryId}, position=${position}, type='CLUSTER_VISIT')}" method="post" hx-target=".block-form" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}">
|
||||
<div class="form-group">
|
||||
<label th:text="#{memory.edit.block.title}" for="title">Title</label>
|
||||
<input type="text" id="title" name="title" class="form-control" th:placeholder="#{memory.edit.block.title.placeholder}" placeholder="Enter title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{memory.edit.block.cluster.visit.select.visits}">Select Visits</label>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:text="#{memory.edit.block.cluster.visit.selected}">Selected</th>
|
||||
<th th:text="#{memory.edit.block.cluster.visit.visit}">Trip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="visit : ${availableVisits}">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
name="selectedParts"
|
||||
th:value="${visit.id}">
|
||||
</td>
|
||||
<td th:text="${visit.place.name} + ' (' + ${#temporals.format(visit.startTime, 'MMM d, yyyy HH:mm')} + ' -> ' + ${#temporals.format(visit.endTime, 'HH:mm')} + ')'">Visit details</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
class="btn btn-secondary"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target=".block-form"
|
||||
hx-swap="delete"
|
||||
th:text="#{memory.block.cancel}">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" th:text="#{memory.block.create}">Save Block</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Trip block form -->
|
||||
<div th:fragment="trip-block-form" class="block-form">
|
||||
<h2 th:text="#{memory.block.trip.new}">Edit Trips Block</h2>
|
||||
<form th:attr="hx-post=@{/memories/{id}/blocks/cluster(id=${memoryId}, position=${position}, type='CLUSTER_TRIP')}" method="post" hx-target=".block-form" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}">
|
||||
<div class="form-group">
|
||||
<label th:text="#{memory.edit.block.title}" for="title">Title</label>
|
||||
<input type="text" id="title" name="title" class="form-control" th:placeholder="#{memory.edit.block.title.placeholder}" placeholder="Enter title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{memory.edit.block.cluster.trip.select.trips}">Select Trips</label>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:text="#{memory.edit.block.cluster.trip.selected}">Selected</th>
|
||||
<th th:text="#{memory.edit.block.cluster.trip.trip}">Trip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="trip : ${availableTrips}">
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
name="selectedParts"
|
||||
th:value="${trip.id}">
|
||||
</td>
|
||||
<td th:text="${#temporals.format(trip.startTime, 'MMM d, yyyy HH:mm')} + ' (' + ${trip.startVisit != null and trip.startVisit.place != null ? trip.startVisit.place.name : 'Unknown'} + ') -> ' + ${#temporals.format(trip.endTime, 'HH:mm')} + ' (' + ${trip.endVisit != null and trip.endVisit.place != null ? trip.endVisit.place.name : 'Unknown'} + ')'">Trip details</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
class="btn btn-secondary"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target=".block-form"
|
||||
hx-swap="delete"
|
||||
th:text="#{memory.block.cancel}">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" th:text="#{memory.block.create}">Save Block</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Image gallery block form -->
|
||||
<div th:fragment="image-gallery-block-form" class="block-form gallery-block-form">
|
||||
<h3 th:text="#{memory.block.gallery.new}">New Image Gallery Block</h3>
|
||||
|
||||
<form th:attr="hx-post=@{/memories/{id}/blocks/image-gallery(id=${memoryId}, position=${position})}" method="post" hx-target=".block-form" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}">
|
||||
<div class="gallery-panels">
|
||||
<!-- Immich Panel -->
|
||||
<div class="gallery-panel immich-panel" th:if="${immichEnabled && isOwner}">
|
||||
<h4 th:text="#{memory.block.gallery.immich.title}">Select from Immich</h4>
|
||||
<div class="immich-photos-container">
|
||||
<div class="immich-photos-grid"
|
||||
id="immichPhotosGrid"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/immich-photos(page=0, id=${memoryId})}"
|
||||
hx-target=".immich-photos-container"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading" th:text="#{memory.block.gallery.loading}">Loading photos...</div>
|
||||
</div>
|
||||
<div class="immich-pagination" id="immichPagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Panel -->
|
||||
<div class="gallery-panel upload-panel">
|
||||
<h4 th:text="#{memory.block.gallery.upload.title}">Upload Images</h4>
|
||||
<div class="upload-area">
|
||||
<input type="file"
|
||||
id="fileInput"
|
||||
name="files"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="form-control"
|
||||
th:attr="hx-post=@{/memories/{id}/blocks/upload-image(id=${memoryId})}"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#selectedPhotos"
|
||||
hx-swap="beforeend">
|
||||
<label for="fileInput" class="upload-label">
|
||||
<i class="lni lni-cloud-upload"></i>
|
||||
<span th:text="#{memory.block.gallery.upload.choose}">Choose files or drag here</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Combined Selected Photos Section -->
|
||||
<div class="selected-photos-section">
|
||||
<h4 th:text="#{memory.block.gallery.selected.title}">Selected Photos</h4>
|
||||
<div class="selected-photos-combined" id="selectedPhotos"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" th:text="#{memory.block.create}">Create Gallery Block</button>
|
||||
<button type="button"
|
||||
class="btn btn-secondary"
|
||||
th:attr="hx-get=@{/memories/fragments/empty}"
|
||||
hx-target=".block-form"
|
||||
hx-swap="delete"
|
||||
th:text="#{memory.block.cancel}">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Uploaded photos fragment -->
|
||||
<th:block th:fragment="uploaded-photos">
|
||||
<div th:each="url : ${urls}" class="selected-photo-item">
|
||||
<img th:src="${url}" alt="Uploaded image">
|
||||
<input type="hidden" name="uploadedUrls" th:value="${url}">
|
||||
<button type="button" class="btn-remove"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target="closest .selected-photo-item"
|
||||
hx-swap="delete"
|
||||
th:title="#{memory.block.gallery.remove}">
|
||||
<i class="lni lni-trash-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
<!-- Immich photos grid fragment -->
|
||||
<div class="immich-photos-grid" th:fragment="immich-photos-grid">
|
||||
<div th:if="${photos.isEmpty()}" class="no-photos" th:text="#{memory.block.gallery.immich.no.photos}">
|
||||
No photos found for this date range
|
||||
</div>
|
||||
<div th:each="photo : ${photos}"
|
||||
class="immich-photo-item"
|
||||
th:attr="hx-post=@{/memories/{id}/blocks/fetch-immich-photo(id=${memoryId}, assetId=${photo.id})}"
|
||||
hx-target="#selectedPhotos"
|
||||
hx-swap="beforeend">
|
||||
<img th:src="${photo.thumbnailUrl}" th:alt="${photo.fileName}">
|
||||
<div class="photo-overlay">
|
||||
<i class="lni lni-plus"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div th:if="${totalPages > 1}" class="pagination-controls">
|
||||
<button th:if="${currentPage > 0}"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
th:text="#{memory.block.gallery.pagination.previous}"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/immich-photos(page=${currentPage - 1}, id=${memoryId})}"
|
||||
hx-target=".immich-photos-container"
|
||||
hx-swap="innerHTML">Previous</button>
|
||||
<span class="page-info" th:text="${currentPage + 1} + ' / ' + ${totalPages}"></span>
|
||||
<button th:if="${currentPage < totalPages - 1}"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
th:text="#{memory.block.gallery.pagination.next}"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/immich-photos(page=${currentPage + 1}, id=${memoryId})}"
|
||||
hx-target=".immich-photos-container"
|
||||
hx-swap="innerHTML">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="years-navigation" th:fragment="years-navigation">
|
||||
<div class="timeline-entry trip active" id="overall-entry" data-year="overall"
|
||||
hx-get="/memories/all"
|
||||
hx-target=".memories-overview"
|
||||
hx-trigger="load, click[shouldTrigger(this)]"
|
||||
hx-swap="outerHTML">
|
||||
<div class="entry-description" th:text="#{memory.list.all}">All</div>
|
||||
</div>
|
||||
<div class="timeline-entry trip" th:each="year : ${years}"
|
||||
th:attr="hx-get='/memories/year/'+ ${year}, data-year=${year}"
|
||||
hx-target=".memories-overview"
|
||||
hx-trigger="click[shouldTrigger(this)]"
|
||||
hx-swap="outerHTML">
|
||||
<div class="entry-description" th:text="${year}">2024</div>
|
||||
</div>
|
||||
<script>
|
||||
new function () {
|
||||
document.querySelector('.years-navigation').addEventListener('click', function (event) {
|
||||
const entry = event.target.closest('.timeline-entry');
|
||||
if (!entry) return;
|
||||
const isCurrentlyActive = entry.classList.contains('active');
|
||||
if (isCurrentlyActive) {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.timeline-container .timeline-entry')
|
||||
.forEach(e => e.classList.remove('active'));
|
||||
entry.classList.add('active');
|
||||
});
|
||||
}()
|
||||
|
||||
</script>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="memories-overview settings-content-area" th:fragment="memories-list">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<h2>Memories</h2>
|
||||
<button th:attr="hx-get=@{/memories/new(year=${year})}" hx-target=".settings-content-area" class="btn btn-primary">Create Memory</button>
|
||||
</div>
|
||||
|
||||
<div th:if="${memories.isEmpty()}" class="empty-state">
|
||||
<i class="lni lni-image"></i>
|
||||
<h2>No memories yet</h2>
|
||||
<p>Create your first memory by selecting a date range on the timeline</p>
|
||||
</div>
|
||||
|
||||
<div class="memories-grid" th:unless="${memories.isEmpty()}">
|
||||
<div th:each="memory : ${memories}" class="memory-card">
|
||||
<div class="memory-card-map-container" th:attr="data-raw-location-url=${memory.rawLocationUrl}">
|
||||
</div>
|
||||
<div class="memory-card-details">
|
||||
<div class="memory-card-info">
|
||||
<h3 th:text="${memory.memory.title}">Memory Title</h3>
|
||||
<div>
|
||||
<span th:text="${#temporals.format(memory.memory.startDate, 'MMM d, yyyy')}">Start Date</span>
|
||||
<span th:if="${memory.memory.startDate != memory.memory.endDate}">
|
||||
- <span th:text="${#temporals.format(memory.memory.endDate, 'MMM d, yyyy')}">End Date</span>
|
||||
</span>
|
||||
</div>
|
||||
<div th:if="${memory.memory.description != null && !memory.memory.description.isEmpty()}"
|
||||
th:text="${memory.memory.description}">Description</div>
|
||||
</div>
|
||||
<div>
|
||||
<a th:href="@{/memories/{id}(id=${memory.memory.id})}"
|
||||
class="btn btn-block memory-link"
|
||||
th:attr="data-memory-id=${memory.memory.id}"
|
||||
onclick="navigateToMemoryWithTimezone(event, this.dataset.memoryId)">View Memory</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function navigateToMemoryWithTimezone(event, memoryId) {
|
||||
event.preventDefault();
|
||||
const timezone = getUserTimezone();
|
||||
window.location.href = `/memories/${memoryId}?timezone=${timezone}`;
|
||||
}
|
||||
|
||||
(function () {
|
||||
const mapContainers = document.querySelectorAll('.memory-card-map-container');
|
||||
for (const mapContainer of mapContainers) {
|
||||
const memoryMap = L.map(mapContainer, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
boxZoom: false,
|
||||
doubleClickZoom: false,
|
||||
dragging: false,
|
||||
keyboard: false,
|
||||
scrollWheelZoom: false,
|
||||
tap: false,
|
||||
touchZoom: false
|
||||
}).setView([51.505, -0.09], 13);
|
||||
L.tileLayer.grayscale('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(memoryMap);
|
||||
const rawLocationLoader = new RawLocationLoader(memoryMap, window.userSettings);
|
||||
const userConfigs = [{
|
||||
url: mapContainer.attributes['data-raw-location-url'].value,
|
||||
color: '#f1ba63',
|
||||
avatarUrl: null,
|
||||
avatarFallback: null,
|
||||
displayName: 'Memory Location Data'
|
||||
}];
|
||||
rawLocationLoader.init(userConfigs);
|
||||
|
||||
// Load location data for the memory date range without bounds filtering
|
||||
rawLocationLoader.loadForDateRange(
|
||||
null,
|
||||
null,
|
||||
false, // autoUpdateMode
|
||||
false // withBounds - don't filter by current map bounds
|
||||
);
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</div>
|
||||
|
||||
|
||||
<div th:fragment="share-overlay" class="share-overlay">
|
||||
<div class="share-overlay-content">
|
||||
<div class="share-overlay-header">
|
||||
<h2 th:text="#{memory.share.title}">Share Memory</h2>
|
||||
<button type="button"
|
||||
class="btn-close"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">×</button>
|
||||
</div>
|
||||
|
||||
<div class="share-overlay-body">
|
||||
<div class="memory-info">
|
||||
<h3 th:text="${memory.title}">Memory Title</h3>
|
||||
<p class="memory-date-range">
|
||||
<i class="lni lni-calendar"></i>
|
||||
<span th:text="${#temporals.format(memory.startDate, 'MMMM d, yyyy')}">Start Date</span>
|
||||
<span th:if="${memory.startDate != memory.endDate}">
|
||||
- <span th:text="${#temporals.format(memory.endDate, 'MMMM d, yyyy')}">End Date</span>
|
||||
</span>
|
||||
</p>
|
||||
<p th:if="${memory.description != null && !memory.description.isEmpty()}"
|
||||
class="memory-description"
|
||||
th:text="${memory.description}">Description</p>
|
||||
</div>
|
||||
|
||||
<div class="sharing-explanation">
|
||||
<h4 th:text="#{memory.share.what.title}">What will be shared?</h4>
|
||||
<ul>
|
||||
<li th:text="#{memory.share.what.content}">The complete memory with all its content blocks</li>
|
||||
<li th:text="#{memory.share.what.location}">Location data and maps for the memory period</li>
|
||||
<li th:text="#{memory.share.what.photos}">Photos and text content within the memory</li>
|
||||
<li th:text="#{memory.share.what.trips}">Trip and visit information during this time period</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sharing-options">
|
||||
<h4 th:text="#{memory.share.permissions.title}">Choose sharing permissions:</h4>
|
||||
|
||||
<div class="sharing-option">
|
||||
<button type="button"
|
||||
class="btn btn-block sharing-option-btn"
|
||||
th:attr="hx-get=@{/memories/{id}/share/form(id=${memory.id}, accessLevel='MEMORY_VIEW_ONLY')}"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">
|
||||
<div class="sharing-option-content">
|
||||
<div class="sharing-option-icon">
|
||||
<i class="lni lni-eye"></i>
|
||||
</div>
|
||||
<div class="sharing-option-details">
|
||||
<h5 th:text="#{memory.share.view.title}">View Only</h5>
|
||||
<p th:text="#{memory.share.view.description}">Recipients can view the memory but cannot make any changes</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sharing-option">
|
||||
<button type="button"
|
||||
class="btn btn-block sharing-option-btn"
|
||||
th:attr="hx-get=@{/memories/{id}/share/form(id=${memory.id}, accessLevel='MEMORY_EDIT_ACCESS')}"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">
|
||||
<div class="sharing-option-content">
|
||||
<div class="sharing-option-icon">
|
||||
<i class="lni lni-pencil-1"></i>
|
||||
</div>
|
||||
<div class="sharing-option-details">
|
||||
<h5 th:text="#{memory.share.edit.title}">Edit Access</h5>
|
||||
<p th:text="#{memory.share.edit.description}">Recipients can view and edit the memory, add blocks, and modify content</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:fragment="share-form" class="share-overlay">
|
||||
<div class="share-overlay-content">
|
||||
<div class="share-overlay-header">
|
||||
<h2 th:text="#{memory.share.configure.title}">Configure Share Link</h2>
|
||||
<button type="button"
|
||||
class="btn-close"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">×</button>
|
||||
</div>
|
||||
|
||||
<div class="share-overlay-body">
|
||||
<div class="share-config-summary">
|
||||
<h3 th:text="#{memory.share.configure.sharing} + ': ' + ${memory.title}">Sharing: Memory Title</h3>
|
||||
<div class="access-level-display">
|
||||
<i th:class="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_VIEW_ONLY ? 'lni lni-eye' : 'lni lni-pencil'}"></i>
|
||||
<span th:text="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_VIEW_ONLY ? #{memory.share.access.view} : #{memory.share.access.edit}}">Access Level</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form th:attr="hx-post=@{/memories/{id}/share(id=${memory.id})}"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML"
|
||||
class="share-config-form">
|
||||
<input type="hidden" name="accessLevel" th:value="${accessLevel}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="validDays" th:text="#{memory.share.expires.label}">Link expires after:</label>
|
||||
<select name="validDays" id="validDays" class="form-control">
|
||||
<option value="7" th:text="#{memory.share.expires.7days}">7 days</option>
|
||||
<option value="30" selected th:text="#{memory.share.expires.30days}">30 days</option>
|
||||
<option value="90" th:text="#{memory.share.expires.90days}">90 days</option>
|
||||
<option value="0" th:text="#{memory.share.expires.never}">Never expires</option>
|
||||
</select>
|
||||
<small class="form-text" th:text="#{memory.share.expires.help}">Choose how long the share link should remain valid</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="lni lni-link"></i> <span th:text="#{memory.share.create.button}">Create Share Link</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn"
|
||||
th:attr="hx-get=@{/memories/{id}/share(id=${memory.id})}"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="lni lni-arrow-left"></i> <span th:text="#{memory.share.back.button}">Back</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:fragment="share-result" class="share-overlay">
|
||||
<div class="share-overlay-content">
|
||||
<div class="share-overlay-header">
|
||||
<h2 th:text="#{memory.share.result.title}">Share Link Created</h2>
|
||||
<button type="button"
|
||||
class="btn-close"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">×</button>
|
||||
</div>
|
||||
|
||||
<div class="share-overlay-body">
|
||||
<div class="share-success">
|
||||
<div class="success-icon">
|
||||
<i class="lni lni-check-circle-1"></i>
|
||||
</div>
|
||||
<h3 th:text="#{memory.share.result.success}">Share Link Created Successfully!</h3>
|
||||
|
||||
<div class="share-details">
|
||||
<div class="share-detail-item">
|
||||
<strong th:text="#{memory.share.result.memory}">Memory:</strong> <span th:text="${memory.title}">Memory Title</span>
|
||||
</div>
|
||||
<div class="share-detail-item">
|
||||
<strong th:text="#{memory.share.result.access}">Access Level:</strong>
|
||||
<span th:text="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_VIEW_ONLY ? #{memory.share.access.view} : #{memory.share.access.edit}}">Access Level</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-link-section">
|
||||
<label for="shareUrl" th:text="#{memory.share.result.link.label}">Share this link:</label>
|
||||
<div class="share-link-container">
|
||||
<input type="text"
|
||||
id="shareUrl"
|
||||
class="form-control share-url-input"
|
||||
th:value="${shareUrl}"
|
||||
readonly
|
||||
onclick="this.select()">
|
||||
<button type="button"
|
||||
class="btn btn-copy"
|
||||
th:attr="data-copied-text=#{memory.share.result.copied}, data-copy-text=#{memory.share.result.copy}"
|
||||
onclick="navigator.clipboard.writeText(document.getElementById('shareUrl').value).then(() => { this.innerHTML = '<i class=\'lni lni-checkmark\'></i> ' + this.dataset.copiedText; setTimeout(() => { this.innerHTML = '<i class=\'lni lni-copy\'></i> ' + this.dataset.copyText; }, 2000); })">
|
||||
<i class="lni lni-copy"></i> <span th:text="#{memory.share.result.copy}">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-instructions">
|
||||
<h4 th:text="#{memory.share.result.instructions.title}">How to share:</h4>
|
||||
<ul>
|
||||
<li th:text="#{memory.share.result.instructions.copy}">Copy the link above and send it to anyone you want to share with</li>
|
||||
<li th:text="#{memory.share.result.instructions.account}">Recipients don't need an account to access the memory</li>
|
||||
<li th:text="#{memory.share.result.instructions.permissions}">The link will work according to the permissions you've set</li>
|
||||
<li th:if="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_VIEW_ONLY}" th:text="#{memory.share.result.instructions.view}">Recipients can view but not edit the memory</li>
|
||||
<li th:if="${accessLevel == T(com.dedicatedcode.reitti.model.security.MagicLinkAccessLevel).MEMORY_EDIT_ACCESS}" th:text="#{memory.share.result.instructions.edit}">Recipients can view and edit the memory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button"
|
||||
class="btn btn-primary"
|
||||
hx-get="/memories/fragments/empty"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="lni lni-checkmark"></i> <span th:text="#{memory.share.result.done}">Done</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn"
|
||||
th:attr="hx-get=@{/memories/{id}/share(id=${memory.id})}"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="lni lni-share"></i> <span th:text="#{memory.share.result.another}">Create Another Link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
56
src/main/resources/templates/memories/list.html
Normal file
56
src/main/resources/templates/memories/list.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Memories - Reitti</title>
|
||||
<link rel="icon" th:href="@{/img/logo.svg}">
|
||||
<link rel="stylesheet" th:href="@{/css/main.css}" href="../../static/css/main.css">
|
||||
<link rel="stylesheet" th:href="@{/css/lineicons.css}" href="../../static/css/lineicons.css">
|
||||
<link rel="stylesheet" th:href="@{/css/leaflet.css}" href="../../static/css/leaflet.css">
|
||||
<script th:src="@{/js/htmx.min.js}"></script>
|
||||
<script th:src="@{/js/leaflet.js}"></script>
|
||||
<script th:src="@{/js/leaflet.geodesic.2.7.2.js}"></script>
|
||||
<script th:src="@{/js/TileLayer.Grayscale.js}"></script>
|
||||
<script th:src="@{/js/raw-location-loader.js}"></script>
|
||||
</head>
|
||||
<body class="memories-page">
|
||||
<div id="memories-panel" class="memories-overview"></div>
|
||||
<div class="timeline">
|
||||
<div class="navbar">
|
||||
<a th:href="@{/}"><img class="logo" th:src="@{/img/logo.svg}" alt="reitti logo" title="reitti" src="/img/logo.svg"></a>
|
||||
<a href="/" class="nav-link" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/memories" class="nav-link active" th:title="#{nav.memories}"><i class="lni lni-agenda"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/statistics" class="nav-link" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
|
||||
<form th:action="@{/logout}" method="post" >
|
||||
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i class="lni lni-exit"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container">
|
||||
<div id="years-navigation"
|
||||
hx-get="/memories/years-navigation"
|
||||
hx-trigger="load">
|
||||
<div class="photo-modal-loading-spinner htmx-indicator" th:text="#{timeline.loading}">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
window.userSettings = /*[[${userSettings}]]*/ {};
|
||||
|
||||
function getUserTimezone() {
|
||||
if (window.userSettings.timeZoneOverride) {
|
||||
return window.userSettings.timeZoneOverride;
|
||||
} else {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldTrigger(element) {
|
||||
return !element.classList.contains('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
124
src/main/resources/templates/memories/new.html
Normal file
124
src/main/resources/templates/memories/new.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title th:text="#{memory.new.page.title}">New Memory - Reitti</title>
|
||||
<link rel="icon" th:href="@{/img/logo.svg}">
|
||||
<link rel="stylesheet" th:href="@{/css/main.css}" href="../../static/css/main.css">
|
||||
<link rel="stylesheet" th:href="@{/css/lineicons.css}" href="../../static/css/lineicons.css">
|
||||
<link rel="stylesheet" th:href="@{/css/gallery-block.css}" href="../../static/css/gallery-block.css">
|
||||
<link rel="stylesheet" th:href="@{/css/photo-client.css}" href="../../static/css/photo-client.css">
|
||||
<link rel="stylesheet" href="/css/leaflet.css">
|
||||
<script src="/js/htmx.min.js"></script>
|
||||
<script src="/js/leaflet.js"></script>
|
||||
<script src="/js/leaflet.geodesic.2.7.2.js"></script>
|
||||
<script src="/js/TileLayer.Grayscale.js"></script>
|
||||
<script src="/js/raw-location-loader.js"></script>
|
||||
<script src="/js/HumanizeDuration.js"></script>
|
||||
<script src="/js/photo-client.js"></script>
|
||||
</head>
|
||||
<body class="memories-page">
|
||||
<div class="settings-container">
|
||||
<nav class="settings-nav">
|
||||
<div class="settings-header">
|
||||
<a href="/memories" class="back-link">
|
||||
<i class="lni lni-arrow-left"></i>
|
||||
<span th:text="#{memory.new.back.to.memories}">Back to Memories</span>
|
||||
</a>
|
||||
<h1 th:text="#{memory.new.title}">New Memory</h1>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div th:fragment="new-memory" class="settings-content-area">
|
||||
<div th:if="${error}" class="alert alert-error">
|
||||
<i class="lni lni-warning"></i>
|
||||
<span th:text="#{${error}}">Error message</span>
|
||||
</div>
|
||||
|
||||
<form th:href="@{/memories}" class="memory-form" onsubmit="return validateDates()">
|
||||
<div class="form-group">
|
||||
<label for="title" th:text="#{memory.form.title.label}">Title *</label>
|
||||
<input type="text" id="title" name="title" required class="form-control" th:placeholder="#{memory.form.title.placeholder}" th:value="${title}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" th:text="#{memory.form.description.label}">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="4" th:placeholder="#{memory.form.description.placeholder}" th:text="${description}"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="startDate" th:text="#{memory.form.start.date.label}">Start Date *</label>
|
||||
<input type="date" id="startDate" name="startDate" required class="form-control"
|
||||
th:value="${startDate}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endDate" th:text="#{memory.form.end.date.label}">End Date *</label>
|
||||
<input type="date" id="endDate" name="endDate" required class="form-control"
|
||||
th:value="${endDate}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dateError" class="alert alert-error" style="display: none;">
|
||||
<i class="lni lni-warning"></i>
|
||||
<span id="dateErrorText"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button th:if="${year != 'all'}" th:attr="hx-get=@{/memories/year/{year}(year=${year})}"
|
||||
hx-target=".memories-overview" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}" class="btn btn-primary" th:text="#{memory.form.cancel}">Cancel</button>
|
||||
<button th:if="${year == 'all'}" th:attr="hx-get=@{/memories/all}"
|
||||
hx-target=".memories-overview" hx-swap="outerHTML" hx-vals="js:{timezone: getUserTimezone()}" class="btn btn-primary" th:text="#{memory.form.cancel}">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"
|
||||
hx-indicator="#create-spinner"
|
||||
hx-disabled-elt="this">
|
||||
<span th:text="#{memory.form.create}">Create Memory</span>
|
||||
<span id="create-spinner" class="htmx-indicator">
|
||||
<i class="lni lni-spinner-2-sacle lni-is-spinning"></i>
|
||||
<span th:text="#{memory.form.creating}">Creating...</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<script th:inline="javascript">
|
||||
// User settings from server
|
||||
window.userSettings = /*[[${userSettings}]]*/ {};
|
||||
|
||||
function getUserTimezone() {
|
||||
if (window.userSettings.timezoneOverride) {
|
||||
return window.userSettings.timezoneOverride;
|
||||
} else {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
}
|
||||
|
||||
function validateDates() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const errorDiv = document.getElementById('dateError');
|
||||
const errorText = document.getElementById('dateErrorText');
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
errorText.textContent = /*[[#{memory.form.date.error.empty}]]*/ 'Dates cannot be empty';
|
||||
errorDiv.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
errorText.textContent = /*[[#{memory.form.date.error.end.before.start}]]*/ 'End date must be on or after start date';
|
||||
errorDiv.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
|
||||
errorDiv.style.display = 'none';
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
679
src/main/resources/templates/memories/view.html
Normal file
679
src/main/resources/templates/memories/view.html
Normal file
@@ -0,0 +1,679 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title th:text="${memory.title} + ' - Reitti'">Memory - Reitti</title>
|
||||
<link rel="icon" th:href="@{/img/logo.svg}">
|
||||
<link rel="stylesheet" th:href="@{/css/main.css}" href="../../static/css/main.css">
|
||||
<link rel="stylesheet" th:href="@{/css/lineicons.css}" href="../../static/css/lineicons.css">
|
||||
<link rel="stylesheet" th:href="@{/css/gallery-block.css}" href="../../static/css/gallery-block.css">
|
||||
<link rel="stylesheet" th:href="@{/css/photo-client.css}" href="../../static/css/photo-client.css">
|
||||
<link rel="stylesheet" th:href="@{/css/leaflet.css}" href="../../static/css/leaflet.css">
|
||||
<link rel="stylesheet" th:href="@{/css/share-overlay.css}" href="../../static/css/share-overlay.css">
|
||||
<script src="/js/htmx.min.js"></script>
|
||||
<script src="/js/leaflet.js"></script>
|
||||
<script src="/js/leaflet.geodesic.2.7.2.js"></script>
|
||||
<script src="/js/TileLayer.Grayscale.js"></script>
|
||||
<script src="/js/raw-location-loader.js"></script>
|
||||
<script src="/js/HumanizeDuration.js"></script>
|
||||
<script src="/js/photo-client.js"></script>
|
||||
</head>
|
||||
<body class="memories-page">
|
||||
<div class="settings-container">
|
||||
<div class="settings-content-area">
|
||||
<div class="memory-header" th:fragment="memory-header">
|
||||
<div class="action-bar">
|
||||
<div sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')"><a class="btn"
|
||||
th:href="@{/memories}"><i class="lni lni-arrow-left"></i>
|
||||
<span th:text="#{memory.view.back}"></span></a></a>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn"
|
||||
th:if="${canEdit}"
|
||||
th:attr="hx-get=@{/memories/{id}/edit(id=${memory.id})}"
|
||||
hx-target=".memory-header"
|
||||
hx-swap="innerHTML"
|
||||
hx-vals="js:{timezone: getUserTimezone()}"
|
||||
th:text="#{memory.view.edit}">Edit
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn"
|
||||
th:if="${canEdit && isOwner}"
|
||||
th:attr="hx-post=@{/memories/{id}/recalculate(id=${memory.id})}"
|
||||
hx-target="body"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Recalculate this memory? This will update all location data."
|
||||
hx-vals="js:{timezone: getUserTimezone()}"
|
||||
th:text="#{memory.view.recalculate}">Recalculate
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn"
|
||||
th:if="${canEdit && isOwner}"
|
||||
th:attr="hx-get=@{/memories/{id}/share(id=${memory.id})}"
|
||||
hx-target="#share-overlay-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="lni lni-share"></i> Share
|
||||
</button>
|
||||
</div>
|
||||
<div class="memory-header-section map-header-section">
|
||||
<div id="memory-map" class="memory-map"></div>
|
||||
</div>
|
||||
|
||||
<div class="memory-details">
|
||||
<h1 class="memory-date-range-large">
|
||||
<span th:text="${memory.title}">Title</span>
|
||||
</h1>
|
||||
<p class="memory-description-large" th:if="${memory.description != null && !memory.description.isEmpty()}"
|
||||
th:text="${memory.description}">Description</p>
|
||||
<p class="memory-date-range-large">
|
||||
<i class="lni lni-calendar"></i>
|
||||
<span th:text="${#temporals.format(memory.startDate, 'MMMM d, yyyy')}">Start Date</span>
|
||||
<span th:if="${memory.startDate != memory.endDate}">
|
||||
- <span th:text="${#temporals.format(memory.endDate, 'MMMM d, yyyy')}">End Date</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="memory-blocks-container">
|
||||
<div th:if="${blocks.isEmpty()}" class="memory-block empty-blocks">
|
||||
<i class="lni lni-layers"></i>
|
||||
<p th:text="#{memory.view.no.blocks}">No blocks yet. Add your first block to start building your memory.</p>
|
||||
<div th:if="${canEdit}" class="add-block-spacer">
|
||||
<button type="button"
|
||||
class="btn btn-primary"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/select-type(id=${memory.id}, position=0)}"
|
||||
hx-target="closest .empty-blocks"
|
||||
hx-swap="outerHTML">
|
||||
<i class="lni lni-plus"></i> <span th:text="#{memory.view.add.first.block}">Add Block after</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="blocks-list" id="blocksList">
|
||||
<div th:fragment="view-block" th:each="block, iterStat : ${blocks}" class="memory-block" th:attr="data-block-id=${block.blockId}, data-block-type=${block.type.name()}">
|
||||
<div class="block-content">
|
||||
<div th:if="${block.type.name() == 'TEXT'}" class="text-block">
|
||||
<div class="block-header" th:if="${block.headline != null && !block.headline.isEmpty()}">
|
||||
<h3 class="title" th:text="${block.headline}">Text Block</h3>
|
||||
</div>
|
||||
<p th:text="${block.content}">Content will be loaded here</p>
|
||||
</div>
|
||||
<div th:if="${block.type.name() == 'IMAGE_GALLERY'}" class="gallery-block">
|
||||
<div class="gallery-images-grid" th:id="${'gallery_' + block.blockId}">
|
||||
<div th:each="image : ${block.images}"
|
||||
class="gallery-image-item"
|
||||
th:attr="data-image-url=${image.imageUrl},data-caption=${image.caption}">
|
||||
<div class="photo-loading-spinner"></div>
|
||||
<img th:data-src="${image.imageUrl}"
|
||||
th:alt="${image.caption != null ? image.caption : 'Gallery image'}"
|
||||
class="lazy-image"
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='200'%3E%3Crect width='100%25' height='100%25' fill='%23f0f0f0'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3ELoading...%3C/text%3E%3C/svg%3E"
|
||||
onload="this.classList.add('loaded'); this.previousElementSibling.style.display='none';"
|
||||
onerror="this.style.display='none'; this.previousElementSibling.style.display='none'; this.parentElement.innerHTML='📷';">
|
||||
</div>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
(function() {
|
||||
const blockId = /*[[${block.blockId}]]*/ 0;
|
||||
const images = /*[[${block.images}]]*/ [];
|
||||
|
||||
const galleryContainer = document.getElementById('gallery_' + blockId);
|
||||
const imageElements = galleryContainer.querySelectorAll('.gallery-image-item');
|
||||
|
||||
imageElements.forEach((element, index) => {
|
||||
element.addEventListener('click', () => {
|
||||
showGalleryModal(images, index);
|
||||
});
|
||||
});
|
||||
|
||||
function showGalleryModal(allImages, currentIndex) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'photo-modal';
|
||||
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'photo-modal-container';
|
||||
|
||||
const modalSpinner = document.createElement('div');
|
||||
modalSpinner.className = 'photo-modal-loading-spinner';
|
||||
imageContainer.appendChild(modalSpinner);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = allImages[currentIndex].imageUrl;
|
||||
img.alt = allImages[currentIndex].caption || 'Gallery image';
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
img.classList.add('loaded');
|
||||
if (imageContainer.contains(modalSpinner)) {
|
||||
imageContainer.removeChild(modalSpinner);
|
||||
}
|
||||
});
|
||||
|
||||
img.addEventListener('error', () => {
|
||||
if (imageContainer.contains(modalSpinner)) {
|
||||
imageContainer.removeChild(modalSpinner);
|
||||
}
|
||||
img.style.display = 'none';
|
||||
const errorMsg = document.createElement('div');
|
||||
errorMsg.textContent = 'Failed to load image';
|
||||
errorMsg.style.color = '#ccc';
|
||||
errorMsg.style.fontSize = '18px';
|
||||
imageContainer.appendChild(errorMsg);
|
||||
});
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.innerHTML = '<i class="lni lni-xmark-circle"></i>';
|
||||
closeButton.className = 'photo-modal-close-button';
|
||||
|
||||
let prevButton, nextButton, counter;
|
||||
if (allImages.length > 1) {
|
||||
prevButton = document.createElement('button');
|
||||
prevButton.innerHTML = '<i class="lni lni-chevron-left"></i>';
|
||||
prevButton.className = 'photo-nav-button photo-nav-prev';
|
||||
prevButton.disabled = currentIndex === 0;
|
||||
|
||||
nextButton = document.createElement('button');
|
||||
nextButton.innerHTML = '<i class="lni lni-chevron-left lni-rotate-180"></i>';
|
||||
nextButton.className = 'photo-nav-button photo-nav-next';
|
||||
nextButton.disabled = currentIndex === allImages.length - 1;
|
||||
|
||||
counter = document.createElement('div');
|
||||
counter.className = 'photo-counter';
|
||||
counter.textContent = `${currentIndex + 1} / ${allImages.length}`;
|
||||
}
|
||||
|
||||
const showPrevImage = () => {
|
||||
if (currentIndex > 0) {
|
||||
closeModal();
|
||||
showGalleryModal(allImages, currentIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const showNextImage = () => {
|
||||
if (currentIndex < allImages.length - 1) {
|
||||
closeModal();
|
||||
showGalleryModal(allImages, currentIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
closeModal();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
showPrevImage();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
showNextImage();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
if (document.body.contains(modal)) {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
};
|
||||
|
||||
closeButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
});
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
if (prevButton) {
|
||||
prevButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showPrevImage();
|
||||
});
|
||||
}
|
||||
|
||||
if (nextButton) {
|
||||
nextButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showNextImage();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
imageContainer.appendChild(img);
|
||||
imageContainer.appendChild(closeButton);
|
||||
|
||||
if (prevButton) imageContainer.appendChild(prevButton);
|
||||
if (nextButton) imageContainer.appendChild(nextButton);
|
||||
if (counter) imageContainer.appendChild(counter);
|
||||
|
||||
modal.appendChild(imageContainer);
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
<div th:if="${block.type.name() == 'CLUSTER_TRIP'}" class="cluster-block">
|
||||
<div class="block-header">
|
||||
<div class="time">
|
||||
<i class="lni lni-timer"></i>
|
||||
<span th:text="${#temporals.format(block.adjustedStartTime, 'MMM d, yyyy HH:mm')}">Start Time</span>
|
||||
<span> - </span>
|
||||
<span th:text="${#temporals.format(block.adjustedEndTime, 'HH:mm')}">End Time</span>
|
||||
</div>
|
||||
<i class="lni lni-layers"></i>
|
||||
<div class="title" th:text="${block.title}">Cluster Title</div>
|
||||
</div>
|
||||
<p th:if="${block.description != null}" class="cluster-description" th:text="${block.description}">Description</p>
|
||||
<div class="cluster-map" th:id="${'map_cluster_' + block.blockId}"></div>
|
||||
<script th:inline="javascript">
|
||||
(function() {
|
||||
const userSettings = /*[[${userSettings}]]*/ {};
|
||||
const blockId = /*[[${'map_cluster_' + block.blockId}]]*/ 'map_cluster_99';
|
||||
const trips = /*[[${block.trips}]]*/ [];
|
||||
const rawLocationUrl = /*[[${block.rawLocationPointsUrl}]]*/ '';
|
||||
const combinedStartTime = /*[[${block.combinedStartTime}]]*/ null;
|
||||
|
||||
// Initialize map
|
||||
const clusterMap = L.map(blockId, {
|
||||
attributionControl: false,
|
||||
}).setView([50,50], 19);
|
||||
|
||||
let tileLayer;
|
||||
if (userSettings.preferColoredMap) {
|
||||
tileLayer = L.tileLayer(userSettings.tiles.service, {
|
||||
maxZoom: 19,
|
||||
attribution: userSettings.tiles.attribution
|
||||
});
|
||||
} else {
|
||||
tileLayer = L.tileLayer.grayscale(userSettings.tiles.service, {
|
||||
maxZoom: 19,
|
||||
attribution: userSettings.tiles.attribution
|
||||
});
|
||||
}
|
||||
tileLayer.addTo(clusterMap);
|
||||
|
||||
let clusterRawLocationLoader = new RawLocationLoader(clusterMap, userSettings);
|
||||
|
||||
// Load raw location data for the cluster's time range
|
||||
if (rawLocationUrl) {
|
||||
const userConfigs = [{
|
||||
respectBounds: false,
|
||||
url: rawLocationUrl,
|
||||
color: '#f1ba63',
|
||||
avatarUrl: null,
|
||||
avatarFallback: null,
|
||||
displayName: 'Cluster Path'
|
||||
}];
|
||||
clusterRawLocationLoader.init(userConfigs);
|
||||
clusterRawLocationLoader.loadForDateRange(false, false);
|
||||
}
|
||||
|
||||
// Add markers for start, stops, and end using default filled circle markers
|
||||
let bounds = [];
|
||||
if (trips.length > 0) {
|
||||
// Start marker
|
||||
const startTrip = trips[0];
|
||||
if (startTrip.startVisit && startTrip.startVisit.place) {
|
||||
const startLat = startTrip.startVisit.place.latitudeCentroid;
|
||||
const startLon = startTrip.startVisit.place.longitudeCentroid;
|
||||
const startName = startTrip.startVisit.place.name;
|
||||
const startTimeFormatted = combinedStartTime ? new Date(combinedStartTime).toLocaleTimeString() : 'unknown';
|
||||
L.circleMarker([startLat, startLon], {
|
||||
color: '#4a9fdc',
|
||||
fillColor: '#59bcff',
|
||||
fillOpacity: 0.2
|
||||
}).addTo(clusterMap).bindPopup('Start: ' + startName + '<br>Departed at: ' + startTimeFormatted);
|
||||
bounds.push([startLat, startLon]);
|
||||
}
|
||||
|
||||
// Intermediate stops (end of each trip except last)
|
||||
for (let i = 0; i < trips.length - 1; i++) {
|
||||
const trip = trips[i];
|
||||
if (trip.endVisit && trip.endVisit.place) {
|
||||
const lat = trip.endVisit.place.latitudeCentroid;
|
||||
const lon = trip.endVisit.place.longitudeCentroid;
|
||||
const name = trip.endVisit.place.name;
|
||||
const arrivedAfter = combinedStartTime && trip.endTime ? humanizeDuration((new Date(trip.endTime) - new Date(combinedStartTime)),{units: ["h", "m"], round: true}) : 'unknown';
|
||||
L.circleMarker([lat, lon], {
|
||||
color: '#6a6a6a',
|
||||
fillColor: '#ff984f',
|
||||
fillOpacity: 0.1
|
||||
}).addTo(clusterMap).bindPopup('Stop: ' + name + '<br>Arrived after: ' + arrivedAfter);
|
||||
bounds.push([lat, lon]);
|
||||
}
|
||||
}
|
||||
|
||||
// End marker
|
||||
const endTrip = trips[trips.length - 1];
|
||||
if (endTrip.endVisit && endTrip.endVisit.place) {
|
||||
const endLat = endTrip.endVisit.place.latitudeCentroid;
|
||||
const endLon = endTrip.endVisit.place.longitudeCentroid;
|
||||
const endName = endTrip.endVisit.place.name;
|
||||
const arrivedAfter = combinedStartTime && endTrip.endTime ? humanizeDuration((new Date(endTrip.endTime) - new Date(combinedStartTime)),{units: ["h", "m"], round: true}) : 'unknown';
|
||||
L.circleMarker([endLat, endLon], {
|
||||
color: '#37bd57',
|
||||
fillColor: '#55ea7a',
|
||||
fillOpacity: 0.2
|
||||
}).addTo(clusterMap).bindPopup('End: ' + endName + '<br>Arrived after: ' + arrivedAfter);
|
||||
bounds.push([endLat, endLon]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fit map to bounds if markers exist
|
||||
if (bounds.length > 0) {
|
||||
clusterMap.fitBounds(bounds, {padding: [10, 10]});
|
||||
} else {
|
||||
clusterMap.setView([51.505, -0.09], 13);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div class="cluster-summary" th:if="${block.combinedStartTime != null && block.combinedEndTime != null}">
|
||||
<div class="cluster-duration" th:if="${block.completeDuration != null}">
|
||||
<span th:text="
|
||||
#{memory.view.block.cluster.duration(${#numbers.formatDecimal(block.completeDuration / 3600.0, 0, 0)},${#numbers.formatDecimal((block.completeDuration % 3600) / 60, 0, 0)},${#numbers.formatDecimal(block.movingDuration / 3600.0, 0, 0)},${#numbers.formatDecimal((block.movingDuration % 3600) / 60, 0, 0)})}">Total Duration</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cluster-trips" th:if="${!block.trips.isEmpty()}">
|
||||
<h4>Trips in this Journey:</h4>
|
||||
<ul>
|
||||
<li th:each="trip : ${block.trips}" class="trip-item">
|
||||
<span th:text="${trip.startVisit != null && trip.startVisit.place != null ? trip.startVisit.place.name : 'Start'}">Start</span>
|
||||
<span> → </span>
|
||||
<span th:text="${trip.endVisit != null && trip.endVisit.place != null ? trip.endVisit.place.name : 'End'}">End</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div th:if="${block.type.name() == 'CLUSTER_VISIT'}" class="cluster-block">
|
||||
<div class="block-header">
|
||||
<div class="time">
|
||||
<i class="lni lni-timer"></i>
|
||||
<span th:text="${#temporals.format(block.adjustedStartTime, 'MMM d, yyyy HH:mm')}">Start Time</span>
|
||||
<span> - </span>
|
||||
<span th:text="${#temporals.format(block.adjustedEndTime, 'HH:mm')}">End Time</span>
|
||||
</div>
|
||||
<i class="lni lni-layers"></i>
|
||||
<div class="title" th:text="${block.title}">Cluster Title</div>
|
||||
</div>
|
||||
<p th:if="${block.description != null}" class="cluster-description" th:text="${block.description}">Description</p>
|
||||
<div class="cluster-map" th:id="${'map_cluster_' + block.blockId}"></div>
|
||||
<script th:inline="javascript">
|
||||
(function() {
|
||||
const userSettings = /*[[${userSettings}]]*/ {};
|
||||
const blockId = /*[[${'map_cluster_' + block.blockId}]]*/ 'map_cluster_99';
|
||||
const visits = /*[[${block.visits}]]*/ [];
|
||||
const rawLocationUrl = /*[[${block.rawLocationPointsUrl}]]*/ '';
|
||||
|
||||
// Initialize map
|
||||
const clusterMap = L.map(blockId, {
|
||||
attributionControl: false,
|
||||
}).setView([50,50], 19);
|
||||
|
||||
let tileLayer;
|
||||
if (userSettings.preferColoredMap) {
|
||||
tileLayer = L.tileLayer(userSettings.tiles.service, {
|
||||
maxZoom: 19,
|
||||
attribution: userSettings.tiles.attribution
|
||||
});
|
||||
} else {
|
||||
tileLayer = L.tileLayer.grayscale(userSettings.tiles.service, {
|
||||
maxZoom: 19,
|
||||
attribution: userSettings.tiles.attribution
|
||||
});
|
||||
}
|
||||
tileLayer.addTo(clusterMap);
|
||||
|
||||
let clusterRawLocationLoader = new RawLocationLoader(clusterMap, userSettings);
|
||||
|
||||
// Load raw location data for the cluster's time range
|
||||
if (rawLocationUrl) {
|
||||
const userConfigs = [{
|
||||
respectBounds: false,
|
||||
url: rawLocationUrl,
|
||||
color: '#f1ba63',
|
||||
avatarUrl: null,
|
||||
avatarFallback: null,
|
||||
displayName: 'Cluster Path'
|
||||
}];
|
||||
clusterRawLocationLoader.init(userConfigs);
|
||||
clusterRawLocationLoader.loadForDateRange(false, false);
|
||||
}
|
||||
|
||||
let bounds = [];
|
||||
if (visits.length > 0) {
|
||||
for (let i = 0; i < visits.length; i++) {
|
||||
const visit = visits[i];
|
||||
if (visit.place) {
|
||||
const lat = visit.place.latitudeCentroid;
|
||||
const lon = visit.place.longitudeCentroid;
|
||||
const name = visit.place.name;
|
||||
L.circleMarker([lat, lon], {
|
||||
color: '#6a6a6a',
|
||||
fillColor: '#ff984f',
|
||||
fillOpacity: 0.1
|
||||
}).addTo(clusterMap).bindPopup('Name: ' + name);
|
||||
bounds.push([lat, lon]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fit map to bounds if markers exist
|
||||
if (bounds.length > 0) {
|
||||
clusterMap.fitBounds(bounds, {padding: [10, 10]});
|
||||
} else {
|
||||
clusterMap.setView([51.505, -0.09], 13);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div class="cluster-trips" th:if="${!block.visits.isEmpty()}">
|
||||
<h4>Visits in this Journey:</h4>
|
||||
<ul>
|
||||
<li th:each="visit : ${block.visits}" class="trip-item">
|
||||
<span th:text="${visit.place != null ? visit.place.name : 'Start'}">Start</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div th:if="${canEdit}" class="block-actions">
|
||||
<button type="button"
|
||||
class="btn"
|
||||
th:attr="hx-get=@{/memories/{memoryId}/blocks/{blockId}/edit(memoryId=${memory.id}, blockId=${block.blockId})}"
|
||||
hx-target="closest .memory-block"
|
||||
hx-swap="innerHTML">
|
||||
<i class="lni lni-pencil-1"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-danger"
|
||||
th:attr="hx-delete=@{/memories/{memoryId}/blocks/{blockId}(memoryId=${memory.id}, blockId=${block.blockId})}"
|
||||
hx-target="closest .memory-block"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this block?">
|
||||
<i class="lni lni-trash-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div th:if="${canEdit}" class="add-block-spacer">
|
||||
<button type="button"
|
||||
class="btn btn-primary"
|
||||
th:attr="hx-get=@{/memories/{id}/blocks/select-type(id=${memory.id})},data-block-id=${block.blockId}"
|
||||
hx-target="closest .add-block-spacer"
|
||||
hx-swap="afterend"
|
||||
hx-vals="js:{position: getBlockPosition()}">
|
||||
<i class="lni lni-plus"></i> <span th:text="#{memory.view.add.block}">Add Block after</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script th:inline="javascript">
|
||||
// User settings for timezone handling
|
||||
window.userSettings = /*[[${userSettings}]]*/ {};
|
||||
|
||||
function getBlockPosition() {
|
||||
let allBlocks = document.querySelectorAll('.memory-block');
|
||||
for (let i = 0; i < allBlocks.length; i++) {
|
||||
const block = allBlocks[i];
|
||||
if (block.dataset.blockId === event.currentTarget.dataset.blockId) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function getUserTimezone() {
|
||||
if (window.userSettings.timeZoneOverride) {
|
||||
return window.userSettings.timeZoneOverride;
|
||||
} else {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
}
|
||||
|
||||
function humanizeDuration(seconds) {
|
||||
if (!seconds || seconds <= 0) return 'unknown';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return hours + 'h ' + minutes + 'm';
|
||||
} else {
|
||||
return minutes + 'm';
|
||||
}
|
||||
}
|
||||
|
||||
const memory = /*[[${memory}]]*/ {};
|
||||
const rawLocationUrl = /*[[${rawLocationUrl}]]*/ '';
|
||||
let rawLocationLoader = null;
|
||||
let memoryMap = null;
|
||||
|
||||
|
||||
function initializeMemoryMap() {
|
||||
// Only initialize if header type is MAP and map container exists
|
||||
if (memory.headerType === 'MAP' && document.getElementById('memory-map')) {
|
||||
if (memoryMap && typeof memoryMap.getContainer === 'function') {
|
||||
memoryMap.remove();
|
||||
}
|
||||
|
||||
memoryMap = L.map('memory-map', {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
boxZoom: false,
|
||||
doubleClickZoom: false,
|
||||
dragging: false,
|
||||
keyboard: false,
|
||||
scrollWheelZoom: false,
|
||||
tap: false,
|
||||
touchZoom: false
|
||||
}).setView([51.505, -0.09], 13);
|
||||
|
||||
L.tileLayer.grayscale('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(memoryMap);
|
||||
if (rawLocationLoader === null) {
|
||||
rawLocationLoader = new RawLocationLoader(memoryMap, window.userSettings);
|
||||
}
|
||||
// Initialize raw location loader for the memory
|
||||
if (rawLocationUrl && rawLocationLoader !== null) {
|
||||
const userConfigs = [{
|
||||
url: rawLocationUrl,
|
||||
color: '#f1ba63',
|
||||
avatarUrl: null,
|
||||
avatarFallback: null,
|
||||
displayName: 'Memory Location Data'
|
||||
}];
|
||||
rawLocationLoader.init(userConfigs);
|
||||
|
||||
// Load location data for the memory date range without bounds filtering
|
||||
rawLocationLoader.loadForDateRange(
|
||||
memory.startDate,
|
||||
memory.endDate,
|
||||
false, // autoUpdateMode
|
||||
false // withBounds - don't filter by current map bounds
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy loading for images using Intersection Observer
|
||||
function initializeLazyLoading() {
|
||||
if ('IntersectionObserver' in window) {
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
const dataSrc = img.getAttribute('data-src');
|
||||
if (dataSrc) {
|
||||
img.src = dataSrc;
|
||||
img.classList.remove('lazy-image');
|
||||
observer.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '50px 0px', // Start loading 50px before the image enters viewport
|
||||
threshold: 0.01
|
||||
});
|
||||
|
||||
// Observe all lazy images
|
||||
document.querySelectorAll('.lazy-image').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
} else {
|
||||
// Fallback for browsers without Intersection Observer support
|
||||
document.querySelectorAll('.lazy-image').forEach(img => {
|
||||
const dataSrc = img.getAttribute('data-src');
|
||||
if (dataSrc) {
|
||||
img.src = dataSrc;
|
||||
img.classList.remove('lazy-image');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize map on page load
|
||||
initializeMemoryMap();
|
||||
|
||||
// Initialize lazy loading on page load
|
||||
initializeLazyLoading();
|
||||
|
||||
// Listen for htmx events to reinitialize map and lazy loading when content is updated
|
||||
document.body.addEventListener('htmx:afterSwap', function (event) {
|
||||
if (event.detail.target.classList.contains('memory-header')) {
|
||||
initializeMemoryMap()
|
||||
|
||||
if (rawLocationUrl) {
|
||||
if (rawLocationLoader === null) {
|
||||
rawLocationLoader = new RawLocationLoader(memoryMap, {});
|
||||
}
|
||||
const userConfigs = [{
|
||||
url: rawLocationUrl,
|
||||
color: '#f1ba63',
|
||||
avatarUrl: null,
|
||||
avatarFallback: null,
|
||||
displayName: 'Memory Location Data'
|
||||
}];
|
||||
rawLocationLoader.init(userConfigs);
|
||||
rawLocationLoader.reloadForCurrentView(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize lazy loading for any new images added via HTMX
|
||||
initializeLazyLoading();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<div id="share-overlay-container"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,7 +12,7 @@
|
||||
<script src="/js/TileLayer.Grayscale.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--color-highlight: #F5DEB3FF;
|
||||
--color-highlight: #fddca1;
|
||||
--color-background-dark: #3b3b3b;
|
||||
--color-background-dark-light: #5c5c5c;
|
||||
--color-text-white: #e3e3e3;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -22,7 +22,9 @@
|
||||
<div class="navbar">
|
||||
<a th:href="@{/}"><img class="logo" th:src="@{/img/logo.svg}" alt="reitti logo" title="reitti" src="/img/logo.svg"></a>
|
||||
<a href="/" class="nav-link" th:title="#{nav.timeline}"><i class="lni lni-route-1"></i></a>
|
||||
<a href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/memories" class="nav-link" th:title="#{nav.memories}"><i class="lni lni-agenda"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/statistics" class="nav-link active" th:title="#{nav.statistics}"><i class="lni lni-bar-chart-4"></i></a>
|
||||
<a sec:authorize="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')" href="/settings" class="nav-link" th:title="#{nav.settings.tooltip}"><i class="lni lni-gear-1"></i></a>
|
||||
<form th:action="@{/logout}" method="post" >
|
||||
<button type="submit" class="nav-link" style="font-size: 1.4rem;" th:title="#{nav.logout.tooltip}"><i class="lni lni-exit"></i>
|
||||
</button>
|
||||
|
||||
@@ -69,7 +69,7 @@ public class TestingService {
|
||||
}
|
||||
|
||||
public User randomUser() {
|
||||
return this.userJdbcService.createUser(new User(UUID.randomUUID().toString(), "Test User"));
|
||||
return this.userJdbcService.createUser(new User(UUID.randomUUID().toString(), "Test User"));
|
||||
}
|
||||
|
||||
public void triggerProcessingPipeline(int timeout) {
|
||||
|
||||
@@ -126,16 +126,16 @@ class MagicLinkJdbcServiceTest {
|
||||
// Given
|
||||
MagicLinkToken originalToken = createTestToken("update-token");
|
||||
Instant lastUsed = Instant.now();
|
||||
|
||||
|
||||
MagicLinkToken updatedToken = new MagicLinkToken(
|
||||
originalToken.getId(),
|
||||
originalToken.getId(),
|
||||
originalToken.getName(),
|
||||
originalToken.getTokenHash(),
|
||||
originalToken.getTokenHash(),
|
||||
MagicLinkAccessLevel.ONLY_LIVE, // Changed access level
|
||||
originalToken.getExpiryDate(),
|
||||
originalToken.getCreatedAt(),
|
||||
lastUsed, // Set last used
|
||||
true
|
||||
originalToken.getExpiryDate(),
|
||||
originalToken.getCreatedAt(),
|
||||
lastUsed, // Set last used
|
||||
true
|
||||
);
|
||||
|
||||
// When
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.memory.*;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@IntegrationTest
|
||||
class MemoryBlockImageGalleryJdbcServiceTest {
|
||||
|
||||
@Autowired
|
||||
private MemoryBlockImageGalleryJdbcService memoryBlockImageGalleryJdbcService;
|
||||
|
||||
@Autowired
|
||||
private MemoryBlockJdbcService memoryBlockJdbcService;
|
||||
|
||||
@Autowired
|
||||
private MemoryJdbcService memoryJdbcService;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private User testUser;
|
||||
private Memory testMemory;
|
||||
private MemoryBlock testBlock;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbcTemplate.update("DELETE FROM memory_block_image_gallery");
|
||||
jdbcTemplate.update("DELETE FROM memory_block");
|
||||
jdbcTemplate.update("DELETE FROM memory");
|
||||
|
||||
testUser = testingService.randomUser();
|
||||
|
||||
Memory memory = new Memory(
|
||||
"Test Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
testMemory = memoryJdbcService.create(testUser, memory);
|
||||
|
||||
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.IMAGE_GALLERY, 0);
|
||||
testBlock = memoryBlockJdbcService.create(block);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateGallery() {
|
||||
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Caption 1", null, null),
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image2.jpg", "Caption 2", null, null)
|
||||
);
|
||||
|
||||
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
|
||||
|
||||
MemoryBlockImageGallery created = memoryBlockImageGalleryJdbcService.create(gallery);
|
||||
|
||||
assertEquals(testBlock.getId(), created.getBlockId());
|
||||
assertEquals(2, created.getImages().size());
|
||||
assertEquals("https://example.com/image1.jpg", created.getImages().get(0).getImageUrl());
|
||||
assertEquals("Caption 1", created.getImages().get(0).getCaption());
|
||||
assertEquals("https://example.com/image2.jpg", created.getImages().get(1).getImageUrl());
|
||||
assertEquals("Caption 2", created.getImages().get(1).getCaption());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateGallery() {
|
||||
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Original Caption", null, null)
|
||||
);
|
||||
|
||||
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
|
||||
MemoryBlockImageGallery created = memoryBlockImageGalleryJdbcService.create(gallery);
|
||||
|
||||
List<MemoryBlockImageGallery.GalleryImage> updatedImages = List.of(
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Updated Caption", null, null),
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image3.jpg", "New Image", null, null)
|
||||
);
|
||||
|
||||
MemoryBlockImageGallery updated = created.withImages(updatedImages);
|
||||
MemoryBlockImageGallery result = memoryBlockImageGalleryJdbcService.update(updated);
|
||||
|
||||
assertEquals(2, result.getImages().size());
|
||||
assertEquals("Updated Caption", result.getImages().get(0).getCaption());
|
||||
assertEquals("https://example.com/image3.jpg", result.getImages().get(1).getImageUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteGallery() {
|
||||
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Caption", null, null)
|
||||
);
|
||||
|
||||
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
|
||||
memoryBlockImageGalleryJdbcService.create(gallery);
|
||||
|
||||
memoryBlockImageGalleryJdbcService.delete(testBlock.getId());
|
||||
|
||||
Optional<MemoryBlockImageGallery> found = memoryBlockImageGalleryJdbcService.findById(testBlock.getId());
|
||||
assertFalse(found.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteByBlockId() {
|
||||
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Caption 1", null, null),
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image2.jpg", "Caption 2", null, null)
|
||||
);
|
||||
|
||||
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
|
||||
memoryBlockImageGalleryJdbcService.create(gallery);
|
||||
|
||||
memoryBlockImageGalleryJdbcService.deleteByBlockId(testBlock.getId());
|
||||
|
||||
Optional<MemoryBlockImageGallery> galleries = memoryBlockImageGalleryJdbcService.findByBlockId(testBlock.getId());
|
||||
assertTrue(galleries.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindById() {
|
||||
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Test Caption", null, null)
|
||||
);
|
||||
|
||||
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
|
||||
memoryBlockImageGalleryJdbcService.create(gallery);
|
||||
|
||||
Optional<MemoryBlockImageGallery> found = memoryBlockImageGalleryJdbcService.findById(testBlock.getId());
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals(testBlock.getId(), found.get().getBlockId());
|
||||
assertEquals(1, found.get().getImages().size());
|
||||
assertEquals("https://example.com/image1.jpg", found.get().getImages().get(0).getImageUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindByBlockId() {
|
||||
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", "Caption 1", null, null),
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image2.jpg", "Caption 2", null, null),
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image3.jpg", "Caption 3", null, null)
|
||||
);
|
||||
|
||||
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
|
||||
memoryBlockImageGalleryJdbcService.create(gallery);
|
||||
|
||||
Optional<MemoryBlockImageGallery> galleries = memoryBlockImageGalleryJdbcService.findByBlockId(testBlock.getId());
|
||||
|
||||
assertTrue(galleries.isPresent());
|
||||
assertEquals(3, galleries.get().getImages().size());
|
||||
assertEquals("https://example.com/image1.jpg", galleries.get().getImages().get(0).getImageUrl());
|
||||
assertEquals("https://example.com/image2.jpg", galleries.get().getImages().get(1).getImageUrl());
|
||||
assertEquals("https://example.com/image3.jpg", galleries.get().getImages().get(2).getImageUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateGalleryWithNullCaptions() {
|
||||
List<MemoryBlockImageGallery.GalleryImage> images = List.of(
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image1.jpg", null, null, null),
|
||||
new MemoryBlockImageGallery.GalleryImage("https://example.com/image2.jpg", "Caption 2", null, null)
|
||||
);
|
||||
|
||||
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), images);
|
||||
MemoryBlockImageGallery created = memoryBlockImageGalleryJdbcService.create(gallery);
|
||||
|
||||
assertNull(created.getImages().get(0).getCaption());
|
||||
assertEquals("Caption 2", created.getImages().get(1).getCaption());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateEmptyGallery() {
|
||||
MemoryBlockImageGallery gallery = new MemoryBlockImageGallery(testBlock.getId(), List.of());
|
||||
MemoryBlockImageGallery created = memoryBlockImageGalleryJdbcService.create(gallery);
|
||||
|
||||
assertTrue(created.getImages().isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.memory.BlockType;
|
||||
import com.dedicatedcode.reitti.model.memory.HeaderType;
|
||||
import com.dedicatedcode.reitti.model.memory.Memory;
|
||||
import com.dedicatedcode.reitti.model.memory.MemoryBlock;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@IntegrationTest
|
||||
class MemoryBlockJdbcServiceTest {
|
||||
|
||||
@Autowired
|
||||
private MemoryBlockJdbcService memoryBlockJdbcService;
|
||||
|
||||
@Autowired
|
||||
private MemoryJdbcService memoryJdbcService;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private User testUser;
|
||||
private Memory testMemory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbcTemplate.update("DELETE FROM memory_block");
|
||||
jdbcTemplate.update("DELETE FROM memory");
|
||||
|
||||
testUser = testingService.randomUser();
|
||||
|
||||
Memory memory = new Memory(
|
||||
"Test Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
testMemory = memoryJdbcService.create(testUser, memory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateBlock() {
|
||||
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
|
||||
MemoryBlock created = memoryBlockJdbcService.create(block);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
assertEquals(testMemory.getId(), created.getMemoryId());
|
||||
assertEquals(BlockType.TEXT, created.getBlockType());
|
||||
assertEquals(0, created.getPosition());
|
||||
assertEquals(1L, created.getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateBlock() {
|
||||
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
MemoryBlock created = memoryBlockJdbcService.create(block);
|
||||
|
||||
MemoryBlock updated = created.withPosition(5);
|
||||
MemoryBlock result = memoryBlockJdbcService.update(updated);
|
||||
|
||||
assertEquals(5, result.getPosition());
|
||||
assertEquals(2L, result.getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateBlockWithWrongVersion() {
|
||||
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
MemoryBlock created = memoryBlockJdbcService.create(block);
|
||||
|
||||
MemoryBlock withWrongVersion = created.withVersion(999L).withPosition(5);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> {
|
||||
memoryBlockJdbcService.update(withWrongVersion);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteBlock() {
|
||||
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
MemoryBlock created = memoryBlockJdbcService.create(block);
|
||||
|
||||
memoryBlockJdbcService.delete(created.getId());
|
||||
|
||||
Optional<MemoryBlock> found = memoryBlockJdbcService.findById(testUser, created.getId());
|
||||
assertFalse(found.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindById() {
|
||||
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
MemoryBlock created = memoryBlockJdbcService.create(block);
|
||||
|
||||
Optional<MemoryBlock> found = memoryBlockJdbcService.findById(testUser, created.getId());
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals(created.getId(), found.get().getId());
|
||||
assertEquals(BlockType.TEXT, found.get().getBlockType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindByMemoryId() {
|
||||
MemoryBlock block1 = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
MemoryBlock block2 = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_VISIT, 1);
|
||||
MemoryBlock block3 = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_TRIP, 2);
|
||||
|
||||
memoryBlockJdbcService.create(block1);
|
||||
memoryBlockJdbcService.create(block2);
|
||||
memoryBlockJdbcService.create(block3);
|
||||
|
||||
List<MemoryBlock> blocks = memoryBlockJdbcService.findByMemoryId(testMemory.getId());
|
||||
|
||||
assertEquals(3, blocks.size());
|
||||
assertEquals(0, blocks.get(0).getPosition());
|
||||
assertEquals(1, blocks.get(1).getPosition());
|
||||
assertEquals(2, blocks.get(2).getPosition());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetMaxPosition() {
|
||||
assertEquals(-1, memoryBlockJdbcService.getMaxPosition(testMemory.getId()));
|
||||
|
||||
MemoryBlock block1 = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
MemoryBlock block2 = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_VISIT, 1);
|
||||
MemoryBlock block3 = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_TRIP, 5);
|
||||
|
||||
memoryBlockJdbcService.create(block1);
|
||||
memoryBlockJdbcService.create(block2);
|
||||
memoryBlockJdbcService.create(block3);
|
||||
|
||||
assertEquals(5, memoryBlockJdbcService.getMaxPosition(testMemory.getId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateMultipleBlockTypes() {
|
||||
MemoryBlock textBlock = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
MemoryBlock visitBlock = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_VISIT, 1);
|
||||
MemoryBlock tripBlock = new MemoryBlock(testMemory.getId(), BlockType.CLUSTER_TRIP, 2);
|
||||
MemoryBlock galleryBlock = new MemoryBlock(testMemory.getId(), BlockType.IMAGE_GALLERY, 3);
|
||||
|
||||
MemoryBlock createdText = memoryBlockJdbcService.create(textBlock);
|
||||
MemoryBlock createdVisit = memoryBlockJdbcService.create(visitBlock);
|
||||
MemoryBlock createdTrip = memoryBlockJdbcService.create(tripBlock);
|
||||
MemoryBlock createdGallery = memoryBlockJdbcService.create(galleryBlock);
|
||||
|
||||
assertEquals(BlockType.TEXT, createdText.getBlockType());
|
||||
assertEquals(BlockType.CLUSTER_VISIT, createdVisit.getBlockType());
|
||||
assertEquals(BlockType.CLUSTER_TRIP, createdTrip.getBlockType());
|
||||
assertEquals(BlockType.IMAGE_GALLERY, createdGallery.getBlockType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.memory.*;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@IntegrationTest
|
||||
class MemoryBlockTextJdbcServiceTest {
|
||||
|
||||
@Autowired
|
||||
private MemoryBlockTextJdbcService memoryBlockTextJdbcService;
|
||||
|
||||
@Autowired
|
||||
private MemoryBlockJdbcService memoryBlockJdbcService;
|
||||
|
||||
@Autowired
|
||||
private MemoryJdbcService memoryJdbcService;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private User testUser;
|
||||
private Memory testMemory;
|
||||
private MemoryBlock testBlock;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbcTemplate.update("DELETE FROM memory_block_text");
|
||||
jdbcTemplate.update("DELETE FROM memory_block");
|
||||
jdbcTemplate.update("DELETE FROM memory");
|
||||
|
||||
testUser = testingService.randomUser();
|
||||
|
||||
Memory memory = new Memory(
|
||||
"Test Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
testMemory = memoryJdbcService.create(testUser, memory);
|
||||
|
||||
MemoryBlock block = new MemoryBlock(testMemory.getId(), BlockType.TEXT, 0);
|
||||
testBlock = memoryBlockJdbcService.create(block);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateTextBlock() {
|
||||
MemoryBlockText textBlock = new MemoryBlockText(
|
||||
testBlock.getId(),
|
||||
"Test Headline",
|
||||
"Test content goes here"
|
||||
);
|
||||
|
||||
MemoryBlockText created = memoryBlockTextJdbcService.create(textBlock);
|
||||
|
||||
assertEquals(testBlock.getId(), created.getBlockId());
|
||||
assertEquals("Test Headline", created.getHeadline());
|
||||
assertEquals("Test content goes here", created.getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateTextBlock() {
|
||||
MemoryBlockText textBlock = new MemoryBlockText(
|
||||
testBlock.getId(),
|
||||
"Original Headline",
|
||||
"Original content"
|
||||
);
|
||||
|
||||
memoryBlockTextJdbcService.create(textBlock);
|
||||
|
||||
MemoryBlockText updated = textBlock
|
||||
.withHeadline("Updated Headline")
|
||||
.withContent("Updated content");
|
||||
|
||||
MemoryBlockText result = memoryBlockTextJdbcService.update(updated);
|
||||
|
||||
assertEquals("Updated Headline", result.getHeadline());
|
||||
assertEquals("Updated content", result.getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindByBlockId() {
|
||||
MemoryBlockText textBlock = new MemoryBlockText(
|
||||
testBlock.getId(),
|
||||
"Test Headline",
|
||||
"Test content"
|
||||
);
|
||||
|
||||
memoryBlockTextJdbcService.create(textBlock);
|
||||
|
||||
Optional<MemoryBlockText> found = memoryBlockTextJdbcService.findByBlockId(testBlock.getId());
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("Test Headline", found.get().getHeadline());
|
||||
assertEquals("Test content", found.get().getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteTextBlock() {
|
||||
MemoryBlockText textBlock = new MemoryBlockText(
|
||||
testBlock.getId(),
|
||||
"Test Headline",
|
||||
"Test content"
|
||||
);
|
||||
|
||||
memoryBlockTextJdbcService.create(textBlock);
|
||||
memoryBlockTextJdbcService.delete(testBlock.getId());
|
||||
|
||||
Optional<MemoryBlockText> found = memoryBlockTextJdbcService.findByBlockId(testBlock.getId());
|
||||
assertFalse(found.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateTextBlockWithNullHeadline() {
|
||||
MemoryBlockText textBlock = new MemoryBlockText(
|
||||
testBlock.getId(),
|
||||
null,
|
||||
"Content without headline"
|
||||
);
|
||||
|
||||
MemoryBlockText created = memoryBlockTextJdbcService.create(textBlock);
|
||||
|
||||
assertNull(created.getHeadline());
|
||||
assertEquals("Content without headline", created.getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateTextBlockWithNullContent() {
|
||||
MemoryBlockText textBlock = new MemoryBlockText(
|
||||
testBlock.getId(),
|
||||
"Headline only",
|
||||
null
|
||||
);
|
||||
|
||||
MemoryBlockText created = memoryBlockTextJdbcService.create(textBlock);
|
||||
|
||||
assertEquals("Headline only", created.getHeadline());
|
||||
assertNull(created.getContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.memory.*;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@IntegrationTest
|
||||
public class MemoryClusterBlockRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private MemoryClusterBlockRepository repository;
|
||||
|
||||
@Autowired
|
||||
private MemoryJdbcService memoryJdbcService;
|
||||
|
||||
@Autowired
|
||||
private MemoryBlockJdbcService memoryBlockJdbcService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
private User user;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
user = testingService.randomUser();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveAndFindByBlockId() {
|
||||
// Given
|
||||
List<Long> tripIds = List.of(1L, 2L, 3L);
|
||||
|
||||
Memory memory = this.memoryJdbcService.create(user,
|
||||
new Memory("Test",
|
||||
"Test description",
|
||||
Instant.parse("2007-12-03T10:15:30.00Z"),
|
||||
Instant.parse("2007-12-03T11:15:30.00Z"),
|
||||
HeaderType.MAP,
|
||||
null));
|
||||
MemoryBlock memoryBlock = memoryBlockJdbcService.create(new MemoryBlock(memory.getId(), BlockType.CLUSTER_TRIP, 0));
|
||||
MemoryClusterBlock cluster = new MemoryClusterBlock(memoryBlock.getId(), tripIds, "Journey to Airport", "A trip from home to the airport", BlockType.CLUSTER_TRIP);
|
||||
|
||||
// When
|
||||
repository.save(user, cluster);
|
||||
Optional<MemoryClusterBlock> found = repository.findByBlockId(user, memoryBlock.getId());
|
||||
|
||||
// Then
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getBlockId()).isEqualTo(memoryBlock.getId());
|
||||
assertThat(found.get().getPartIds()).isEqualTo(tripIds);
|
||||
assertThat(found.get().getTitle()).isEqualTo("Journey to Airport");
|
||||
assertThat(found.get().getDescription()).isEqualTo("A trip from home to the airport");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDeleteByBlockId() {
|
||||
// Given
|
||||
|
||||
Memory memory = this.memoryJdbcService.create(user,
|
||||
new Memory("Test",
|
||||
"Test description",
|
||||
Instant.parse("2007-12-03T10:15:30.00Z"),
|
||||
Instant.parse("2007-12-03T11:15:30.00Z"),
|
||||
HeaderType.MAP,
|
||||
null));
|
||||
MemoryBlock memoryBlock = memoryBlockJdbcService.create(new MemoryBlock(memory.getId(), BlockType.CLUSTER_TRIP, 0));
|
||||
List<Long> tripIds = List.of(4L, 5L);
|
||||
MemoryClusterBlock cluster = new MemoryClusterBlock(memoryBlock.getId(), tripIds, "Another Journey", "Description", BlockType.CLUSTER_TRIP);
|
||||
repository.save(user, cluster);
|
||||
|
||||
// When
|
||||
repository.deleteByBlockId(user, 101L);
|
||||
Optional<MemoryClusterBlock> found = repository.findByBlockId(user, 101L);
|
||||
|
||||
// Then
|
||||
assertThat(found).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFindByBlockIdNotFound() {
|
||||
// When
|
||||
Optional<MemoryClusterBlock> found = repository.findByBlockId(user, 999L);
|
||||
|
||||
// Then
|
||||
assertThat(found).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.dedicatedcode.reitti.repository;
|
||||
|
||||
import com.dedicatedcode.reitti.IntegrationTest;
|
||||
import com.dedicatedcode.reitti.TestingService;
|
||||
import com.dedicatedcode.reitti.model.memory.HeaderType;
|
||||
import com.dedicatedcode.reitti.model.memory.Memory;
|
||||
import com.dedicatedcode.reitti.model.security.User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@IntegrationTest
|
||||
class MemoryJdbcServiceTest {
|
||||
|
||||
@Autowired
|
||||
private MemoryJdbcService memoryJdbcService;
|
||||
|
||||
@Autowired
|
||||
private TestingService testingService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbcTemplate.update("DELETE FROM memory");
|
||||
testUser = testingService.randomUser();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateMemory() {
|
||||
Memory memory = new Memory(
|
||||
"Test Memory",
|
||||
"Test Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory created = memoryJdbcService.create(testUser, memory);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
assertEquals("Test Memory", created.getTitle());
|
||||
assertEquals("Test Description", created.getDescription());
|
||||
assertEquals(LocalDate.of(2024, 1, 1), created.getStartDate());
|
||||
assertEquals(LocalDate.of(2024, 1, 7), created.getEndDate());
|
||||
assertEquals(HeaderType.MAP, created.getHeaderType());
|
||||
assertNull(created.getHeaderImageUrl());
|
||||
assertNotNull(created.getCreatedAt());
|
||||
assertNotNull(created.getUpdatedAt());
|
||||
assertEquals(1L, created.getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateMemoryWithImage() {
|
||||
Memory memory = new Memory(
|
||||
"Image Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 2, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 2, 5).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.IMAGE,
|
||||
"https://example.com/image.jpg"
|
||||
);
|
||||
|
||||
Memory created = memoryJdbcService.create(testUser, memory);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
assertEquals(HeaderType.IMAGE, created.getHeaderType());
|
||||
assertEquals("https://example.com/image.jpg", created.getHeaderImageUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateMemory() {
|
||||
Memory memory = new Memory(
|
||||
"Original Title",
|
||||
"Original Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory created = memoryJdbcService.create(testUser, memory);
|
||||
|
||||
Memory updated = created
|
||||
.withTitle("Updated Title")
|
||||
.withDescription("Updated Description")
|
||||
.withHeaderType(HeaderType.IMAGE)
|
||||
.withHeaderImageUrl("https://example.com/new-image.jpg");
|
||||
|
||||
Memory result = memoryJdbcService.update(testUser, updated);
|
||||
|
||||
assertEquals("Updated Title", result.getTitle());
|
||||
assertEquals("Updated Description", result.getDescription());
|
||||
assertEquals(HeaderType.IMAGE, result.getHeaderType());
|
||||
assertEquals("https://example.com/new-image.jpg", result.getHeaderImageUrl());
|
||||
assertEquals(2L, result.getVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateMemoryWithWrongVersion() {
|
||||
Memory memory = new Memory(
|
||||
"Test Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory created = memoryJdbcService.create(testUser, memory);
|
||||
Memory withWrongVersion = created.withVersion(999L).withTitle("Updated");
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> {
|
||||
memoryJdbcService.update(testUser, withWrongVersion);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDeleteMemory() {
|
||||
Memory memory = new Memory(
|
||||
"Test Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory created = memoryJdbcService.create(testUser, memory);
|
||||
memoryJdbcService.delete(testUser, created.getId());
|
||||
|
||||
Optional<Memory> found = memoryJdbcService.findById(testUser, created.getId());
|
||||
assertFalse(found.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindById() {
|
||||
Memory memory = new Memory(
|
||||
"Test Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory created = memoryJdbcService.create(testUser, memory);
|
||||
Optional<Memory> found = memoryJdbcService.findById(testUser, created.getId());
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals(created.getId(), found.get().getId());
|
||||
assertEquals("Test Memory", found.get().getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindByIdDifferentUser() {
|
||||
User otherUser = testingService.randomUser();
|
||||
|
||||
Memory memory = new Memory(
|
||||
"Test Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory created = memoryJdbcService.create(testUser, memory);
|
||||
Optional<Memory> found = memoryJdbcService.findById(otherUser, created.getId());
|
||||
|
||||
assertFalse(found.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindAllByUser() {
|
||||
Memory memory1 = new Memory(
|
||||
"Memory 1",
|
||||
"Description 1",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory memory2 = new Memory(
|
||||
"Memory 2",
|
||||
"Description 2",
|
||||
LocalDate.of(2024, 2, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 2, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.IMAGE,
|
||||
"https://example.com/image.jpg"
|
||||
);
|
||||
|
||||
memoryJdbcService.create(testUser, memory1);
|
||||
memoryJdbcService.create(testUser, memory2);
|
||||
|
||||
List<Memory> memories = memoryJdbcService.findAllByUser(testUser);
|
||||
|
||||
assertEquals(2, memories.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindByDateRange() {
|
||||
Memory memory1 = new Memory(
|
||||
"January Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 1, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory memory2 = new Memory(
|
||||
"February Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 2, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 2, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
Memory memory3 = new Memory(
|
||||
"March Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 3, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 3, 7).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
memoryJdbcService.create(testUser, memory1);
|
||||
memoryJdbcService.create(testUser, memory2);
|
||||
memoryJdbcService.create(testUser, memory3);
|
||||
|
||||
List<Memory> memories = memoryJdbcService.findByDateRange(
|
||||
testUser,
|
||||
LocalDate.of(2024, 1, 15).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 2, 15).atStartOfDay().toInstant(ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
assertEquals(2, memories.size());
|
||||
assertTrue(memories.stream().anyMatch(m -> m.getTitle().equals("January Memory")));
|
||||
assertTrue(memories.stream().anyMatch(m -> m.getTitle().equals("February Memory")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFindByDateRangeOverlapping() {
|
||||
Memory memory = new Memory(
|
||||
"Long Memory",
|
||||
"Description",
|
||||
LocalDate.of(2024, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 3, 31).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
HeaderType.MAP,
|
||||
null
|
||||
);
|
||||
|
||||
memoryJdbcService.create(testUser, memory);
|
||||
|
||||
List<Memory> memories = memoryJdbcService.findByDateRange(
|
||||
testUser,
|
||||
LocalDate.of(2024, 2, 1).atStartOfDay().toInstant(ZoneOffset.UTC),
|
||||
LocalDate.of(2024, 2, 28).atStartOfDay().toInstant(ZoneOffset.UTC)
|
||||
);
|
||||
|
||||
assertEquals(1, memories.size());
|
||||
assertEquals("Long Memory", memories.get(0).getTitle());
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,7 @@ class SignificantPlaceJdbcServiceTest {
|
||||
created.getId(),
|
||||
"Updated Name",
|
||||
"Updated Address",
|
||||
"Berlin",
|
||||
"DE",
|
||||
53.863149,
|
||||
10.700927,
|
||||
@@ -210,6 +211,7 @@ class SignificantPlaceJdbcServiceTest {
|
||||
created1.getId(),
|
||||
created1.getName(),
|
||||
"Some Address",
|
||||
"Berlin",
|
||||
"DE",
|
||||
created1.getLatitudeCentroid(),
|
||||
created1.getLongitudeCentroid(),
|
||||
@@ -265,6 +267,7 @@ class SignificantPlaceJdbcServiceTest {
|
||||
name,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
latitude,
|
||||
longitude,
|
||||
SignificantPlace.PlaceType.OTHER,
|
||||
@@ -280,6 +283,7 @@ class SignificantPlaceJdbcServiceTest {
|
||||
name,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
latitude,
|
||||
longitude,
|
||||
SignificantPlace.PlaceType.OTHER,
|
||||
|
||||
Reference in New Issue
Block a user