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/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
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
|
### 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'
|
'text-blue-600'
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,6 @@ import "leaflet-providers"
|
||||||
import "chartkick"
|
import "chartkick"
|
||||||
import "Chart.bundle"
|
import "Chart.bundle"
|
||||||
import "./channels"
|
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 { handleAreaCreated } from "../maps/areas";
|
||||||
|
|
||||||
import { showFlashMessage } from "../maps/helpers";
|
import { showFlashMessage } from "../maps/helpers";
|
||||||
|
import { fetchAndDisplayPhotos } from '../maps/helpers';
|
||||||
|
|
||||||
import { osmMapLayer } from "../maps/layers";
|
import { osmMapLayer } from "../maps/layers";
|
||||||
import { osmHotMapLayer } from "../maps/layers";
|
import { osmHotMapLayer } from "../maps/layers";
|
||||||
|
|
@ -83,14 +84,13 @@ export default class extends Controller {
|
||||||
Photos: this.photoMarkers
|
Photos: this.photoMarkers
|
||||||
};
|
};
|
||||||
|
|
||||||
L.control
|
// Add scale control to bottom right
|
||||||
.scale({
|
L.control.scale({
|
||||||
position: "bottomright",
|
position: 'bottomright',
|
||||||
metric: true,
|
imperial: this.distanceUnit === 'mi',
|
||||||
imperial: true,
|
metric: this.distanceUnit === 'km',
|
||||||
maxWidth: 120,
|
maxWidth: 120
|
||||||
})
|
}).addTo(this.map)
|
||||||
.addTo(this.map);
|
|
||||||
|
|
||||||
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).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();
|
this.initializeDrawControl();
|
||||||
|
|
||||||
// Add event listeners to toggle draw controls
|
// Add event listeners to toggle draw controls
|
||||||
this.map.on('overlayadd', (e) => {
|
this.map.on('overlayadd', async (e) => {
|
||||||
if (e.name === 'Areas') {
|
if (e.name === 'Areas') {
|
||||||
this.map.addControl(this.drawControl);
|
this.map.addControl(this.drawControl);
|
||||||
}
|
}
|
||||||
if (e.name === 'Photos') {
|
if (e.name === 'Photos') {
|
||||||
// Extract dates from URL parameters
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
|
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];
|
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);
|
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) {
|
createPhotoMarker(photo) {
|
||||||
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
|
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';
|
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 :visits, dependent: :destroy
|
||||||
has_many :points, through: :imports
|
has_many :points, through: :imports
|
||||||
has_many :places, through: :visits
|
has_many :places, through: :visits
|
||||||
|
has_many :trips, dependent: :destroy
|
||||||
|
|
||||||
after_create :create_api_key
|
after_create :create_api_key
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class Immich::RequestPhotos
|
||||||
data = []
|
data = []
|
||||||
max_pages = 10_000 # Prevent infinite loop
|
max_pages = 10_000 # Prevent infinite loop
|
||||||
|
|
||||||
|
# TODO: Handle pagination using nextPage
|
||||||
while page <= max_pages
|
while page <= max_pages
|
||||||
response = JSON.parse(
|
response = JSON.parse(
|
||||||
HTTParty.post(
|
HTTParty.post(
|
||||||
|
|
@ -38,6 +39,8 @@ class Immich::RequestPhotos
|
||||||
|
|
||||||
if items.blank?
|
if items.blank?
|
||||||
Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
|
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(response)
|
||||||
Rails.logger.debug('==== IMMICH RESPONSE WITH NO ITEMS ====')
|
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" %>
|
<% content_for :title, "Exports" %>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full my-5">
|
||||||
<div class="flex justify-center my-5">
|
<div class="flex justify-center">
|
||||||
<h1 class="font-bold text-4xl">Exports</h1>
|
<h1 class="font-bold text-4xl">Exports</h1>
|
||||||
</div>
|
</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="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 h-fit bg-base-200 py-20" style="background-image: url(<%= '/images/bg-image.jpg' %>);">
|
||||||
<div class="hero-content text-center">
|
<div class="hero-content text-center">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<% content_for :title, 'Imports' %>
|
<% content_for :title, 'Imports' %>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full my-5">
|
||||||
<div class="flex justify-between items-center mb-3">
|
<div class="flex justify-between items-center">
|
||||||
<h1 class="font-bold text-4xl">Imports</h1>
|
<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" %>
|
<%= 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' %>
|
<% 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='w-full lg:w-5/6'>
|
||||||
<div class="flex flex-col space-y-4 mb-4 w-full">
|
<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| %>
|
<%= 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="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">
|
<div class="flex flex-col space-y-2">
|
||||||
<%= f.label :start_at, class: "text-sm font-semibold" %>
|
<%= 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>
|
</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">
|
<div class="flex flex-col space-y-2">
|
||||||
<%= f.label :end_at, class: "text-sm font-semibold" %>
|
<%= 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>
|
</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">
|
<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>
|
</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">
|
<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>
|
</div>
|
||||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
<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">
|
<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>
|
</div>
|
||||||
<div class="w-full sm:w-6/12 md:w-3/12 lg:w-2/12">
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -50,7 +52,7 @@
|
||||||
data-user_settings=<%= current_user.settings.to_json %>
|
data-user_settings=<%= current_user.settings.to_json %>
|
||||||
data-coordinates="<%= @coordinates %>"
|
data-coordinates="<%= @coordinates %>"
|
||||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
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 id="fog" class="fog"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<% content_for :title, "Places" %>
|
<% content_for :title, "Places" %>
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full my-5">
|
||||||
<div class="flex justify-center my-5">
|
<div class="flex justify-center">
|
||||||
<h1 class="font-bold text-4xl">Places</h1>
|
<h1 class="font-bold text-4xl">Places</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<% content_for :title, 'Points' %>
|
<% 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| %>
|
<%= 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="flex flex-col md:flex-row md:space-x-4 md:items-end">
|
||||||
<div class="w-full md:w-2/12">
|
<div class="w-full md:w-2/12">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<% content_for :title, "Background jobs" %>
|
<% content_for :title, "Background jobs" %>
|
||||||
|
|
||||||
<div class="min-h-content w-full">
|
<div class="min-h-content w-full my-5">
|
||||||
<%= render 'settings/navigation' %>
|
<%= render 'settings/navigation' %>
|
||||||
|
|
||||||
<div class="flex justify-between items-center mt-5">
|
<div class="flex justify-between items-center mt-5">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<% content_for :title, 'Settings' %>
|
<% content_for :title, 'Settings' %>
|
||||||
|
|
||||||
<div class="min-h-content w-full">
|
<div class="min-h-content w-full my-5">
|
||||||
<%= render 'settings/navigation' %>
|
<%= render 'settings/navigation' %>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row w-full my-10 space-x-4">
|
<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 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
|
||||||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_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 '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 '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 '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 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -41,13 +42,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-center hidden lg:flex">
|
<div class="navbar-center hidden lg:flex">
|
||||||
<ul class="menu menu-horizontal px-1">
|
<ul class="menu menu-horizontal px-1">
|
||||||
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
|
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
|
||||||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
<li><%= link_to 'Points', points_url, class: "mx-1 #{active_class?(points_url)}" %></li>
|
||||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_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: "#{active_class?(visits_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: "#{active_class?(places_url)}" %></li>
|
<li><%= link_to 'Places<sup>α</sup>'.html_safe, places_url, class: "mx-1 #{active_class?(places_url)}" %></li>
|
||||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
|
||||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
<div id='years-nav'>
|
<div id='years-nav'>
|
||||||
<div class="dropdown">
|
<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">
|
<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| %>
|
<% 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>
|
<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' %>
|
<% 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="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
|
||||||
<div class="stat text-center">
|
<div class="stat text-center">
|
||||||
<div class="stat-value text-primary">
|
<div class="stat-value text-primary">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<% content_for :title, "Statistics for #{@year} year" %>
|
<% 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 } %>
|
<%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %>
|
||||||
</div>
|
</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" %>
|
||||||
<% 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>
|
<h1 class="font-bold text-4xl">Visits</h1>
|
||||||
<div role="tablist" class="tabs tabs-boxed">
|
<div role="tablist" class="tabs tabs-boxed">
|
||||||
<%= link_to 'Confirmed', visits_path(status: :confirmed), role: 'tab',
|
<%= 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 'notifications_channel', to: 'channels/notifications_channel.js'
|
||||||
pin 'points_channel', to: 'channels/points_channel.js'
|
pin 'points_channel', to: 'channels/points_channel.js'
|
||||||
pin 'imports_channel', to: 'channels/imports_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 :visits, only: %i[index update]
|
||||||
resources :places, only: %i[index destroy]
|
resources :places, only: %i[index destroy]
|
||||||
resources :exports, only: %i[index create destroy]
|
resources :exports, only: %i[index create destroy]
|
||||||
|
resources :trips
|
||||||
resources :points, only: %i[index] do
|
resources :points, only: %i[index] do
|
||||||
collection do
|
collection do
|
||||||
delete :bulk_destroy
|
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.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
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|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", 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"
|
t.index ["year"], name: "index_stats_on_year"
|
||||||
end
|
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|
|
create_table "users", force: :cascade do |t|
|
||||||
t.string "email", default: "", null: false
|
t.string "email", default: "", null: false
|
||||||
t.string "encrypted_password", 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", "users"
|
||||||
add_foreign_key "points", "visits"
|
add_foreign_key "points", "visits"
|
||||||
add_foreign_key "stats", "users"
|
add_foreign_key "stats", "users"
|
||||||
|
add_foreign_key "trips", "users"
|
||||||
add_foreign_key "visits", "areas"
|
add_foreign_key "visits", "areas"
|
||||||
add_foreign_key "visits", "places"
|
add_foreign_key "visits", "places"
|
||||||
add_foreign_key "visits", "users"
|
add_foreign_key "visits", "users"
|
||||||
|
|
|
||||||
59
package-lock.json
generated
59
package-lock.json
generated
|
|
@ -6,7 +6,9 @@
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/turbo-rails": "^7.3.0",
|
"@hotwired/turbo-rails": "^7.3.0",
|
||||||
"leaflet": "^1.9.4"
|
"@rails/actiontext": "^8.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"trix": "^2.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"daisyui": "^4.7.3"
|
"daisyui": "^4.7.3"
|
||||||
|
|
@ -34,6 +36,25 @@
|
||||||
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
|
||||||
"integrity": "sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA=="
|
"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": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
|
|
@ -186,6 +207,16 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"dependencies": {
|
||||||
|
|
@ -208,6 +239,22 @@
|
||||||
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz",
|
||||||
"integrity": "sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA=="
|
"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": {
|
"camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
|
|
@ -299,6 +346,16 @@
|
||||||
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": 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": {
|
"dependencies": {
|
||||||
"@hotwired/turbo-rails": "^7.3.0",
|
"@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": {
|
"devDependencies": {
|
||||||
"daisyui": "^4.7.3"
|
"daisyui": "^4.7.3"
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ FactoryBot.define do
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :reverse_geocoded do
|
||||||
|
country { FFaker::Address.country }
|
||||||
|
city { FFaker::Address.city }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
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(:areas).dependent(:destroy) }
|
||||||
it { is_expected.to have_many(:visits).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(:places).through(:visits) }
|
||||||
|
it { is_expected.to have_many(:trips).dependent(:destroy) }
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'callbacks' do
|
describe 'callbacks' do
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,14 @@ RSpec.describe 'Api::V1::Photos', type: :request do
|
||||||
'id' => '123',
|
'id' => '123',
|
||||||
'latitude' => 35.6762,
|
'latitude' => 35.6762,
|
||||||
'longitude' => 139.6503,
|
'longitude' => 139.6503,
|
||||||
'createdAt' => '2024-01-01T00:00:00.000Z',
|
'localDateTime' => '2024-01-01T00:00:00.000Z',
|
||||||
'type' => 'photo'
|
'type' => 'photo'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id' => '456',
|
'id' => '456',
|
||||||
'latitude' => 40.7128,
|
'latitude' => 40.7128,
|
||||||
'longitude' => -74.0060,
|
'longitude' => -74.0060,
|
||||||
'createdAt' => '2024-01-02T00:00:00.000Z',
|
'localDateTime' => '2024-01-02T00:00:00.000Z',
|
||||||
'type' => 'photo'
|
'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": []
|
"facets": []
|
||||||
},
|
},
|
||||||
"assets": {
|
"assets": {
|
||||||
"total": 1000,
|
"total": 2,
|
||||||
"count": 1000,
|
"count": 2,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c',
|
"id": '7fe486e3-c3ba-4b54-bbf9-1281b39ed15c',
|
||||||
|
|
@ -71,8 +71,60 @@ RSpec.describe Immich::RequestPhotos do
|
||||||
"hasMetadata": true,
|
"hasMetadata": true,
|
||||||
"duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375',
|
"duplicateId": '88a34bee-783d-46e4-aa52-33b75ffda375',
|
||||||
"resized": true
|
"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
|
}.to_json
|
||||||
end
|
end
|
||||||
|
|
@ -84,6 +136,10 @@ RSpec.describe Immich::RequestPhotos do
|
||||||
'http://immich.app/api/search/metadata'
|
'http://immich.app/api/search/metadata'
|
||||||
).to_return(status: 200, body: immich_data, headers: {})
|
).to_return(status: 200, body: immich_data, headers: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns images and videos' do
|
||||||
|
expect(service.map { _1['type'] }.uniq).to eq(['IMAGE', 'VIDEO'])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when user has no immich_url' do
|
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"
|
resolved "https://registry.npmjs.org/@rails/actioncable/-/actioncable-7.1.3.tgz"
|
||||||
integrity sha512-ojNvnoZtPN0pYvVFtlO7dyEN9Oml1B6IDM+whGKVak69MMYW99lC2NOWXWeE3bmwEydbP/nn6ERcpfjHVjYQjA==
|
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:
|
camelcase-css@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
|
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==
|
integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==
|
||||||
|
|
||||||
nanoid@^3.3.7:
|
nanoid@^3.3.7:
|
||||||
version "3.3.7"
|
version "3.3.8"
|
||||||
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz"
|
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
|
||||||
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
|
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
|
||||||
|
|
||||||
picocolors@^1, picocolors@^1.0.0:
|
picocolors@^1:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz"
|
||||||
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
|
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:
|
postcss-js@^4:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz"
|
resolved "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz"
|
||||||
|
|
@ -80,16 +99,26 @@ postcss-js@^4:
|
||||||
dependencies:
|
dependencies:
|
||||||
camelcase-css "^2.0.1"
|
camelcase-css "^2.0.1"
|
||||||
|
|
||||||
postcss@^8.4.21:
|
postcss@^8.4.49:
|
||||||
version "8.4.35"
|
version "8.4.49"
|
||||||
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz"
|
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
|
||||||
integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==
|
integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid "^3.3.7"
|
nanoid "^3.3.7"
|
||||||
picocolors "^1.0.0"
|
picocolors "^1.1.1"
|
||||||
source-map-js "^1.0.2"
|
source-map-js "^1.2.1"
|
||||||
|
|
||||||
source-map-js@^1.0.2:
|
source-map-js@^1.2.1:
|
||||||
version "1.0.2"
|
version "1.2.1"
|
||||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
|
||||||
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
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