Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/controllers/shared/trips_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

class Shared::TripsController < ApplicationController
def show
@trip = Trip.find_by(sharing_uuid: params[:trip_uuid])

redirect_to root_path, alert: 'Shared trip not found or no longer available' and return unless @trip&.public_accessible?

@user = @trip.user
@coordinates = extract_coordinates
@photo_previews = fetch_photo_previews
end

private

def extract_coordinates
return [] unless @trip.path&.coordinates

# Convert PostGIS LineString coordinates [lng, lat] to [lat, lng] for Leaflet
@trip.path.coordinates.map { |coord| [coord[1], coord[0]] }
end

def fetch_photo_previews
return [] unless @trip.share_photos?

Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
@trip.photo_previews
end
rescue StandardError => e
Rails.logger.error("Failed to fetch photo previews for trip #{@trip.id}: #{e.message}")
[]
end
end
32 changes: 32 additions & 0 deletions app/controllers/trips_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ def create
end

def update
# Handle sharing settings update
if params[:trip] && params[:trip][:sharing]
handle_sharing_update

respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("sharing_form_#{@trip.id}", partial: 'trips/sharing', locals: { trip: @trip }) }
format.html { redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other }
end
return
end

# Handle regular trip update
if @trip.update(trip_params)
redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other
else
Expand All @@ -64,7 +76,27 @@ def set_coordinates
).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 handle_sharing_update
sharing_params = params[:trip][:sharing]

if to_boolean(sharing_params[:enabled])
sharing_options = {
expiration: sharing_params[:expiration] || '24h',
share_notes: to_boolean(sharing_params[:share_notes]),
share_photos: to_boolean(sharing_params[:share_photos])
}

@trip.enable_sharing!(**sharing_options)
else
@trip.disable_sharing!
end
end

def trip_params
params.require(:trip).permit(:name, :started_at, :ended_at, :notes)
end

def to_boolean(value)
ActiveModel::Type::Boolean.new.cast(value)
end
end
35 changes: 35 additions & 0 deletions app/helpers/trips_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,39 @@ def trip_duration(trip)
parts = ["0 hours"] if parts.empty?
parts.join(', ')
end

def trip_sharing_status_badge(trip)
return unless trip.sharing_enabled?

if trip.sharing_expired?
content_tag(:span, 'Expired', class: 'badge badge-warning')
else
content_tag(:span, 'Shared', class: 'badge badge-success')
end
end

def trip_sharing_expiration_text(trip)
return 'Not shared' unless trip.sharing_enabled?

expiration = trip.sharing_settings['expiration']
expires_at = trip.sharing_settings['expires_at']

case expiration
when 'permanent'
'Never expires'
when '1h', '12h', '24h'
if expires_at
expires_at_time = Time.zone.parse(expires_at)
if expires_at_time > Time.current
"Expires #{time_ago_in_words(expires_at_time)} from now"
else
'Expired'
end
else
'Invalid expiration'
end
else
'Unknown expiration'
end
end
end
31 changes: 31 additions & 0 deletions app/javascript/controllers/copy_button_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["button"]
static values = { text: String }

copy(event) {
event.preventDefault()
const text = event.currentTarget.dataset.copyText

navigator.clipboard.writeText(text).then(() => {
const button = event.currentTarget
const originalHTML = button.innerHTML

// Show "Copied!" feedback
button.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>Copied!</span>
`

// Restore original content after 2 seconds
setTimeout(() => {
button.innerHTML = originalHTML
}, 2000)
}).catch(err => {
console.error('Failed to copy text: ', err)
})
}
}
94 changes: 94 additions & 0 deletions app/javascript/controllers/shared/trip_map_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import L from "leaflet";
import BaseController from "../base_controller";

export default class extends BaseController {
static values = {
coordinates: Array,
name: String
};

connect() {
super.connect();
this.initializeMap();
this.renderTripPath();
}

disconnect() {
if (this.map) {
this.map.remove();
}
}

initializeMap() {
// Initialize map with interactive controls enabled
this.map = L.map(this.element, {
zoomControl: true,
scrollWheelZoom: true,
doubleClickZoom: true,
touchZoom: true,
dragging: true,
keyboard: true
});

// Add OpenStreetMap tile layer (free for public use)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(this.map);

// Add scale control
L.control.scale({
position: 'bottomright',
imperial: false,
metric: true,
maxWidth: 120
}).addTo(this.map);

// Default view
this.map.setView([20, 0], 2);
}

renderTripPath() {
if (!this.coordinatesValue || this.coordinatesValue.length === 0) {
return;
}

// Create polyline from coordinates
const polyline = L.polyline(this.coordinatesValue, {
color: '#3b82f6',
opacity: 0.8,
weight: 3
}).addTo(this.map);

// Add start and end markers
if (this.coordinatesValue.length > 0) {
const startCoord = this.coordinatesValue[0];
const endCoord = this.coordinatesValue[this.coordinatesValue.length - 1];

// Start marker (green)
L.circleMarker(startCoord, {
radius: 8,
fillColor: '#10b981',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(this.map).bindPopup('<strong>Start</strong>');

// End marker (red)
L.circleMarker(endCoord, {
radius: 8,
fillColor: '#ef4444',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(this.map).bindPopup('<strong>End</strong>');
}

// Fit map to polyline bounds with padding
this.map.fitBounds(polyline.getBounds(), {
padding: [50, 50]
});
}
}
87 changes: 87 additions & 0 deletions app/models/concerns/shareable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# frozen_string_literal: true

module Shareable
extend ActiveSupport::Concern

included do
before_create :generate_sharing_uuid
end

def sharing_enabled?
sharing_settings.try(:[], 'enabled') == true
end

def sharing_expired?
expiration = sharing_settings.try(:[], 'expiration')
return false if expiration.blank?

expires_at_value = sharing_settings.try(:[], 'expires_at')
return true if expires_at_value.blank?

expires_at = begin
Time.zone.parse(expires_at_value)
rescue StandardError
nil
end

expires_at.present? ? Time.current > expires_at : true
end

def public_accessible?
sharing_enabled? && !sharing_expired?
end

def generate_new_sharing_uuid!
update!(sharing_uuid: SecureRandom.uuid)
end

def enable_sharing!(expiration: '1h', **options)
# Default to 24h if an invalid expiration is provided
expiration = '24h' unless %w[1h 12h 24h permanent].include?(expiration)

expires_at = case expiration
when '1h' then 1.hour.from_now
when '12h' then 12.hours.from_now
when '24h' then 24.hours.from_now
when 'permanent' then nil
end

settings = {
'enabled' => true,
'expiration' => expiration,
'expires_at' => expires_at&.iso8601
}

# Merge additional options (like share_notes, share_photos)
settings.merge!(options.stringify_keys)

update!(
sharing_settings: settings,
sharing_uuid: sharing_uuid || SecureRandom.uuid
)
end

def disable_sharing!
update!(
sharing_settings: {
'enabled' => false,
'expiration' => nil,
'expires_at' => nil
}
)
end

def share_notes?
sharing_settings.try(:[], 'share_notes') == true
end

def share_photos?
sharing_settings.try(:[], 'share_photos') == true
end

private

def generate_sharing_uuid
self.sharing_uuid ||= SecureRandom.uuid
end
end
Loading