mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
commit
d0c66b68ac
52 changed files with 1463 additions and 157 deletions
|
|
@ -1 +1 @@
|
|||
0.17.2
|
||||
0.18.0
|
||||
|
|
|
|||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -5,7 +5,25 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# 0.17.1 - 2024-11-27
|
||||
# 0.18.0 - 2024-11-28
|
||||
|
||||
## The Trips release
|
||||
|
||||
You can now create, edit and delete trips. To create a trip, click on the "New Trip" button on the Trips page. Provide a name, date and time for start and end of the trip. You can add your own notes to the trip as well.
|
||||
|
||||
If you have points tracked during provided timeframe, they will be automatically added to the trip and will be shown on the trip map.
|
||||
|
||||
Also, if you have Immich integrated, you will see photos from the trip on the trip page, along with a link to look at them on Immich.
|
||||
|
||||
### Added
|
||||
|
||||
- The Trips feature. Read above for more details.
|
||||
|
||||
### Changed
|
||||
|
||||
- Maps are now not so rough on the edges.
|
||||
|
||||
# 0.17.2 - 2024-11-27
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
45
app/assets/stylesheets/actiontext.css
Normal file
45
app/assets/stylesheets/actiontext.css
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Provides a drop-in pointer for the default Trix stylesheet that will format the toolbar and
|
||||
* the trix-editor content (whether displayed or under editing). Feel free to incorporate this
|
||||
* inclusion directly in any other asset bundle and remove this file.
|
||||
*
|
||||
*= require trix
|
||||
*/
|
||||
|
||||
/*
|
||||
* We need to override trix.css’s image gallery styles to accommodate the
|
||||
* <action-text-attachment> element we wrap around attachments. Otherwise,
|
||||
* images in galleries will be squished by the max-width: 33%; rule.
|
||||
*/
|
||||
.trix-content .attachment-gallery > action-text-attachment,
|
||||
.trix-content .attachment-gallery > .attachment {
|
||||
flex: 1 0 33%;
|
||||
padding: 0 0.5em;
|
||||
max-width: 33%;
|
||||
}
|
||||
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment,
|
||||
.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment,
|
||||
.trix-content .attachment-gallery.attachment-gallery--4 > .attachment {
|
||||
flex-basis: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.trix-content action-text-attachment .attachment {
|
||||
padding: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* Hide both attach files and attach images buttons in trix editor*/
|
||||
.trix-button-group.trix-button-group--file-tools {
|
||||
display:none;
|
||||
}
|
||||
|
||||
/* Color buttons in white */
|
||||
.trix-button-row button {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.trix-content {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
|
@ -12,3 +12,4 @@
|
|||
}
|
||||
|
||||
*/
|
||||
@import 'actiontext.css';
|
||||
69
app/controllers/trips_controller.rb
Normal file
69
app/controllers/trips_controller.rb
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TripsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_trip, only: %i[show edit update destroy]
|
||||
before_action :set_coordinates, only: %i[show edit]
|
||||
|
||||
def index
|
||||
@trips = current_user.trips.order(started_at: :desc).page(params[:page]).per(6)
|
||||
end
|
||||
|
||||
def show
|
||||
@coordinates = @trip.points.pluck(
|
||||
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
|
||||
:country
|
||||
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
|
||||
|
||||
@photos = Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
|
||||
@trip.photos
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
@trip = Trip.new
|
||||
@coordinates = []
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
@trip = current_user.trips.build(trip_params)
|
||||
|
||||
if @trip.save
|
||||
redirect_to @trip, notice: 'Trip was successfully created.'
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @trip.update(trip_params)
|
||||
redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@trip.destroy!
|
||||
redirect_to trips_url, notice: 'Trip was successfully destroyed.', status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_trip
|
||||
@trip = current_user.trips.find(params[:id])
|
||||
end
|
||||
|
||||
def set_coordinates
|
||||
@coordinates = @trip.points.pluck(
|
||||
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
|
||||
:country
|
||||
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
|
||||
end
|
||||
|
||||
def trip_params
|
||||
params.require(:trip).permit(:name, :started_at, :ended_at, :notes)
|
||||
end
|
||||
end
|
||||
|
|
@ -106,4 +106,18 @@ module ApplicationHelper
|
|||
|
||||
'text-blue-600'
|
||||
end
|
||||
|
||||
def human_date(date)
|
||||
date.strftime('%e %B %Y')
|
||||
end
|
||||
|
||||
def immich_search_url(base_url, start_date, end_date)
|
||||
query = {
|
||||
takenAfter: "#{start_date.to_date}T00:00:00.000Z",
|
||||
takenBefore: "#{end_date.to_date}T23:59:59.999Z"
|
||||
}
|
||||
|
||||
encoded_query = URI.encode_www_form_component(query.to_json)
|
||||
"#{base_url}/search?query=#{encoded_query}"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,3 +9,6 @@ import "leaflet-providers"
|
|||
import "chartkick"
|
||||
import "Chart.bundle"
|
||||
import "./channels"
|
||||
|
||||
import "trix"
|
||||
import "@rails/actiontext"
|
||||
|
|
|
|||
69
app/javascript/controllers/datetime_controller.js
Normal file
69
app/javascript/controllers/datetime_controller.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["startedAt", "endedAt", "apiKey"]
|
||||
static values = { tripsId: String }
|
||||
|
||||
connect() {
|
||||
console.log("Datetime controller connected")
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
|
||||
async updateCoordinates(event) {
|
||||
// Clear any existing timeout
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
const startedAt = this.startedAtTarget.value
|
||||
const endedAt = this.endedAtTarget.value
|
||||
const apiKey = this.apiKeyTarget.value
|
||||
|
||||
if (startedAt && endedAt) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start_at: startedAt,
|
||||
end_at: endedAt,
|
||||
api_key: apiKey,
|
||||
slim: true
|
||||
})
|
||||
let allPoints = [];
|
||||
let currentPage = 1;
|
||||
const perPage = 1000;
|
||||
|
||||
do {
|
||||
const paginatedParams = `${params}&page=${currentPage}&per_page=${perPage}`;
|
||||
const response = await fetch(`/api/v1/points?${paginatedParams}`);
|
||||
const data = await response.json();
|
||||
|
||||
allPoints = [...allPoints, ...data];
|
||||
|
||||
const totalPages = parseInt(response.headers.get('X-Total-Pages'));
|
||||
currentPage++;
|
||||
|
||||
if (!totalPages || currentPage > totalPages) {
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
const event = new CustomEvent('coordinates-updated', {
|
||||
detail: { coordinates: allPoints },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
})
|
||||
|
||||
const tripsElement = document.querySelector('[data-controller="trips"]')
|
||||
if (tripsElement) {
|
||||
tripsElement.dispatchEvent(event)
|
||||
} else {
|
||||
console.error('Trips controller element not found')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { fetchAndDrawAreas } from "../maps/areas";
|
|||
import { handleAreaCreated } from "../maps/areas";
|
||||
|
||||
import { showFlashMessage } from "../maps/helpers";
|
||||
import { fetchAndDisplayPhotos } from '../maps/helpers';
|
||||
|
||||
import { osmMapLayer } from "../maps/layers";
|
||||
import { osmHotMapLayer } from "../maps/layers";
|
||||
|
|
@ -83,14 +84,13 @@ export default class extends Controller {
|
|||
Photos: this.photoMarkers
|
||||
};
|
||||
|
||||
L.control
|
||||
.scale({
|
||||
position: "bottomright",
|
||||
metric: true,
|
||||
imperial: true,
|
||||
maxWidth: 120,
|
||||
})
|
||||
.addTo(this.map);
|
||||
// Add scale control to bottom right
|
||||
L.control.scale({
|
||||
position: 'bottomright',
|
||||
imperial: this.distanceUnit === 'mi',
|
||||
metric: this.distanceUnit === 'km',
|
||||
maxWidth: 120
|
||||
}).addTo(this.map)
|
||||
|
||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
|
||||
|
|
@ -132,16 +132,22 @@ export default class extends Controller {
|
|||
this.initializeDrawControl();
|
||||
|
||||
// Add event listeners to toggle draw controls
|
||||
this.map.on('overlayadd', (e) => {
|
||||
this.map.on('overlayadd', async (e) => {
|
||||
if (e.name === 'Areas') {
|
||||
this.map.addControl(this.drawControl);
|
||||
}
|
||||
if (e.name === 'Photos') {
|
||||
// Extract dates from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
|
||||
const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
|
||||
this.fetchAndDisplayPhotos(startDate, endDate);
|
||||
await fetchAndDisplayPhotos({
|
||||
map: this.map,
|
||||
photoMarkers: this.photoMarkers,
|
||||
apiKey: this.apiKey,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
userSettings: this.userSettings
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -782,88 +788,6 @@ export default class extends Controller {
|
|||
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
|
||||
}
|
||||
|
||||
async fetchAndDisplayPhotos(startDate, endDate, retryCount = 0) {
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 3000; // 3 seconds
|
||||
|
||||
// Create loading control
|
||||
const LoadingControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const container = L.DomUtil.create('div', 'leaflet-loading-control');
|
||||
container.innerHTML = '<div class="loading-spinner"></div>';
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
const loadingControl = new LoadingControl({ position: 'topleft' });
|
||||
this.map.addControl(loadingControl);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
api_key: this.apiKey,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/v1/photos?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const photos = await response.json();
|
||||
this.photoMarkers.clearLayers();
|
||||
|
||||
// Create a promise for each photo to track when it's fully loaded
|
||||
const photoLoadPromises = photos.map(photo => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`;
|
||||
|
||||
img.onload = () => {
|
||||
this.createPhotoMarker(photo);
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error(`Failed to load photo ${photo.id}`);
|
||||
resolve(); // Resolve anyway to not block other photos
|
||||
};
|
||||
|
||||
img.src = thumbnailUrl;
|
||||
});
|
||||
});
|
||||
|
||||
// Wait for all photos to be loaded and rendered
|
||||
await Promise.all(photoLoadPromises);
|
||||
|
||||
if (!this.map.hasLayer(this.photoMarkers)) {
|
||||
this.photoMarkers.addTo(this.map);
|
||||
}
|
||||
|
||||
// Show checkmark for 1 second before removing
|
||||
const loadingSpinner = document.querySelector('.loading-spinner');
|
||||
loadingSpinner.classList.add('done');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
showFlashMessage('error', 'Failed to fetch photos');
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
|
||||
setTimeout(() => {
|
||||
this.fetchAndDisplayPhotos(startDate, endDate, retryCount + 1);
|
||||
}, RETRY_DELAY);
|
||||
} else {
|
||||
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
|
||||
}
|
||||
} finally {
|
||||
// Remove loading control after the delay
|
||||
this.map.removeControl(loadingControl);
|
||||
}
|
||||
}
|
||||
|
||||
createPhotoMarker(photo) {
|
||||
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
|
||||
|
||||
|
|
|
|||
60
app/javascript/controllers/trip_map_controller.js
Normal file
60
app/javascript/controllers/trip_map_controller.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import L from "leaflet"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
tripId: Number,
|
||||
coordinates: Array,
|
||||
apiKey: String,
|
||||
userSettings: Object,
|
||||
timezone: String,
|
||||
distanceUnit: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
setTimeout(() => {
|
||||
this.initializeMap()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
// Initialize map with basic configuration
|
||||
this.map = L.map(this.element, {
|
||||
zoomControl: false,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
attributionControl: true // Disable default attribution control
|
||||
})
|
||||
|
||||
// Add the tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: "© <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
|
||||
}).addTo(this.map)
|
||||
|
||||
// If we have coordinates, show the route
|
||||
if (this.hasCoordinatesValue && this.coordinatesValue.length > 0) {
|
||||
this.showRoute()
|
||||
}
|
||||
}
|
||||
|
||||
showRoute() {
|
||||
const points = this.coordinatesValue.map(coord => [coord[0], coord[1]])
|
||||
|
||||
const polyline = L.polyline(points, {
|
||||
color: 'blue',
|
||||
weight: 3,
|
||||
opacity: 0.8
|
||||
}).addTo(this.map)
|
||||
|
||||
this.map.fitBounds(polyline.getBounds(), {
|
||||
padding: [20, 20]
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
182
app/javascript/controllers/trips_controller.js
Normal file
182
app/javascript/controllers/trips_controller.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
import L from "leaflet"
|
||||
import { osmMapLayer } from "../maps/layers"
|
||||
import { createPopupContent } from "../maps/popups"
|
||||
import { osmHotMapLayer } from "../maps/layers"
|
||||
import { OPNVMapLayer } from "../maps/layers"
|
||||
import { openTopoMapLayer } from "../maps/layers"
|
||||
import { cyclOsmMapLayer } from "../maps/layers"
|
||||
import { esriWorldStreetMapLayer } from "../maps/layers"
|
||||
import { esriWorldTopoMapLayer } from "../maps/layers"
|
||||
import { esriWorldImageryMapLayer } from "../maps/layers"
|
||||
import { esriWorldGrayCanvasMapLayer } from "../maps/layers"
|
||||
import { fetchAndDisplayPhotos } from '../maps/helpers';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["container", "startedAt", "endedAt"]
|
||||
static values = { }
|
||||
|
||||
connect() {
|
||||
if (!this.hasContainerTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Trips controller connected")
|
||||
this.coordinates = JSON.parse(this.containerTarget.dataset.coordinates)
|
||||
this.apiKey = this.containerTarget.dataset.api_key
|
||||
this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings)
|
||||
this.timezone = this.containerTarget.dataset.timezone
|
||||
this.distanceUnit = this.containerTarget.dataset.distance_unit
|
||||
|
||||
// Initialize map and layers
|
||||
this.initializeMap()
|
||||
|
||||
// Add event listener for coordinates updates
|
||||
this.element.addEventListener('coordinates-updated', (event) => {
|
||||
console.log("Coordinates updated:", event.detail.coordinates)
|
||||
this.updateMapWithCoordinates(event.detail.coordinates)
|
||||
})
|
||||
}
|
||||
|
||||
// Move map initialization to separate method
|
||||
initializeMap() {
|
||||
// Initialize layer groups
|
||||
this.markersLayer = L.layerGroup()
|
||||
this.polylinesLayer = L.layerGroup()
|
||||
this.photoMarkers = L.layerGroup()
|
||||
|
||||
// Set default center and zoom for world view
|
||||
const hasValidCoordinates = this.coordinates && Array.isArray(this.coordinates) && this.coordinates.length > 0
|
||||
const center = hasValidCoordinates
|
||||
? [this.coordinates[0][0], this.coordinates[0][1]]
|
||||
: [20, 0] // Roughly centers the world map
|
||||
const zoom = hasValidCoordinates ? 14 : 2
|
||||
|
||||
// Initialize map
|
||||
this.map = L.map(this.containerTarget).setView(center, zoom)
|
||||
|
||||
// Add base map layer
|
||||
osmMapLayer(this.map, "OpenStreetMap")
|
||||
|
||||
// Add scale control to bottom right
|
||||
L.control.scale({
|
||||
position: 'bottomright',
|
||||
imperial: this.distanceUnit === 'mi',
|
||||
metric: this.distanceUnit === 'km',
|
||||
maxWidth: 120
|
||||
}).addTo(this.map)
|
||||
|
||||
const overlayMaps = {
|
||||
"Points": this.markersLayer,
|
||||
"Route": this.polylinesLayer,
|
||||
"Photos": this.photoMarkers
|
||||
}
|
||||
|
||||
// Add layer control
|
||||
L.control.layers(this.baseMaps(), overlayMaps).addTo(this.map)
|
||||
|
||||
// Add event listener for layer changes
|
||||
this.map.on('overlayadd', (e) => {
|
||||
if (e.name === 'Photos' && this.coordinates?.length > 0) {
|
||||
const firstCoord = this.coordinates[0];
|
||||
const lastCoord = this.coordinates[this.coordinates.length - 1];
|
||||
|
||||
const startDate = new Date(firstCoord[4] * 1000).toISOString().split('T')[0];
|
||||
const endDate = new Date(lastCoord[4] * 1000).toISOString().split('T')[0];
|
||||
|
||||
fetchAndDisplayPhotos({
|
||||
map: this.map,
|
||||
photoMarkers: this.photoMarkers,
|
||||
apiKey: this.apiKey,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
userSettings: this.userSettings
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add markers and route
|
||||
if (this.coordinates?.length > 0) {
|
||||
this.addMarkers()
|
||||
this.addPolyline()
|
||||
this.fitMapToBounds()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
}
|
||||
}
|
||||
|
||||
baseMaps() {
|
||||
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
|
||||
|
||||
return {
|
||||
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
|
||||
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
|
||||
OPNV: OPNVMapLayer(this.map, selectedLayerName),
|
||||
openTopo: openTopoMapLayer(this.map, selectedLayerName),
|
||||
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
|
||||
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
|
||||
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
|
||||
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
|
||||
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
|
||||
};
|
||||
}
|
||||
|
||||
addMarkers() {
|
||||
this.coordinates.forEach(coord => {
|
||||
const marker = L.circleMarker([coord[0], coord[1]], {radius: 4})
|
||||
|
||||
const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit)
|
||||
marker.bindPopup(popupContent)
|
||||
|
||||
// Add to markers layer instead of directly to map
|
||||
marker.addTo(this.markersLayer)
|
||||
})
|
||||
}
|
||||
|
||||
addPolyline() {
|
||||
const points = this.coordinates.map(coord => [coord[0], coord[1]])
|
||||
const polyline = L.polyline(points, {
|
||||
color: 'blue',
|
||||
weight: 3,
|
||||
opacity: 0.8
|
||||
})
|
||||
// Add to polylines layer instead of directly to map
|
||||
this.polylinesLayer.addTo(this.map)
|
||||
polyline.addTo(this.polylinesLayer)
|
||||
}
|
||||
|
||||
fitMapToBounds() {
|
||||
const bounds = L.latLngBounds(
|
||||
this.coordinates.map(coord => [coord[0], coord[1]])
|
||||
)
|
||||
this.map.fitBounds(bounds, { padding: [50, 50] })
|
||||
}
|
||||
|
||||
// Add this new method to update coordinates and refresh the map
|
||||
updateMapWithCoordinates(newCoordinates) {
|
||||
// Transform the coordinates to match the expected format
|
||||
this.coordinates = newCoordinates.map(point => [
|
||||
parseFloat(point.latitude),
|
||||
parseFloat(point.longitude),
|
||||
point.id,
|
||||
null, // This is so we can use the same order and position of elements in the coordinates object as in the api/v1/points response
|
||||
(point.timestamp).toString()
|
||||
]).sort((a, b) => a[4] - b[4]);
|
||||
|
||||
// Clear existing layers
|
||||
this.markersLayer.clearLayers()
|
||||
this.polylinesLayer.clearLayers()
|
||||
this.photoMarkers.clearLayers()
|
||||
|
||||
// Add new markers and route if coordinates exist
|
||||
if (this.coordinates?.length > 0) {
|
||||
this.addMarkers()
|
||||
this.addPolyline()
|
||||
this.fitMapToBounds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,3 +136,131 @@ function classesForFlash(type) {
|
|||
return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate, userSettings }, retryCount = 0) {
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 3000; // 3 seconds
|
||||
|
||||
// Create loading control
|
||||
const LoadingControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const container = L.DomUtil.create('div', 'leaflet-loading-control');
|
||||
container.innerHTML = '<div class="loading-spinner"></div>';
|
||||
return container;
|
||||
}
|
||||
});
|
||||
|
||||
const loadingControl = new LoadingControl({ position: 'topleft' });
|
||||
map.addControl(loadingControl);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
api_key: apiKey,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/v1/photos?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const photos = await response.json();
|
||||
photoMarkers.clearLayers();
|
||||
|
||||
const photoLoadPromises = photos.map(photo => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`;
|
||||
|
||||
img.onload = () => {
|
||||
createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey);
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error(`Failed to load photo ${photo.id}`);
|
||||
resolve(); // Resolve anyway to not block other photos
|
||||
};
|
||||
|
||||
img.src = thumbnailUrl;
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(photoLoadPromises);
|
||||
|
||||
if (!map.hasLayer(photoMarkers)) {
|
||||
photoMarkers.addTo(map);
|
||||
}
|
||||
|
||||
// Show checkmark for 1 second before removing
|
||||
const loadingSpinner = document.querySelector('.loading-spinner');
|
||||
loadingSpinner.classList.add('done');
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
showFlashMessage('error', 'Failed to fetch photos');
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
|
||||
setTimeout(() => {
|
||||
fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate }, retryCount + 1);
|
||||
}, RETRY_DELAY);
|
||||
} else {
|
||||
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
|
||||
}
|
||||
} finally {
|
||||
map.removeControl(loadingControl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
|
||||
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
|
||||
|
||||
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`;
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'photo-marker',
|
||||
html: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
|
||||
iconSize: [48, 48]
|
||||
});
|
||||
|
||||
const marker = L.marker(
|
||||
[photo.exifInfo.latitude, photo.exifInfo.longitude],
|
||||
{ icon }
|
||||
);
|
||||
|
||||
const startOfDay = new Date(photo.localDateTime);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(photo.localDateTime);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const queryParams = {
|
||||
takenAfter: startOfDay.toISOString(),
|
||||
takenBefore: endOfDay.toISOString()
|
||||
};
|
||||
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
|
||||
const immich_photo_link = `${immichUrl}/search?query=${encodedQuery}`;
|
||||
const popupContent = `
|
||||
<div class="max-w-xs">
|
||||
<a href="${immich_photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
|
||||
onmouseout="this.firstElementChild.style.boxShadow = '';">
|
||||
<img src="${thumbnailUrl}"
|
||||
class="mb-2 rounded"
|
||||
style="transition: box-shadow 0.3s ease;"
|
||||
alt="${photo.originalFileName}">
|
||||
</a>
|
||||
<h3 class="font-bold">${photo.originalFileName}</h3>
|
||||
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
|
||||
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p>
|
||||
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
|
||||
</div>
|
||||
`;
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
photoMarkers.addLayer(marker);
|
||||
}
|
||||
|
|
|
|||
55
app/models/trip.rb
Normal file
55
app/models/trip.rb
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Trip < ApplicationRecord
|
||||
has_rich_text :notes
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :name, :started_at, :ended_at, presence: true
|
||||
|
||||
before_save :calculate_distance
|
||||
|
||||
def points
|
||||
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
|
||||
end
|
||||
|
||||
def countries
|
||||
points.pluck(:country).uniq.compact
|
||||
end
|
||||
|
||||
def photos
|
||||
immich_photos = Immich::RequestPhotos.new(
|
||||
user,
|
||||
start_date: started_at.to_date.to_s,
|
||||
end_date: ended_at.to_date.to_s
|
||||
).call.reject { |asset| asset['type'].downcase == 'video' }
|
||||
|
||||
# let's count what photos are more: vertical or horizontal and select the ones that are more
|
||||
vertical_photos = immich_photos.select { _1['exifInfo']['orientation'] == '6' }
|
||||
horizontal_photos = immich_photos.select { _1['exifInfo']['orientation'] == '3' }
|
||||
|
||||
# this is ridiculous, but I couldn't find my way around frontend
|
||||
# to show all photos in the same height
|
||||
photos = vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
|
||||
|
||||
photos.sample(12).sort_by { _1['localDateTime'] }.map do |asset|
|
||||
{ url: "/api/v1/photos/#{asset['id']}/thumbnail.jpg?api_key=#{user.api_key}" }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_distance
|
||||
distance = 0
|
||||
|
||||
points.each_cons(2) do |point1, point2|
|
||||
distance_between = Geocoder::Calculations.distance_between(
|
||||
point1.to_coordinates, point2.to_coordinates, units: ::DISTANCE_UNIT
|
||||
)
|
||||
|
||||
distance += distance_between
|
||||
end
|
||||
|
||||
self.distance = distance.round
|
||||
end
|
||||
end
|
||||
|
|
@ -15,6 +15,7 @@ class User < ApplicationRecord
|
|||
has_many :visits, dependent: :destroy
|
||||
has_many :points, through: :imports
|
||||
has_many :places, through: :visits
|
||||
has_many :trips, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class Immich::RequestPhotos
|
|||
data = []
|
||||
max_pages = 10_000 # Prevent infinite loop
|
||||
|
||||
# TODO: Handle pagination using nextPage
|
||||
while page <= max_pages
|
||||
response = JSON.parse(
|
||||
HTTParty.post(
|
||||
|
|
@ -38,6 +39,8 @@ class Immich::RequestPhotos
|
|||
|
||||
if items.blank?
|
||||
Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
|
||||
Rails.logger.debug("START_DATE: #{start_date}")
|
||||
Rails.logger.debug("END_DATE: #{end_date}")
|
||||
Rails.logger.debug(response)
|
||||
Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
|
||||
|
||||
|
|
|
|||
14
app/views/active_storage/blobs/_blob.html.erb
Normal file
14
app/views/active_storage/blobs/_blob.html.erb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
|
||||
<% if blob.representable? %>
|
||||
<%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
|
||||
<% end %>
|
||||
|
||||
<figcaption class="attachment__caption">
|
||||
<% if caption = blob.try(:caption) %>
|
||||
<%= caption %>
|
||||
<% else %>
|
||||
<span class="attachment__name"><%= blob.filename %></span>
|
||||
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
|
||||
<% end %>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<% content_for :title, "Exports" %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-center my-5">
|
||||
<div class="w-full my-5">
|
||||
<div class="flex justify-center">
|
||||
<h1 class="font-bold text-4xl">Exports</h1>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<div class="w-full mx-auto">
|
||||
<div class="w-full mx-auto my-5">
|
||||
<div class="flex justify-between items-center mt-5 mb-5">
|
||||
<div class="hero h-fit bg-base-200 py-20" style="background-image: url(<%= '/images/bg-image.jpg' %>);">
|
||||
<div class="hero-content text-center">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<% content_for :title, 'Imports' %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="w-full my-5">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="font-bold text-4xl">Imports</h1>
|
||||
<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
|
||||
|
||||
|
|
|
|||
3
app/views/layouts/action_text/contents/_content.html.erb
Normal file
3
app/views/layouts/action_text/contents/_content.html.erb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<div class="trix-content">
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
|
@ -1,40 +1,42 @@
|
|||
<% content_for :title, 'Map' %>
|
||||
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-4 mt-8 w-full">
|
||||
<div class="flex flex-col lg:flex-row lg:space-x-4 my-5 w-full">
|
||||
<div class='w-full lg:w-5/6'>
|
||||
<div class="flex flex-col space-y-4 mb-4 w-full">
|
||||
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
|
||||
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4 sm:items-end">
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :start_at, class: "rounded-md w-full", value: @start_at %>
|
||||
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
|
||||
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
||||
<%= f.datetime_local_field :end_at, class: "rounded-md w-full", value: @end_at %>
|
||||
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-3/12">
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
|
||||
<%= f.submit "Search", class: "btn btn-primary hover:btn-info" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-2/12">
|
||||
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
<%= link_to "Yesterday",
|
||||
map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day, import_id: params[:import_id]),
|
||||
class: "btn btn-neutral hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
|
||||
<%= link_to "Last month", map_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -50,7 +52,7 @@
|
|||
data-user_settings=<%= current_user.settings.to_json %>
|
||||
data-coordinates="<%= @coordinates %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
<div data-maps-target="container" class="h-[25rem] w-full min-h-screen">
|
||||
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen">
|
||||
<div id="fog" class="fog"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<% content_for :title, "Places" %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="flex justify-center my-5">
|
||||
<div class="w-full my-5">
|
||||
<div class="flex justify-center">
|
||||
<h1 class="font-bold text-4xl">Places</h1>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<% content_for :title, 'Points' %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full my-5">
|
||||
<%= form_with url: points_path(import_id: params[:import_id]), data: { turbo_method: :get }, method: :get do |f| %>
|
||||
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
|
||||
<div class="w-full md:w-2/12">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<% content_for :title, "Background jobs" %>
|
||||
|
||||
<div class="min-h-content w-full">
|
||||
<div class="min-h-content w-full my-5">
|
||||
<%= render 'settings/navigation' %>
|
||||
|
||||
<div class="flex justify-between items-center mt-5">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<% content_for :title, 'Settings' %>
|
||||
|
||||
<div class="min-h-content w-full">
|
||||
<div class="min-h-content w-full my-5">
|
||||
<%= render 'settings/navigation' %>
|
||||
|
||||
<div class="flex flex-col lg:flex-row w-full my-10 space-x-4">
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
|
||||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
|
||||
<li><%= link_to 'Places<sup>β</sup>'.html_safe, places_url, class: "#{active_class?(places_url)}" %></li>
|
||||
<li><%= link_to 'Visits<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
|
||||
<li><%= link_to 'Places<sup>α</sup>'.html_safe, places_url, class: "#{active_class?(places_url)}" %></li>
|
||||
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
</ul>
|
||||
|
|
@ -41,13 +42,14 @@
|
|||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
|
||||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
|
||||
<li><%= link_to 'Places<sup>β</sup>'.html_safe, places_url, class: "#{active_class?(places_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
|
||||
<li><%= link_to 'Points', points_url, class: "mx-1 #{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Visits<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "mx-1 #{active_class?(visits_url)}" %></li>
|
||||
<li><%= link_to 'Places<sup>α</sup>'.html_safe, places_url, class: "mx-1 #{active_class?(places_url)}" %></li>
|
||||
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "mx-1 #{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "mx-1 #{active_class?(exports_url)}" %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<div id='years-nav'>
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn m-1">Select year</div>
|
||||
<div tabindex="0" role="button" class="btn">Select year</div>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<% current_user.stats.years.each do |year| %>
|
||||
<li><%= link_to year, map_url(year_timespan(year).merge(year: year, import_id: params[:import_id])) %></li>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<% content_for :title, 'Statistics' %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full my-5">
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||
<div class="stat text-center">
|
||||
<div class="stat-value text-primary">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<% content_for :title, "Statistics for #{@year} year" %>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="w-full my-5">
|
||||
<%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %>
|
||||
</div>
|
||||
|
|
|
|||
73
app/views/trips/_form.html.erb
Normal file
73
app/views/trips/_form.html.erb
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<%= form_with(model: trip, class: "contents") do |form| %>
|
||||
<% if trip.errors.any? %>
|
||||
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
|
||||
<h2><%= pluralize(trip.errors.count, "error") %> prohibited this trip from being saved:</h2>
|
||||
|
||||
<ul>
|
||||
<% trip.errors.each do |error| %>
|
||||
<li><%= error.full_message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4 my-4" data-controller="trips">
|
||||
<div class="w-full lg:w-1/2">
|
||||
<div
|
||||
id='map trips-container'
|
||||
class="w-full h-full rounded-lg"
|
||||
data-trips-target="container"
|
||||
data-distance_unit="<%= DISTANCE_UNIT %>"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-user_settings="<%= current_user.settings.to_json %>"
|
||||
data-coordinates="<%= @coordinates.to_json %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full lg:w-1/2 space-y-4"
|
||||
data-controller="datetime">
|
||||
|
||||
<div class="form-control">
|
||||
<%= form.label :name %>
|
||||
<%= form.text_field :name, class: 'input input-bordered w-full' %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="form-control w-full">
|
||||
<input type="hidden" data-datetime-target="apiKey" value="<%= current_user.api_key %>">
|
||||
<%= form.label :started_at %>
|
||||
<%= form.datetime_field :started_at,
|
||||
include_seconds: false,
|
||||
class: 'input input-bordered w-full',
|
||||
value: trip.started_at,
|
||||
data: {
|
||||
datetime_target: "startedAt",
|
||||
action: "change->datetime#updateCoordinates"
|
||||
} %>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<%= form.label :ended_at %>
|
||||
<%= form.datetime_field :ended_at,
|
||||
include_seconds: false,
|
||||
class: 'input input-bordered w-full',
|
||||
value: trip.ended_at,
|
||||
data: {
|
||||
datetime_target: "endedAt",
|
||||
action: "change->datetime#updateCoordinates"
|
||||
} %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<%= form.label :notes %>
|
||||
<%= form.rich_text_area :notes %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
24
app/views/trips/_trip.html.erb
Normal file
24
app/views/trips/_trip.html.erb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<%= link_to trip, class: "block hover:shadow-lg rounded-lg" do %>
|
||||
<div class="card bg-base-200 shadow-xl hover:shadow-2xl transition-shadow duration-200" data-trip-id="<%= trip.id %>" id="trip-<%= trip.id %>">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title justify-center">
|
||||
<span class="hover:underline"><%= trip.name %></span>
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
<%= "#{human_date(trip.started_at)} – #{human_date(trip.ended_at)}, #{trip.distance} #{DISTANCE_UNIT}" %>
|
||||
</p>
|
||||
|
||||
<div style="width: 100%; aspect-ratio: 1/1;"
|
||||
id="map-<%= trip.id %>"
|
||||
class="rounded-lg"
|
||||
data-controller="trip-map"
|
||||
data-trip-map-trip-id-value="<%= trip.id %>"
|
||||
data-trip-map-coordinates-value="<%= trip.points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country).to_json %>"
|
||||
data-trip-map-api-key-value="<%= current_user.api_key %>"
|
||||
data-trip-map-user-settings-value="<%= current_user.settings.to_json %>"
|
||||
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>"
|
||||
data-trip-map-distance-unit-value="<%= DISTANCE_UNIT %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
13
app/views/trips/edit.html.erb
Normal file
13
app/views/trips/edit.html.erb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<div class="mx-auto md:w-2/3 w-full my-5">
|
||||
<h1 class="font-bold text-4xl">Editing trip</h1>
|
||||
|
||||
<%= render "form", trip: @trip %>
|
||||
|
||||
<!-- Action Buttons Section -->
|
||||
<div class="bg-base-100 items-center mt-8 mb-4">
|
||||
<div class="flex flex-wrap gap-2 justify-center">
|
||||
<%= link_to "Show this trip", @trip, class: "btn" %>
|
||||
<%= link_to "Back to trips", trips_path, class: "btn" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
35
app/views/trips/index.html.erb
Normal file
35
app/views/trips/index.html.erb
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<% content_for :title, 'Trips' %>
|
||||
|
||||
<div class="w-full my-5">
|
||||
<div id="trips" class="min-w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="font-bold text-4xl">Trips</h1>
|
||||
<%= link_to 'New trip', new_trip_path, class: 'btn btn-primary' %>
|
||||
</div>
|
||||
|
||||
<% if @trips.empty? %>
|
||||
<div class="hero min-h-80 bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||
<p class="py-6">
|
||||
Here you'll find your trips, but now there are none. Let's <%= link_to 'create one', new_trip_path, class: 'link' %>!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center my-5">
|
||||
<div class='flex'>
|
||||
<%= paginate @trips %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 my-4">
|
||||
<% @trips.each do |trip| %>
|
||||
<%= render 'trip', trip: trip %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
9
app/views/trips/new.html.erb
Normal file
9
app/views/trips/new.html.erb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<% content_for :title, 'New trip' %>
|
||||
|
||||
<div class="mx-auto md:w-2/3 w-full my-5">
|
||||
<h1 class="font-bold text-4xl">New trip</h1>
|
||||
|
||||
<%= render "form", trip: @trip %>
|
||||
|
||||
<%= link_to "Back to trips", trips_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
|
||||
</div>
|
||||
76
app/views/trips/show.html.erb
Normal file
76
app/views/trips/show.html.erb
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<% content_for :title, @trip.name %>
|
||||
|
||||
<div class="container mx-auto px-4 max-w-4xl my-5">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold mb-2"><%= @trip.name %></h1>
|
||||
<p class="text-md text-base-content/60">
|
||||
<%= human_date(@trip.started_at) %> - <%= human_date(@trip.ended_at) %>
|
||||
</p>
|
||||
<% if @trip.countries.any? %>
|
||||
<p class="text-lg text-base-content/60">
|
||||
<%= "#{@trip.countries.join(', ')} (#{@trip.distance} #{DISTANCE_UNIT})" %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 my-8 p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="w-full">
|
||||
<div
|
||||
id='map'
|
||||
class="w-full h-full rounded-lg"
|
||||
data-controller="trips"
|
||||
data-trips-target="container"
|
||||
data-distance_unit="<%= DISTANCE_UNIT %>"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-user_settings="<%= current_user.settings.to_json %>"
|
||||
data-coordinates="<%= @coordinates.to_json %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
<div data-trips-target="container" class="h-[25rem] w-full min-h-screen">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div>
|
||||
<%= @trip.notes.body %>
|
||||
</div>
|
||||
|
||||
<!-- Photos Grid Section -->
|
||||
<% if @photos.any? %>
|
||||
<% @photos.each_slice(4) do |slice| %>
|
||||
<div class="h-32 flex gap-4 mt-4 justify-center">
|
||||
<% slice.each do |photo| %>
|
||||
<div class="flex-1 h-full overflow-hidden rounded-lg transition-transform duration-300 hover:scale-105 hover:shadow-lg">
|
||||
<img
|
||||
src="<%= photo[:url] %>"
|
||||
loading='lazy'
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="text-center mt-6">
|
||||
<%= link_to "More photos on Immich", immich_search_url(current_user.settings['immich_url'], @trip.started_at, @trip.ended_at), class: "btn btn-primary", target: '_blank' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons Section -->
|
||||
<div class="bg-base-100 items-center">
|
||||
<div class="flex flex-wrap gap-2 justify-center">
|
||||
<%= link_to "Edit this trip", edit_trip_path(@trip), class: "btn" %>
|
||||
<%= link_to "Destroy this trip",
|
||||
trip_path(@trip),
|
||||
data: {
|
||||
turbo_confirm: "Are you sure?",
|
||||
turbo_method: :delete
|
||||
},
|
||||
class: "btn" %>
|
||||
<%= link_to "Back to trips", trips_path, class: "btn" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
<div class="w-full">
|
||||
<% content_for :title, "Visits" %>
|
||||
|
||||
<div class="flex justify-between my-5">
|
||||
<div class="w-full my-5">
|
||||
|
||||
<div class="flex justify-between">
|
||||
<h1 class="font-bold text-4xl">Visits</h1>
|
||||
<div role="tablist" class="tabs tabs-boxed">
|
||||
<%= link_to 'Confirmed', visits_path(status: :confirmed), role: 'tab',
|
||||
|
|
|
|||
|
|
@ -22,3 +22,5 @@ pin_all_from 'app/javascript/channels', under: 'channels'
|
|||
pin 'notifications_channel', to: 'channels/notifications_channel.js'
|
||||
pin 'points_channel', to: 'channels/points_channel.js'
|
||||
pin 'imports_channel', to: 'channels/imports_channel.js'
|
||||
pin "trix"
|
||||
pin "@rails/actiontext", to: "actiontext.esm.js"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ Rails.application.routes.draw do
|
|||
resources :visits, only: %i[index update]
|
||||
resources :places, only: %i[index destroy]
|
||||
resources :exports, only: %i[index create destroy]
|
||||
resources :trips
|
||||
resources :points, only: %i[index] do
|
||||
collection do
|
||||
delete :bulk_destroy
|
||||
|
|
|
|||
15
db/migrate/20241127161621_create_trips.rb
Normal file
15
db/migrate/20241127161621_create_trips.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateTrips < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :trips do |t|
|
||||
t.string :name, null: false
|
||||
t.datetime :started_at, null: false
|
||||
t.datetime :ended_at, null: false
|
||||
t.integer :distance
|
||||
t.references :user, null: false, foreign_key: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# This migration comes from action_text (originally 20180528164100)
|
||||
class CreateActionTextTables < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
# Use Active Record's configured type for primary and foreign keys
|
||||
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
||||
|
||||
create_table :action_text_rich_texts, id: primary_key_type do |t|
|
||||
t.string :name, null: false
|
||||
t.text :body, size: :long
|
||||
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
|
||||
|
||||
t.timestamps
|
||||
|
||||
t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def primary_and_foreign_key_types
|
||||
config = Rails.configuration.generators
|
||||
setting = config.options[config.orm][:primary_key_type]
|
||||
primary_key_type = setting || :primary_key
|
||||
foreign_key_type = setting || :bigint
|
||||
[ primary_key_type, foreign_key_type ]
|
||||
end
|
||||
end
|
||||
24
db/schema.rb
generated
24
db/schema.rb
generated
|
|
@ -10,10 +10,20 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_11_28_095325) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
create_table "action_text_rich_texts", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "body"
|
||||
t.string "record_type", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
|
|
@ -175,6 +185,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
|
|||
t.index ["year"], name: "index_stats_on_year"
|
||||
end
|
||||
|
||||
create_table "trips", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.datetime "started_at", null: false
|
||||
t.datetime "ended_at", null: false
|
||||
t.integer "distance"
|
||||
t.bigint "user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id"], name: "index_trips_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "email", default: "", null: false
|
||||
t.string "encrypted_password", default: "", null: false
|
||||
|
|
@ -216,6 +237,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_152025) do
|
|||
add_foreign_key "points", "users"
|
||||
add_foreign_key "points", "visits"
|
||||
add_foreign_key "stats", "users"
|
||||
add_foreign_key "trips", "users"
|
||||
add_foreign_key "visits", "areas"
|
||||
add_foreign_key "visits", "places"
|
||||
add_foreign_key "visits", "users"
|
||||
|
|
|
|||
59
package-lock.json
generated
59
package-lock.json
generated
|
|
@ -6,7 +6,9 @@
|
|||
"": {
|
||||
"dependencies": {
|
||||
"@hotwired/turbo-rails": "^7.3.0",
|
||||
"leaflet": "^1.9.4"
|
||||
"@rails/actiontext": "^8.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"trix": "^2.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"daisyui": "^4.7.3"
|
||||
|
|
@ -34,6 +36,25 @@
|
|||
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
|
||||
"integrity": "sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA=="
|
||||
},
|
||||
"node_modules/@rails/actiontext": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.0.0.tgz",
|
||||
"integrity": "sha512-8pvXDEHqlVHptzfYDUXmBpstHsfHAVacYxO47cWDRjRmp1zdVXusLcom8UvqkRdTcAPXpte+LkjcfpD9S4DSSQ==",
|
||||
"dependencies": {
|
||||
"@rails/activestorage": ">= 8.0.0-alpha"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"trix": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rails/activestorage": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.0.0.tgz",
|
||||
"integrity": "sha512-qoA7U1gMcWXhDnImwDIyRQDXkQKzThT2lu2Xpim8CnTOCEeAgkQ5Co2kzodpAI2grF1JSDvwXSPYNWwVAswndA==",
|
||||
"dependencies": {
|
||||
"spark-md5": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
|
|
@ -186,6 +207,16 @@
|
|||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spark-md5": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
|
||||
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw=="
|
||||
},
|
||||
"node_modules/trix": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/trix/-/trix-2.1.8.tgz",
|
||||
"integrity": "sha512-y1h5mKQcjMsZDsUOqOgyIUfw+Z31u4Fe9JqXtKGUzIC7FM9cTpxZFFWxQggwXBo18ccIKYx1Fn9toVO5mCpn9g=="
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -208,6 +239,22 @@
|
|||
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
|
||||
"integrity": "sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA=="
|
||||
},
|
||||
"@rails/actiontext": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.0.0.tgz",
|
||||
"integrity": "sha512-8pvXDEHqlVHptzfYDUXmBpstHsfHAVacYxO47cWDRjRmp1zdVXusLcom8UvqkRdTcAPXpte+LkjcfpD9S4DSSQ==",
|
||||
"requires": {
|
||||
"@rails/activestorage": ">= 8.0.0-alpha"
|
||||
}
|
||||
},
|
||||
"@rails/activestorage": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.0.0.tgz",
|
||||
"integrity": "sha512-qoA7U1gMcWXhDnImwDIyRQDXkQKzThT2lu2Xpim8CnTOCEeAgkQ5Co2kzodpAI2grF1JSDvwXSPYNWwVAswndA==",
|
||||
"requires": {
|
||||
"spark-md5": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
|
|
@ -299,6 +346,16 @@
|
|||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"spark-md5": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
|
||||
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw=="
|
||||
},
|
||||
"trix": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/trix/-/trix-2.1.8.tgz",
|
||||
"integrity": "sha512-y1h5mKQcjMsZDsUOqOgyIUfw+Z31u4Fe9JqXtKGUzIC7FM9cTpxZFFWxQggwXBo18ccIKYx1Fn9toVO5mCpn9g=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@hotwired/turbo-rails": "^7.3.0",
|
||||
"leaflet": "^1.9.4"
|
||||
"@rails/actiontext": "^8.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"postcss": "^8.4.49",
|
||||
"trix": "^2.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"daisyui": "^4.7.3"
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ FactoryBot.define do
|
|||
}
|
||||
}
|
||||
end
|
||||
|
||||
trait :reverse_geocoded do
|
||||
country { FFaker::Address.country }
|
||||
city { FFaker::Address.city }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
21
spec/factories/trips.rb
Normal file
21
spec/factories/trips.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :trip do
|
||||
user
|
||||
name { FFaker::Lorem.word }
|
||||
started_at { DateTime.new(2024, 11, 27, 17, 16, 21) }
|
||||
ended_at { DateTime.new(2024, 11, 29, 17, 16, 21) }
|
||||
notes { FFaker::Lorem.sentence }
|
||||
|
||||
trait :with_points do
|
||||
after(:build) do |trip|
|
||||
create_list(
|
||||
:point, 25,
|
||||
user: trip.user,
|
||||
timestamp: trip.started_at + (1..1000).to_a.sample.minutes
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
96
spec/models/trip_spec.rb
Normal file
96
spec/models/trip_spec.rb
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Trip, type: :model do
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_presence_of(:started_at) }
|
||||
it { is_expected.to validate_presence_of(:ended_at) }
|
||||
end
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
let(:user) { create(:user) }
|
||||
let(:trip) { create(:trip, :with_points, user:) }
|
||||
let(:calculated_distance) { trip.send(:calculate_distance) }
|
||||
|
||||
it 'sets the distance' do
|
||||
expect(trip.distance).to eq(calculated_distance)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#countries' do
|
||||
let(:user) { create(:user) }
|
||||
let(:trip) { create(:trip, user:) }
|
||||
let(:points) do
|
||||
create_list(
|
||||
:point,
|
||||
25,
|
||||
:reverse_geocoded,
|
||||
user:,
|
||||
timestamp: (trip.started_at.to_i..trip.ended_at.to_i).to_a.sample
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the unique countries of the points' do
|
||||
expect(trip.countries).to eq(trip.points.pluck(:country).uniq.compact)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#photos' do
|
||||
let(:photo_data) do
|
||||
[
|
||||
{
|
||||
'id' => '123',
|
||||
'latitude' => 35.6762,
|
||||
'longitude' => 139.6503,
|
||||
'localDateTime' => '2024-01-01T03:00:00.000Z',
|
||||
'type' => 'photo',
|
||||
'exifInfo' => {
|
||||
'orientation' => '3'
|
||||
}
|
||||
},
|
||||
{
|
||||
'id' => '456',
|
||||
'latitude' => 40.7128,
|
||||
'longitude' => -74.0060,
|
||||
'localDateTime' => '2024-01-02T01:00:00.000Z',
|
||||
'type' => 'photo',
|
||||
'exifInfo' => {
|
||||
'orientation' => '6'
|
||||
}
|
||||
},
|
||||
{
|
||||
'id' => '789',
|
||||
'latitude' => 40.7128,
|
||||
'longitude' => -74.0060,
|
||||
'localDateTime' => '2024-01-02T02:00:00.000Z',
|
||||
'type' => 'photo',
|
||||
'exifInfo' => {
|
||||
'orientation' => '6'
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
let(:user) { create(:user) }
|
||||
let(:trip) { create(:trip, user:) }
|
||||
let(:expected_photos) do
|
||||
[
|
||||
{ url: "/api/v1/photos/456/thumbnail.jpg?api_key=#{user.api_key}" },
|
||||
{ url: "/api/v1/photos/789/thumbnail.jpg?api_key=#{user.api_key}" }
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow_any_instance_of(Immich::RequestPhotos).to receive(:call).and_return(photo_data)
|
||||
end
|
||||
|
||||
it 'returns the photos' do
|
||||
expect(trip.photos).to eq(expected_photos)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -13,6 +13,7 @@ RSpec.describe User, type: :model do
|
|||
it { is_expected.to have_many(:areas).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:visits).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:places).through(:visits) }
|
||||
it { is_expected.to have_many(:trips).dependent(:destroy) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ RSpec.describe 'Api::V1::Photos', type: :request do
|
|||
'id' => '123',
|
||||
'latitude' => 35.6762,
|
||||
'longitude' => 139.6503,
|
||||
'createdAt' => '2024-01-01T00:00:00.000Z',
|
||||
'localDateTime' => '2024-01-01T00:00:00.000Z',
|
||||
'type' => 'photo'
|
||||
},
|
||||
{
|
||||
'id' => '456',
|
||||
'latitude' => 40.7128,
|
||||
'longitude' => -74.0060,
|
||||
'createdAt' => '2024-01-02T00:00:00.000Z',
|
||||
'localDateTime' => '2024-01-02T00:00:00.000Z',
|
||||
'type' => 'photo'
|
||||
}
|
||||
]
|
||||
|
|
|
|||
148
spec/requests/trips_spec.rb
Normal file
148
spec/requests/trips_spec.rb
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/trips', type: :request do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
name: 'Summer Vacation 2024',
|
||||
started_at: Date.tomorrow,
|
||||
ended_at: Date.tomorrow + 7.days,
|
||||
notes: 'A wonderful week-long trip'
|
||||
}
|
||||
end
|
||||
|
||||
let(:invalid_attributes) do
|
||||
{
|
||||
name: '', # name can't be blank
|
||||
start_date: nil, # dates are required
|
||||
end_date: Date.yesterday # end date can't be before start date
|
||||
}
|
||||
end
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before do
|
||||
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
||||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
||||
|
||||
allow_any_instance_of(Trip).to receive(:photos).and_return([])
|
||||
|
||||
sign_in user
|
||||
end
|
||||
|
||||
describe 'GET /index' do
|
||||
it 'renders a successful response' do
|
||||
get trips_url
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /show' do
|
||||
let(:trip) { create(:trip, :with_points, user:) }
|
||||
|
||||
it 'renders a successful response' do
|
||||
get trip_url(trip)
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /new' do
|
||||
it 'renders a successful response' do
|
||||
get new_trip_url
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /edit' do
|
||||
let(:trip) { create(:trip, :with_points, user:) }
|
||||
|
||||
it 'renders a successful response' do
|
||||
get edit_trip_url(trip)
|
||||
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /create' do
|
||||
context 'with valid parameters' do
|
||||
it 'creates a new Trip' do
|
||||
expect do
|
||||
post trips_url, params: { trip: valid_attributes }
|
||||
end.to change(Trip, :count).by(1)
|
||||
end
|
||||
|
||||
it 'redirects to the created trip' do
|
||||
post trips_url, params: { trip: valid_attributes }
|
||||
expect(response).to redirect_to(trip_url(Trip.last))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
it 'does not create a new Trip' do
|
||||
expect do
|
||||
post trips_url, params: { trip: invalid_attributes }
|
||||
end.to change(Trip, :count).by(0)
|
||||
end
|
||||
|
||||
it "renders a response with 422 status (i.e. to display the 'new' template)" do
|
||||
post trips_url, params: { trip: invalid_attributes }
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /update' do
|
||||
context 'with valid parameters' do
|
||||
let(:new_attributes) do
|
||||
{
|
||||
name: 'Updated Trip Name',
|
||||
notes: 'Changed trip notes'
|
||||
}
|
||||
end
|
||||
let(:trip) { create(:trip, :with_points, user:) }
|
||||
|
||||
it 'updates the requested trip' do
|
||||
patch trip_url(trip), params: { trip: new_attributes }
|
||||
trip.reload
|
||||
|
||||
expect(trip.name).to eq('Updated Trip Name')
|
||||
expect(trip.notes.body.to_plain_text).to eq('Changed trip notes')
|
||||
expect(trip.notes).to be_an(ActionText::RichText)
|
||||
end
|
||||
|
||||
it 'redirects to the trip' do
|
||||
patch trip_url(trip), params: { trip: new_attributes }
|
||||
trip.reload
|
||||
|
||||
expect(response).to redirect_to(trip_url(trip))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:trip) { create(:trip, :with_points, user:) }
|
||||
|
||||
it 'renders a response with 422 status' do
|
||||
patch trip_url(trip), params: { trip: invalid_attributes }
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /destroy' do
|
||||
let!(:trip) { create(:trip, :with_points, user:) }
|
||||
|
||||
it 'destroys the requested trip' do
|
||||
expect do
|
||||
delete trip_url(trip)
|
||||
end.to change(Trip, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'redirects to the trips list' do
|
||||
delete trip_url(trip)
|
||||
|
||||
expect(response).to redirect_to(trips_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -18,8 +18,8 @@ RSpec.describe Immich::RequestPhotos do
|
|||
"facets": []
|
||||
},
|
||||
"assets": {
|
||||
"total": 1000,
|
||||
"count": 1000,
|
||||
"total": 2,
|
||||
"count": 2,
|
||||
"items": [
|
||||
{
|
||||
"id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c',
|
||||
|
|
@ -71,8 +71,60 @@ RSpec.describe Immich::RequestPhotos do
|
|||
"hasMetadata": true,
|
||||
"duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375',
|
||||
"resized": true
|
||||
},
|
||||
{
|
||||
"id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c2',
|
||||
"deviceAssetId": 'IMG_9913.jpeg-1168914',
|
||||
"ownerId": 'f579f328-c355-438c-a82c-fe3390bd5f08',
|
||||
"deviceId": 'CLI',
|
||||
"libraryId": nil,
|
||||
"type": 'VIDEO',
|
||||
"originalPath": 'upload/library/admin/2023/2023-06-08/IMG_9913.jpeg',
|
||||
"originalFileName": 'IMG_9913.jpeg',
|
||||
"originalMimeType": 'image/jpeg',
|
||||
"thumbhash": '4RgONQaZqYaH93g3h3p3d6RfPPrG',
|
||||
"fileCreatedAt": '2023-06-08T07:58:45.637Z',
|
||||
"fileModifiedAt": '2023-06-08T09:58:45.000Z',
|
||||
"localDateTime": '2023-06-08T09:58:45.637Z',
|
||||
"updatedAt": '2024-08-24T18:20:47.965Z',
|
||||
"isFavorite": false,
|
||||
"isArchived": false,
|
||||
"isTrashed": false,
|
||||
"duration": '0:00:00.00000',
|
||||
"exifInfo": {
|
||||
"make": 'Apple',
|
||||
"model": 'iPhone 12 Pro',
|
||||
"exifImageWidth": 4032,
|
||||
"exifImageHeight": 3024,
|
||||
"fileSizeInByte": 1_168_914,
|
||||
"orientation": '6',
|
||||
"dateTimeOriginal": '2023-06-08T07:58:45.637Z',
|
||||
"modifyDate": '2023-06-08T07:58:45.000Z',
|
||||
"timeZone": 'Europe/Berlin',
|
||||
"lensModel": 'iPhone 12 Pro back triple camera 4.2mm f/1.6',
|
||||
"fNumber": 1.6,
|
||||
"focalLength": 4.2,
|
||||
"iso": 320,
|
||||
"exposureTime": '1/60',
|
||||
"latitude": 52.11,
|
||||
"longitude": 13.22,
|
||||
"city": 'Johannisthal',
|
||||
"state": 'Berlin',
|
||||
"country": 'Germany',
|
||||
"description": '',
|
||||
"projectionType": nil,
|
||||
"rating": nil
|
||||
},
|
||||
"livePhotoVideoId": nil,
|
||||
"people": [],
|
||||
"checksum": 'aL1edPVg4ZpEnS6xCRWNUY0pUS8=',
|
||||
"isOffline": false,
|
||||
"hasMetadata": true,
|
||||
"duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375',
|
||||
"resized": true
|
||||
}
|
||||
]
|
||||
],
|
||||
nextPage: nil
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
|
@ -84,6 +136,10 @@ RSpec.describe Immich::RequestPhotos do
|
|||
'http://immich.app/api/search/metadata'
|
||||
).to_return(status: 200, body: immich_data, headers: {})
|
||||
end
|
||||
|
||||
it 'returns images and videos' do
|
||||
expect(service.map { _1['type'] }.uniq).to eq(['IMAGE', 'VIDEO'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no immich_url' do
|
||||
|
|
|
|||
57
yarn.lock
57
yarn.lock
|
|
@ -20,6 +20,20 @@
|
|||
resolved "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz"
|
||||
integrity sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA==
|
||||
|
||||
"@rails/actiontext@^8.0.0":
|
||||
version "8.0.0"
|
||||
resolved "https://registry.npmjs.org/@rails/actiontext/-/actiontext-8.0.0.tgz"
|
||||
integrity sha512-8pvXDEHqlVHptzfYDUXmBpstHsfHAVacYxO47cWDRjRmp1zdVXusLcom8UvqkRdTcAPXpte+LkjcfpD9S4DSSQ==
|
||||
dependencies:
|
||||
"@rails/activestorage" ">= 8.0.0-alpha"
|
||||
|
||||
"@rails/activestorage@>= 8.0.0-alpha":
|
||||
version "8.0.0"
|
||||
resolved "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.0.0.tgz"
|
||||
integrity sha512-qoA7U1gMcWXhDnImwDIyRQDXkQKzThT2lu2Xpim8CnTOCEeAgkQ5Co2kzodpAI2grF1JSDvwXSPYNWwVAswndA==
|
||||
dependencies:
|
||||
spark-md5 "^3.0.1"
|
||||
|
||||
camelcase-css@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
|
||||
|
|
@ -64,15 +78,20 @@ leaflet@^1.9.4:
|
|||
integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
|
||||
|
||||
nanoid@^3.3.7:
|
||||
version "3.3.7"
|
||||
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz"
|
||||
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
||||
version "3.3.8"
|
||||
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
|
||||
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
|
||||
|
||||
picocolors@^1, picocolors@^1.0.0:
|
||||
picocolors@^1:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
|
||||
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
|
||||
|
||||
picocolors@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||
|
||||
postcss-js@^4:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz"
|
||||
|
|
@ -80,16 +99,26 @@ postcss-js@^4:
|
|||
dependencies:
|
||||
camelcase-css "^2.0.1"
|
||||
|
||||
postcss@^8.4.21:
|
||||
version "8.4.35"
|
||||
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz"
|
||||
integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==
|
||||
postcss@^8.4.49:
|
||||
version "8.4.49"
|
||||
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
|
||||
integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
|
||||
dependencies:
|
||||
nanoid "^3.3.7"
|
||||
picocolors "^1.0.0"
|
||||
source-map-js "^1.0.2"
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
source-map-js@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
|
||||
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||
source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
|
||||
|
||||
spark-md5@^3.0.1:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz"
|
||||
integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==
|
||||
|
||||
trix@^2.1.8:
|
||||
version "2.1.8"
|
||||
resolved "https://registry.npmjs.org/trix/-/trix-2.1.8.tgz#b9383af8cd9c1a0a0818d6b4e0c9e771bf7fd564"
|
||||
integrity sha512-y1h5mKQcjMsZDsUOqOgyIUfw+Z31u4Fe9JqXtKGUzIC7FM9cTpxZFFWxQggwXBo18ccIKYx1Fn9toVO5mCpn9g==
|
||||
|
|
|
|||
Loading…
Reference in a new issue