Merge branch 'dev', remote-tracking branch 'origin' into feature/full-screen-map
|
|
@ -1 +1 @@
|
|||
0.30.9
|
||||
0.34.0
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ orbs:
|
|||
jobs:
|
||||
test:
|
||||
docker:
|
||||
- image: cimg/ruby:3.4.1-browsers
|
||||
- image: cimg/ruby:3.4.6-browsers
|
||||
environment:
|
||||
RAILS_ENV: test
|
||||
CI: true
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Base-Image for Ruby and Node.js
|
||||
FROM ruby:3.4.1-alpine
|
||||
FROM ruby:3.4.6-alpine
|
||||
|
||||
ENV APP_PATH=/var/app
|
||||
ENV BUNDLE_VERSION=2.5.21
|
||||
|
|
|
|||
|
|
@ -4,3 +4,6 @@ DATABASE_PASSWORD=password
|
|||
DATABASE_NAME=dawarich_development
|
||||
DATABASE_PORT=5432
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Fix for macOS fork() issues with Sidekiq
|
||||
OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -7,6 +7,8 @@ assignees: ''
|
|||
|
||||
---
|
||||
|
||||
**BEFORE OPENING AN ISSUE, MAKE SURE YOU READ THIS: https://github.com/Freika/dawarich/issues/1382**
|
||||
|
||||
**OS & Hardware**
|
||||
Provide your software and hardware specs
|
||||
|
||||
|
|
|
|||
4
.github/workflows/build_and_push.yml
vendored
|
|
@ -71,8 +71,8 @@ jobs:
|
|||
|
||||
TAGS="freikin/dawarich:${VERSION}"
|
||||
|
||||
# Set platforms based on release type
|
||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6"
|
||||
# Set platforms based on version type and release type
|
||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7"
|
||||
|
||||
# Add :rc tag for pre-releases
|
||||
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.4.1'
|
||||
ruby-version: '3.4.6'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.4.1
|
||||
3.4.6
|
||||
|
|
|
|||
32
AGENTS.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `app/` holds the Rails application: controllers and views under feature-oriented folders, `services/` for importers and background workflows, and `policies/` for Pundit authorization.
|
||||
- `app/javascript/` contains Stimulus controllers (`controllers/`), map widgets (`maps/`), and Tailwind/Turbo setup in `application.js`.
|
||||
- `lib/` stores reusable support code and rake tasks, while `config/` tracks environment settings, credentials, and initializers.
|
||||
- `db/` carries schema migrations and data migrations; `spec/` provides RSpec coverage; `e2e/` hosts Playwright scenarios; `docker/` bundles deployment compose files.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `bundle exec rails db:prepare` initializes or migrates the PostgreSQL database.
|
||||
- `bundle exec bin/dev` starts the Rails app plus JS bundler via Foreman using `Procfile.dev` (set `PROMETHEUS_EXPORTER_ENABLED=true` to use the Prometheus profile).
|
||||
- `bundle exec sidekiq` runs background jobs locally alongside the web server.
|
||||
- `docker compose -f docker/docker-compose.yml up` brings up the containerized stack for end-to-end smoke checks.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Follow default Ruby style with two-space indentation and snake_case filenames; run `bin/rubocop` before pushing.
|
||||
- JavaScript modules in `app/javascript/` use ES modules and Stimulus naming (`*_controller.js`); keep exports camelCase and limit files to a single controller.
|
||||
- Tailwind classes power the UI; co-locate shared styles under `app/javascript/styles/` rather than inline overrides.
|
||||
|
||||
## Testing Guidelines
|
||||
- Use `bundle exec rspec` for unit and feature specs; mirror production behavior by tagging jobs or services with factories in `spec/support`.
|
||||
- End-to-end flows live in `e2e/`; execute `npx playwright test` (set `BASE_URL` if the server runs on a non-default port).
|
||||
- Commit failing scenarios together with the fix, and prefer descriptive `it "..."` strings that capture user intent.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Write concise, imperative commit titles (e.g., `Add family sharing policy`); group related changes rather than omnibus commits.
|
||||
- Target pull requests at the `dev` branch, describe the motivation, reference GitHub issues when applicable, and attach screenshots for UI-facing changes.
|
||||
- Confirm CI, lint, and test status before requesting review; call out migrations or data tasks in the PR checklist.
|
||||
|
||||
## Environment & Configuration Tips
|
||||
- Copy `.env.example` to `.env` or rely on Docker secrets to supply API keys, map tokens, and mail credentials.
|
||||
- Regenerate credentials with `bin/rails credentials:edit` when altering secrets, and avoid committing any generated `.env` or `credentials.yml.enc` changes.
|
||||
144
CHANGELOG.md
|
|
@ -4,6 +4,147 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# [0.34.0] - 2025-10-10
|
||||
|
||||
## The Family release
|
||||
|
||||
In this release we're introducing family features that allow users to create family groups, invite members, and share location data. Family owners can manage members, control sharing settings, and ensure secure access to shared information. Location sharing is optional and can be enabled or disabled by each member individually. Users can join only one family at a time. Location sharing settings can be set to share location for 1, 6, 12, 24 hours or permanently. Family features are now available only for self-hosted instances and will be available in the cloud in the future. When "Family members" layer is enabled on the map, family member markers will be updated in real-time.
|
||||
|
||||
## Added
|
||||
|
||||
- Users can now create family groups and invite members to join.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Sign out button works again. #1844
|
||||
|
||||
## Changed
|
||||
|
||||
- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840
|
||||
|
||||
|
||||
# [0.33.1] - 2025-10-07
|
||||
|
||||
## Changed
|
||||
|
||||
- On the Trip page, instead of list of visited countries, a number of them is being shown. Clicking on it opens a modal with a list of countries visited during the trip. #1731
|
||||
|
||||
## Fixed
|
||||
|
||||
- `GET /api/v1/stats` endpoint now returns correct 0 instead of null if no points were tracked in the requested period.
|
||||
- User import data now being streamed instead of loaded into memory all at once. This should prevent large imports from exhausting memory or hitting IO limits while reading export archives.
|
||||
- Popup for manual visit creation now looks better in both light and dark modes. #1835
|
||||
- Fixed a bug where visit circles were not interactive on the map page. #1833
|
||||
- Fixed a bug with stats sharing settings being not filled. #1826
|
||||
- Fixed a bug where user could not be deleted due to counter cache on points. #1818
|
||||
- Introduce apt-get upgrade before installing new packages in the docker image to prevent vulnerabilities. #1793
|
||||
- Fixed time shift when creating visits manually. #1679
|
||||
- Provide default map layer if user settings are not set.
|
||||
|
||||
# [0.33.0] - 2025-09-29
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fix a bug where some points from Owntracks were not being processed correctly which prevented import from being created. #1745
|
||||
- Hexagons for the stats page are now being calculated a lot faster.
|
||||
- Prometheus exporter is now not being started when console is being run.
|
||||
- Stats will now properly reflect countries and cities visited after importing new points.
|
||||
- `GET /api/v1/points` will now return correct latitude and longitude values. #1502
|
||||
- Deleting an import will now trigger stats recalculation for affected months. #1789
|
||||
- Importing process should now schedule visits suggestions job a lot faster.
|
||||
- Importing GPX files that start with `<gpx` tag will now be detected correctly. #1775
|
||||
- Buttons on the map now have correct contrast in both light and dark modes.
|
||||
|
||||
## Changed
|
||||
|
||||
- Onboarding modal window now features a link to the App Store and a QR code to configure the Dawarich iOS app.
|
||||
- A permanent option was removed from stats sharing options. Now, stats can be shared for 1, 12 or 24 hours only.
|
||||
- User data archive importing now uploads the file directly to the storage service instead of uploading it to the app first.
|
||||
- Importing progress bars are now looking nice.
|
||||
- Ruby version was updated to 3.4.6.
|
||||
|
||||
## Added
|
||||
|
||||
- Based on preferred theme (light or dark), the map controls will now load with the corresponding styles.
|
||||
- [Dawarich Cloud] Added foundation for upcoming authentication from iOS app.
|
||||
- [Dawarich Cloud] Trial users can now create up to 5 imports. After that, they will be prompted to subscribe to a paid plan.
|
||||
- [Dawarich Cloud] Added Posthog analytics. Disabled by default, can be enabled with POSTHOG_ENABLED environment variable.
|
||||
|
||||
|
||||
# [0.32.0] - 2025-09-13
|
||||
|
||||
## Fixed
|
||||
|
||||
- Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466
|
||||
- Stats are now being calculated for trial users as well as active ones.
|
||||
|
||||
## Added
|
||||
|
||||
- A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours.
|
||||
- A new month stat page, featuring insights on how user's month went: distance traveled, active days, countries visited and more.
|
||||
- Month stat page can now be shared via public link. User can limit access to the page by sharing period: 1/12/24 hours or permanent.
|
||||
|
||||
## Changed
|
||||
|
||||
- Stats page now loads significantly faster due to caching.
|
||||
- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour.
|
||||
- Minor versions are now being built only for amd64 architecture to speed up the build process.
|
||||
- If user is not authorized to see a page, they will be redirected to the home page with appropriate message instead of seeing an error.
|
||||
|
||||
# [0.31.0] - 2025-09-04
|
||||
|
||||
The Search release
|
||||
|
||||
In this release we're introducing a new search feature that allows users to search for places and see when they visited them. On the map page, click on Search icon, enter a place name (e.g. "Alexanderplatz"), wait for suggestions to load, and click on the suggestion you want to search for. You then will see a list of years you visited that place. Click on the year to unfold list of visits for that year. Then click on the visit you want to see on the map and you will be moved to that visit on the map. From the opened visit popup you can create a new visit to save it in the database.
|
||||
|
||||
Important: This feature relies on reverse geocoding. Without reverse geocoding, the search feature will not work.
|
||||
|
||||
## Added
|
||||
|
||||
- User can now search for places and see when they visited them.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Default value for `points_count` attribute is now set to 0 in the User model.
|
||||
|
||||
## Changed
|
||||
|
||||
- Tracks are not being calculated by server instead of the database. This feature is still in progress.
|
||||
|
||||
|
||||
# [0.30.12] - 2025-08-26
|
||||
|
||||
## Fixed
|
||||
|
||||
- Number of user points is not being cached resulting in performance boost on certain pages and operations.
|
||||
- Logout bug
|
||||
- Api key is now shown even in trial period
|
||||
|
||||
|
||||
# [0.30.11] - 2025-08-23
|
||||
|
||||
## Changed
|
||||
|
||||
- If user already have import with the same name, it will be appended with timestamp during the import process.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Some types of imports were not being detected correctly and were failing to import. #1678
|
||||
|
||||
|
||||
# [0.30.10] - 2025-08-22
|
||||
|
||||
## Added
|
||||
|
||||
- `POST /api/v1/visits` endpoint.
|
||||
- User now can create visits manually on the map.
|
||||
- User can now delete a visit by clicking on the delete button in the visit popup.
|
||||
- Import failure now throws an internal server error.
|
||||
|
||||
## Changed
|
||||
|
||||
- Source of imports is now being detected automatically.
|
||||
|
||||
|
||||
# [0.30.9] - 2025-08-19
|
||||
|
||||
|
|
@ -18,7 +159,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Trial version for cloud users is now available.
|
||||
|
||||
|
||||
|
||||
# [0.30.8] - 2025-08-01
|
||||
|
||||
## Fixed
|
||||
|
|
@ -28,7 +168,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
- Scratch map is now working correctly.
|
||||
|
||||
|
||||
|
||||
# [0.30.7] - 2025-08-01
|
||||
|
||||
## Fixed
|
||||
|
|
@ -77,7 +216,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
- Prometheus metrics are now available at `/metrics`. Configure `METRICS_USERNAME` and `METRICS_PASSWORD` environment variables for basic authentication, default values are `prometheus` for both. All other prometheus-related environment variables are also necessary.
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- The Warden error in jobs is now fixed. #1556
|
||||
|
|
|
|||
255
CLAUDE.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# CLAUDE.md - Dawarich Development Guide
|
||||
|
||||
This file contains essential information for Claude to work effectively with the Dawarich codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Dawarich** is a self-hostable web application built with Ruby on Rails 8.0 that serves as a replacement for Google Timeline (Google Location History). It allows users to track, visualize, and analyze their location data through an interactive web interface.
|
||||
|
||||
### Key Features
|
||||
- Location history tracking and visualization
|
||||
- Interactive maps with multiple layers (heatmap, points, lines, fog of war)
|
||||
- Import from various sources (Google Maps Timeline, OwnTracks, Strava, GPX, GeoJSON, photos)
|
||||
- Export to GeoJSON and GPX formats
|
||||
- Statistics and analytics (countries visited, distance traveled, etc.)
|
||||
- Public sharing of monthly statistics with time-based expiration
|
||||
- Trips management with photo integration
|
||||
- Areas and visits tracking
|
||||
- Integration with photo management systems (Immich, Photoprism)
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Backend
|
||||
- **Framework**: Ruby on Rails 8.0
|
||||
- **Database**: PostgreSQL with PostGIS extension
|
||||
- **Background Jobs**: Sidekiq with Redis
|
||||
- **Authentication**: Devise
|
||||
- **Authorization**: Pundit
|
||||
- **API Documentation**: rSwag (Swagger)
|
||||
- **Monitoring**: Prometheus, Sentry
|
||||
- **File Processing**: AWS S3 integration
|
||||
|
||||
### Frontend
|
||||
- **CSS Framework**: Tailwind CSS with DaisyUI components
|
||||
- **JavaScript**: Stimulus, Turbo Rails, Hotwired
|
||||
- **Maps**: Leaflet.js
|
||||
- **Charts**: Chartkick
|
||||
|
||||
### Key Gems
|
||||
- `activerecord-postgis-adapter` - PostgreSQL PostGIS support
|
||||
- `geocoder` - Geocoding services
|
||||
- `rgeo` - Ruby Geometric Library
|
||||
- `gpx` - GPX file processing
|
||||
- `parallel` - Parallel processing
|
||||
- `sidekiq` - Background job processing
|
||||
- `chartkick` - Chart generation
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── app/
|
||||
│ ├── controllers/ # Rails controllers
|
||||
│ ├── models/ # ActiveRecord models with PostGIS support
|
||||
│ ├── views/ # ERB templates
|
||||
│ ├── services/ # Business logic services
|
||||
│ ├── jobs/ # Sidekiq background jobs
|
||||
│ ├── queries/ # Database query objects
|
||||
│ ├── policies/ # Pundit authorization policies
|
||||
│ ├── serializers/ # API response serializers
|
||||
│ ├── javascript/ # Stimulus controllers and JS
|
||||
│ └── assets/ # CSS and static assets
|
||||
├── config/ # Rails configuration
|
||||
├── db/ # Database migrations and seeds
|
||||
├── docker/ # Docker configuration
|
||||
├── spec/ # RSpec test suite
|
||||
└── swagger/ # API documentation
|
||||
```
|
||||
|
||||
## Core Models
|
||||
|
||||
### Primary Models
|
||||
- **User**: Authentication and user management
|
||||
- **Point**: Individual location points with coordinates and timestamps
|
||||
- **Track**: Collections of related points forming routes
|
||||
- **Area**: Geographic areas drawn by users
|
||||
- **Visit**: Detected visits to areas
|
||||
- **Trip**: User-defined travel periods with analytics
|
||||
- **Import**: Data import operations
|
||||
- **Export**: Data export operations
|
||||
- **Stat**: Calculated statistics and metrics with public sharing capabilities
|
||||
|
||||
### Geographic Features
|
||||
- Uses PostGIS for advanced geographic queries
|
||||
- Implements distance calculations and spatial relationships
|
||||
- Supports various coordinate systems and projections
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Setup
|
||||
1. **Docker Development**: Use `docker-compose -f docker/docker-compose.yml up`
|
||||
2. **DevContainer**: VS Code devcontainer support available
|
||||
3. **Local Development**:
|
||||
- `bundle exec rails db:prepare`
|
||||
- `bundle exec sidekiq` (background jobs)
|
||||
- `bundle exec bin/dev` (main application)
|
||||
|
||||
### Default Credentials
|
||||
- Username: `demo@dawarich.app`
|
||||
- Password: `password`
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Suite
|
||||
- **Framework**: RSpec
|
||||
- **System Tests**: Capybara + Selenium WebDriver
|
||||
- **E2E Tests**: Playwright
|
||||
- **Coverage**: SimpleCov
|
||||
- **Factories**: FactoryBot
|
||||
- **Mocking**: WebMock
|
||||
|
||||
### Test Commands
|
||||
```bash
|
||||
bundle exec rspec # Run all specs
|
||||
bundle exec rspec spec/models/ # Model specs only
|
||||
npx playwright test # E2E tests
|
||||
```
|
||||
|
||||
## Background Jobs
|
||||
|
||||
### Sidekiq Jobs
|
||||
- **Import Jobs**: Process uploaded location data files
|
||||
- **Calculation Jobs**: Generate statistics and analytics
|
||||
- **Notification Jobs**: Send user notifications
|
||||
- **Photo Processing**: Extract EXIF data from photos
|
||||
|
||||
### Key Job Classes
|
||||
- `Tracks::ParallelGeneratorJob` - Generate track data in parallel
|
||||
- Various import jobs for different data sources
|
||||
- Statistical calculation jobs
|
||||
|
||||
## Public Sharing System
|
||||
|
||||
### Overview
|
||||
Dawarich includes a comprehensive public sharing system that allows users to share their monthly statistics with others without requiring authentication. This feature enables users to showcase their location data while maintaining privacy control through configurable expiration settings.
|
||||
|
||||
### Key Features
|
||||
- **Time-based expiration**: Share links can expire after 1 hour, 12 hours, 24 hours, or be permanent
|
||||
- **UUID-based access**: Each shared stat has a unique, unguessable UUID for security
|
||||
- **Public API endpoints**: Hexagon map data can be accessed via API without authentication when sharing is enabled
|
||||
- **Automatic cleanup**: Expired shares are automatically inaccessible
|
||||
- **Privacy controls**: Users can enable/disable sharing and regenerate sharing URLs at any time
|
||||
|
||||
### Technical Implementation
|
||||
- **Database**: `sharing_settings` (JSONB) and `sharing_uuid` (UUID) columns on `stats` table
|
||||
- **Routes**: `/shared/month/:uuid` for public viewing, `/stats/:year/:month/sharing` for management
|
||||
- **API**: `/api/v1/maps/hexagons` supports public access via `uuid` parameter
|
||||
- **Controllers**: `Shared::StatsController` handles public views, sharing management integrated into existing stats flow
|
||||
|
||||
### Security Features
|
||||
- **No authentication bypass**: Public sharing only exposes specifically designed endpoints
|
||||
- **UUID-based access**: Sharing URLs use unguessable UUIDs rather than sequential IDs
|
||||
- **Expiration enforcement**: Automatic expiration checking prevents access to expired shares
|
||||
- **Limited data exposure**: Only monthly statistics and hexagon data are publicly accessible
|
||||
|
||||
### Usage Patterns
|
||||
- **Social sharing**: Users can share interesting travel months with friends and family
|
||||
- **Portfolio/showcase**: Travel bloggers and photographers can showcase location statistics
|
||||
- **Data collaboration**: Researchers can share aggregated location data for analysis
|
||||
- **Public demonstrations**: Demo instances can provide public examples without compromising user data
|
||||
|
||||
## API Documentation
|
||||
|
||||
- **Framework**: rSwag (Swagger/OpenAPI)
|
||||
- **Location**: `/api-docs` endpoint
|
||||
- **Authentication**: API key (Bearer) for API access, UUID-based access for public shares
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Key Tables
|
||||
- `users` - User accounts and settings
|
||||
- `points` - Location points with PostGIS geometry
|
||||
- `tracks` - Route collections
|
||||
- `areas` - User-defined geographic areas
|
||||
- `visits` - Detected area visits
|
||||
- `trips` - Travel periods
|
||||
- `imports`/`exports` - Data transfer operations
|
||||
- `stats` - Calculated metrics with sharing capabilities (`sharing_settings`, `sharing_uuid`)
|
||||
|
||||
### PostGIS Integration
|
||||
- Extensive use of PostGIS geometry types
|
||||
- Spatial indexes for performance
|
||||
- Geographic calculations and queries
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
See `.env.template` for available configuration options including:
|
||||
- Database configuration
|
||||
- Redis settings
|
||||
- AWS S3 credentials
|
||||
- External service integrations
|
||||
- Feature flags
|
||||
|
||||
### Key Config Files
|
||||
- `config/database.yml` - Database configuration
|
||||
- `config/sidekiq.yml` - Background job settings
|
||||
- `config/schedule.yml` - Cron job schedules
|
||||
- `docker/docker-compose.yml` - Development environment
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
- Production: `docker/docker-compose.production.yml`
|
||||
- Development: `docker/docker-compose.yml`
|
||||
- Multi-stage Docker builds supported
|
||||
|
||||
### Procfiles
|
||||
- `Procfile` - Production Heroku deployment
|
||||
- `Procfile.dev` - Development with Foreman
|
||||
- `Procfile.production` - Production processes
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Tools
|
||||
- **Linting**: RuboCop with Rails extensions
|
||||
- **Security**: Brakeman, bundler-audit
|
||||
- **Dependencies**: Strong Migrations for safe database changes
|
||||
- **Performance**: Stackprof for profiling
|
||||
|
||||
### Commands
|
||||
```bash
|
||||
bundle exec rubocop # Code linting
|
||||
bundle exec brakeman # Security scan
|
||||
bundle exec bundle-audit # Dependency security
|
||||
```
|
||||
|
||||
## Important Notes for Development
|
||||
|
||||
1. **Location Data**: Always handle location data with appropriate precision and privacy considerations
|
||||
2. **PostGIS**: Leverage PostGIS features for geographic calculations rather than Ruby-based solutions
|
||||
2.1 **Coordinates**: Use `lonlat` column in `points` table for geographic calculations
|
||||
3. **Background Jobs**: Use Sidekiq for any potentially long-running operations
|
||||
4. **Testing**: Include both unit and integration tests for location-based features
|
||||
5. **Performance**: Consider database indexes for geographic queries
|
||||
6. **Security**: Never log or expose user location data inappropriately
|
||||
7. **Public Sharing**: When implementing features that interact with stats, consider public sharing access patterns:
|
||||
- Use `public_accessible?` method to check if a stat can be publicly accessed
|
||||
- Support UUID-based access in API endpoints when appropriate
|
||||
- Respect expiration settings and disable sharing when expired
|
||||
- Only expose minimal necessary data in public sharing contexts
|
||||
|
||||
## Contributing
|
||||
|
||||
- **Main Branch**: `master`
|
||||
- **Development**: `dev` branch for pull requests
|
||||
- **Issues**: GitHub Issues for bug reports
|
||||
- **Discussions**: GitHub Discussions for feature requests
|
||||
- **Community**: Discord server for questions
|
||||
|
||||
## Resources
|
||||
|
||||
- **Documentation**: https://dawarich.app/docs/
|
||||
- **Repository**: https://github.com/Freika/dawarich
|
||||
- **Discord**: https://discord.gg/pHsBjpt5J8
|
||||
- **Changelog**: See CHANGELOG.md for version history
|
||||
- **Development Setup**: See DEVELOPMENT.md
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
## How to contribute to Dawarich
|
||||
|
||||
Refer to [Repository Guidelines](AGENTS.md) for structure, tooling, and workflow expectations before submitting changes.
|
||||
|
||||
#### **Did you find a bug?**
|
||||
|
||||
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/Freika/dawarich/issues).
|
||||
|
|
|
|||
16
Gemfile
|
|
@ -7,9 +7,9 @@ ruby File.read('.ruby-version').strip
|
|||
|
||||
gem 'activerecord-postgis-adapter'
|
||||
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
|
||||
gem 'aws-sdk-s3', '~> 1.177.0', require: false
|
||||
gem 'aws-sdk-core', '~> 3.215.1', require: false
|
||||
gem 'aws-sdk-kms', '~> 1.96.0', require: false
|
||||
gem 'aws-sdk-s3', '~> 1.177.0', require: false
|
||||
gem 'bootsnap', require: false
|
||||
gem 'chartkick'
|
||||
gem 'data_migrate'
|
||||
|
|
@ -17,41 +17,43 @@ gem 'devise'
|
|||
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
|
||||
gem 'gpx'
|
||||
gem 'groupdate'
|
||||
gem 'h3', '~> 3.7'
|
||||
gem 'httparty'
|
||||
gem 'importmap-rails'
|
||||
gem 'jwt', '~> 2.8'
|
||||
gem 'kaminari'
|
||||
gem 'lograge'
|
||||
gem 'oj'
|
||||
gem 'parallel'
|
||||
gem 'pg'
|
||||
gem 'prometheus_exporter'
|
||||
gem 'rqrcode', '~> 3.0'
|
||||
gem 'puma'
|
||||
gem 'pundit'
|
||||
gem 'rails', '~> 8.0'
|
||||
gem 'rails_icons'
|
||||
gem 'redis'
|
||||
gem 'rexml'
|
||||
gem 'rgeo'
|
||||
gem 'rgeo-activerecord'
|
||||
gem 'rgeo-geojson'
|
||||
gem 'rqrcode', '~> 3.0'
|
||||
gem 'rswag-api'
|
||||
gem 'rswag-ui'
|
||||
gem 'rubyzip', '~> 2.4'
|
||||
gem 'sentry-ruby'
|
||||
gem 'rubyzip', '~> 3.1'
|
||||
gem 'sentry-rails'
|
||||
gem 'stackprof'
|
||||
gem 'sentry-ruby'
|
||||
gem 'sidekiq'
|
||||
gem 'sidekiq-cron'
|
||||
gem 'sidekiq-limit_fetch'
|
||||
gem 'sprockets-rails'
|
||||
gem 'stackprof'
|
||||
gem 'stimulus-rails'
|
||||
gem 'strong_migrations'
|
||||
gem 'tailwindcss-rails'
|
||||
gem 'turbo-rails'
|
||||
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
|
||||
gem 'jwt'
|
||||
|
||||
group :development, :test do
|
||||
group :development, :test, :staging do
|
||||
gem 'brakeman', require: false
|
||||
gem 'bundler-audit', require: false
|
||||
gem 'debug', platforms: %i[mri mingw x64_mingw]
|
||||
|
|
|
|||
74
Gemfile.lock
|
|
@ -107,7 +107,7 @@ GEM
|
|||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bigdecimal (3.2.3)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
|
|
@ -130,7 +130,7 @@ GEM
|
|||
chunky_png (1.4.0)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
connection_pool (2.5.4)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
|
|
@ -165,14 +165,21 @@ GEM
|
|||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
factory_bot (6.5.4)
|
||||
factory_bot (6.5.5)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.5.0)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
fakeredis (0.1.4)
|
||||
ffaker (2.24.0)
|
||||
foreman (0.88.1)
|
||||
ffaker (2.25.0)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86-linux-gnu)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
|
|
@ -184,6 +191,10 @@ GEM
|
|||
rake
|
||||
groupdate (6.7.0)
|
||||
activesupport (>= 7.1)
|
||||
h3 (3.7.4)
|
||||
ffi (~> 1.9)
|
||||
rgeo-geojson (~> 2.1)
|
||||
zeitwerk (~> 2.5)
|
||||
hashdiff (1.1.2)
|
||||
httparty (0.23.1)
|
||||
csv
|
||||
|
|
@ -191,7 +202,7 @@ GEM
|
|||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.7)
|
||||
concurrent-ruby (~> 1.0)
|
||||
importmap-rails (2.1.0)
|
||||
importmap-rails (2.2.2)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
|
|
@ -201,7 +212,7 @@ GEM
|
|||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.0)
|
||||
json (2.13.2)
|
||||
json-schema (5.0.1)
|
||||
addressable (~> 2.8)
|
||||
jwt (2.10.1)
|
||||
|
|
@ -274,16 +285,20 @@ GEM
|
|||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.8.0)
|
||||
parser (3.3.9.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
patience_diff (1.2.0)
|
||||
optimist (~> 3.0)
|
||||
pg (1.5.9)
|
||||
pg (1.6.2)
|
||||
pg (1.6.2-aarch64-linux)
|
||||
pg (1.6.2-arm64-darwin)
|
||||
pg (1.6.2-x86_64-darwin)
|
||||
pg (1.6.2-x86_64-linux)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
prism (1.5.1)
|
||||
prometheus_exporter (2.2.0)
|
||||
webrick
|
||||
pry (0.15.2)
|
||||
|
|
@ -304,7 +319,7 @@ GEM
|
|||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.16)
|
||||
rack (3.2.1)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
|
|
@ -333,6 +348,9 @@ GEM
|
|||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails_icons (1.4.0)
|
||||
nokogiri (~> 1.16, >= 1.16.4)
|
||||
rails (> 6.1)
|
||||
railties (8.0.2.1)
|
||||
actionpack (= 8.0.2.1)
|
||||
activesupport (= 8.0.2.1)
|
||||
|
|
@ -350,7 +368,7 @@ GEM
|
|||
redis-client (>= 0.22.0)
|
||||
redis-client (0.24.0)
|
||||
connection_pool
|
||||
regexp_parser (2.10.0)
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.2)
|
||||
io-console (~> 0.5)
|
||||
request_store (1.7.0)
|
||||
|
|
@ -358,7 +376,7 @@ GEM
|
|||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rgeo (3.0.1)
|
||||
rgeo-activerecord (8.0.0)
|
||||
activerecord (>= 7.0)
|
||||
|
|
@ -398,7 +416,7 @@ GEM
|
|||
rswag-ui (2.16.0)
|
||||
actionpack (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rubocop (1.75.6)
|
||||
rubocop (1.80.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
@ -406,26 +424,26 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-ast (>= 1.46.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.44.1)
|
||||
rubocop-ast (1.46.0)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.4)
|
||||
rubocop-rails (2.32.0)
|
||||
rubocop-rails (2.33.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
rubyzip (2.4.1)
|
||||
rubyzip (3.1.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.33.0)
|
||||
selenium-webdriver (4.35.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.26.0)
|
||||
railties (>= 5.0)
|
||||
|
|
@ -488,9 +506,9 @@ GEM
|
|||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.3)
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
|
|
@ -539,9 +557,10 @@ DEPENDENCIES
|
|||
geocoder!
|
||||
gpx
|
||||
groupdate
|
||||
h3 (~> 3.7)
|
||||
httparty
|
||||
importmap-rails
|
||||
jwt
|
||||
jwt (~> 2.8)
|
||||
kaminari
|
||||
lograge
|
||||
oj
|
||||
|
|
@ -553,6 +572,7 @@ DEPENDENCIES
|
|||
puma
|
||||
pundit
|
||||
rails (~> 8.0)
|
||||
rails_icons
|
||||
redis
|
||||
rexml
|
||||
rgeo
|
||||
|
|
@ -564,7 +584,7 @@ DEPENDENCIES
|
|||
rswag-specs
|
||||
rswag-ui
|
||||
rubocop-rails
|
||||
rubyzip (~> 2.4)
|
||||
rubyzip (~> 3.1)
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
|
|
@ -584,7 +604,7 @@ DEPENDENCIES
|
|||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.1p0
|
||||
ruby 3.4.6p54
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.21
|
||||
|
|
|
|||
1
Procfile
|
|
@ -1,2 +1,3 @@
|
|||
release: bundle exec rails db:migrate
|
||||
web: bundle exec puma -C config/puma.rb
|
||||
worker: bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
|
|
|||
5
app.json
|
|
@ -5,11 +5,6 @@
|
|||
{ "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" },
|
||||
{ "url": "https://github.com/heroku/heroku-buildpack-ruby.git" }
|
||||
],
|
||||
"scripts": {
|
||||
"dokku": {
|
||||
"predeploy": "bundle exec rails db:migrate"
|
||||
}
|
||||
},
|
||||
"healthchecks": {
|
||||
"web": [
|
||||
{
|
||||
|
|
|
|||
46
app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
|
||||
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
|
||||
<g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
|
||||
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
|
||||
</g>
|
||||
<g id="_Group_" data-name="<Group>">
|
||||
<g id="_Group_2" data-name="<Group>">
|
||||
<g id="_Group_3" data-name="<Group>">
|
||||
<path id="_Path_" data-name="<Path>" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
|
||||
<path id="_Path_2" data-name="<Path>" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
|
||||
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
|
||||
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
|
||||
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
|
||||
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
|
||||
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="_Group_4" data-name="<Group>">
|
||||
<g>
|
||||
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
|
||||
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
|
||||
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
|
||||
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
|
||||
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
|
||||
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
|
||||
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 755 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 658 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 546 KiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "Dawarich",
|
||||
"short_name": "Dawarich",
|
||||
"icons": [
|
||||
{
|
||||
"src": "<%= asset_path 'favicon/android-chrome-192x192.png' %>",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "<%= asset_path 'favicon/android-chrome-512x512.png' %>",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
}
|
||||
|
||||
.loading-spinner::before {
|
||||
content: '🔵';
|
||||
content: '';
|
||||
font-size: 18px;
|
||||
animation: spinner 1s linear infinite;
|
||||
}
|
||||
|
|
@ -101,3 +101,63 @@
|
|||
content: '✅';
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Flash message animations */
|
||||
@keyframes slideInFromRight {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutToRight {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Family feature specific styles */
|
||||
.family-member-card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.family-member-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.invitation-card {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.family-invitation-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
*/
|
||||
@import 'actiontext.css';
|
||||
@import 'leaflet_theme.css';
|
||||
|
||||
@layer components {
|
||||
.fade-out {
|
||||
|
|
@ -33,18 +34,44 @@
|
|||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Add Visit Marker Styles */
|
||||
.add-visit-marker {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
background: white;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
animation: pulse-visit 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-visit {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Visit Form Popup Styles */
|
||||
.visit-form-popup .leaflet-popup-content-wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.leaflet-right-panel.controls-shifted {
|
||||
right: 310px;
|
||||
}
|
||||
|
||||
.leaflet-control-button {
|
||||
background-color: white !important;
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-button:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
/* Drawer Panel Styles */
|
||||
.leaflet-drawer {
|
||||
|
|
|
|||
189
app/assets/stylesheets/leaflet_theme.css
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/* Leaflet Theme Styles - Light and Dark mode support */
|
||||
|
||||
/* CSS Custom Properties for Light Theme */
|
||||
[data-theme="light"] {
|
||||
--leaflet-bg-color: #ffffff;
|
||||
--leaflet-text-color: #000000;
|
||||
--leaflet-border-color: #e5e7eb;
|
||||
--leaflet-shadow-color: rgba(0, 0, 0, 0.1);
|
||||
--leaflet-hover-color: #f3f4f6;
|
||||
--leaflet-link-color: #0066cc;
|
||||
--leaflet-scale-bg: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* CSS Custom Properties for Dark Theme */
|
||||
[data-theme="dark"] {
|
||||
--leaflet-bg-color: #374151;
|
||||
--leaflet-text-color: #ffffff;
|
||||
--leaflet-border-color: #4b5563;
|
||||
--leaflet-shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--leaflet-hover-color: #4b5563;
|
||||
--leaflet-link-color: #66b3ff;
|
||||
--leaflet-scale-bg: rgba(55, 65, 81, 0.9);
|
||||
}
|
||||
|
||||
/* Leaflet default controls theme override */
|
||||
.leaflet-control-layers,
|
||||
.leaflet-control-zoom,
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle,
|
||||
.leaflet-control-layers-list,
|
||||
.leaflet-control-draw {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
border-color: var(--leaflet-border-color) !important;
|
||||
box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important;
|
||||
}
|
||||
|
||||
/* Leaflet zoom buttons */
|
||||
.leaflet-control-zoom a {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
border-bottom: 1px solid var(--leaflet-border-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background-color: var(--leaflet-hover-color) !important;
|
||||
}
|
||||
|
||||
/* Leaflet layer control */
|
||||
.leaflet-control-layers-toggle {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers-expanded {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-layers label {
|
||||
color: var(--leaflet-text-color) !important;
|
||||
}
|
||||
|
||||
/* Leaflet Draw controls */
|
||||
.leaflet-draw-toolbar a {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
border-bottom: 1px solid var(--leaflet-border-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-draw-toolbar a:hover {
|
||||
background-color: var(--leaflet-hover-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-draw-actions a {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
}
|
||||
|
||||
/* Leaflet popups */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
}
|
||||
|
||||
/* Attribution control */
|
||||
.leaflet-control-attribution a {
|
||||
color: var(--leaflet-link-color) !important;
|
||||
}
|
||||
|
||||
/* Custom control buttons */
|
||||
.leaflet-control-button,
|
||||
.add-visit-button,
|
||||
.leaflet-bar button {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
border: 1px solid var(--leaflet-border-color) !important;
|
||||
box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-button:hover,
|
||||
.add-visit-button:hover,
|
||||
.leaflet-bar button:hover {
|
||||
background-color: var(--leaflet-hover-color) !important;
|
||||
}
|
||||
|
||||
/* Any other custom controls */
|
||||
.leaflet-top .leaflet-control button,
|
||||
.leaflet-bottom .leaflet-control button,
|
||||
.leaflet-left .leaflet-control button,
|
||||
.leaflet-right .leaflet-control button {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
border: 1px solid var(--leaflet-border-color) !important;
|
||||
}
|
||||
|
||||
/* Location search button */
|
||||
.location-search-toggle,
|
||||
#location-search-toggle {
|
||||
background-color: var(--leaflet-bg-color) !important;
|
||||
color: var(--leaflet-text-color) !important;
|
||||
border: 1px solid var(--leaflet-border-color) !important;
|
||||
box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important;
|
||||
}
|
||||
|
||||
.location-search-toggle:hover,
|
||||
#location-search-toggle:hover {
|
||||
background-color: var(--leaflet-hover-color) !important;
|
||||
}
|
||||
|
||||
/* Distance scale control */
|
||||
.leaflet-control-scale {
|
||||
background: var(--leaflet-scale-bg) !important;
|
||||
border-radius: 3px !important;
|
||||
padding: 2px !important;
|
||||
}
|
||||
|
||||
/* Family member tooltip - dark styled like the visit popup */
|
||||
.leaflet-tooltip.family-member-tooltip {
|
||||
background-color: #374151 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #4b5563 !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 11px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.leaflet-tooltip.family-member-tooltip::before {
|
||||
border-top-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Family member popup - just override colors, keep default layout */
|
||||
.leaflet-popup-content-wrapper:has(.family-member-popup) {
|
||||
background-color: #1f2937 !important;
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper:has(.family-member-popup) + .leaflet-popup-tip {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Family member marker pulse animation for recent updates */
|
||||
@keyframes family-marker-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.family-member-marker-recent {
|
||||
animation: family-marker-pulse 2s infinite;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
.family-member-marker-recent .leaflet-marker-icon > div {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
border-radius: 50%;
|
||||
}
|
||||
13
app/assets/svg/icons/lucide/outline/activity.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
1
app/assets/svg/icons/lucide/outline/bell.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bell-icon lucide-bell"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>
|
||||
|
After Width: | Height: | Size: 409 B |
23
app/assets/svg/icons/lucide/outline/building.svg
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 10h.01" />
|
||||
<path d="M12 14h.01" />
|
||||
<path d="M12 6h.01" />
|
||||
<path d="M16 10h.01" />
|
||||
<path d="M16 14h.01" />
|
||||
<path d="M16 6h.01" />
|
||||
<path d="M8 10h.01" />
|
||||
<path d="M8 14h.01" />
|
||||
<path d="M8 6h.01" />
|
||||
<path d="M9 22v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||
<rect x="4" y="2" width="16" height="20" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 545 B |
19
app/assets/svg/icons/lucide/outline/bus.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 6v6" />
|
||||
<path d="M15 6v6" />
|
||||
<path d="M2 12h19.6" />
|
||||
<path d="M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2 0-.4-.1-.8-.2-1.2l-1.4-5C20.1 6.8 19.1 6 18 6H4a2 2 0 0 0-2 2v10h3" />
|
||||
<circle cx="7" cy="18" r="2" />
|
||||
<path d="M9 18h5" />
|
||||
<circle cx="16" cy="18" r="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 492 B |
17
app/assets/svg/icons/lucide/outline/calendar-check-2.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8 2v4" />
|
||||
<path d="M16 2v4" />
|
||||
<path d="M21 14V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="m16 20 2 2 4-4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 380 B |
14
app/assets/svg/icons/lucide/outline/camera.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
16
app/assets/svg/icons/lucide/outline/car.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2" />
|
||||
<circle cx="7" cy="17" r="2" />
|
||||
<path d="M9 17h6" />
|
||||
<circle cx="17" cy="17" r="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
1
app/assets/svg/icons/lucide/outline/chart-column.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chart-column-icon lucide-chart-column"><path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></svg>
|
||||
|
After Width: | Height: | Size: 344 B |
1
app/assets/svg/icons/lucide/outline/chevron-left.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left-icon lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 274 B |
1
app/assets/svg/icons/lucide/outline/chevron-right.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 275 B |
1
app/assets/svg/icons/lucide/outline/circle-alert.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-alert-icon lucide-circle-alert"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
|
||||
|
After Width: | Height: | Size: 360 B |
1
app/assets/svg/icons/lucide/outline/circle-check.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
|
After Width: | Height: | Size: 305 B |
1
app/assets/svg/icons/lucide/outline/circle-x.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
|
||||
|
After Width: | Height: | Size: 313 B |
14
app/assets/svg/icons/lucide/outline/copy.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 339 B |
16
app/assets/svg/icons/lucide/outline/earth.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
|
||||
<path d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17" />
|
||||
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
13
app/assets/svg/icons/lucide/outline/flame.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 393 B |
1
app/assets/svg/icons/lucide/outline/flower.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-flower-icon lucide-flower"><circle cx="12" cy="12" r="3"/><path d="M12 16.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 1 1 12 7.5a4.5 4.5 0 1 1 4.5 4.5 4.5 4.5 0 1 1-4.5 4.5"/><path d="M12 7.5V9"/><path d="M7.5 12H9"/><path d="M16.5 12H15"/><path d="M12 16.5V15"/><path d="m8 8 1.88 1.88"/><path d="M14.12 9.88 16 8"/><path d="m8 16 1.88-1.88"/><path d="M14.12 14.12 16 16"/></svg>
|
||||
|
After Width: | Height: | Size: 572 B |
15
app/assets/svg/icons/lucide/outline/globe.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 331 B |
1
app/assets/svg/icons/lucide/outline/goal.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-goal-icon lucide-goal"><path d="M12 13V2l8 4-8 4"/><path d="M20.561 10.222a9 9 0 1 1-12.55-5.29"/><path d="M8.002 9.997a5 5 0 1 0 8.9 2.02"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
1
app/assets/svg/icons/lucide/outline/heart.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heart-icon lucide-heart"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/></svg>
|
||||
|
After Width: | Height: | Size: 395 B |
14
app/assets/svg/icons/lucide/outline/house.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
15
app/assets/svg/icons/lucide/outline/info.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
1
app/assets/svg/icons/lucide/outline/leaf.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-leaf-icon lucide-leaf"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
15
app/assets/svg/icons/lucide/outline/lightbulb.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 22h4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
14
app/assets/svg/icons/lucide/outline/link.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 359 B |
16
app/assets/svg/icons/lucide/outline/map-pin-plus.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M19.914 11.105A7.298 7.298 0 0 0 20 10a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32 32 0 0 0 .824-.738" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
<path d="M16 18h6" />
|
||||
<path d="M19 15v6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 434 B |
14
app/assets/svg/icons/lucide/outline/map-pin.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 359 B |
17
app/assets/svg/icons/lucide/outline/map-plus.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m11 19-1.106-.552a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0l4.212 2.106a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619V12" />
|
||||
<path d="M15 5.764V12" />
|
||||
<path d="M18 15v6" />
|
||||
<path d="M21 18h-6" />
|
||||
<path d="M9 3.236v15" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 513 B |
15
app/assets/svg/icons/lucide/outline/map.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z" />
|
||||
<path d="M15 5.764v15" />
|
||||
<path d="M9 3.236v15" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
13
app/assets/svg/icons/lucide/outline/plane.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
1
app/assets/svg/icons/lucide/outline/refresh-ccw.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-ccw-icon lucide-refresh-ccw"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>
|
||||
|
After Width: | Height: | Size: 413 B |
15
app/assets/svg/icons/lucide/outline/share.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 2v13" />
|
||||
<path d="m16 6-4-4-4 4" />
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 318 B |
1
app/assets/svg/icons/lucide/outline/shield-check.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shield-check-icon lucide-shield-check"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
|
After Width: | Height: | Size: 447 B |
15
app/assets/svg/icons/lucide/outline/shopping-cart.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="8" cy="21" r="1" />
|
||||
<circle cx="19" cy="21" r="1" />
|
||||
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
24
app/assets/svg/icons/lucide/outline/snowflake.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m10 20-1.25-2.5L6 18" />
|
||||
<path d="M10 4 8.75 6.5 6 6" />
|
||||
<path d="m14 20 1.25-2.5L18 18" />
|
||||
<path d="m14 4 1.25 2.5L18 6" />
|
||||
<path d="m17 21-3-6h-4" />
|
||||
<path d="m17 3-3 6 1.5 3" />
|
||||
<path d="M2 12h6.5L10 9" />
|
||||
<path d="m20 10-1.5 2 1.5 2" />
|
||||
<path d="M22 12h-6.5L14 15" />
|
||||
<path d="m4 10 1.5 2L4 14" />
|
||||
<path d="m7 21 3-6-1.5-3" />
|
||||
<path d="m7 3 3 6h4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
1
app/assets/svg/icons/lucide/outline/square-pen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
|
||||
|
After Width: | Height: | Size: 445 B |
13
app/assets/svg/icons/lucide/outline/star.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 588 B |
1
app/assets/svg/icons/lucide/outline/trash-2.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
|
After Width: | Height: | Size: 398 B |
1
app/assets/svg/icons/lucide/outline/tree-palm.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-tree-palm-icon lucide-tree-palm"><path d="M13 8c0-2.76-2.46-5-5.5-5S2 5.24 2 8h2l1-1 1 1h4"/><path d="M13 7.14A5.82 5.82 0 0 1 16.5 6c3.04 0 5.5 2.24 5.5 5h-3l-1-1-1 1h-3"/><path d="M5.89 9.71c-2.15 2.15-2.3 5.47-.35 7.43l4.24-4.25.7-.7.71-.71 2.12-2.12c-1.95-1.96-5.27-1.8-7.42.35"/><path d="M11 15.5c.5 2.5-.17 4.5-1 6.5h4c2-5.5-.5-12-1-14"/></svg>
|
||||
|
After Width: | Height: | Size: 553 B |
14
app/assets/svg/icons/lucide/outline/trending-up.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 7h6v6" />
|
||||
<path d="m22 7-8.5 8.5-5-5L2 17" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
18
app/assets/svg/icons/lucide/outline/trophy.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978" />
|
||||
<path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978" />
|
||||
<path d="M18 9h1.5a1 1 0 0 0 0-5H18" />
|
||||
<path d="M4 22h16" />
|
||||
<path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z" />
|
||||
<path d="M6 9H4.5a1 1 0 0 1 0-5H6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
1
app/assets/svg/icons/lucide/outline/user.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-icon lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
1
app/assets/svg/icons/lucide/outline/users.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users-icon lucide-users"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><path d="M16 3.128a4 4 0 0 1 0 7.744"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><circle cx="9" cy="7" r="4"/></svg>
|
||||
|
After Width: | Height: | Size: 393 B |
20
app/channels/family_locations_channel.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamilyLocationsChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
return reject unless family_feature_enabled?
|
||||
return reject unless current_user.in_family?
|
||||
|
||||
stream_for current_user.family
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
# Any cleanup needed when channel is unsubscribed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def family_feature_enabled?
|
||||
DawarichSettings.family_feature_enabled?
|
||||
end
|
||||
end
|
||||
|
|
@ -15,7 +15,7 @@ class Api::V1::AreasController < ApiController
|
|||
if @area.save
|
||||
render json: @area, status: :created
|
||||
else
|
||||
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
|
||||
render json: { errors: @area.errors.full_messages }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ class Api::V1::AreasController < ApiController
|
|||
if @area.update(area_params)
|
||||
render json: @area, status: :ok
|
||||
else
|
||||
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
|
||||
render json: { errors: @area.errors.full_messages }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class Api::V1::Countries::VisitedCitiesController < ApiController
|
|||
end_at = DateTime.parse(params[:end_at]).to_i
|
||||
|
||||
points = current_api_user
|
||||
.tracked_points
|
||||
.points
|
||||
.where(timestamp: start_at..end_at)
|
||||
|
||||
render json: { data: CountriesAndCities.new(points).call }
|
||||
|
|
|
|||
24
app/controllers/api/v1/families_controller.rb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::FamiliesController < ApiController
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :ensure_user_in_family!
|
||||
|
||||
def locations
|
||||
family_locations = Families::Locations.new(current_api_user).call
|
||||
|
||||
render json: {
|
||||
locations: family_locations,
|
||||
updated_at: Time.current.iso8601,
|
||||
sharing_enabled: current_api_user.family_sharing_enabled?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_user_in_family!
|
||||
return if current_api_user.in_family?
|
||||
|
||||
render json: { error: 'User is not part of a family' }, status: :forbidden
|
||||
end
|
||||
end
|
||||
99
app/controllers/api/v1/locations_controller.rb
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::LocationsController < ApiController
|
||||
before_action :validate_search_params, only: [:index]
|
||||
before_action :validate_suggestion_params, only: [:suggestions]
|
||||
|
||||
def index
|
||||
if coordinate_search?
|
||||
search_results = LocationSearch::PointFinder.new(current_api_user, search_params).call
|
||||
|
||||
render json: Api::LocationSearchResultSerializer.new(search_results).call
|
||||
else
|
||||
render json: { error: 'Coordinates (lat, lon) are required' }, status: :bad_request
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Location search error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
render json: { error: 'Search failed. Please try again.' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def suggestions
|
||||
if search_query.present? && search_query.length >= 2
|
||||
suggestions = LocationSearch::GeocodingService.new(search_query).search
|
||||
|
||||
# Format suggestions for the frontend
|
||||
formatted_suggestions = suggestions.map do |suggestion|
|
||||
{
|
||||
name: suggestion[:name],
|
||||
address: suggestion[:address],
|
||||
coordinates: [suggestion[:lat], suggestion[:lon]],
|
||||
type: suggestion[:type]
|
||||
}
|
||||
end
|
||||
|
||||
render json: { suggestions: formatted_suggestions }
|
||||
else
|
||||
render json: { suggestions: [] }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Suggestions error: #{e.message}"
|
||||
render json: { suggestions: [] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_query
|
||||
params[:q]&.strip
|
||||
end
|
||||
|
||||
def search_params
|
||||
{
|
||||
latitude: params[:lat]&.to_f,
|
||||
longitude: params[:lon]&.to_f,
|
||||
limit: params[:limit]&.to_i || 50,
|
||||
date_from: parse_date(params[:date_from]),
|
||||
date_to: parse_date(params[:date_to]),
|
||||
radius_override: params[:radius_override]&.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def coordinate_search?
|
||||
params[:lat].present? && params[:lon].present?
|
||||
end
|
||||
|
||||
def validate_search_params
|
||||
unless coordinate_search?
|
||||
render json: { error: 'Coordinates (lat, lon) are required' }, status: :bad_request
|
||||
return false
|
||||
end
|
||||
|
||||
lat = params[:lat]&.to_f
|
||||
lon = params[:lon]&.to_f
|
||||
|
||||
if lat.abs > 90 || lon.abs > 180
|
||||
render json: { error: 'Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180' },
|
||||
status: :bad_request
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def validate_suggestion_params
|
||||
if search_query.present? && search_query.length > 200
|
||||
render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def parse_date(date_string)
|
||||
return nil if date_string.blank?
|
||||
|
||||
Date.parse(date_string)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
end
|
||||
93
app/controllers/api/v1/maps/hexagons_controller.rb
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Maps::HexagonsController < ApiController
|
||||
skip_before_action :authenticate_api_key, if: :public_sharing_request?
|
||||
|
||||
def index
|
||||
context = resolve_hexagon_context
|
||||
|
||||
result = Maps::HexagonRequestHandler.new(
|
||||
params: params,
|
||||
user: context[:user] || current_api_user,
|
||||
stat: context[:stat],
|
||||
start_date: context[:start_date],
|
||||
end_date: context[:end_date]
|
||||
).call
|
||||
|
||||
render json: result
|
||||
rescue ActionController::ParameterMissing => e
|
||||
render json: { error: "Missing required parameter: #{e.param}" }, status: :bad_request
|
||||
rescue ActionController::BadRequest => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'Shared stats not found or no longer available' }, status: :not_found
|
||||
rescue Stats::CalculateMonth::PostGISError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue StandardError => _e
|
||||
handle_service_error
|
||||
end
|
||||
|
||||
def bounds
|
||||
context = resolve_hexagon_context
|
||||
|
||||
result = Maps::BoundsCalculator.new(
|
||||
user: context[:user] || context[:target_user],
|
||||
start_date: context[:start_date],
|
||||
end_date: context[:end_date]
|
||||
).call
|
||||
|
||||
if result[:success]
|
||||
render json: result[:data]
|
||||
else
|
||||
render json: {
|
||||
error: result[:error],
|
||||
point_count: result[:point_count]
|
||||
}, status: :not_found
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
render json: { error: 'Shared stats not found or no longer available' }, status: :not_found
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
rescue Maps::BoundsCalculator::NoUserFoundError => e
|
||||
render json: { error: e.message }, status: :not_found
|
||||
rescue Maps::BoundsCalculator::NoDateRangeError => e
|
||||
render json: { error: e.message }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_hexagon_context
|
||||
return resolve_public_sharing_context if public_sharing_request?
|
||||
|
||||
resolve_authenticated_context
|
||||
end
|
||||
|
||||
def resolve_public_sharing_context
|
||||
stat = Stat.find_by(sharing_uuid: params[:uuid])
|
||||
raise ActiveRecord::RecordNotFound unless stat&.public_accessible?
|
||||
|
||||
{
|
||||
user: stat.user,
|
||||
start_date: Date.new(stat.year, stat.month, 1).beginning_of_day.iso8601,
|
||||
end_date: Date.new(stat.year, stat.month, 1).end_of_month.end_of_day.iso8601,
|
||||
stat: stat
|
||||
}
|
||||
end
|
||||
|
||||
def resolve_authenticated_context
|
||||
{
|
||||
user: current_api_user,
|
||||
start_date: params[:start_date],
|
||||
end_date: params[:end_date],
|
||||
stat: nil
|
||||
}
|
||||
end
|
||||
|
||||
def handle_service_error
|
||||
render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def public_sharing_request?
|
||||
params[:uuid].present?
|
||||
end
|
||||
end
|
||||
|
|
@ -10,7 +10,7 @@ class Api::V1::PointsController < ApiController
|
|||
order = params[:order] || 'desc'
|
||||
|
||||
points = current_api_user
|
||||
.tracked_points
|
||||
.points
|
||||
.where(timestamp: start_at..end_at)
|
||||
.order(timestamp: order)
|
||||
.page(params[:page])
|
||||
|
|
@ -31,7 +31,7 @@ class Api::V1::PointsController < ApiController
|
|||
end
|
||||
|
||||
def update
|
||||
point = current_api_user.tracked_points.find(params[:id])
|
||||
point = current_api_user.points.find(params[:id])
|
||||
|
||||
point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})")
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ class Api::V1::PointsController < ApiController
|
|||
end
|
||||
|
||||
def destroy
|
||||
point = current_api_user.tracked_points.find(params[:id])
|
||||
point = current_api_user.points.find(params[:id])
|
||||
point.destroy
|
||||
|
||||
render json: { message: 'Point deleted successfully' }
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class Api::V1::SettingsController < ApiController
|
|||
status: :ok
|
||||
else
|
||||
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@ class Api::V1::SubscriptionsController < ApiController
|
|||
render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized
|
||||
rescue ArgumentError => e
|
||||
ExceptionReporter.call(e)
|
||||
render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity
|
||||
render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@ class Api::V1::VisitsController < ApiController
|
|||
render json: serialized_visits
|
||||
end
|
||||
|
||||
def create
|
||||
service = Visits::Create.new(current_api_user, visit_params)
|
||||
|
||||
result = service.call
|
||||
|
||||
if result
|
||||
render json: Api::VisitSerializer.new(service.visit).call
|
||||
else
|
||||
error_message = service.errors || 'Failed to create visit'
|
||||
render json: { error: error_message }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
visit = update_visit(visit)
|
||||
|
|
@ -21,7 +34,7 @@ class Api::V1::VisitsController < ApiController
|
|||
# Validate that we have at least 2 visit IDs
|
||||
visit_ids = params[:visit_ids]
|
||||
if visit_ids.blank? || visit_ids.length < 2
|
||||
return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_entity
|
||||
return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
# Find all visits that belong to the current user
|
||||
|
|
@ -39,7 +52,7 @@ class Api::V1::VisitsController < ApiController
|
|||
if merged_visit&.persisted?
|
||||
render json: Api::VisitSerializer.new(merged_visit).call, status: :ok
|
||||
else
|
||||
render json: { error: service.errors.join(', ') }, status: :unprocessable_entity
|
||||
render json: { error: service.errors.join(', ') }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -58,14 +71,29 @@ class Api::V1::VisitsController < ApiController
|
|||
updated_count: result[:count]
|
||||
}, status: :ok
|
||||
else
|
||||
render json: { error: service.errors.join(', ') }, status: :unprocessable_entity
|
||||
render json: { error: service.errors.join(', ') }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
|
||||
if visit.destroy
|
||||
head :no_content
|
||||
else
|
||||
render json: {
|
||||
error: 'Failed to delete visit',
|
||||
errors: visit.errors.full_messages
|
||||
}, status: :unprocessable_content
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Visit not found' }, status: :not_found
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def visit_params
|
||||
params.require(:visit).permit(:name, :place_id, :status)
|
||||
params.require(:visit).permit(:name, :place_id, :status, :latitude, :longitude, :started_at, :ended_at)
|
||||
end
|
||||
|
||||
def merge_params
|
||||
|
|
@ -78,6 +106,8 @@ class Api::V1::VisitsController < ApiController
|
|||
|
||||
def update_visit(visit)
|
||||
visit_params.each do |key, value|
|
||||
next if %w[latitude longitude].include?(key.to_s)
|
||||
|
||||
visit[key] = value
|
||||
visit.name = visit.place.name if visit_params[:place_id].present?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
class ApplicationController < ActionController::Base
|
||||
include Pundit::Authorization
|
||||
|
||||
before_action :unread_notifications, :set_self_hosted_status
|
||||
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
||||
|
||||
before_action :unread_notifications, :set_self_hosted_status, :store_client_header
|
||||
|
||||
protected
|
||||
|
||||
|
|
@ -16,13 +18,13 @@ class ApplicationController < ActionController::Base
|
|||
def authenticate_admin!
|
||||
return if current_user&.admin?
|
||||
|
||||
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
|
||||
user_not_authorized
|
||||
end
|
||||
|
||||
def authenticate_self_hosted!
|
||||
return if DawarichSettings.self_hosted?
|
||||
|
||||
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
|
||||
user_not_authorized
|
||||
end
|
||||
|
||||
def authenticate_active_user!
|
||||
|
|
@ -34,7 +36,30 @@ class ApplicationController < ActionController::Base
|
|||
def authenticate_non_self_hosted!
|
||||
return unless DawarichSettings.self_hosted?
|
||||
|
||||
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
|
||||
user_not_authorized
|
||||
end
|
||||
|
||||
def after_sign_in_path_for(resource)
|
||||
client_type = request.headers['X-Dawarich-Client'] || session[:dawarich_client]
|
||||
|
||||
case client_type
|
||||
when 'ios'
|
||||
payload = { api_key: resource.api_key, exp: 5.minutes.from_now.to_i }
|
||||
|
||||
token = Subscription::EncodeJwtToken.new(
|
||||
payload, ENV['AUTH_JWT_SECRET_KEY']
|
||||
).call
|
||||
|
||||
ios_success_path(token:)
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_family_feature_enabled!
|
||||
return if DawarichSettings.family_feature_enabled?
|
||||
|
||||
render json: { error: 'Family feature is not enabled' }, status: :forbidden
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -42,4 +67,16 @@ class ApplicationController < ActionController::Base
|
|||
def set_self_hosted_status
|
||||
@self_hosted = DawarichSettings.self_hosted?
|
||||
end
|
||||
|
||||
def store_client_header
|
||||
return unless request.headers['X-Dawarich-Client']
|
||||
|
||||
session[:dawarich_client] = request.headers['X-Dawarich-Client']
|
||||
end
|
||||
|
||||
def user_not_authorized
|
||||
redirect_to (request.referer || root_path),
|
||||
alert: 'You are not authorized to perform this action.',
|
||||
status: :see_other
|
||||
end
|
||||
end
|
||||
|
|
|
|||
21
app/controllers/auth/ios_controller.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Auth
|
||||
class IosController < ApplicationController
|
||||
def success
|
||||
# If token is provided, this is the final callback for ASWebAuthenticationSession
|
||||
if params[:token].present?
|
||||
# ASWebAuthenticationSession will capture this URL and extract the token
|
||||
render plain: "Authentication successful! You can close this window.", status: :ok
|
||||
else
|
||||
# This should not happen with our current flow, but keeping for safety
|
||||
render json: {
|
||||
success: true,
|
||||
message: 'iOS authentication successful',
|
||||
redirect_url: root_url
|
||||
}, status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
|
@ -27,7 +27,7 @@ class ExportsController < ApplicationController
|
|||
|
||||
ExceptionReporter.call(e)
|
||||
|
||||
redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity
|
||||
redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_content
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
|
|
|||
99
app/controllers/families_controller.rb
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FamiliesController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :set_family, only: %i[show edit update destroy update_location_sharing]
|
||||
|
||||
def show
|
||||
authorize @family
|
||||
|
||||
@members = @family.members.includes(:family_membership).order(:email)
|
||||
@pending_invitations = @family.active_invitations.order(:created_at)
|
||||
|
||||
@member_count = @family.member_count
|
||||
@can_invite = @family.can_add_members?
|
||||
end
|
||||
|
||||
def new
|
||||
redirect_to family_path and return if current_user.in_family?
|
||||
|
||||
@family = Family.new
|
||||
authorize @family
|
||||
end
|
||||
|
||||
def create
|
||||
@family = Family.new(family_params)
|
||||
authorize @family
|
||||
|
||||
service = Families::Create.new(
|
||||
user: current_user,
|
||||
name: family_params[:name]
|
||||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path, notice: 'Family created successfully!'
|
||||
else
|
||||
@family = Family.new(family_params)
|
||||
|
||||
if service.errors.any?
|
||||
service.errors.each do |error|
|
||||
@family.errors.add(error.attribute, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
if service.error_message.present?
|
||||
@family.errors.add(:base, service.error_message)
|
||||
end
|
||||
|
||||
flash.now[:alert] = service.error_message || 'Failed to create family'
|
||||
render :new, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @family
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @family
|
||||
|
||||
if @family.update(family_params)
|
||||
redirect_to family_path, notice: 'Family updated successfully!'
|
||||
else
|
||||
render :edit, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @family
|
||||
|
||||
if @family.members.count > 1
|
||||
redirect_to family_path, alert: 'Cannot delete family with members. Remove all members first.'
|
||||
else
|
||||
@family.destroy
|
||||
redirect_to new_family_path, notice: 'Family deleted successfully!'
|
||||
end
|
||||
end
|
||||
|
||||
def update_location_sharing
|
||||
result = Families::UpdateLocationSharing.new(
|
||||
user: current_user,
|
||||
enabled: params[:enabled],
|
||||
duration: params[:duration]
|
||||
).call
|
||||
|
||||
render json: result.payload, status: result.status
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_family
|
||||
@family = current_user.family
|
||||
redirect_to new_family_path, alert: 'You are not in a family' unless @family
|
||||
end
|
||||
|
||||
def family_params
|
||||
params.require(:family).permit(:name)
|
||||
end
|
||||
end
|
||||
76
app/controllers/family/invitations_controller.rb
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::InvitationsController < ApplicationController
|
||||
before_action :authenticate_user!, except: %i[show]
|
||||
before_action :ensure_family_feature_enabled!, except: %i[show]
|
||||
before_action :set_family, except: %i[show]
|
||||
before_action :set_invitation_by_id_and_family, only: %i[destroy]
|
||||
|
||||
def index
|
||||
authorize @family, :show?
|
||||
|
||||
@pending_invitations = @family.family_invitations.active
|
||||
end
|
||||
|
||||
def show
|
||||
@invitation = Family::Invitation.find_by!(token: params[:token])
|
||||
|
||||
if @invitation.expired?
|
||||
redirect_to root_path, alert: 'This invitation has expired.' and return
|
||||
end
|
||||
|
||||
unless @invitation.pending?
|
||||
redirect_to root_path, alert: 'This invitation is no longer valid.' and return
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
authorize @family, :invite?
|
||||
|
||||
service = Families::Invite.new(
|
||||
family: @family,
|
||||
email: invitation_params[:email],
|
||||
invited_by: current_user
|
||||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path, notice: 'Invitation sent successfully!'
|
||||
else
|
||||
redirect_to family_path, alert: service.error_message || 'Failed to send invitation'
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @family, :manage_invitations?
|
||||
|
||||
begin
|
||||
if @invitation.update(status: :cancelled)
|
||||
redirect_to family_path, notice: 'Invitation cancelled'
|
||||
else
|
||||
redirect_to family_path, alert: 'Failed to cancel invitation. Please try again'
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error cancelling family invitation: #{e.message}"
|
||||
redirect_to family_path, alert: 'An unexpected error occurred while cancelling the invitation'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_family
|
||||
@family = current_user.family
|
||||
|
||||
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
|
||||
end
|
||||
|
||||
def set_invitation_by_id_and_family
|
||||
# For authenticated nested routes: /families/:family_id/invitations/:id
|
||||
# The :id param contains the token value
|
||||
@family = current_user.family
|
||||
@invitation = @family.family_invitations.find_by!(token: params[:id])
|
||||
end
|
||||
|
||||
def invitation_params
|
||||
params.require(:family_invitation).permit(:email)
|
||||
end
|
||||
end
|
||||
70
app/controllers/family/memberships_controller.rb
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Family::MembershipsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_family_feature_enabled!
|
||||
before_action :set_family, except: %i[create]
|
||||
before_action :set_membership, only: %i[destroy]
|
||||
before_action :set_invitation, only: %i[create]
|
||||
|
||||
def create
|
||||
authorize @invitation, policy_class: Family::MembershipPolicy
|
||||
|
||||
service = Families::AcceptInvitation.new(
|
||||
invitation: @invitation,
|
||||
user: current_user
|
||||
)
|
||||
|
||||
if service.call
|
||||
redirect_to family_path, notice: 'Welcome to the family!'
|
||||
else
|
||||
redirect_to root_path, alert: service.error_message || 'Unable to accept invitation'
|
||||
end
|
||||
rescue Pundit::NotAuthorizedError
|
||||
if @invitation.expired?
|
||||
redirect_to root_path, alert: 'This invitation is no longer valid or has expired'
|
||||
elsif !@invitation.pending?
|
||||
redirect_to root_path, alert: 'This invitation has already been processed'
|
||||
elsif @invitation.email != current_user.email
|
||||
redirect_to root_path, alert: 'This invitation is not for your email address'
|
||||
else
|
||||
redirect_to root_path, alert: 'You are not authorized to accept this invitation'
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error accepting family invitation: #{e.message}"
|
||||
redirect_to root_path, alert: 'An unexpected error occurred. Please try again later'
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @membership
|
||||
|
||||
member_user = @membership.user
|
||||
service = Families::Memberships::Destroy.new(user: current_user, member_to_remove: member_user)
|
||||
|
||||
if service.call
|
||||
if member_user == current_user
|
||||
redirect_to new_family_path, notice: 'You have left the family'
|
||||
else
|
||||
redirect_to family_path, notice: "#{member_user.email} has been removed from the family"
|
||||
end
|
||||
else
|
||||
redirect_to family_path, alert: service.error_message || 'Failed to remove member'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_family
|
||||
@family = current_user.family
|
||||
|
||||
redirect_to new_family_path, alert: 'You are not in a family' and return unless @family
|
||||
end
|
||||
|
||||
def set_membership
|
||||
@membership = @family.family_memberships.find(params[:id])
|
||||
end
|
||||
|
||||
def set_invitation
|
||||
@invitation = Family::Invitation.find_by!(token: params[:token])
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,6 @@ class HomeController < ApplicationController
|
|||
|
||||
redirect_to map_url if current_user
|
||||
|
||||
@points = current_user.tracked_points.without_raw_data if current_user
|
||||
@points = current_user.points.without_raw_data if current_user
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,8 +43,7 @@ class ImportsController < ApplicationController
|
|||
raw_files = Array(files_params).reject(&:blank?)
|
||||
|
||||
if raw_files.empty?
|
||||
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity
|
||||
return
|
||||
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_content and return
|
||||
end
|
||||
|
||||
created_imports = []
|
||||
|
|
@ -59,11 +58,11 @@ class ImportsController < ApplicationController
|
|||
if created_imports.any?
|
||||
redirect_to imports_url,
|
||||
notice: "#{created_imports.size} files are queued to be imported in background",
|
||||
status: :see_other
|
||||
status: :see_other and return
|
||||
else
|
||||
redirect_to new_import_path,
|
||||
alert: 'No valid file references were found. Please upload files using the file selector.',
|
||||
status: :unprocessable_entity
|
||||
status: :unprocessable_content and return
|
||||
end
|
||||
rescue StandardError => e
|
||||
if created_imports.present?
|
||||
|
|
@ -75,7 +74,7 @@ class ImportsController < ApplicationController
|
|||
Rails.logger.error e.backtrace.join("\n")
|
||||
ExceptionReporter.call(e)
|
||||
|
||||
redirect_to new_import_path, alert: e.message, status: :unprocessable_entity
|
||||
redirect_to new_import_path, alert: e.message, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
|
@ -95,7 +94,7 @@ class ImportsController < ApplicationController
|
|||
end
|
||||
|
||||
def import_params
|
||||
params.require(:import).permit(:name, :source, files: [])
|
||||
params.require(:import).permit(:name, files: [])
|
||||
end
|
||||
|
||||
def create_import_from_signed_id(signed_id)
|
||||
|
|
@ -103,11 +102,8 @@ class ImportsController < ApplicationController
|
|||
|
||||
blob = ActiveStorage::Blob.find_signed(signed_id)
|
||||
|
||||
import = current_user.imports.build(
|
||||
name: blob.filename.to_s,
|
||||
source: params[:import][:source]
|
||||
)
|
||||
|
||||
import_name = generate_unique_import_name(blob.filename.to_s)
|
||||
import = current_user.imports.build(name: import_name)
|
||||
import.file.attach(blob)
|
||||
|
||||
import.save!
|
||||
|
|
@ -115,9 +111,21 @@ class ImportsController < ApplicationController
|
|||
import
|
||||
end
|
||||
|
||||
def generate_unique_import_name(original_name)
|
||||
return original_name unless current_user.imports.exists?(name: original_name)
|
||||
|
||||
# Extract filename and extension
|
||||
basename = File.basename(original_name, File.extname(original_name))
|
||||
extension = File.extname(original_name)
|
||||
|
||||
# Add current datetime
|
||||
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
|
||||
"#{basename}_#{timestamp}#{extension}"
|
||||
end
|
||||
|
||||
def validate_points_limit
|
||||
limit_exceeded = PointsLimitExceeded.new(current_user).call
|
||||
|
||||
redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_entity if limit_exceeded
|
||||
redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_content if limit_exceeded
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class MapController < ApplicationController
|
|||
@end_at = parsed_end_at
|
||||
@years = years_range
|
||||
@points_number = points_count
|
||||
@features = DawarichSettings.features
|
||||
end
|
||||
|
||||
private
|
||||
|
|
@ -37,6 +38,8 @@ class MapController < ApplicationController
|
|||
end
|
||||
|
||||
def calculate_distance
|
||||
return 0 if @coordinates.size < 2
|
||||
|
||||
total_distance = 0
|
||||
|
||||
@coordinates.each_cons(2) do
|
||||
|
|
@ -89,6 +92,6 @@ class MapController < ApplicationController
|
|||
end
|
||||
|
||||
def points_from_user
|
||||
current_user.tracked_points.without_raw_data.order(timestamp: :asc)
|
||||
current_user.points.without_raw_data.order(timestamp: :asc)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class PointsController < ApplicationController
|
|||
alert: 'No points selected.',
|
||||
status: :see_other and return if point_ids.blank?
|
||||
|
||||
current_user.tracked_points.where(id: point_ids).destroy_all
|
||||
current_user.points.where(id: point_ids).destroy_all
|
||||
|
||||
redirect_to points_url(preserved_params),
|
||||
notice: 'Points were successfully destroyed.',
|
||||
|
|
@ -58,7 +58,7 @@ class PointsController < ApplicationController
|
|||
end
|
||||
|
||||
def user_points
|
||||
current_user.tracked_points
|
||||
current_user.points
|
||||
end
|
||||
|
||||
def order_by
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Settings::UsersController < ApplicationController
|
||||
before_action :authenticate_self_hosted!, except: [:export, :import]
|
||||
before_action :authenticate_admin!, except: [:export, :import]
|
||||
before_action :authenticate_self_hosted!, except: %i[export import]
|
||||
before_action :authenticate_user!
|
||||
before_action :authenticate_admin!, except: %i[export import]
|
||||
|
||||
def index
|
||||
@users = User.order(created_at: :desc)
|
||||
|
|
@ -19,7 +19,7 @@ class Settings::UsersController < ApplicationController
|
|||
if @user.update(user_params)
|
||||
redirect_to settings_users_url, notice: 'User was successfully updated.'
|
||||
else
|
||||
redirect_to settings_users_url, notice: 'User could not be updated.', status: :unprocessable_entity
|
||||
redirect_to settings_users_url, notice: 'User could not be updated.', status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ class Settings::UsersController < ApplicationController
|
|||
if @user.save
|
||||
redirect_to settings_users_url, notice: 'User was successfully created'
|
||||
else
|
||||
redirect_to settings_users_url, notice: 'User could not be created.', status: :unprocessable_entity
|
||||
redirect_to settings_users_url, notice: 'User could not be created.', status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ class Settings::UsersController < ApplicationController
|
|||
if @user.destroy
|
||||
redirect_to settings_url, notice: 'User was successfully deleted.'
|
||||
else
|
||||
redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_entity
|
||||
redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -54,21 +54,13 @@ class Settings::UsersController < ApplicationController
|
|||
end
|
||||
|
||||
def import
|
||||
unless params[:archive].present?
|
||||
if params[:archive].blank?
|
||||
redirect_to edit_user_registration_path, alert: 'Please select a ZIP archive to import.'
|
||||
return
|
||||
end
|
||||
|
||||
archive_file = params[:archive]
|
||||
|
||||
validate_archive_file(archive_file)
|
||||
|
||||
import = current_user.imports.build(
|
||||
name: archive_file.original_filename,
|
||||
source: :user_data_archive
|
||||
)
|
||||
|
||||
import.file.attach(archive_file)
|
||||
import =
|
||||
create_import_from_signed_archive_id(params[:archive])
|
||||
|
||||
if import.save
|
||||
redirect_to edit_user_registration_path,
|
||||
|
|
@ -89,12 +81,49 @@ class Settings::UsersController < ApplicationController
|
|||
params.require(:user).permit(:email, :password)
|
||||
end
|
||||
|
||||
def create_import_from_signed_archive_id(signed_id)
|
||||
Rails.logger.debug "Creating archive import from signed ID: #{signed_id[0..20]}..."
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(signed_id)
|
||||
|
||||
# Validate that it's a ZIP file
|
||||
validate_blob_file_type(blob)
|
||||
|
||||
import_name = generate_unique_import_name(blob.filename.to_s)
|
||||
import = current_user.imports.build(
|
||||
name: import_name,
|
||||
source: :user_data_archive
|
||||
)
|
||||
import.file.attach(blob)
|
||||
|
||||
import
|
||||
end
|
||||
|
||||
def generate_unique_import_name(original_name)
|
||||
return original_name unless current_user.imports.exists?(name: original_name)
|
||||
|
||||
# Extract filename and extension
|
||||
basename = File.basename(original_name, File.extname(original_name))
|
||||
extension = File.extname(original_name)
|
||||
|
||||
# Add current datetime
|
||||
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
|
||||
"#{basename}_#{timestamp}#{extension}"
|
||||
end
|
||||
|
||||
def validate_archive_file(archive_file)
|
||||
unless archive_file.content_type == 'application/zip' ||
|
||||
archive_file.content_type == 'application/x-zip-compressed' ||
|
||||
unless ['application/zip', 'application/x-zip-compressed'].include?(archive_file.content_type) ||
|
||||
File.extname(archive_file.original_filename).downcase == '.zip'
|
||||
|
||||
redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.' and return
|
||||
end
|
||||
end
|
||||
|
||||
def validate_blob_file_type(blob)
|
||||
unless ['application/zip', 'application/x-zip-compressed'].include?(blob.content_type) ||
|
||||
File.extname(blob.filename.to_s).downcase == '.zip'
|
||||
|
||||
raise StandardError, 'Please upload a valid ZIP file.'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
55
app/controllers/shared/stats_controller.rb
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Shared::StatsController < ApplicationController
|
||||
before_action :authenticate_user!, except: [:show]
|
||||
before_action :authenticate_active_user!, only: [:update]
|
||||
|
||||
def show
|
||||
@stat = Stat.find_by(sharing_uuid: params[:uuid])
|
||||
|
||||
unless @stat&.public_accessible?
|
||||
return redirect_to root_path,
|
||||
alert: 'Shared stats not found or no longer available'
|
||||
end
|
||||
|
||||
@year = @stat.year
|
||||
@month = @stat.month
|
||||
@user = @stat.user
|
||||
@is_public_view = true
|
||||
@data_bounds = @stat.calculate_data_bounds
|
||||
@hexagons_available = @stat.hexagons_available?
|
||||
|
||||
render 'stats/public_month'
|
||||
end
|
||||
|
||||
def update
|
||||
@year = params[:year].to_i
|
||||
@month = params[:month].to_i
|
||||
@stat = current_user.stats.find_by(year: @year, month: @month)
|
||||
|
||||
return head :not_found unless @stat
|
||||
|
||||
if params[:enabled] == '1'
|
||||
@stat.enable_sharing!(expiration: params[:expiration] || '24h')
|
||||
sharing_url = shared_stat_url(@stat.sharing_uuid)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
sharing_url: sharing_url,
|
||||
message: 'Sharing enabled successfully'
|
||||
}
|
||||
else
|
||||
@stat.disable_sharing!
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message: 'Sharing disabled successfully'
|
||||
}
|
||||
end
|
||||
rescue StandardError
|
||||
render json: {
|
||||
success: false,
|
||||
message: 'Failed to update sharing settings'
|
||||
}, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||