Spaces:
Running
Running
migrate from angular to flask
Browse files- .editorconfig +0 -16
- .gitignore +40 -36
- Dockerfile +15 -18
- README.md +55 -8
- angular.json +0 -100
- app.py +153 -0
- docker-compose.yml +4 -6
- models.py +128 -0
- package-lock.json +0 -0
- package.json +0 -45
- requirements.txt +5 -0
- server.ts +0 -56
- src/app/app.component.html +0 -41
- src/app/app.component.spec.ts +0 -29
- src/app/app.component.ts +0 -58
- src/app/app.config.server.ts +0 -11
- src/app/app.config.ts +0 -16
- src/app/app.routes.ts +0 -3
- src/app/models/pesca.model.ts +0 -9
- src/assets/.gitkeep +0 -0
- src/favicon.ico +0 -3
- src/index.html +0 -13
- src/main.server.ts +0 -7
- src/main.ts +0 -6
- src/styles.css +0 -1
- static/css/styles.css +334 -0
- src/app/app.component.css → static/favicon.ico +0 -0
- static/index.html +63 -0
- static/js/api.js +66 -0
- static/js/app.js +79 -0
- static/js/components/capture-form.js +176 -0
- static/js/components/confirmation.js +100 -0
- static/js/components/history.js +96 -0
- static/js/components/home.js +89 -0
- static/js/components/species-selector.js +102 -0
- static/js/db.js +68 -0
- static/js/geolocation.js +89 -0
- static/js/sync.js +103 -0
- static/manifest.json +29 -0
- static/sw.js +131 -0
- tsconfig.app.json +0 -18
- tsconfig.json +0 -32
- tsconfig.spec.json +0 -14
.editorconfig
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
# Editor configuration, see https://editorconfig.org
|
| 2 |
-
root = true
|
| 3 |
-
|
| 4 |
-
[*]
|
| 5 |
-
charset = utf-8
|
| 6 |
-
indent_style = space
|
| 7 |
-
indent_size = 2
|
| 8 |
-
insert_final_newline = true
|
| 9 |
-
trim_trailing_whitespace = true
|
| 10 |
-
|
| 11 |
-
[*.ts]
|
| 12 |
-
quote_type = single
|
| 13 |
-
|
| 14 |
-
[*.md]
|
| 15 |
-
max_line_length = off
|
| 16 |
-
trim_trailing_whitespace = false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
CHANGED
|
@@ -1,42 +1,46 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
#
|
| 4 |
-
/
|
| 5 |
-
|
| 6 |
-
/out-tsc
|
| 7 |
-
/bazel-out
|
| 8 |
|
| 9 |
-
#
|
| 10 |
-
/
|
| 11 |
-
npm-debug.log
|
| 12 |
-
yarn-error.log
|
| 13 |
-
|
| 14 |
-
# IDEs and editors
|
| 15 |
.idea/
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
*.launch
|
| 20 |
-
.settings/
|
| 21 |
-
*.sublime-workspace
|
| 22 |
-
|
| 23 |
-
# Visual Studio Code
|
| 24 |
-
.vscode/*
|
| 25 |
-
!.vscode/settings.json
|
| 26 |
-
!.vscode/tasks.json
|
| 27 |
-
!.vscode/launch.json
|
| 28 |
-
!.vscode/extensions.json
|
| 29 |
-
.history/*
|
| 30 |
-
|
| 31 |
-
# Miscellaneous
|
| 32 |
-
/.angular/cache
|
| 33 |
-
.sass-cache/
|
| 34 |
-
/connect.lock
|
| 35 |
-
/coverage
|
| 36 |
-
/libpeerconnection.log
|
| 37 |
-
testem.log
|
| 38 |
-
/typings
|
| 39 |
|
| 40 |
-
#
|
| 41 |
.DS_Store
|
| 42 |
Thumbs.db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
env/
|
| 8 |
+
venv/
|
| 9 |
+
ENV/
|
| 10 |
+
build/
|
| 11 |
+
develop-eggs/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
|
| 26 |
+
# Flask
|
| 27 |
+
instance/
|
| 28 |
+
.webassets-cache
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
# IDEs
|
| 31 |
+
.vscode/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
.idea/
|
| 33 |
+
*.swp
|
| 34 |
+
*.swo
|
| 35 |
+
*~
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
# OS
|
| 38 |
.DS_Store
|
| 39 |
Thumbs.db
|
| 40 |
+
|
| 41 |
+
# Logs
|
| 42 |
+
*.log
|
| 43 |
+
|
| 44 |
+
# Environment
|
| 45 |
+
.env
|
| 46 |
+
.env.local
|
Dockerfile
CHANGED
|
@@ -1,26 +1,23 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
WORKDIR /app
|
| 4 |
-
|
| 5 |
-
# Instalar dependencias
|
| 6 |
-
COPY package*.json ./
|
| 7 |
-
RUN npm install
|
| 8 |
|
| 9 |
-
|
| 10 |
-
COPY . .
|
| 11 |
-
RUN npx ng build --configuration production
|
| 12 |
|
| 13 |
-
# ETAPA 2: Ejecución (Hugging Face)
|
| 14 |
-
FROM node:20-alpine
|
| 15 |
WORKDIR /app
|
| 16 |
|
| 17 |
-
#
|
| 18 |
-
COPY
|
|
|
|
| 19 |
|
| 20 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
ENV PORT=7860
|
|
|
|
| 22 |
EXPOSE 7860
|
| 23 |
|
| 24 |
-
#
|
| 25 |
-
|
| 26 |
-
CMD ["node", "dist/mi-pesca-rd/server/server.mjs"]
|
|
|
|
| 1 |
+
# Hugging Face Spaces Configuration
|
| 2 |
+
# Mi Pesca RD - Flask PWA
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
FROM python:3.11-slim
|
|
|
|
|
|
|
| 5 |
|
|
|
|
|
|
|
| 6 |
WORKDIR /app
|
| 7 |
|
| 8 |
+
# Copy requirements and install dependencies
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
|
| 12 |
+
# Copy application files
|
| 13 |
+
COPY app.py .
|
| 14 |
+
COPY models.py .
|
| 15 |
+
COPY static/ ./static/
|
| 16 |
+
|
| 17 |
+
# Hugging Face Spaces requires port 7860
|
| 18 |
ENV PORT=7860
|
| 19 |
+
|
| 20 |
EXPOSE 7860
|
| 21 |
|
| 22 |
+
# Run with Gunicorn for production
|
| 23 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "2", "--timeout", "120", "app:app"]
|
|
|
README.md
CHANGED
|
@@ -1,8 +1,55 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mi Pesca RD
|
| 2 |
+
|
| 3 |
+
Aplicación PWA offline-first para registro de capturas pesqueras en República Dominicana.
|
| 4 |
+
|
| 5 |
+
## Características
|
| 6 |
+
|
| 7 |
+
- 🎣 Registro rápido de capturas (3 clics)
|
| 8 |
+
- 📍 Captura automática de coordenadas GPS
|
| 9 |
+
- 📱 Funciona sin conexión (offline-first)
|
| 10 |
+
- 🔄 Sincronización automática cuando hay conexión
|
| 11 |
+
- 🐟 Selector visual de especies con fotos
|
| 12 |
+
- 📊 Historial de capturas
|
| 13 |
+
- 🌊 Diseñado para uso en ambientes marinos
|
| 14 |
+
|
| 15 |
+
## Tecnologías
|
| 16 |
+
|
| 17 |
+
- **Backend**: Flask (Python)
|
| 18 |
+
- **Frontend**: HTML/CSS/JavaScript vanilla
|
| 19 |
+
- **Base de datos local**: IndexedDB (Dexie.js)
|
| 20 |
+
- **PWA**: Service Worker para funcionalidad offline
|
| 21 |
+
- **Despliegue**: Docker (Hugging Face Spaces)
|
| 22 |
+
|
| 23 |
+
## Instalación Local
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# Instalar dependencias
|
| 27 |
+
pip install -r requirements.txt
|
| 28 |
+
|
| 29 |
+
# Ejecutar aplicación
|
| 30 |
+
python app.py
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
La aplicación estará disponible en `http://localhost:7860`
|
| 34 |
+
|
| 35 |
+
## Docker
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
# Construir imagen
|
| 39 |
+
docker build -t mipesca .
|
| 40 |
+
|
| 41 |
+
# Ejecutar contenedor
|
| 42 |
+
docker run -p 7860:7860 mipesca
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
## Hugging Face Spaces
|
| 46 |
+
|
| 47 |
+
Este proyecto está configurado para ejecutarse en Hugging Face Spaces:
|
| 48 |
+
|
| 49 |
+
- SDK: Docker
|
| 50 |
+
- Puerto: 7860
|
| 51 |
+
- Arquitectura: Offline-first PWA
|
| 52 |
+
|
| 53 |
+
## Licencia
|
| 54 |
+
|
| 55 |
+
MIT
|
angular.json
DELETED
|
@@ -1,100 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
| 3 |
-
"version": 1,
|
| 4 |
-
"newProjectRoot": "projects",
|
| 5 |
-
"projects": {
|
| 6 |
-
"mi-pesca-rd": {
|
| 7 |
-
"projectType": "application",
|
| 8 |
-
"schematics": {},
|
| 9 |
-
"root": "",
|
| 10 |
-
"sourceRoot": "src",
|
| 11 |
-
"prefix": "app",
|
| 12 |
-
"architect": {
|
| 13 |
-
"build": {
|
| 14 |
-
"builder": "@angular-devkit/build-angular:application",
|
| 15 |
-
"options": {
|
| 16 |
-
"outputPath": "dist/mi-pesca-rd",
|
| 17 |
-
"index": "src/index.html",
|
| 18 |
-
"browser": "src/main.ts",
|
| 19 |
-
"polyfills": [
|
| 20 |
-
"zone.js"
|
| 21 |
-
],
|
| 22 |
-
"tsConfig": "tsconfig.app.json",
|
| 23 |
-
"assets": [
|
| 24 |
-
"src/favicon.ico",
|
| 25 |
-
"src/assets"
|
| 26 |
-
],
|
| 27 |
-
"styles": [
|
| 28 |
-
"src/styles.css"
|
| 29 |
-
],
|
| 30 |
-
"scripts": [],
|
| 31 |
-
"server": "src/main.server.ts",
|
| 32 |
-
"prerender": true,
|
| 33 |
-
"ssr": {
|
| 34 |
-
"entry": "server.ts"
|
| 35 |
-
}
|
| 36 |
-
},
|
| 37 |
-
"configurations": {
|
| 38 |
-
"production": {
|
| 39 |
-
"budgets": [
|
| 40 |
-
{
|
| 41 |
-
"type": "initial",
|
| 42 |
-
"maximumWarning": "500kb",
|
| 43 |
-
"maximumError": "1mb"
|
| 44 |
-
},
|
| 45 |
-
{
|
| 46 |
-
"type": "anyComponentStyle",
|
| 47 |
-
"maximumWarning": "2kb",
|
| 48 |
-
"maximumError": "4kb"
|
| 49 |
-
}
|
| 50 |
-
],
|
| 51 |
-
"outputHashing": "all"
|
| 52 |
-
},
|
| 53 |
-
"development": {
|
| 54 |
-
"optimization": false,
|
| 55 |
-
"extractLicenses": false,
|
| 56 |
-
"sourceMap": true
|
| 57 |
-
}
|
| 58 |
-
},
|
| 59 |
-
"defaultConfiguration": "production"
|
| 60 |
-
},
|
| 61 |
-
"serve": {
|
| 62 |
-
"builder": "@angular-devkit/build-angular:dev-server",
|
| 63 |
-
"configurations": {
|
| 64 |
-
"production": {
|
| 65 |
-
"buildTarget": "mi-pesca-rd:build:production"
|
| 66 |
-
},
|
| 67 |
-
"development": {
|
| 68 |
-
"buildTarget": "mi-pesca-rd:build:development"
|
| 69 |
-
}
|
| 70 |
-
},
|
| 71 |
-
"defaultConfiguration": "development"
|
| 72 |
-
},
|
| 73 |
-
"extract-i18n": {
|
| 74 |
-
"builder": "@angular-devkit/build-angular:extract-i18n",
|
| 75 |
-
"options": {
|
| 76 |
-
"buildTarget": "mi-pesca-rd:build"
|
| 77 |
-
}
|
| 78 |
-
},
|
| 79 |
-
"test": {
|
| 80 |
-
"builder": "@angular-devkit/build-angular:karma",
|
| 81 |
-
"options": {
|
| 82 |
-
"polyfills": [
|
| 83 |
-
"zone.js",
|
| 84 |
-
"zone.js/testing"
|
| 85 |
-
],
|
| 86 |
-
"tsConfig": "tsconfig.spec.json",
|
| 87 |
-
"assets": [
|
| 88 |
-
"src/favicon.ico",
|
| 89 |
-
"src/assets"
|
| 90 |
-
],
|
| 91 |
-
"styles": [
|
| 92 |
-
"src/styles.css"
|
| 93 |
-
],
|
| 94 |
-
"scripts": []
|
| 95 |
-
}
|
| 96 |
-
}
|
| 97 |
-
}
|
| 98 |
-
}
|
| 99 |
-
}
|
| 100 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flask backend for Mi Pesca RD application."""
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from flask import Flask, request, jsonify, send_from_directory
|
| 6 |
+
from flask_cors import CORS
|
| 7 |
+
from models import Capture, Species, ClosedSeason
|
| 8 |
+
|
| 9 |
+
app = Flask(__name__, static_folder='static', static_url_path='')
|
| 10 |
+
CORS(app)
|
| 11 |
+
|
| 12 |
+
# Sample species data (in production, this would come from a database)
|
| 13 |
+
SPECIES_DATA = [
|
| 14 |
+
Species(
|
| 15 |
+
id='chillo',
|
| 16 |
+
commonName='Chillo',
|
| 17 |
+
scientificName='Lutjanus campechanus',
|
| 18 |
+
category='Fondo',
|
| 19 |
+
imageUrl='/assets/species/chillo.jpg',
|
| 20 |
+
protected=False,
|
| 21 |
+
closedSeason=ClosedSeason(
|
| 22 |
+
start='04-01',
|
| 23 |
+
end='06-30',
|
| 24 |
+
description='Veda de reproducción'
|
| 25 |
+
)
|
| 26 |
+
),
|
| 27 |
+
Species(
|
| 28 |
+
id='dorado',
|
| 29 |
+
commonName='Dorado',
|
| 30 |
+
scientificName='Coryphaena hippurus',
|
| 31 |
+
category='Pelágico',
|
| 32 |
+
imageUrl='/assets/species/dorado.jpg',
|
| 33 |
+
protected=False
|
| 34 |
+
),
|
| 35 |
+
Species(
|
| 36 |
+
id='langosta',
|
| 37 |
+
commonName='Langosta del Caribe',
|
| 38 |
+
scientificName='Panulirus argus',
|
| 39 |
+
category='Invertebrado',
|
| 40 |
+
imageUrl='/assets/species/langosta.jpg',
|
| 41 |
+
protected=False,
|
| 42 |
+
closedSeason=ClosedSeason(
|
| 43 |
+
start='03-01',
|
| 44 |
+
end='06-30',
|
| 45 |
+
description='Veda de reproducción'
|
| 46 |
+
)
|
| 47 |
+
),
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
# In-memory storage for captures (in production, use a database)
|
| 51 |
+
captures_storage = []
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@app.route('/')
|
| 55 |
+
def index():
|
| 56 |
+
"""Serve the main HTML page."""
|
| 57 |
+
return send_from_directory('static', 'index.html')
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.route('/api/species', methods=['GET'])
|
| 61 |
+
def get_species():
|
| 62 |
+
"""Get list of all species."""
|
| 63 |
+
return jsonify([species.to_dict() for species in SPECIES_DATA])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@app.route('/api/captures', methods=['POST'])
|
| 67 |
+
def create_capture():
|
| 68 |
+
"""Receive and store a new capture."""
|
| 69 |
+
try:
|
| 70 |
+
data = request.get_json()
|
| 71 |
+
|
| 72 |
+
# Create Capture object from request data
|
| 73 |
+
capture = Capture.from_dict(data)
|
| 74 |
+
|
| 75 |
+
# TODO: Enrich with environmental data (temperature, sea state)
|
| 76 |
+
# This would call external APIs like NOAA or OpenWeatherMap
|
| 77 |
+
|
| 78 |
+
# Store capture (in production, save to database)
|
| 79 |
+
captures_storage.append(capture)
|
| 80 |
+
|
| 81 |
+
# Log for debugging
|
| 82 |
+
print(f"Received capture: {capture.id} at {capture.latitude}, {capture.longitude}")
|
| 83 |
+
|
| 84 |
+
return jsonify({
|
| 85 |
+
'success': True,
|
| 86 |
+
'id': capture.id,
|
| 87 |
+
'message': 'Captura registrada exitosamente'
|
| 88 |
+
}), 201
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
print(f"Error processing capture: {str(e)}")
|
| 92 |
+
return jsonify({
|
| 93 |
+
'success': False,
|
| 94 |
+
'error': str(e)
|
| 95 |
+
}), 400
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.route('/api/sync', methods=['POST'])
|
| 99 |
+
def sync_captures():
|
| 100 |
+
"""Batch synchronization endpoint for multiple captures."""
|
| 101 |
+
try:
|
| 102 |
+
data = request.get_json()
|
| 103 |
+
captures = data.get('captures', [])
|
| 104 |
+
|
| 105 |
+
synced_ids = []
|
| 106 |
+
errors = []
|
| 107 |
+
|
| 108 |
+
for capture_data in captures:
|
| 109 |
+
try:
|
| 110 |
+
capture = Capture.from_dict(capture_data)
|
| 111 |
+
captures_storage.append(capture)
|
| 112 |
+
synced_ids.append(capture.id)
|
| 113 |
+
except Exception as e:
|
| 114 |
+
errors.append({
|
| 115 |
+
'id': capture_data.get('id'),
|
| 116 |
+
'error': str(e)
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
return jsonify({
|
| 120 |
+
'success': True,
|
| 121 |
+
'synced': synced_ids,
|
| 122 |
+
'errors': errors,
|
| 123 |
+
'total': len(synced_ids)
|
| 124 |
+
}), 200
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
return jsonify({
|
| 128 |
+
'success': False,
|
| 129 |
+
'error': str(e)
|
| 130 |
+
}), 400
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@app.route('/api/captures', methods=['GET'])
|
| 134 |
+
def get_captures():
|
| 135 |
+
"""Get all stored captures (for admin/debugging)."""
|
| 136 |
+
return jsonify([capture.to_dict() for capture in captures_storage])
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@app.route('/health', methods=['GET'])
|
| 140 |
+
def health_check():
|
| 141 |
+
"""Health check endpoint."""
|
| 142 |
+
return jsonify({
|
| 143 |
+
'status': 'healthy',
|
| 144 |
+
'timestamp': datetime.now().isoformat()
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
if __name__ == '__main__':
|
| 149 |
+
# Get port from environment variable (Hugging Face uses 7860)
|
| 150 |
+
port = int(os.environ.get('PORT', 7860))
|
| 151 |
+
|
| 152 |
+
# Run the Flask app
|
| 153 |
+
app.run(host='0.0.0.0', port=port, debug=False)
|
docker-compose.yml
CHANGED
|
@@ -1,11 +1,9 @@
|
|
| 1 |
version: '3.8'
|
|
|
|
| 2 |
services:
|
| 3 |
-
|
| 4 |
build: .
|
| 5 |
-
volumes:
|
| 6 |
-
- .:/app
|
| 7 |
ports:
|
| 8 |
-
- "4200:4200"
|
| 9 |
- "7860:7860"
|
| 10 |
-
|
| 11 |
-
|
|
|
|
| 1 |
version: '3.8'
|
| 2 |
+
|
| 3 |
services:
|
| 4 |
+
mipesca:
|
| 5 |
build: .
|
|
|
|
|
|
|
| 6 |
ports:
|
|
|
|
| 7 |
- "7860:7860"
|
| 8 |
+
environment:
|
| 9 |
+
- PORT=7860
|
models.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models for Mi Pesca RD application."""
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import List, Optional, Literal
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class ClosedSeason:
|
| 9 |
+
"""Closed season information for a species."""
|
| 10 |
+
start: str # MM-DD format
|
| 11 |
+
end: str # MM-DD format
|
| 12 |
+
description: str
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class Species:
|
| 17 |
+
"""Species data model."""
|
| 18 |
+
id: str
|
| 19 |
+
commonName: str
|
| 20 |
+
scientificName: str
|
| 21 |
+
category: Literal['Fondo', 'Pelágico', 'Invertebrado']
|
| 22 |
+
imageUrl: str
|
| 23 |
+
protected: bool
|
| 24 |
+
closedSeason: Optional[ClosedSeason] = None
|
| 25 |
+
|
| 26 |
+
def to_dict(self):
|
| 27 |
+
"""Convert to dictionary for JSON serialization."""
|
| 28 |
+
data = {
|
| 29 |
+
'id': self.id,
|
| 30 |
+
'commonName': self.commonName,
|
| 31 |
+
'scientificName': self.scientificName,
|
| 32 |
+
'category': self.category,
|
| 33 |
+
'imageUrl': self.imageUrl,
|
| 34 |
+
'protected': self.protected
|
| 35 |
+
}
|
| 36 |
+
if self.closedSeason:
|
| 37 |
+
data['closedSeason'] = {
|
| 38 |
+
'start': self.closedSeason.start,
|
| 39 |
+
'end': self.closedSeason.end,
|
| 40 |
+
'description': self.closedSeason.description
|
| 41 |
+
}
|
| 42 |
+
return data
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class CaptureItem:
|
| 47 |
+
"""Individual capture item (species with quantity)."""
|
| 48 |
+
speciesId: str
|
| 49 |
+
quantity: float
|
| 50 |
+
unit: Literal['lbs', 'units']
|
| 51 |
+
|
| 52 |
+
def to_dict(self):
|
| 53 |
+
"""Convert to dictionary for JSON serialization."""
|
| 54 |
+
return {
|
| 55 |
+
'speciesId': self.speciesId,
|
| 56 |
+
'quantity': self.quantity,
|
| 57 |
+
'unit': self.unit
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class Capture:
|
| 63 |
+
"""Capture data model."""
|
| 64 |
+
id: str
|
| 65 |
+
timestamp: datetime
|
| 66 |
+
latitude: float
|
| 67 |
+
longitude: float
|
| 68 |
+
species: List[str]
|
| 69 |
+
items: List[CaptureItem]
|
| 70 |
+
fishingMethod: str
|
| 71 |
+
depth: float
|
| 72 |
+
synced: bool
|
| 73 |
+
userId: Optional[str] = None
|
| 74 |
+
syncedAt: Optional[datetime] = None
|
| 75 |
+
temperature: Optional[float] = None
|
| 76 |
+
seaState: Optional[str] = None
|
| 77 |
+
|
| 78 |
+
def to_dict(self):
|
| 79 |
+
"""Convert to dictionary for JSON serialization."""
|
| 80 |
+
return {
|
| 81 |
+
'id': self.id,
|
| 82 |
+
'timestamp': self.timestamp.isoformat() if isinstance(self.timestamp, datetime) else self.timestamp,
|
| 83 |
+
'latitude': self.latitude,
|
| 84 |
+
'longitude': self.longitude,
|
| 85 |
+
'species': self.species,
|
| 86 |
+
'items': [item.to_dict() if hasattr(item, 'to_dict') else item for item in self.items],
|
| 87 |
+
'fishingMethod': self.fishingMethod,
|
| 88 |
+
'depth': self.depth,
|
| 89 |
+
'synced': self.synced,
|
| 90 |
+
'userId': self.userId,
|
| 91 |
+
'syncedAt': self.syncedAt.isoformat() if self.syncedAt and isinstance(self.syncedAt, datetime) else self.syncedAt,
|
| 92 |
+
'temperature': self.temperature,
|
| 93 |
+
'seaState': self.seaState
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
@classmethod
|
| 97 |
+
def from_dict(cls, data: dict):
|
| 98 |
+
"""Create Capture instance from dictionary."""
|
| 99 |
+
items = []
|
| 100 |
+
for item in data.get('items', []):
|
| 101 |
+
if isinstance(item, dict):
|
| 102 |
+
items.append(CaptureItem(**item))
|
| 103 |
+
else:
|
| 104 |
+
items.append(item)
|
| 105 |
+
|
| 106 |
+
timestamp = data.get('timestamp')
|
| 107 |
+
if isinstance(timestamp, str):
|
| 108 |
+
timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
| 109 |
+
|
| 110 |
+
syncedAt = data.get('syncedAt')
|
| 111 |
+
if syncedAt and isinstance(syncedAt, str):
|
| 112 |
+
syncedAt = datetime.fromisoformat(syncedAt.replace('Z', '+00:00'))
|
| 113 |
+
|
| 114 |
+
return cls(
|
| 115 |
+
id=data.get('id'),
|
| 116 |
+
timestamp=timestamp,
|
| 117 |
+
latitude=data.get('latitude'),
|
| 118 |
+
longitude=data.get('longitude'),
|
| 119 |
+
species=data.get('species', []),
|
| 120 |
+
items=items,
|
| 121 |
+
fishingMethod=data.get('fishingMethod'),
|
| 122 |
+
depth=data.get('depth'),
|
| 123 |
+
synced=data.get('synced', False),
|
| 124 |
+
userId=data.get('userId'),
|
| 125 |
+
syncedAt=syncedAt,
|
| 126 |
+
temperature=data.get('temperature'),
|
| 127 |
+
seaState=data.get('seaState')
|
| 128 |
+
)
|
package-lock.json
DELETED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
DELETED
|
@@ -1,45 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "mi-pesca-rd",
|
| 3 |
-
"version": "0.0.0",
|
| 4 |
-
"scripts": {
|
| 5 |
-
"ng": "ng",
|
| 6 |
-
"start": "ng serve",
|
| 7 |
-
"build": "ng build",
|
| 8 |
-
"watch": "ng build --watch --configuration development",
|
| 9 |
-
"test": "ng test",
|
| 10 |
-
"serve:ssr:mi-pesca-rd": "node dist/mi-pesca-rd/server/server.mjs"
|
| 11 |
-
},
|
| 12 |
-
"private": true,
|
| 13 |
-
"dependencies": {
|
| 14 |
-
"@angular/service-worker": "^17.0.0",
|
| 15 |
-
"@angular/animations": "^17.3.0",
|
| 16 |
-
"@angular/common": "^17.3.0",
|
| 17 |
-
"@angular/compiler": "^17.3.0",
|
| 18 |
-
"@angular/core": "^17.3.0",
|
| 19 |
-
"@angular/forms": "^17.3.0",
|
| 20 |
-
"@angular/platform-browser": "^17.3.0",
|
| 21 |
-
"@angular/platform-browser-dynamic": "^17.3.0",
|
| 22 |
-
"@angular/platform-server": "^17.3.0",
|
| 23 |
-
"@angular/router": "^17.3.0",
|
| 24 |
-
"@angular/ssr": "^17.3.17",
|
| 25 |
-
"express": "^4.18.2",
|
| 26 |
-
"rxjs": "~7.8.0",
|
| 27 |
-
"tslib": "^2.3.0",
|
| 28 |
-
"zone.js": "~0.14.3"
|
| 29 |
-
},
|
| 30 |
-
"devDependencies": {
|
| 31 |
-
"@angular-devkit/build-angular": "^17.3.17",
|
| 32 |
-
"@angular/cli": "^17.3.17",
|
| 33 |
-
"@angular/compiler-cli": "^17.3.0",
|
| 34 |
-
"@types/express": "^4.17.17",
|
| 35 |
-
"@types/jasmine": "~5.1.0",
|
| 36 |
-
"@types/node": "^18.18.0",
|
| 37 |
-
"jasmine-core": "~5.1.0",
|
| 38 |
-
"karma": "~6.4.0",
|
| 39 |
-
"karma-chrome-launcher": "~3.2.0",
|
| 40 |
-
"karma-coverage": "~2.2.0",
|
| 41 |
-
"karma-jasmine": "~5.1.0",
|
| 42 |
-
"karma-jasmine-html-reporter": "~2.1.0",
|
| 43 |
-
"typescript": "~5.4.2"
|
| 44 |
-
}
|
| 45 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Flask==3.0.0
|
| 2 |
+
Flask-CORS==4.0.0
|
| 3 |
+
python-dotenv==1.0.0
|
| 4 |
+
requests==2.31.0
|
| 5 |
+
gunicorn==21.2.0
|
server.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
import { APP_BASE_HREF } from '@angular/common';
|
| 2 |
-
import { CommonEngine } from '@angular/ssr';
|
| 3 |
-
import express from 'express';
|
| 4 |
-
import { fileURLToPath } from 'node:url';
|
| 5 |
-
import { dirname, join, resolve } from 'node:path';
|
| 6 |
-
import bootstrap from './src/main.server';
|
| 7 |
-
|
| 8 |
-
// The Express app is exported so that it can be used by serverless Functions.
|
| 9 |
-
export function app(): express.Express {
|
| 10 |
-
const server = express();
|
| 11 |
-
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
| 12 |
-
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
| 13 |
-
const indexHtml = join(serverDistFolder, 'index.server.html');
|
| 14 |
-
|
| 15 |
-
const commonEngine = new CommonEngine();
|
| 16 |
-
|
| 17 |
-
server.set('view engine', 'html');
|
| 18 |
-
server.set('views', browserDistFolder);
|
| 19 |
-
|
| 20 |
-
// Example Express Rest API endpoints
|
| 21 |
-
// server.get('/api/**', (req, res) => { });
|
| 22 |
-
// Serve static files from /browser
|
| 23 |
-
server.get('*.*', express.static(browserDistFolder, {
|
| 24 |
-
maxAge: '1y'
|
| 25 |
-
}));
|
| 26 |
-
|
| 27 |
-
// All regular routes use the Angular engine
|
| 28 |
-
server.get('*', (req, res, next) => {
|
| 29 |
-
const { protocol, originalUrl, baseUrl, headers } = req;
|
| 30 |
-
|
| 31 |
-
commonEngine
|
| 32 |
-
.render({
|
| 33 |
-
bootstrap,
|
| 34 |
-
documentFilePath: indexHtml,
|
| 35 |
-
url: `${protocol}://${headers.host}${originalUrl}`,
|
| 36 |
-
publicPath: browserDistFolder,
|
| 37 |
-
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
|
| 38 |
-
})
|
| 39 |
-
.then((html) => res.send(html))
|
| 40 |
-
.catch((err) => next(err));
|
| 41 |
-
});
|
| 42 |
-
|
| 43 |
-
return server;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
function run(): void {
|
| 47 |
-
const port = process.env['PORT'] || 4000;
|
| 48 |
-
|
| 49 |
-
// Start up the Node server
|
| 50 |
-
const server = app();
|
| 51 |
-
server.listen(port, () => {
|
| 52 |
-
console.log(`Node Express server listening on http://localhost:${port}`);
|
| 53 |
-
});
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
run();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/app.component.html
DELETED
|
@@ -1,41 +0,0 @@
|
|
| 1 |
-
<div class="container" style="padding: 20px; max-width: 500px; margin: auto;">
|
| 2 |
-
<header style="text-align: center; border-bottom: 2px solid #004488;">
|
| 3 |
-
<h1 style="color: #004488; margin-bottom: 5px;">🎣 MI PESCA RD</h1>
|
| 4 |
-
<p style="font-size: 0.9em; color: #666;">Investigación de Recursos Pesqueros</p>
|
| 5 |
-
</header>
|
| 6 |
-
|
| 7 |
-
<main style="margin-top: 20px;">
|
| 8 |
-
<div style="margin-bottom: 15px;">
|
| 9 |
-
<label>Especie:</label>
|
| 10 |
-
<select #esp style="width: 100%; padding: 15px; font-size: 1.2rem; border-radius: 8px;">
|
| 11 |
-
<option value="Chillo">Chillo</option>
|
| 12 |
-
<option value="Colirrubia">Colirrubia</option>
|
| 13 |
-
<option value="Mero">Mero Rojo</option>
|
| 14 |
-
<option value="Langosta">Langosta (Veda check)</option>
|
| 15 |
-
</select>
|
| 16 |
-
</div>
|
| 17 |
-
|
| 18 |
-
<div style="margin-bottom: 15px;">
|
| 19 |
-
<label>Peso Estimado (Lbs):</label>
|
| 20 |
-
<input #peso type="number" style="width: 100%; padding: 15px; font-size: 1.2rem; border-radius: 8px;">
|
| 21 |
-
</div>
|
| 22 |
-
|
| 23 |
-
<div style="margin-bottom: 25px;">
|
| 24 |
-
<label>Profundidad (Brazadas):</label>
|
| 25 |
-
<input #braz type="range" min="1" max="150" value="20" style="width: 100%;">
|
| 26 |
-
<p style="text-align: center; font-weight: bold;">{{braz.value}} brazadas</p>
|
| 27 |
-
</div>
|
| 28 |
-
|
| 29 |
-
<button (click)="guardarCaptura(esp.value, +peso.value, +braz.value)"
|
| 30 |
-
style="width: 100%; padding: 20px; background: #004488; color: white; border: none; border-radius: 10px; font-weight: bold; font-size: 1.1rem; cursor: pointer;">
|
| 31 |
-
REGISTRAR CAPTURA
|
| 32 |
-
</button>
|
| 33 |
-
</main>
|
| 34 |
-
|
| 35 |
-
<section style="margin-top: 30px;">
|
| 36 |
-
<h3>Capturas hoy: {{registros.length}}</h3>
|
| 37 |
-
<div *ngFor="let r of registros.slice(-3)" style="background: #f0f0f0; margin-bottom: 10px; padding: 10px; border-radius: 5px; font-size: 0.9em;">
|
| 38 |
-
{{r.especie}} - {{r.pesoLbs}}lbs - {{r.fecha | date:'shortTime'}}
|
| 39 |
-
</div>
|
| 40 |
-
</section>
|
| 41 |
-
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/app.component.spec.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
import { TestBed } from '@angular/core/testing';
|
| 2 |
-
import { AppComponent } from './app.component';
|
| 3 |
-
|
| 4 |
-
describe('AppComponent', () => {
|
| 5 |
-
beforeEach(async () => {
|
| 6 |
-
await TestBed.configureTestingModule({
|
| 7 |
-
imports: [AppComponent],
|
| 8 |
-
}).compileComponents();
|
| 9 |
-
});
|
| 10 |
-
|
| 11 |
-
it('should create the app', () => {
|
| 12 |
-
const fixture = TestBed.createComponent(AppComponent);
|
| 13 |
-
const app = fixture.componentInstance;
|
| 14 |
-
expect(app).toBeTruthy();
|
| 15 |
-
});
|
| 16 |
-
|
| 17 |
-
it(`should have the 'mi-pesca-rd' title`, () => {
|
| 18 |
-
const fixture = TestBed.createComponent(AppComponent);
|
| 19 |
-
const app = fixture.componentInstance;
|
| 20 |
-
expect(app.title).toEqual('mi-pesca-rd');
|
| 21 |
-
});
|
| 22 |
-
|
| 23 |
-
it('should render title', () => {
|
| 24 |
-
const fixture = TestBed.createComponent(AppComponent);
|
| 25 |
-
fixture.detectChanges();
|
| 26 |
-
const compiled = fixture.nativeElement as HTMLElement;
|
| 27 |
-
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, mi-pesca-rd');
|
| 28 |
-
});
|
| 29 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/app.component.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
import { Component, Inject, PLATFORM_ID, OnInit } from '@angular/core';
|
| 2 |
-
import { CommonModule,isPlatformBrowser } from '@angular/common';
|
| 3 |
-
import { RegistroPesca } from './models/pesca.model';
|
| 4 |
-
|
| 5 |
-
@Component({
|
| 6 |
-
selector: 'app-root',
|
| 7 |
-
standalone: true,
|
| 8 |
-
imports:[CommonModule],
|
| 9 |
-
templateUrl: './app.component.html',
|
| 10 |
-
styleUrls: ['./app.component.css']
|
| 11 |
-
})
|
| 12 |
-
export class AppComponent implements OnInit {
|
| 13 |
-
registros: RegistroPesca[] = [];
|
| 14 |
-
isBrowser: boolean;
|
| 15 |
-
|
| 16 |
-
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
|
| 17 |
-
// Verificamos si estamos en el navegador del pescador
|
| 18 |
-
this.isBrowser = isPlatformBrowser(this.platformId);
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
ngOnInit() {
|
| 22 |
-
if (this.isBrowser) {
|
| 23 |
-
this.cargarDatosLocales();
|
| 24 |
-
}
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
guardarCaptura(especie: string, peso: number, brazadas: number) {
|
| 28 |
-
if (!this.isBrowser) return;
|
| 29 |
-
|
| 30 |
-
// Captura de GPS obligatoria para el mapa de calor
|
| 31 |
-
navigator.geolocation.getCurrentPosition(
|
| 32 |
-
(pos) => {
|
| 33 |
-
const nuevo: RegistroPesca = {
|
| 34 |
-
especie,
|
| 35 |
-
pesoLbs: peso,
|
| 36 |
-
brazadas,
|
| 37 |
-
latitud: pos.coords.latitude,
|
| 38 |
-
longitud: pos.coords.longitude,
|
| 39 |
-
fecha: new Date().toISOString(),
|
| 40 |
-
sincronizado: false
|
| 41 |
-
};
|
| 42 |
-
|
| 43 |
-
this.registros.push(nuevo);
|
| 44 |
-
localStorage.setItem('db_pesca', JSON.stringify(this.registros));
|
| 45 |
-
alert('Captura registrada exitosamente en alta mar 🌊');
|
| 46 |
-
},
|
| 47 |
-
(error) => {
|
| 48 |
-
alert('Por favor activa el GPS para registrar la ubicación del recurso.');
|
| 49 |
-
},
|
| 50 |
-
{ enableHighAccuracy: true } // Alta precisión para investigación científica
|
| 51 |
-
);
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
cargarDatosLocales() {
|
| 55 |
-
const data = localStorage.getItem('db_pesca');
|
| 56 |
-
if (data) this.registros = JSON.parse(data);
|
| 57 |
-
}
|
| 58 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/app.config.server.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
| 1 |
-
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
| 2 |
-
import { provideServerRendering } from '@angular/platform-server';
|
| 3 |
-
import { appConfig } from './app.config';
|
| 4 |
-
|
| 5 |
-
const serverConfig: ApplicationConfig = {
|
| 6 |
-
providers: [
|
| 7 |
-
provideServerRendering()
|
| 8 |
-
]
|
| 9 |
-
};
|
| 10 |
-
|
| 11 |
-
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/app.config.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
import { ApplicationConfig, isDevMode } from '@angular/core';
|
| 2 |
-
import { provideRouter } from '@angular/router';
|
| 3 |
-
import { routes } from './app.routes';
|
| 4 |
-
import { provideClientHydration } from '@angular/platform-browser';
|
| 5 |
-
import { provideServiceWorker } from '@angular/service-worker';
|
| 6 |
-
|
| 7 |
-
export const appConfig: ApplicationConfig = {
|
| 8 |
-
providers: [
|
| 9 |
-
provideRouter(routes),
|
| 10 |
-
provideClientHydration(), // Importante para SSR
|
| 11 |
-
provideServiceWorker('ngsw-worker.js', {
|
| 12 |
-
enabled: !isDevMode(),
|
| 13 |
-
registrationStrategy: 'registerWhenStable:30000'
|
| 14 |
-
})
|
| 15 |
-
]
|
| 16 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/app/app.routes.ts
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
import { Routes } from '@angular/router';
|
| 2 |
-
|
| 3 |
-
export const routes: Routes = [];
|
|
|
|
|
|
|
|
|
|
|
|
src/app/models/pesca.model.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
export interface RegistroPesca {
|
| 2 |
-
especie: string;
|
| 3 |
-
pesoLbs: number;
|
| 4 |
-
brazadas: number;
|
| 5 |
-
latitud: number;
|
| 6 |
-
longitud: number;
|
| 7 |
-
fecha: string; // ISO String
|
| 8 |
-
sincronizado: boolean;
|
| 9 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/assets/.gitkeep
DELETED
|
File without changes
|
src/favicon.ico
DELETED
Git LFS Details
|
src/index.html
DELETED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="utf-8">
|
| 5 |
-
<title>MiPescaRd</title>
|
| 6 |
-
<base href="/">
|
| 7 |
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 8 |
-
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
| 9 |
-
</head>
|
| 10 |
-
<body>
|
| 11 |
-
<app-root></app-root>
|
| 12 |
-
</body>
|
| 13 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/main.server.ts
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
import { bootstrapApplication } from '@angular/platform-browser';
|
| 2 |
-
import { AppComponent } from './app/app.component';
|
| 3 |
-
import { config } from './app/app.config.server';
|
| 4 |
-
|
| 5 |
-
const bootstrap = () => bootstrapApplication(AppComponent, config);
|
| 6 |
-
|
| 7 |
-
export default bootstrap;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/main.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
| 1 |
-
import { bootstrapApplication } from '@angular/platform-browser';
|
| 2 |
-
import { appConfig } from './app/app.config';
|
| 3 |
-
import { AppComponent } from './app/app.component';
|
| 4 |
-
|
| 5 |
-
bootstrapApplication(AppComponent, appConfig)
|
| 6 |
-
.catch((err) => console.error(err));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/styles.css
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
/* You can add global styles to this file, and also import other style files */
|
|
|
|
|
|
static/css/styles.css
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Global Styles - Mi Pesca RD */
|
| 2 |
+
:root {
|
| 3 |
+
--primary-color: #0D47A1;
|
| 4 |
+
--secondary-color: #4CAF50;
|
| 5 |
+
--warning-color: #FF9800;
|
| 6 |
+
--danger-color: #F44336;
|
| 7 |
+
--background-color: #ffffff;
|
| 8 |
+
--text-color: #000000;
|
| 9 |
+
--card-bg: #f8f9fa;
|
| 10 |
+
--border-radius: 8px;
|
| 11 |
+
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
* {
|
| 15 |
+
box-sizing: border-box;
|
| 16 |
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
body {
|
| 20 |
+
margin: 0;
|
| 21 |
+
padding: 0;
|
| 22 |
+
background-color: var(--background-color);
|
| 23 |
+
color: var(--text-color);
|
| 24 |
+
font-size: 18px;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
h1,
|
| 28 |
+
h2,
|
| 29 |
+
h3 {
|
| 30 |
+
margin-top: 0;
|
| 31 |
+
color: var(--primary-color);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
html {
|
| 35 |
+
scroll-behavior: smooth;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* App Container */
|
| 39 |
+
#app {
|
| 40 |
+
max-width: 600px;
|
| 41 |
+
margin: 0 auto;
|
| 42 |
+
min-height: 100vh;
|
| 43 |
+
display: flex;
|
| 44 |
+
flex-direction: column;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* Loading State */
|
| 48 |
+
.loading {
|
| 49 |
+
display: flex;
|
| 50 |
+
flex-direction: column;
|
| 51 |
+
align-items: center;
|
| 52 |
+
justify-content: center;
|
| 53 |
+
min-height: 100vh;
|
| 54 |
+
text-align: center;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* Navigation */
|
| 58 |
+
.nav {
|
| 59 |
+
background-color: var(--primary-color);
|
| 60 |
+
color: white;
|
| 61 |
+
padding: 1rem;
|
| 62 |
+
display: flex;
|
| 63 |
+
align-items: center;
|
| 64 |
+
justify-content: space-between;
|
| 65 |
+
box-shadow: var(--shadow);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.nav h1 {
|
| 69 |
+
margin: 0;
|
| 70 |
+
font-size: 1.5rem;
|
| 71 |
+
color: white;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.nav-back {
|
| 75 |
+
background: none;
|
| 76 |
+
border: none;
|
| 77 |
+
color: white;
|
| 78 |
+
font-size: 1.5rem;
|
| 79 |
+
cursor: pointer;
|
| 80 |
+
padding: 0.5rem;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* Main Content */
|
| 84 |
+
.content {
|
| 85 |
+
flex: 1;
|
| 86 |
+
padding: 1rem;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/* Cards */
|
| 90 |
+
.card {
|
| 91 |
+
background-color: var(--card-bg);
|
| 92 |
+
border-radius: var(--border-radius);
|
| 93 |
+
padding: 1.5rem;
|
| 94 |
+
margin-bottom: 1rem;
|
| 95 |
+
box-shadow: var(--shadow);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* Buttons */
|
| 99 |
+
button {
|
| 100 |
+
min-height: 50px;
|
| 101 |
+
padding: 0.75rem 1.5rem;
|
| 102 |
+
font-size: 1rem;
|
| 103 |
+
font-weight: 600;
|
| 104 |
+
border: none;
|
| 105 |
+
border-radius: var(--border-radius);
|
| 106 |
+
cursor: pointer;
|
| 107 |
+
touch-action: manipulation;
|
| 108 |
+
transition: all 0.2s;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.btn-primary {
|
| 112 |
+
background-color: var(--primary-color);
|
| 113 |
+
color: white;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.btn-primary:hover {
|
| 117 |
+
background-color: #0a3a7f;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.btn-secondary {
|
| 121 |
+
background-color: var(--secondary-color);
|
| 122 |
+
color: white;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.btn-secondary:hover {
|
| 126 |
+
background-color: #45a049;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.btn-danger {
|
| 130 |
+
background-color: var(--danger-color);
|
| 131 |
+
color: white;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
.btn-full {
|
| 135 |
+
width: 100%;
|
| 136 |
+
margin-bottom: 1rem;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* Forms */
|
| 140 |
+
.form-group {
|
| 141 |
+
margin-bottom: 1.5rem;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.form-group label {
|
| 145 |
+
display: block;
|
| 146 |
+
margin-bottom: 0.5rem;
|
| 147 |
+
font-weight: 600;
|
| 148 |
+
color: var(--primary-color);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.form-group input,
|
| 152 |
+
.form-group select,
|
| 153 |
+
.form-group textarea {
|
| 154 |
+
width: 100%;
|
| 155 |
+
padding: 0.75rem;
|
| 156 |
+
font-size: 1rem;
|
| 157 |
+
border: 2px solid #ddd;
|
| 158 |
+
border-radius: var(--border-radius);
|
| 159 |
+
background-color: white;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.form-group input:focus,
|
| 163 |
+
.form-group select:focus,
|
| 164 |
+
.form-group textarea:focus {
|
| 165 |
+
outline: none;
|
| 166 |
+
border-color: var(--primary-color);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/* Species Grid */
|
| 170 |
+
.species-grid {
|
| 171 |
+
display: grid;
|
| 172 |
+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
| 173 |
+
gap: 1rem;
|
| 174 |
+
margin-bottom: 1rem;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.species-card {
|
| 178 |
+
background: white;
|
| 179 |
+
border: 3px solid transparent;
|
| 180 |
+
border-radius: var(--border-radius);
|
| 181 |
+
padding: 1rem;
|
| 182 |
+
cursor: pointer;
|
| 183 |
+
text-align: center;
|
| 184 |
+
transition: all 0.2s;
|
| 185 |
+
box-shadow: var(--shadow);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.species-card:hover {
|
| 189 |
+
transform: translateY(-2px);
|
| 190 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.species-card.selected {
|
| 194 |
+
border-color: var(--secondary-color);
|
| 195 |
+
background-color: #e8f5e9;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.species-card.protected {
|
| 199 |
+
border-color: var(--danger-color);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.species-card img {
|
| 203 |
+
width: 100%;
|
| 204 |
+
height: 100px;
|
| 205 |
+
object-fit: cover;
|
| 206 |
+
border-radius: 4px;
|
| 207 |
+
margin-bottom: 0.5rem;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.species-card h3 {
|
| 211 |
+
font-size: 1rem;
|
| 212 |
+
margin: 0.5rem 0 0 0;
|
| 213 |
+
color: var(--text-color);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.species-card .badge {
|
| 217 |
+
display: inline-block;
|
| 218 |
+
padding: 0.25rem 0.5rem;
|
| 219 |
+
font-size: 0.75rem;
|
| 220 |
+
border-radius: 4px;
|
| 221 |
+
margin-top: 0.5rem;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.badge-protected {
|
| 225 |
+
background-color: var(--danger-color);
|
| 226 |
+
color: white;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.badge-veda {
|
| 230 |
+
background-color: var(--warning-color);
|
| 231 |
+
color: white;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/* History List */
|
| 235 |
+
.history-list {
|
| 236 |
+
list-style: none;
|
| 237 |
+
padding: 0;
|
| 238 |
+
margin: 0;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.history-item {
|
| 242 |
+
background: white;
|
| 243 |
+
border-left: 4px solid var(--primary-color);
|
| 244 |
+
padding: 1rem;
|
| 245 |
+
margin-bottom: 1rem;
|
| 246 |
+
border-radius: var(--border-radius);
|
| 247 |
+
box-shadow: var(--shadow);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.history-item.synced {
|
| 251 |
+
border-left-color: var(--secondary-color);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.history-item.unsynced {
|
| 255 |
+
border-left-color: var(--warning-color);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.history-item h3 {
|
| 259 |
+
margin: 0 0 0.5rem 0;
|
| 260 |
+
font-size: 1rem;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.history-item p {
|
| 264 |
+
margin: 0.25rem 0;
|
| 265 |
+
font-size: 0.9rem;
|
| 266 |
+
color: #666;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/* Status Indicators */
|
| 270 |
+
.status-bar {
|
| 271 |
+
background-color: var(--card-bg);
|
| 272 |
+
padding: 0.5rem 1rem;
|
| 273 |
+
text-align: center;
|
| 274 |
+
font-size: 0.9rem;
|
| 275 |
+
border-bottom: 1px solid #ddd;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.status-online {
|
| 279 |
+
color: var(--secondary-color);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.status-offline {
|
| 283 |
+
color: var(--warning-color);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
/* Confirmation View */
|
| 287 |
+
.confirmation-details {
|
| 288 |
+
background: white;
|
| 289 |
+
padding: 1rem;
|
| 290 |
+
border-radius: var(--border-radius);
|
| 291 |
+
margin-bottom: 1rem;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.confirmation-details dt {
|
| 295 |
+
font-weight: 600;
|
| 296 |
+
color: var(--primary-color);
|
| 297 |
+
margin-top: 0.5rem;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.confirmation-details dd {
|
| 301 |
+
margin: 0.25rem 0 0.5rem 0;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/* Mobile Responsive */
|
| 305 |
+
@media (max-width: 600px) {
|
| 306 |
+
body {
|
| 307 |
+
font-size: 16px;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.species-grid {
|
| 311 |
+
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.content {
|
| 315 |
+
padding: 0.75rem;
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/* Utility Classes */
|
| 320 |
+
.text-center {
|
| 321 |
+
text-align: center;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.mt-1 {
|
| 325 |
+
margin-top: 1rem;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.mb-1 {
|
| 329 |
+
margin-bottom: 1rem;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.hidden {
|
| 333 |
+
display: none;
|
| 334 |
+
}
|
src/app/app.component.css → static/favicon.ico
RENAMED
|
File without changes
|
static/index.html
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<meta name="description" content="Mi Pesca RD - Registro de capturas pesqueras en República Dominicana">
|
| 8 |
+
<meta name="theme-color" content="#0D47A1">
|
| 9 |
+
|
| 10 |
+
<!-- PWA Meta Tags -->
|
| 11 |
+
<link rel="manifest" href="/manifest.json">
|
| 12 |
+
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
| 13 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 14 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 15 |
+
<meta name="apple-mobile-web-app-title" content="Mi Pesca RD">
|
| 16 |
+
|
| 17 |
+
<title>Mi Pesca RD</title>
|
| 18 |
+
|
| 19 |
+
<!-- Styles -->
|
| 20 |
+
<link rel="stylesheet" href="/css/styles.css">
|
| 21 |
+
|
| 22 |
+
<!-- Dexie.js for IndexedDB -->
|
| 23 |
+
<script src="https://unpkg.com/dexie@3.2.4/dist/dexie.min.js"></script>
|
| 24 |
+
</head>
|
| 25 |
+
|
| 26 |
+
<body>
|
| 27 |
+
<div id="app">
|
| 28 |
+
<!-- App content will be rendered here -->
|
| 29 |
+
<div class="loading">
|
| 30 |
+
<h1>🎣 Mi Pesca RD</h1>
|
| 31 |
+
<p>Cargando...</p>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<!-- Scripts -->
|
| 36 |
+
<script src="/js/db.js"></script>
|
| 37 |
+
<script src="/js/geolocation.js"></script>
|
| 38 |
+
<script src="/js/api.js"></script>
|
| 39 |
+
<script src="/js/sync.js"></script>
|
| 40 |
+
<script src="/js/components/home.js"></script>
|
| 41 |
+
<script src="/js/components/species-selector.js"></script>
|
| 42 |
+
<script src="/js/components/capture-form.js"></script>
|
| 43 |
+
<script src="/js/components/confirmation.js"></script>
|
| 44 |
+
<script src="/js/components/history.js"></script>
|
| 45 |
+
<script src="/js/app.js"></script>
|
| 46 |
+
|
| 47 |
+
<!-- Register Service Worker -->
|
| 48 |
+
<script>
|
| 49 |
+
if ('serviceWorker' in navigator) {
|
| 50 |
+
window.addEventListener('load', () => {
|
| 51 |
+
navigator.serviceWorker.register('/sw.js')
|
| 52 |
+
.then(registration => {
|
| 53 |
+
console.log('Service Worker registered:', registration);
|
| 54 |
+
})
|
| 55 |
+
.catch(error => {
|
| 56 |
+
console.log('Service Worker registration failed:', error);
|
| 57 |
+
});
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
</script>
|
| 61 |
+
</body>
|
| 62 |
+
|
| 63 |
+
</html>
|
static/js/api.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* API Service for backend communication
|
| 3 |
+
* Migrated from Angular api.service.ts
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const apiService = {
|
| 7 |
+
baseUrl: window.location.origin,
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Fetch wrapper with error handling
|
| 11 |
+
*/
|
| 12 |
+
async request(endpoint, options = {}) {
|
| 13 |
+
try {
|
| 14 |
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
| 15 |
+
headers: {
|
| 16 |
+
'Content-Type': 'application/json',
|
| 17 |
+
...options.headers
|
| 18 |
+
},
|
| 19 |
+
...options
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
if (!response.ok) {
|
| 23 |
+
throw new Error(`HTTP error! status: ${response.status}`);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
return await response.json();
|
| 27 |
+
} catch (error) {
|
| 28 |
+
console.error('API request failed:', error);
|
| 29 |
+
throw error;
|
| 30 |
+
}
|
| 31 |
+
},
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Get species list from backend
|
| 35 |
+
*/
|
| 36 |
+
async getSpecies() {
|
| 37 |
+
return await this.request('/api/species');
|
| 38 |
+
},
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* Send a capture to the backend
|
| 42 |
+
*/
|
| 43 |
+
async sendCapture(capture) {
|
| 44 |
+
return await this.request('/api/captures', {
|
| 45 |
+
method: 'POST',
|
| 46 |
+
body: JSON.stringify(capture)
|
| 47 |
+
});
|
| 48 |
+
},
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Batch sync multiple captures
|
| 52 |
+
*/
|
| 53 |
+
async syncCaptures(captures) {
|
| 54 |
+
return await this.request('/api/sync', {
|
| 55 |
+
method: 'POST',
|
| 56 |
+
body: JSON.stringify({ captures })
|
| 57 |
+
});
|
| 58 |
+
},
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* Check if online
|
| 62 |
+
*/
|
| 63 |
+
isOnline() {
|
| 64 |
+
return navigator.onLine;
|
| 65 |
+
}
|
| 66 |
+
};
|
static/js/app.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Main Application
|
| 3 |
+
* SPA Router and State Management
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const app = {
|
| 7 |
+
currentRoute: 'home',
|
| 8 |
+
currentComponent: null,
|
| 9 |
+
state: {},
|
| 10 |
+
|
| 11 |
+
routes: {
|
| 12 |
+
'home': HomeComponent,
|
| 13 |
+
'species-selector': SpeciesSelectorComponent,
|
| 14 |
+
'capture-form': CaptureFormComponent,
|
| 15 |
+
'confirmation': ConfirmationComponent,
|
| 16 |
+
'history': HistoryComponent
|
| 17 |
+
},
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Initialize the application
|
| 21 |
+
*/
|
| 22 |
+
async init() {
|
| 23 |
+
console.log('Initializing Mi Pesca RD...');
|
| 24 |
+
|
| 25 |
+
// Initialize sync service
|
| 26 |
+
syncService.init();
|
| 27 |
+
|
| 28 |
+
// Load initial route
|
| 29 |
+
this.navigate('home');
|
| 30 |
+
},
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Navigate to a route
|
| 34 |
+
*/
|
| 35 |
+
async navigate(route) {
|
| 36 |
+
if (!this.routes[route]) {
|
| 37 |
+
console.error(`Route not found: ${route}`);
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Destroy previous component
|
| 42 |
+
if (this.currentComponent && this.currentComponent.destroy) {
|
| 43 |
+
this.currentComponent.destroy();
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Update current route
|
| 47 |
+
this.currentRoute = route;
|
| 48 |
+
this.currentComponent = this.routes[route];
|
| 49 |
+
|
| 50 |
+
// Initialize component
|
| 51 |
+
if (this.currentComponent.init) {
|
| 52 |
+
await this.currentComponent.init();
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Render component
|
| 56 |
+
this.render();
|
| 57 |
+
},
|
| 58 |
+
|
| 59 |
+
/**
|
| 60 |
+
* Render current component
|
| 61 |
+
*/
|
| 62 |
+
render() {
|
| 63 |
+
const appContainer = document.getElementById('app');
|
| 64 |
+
|
| 65 |
+
if (!appContainer) {
|
| 66 |
+
console.error('App container not found');
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if (this.currentComponent && this.currentComponent.render) {
|
| 71 |
+
appContainer.innerHTML = this.currentComponent.render();
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
// Initialize app when DOM is ready
|
| 77 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 78 |
+
app.init();
|
| 79 |
+
});
|
static/js/components/capture-form.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Capture Form Component
|
| 3 |
+
* Data entry form for capture details
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const CaptureFormComponent = {
|
| 7 |
+
captureData: null,
|
| 8 |
+
location: null,
|
| 9 |
+
|
| 10 |
+
render() {
|
| 11 |
+
const selectedSpecies = app.state.selectedSpecies || [];
|
| 12 |
+
|
| 13 |
+
return `
|
| 14 |
+
<div class="nav">
|
| 15 |
+
<button class="nav-back" onclick="app.navigate('species-selector')">←</button>
|
| 16 |
+
<h1>Detalles de Captura</h1>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<div class="content">
|
| 20 |
+
<div class="card">
|
| 21 |
+
<h3>📍 Ubicación</h3>
|
| 22 |
+
<p id="location-status">Obteniendo ubicación GPS...</p>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
<form id="capture-form" onsubmit="CaptureFormComponent.submit(event)">
|
| 26 |
+
${selectedSpecies.map((speciesId, index) => `
|
| 27 |
+
<div class="card">
|
| 28 |
+
<h3>${this.getSpeciesName(speciesId)}</h3>
|
| 29 |
+
|
| 30 |
+
<div class="form-group">
|
| 31 |
+
<label for="quantity-${index}">Cantidad</label>
|
| 32 |
+
<input
|
| 33 |
+
type="number"
|
| 34 |
+
id="quantity-${index}"
|
| 35 |
+
name="quantity-${index}"
|
| 36 |
+
min="0.1"
|
| 37 |
+
step="0.1"
|
| 38 |
+
required
|
| 39 |
+
>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div class="form-group">
|
| 43 |
+
<label for="unit-${index}">Unidad</label>
|
| 44 |
+
<select id="unit-${index}" name="unit-${index}" required>
|
| 45 |
+
<option value="lbs">Libras (lbs)</option>
|
| 46 |
+
<option value="units">Unidades</option>
|
| 47 |
+
</select>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
`).join('')}
|
| 51 |
+
|
| 52 |
+
<div class="card">
|
| 53 |
+
<div class="form-group">
|
| 54 |
+
<label for="fishing-method">Método de Pesca</label>
|
| 55 |
+
<select id="fishing-method" name="fishing-method" required>
|
| 56 |
+
<option value="">Seleccionar...</option>
|
| 57 |
+
<option value="Línea de mano">Línea de mano</option>
|
| 58 |
+
<option value="Nasa">Nasa</option>
|
| 59 |
+
<option value="Red de enmalle">Red de enmalle</option>
|
| 60 |
+
<option value="Palangre">Palangre</option>
|
| 61 |
+
<option value="Buceo">Buceo</option>
|
| 62 |
+
<option value="Otro">Otro</option>
|
| 63 |
+
</select>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div class="form-group">
|
| 67 |
+
<label for="depth">Profundidad (Brazadas)</label>
|
| 68 |
+
<input
|
| 69 |
+
type="number"
|
| 70 |
+
id="depth"
|
| 71 |
+
name="depth"
|
| 72 |
+
min="0"
|
| 73 |
+
step="1"
|
| 74 |
+
required
|
| 75 |
+
>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<button type="submit" class="btn-primary btn-full">
|
| 80 |
+
Guardar Captura
|
| 81 |
+
</button>
|
| 82 |
+
</form>
|
| 83 |
+
</div>
|
| 84 |
+
`;
|
| 85 |
+
},
|
| 86 |
+
|
| 87 |
+
async init() {
|
| 88 |
+
// Get GPS location
|
| 89 |
+
try {
|
| 90 |
+
this.location = await geolocationService.getCurrentPosition();
|
| 91 |
+
|
| 92 |
+
const locationStatus = document.getElementById('location-status');
|
| 93 |
+
if (locationStatus) {
|
| 94 |
+
locationStatus.innerHTML = `
|
| 95 |
+
✅ Ubicación obtenida<br>
|
| 96 |
+
<small>Lat: ${this.location.latitude.toFixed(6)}, Lon: ${this.location.longitude.toFixed(6)}</small>
|
| 97 |
+
`;
|
| 98 |
+
}
|
| 99 |
+
} catch (error) {
|
| 100 |
+
console.error('Error getting location:', error);
|
| 101 |
+
const locationStatus = document.getElementById('location-status');
|
| 102 |
+
if (locationStatus) {
|
| 103 |
+
locationStatus.innerHTML = `❌ ${error.message}`;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
},
|
| 107 |
+
|
| 108 |
+
getSpeciesName(speciesId) {
|
| 109 |
+
const species = SpeciesSelectorComponent.allSpecies.find(s => s.id === speciesId);
|
| 110 |
+
return species ? species.commonName : speciesId;
|
| 111 |
+
},
|
| 112 |
+
|
| 113 |
+
async submit(event) {
|
| 114 |
+
event.preventDefault();
|
| 115 |
+
|
| 116 |
+
if (!this.location) {
|
| 117 |
+
alert('No se pudo obtener la ubicación GPS. Por favor, intenta de nuevo.');
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const selectedSpecies = app.state.selectedSpecies || [];
|
| 122 |
+
const items = [];
|
| 123 |
+
|
| 124 |
+
// Collect data for each species
|
| 125 |
+
selectedSpecies.forEach((speciesId, index) => {
|
| 126 |
+
const quantity = parseFloat(document.getElementById(`quantity-${index}`).value);
|
| 127 |
+
const unit = document.getElementById(`unit-${index}`).value;
|
| 128 |
+
|
| 129 |
+
items.push({
|
| 130 |
+
speciesId: speciesId,
|
| 131 |
+
quantity: quantity,
|
| 132 |
+
unit: unit
|
| 133 |
+
});
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
// Create capture object
|
| 137 |
+
const capture = {
|
| 138 |
+
id: this.generateUUID(),
|
| 139 |
+
timestamp: new Date().toISOString(),
|
| 140 |
+
latitude: this.location.latitude,
|
| 141 |
+
longitude: this.location.longitude,
|
| 142 |
+
species: selectedSpecies,
|
| 143 |
+
items: items,
|
| 144 |
+
fishingMethod: document.getElementById('fishing-method').value,
|
| 145 |
+
depth: parseFloat(document.getElementById('depth').value),
|
| 146 |
+
synced: false
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
// Save to IndexedDB
|
| 150 |
+
try {
|
| 151 |
+
await dbService.addCapture(capture);
|
| 152 |
+
|
| 153 |
+
// Store in app state for confirmation
|
| 154 |
+
app.state.lastCapture = capture;
|
| 155 |
+
|
| 156 |
+
// Navigate to confirmation
|
| 157 |
+
app.navigate('confirmation');
|
| 158 |
+
|
| 159 |
+
// Trigger sync if online
|
| 160 |
+
if (navigator.onLine) {
|
| 161 |
+
syncService.syncNow();
|
| 162 |
+
}
|
| 163 |
+
} catch (error) {
|
| 164 |
+
console.error('Error saving capture:', error);
|
| 165 |
+
alert('Error al guardar la captura. Por favor, intenta de nuevo.');
|
| 166 |
+
}
|
| 167 |
+
},
|
| 168 |
+
|
| 169 |
+
generateUUID() {
|
| 170 |
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
| 171 |
+
const r = Math.random() * 16 | 0;
|
| 172 |
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
| 173 |
+
return v.toString(16);
|
| 174 |
+
});
|
| 175 |
+
}
|
| 176 |
+
};
|
static/js/components/confirmation.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Confirmation Component
|
| 3 |
+
* Shows capture summary after submission
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const ConfirmationComponent = {
|
| 7 |
+
render() {
|
| 8 |
+
const capture = app.state.lastCapture;
|
| 9 |
+
|
| 10 |
+
if (!capture) {
|
| 11 |
+
return `
|
| 12 |
+
<div class="nav">
|
| 13 |
+
<h1>Confirmación</h1>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="content">
|
| 16 |
+
<div class="card">
|
| 17 |
+
<p>No hay datos de captura.</p>
|
| 18 |
+
</div>
|
| 19 |
+
<button class="btn-primary btn-full" onclick="app.navigate('home')">
|
| 20 |
+
Volver al Inicio
|
| 21 |
+
</button>
|
| 22 |
+
</div>
|
| 23 |
+
`;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
return `
|
| 27 |
+
<div class="nav">
|
| 28 |
+
<h1>✅ Captura Registrada</h1>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div class="content">
|
| 32 |
+
<div class="card">
|
| 33 |
+
<h2 style="color: var(--secondary-color); text-align: center;">
|
| 34 |
+
¡Captura guardada exitosamente!
|
| 35 |
+
</h2>
|
| 36 |
+
<p class="text-center">
|
| 37 |
+
${capture.synced ? '✅ Sincronizada con el servidor' : '⏳ Se sincronizará cuando haya conexión'}
|
| 38 |
+
</p>
|
| 39 |
+
</div>
|
| 40 |
+
|
| 41 |
+
<div class="card confirmation-details">
|
| 42 |
+
<h3>Resumen de Captura</h3>
|
| 43 |
+
|
| 44 |
+
<dl>
|
| 45 |
+
<dt>📅 Fecha y Hora</dt>
|
| 46 |
+
<dd>${this.formatDate(capture.timestamp)}</dd>
|
| 47 |
+
|
| 48 |
+
<dt>📍 Ubicación</dt>
|
| 49 |
+
<dd>
|
| 50 |
+
Lat: ${capture.latitude.toFixed(6)}<br>
|
| 51 |
+
Lon: ${capture.longitude.toFixed(6)}
|
| 52 |
+
</dd>
|
| 53 |
+
|
| 54 |
+
<dt>🐟 Especies</dt>
|
| 55 |
+
<dd>
|
| 56 |
+
${capture.items.map(item => `
|
| 57 |
+
${this.getSpeciesName(item.speciesId)}:
|
| 58 |
+
${item.quantity} ${item.unit === 'lbs' ? 'libras' : 'unidades'}
|
| 59 |
+
`).join('<br>')}
|
| 60 |
+
</dd>
|
| 61 |
+
|
| 62 |
+
<dt>🎣 Método de Pesca</dt>
|
| 63 |
+
<dd>${capture.fishingMethod}</dd>
|
| 64 |
+
|
| 65 |
+
<dt>📏 Profundidad</dt>
|
| 66 |
+
<dd>${capture.depth} brazadas</dd>
|
| 67 |
+
</dl>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<button class="btn-primary btn-full" onclick="app.navigate('home')">
|
| 71 |
+
🏠 Volver al Inicio
|
| 72 |
+
</button>
|
| 73 |
+
|
| 74 |
+
<button class="btn-secondary btn-full" onclick="app.navigate('species-selector')">
|
| 75 |
+
➕ Registrar Otra Captura
|
| 76 |
+
</button>
|
| 77 |
+
</div>
|
| 78 |
+
`;
|
| 79 |
+
},
|
| 80 |
+
|
| 81 |
+
init() {
|
| 82 |
+
// Component initialized
|
| 83 |
+
},
|
| 84 |
+
|
| 85 |
+
formatDate(dateString) {
|
| 86 |
+
const date = new Date(dateString);
|
| 87 |
+
return date.toLocaleString('es-DO', {
|
| 88 |
+
year: 'numeric',
|
| 89 |
+
month: 'long',
|
| 90 |
+
day: 'numeric',
|
| 91 |
+
hour: '2-digit',
|
| 92 |
+
minute: '2-digit'
|
| 93 |
+
});
|
| 94 |
+
},
|
| 95 |
+
|
| 96 |
+
getSpeciesName(speciesId) {
|
| 97 |
+
const species = SpeciesSelectorComponent.allSpecies.find(s => s.id === speciesId);
|
| 98 |
+
return species ? species.commonName : speciesId;
|
| 99 |
+
}
|
| 100 |
+
};
|
static/js/components/history.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* History Component
|
| 3 |
+
* Display list of saved captures
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const HistoryComponent = {
|
| 7 |
+
captures: [],
|
| 8 |
+
|
| 9 |
+
render() {
|
| 10 |
+
return `
|
| 11 |
+
<div class="nav">
|
| 12 |
+
<button class="nav-back" onclick="app.navigate('home')">←</button>
|
| 13 |
+
<h1>Historial de Capturas</h1>
|
| 14 |
+
</div>
|
| 15 |
+
|
| 16 |
+
<div class="content">
|
| 17 |
+
<div class="card">
|
| 18 |
+
<p><strong>${this.captures.length}</strong> captura(s) registrada(s)</p>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
${this.captures.length === 0 ? `
|
| 22 |
+
<div class="card text-center">
|
| 23 |
+
<p>No hay capturas registradas aún.</p>
|
| 24 |
+
<button class="btn-primary" onclick="app.navigate('species-selector')">
|
| 25 |
+
➕ Registrar Primera Captura
|
| 26 |
+
</button>
|
| 27 |
+
</div>
|
| 28 |
+
` : `
|
| 29 |
+
<ul class="history-list">
|
| 30 |
+
${this.renderCapturesList()}
|
| 31 |
+
</ul>
|
| 32 |
+
`}
|
| 33 |
+
</div>
|
| 34 |
+
`;
|
| 35 |
+
},
|
| 36 |
+
|
| 37 |
+
renderCapturesList() {
|
| 38 |
+
return this.captures
|
| 39 |
+
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
| 40 |
+
.map(capture => `
|
| 41 |
+
<li class="history-item ${capture.synced ? 'synced' : 'unsynced'}">
|
| 42 |
+
<h3>
|
| 43 |
+
${this.formatDate(capture.timestamp)}
|
| 44 |
+
${capture.synced ? '✅' : '⏳'}
|
| 45 |
+
</h3>
|
| 46 |
+
|
| 47 |
+
<p>
|
| 48 |
+
<strong>Especies:</strong>
|
| 49 |
+
${capture.items.map(item =>
|
| 50 |
+
`${this.getSpeciesName(item.speciesId)} (${item.quantity} ${item.unit === 'lbs' ? 'lbs' : 'unidades'})`
|
| 51 |
+
).join(', ')}
|
| 52 |
+
</p>
|
| 53 |
+
|
| 54 |
+
<p>
|
| 55 |
+
<strong>Método:</strong> ${capture.fishingMethod} |
|
| 56 |
+
<strong>Profundidad:</strong> ${capture.depth} brazadas
|
| 57 |
+
</p>
|
| 58 |
+
|
| 59 |
+
<p>
|
| 60 |
+
<strong>Ubicación:</strong>
|
| 61 |
+
${capture.latitude.toFixed(4)}, ${capture.longitude.toFixed(4)}
|
| 62 |
+
</p>
|
| 63 |
+
|
| 64 |
+
<p style="font-size: 0.85rem; color: #666;">
|
| 65 |
+
${capture.synced ?
|
| 66 |
+
`Sincronizada: ${this.formatDate(capture.syncedAt)}` :
|
| 67 |
+
'Pendiente de sincronización'
|
| 68 |
+
}
|
| 69 |
+
</p>
|
| 70 |
+
</li>
|
| 71 |
+
`).join('');
|
| 72 |
+
},
|
| 73 |
+
|
| 74 |
+
async init() {
|
| 75 |
+
// Load all captures from IndexedDB
|
| 76 |
+
this.captures = await dbService.getAllCaptures();
|
| 77 |
+
},
|
| 78 |
+
|
| 79 |
+
formatDate(dateString) {
|
| 80 |
+
if (!dateString) return 'N/A';
|
| 81 |
+
|
| 82 |
+
const date = new Date(dateString);
|
| 83 |
+
return date.toLocaleString('es-DO', {
|
| 84 |
+
year: 'numeric',
|
| 85 |
+
month: 'short',
|
| 86 |
+
day: 'numeric',
|
| 87 |
+
hour: '2-digit',
|
| 88 |
+
minute: '2-digit'
|
| 89 |
+
});
|
| 90 |
+
},
|
| 91 |
+
|
| 92 |
+
getSpeciesName(speciesId) {
|
| 93 |
+
const species = SpeciesSelectorComponent.allSpecies.find(s => s.id === speciesId);
|
| 94 |
+
return species ? species.commonName : speciesId;
|
| 95 |
+
}
|
| 96 |
+
};
|
static/js/components/home.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Home Component
|
| 3 |
+
* Main dashboard view
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const HomeComponent = {
|
| 7 |
+
render() {
|
| 8 |
+
return `
|
| 9 |
+
<div class="nav">
|
| 10 |
+
<h1>🎣 Mi Pesca RD</h1>
|
| 11 |
+
</div>
|
| 12 |
+
|
| 13 |
+
<div id="status-bar" class="status-bar">
|
| 14 |
+
<span id="online-status"></span>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<div class="content">
|
| 18 |
+
<div class="card">
|
| 19 |
+
<h2>Bienvenido</h2>
|
| 20 |
+
<p>Registra tus capturas de forma rápida y sencilla. Los datos se guardan localmente y se sincronizan automáticamente cuando tengas conexión.</p>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<button class="btn-primary btn-full" onclick="app.navigate('species-selector')">
|
| 24 |
+
➕ Nueva Captura
|
| 25 |
+
</button>
|
| 26 |
+
|
| 27 |
+
<button class="btn-secondary btn-full" onclick="app.navigate('history')">
|
| 28 |
+
📋 Ver Historial
|
| 29 |
+
</button>
|
| 30 |
+
|
| 31 |
+
<div class="card">
|
| 32 |
+
<h3>Estado de Sincronización</h3>
|
| 33 |
+
<p id="sync-info">Cargando...</p>
|
| 34 |
+
<button class="btn-secondary" onclick="syncService.syncNow()" id="sync-btn">
|
| 35 |
+
🔄 Sincronizar Ahora
|
| 36 |
+
</button>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
`;
|
| 40 |
+
},
|
| 41 |
+
|
| 42 |
+
async init() {
|
| 43 |
+
await this.updateSyncStatus();
|
| 44 |
+
|
| 45 |
+
// Update status every 5 seconds
|
| 46 |
+
this.statusInterval = setInterval(() => {
|
| 47 |
+
this.updateOnlineStatus();
|
| 48 |
+
}, 5000);
|
| 49 |
+
|
| 50 |
+
// Listen for sync events
|
| 51 |
+
window.addEventListener('syncComplete', () => {
|
| 52 |
+
this.updateSyncStatus();
|
| 53 |
+
});
|
| 54 |
+
},
|
| 55 |
+
|
| 56 |
+
updateOnlineStatus() {
|
| 57 |
+
const statusEl = document.getElementById('online-status');
|
| 58 |
+
if (statusEl) {
|
| 59 |
+
if (navigator.onLine) {
|
| 60 |
+
statusEl.innerHTML = '🟢 En línea';
|
| 61 |
+
statusEl.className = 'status-online';
|
| 62 |
+
} else {
|
| 63 |
+
statusEl.innerHTML = '🔴 Sin conexión';
|
| 64 |
+
statusEl.className = 'status-offline';
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
},
|
| 68 |
+
|
| 69 |
+
async updateSyncStatus() {
|
| 70 |
+
this.updateOnlineStatus();
|
| 71 |
+
|
| 72 |
+
const status = await syncService.getSyncStatus();
|
| 73 |
+
const syncInfo = document.getElementById('sync-info');
|
| 74 |
+
|
| 75 |
+
if (syncInfo) {
|
| 76 |
+
if (status.unsyncedCount === 0) {
|
| 77 |
+
syncInfo.innerHTML = '✅ Todas las capturas sincronizadas';
|
| 78 |
+
} else {
|
| 79 |
+
syncInfo.innerHTML = `⚠️ ${status.unsyncedCount} captura(s) pendiente(s) de sincronizar`;
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
},
|
| 83 |
+
|
| 84 |
+
destroy() {
|
| 85 |
+
if (this.statusInterval) {
|
| 86 |
+
clearInterval(this.statusInterval);
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
};
|
static/js/components/species-selector.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Species Selector Component
|
| 3 |
+
* Visual species picker with images
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const SpeciesSelectorComponent = {
|
| 7 |
+
selectedSpecies: [],
|
| 8 |
+
allSpecies: [],
|
| 9 |
+
|
| 10 |
+
render() {
|
| 11 |
+
return `
|
| 12 |
+
<div class="nav">
|
| 13 |
+
<button class="nav-back" onclick="app.navigate('home')">←</button>
|
| 14 |
+
<h1>Seleccionar Especies</h1>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<div class="content">
|
| 18 |
+
<div class="card">
|
| 19 |
+
<p>Selecciona las especies capturadas (puedes elegir varias):</p>
|
| 20 |
+
<p><strong>${this.selectedSpecies.length}</strong> especie(s) seleccionada(s)</p>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<div id="species-grid" class="species-grid">
|
| 24 |
+
${this.renderSpeciesGrid()}
|
| 25 |
+
</div>
|
| 26 |
+
|
| 27 |
+
<button
|
| 28 |
+
class="btn-primary btn-full"
|
| 29 |
+
onclick="SpeciesSelectorComponent.continue()"
|
| 30 |
+
${this.selectedSpecies.length === 0 ? 'disabled' : ''}
|
| 31 |
+
>
|
| 32 |
+
Continuar →
|
| 33 |
+
</button>
|
| 34 |
+
</div>
|
| 35 |
+
`;
|
| 36 |
+
},
|
| 37 |
+
|
| 38 |
+
renderSpeciesGrid() {
|
| 39 |
+
if (this.allSpecies.length === 0) {
|
| 40 |
+
return '<p>Cargando especies...</p>';
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return this.allSpecies.map(species => {
|
| 44 |
+
const isSelected = this.selectedSpecies.includes(species.id);
|
| 45 |
+
const isProtected = species.protected;
|
| 46 |
+
const hasVeda = species.closedSeason;
|
| 47 |
+
|
| 48 |
+
return `
|
| 49 |
+
<div
|
| 50 |
+
class="species-card ${isSelected ? 'selected' : ''} ${isProtected ? 'protected' : ''}"
|
| 51 |
+
onclick="SpeciesSelectorComponent.toggleSpecies('${species.id}')"
|
| 52 |
+
>
|
| 53 |
+
<img src="${species.imageUrl}" alt="${species.commonName}" onerror="this.src='/assets/species/placeholder.jpg'">
|
| 54 |
+
<h3>${species.commonName}</h3>
|
| 55 |
+
<p style="font-size: 0.8rem; color: #666; margin: 0;">${species.scientificName}</p>
|
| 56 |
+
${isProtected ? '<span class="badge badge-protected">PROTEGIDA</span>' : ''}
|
| 57 |
+
${hasVeda ? '<span class="badge badge-veda">VEDA</span>' : ''}
|
| 58 |
+
</div>
|
| 59 |
+
`;
|
| 60 |
+
}).join('');
|
| 61 |
+
},
|
| 62 |
+
|
| 63 |
+
async init() {
|
| 64 |
+
this.selectedSpecies = [];
|
| 65 |
+
|
| 66 |
+
// Load species from IndexedDB
|
| 67 |
+
let species = await dbService.getAllSpecies();
|
| 68 |
+
|
| 69 |
+
// If no species in DB, fetch from API
|
| 70 |
+
if (species.length === 0) {
|
| 71 |
+
try {
|
| 72 |
+
species = await apiService.getSpecies();
|
| 73 |
+
await dbService.saveSpecies(species);
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error('Error loading species:', error);
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
this.allSpecies = species;
|
| 80 |
+
},
|
| 81 |
+
|
| 82 |
+
toggleSpecies(speciesId) {
|
| 83 |
+
const index = this.selectedSpecies.indexOf(speciesId);
|
| 84 |
+
|
| 85 |
+
if (index > -1) {
|
| 86 |
+
this.selectedSpecies.splice(index, 1);
|
| 87 |
+
} else {
|
| 88 |
+
this.selectedSpecies.push(speciesId);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// Re-render
|
| 92 |
+
app.render();
|
| 93 |
+
},
|
| 94 |
+
|
| 95 |
+
continue() {
|
| 96 |
+
if (this.selectedSpecies.length > 0) {
|
| 97 |
+
// Store selected species in app state
|
| 98 |
+
app.state.selectedSpecies = [...this.selectedSpecies];
|
| 99 |
+
app.navigate('capture-form');
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
};
|
static/js/db.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* IndexedDB Database Service using Dexie.js
|
| 3 |
+
* Migrated from Angular db.service.ts
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const db = new Dexie('MiPescaDB');
|
| 7 |
+
|
| 8 |
+
// Define database schema
|
| 9 |
+
db.version(1).stores({
|
| 10 |
+
captures: 'id, timestamp, synced',
|
| 11 |
+
species: 'id, commonName, category'
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
// Database operations
|
| 15 |
+
const dbService = {
|
| 16 |
+
/**
|
| 17 |
+
* Add a new capture to the database
|
| 18 |
+
*/
|
| 19 |
+
async addCapture(capture) {
|
| 20 |
+
return await db.captures.add(capture);
|
| 21 |
+
},
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Get all captures
|
| 25 |
+
*/
|
| 26 |
+
async getAllCaptures() {
|
| 27 |
+
return await db.captures.toArray();
|
| 28 |
+
},
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Get unsynced captures
|
| 32 |
+
*/
|
| 33 |
+
async getUnsyncedCaptures() {
|
| 34 |
+
return await db.captures.where('synced').equals(false).toArray();
|
| 35 |
+
},
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* Update sync status of a capture
|
| 39 |
+
*/
|
| 40 |
+
async updateSyncStatus(id, synced) {
|
| 41 |
+
return await db.captures.update(id, {
|
| 42 |
+
synced: synced,
|
| 43 |
+
syncedAt: new Date()
|
| 44 |
+
});
|
| 45 |
+
},
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Save species list to database
|
| 49 |
+
*/
|
| 50 |
+
async saveSpecies(speciesList) {
|
| 51 |
+
return await db.species.bulkPut(speciesList);
|
| 52 |
+
},
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Get all species
|
| 56 |
+
*/
|
| 57 |
+
async getAllSpecies() {
|
| 58 |
+
return await db.species.toArray();
|
| 59 |
+
},
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Clear all data (for testing)
|
| 63 |
+
*/
|
| 64 |
+
async clearAll() {
|
| 65 |
+
await db.captures.clear();
|
| 66 |
+
await db.species.clear();
|
| 67 |
+
}
|
| 68 |
+
};
|
static/js/geolocation.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Geolocation Service
|
| 3 |
+
* Migrated from Angular geolocation.service.ts
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const geolocationService = {
|
| 7 |
+
/**
|
| 8 |
+
* Get current GPS coordinates
|
| 9 |
+
*/
|
| 10 |
+
async getCurrentPosition() {
|
| 11 |
+
return new Promise((resolve, reject) => {
|
| 12 |
+
if (!navigator.geolocation) {
|
| 13 |
+
reject(new Error('Geolocation no está soportada por este navegador'));
|
| 14 |
+
return;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
navigator.geolocation.getCurrentPosition(
|
| 18 |
+
(position) => {
|
| 19 |
+
resolve({
|
| 20 |
+
latitude: position.coords.latitude,
|
| 21 |
+
longitude: position.coords.longitude,
|
| 22 |
+
accuracy: position.coords.accuracy,
|
| 23 |
+
timestamp: new Date(position.timestamp)
|
| 24 |
+
});
|
| 25 |
+
},
|
| 26 |
+
(error) => {
|
| 27 |
+
let errorMessage = 'Error obteniendo ubicación';
|
| 28 |
+
|
| 29 |
+
switch (error.code) {
|
| 30 |
+
case error.PERMISSION_DENIED:
|
| 31 |
+
errorMessage = 'Permiso de ubicación denegado';
|
| 32 |
+
break;
|
| 33 |
+
case error.POSITION_UNAVAILABLE:
|
| 34 |
+
errorMessage = 'Ubicación no disponible';
|
| 35 |
+
break;
|
| 36 |
+
case error.TIMEOUT:
|
| 37 |
+
errorMessage = 'Tiempo de espera agotado';
|
| 38 |
+
break;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
reject(new Error(errorMessage));
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
enableHighAccuracy: true,
|
| 45 |
+
timeout: 10000,
|
| 46 |
+
maximumAge: 0
|
| 47 |
+
}
|
| 48 |
+
);
|
| 49 |
+
});
|
| 50 |
+
},
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Watch position for continuous tracking
|
| 54 |
+
*/
|
| 55 |
+
watchPosition(onSuccess, onError) {
|
| 56 |
+
if (!navigator.geolocation) {
|
| 57 |
+
onError(new Error('Geolocation no está soportada'));
|
| 58 |
+
return null;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return navigator.geolocation.watchPosition(
|
| 62 |
+
(position) => {
|
| 63 |
+
onSuccess({
|
| 64 |
+
latitude: position.coords.latitude,
|
| 65 |
+
longitude: position.coords.longitude,
|
| 66 |
+
accuracy: position.coords.accuracy,
|
| 67 |
+
timestamp: new Date(position.timestamp)
|
| 68 |
+
});
|
| 69 |
+
},
|
| 70 |
+
(error) => {
|
| 71 |
+
onError(error);
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
enableHighAccuracy: true,
|
| 75 |
+
timeout: 10000,
|
| 76 |
+
maximumAge: 0
|
| 77 |
+
}
|
| 78 |
+
);
|
| 79 |
+
},
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* Clear position watch
|
| 83 |
+
*/
|
| 84 |
+
clearWatch(watchId) {
|
| 85 |
+
if (watchId && navigator.geolocation) {
|
| 86 |
+
navigator.geolocation.clearWatch(watchId);
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
};
|
static/js/sync.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Synchronization Service
|
| 3 |
+
* Migrated from Angular sync.service.ts
|
| 4 |
+
* Handles offline-online data synchronization
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
const syncService = {
|
| 8 |
+
isSyncing: false,
|
| 9 |
+
syncInterval: null,
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Initialize sync service
|
| 13 |
+
*/
|
| 14 |
+
init() {
|
| 15 |
+
// Listen for online/offline events
|
| 16 |
+
window.addEventListener('online', () => {
|
| 17 |
+
console.log('Connection restored, syncing...');
|
| 18 |
+
this.syncNow();
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
window.addEventListener('offline', () => {
|
| 22 |
+
console.log('Connection lost, working offline');
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
// Auto-sync every 5 minutes when online
|
| 26 |
+
this.syncInterval = setInterval(() => {
|
| 27 |
+
if (navigator.onLine) {
|
| 28 |
+
this.syncNow();
|
| 29 |
+
}
|
| 30 |
+
}, 5 * 60 * 1000);
|
| 31 |
+
|
| 32 |
+
// Initial sync if online
|
| 33 |
+
if (navigator.onLine) {
|
| 34 |
+
this.syncNow();
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Sync unsynced captures to backend
|
| 40 |
+
*/
|
| 41 |
+
async syncNow() {
|
| 42 |
+
if (this.isSyncing || !navigator.onLine) {
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
this.isSyncing = true;
|
| 47 |
+
|
| 48 |
+
try {
|
| 49 |
+
const unsyncedCaptures = await dbService.getUnsyncedCaptures();
|
| 50 |
+
|
| 51 |
+
if (unsyncedCaptures.length === 0) {
|
| 52 |
+
console.log('No captures to sync');
|
| 53 |
+
this.isSyncing = false;
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
console.log(`Syncing ${unsyncedCaptures.length} captures...`);
|
| 58 |
+
|
| 59 |
+
const result = await apiService.syncCaptures(unsyncedCaptures);
|
| 60 |
+
|
| 61 |
+
// Update sync status for successfully synced captures
|
| 62 |
+
for (const id of result.synced) {
|
| 63 |
+
await dbService.updateSyncStatus(id, true);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
console.log(`Successfully synced ${result.synced.length} captures`);
|
| 67 |
+
|
| 68 |
+
// Dispatch event for UI updates
|
| 69 |
+
window.dispatchEvent(new CustomEvent('syncComplete', {
|
| 70 |
+
detail: {
|
| 71 |
+
synced: result.synced.length,
|
| 72 |
+
errors: result.errors.length
|
| 73 |
+
}
|
| 74 |
+
}));
|
| 75 |
+
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error('Sync failed:', error);
|
| 78 |
+
} finally {
|
| 79 |
+
this.isSyncing = false;
|
| 80 |
+
}
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Get sync status
|
| 85 |
+
*/
|
| 86 |
+
async getSyncStatus() {
|
| 87 |
+
const unsyncedCount = (await dbService.getUnsyncedCaptures()).length;
|
| 88 |
+
return {
|
| 89 |
+
isOnline: navigator.onLine,
|
| 90 |
+
isSyncing: this.isSyncing,
|
| 91 |
+
unsyncedCount: unsyncedCount
|
| 92 |
+
};
|
| 93 |
+
},
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Stop sync service
|
| 97 |
+
*/
|
| 98 |
+
stop() {
|
| 99 |
+
if (this.syncInterval) {
|
| 100 |
+
clearInterval(this.syncInterval);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
};
|
static/manifest.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Mi Pesca RD",
|
| 3 |
+
"short_name": "MiPesca",
|
| 4 |
+
"description": "Aplicación para registro de capturas pesqueras en República Dominicana",
|
| 5 |
+
"start_url": "/",
|
| 6 |
+
"display": "standalone",
|
| 7 |
+
"background_color": "#ffffff",
|
| 8 |
+
"theme_color": "#0D47A1",
|
| 9 |
+
"orientation": "portrait",
|
| 10 |
+
"icons": [
|
| 11 |
+
{
|
| 12 |
+
"src": "/assets/icon-192.png",
|
| 13 |
+
"sizes": "192x192",
|
| 14 |
+
"type": "image/png",
|
| 15 |
+
"purpose": "any maskable"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"src": "/assets/icon-512.png",
|
| 19 |
+
"sizes": "512x512",
|
| 20 |
+
"type": "image/png",
|
| 21 |
+
"purpose": "any maskable"
|
| 22 |
+
}
|
| 23 |
+
],
|
| 24 |
+
"categories": [
|
| 25 |
+
"productivity",
|
| 26 |
+
"utilities"
|
| 27 |
+
],
|
| 28 |
+
"lang": "es-DO"
|
| 29 |
+
}
|
static/sw.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Service Worker for PWA
|
| 3 |
+
* Handles offline functionality and caching
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
const CACHE_NAME = 'mipesca-v1';
|
| 7 |
+
const STATIC_CACHE = [
|
| 8 |
+
'/',
|
| 9 |
+
'/index.html',
|
| 10 |
+
'/css/styles.css',
|
| 11 |
+
'/js/app.js',
|
| 12 |
+
'/js/db.js',
|
| 13 |
+
'/js/geolocation.js',
|
| 14 |
+
'/js/api.js',
|
| 15 |
+
'/js/sync.js',
|
| 16 |
+
'/js/components/home.js',
|
| 17 |
+
'/js/components/species-selector.js',
|
| 18 |
+
'/js/components/capture-form.js',
|
| 19 |
+
'/js/components/confirmation.js',
|
| 20 |
+
'/js/components/history.js',
|
| 21 |
+
'/manifest.json',
|
| 22 |
+
'https://unpkg.com/dexie@3.2.4/dist/dexie.min.js'
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
// Install event - cache static assets
|
| 26 |
+
self.addEventListener('install', (event) => {
|
| 27 |
+
console.log('Service Worker installing...');
|
| 28 |
+
|
| 29 |
+
event.waitUntil(
|
| 30 |
+
caches.open(CACHE_NAME)
|
| 31 |
+
.then((cache) => {
|
| 32 |
+
console.log('Caching static assets');
|
| 33 |
+
return cache.addAll(STATIC_CACHE);
|
| 34 |
+
})
|
| 35 |
+
.then(() => self.skipWaiting())
|
| 36 |
+
);
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
// Activate event - clean up old caches
|
| 40 |
+
self.addEventListener('activate', (event) => {
|
| 41 |
+
console.log('Service Worker activating...');
|
| 42 |
+
|
| 43 |
+
event.waitUntil(
|
| 44 |
+
caches.keys().then((cacheNames) => {
|
| 45 |
+
return Promise.all(
|
| 46 |
+
cacheNames.map((cacheName) => {
|
| 47 |
+
if (cacheName !== CACHE_NAME) {
|
| 48 |
+
console.log('Deleting old cache:', cacheName);
|
| 49 |
+
return caches.delete(cacheName);
|
| 50 |
+
}
|
| 51 |
+
})
|
| 52 |
+
);
|
| 53 |
+
}).then(() => self.clients.claim())
|
| 54 |
+
);
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
// Fetch event - serve from cache, fallback to network
|
| 58 |
+
self.addEventListener('fetch', (event) => {
|
| 59 |
+
const { request } = event;
|
| 60 |
+
const url = new URL(request.url);
|
| 61 |
+
|
| 62 |
+
// API requests - network first, then cache
|
| 63 |
+
if (url.pathname.startsWith('/api/')) {
|
| 64 |
+
event.respondWith(
|
| 65 |
+
fetch(request)
|
| 66 |
+
.then((response) => {
|
| 67 |
+
// Clone response to cache it
|
| 68 |
+
const responseClone = response.clone();
|
| 69 |
+
caches.open(CACHE_NAME).then((cache) => {
|
| 70 |
+
cache.put(request, responseClone);
|
| 71 |
+
});
|
| 72 |
+
return response;
|
| 73 |
+
})
|
| 74 |
+
.catch(() => {
|
| 75 |
+
// If network fails, try cache
|
| 76 |
+
return caches.match(request);
|
| 77 |
+
})
|
| 78 |
+
);
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Static assets - cache first, then network
|
| 83 |
+
event.respondWith(
|
| 84 |
+
caches.match(request)
|
| 85 |
+
.then((cachedResponse) => {
|
| 86 |
+
if (cachedResponse) {
|
| 87 |
+
return cachedResponse;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
return fetch(request)
|
| 91 |
+
.then((response) => {
|
| 92 |
+
// Don't cache non-successful responses
|
| 93 |
+
if (!response || response.status !== 200 || response.type === 'error') {
|
| 94 |
+
return response;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Clone response to cache it
|
| 98 |
+
const responseClone = response.clone();
|
| 99 |
+
caches.open(CACHE_NAME).then((cache) => {
|
| 100 |
+
cache.put(request, responseClone);
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
return response;
|
| 104 |
+
});
|
| 105 |
+
})
|
| 106 |
+
.catch(() => {
|
| 107 |
+
// Return offline page if available
|
| 108 |
+
if (request.mode === 'navigate') {
|
| 109 |
+
return caches.match('/index.html');
|
| 110 |
+
}
|
| 111 |
+
})
|
| 112 |
+
);
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
// Background sync for captures
|
| 116 |
+
self.addEventListener('sync', (event) => {
|
| 117 |
+
if (event.tag === 'sync-captures') {
|
| 118 |
+
console.log('Background sync triggered');
|
| 119 |
+
event.waitUntil(
|
| 120 |
+
// Notify clients to sync
|
| 121 |
+
self.clients.matchAll().then((clients) => {
|
| 122 |
+
clients.forEach((client) => {
|
| 123 |
+
client.postMessage({
|
| 124 |
+
type: 'BACKGROUND_SYNC',
|
| 125 |
+
action: 'sync-captures'
|
| 126 |
+
});
|
| 127 |
+
});
|
| 128 |
+
})
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
});
|
tsconfig.app.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
| 1 |
-
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
| 2 |
-
{
|
| 3 |
-
"extends": "./tsconfig.json",
|
| 4 |
-
"compilerOptions": {
|
| 5 |
-
"outDir": "./out-tsc/app",
|
| 6 |
-
"types": [
|
| 7 |
-
"node"
|
| 8 |
-
]
|
| 9 |
-
},
|
| 10 |
-
"files": [
|
| 11 |
-
"src/main.ts",
|
| 12 |
-
"src/main.server.ts",
|
| 13 |
-
"server.ts"
|
| 14 |
-
],
|
| 15 |
-
"include": [
|
| 16 |
-
"src/**/*.d.ts"
|
| 17 |
-
]
|
| 18 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tsconfig.json
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
| 2 |
-
{
|
| 3 |
-
"compileOnSave": false,
|
| 4 |
-
"compilerOptions": {
|
| 5 |
-
"outDir": "./dist/out-tsc",
|
| 6 |
-
"strict": true,
|
| 7 |
-
"noImplicitOverride": true,
|
| 8 |
-
"noPropertyAccessFromIndexSignature": true,
|
| 9 |
-
"noImplicitReturns": true,
|
| 10 |
-
"noFallthroughCasesInSwitch": true,
|
| 11 |
-
"skipLibCheck": true,
|
| 12 |
-
"esModuleInterop": true,
|
| 13 |
-
"sourceMap": true,
|
| 14 |
-
"declaration": false,
|
| 15 |
-
"experimentalDecorators": true,
|
| 16 |
-
"moduleResolution": "node",
|
| 17 |
-
"importHelpers": true,
|
| 18 |
-
"target": "ES2022",
|
| 19 |
-
"module": "ES2022",
|
| 20 |
-
"useDefineForClassFields": false,
|
| 21 |
-
"lib": [
|
| 22 |
-
"ES2022",
|
| 23 |
-
"dom"
|
| 24 |
-
]
|
| 25 |
-
},
|
| 26 |
-
"angularCompilerOptions": {
|
| 27 |
-
"enableI18nLegacyMessageIdFormat": false,
|
| 28 |
-
"strictInjectionParameters": true,
|
| 29 |
-
"strictInputAccessModifiers": true,
|
| 30 |
-
"strictTemplates": true
|
| 31 |
-
}
|
| 32 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tsconfig.spec.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
| 1 |
-
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
| 2 |
-
{
|
| 3 |
-
"extends": "./tsconfig.json",
|
| 4 |
-
"compilerOptions": {
|
| 5 |
-
"outDir": "./out-tsc/spec",
|
| 6 |
-
"types": [
|
| 7 |
-
"jasmine"
|
| 8 |
-
]
|
| 9 |
-
},
|
| 10 |
-
"include": [
|
| 11 |
-
"src/**/*.spec.ts",
|
| 12 |
-
"src/**/*.d.ts"
|
| 13 |
-
]
|
| 14 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|