merge: unificar README raíz con backend y firmware
This commit is contained in:
commit
0d4b126945
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
46
.env.example
Normal file
46
.env.example
Normal file
@ -0,0 +1,46 @@
|
||||
APP_NAME=DummyBot
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=es
|
||||
APP_FALLBACK_LOCALE=es
|
||||
APP_FAKER_LOCALE=es_ES
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=info
|
||||
|
||||
# ───────── DB (valores consumidos por docker-compose) ─────────
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=dummybot
|
||||
DB_USERNAME=dummybot
|
||||
DB_PASSWORD=
|
||||
DB_ROOT_PASSWORD=
|
||||
|
||||
# ───────── Compose vars (no leídos por Laravel pero los usa docker-compose) ─────────
|
||||
AUTO_MIGRATE=0
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
/docker-data/
|
||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
@ -0,0 +1,56 @@
|
||||
FROM php:8.4-fpm
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
libpng-dev \
|
||||
libjpeg62-turbo-dev \
|
||||
libfreetype6-dev \
|
||||
locales \
|
||||
zip \
|
||||
jpegoptim optipng pngquant gifsicle \
|
||||
vim \
|
||||
unzip \
|
||||
git \
|
||||
curl \
|
||||
libonig-dev \
|
||||
libxml2-dev \
|
||||
libzip-dev \
|
||||
nodejs \
|
||||
npm
|
||||
|
||||
# Clear cache
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www
|
||||
|
||||
# Copy application code
|
||||
COPY . /var/www
|
||||
|
||||
# Install PHP dependencies
|
||||
RUN composer install --optimize-autoloader --no-dev
|
||||
|
||||
# Install Node dependencies and build assets
|
||||
RUN npm install && npm run build
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Change ownership
|
||||
RUN chown -R www-data:www-data /var/www
|
||||
RUN chmod -R 755 /var/www/storage
|
||||
RUN chmod -R 755 /var/www/bootstrap/cache
|
||||
|
||||
# Expose port
|
||||
EXPOSE 9000
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["php-fpm"]
|
||||
214
README.md
214
README.md
@ -1,105 +1,153 @@
|
||||
# DummyBot
|
||||
# DummyBot Backend
|
||||
|
||||
Bot físico de tareas para personas con TDAH, basado en **ESP32** + **pantalla OLED SSD1306 128x64 (I2C)** + **3 botones TTP223** + **vibrador**.
|
||||
API REST y UI web para **DummyBot** — un bot físico de tareas para personas con TDAH, basado en ESP32 + ST7789 + caritas kawaii.
|
||||
|
||||
Las tareas se gestionan desde una **mini-app web Laravel** (dark mode, atajos de teclado, responsive). El ESP32 hace polling HTTP cada 30 s y vibra cuando llega una tarea nueva. Se configura por **captive portal**: abre un AP `DummyBot-Setup` con un formulario web.
|
||||
Stack: **Laravel 12 · PHP 8.4 · MySQL 8 · Sanctum · Tailwind (CDN) + Alpine.js (CDN)**.
|
||||
|
||||
## Arquitectura
|
||||
---
|
||||
|
||||
```
|
||||
┌──────────────────┐ HTTP/JSON (Bearer token) ┌──────────────────────┐
|
||||
│ Navegador web │ ◀───────────────────────────▶ │ Laravel + MySQL │
|
||||
│ (Tailwind+Alpine)│ │ /api/auth │
|
||||
└──────────────────┘ │ /api/tasks │
|
||||
│ /api/projects │
|
||||
│ /api/tags │
|
||||
│ /api/devices │
|
||||
│ /api/device/... │
|
||||
└──────────┬───────────┘
|
||||
│ HTTP
|
||||
│ (device token)
|
||||
┌──────────▼───────────┐
|
||||
│ ESP32 │
|
||||
│ OLED SSD1306 128x64│
|
||||
│ 3 botones TTP223 │
|
||||
│ Vibrador │
|
||||
└──────────────────────┘
|
||||
## 🚀 Setup rápido
|
||||
|
||||
```bash
|
||||
# 1. Crear BD
|
||||
mysql -u root -p
|
||||
> CREATE DATABASE dummybot CHARACTER SET utf8mb4;
|
||||
> CREATE USER 'dummybot'@'localhost' IDENTIFIED BY 'TU_PASSWORD';
|
||||
> GRANT ALL ON dummybot.* TO 'dummybot'@'localhost';
|
||||
|
||||
# 2. Configurar
|
||||
cp .env.example .env
|
||||
# Edita DB_PASSWORD, APP_URL, etc.
|
||||
|
||||
# 3. Instalar + migrar
|
||||
composer install --no-dev --optimize-autoloader
|
||||
php artisan key:generate
|
||||
php artisan migrate
|
||||
|
||||
# 4. Arrancar
|
||||
php artisan serve --host=0.0.0.0 --port=8000
|
||||
```
|
||||
|
||||
## Estructura del proyecto
|
||||
Abre `http://localhost:8000/`. Crea una cuenta, ve a **Ajustes → Dispositivos** y crea tu primer DummyBot para obtener un token.
|
||||
|
||||
```
|
||||
dummybot/
|
||||
├── firmware/ # PlatformIO – ESP32 Arduino
|
||||
│ ├── platformio.ini
|
||||
│ ├── src/main.cpp # Lógica principal (OLED + WiFi + portal)
|
||||
│ ├── partitions.csv
|
||||
│ └── README.md
|
||||
└── backend-laravel/ # Laravel 12 (PHP 8.4, MySQL, Sanctum)
|
||||
├── app/Models/Task.php
|
||||
├── app/Models/Project.php
|
||||
├── app/Models/Tag.php
|
||||
├── app/Models/Device.php
|
||||
├── app/Http/Controllers/Auth/ # AuthController, LoginController
|
||||
├── app/Http/Controllers/Api/ # Project, Tag, Device
|
||||
├── app/Http/Controllers/TaskController.php
|
||||
├── app/Http/Controllers/PageController.php
|
||||
├── app/Http/Middleware/AuthenticateDevice.php
|
||||
├── database/migrations/
|
||||
├── resources/views/pages/ # home, login, register, app, settings
|
||||
├── resources/views/layouts/base.blade.php
|
||||
├── routes/{web,api}.php
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── nginx.conf
|
||||
## 🐳 Docker
|
||||
|
||||
```bash
|
||||
AUTO_MIGRATE=1 docker compose up -d --build
|
||||
```
|
||||
|
||||
## Endpoints clave (resumen)
|
||||
Esto levanta `dummybot_app` (PHP-FPM), `dummybot_web` (nginx) y `dummybot_db` (MySQL 8). El puerto 80 queda interno (el proxy externo ya está gestionado en producción).
|
||||
|
||||
Ver [`backend-laravel/README.md`](backend-laravel/README.md) para detalle completo.
|
||||
## 🧠 Modelo de datos
|
||||
|
||||
| Tabla | Descripción |
|
||||
|------------|-------------|
|
||||
| `users` | Usuarios (auth vía Sanctum) |
|
||||
| `tasks` | Tareas (`title`, `description`, `priority`, `estimate_min`, `deadline`, `recurrence`, `done`, `snooze_until`, `done_at`, `project_id`, `user_id`) |
|
||||
| `projects` | Agrupa tareas (color + icono) |
|
||||
| `tags` | Etiquetas transversales (muchas a muchas con tasks) |
|
||||
| `devices` | Cada ESP32 con su propio `token` y `last_seen_at` |
|
||||
|
||||
## 🔌 API REST
|
||||
|
||||
Todas las rutas devuelven JSON. Auth vía `Authorization: Bearer <token>`.
|
||||
|
||||
### Auth (web)
|
||||
```
|
||||
POST /api/auth/register
|
||||
POST /api/auth/login
|
||||
POST /api/auth/register {name, email, password, password_confirmation}
|
||||
POST /api/auth/login {email, password}
|
||||
POST /api/auth/logout (auth)
|
||||
GET /api/auth/me (auth)
|
||||
|
||||
GET /api/tasks (auth)
|
||||
POST /api/tasks (auth)
|
||||
POST /api/tasks/{id}/done (auth o device)
|
||||
POST /api/tasks/{id}/snooze (auth o device)
|
||||
GET /api/tasks/stats (auth)
|
||||
|
||||
GET /api/projects (auth)
|
||||
POST /api/projects (auth)
|
||||
|
||||
GET /api/tags (auth)
|
||||
POST /api/tags (auth)
|
||||
|
||||
GET /api/devices (auth)
|
||||
POST /api/devices → devuelve token (solo 1 vez)
|
||||
|
||||
# Para el ESP32 (auth.device)
|
||||
GET /api/device/tasks/pending
|
||||
POST /api/device/heartbeat
|
||||
```
|
||||
|
||||
## Setup del usuario
|
||||
### Tareas (web, con sesión o token Sanctum)
|
||||
```
|
||||
GET /api/tasks Listar (con project + tags)
|
||||
POST /api/tasks Crear
|
||||
PUT /api/tasks/{id} Editar
|
||||
DELETE /api/tasks/{id} Borrar
|
||||
POST /api/tasks/{id}/done Marcar como hecha (genera recurrente si aplica)
|
||||
POST /api/tasks/{id}/snooze Posponer {minutes: N}
|
||||
POST /api/tasks/{id}/restore Reabrir
|
||||
GET /api/tasks/stats {pending, today, done_today, overdue, total_done}
|
||||
```
|
||||
|
||||
1. Abre `http://157.180.77.232:18080/` (o `http://localhost:8000/` en dev).
|
||||
2. Crea una cuenta.
|
||||
3. Ve a **Ajustes → Dispositivos**, crea tu DummyBot y **guarda el token**.
|
||||
4. Flashea el firmware (ver `firmware/README.md`).
|
||||
5. En el primer arranque, el ESP32 abre un AP `DummyBot-Setup`. Conéctate desde el móvil y configura WiFi + token desde el formulario web.
|
||||
6. Tras guardar, el bot se reinicia y se conecta solo.
|
||||
### Proyectos
|
||||
```
|
||||
GET /api/projects
|
||||
POST /api/projects {name, color, icon}
|
||||
PUT /api/projects/{id}
|
||||
DELETE /api/projects/{id}
|
||||
```
|
||||
|
||||
## Hardware
|
||||
### Tags
|
||||
```
|
||||
GET /api/tags
|
||||
POST /api/tags {name, color}
|
||||
DELETE /api/tags/{id}
|
||||
```
|
||||
|
||||
- **ESP32-D0WD-V3** (DevKitC / WROOM-32, 4 MB flash)
|
||||
- **Pantalla OLED SSD1306 0.96"** (128x64, I2C, 4 pines: GND/VDD/SCK/SDA)
|
||||
- **3× TTP223** (botones táctiles: Siguiente / Hecha / Snooze)
|
||||
- **1× motor vibrador 1027** (3V DC, controlado por MOSFET en GPIO 25)
|
||||
### Dispositivos (DummyBots)
|
||||
```
|
||||
GET /api/devices Tus DummyBots
|
||||
POST /api/devices {name, hardware?, firmware_version?} → devuelve token (solo 1 vez)
|
||||
POST /api/devices/{id}/rotate Invalida y crea nuevo token
|
||||
DELETE /api/devices/{id}
|
||||
```
|
||||
|
||||
Ver [`firmware/README.md`](firmware/README.md) para detalle completo de cableado.
|
||||
### Para el ESP32 (auth.device, con token del dispositivo)
|
||||
```
|
||||
GET /api/device/tasks/pending Polling cada 30s
|
||||
POST /api/device/tasks Crear tarea (desde bot, ej. comandos)
|
||||
POST /api/device/tasks/{id}/done
|
||||
POST /api/device/tasks/{id}/snooze
|
||||
DELETE /api/device/tasks/{id}
|
||||
POST /api/device/heartbeat Ping (refresca last_seen_at)
|
||||
```
|
||||
|
||||
## 🎨 Frontend (Blade + Alpine)
|
||||
|
||||
- `pages/home.blade.php` — landing
|
||||
- `pages/login.blade.php` / `pages/register.blade.php` — auth
|
||||
- `pages/app.blade.php` — **app principal** (lista tareas, filtros, atajos teclado)
|
||||
- `pages/settings.blade.php` — gestión de dispositivos, proyectos, tags
|
||||
|
||||
### Atajos de teclado (en `/app`)
|
||||
|
||||
- `n` → nueva tarea
|
||||
- `/` → buscador
|
||||
- `Esc` → cerrar modal
|
||||
- `1` / `2` / `3` / `4` → filtro Todas / Pendientes / Hoy / Hechas
|
||||
- `s` → snooze 5 min (sobre tarea enfocada)
|
||||
|
||||
### TDAH-friendly
|
||||
|
||||
- **Sin gamificación tóxica** (sin puntos, sin streaks).
|
||||
- **No judgemental**: "sin tareas" muestra un emoji festivo, no un mensaje triste.
|
||||
- **Dark mode por defecto** con gradientes suaves.
|
||||
- **Atajos de teclado** para no tener que tocar el ratón.
|
||||
- **Filtros contextuales** (Hoy, Vencidas, Por proyecto) para evitar el agobio del "todo".
|
||||
|
||||
## 🔌 Webhooks (TODO)
|
||||
|
||||
Pendiente: hook de salida cuando se crea/marca una tarea. Pensado para integrarse con Notion, Zapier, n8n, etc.
|
||||
|
||||
## 📋 Importar / Exportar (TODO)
|
||||
|
||||
Endpoints para CSV/JSON de tareas.
|
||||
|
||||
## 🚀 Despliegue en producción
|
||||
|
||||
Ya está dockerizado. El repo está en `https://git.anabolictrack.com/javiservices/dummybot`.
|
||||
|
||||
```bash
|
||||
# En el server
|
||||
cd /root/dummybot
|
||||
git pull
|
||||
AUTO_MIGRATE=1 docker compose up -d --build
|
||||
```
|
||||
|
||||
Para que el proxy-manager lo exponga en HTTPS, añadir host en `http://157.180.77.232:81` apuntando al contenedor `dummybot_web:80`.
|
||||
|
||||
## Licencia
|
||||
|
||||
|
||||
41
app/Http/Controllers/Api/DeviceController.php
Normal file
41
app/Http/Controllers/Api/DeviceController.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DeviceController extends Controller {
|
||||
public function index(Request $r): JsonResponse {
|
||||
return response()->json($r->user()->devices()->orderBy('name')->get());
|
||||
}
|
||||
|
||||
public function store(Request $r): JsonResponse {
|
||||
$data = $r->validate([
|
||||
'name' => 'required|string|max:60',
|
||||
'hardware' => 'nullable|string|max:60',
|
||||
'firmware_version' => 'nullable|string|max:30',
|
||||
]);
|
||||
$device = $r->user()->devices()->create([
|
||||
'name' => $data['name'],
|
||||
'hardware' => $data['hardware'] ?? 'esp32',
|
||||
'firmware_version' => $data['firmware_version'] ?? null,
|
||||
'token' => Device::makeToken(),
|
||||
]);
|
||||
return response()->json($device, 201);
|
||||
}
|
||||
|
||||
public function rotate(Request $r, Device $device): JsonResponse {
|
||||
abort_unless($device->user_id === $r->user()->id, 403);
|
||||
$device->update(['token' => Device::makeToken()]);
|
||||
return response()->json($device);
|
||||
}
|
||||
|
||||
public function destroy(Request $r, Device $device): JsonResponse {
|
||||
abort_unless($device->user_id === $r->user()->id, 403);
|
||||
$device->delete();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/Api/ProjectController.php
Normal file
43
app/Http/Controllers/Api/ProjectController.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProjectController extends Controller {
|
||||
public function index(Request $r): JsonResponse {
|
||||
return response()->json($r->user()->projects()->orderBy('name')->get());
|
||||
}
|
||||
|
||||
public function store(Request $r): JsonResponse {
|
||||
$data = $r->validate([
|
||||
'name' => 'required|string|max:80',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'icon' => 'nullable|string|max:40',
|
||||
]);
|
||||
return response()->json(
|
||||
$r->user()->projects()->create($data + ['color' => $data['color'] ?? '#6366f1']),
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
public function update(Request $r, Project $project): JsonResponse {
|
||||
abort_unless($project->user_id === $r->user()->id, 403);
|
||||
$data = $r->validate([
|
||||
'name' => 'sometimes|required|string|max:80',
|
||||
'color' => 'nullable|string|max:7',
|
||||
'icon' => 'nullable|string|max:40',
|
||||
]);
|
||||
$project->update($data);
|
||||
return response()->json($project);
|
||||
}
|
||||
|
||||
public function destroy(Request $r, Project $project): JsonResponse {
|
||||
abort_unless($project->user_id === $r->user()->id, 403);
|
||||
$project->delete();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
31
app/Http/Controllers/Api/TagController.php
Normal file
31
app/Http/Controllers/Api/TagController.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller {
|
||||
public function index(Request $r): JsonResponse {
|
||||
return response()->json($r->user()->tags()->orderBy('name')->get());
|
||||
}
|
||||
|
||||
public function store(Request $r): JsonResponse {
|
||||
$data = $r->validate([
|
||||
'name' => 'required|string|max:30',
|
||||
'color' => 'nullable|string|max:7',
|
||||
]);
|
||||
return response()->json(
|
||||
$r->user()->tags()->create($data + ['color' => $data['color'] ?? '#a3a3a3']),
|
||||
201
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $r, Tag $tag): JsonResponse {
|
||||
abort_unless($tag->user_id === $r->user()->id, 403);
|
||||
$tag->delete();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Auth/AuthController.php
Normal file
50
app/Http/Controllers/Auth/AuthController.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AuthController extends Controller {
|
||||
public function register(Request $r): JsonResponse {
|
||||
$data = $r->validate([
|
||||
'name' => 'required|string|max:60',
|
||||
'email' => 'required|email|max:120|unique:users',
|
||||
'password' => 'required|string|min:8|max:120',
|
||||
]);
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
]);
|
||||
$token = $user->createToken('web')->plainTextToken;
|
||||
return response()->json(['user' => $user, 'token' => $token], 201);
|
||||
}
|
||||
|
||||
public function login(Request $r): JsonResponse {
|
||||
$data = $r->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
$user = User::where('email', $data['email'])->first();
|
||||
if (! $user || ! Hash::check($data['password'], $user->password)) {
|
||||
return response()->json(['message' => 'Credenciales inválidas'], 401);
|
||||
}
|
||||
$token = $user->createToken('web')->plainTextToken;
|
||||
return response()->json(['user' => $user, 'token' => $token]);
|
||||
}
|
||||
|
||||
public function logout(Request $r): JsonResponse {
|
||||
$r->user()->currentAccessToken()->delete();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function me(Request $r): JsonResponse {
|
||||
return response()->json($r->user());
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/Auth/LoginController.php
Normal file
48
app/Http/Controllers/Auth/LoginController.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class LoginController extends Controller {
|
||||
public function showLogin() { return view('pages.login'); }
|
||||
public function showRegister() { return view('pages.register'); }
|
||||
|
||||
public function login(Request $r) {
|
||||
$cred = $r->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
if (! Auth::attempt($cred, true)) {
|
||||
return back()->withErrors(['email' => 'Credenciales inválidas'])->withInput();
|
||||
}
|
||||
$r->session()->regenerate();
|
||||
return redirect()->route('app');
|
||||
}
|
||||
|
||||
public function register(Request $r) {
|
||||
$data = $r->validate([
|
||||
'name' => 'required|string|max:60',
|
||||
'email' => 'required|email|unique:users,email',
|
||||
'password' => 'required|string|min:8|confirmed',
|
||||
]);
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
]);
|
||||
Auth::login($user);
|
||||
return redirect()->route('app');
|
||||
}
|
||||
|
||||
public function logout(Request $r) {
|
||||
Auth::logout();
|
||||
$r->session()->invalidate();
|
||||
$r->session()->regenerateToken();
|
||||
return redirect()->route('home');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
16
app/Http/Controllers/PageController.php
Normal file
16
app/Http/Controllers/PageController.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class PageController extends Controller {
|
||||
public function home() {
|
||||
if (auth()->check()) return redirect()->route('app');
|
||||
return view('pages.home');
|
||||
}
|
||||
public function app() {
|
||||
return view('pages.app');
|
||||
}
|
||||
public function settings() {
|
||||
return view('pages.settings');
|
||||
}
|
||||
}
|
||||
156
app/Http/Controllers/TaskController.php
Normal file
156
app/Http/Controllers/TaskController.php
Normal file
@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Task;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TaskController extends Controller {
|
||||
public function index(Request $r) {
|
||||
$tasks = $r->user()->tasks()
|
||||
->with(['project:id,name,color', 'tags:id,name,color'])
|
||||
->orderBy('done')
|
||||
->orderByRaw("FIELD(priority,'high','med','low')")
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
return response()->json($tasks);
|
||||
}
|
||||
|
||||
public function pending(Request $r): JsonResponse {
|
||||
$tasks = $r->user()->tasks()
|
||||
->active()
|
||||
->with('project:id,name,color')
|
||||
->orderByRaw("FIELD(priority,'high','med','low')")
|
||||
->orderBy('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
return response()->json($tasks->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'title' => $t->title,
|
||||
'priority' => $t->priority,
|
||||
'project' => $t->project?->name,
|
||||
'next_due_in' => $t->next_due_in,
|
||||
'estimate_min' => $t->estimate_min,
|
||||
]));
|
||||
}
|
||||
|
||||
public function store(Request $r): JsonResponse {
|
||||
$data = $r->validate([
|
||||
'title' => 'required|string|max:120',
|
||||
'description' => 'nullable|string',
|
||||
'priority' => 'nullable|in:low,med,high',
|
||||
'estimate_min' => 'nullable|integer|min:1|max:9999',
|
||||
'deadline' => 'nullable|date',
|
||||
'project_id' => 'nullable|integer|exists:projects,id',
|
||||
'recurrence' => 'nullable|in:daily,weekly,monthly',
|
||||
'tags' => 'nullable|array',
|
||||
'tags.*' => 'string|max:30',
|
||||
]);
|
||||
$task = $r->user()->tasks()->create([
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'priority' => $data['priority'] ?? 'med',
|
||||
'estimate_min' => $data['estimate_min'] ?? null,
|
||||
'deadline' => $data['deadline'] ?? null,
|
||||
'project_id' => $data['project_id'] ?? null,
|
||||
'recurrence' => $data['recurrence'] ?? null,
|
||||
]);
|
||||
if (! empty($data['tags'])) {
|
||||
$this->syncTags($task, $data['tags']);
|
||||
}
|
||||
return response()->json($task->load('project', 'tags'), 201);
|
||||
}
|
||||
|
||||
public function update(Request $r, Task $task): JsonResponse {
|
||||
abort_unless($task->user_id === $r->user()->id, 403);
|
||||
$data = $r->validate([
|
||||
'title' => 'sometimes|required|string|max:120',
|
||||
'description' => 'nullable|string',
|
||||
'priority' => 'nullable|in:low,med,high',
|
||||
'estimate_min' => 'nullable|integer|min:1|max:9999',
|
||||
'deadline' => 'nullable|date',
|
||||
'project_id' => 'nullable|integer|exists:projects,id',
|
||||
'recurrence' => 'nullable|in:daily,weekly,monthly',
|
||||
'tags' => 'nullable|array',
|
||||
]);
|
||||
$task->update(array_intersect_key($data, array_flip([
|
||||
'title', 'description', 'priority', 'estimate_min',
|
||||
'deadline', 'project_id', 'recurrence',
|
||||
])));
|
||||
if (array_key_exists('tags', $data)) {
|
||||
$this->syncTags($task, $data['tags']);
|
||||
}
|
||||
return response()->json($task->load('project', 'tags'));
|
||||
}
|
||||
|
||||
public function done(Request $r, Task $task): JsonResponse {
|
||||
abort_unless($task->user_id === $r->user()->id, 403);
|
||||
$task->update(['done' => true, 'done_at' => now()]);
|
||||
// Si la tarea es recurrente, generamos la siguiente
|
||||
if ($task->recurrence) {
|
||||
$next = $task->replicate(['done', 'done_at', 'snooze_until']);
|
||||
$next->done = false;
|
||||
$next->done_at = null;
|
||||
$next->snooze_until = null;
|
||||
$next->deadline = match ($task->recurrence) {
|
||||
'daily' => $task->deadline?->addDay() ?? now()->addDay(),
|
||||
'weekly' => $task->deadline?->addWeek() ?? now()->addWeek(),
|
||||
'monthly' => $task->deadline?->addMonth() ?? now()->addMonth(),
|
||||
default => null,
|
||||
};
|
||||
$next->save();
|
||||
}
|
||||
return response()->json(['ok' => true, 'id' => $task->id]);
|
||||
}
|
||||
|
||||
public function snooze(Request $r, Task $task): JsonResponse {
|
||||
abort_unless($task->user_id === $r->user()->id, 403);
|
||||
$minutes = (int) $r->input('minutes', 5);
|
||||
$minutes = max(1, min(720, $minutes));
|
||||
$task->update(['snooze_until' => now()->addMinutes($minutes)]);
|
||||
return response()->json(['ok' => true, 'id' => $task->id, 'minutes' => $minutes]);
|
||||
}
|
||||
|
||||
public function destroy(Request $r, Task $task): JsonResponse {
|
||||
abort_unless($task->user_id === $r->user()->id, 403);
|
||||
$task->delete();
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function restore(Request $r, Task $task): JsonResponse {
|
||||
abort_unless($task->user_id === $r->user()->id, 403);
|
||||
$task->update(['done' => false, 'done_at' => null, 'snooze_until' => null]);
|
||||
return response()->json($task);
|
||||
}
|
||||
|
||||
/** Estadísticas rápidas para el dashboard */
|
||||
public function stats(Request $r): JsonResponse {
|
||||
$userId = $r->user()->id;
|
||||
$today = now()->startOfDay();
|
||||
return response()->json([
|
||||
'pending' => Task::forUser($userId)->active()->count(),
|
||||
'today' => Task::forUser($userId)->active()
|
||||
->where('created_at', '>=', $today)->count(),
|
||||
'done_today'=> Task::forUser($userId)->where('done', true)
|
||||
->where('done_at', '>=', $today)->count(),
|
||||
'overdue' => Task::forUser($userId)->active()
|
||||
->where('deadline', '<', now())->count(),
|
||||
'total_done'=> Task::forUser($userId)->where('done', true)->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function syncTags(Task $task, array $tagNames): void {
|
||||
$userId = $task->user_id;
|
||||
$ids = [];
|
||||
foreach ($tagNames as $name) {
|
||||
$tag = \App\Models\Tag::firstOrCreate(
|
||||
['user_id' => $userId, 'name' => $name],
|
||||
['color' => '#a3a3a3']
|
||||
);
|
||||
$ids[] = $tag->id;
|
||||
}
|
||||
$task->tags()->sync($ids);
|
||||
}
|
||||
}
|
||||
25
app/Http/Middleware/AuthenticateDevice.php
Normal file
25
app/Http/Middleware/AuthenticateDevice.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Device;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateDevice {
|
||||
public function handle(Request $request, Closure $next): Response {
|
||||
$token = $request->bearerToken();
|
||||
if (! $token) {
|
||||
return response()->json(['error' => 'No device token'], 401);
|
||||
}
|
||||
$device = Device::where('token', $token)->first();
|
||||
if (! $device) {
|
||||
return response()->json(['error' => 'Invalid device token'], 401);
|
||||
}
|
||||
$device->touchSeen();
|
||||
$request->setUserResolver(fn () => $device->user);
|
||||
$request->attributes->set('device', $device);
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
33
app/Models/Device.php
Normal file
33
app/Models/Device.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Device extends Model {
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'name', 'token', 'hardware',
|
||||
'firmware_version', 'last_seen_at', 'settings',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_seen_at' => 'datetime',
|
||||
'settings' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
|
||||
/** Genera un token nuevo seguro */
|
||||
public static function makeToken(): string {
|
||||
return Str::random(48);
|
||||
}
|
||||
|
||||
public function touchSeen(): void {
|
||||
$this->update(['last_seen_at' => now()]);
|
||||
}
|
||||
}
|
||||
17
app/Models/Project.php
Normal file
17
app/Models/Project.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Project extends Model {
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['user_id', 'name', 'color', 'icon'];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
public function tasks(): HasMany { return $this->hasMany(Task::class); }
|
||||
}
|
||||
17
app/Models/Tag.php
Normal file
17
app/Models/Tag.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Tag extends Model {
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['user_id', 'name', 'color'];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
public function tasks(): BelongsToMany { return $this->belongsToMany(Task::class); }
|
||||
}
|
||||
50
app/Models/Task.php
Normal file
50
app/Models/Task.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Task extends Model {
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'project_id', 'title', 'description', 'priority',
|
||||
'estimate_min', 'deadline', 'recurrence',
|
||||
'done', 'done_at', 'snooze_until',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'done' => 'boolean',
|
||||
'done_at' => 'datetime',
|
||||
'snooze_until' => 'datetime',
|
||||
'deadline' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||
public function project(): BelongsTo { return $this->belongsTo(Project::class); }
|
||||
public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); }
|
||||
|
||||
/** Tareas activas (no hechas y no snoozed) */
|
||||
public function scopeActive(Builder $q): Builder {
|
||||
return $q->where('done', false)
|
||||
->where(function ($q2) {
|
||||
$q2->whereNull('snooze_until')
|
||||
->orWhere('snooze_until', '<=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForUser(Builder $q, int $userId): Builder {
|
||||
return $q->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function getNextDueInAttribute(): int {
|
||||
if (! $this->deadline) return 0;
|
||||
if ($this->deadline->isPast()) return 0;
|
||||
return max(0, now()->diffInSeconds($this->deadline, false));
|
||||
}
|
||||
}
|
||||
33
app/Models/User.php
Normal file
33
app/Models/User.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function tasks(): HasMany { return $this->hasMany(Task::class); }
|
||||
public function projects(): HasMany { return $this->hasMany(Project::class); }
|
||||
public function tags(): HasMany { return $this->hasMany(Tag::class); }
|
||||
public function devices(): HasMany { return $this->hasMany(Device::class); }
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
18
artisan
Executable file
18
artisan
Executable file
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
24
bootstrap/app.php
Normal file
24
bootstrap/app.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'auth.device' => \App\Http\Middleware\AuthenticateDevice::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*') || $request->is('api/device/*'),
|
||||
);
|
||||
})->create();
|
||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
7
bootstrap/providers.php
Normal file
7
bootstrap/providers.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
87
composer.json
Normal file
87
composer.json
Normal file
@ -0,0 +1,87 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install --ignore-scripts",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
8359
composer.lock
generated
Normal file
8359
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
117
config/auth.php
Normal file
117
config/auth.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
136
config/cache.php
Normal file
136
config/cache.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "storage", "octane",
|
||||
| "session", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
184
config/database.php
Normal file
184
config/database.php
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
80
config/filesystems.php
Normal file
80
config/filesystems.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
87
config/sanctum.php
Normal file
87
config/sanctum.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort(),
|
||||
// Sanctum::currentRequestHost(),
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => AuthenticateSession::class,
|
||||
'encrypt_cookies' => EncryptCookies::class,
|
||||
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
38
config/services.php
Normal file
38
config/services.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
233
config/session.php
Normal file
233
config/session.php
Normal file
@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
24
database/factories/DeviceFactory.php
Normal file
24
database/factories/DeviceFactory.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Device;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Device>
|
||||
*/
|
||||
class DeviceFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
24
database/factories/ProjectFactory.php
Normal file
24
database/factories/ProjectFactory.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Project>
|
||||
*/
|
||||
class ProjectFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
24
database/factories/TaskFactory.php
Normal file
24
database/factories/TaskFactory.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Task;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Task>
|
||||
*/
|
||||
class TaskFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
45
database/factories/UserFactory.php
Normal file
45
database/factories/UserFactory.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedSmallInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('connection');
|
||||
$table->string('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
|
||||
$table->index(['connection', 'queue', 'failed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
28
database/migrations/2026_06_16_215608_create_tasks_table.php
Normal file
28
database/migrations/2026_06_16_215608_create_tasks_table.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('tasks', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
|
||||
$t->string('title', 120);
|
||||
$t->text('description')->nullable();
|
||||
$t->enum('priority', ['low', 'med', 'high'])->default('med');
|
||||
$t->unsignedSmallInteger('estimate_min')->nullable(); // estimación en min
|
||||
$t->timestamp('deadline')->nullable();
|
||||
$t->string('recurrence', 30)->nullable(); // 'daily', 'weekly', null
|
||||
$t->boolean('done')->default(false);
|
||||
$t->timestamp('done_at')->nullable();
|
||||
$t->timestamp('snooze_until')->nullable();
|
||||
$t->timestamps();
|
||||
|
||||
$t->index(['user_id', 'done', 'snooze_until']);
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('tasks'); }
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('tokenable');
|
||||
$table->text('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('projects', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('name', 80);
|
||||
$t->string('color', 7)->default('#6366f1'); // hex color
|
||||
$t->string('icon', 40)->nullable(); // emoji o nombre
|
||||
$t->timestamps();
|
||||
$t->unique(['user_id', 'name']);
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('projects'); }
|
||||
};
|
||||
30
database/migrations/2026_06_16_234920_create_tags_table.php
Normal file
30
database/migrations/2026_06_16_234920_create_tags_table.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('tags', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('name', 30);
|
||||
$t->string('color', 7)->default('#a3a3a3');
|
||||
$t->timestamps();
|
||||
$t->unique(['user_id', 'name']);
|
||||
});
|
||||
|
||||
// Pivot task <-> tag
|
||||
Schema::create('tag_task', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('task_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('tag_id')->constrained()->cascadeOnDelete();
|
||||
$t->unique(['task_id', 'tag_id']);
|
||||
});
|
||||
}
|
||||
public function down(): void {
|
||||
Schema::dropIfExists('tag_task');
|
||||
Schema::dropIfExists('tags');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('devices', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('name', 60); // "ESP32 del curro"
|
||||
$t->string('token', 64)->unique(); // token dedicado
|
||||
$t->string('hardware', 60)->default('esp32');
|
||||
$t->string('firmware_version', 30)->nullable();
|
||||
$t->timestamp('last_seen_at')->nullable();
|
||||
$t->json('settings')->nullable(); // config del bot
|
||||
$t->timestamps();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('devices'); }
|
||||
};
|
||||
25
database/seeders/DatabaseSeeder.php
Normal file
25
database/seeders/DatabaseSeeder.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
}
|
||||
}
|
||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal file
@ -0,0 +1,70 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: dummybot_app
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www
|
||||
volumes:
|
||||
- .:/var/www
|
||||
environment:
|
||||
- AUTO_MIGRATE=${AUTO_MIGRATE:-0}
|
||||
networks:
|
||||
- laravel
|
||||
|
||||
webserver:
|
||||
image: nginx:alpine
|
||||
container_name: dummybot_web
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- 80
|
||||
volumes:
|
||||
- .:/var/www
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
networks:
|
||||
- laravel
|
||||
- proxy
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: dummybot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: ${DB_DATABASE:-dummybot}
|
||||
MYSQL_USER: ${DB_USERNAME:-dummybot}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-dummybot}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root}
|
||||
volumes:
|
||||
- dbdata:/var/lib/mysql
|
||||
networks:
|
||||
- laravel
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Túnel socat para exponer el backend en :18080 sin TLS.
|
||||
# Útil mientras el proxy-manager no tiene host para dummybot.
|
||||
# Si ya tienes HTTPS, este contenedor es opcional.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
expose:
|
||||
image: alpine/socat
|
||||
container_name: dummybot_expose
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18080:80"
|
||||
command: TCP-LISTEN:80,fork,reuseaddr TCP:dummybot_web:80
|
||||
networks:
|
||||
- proxy
|
||||
depends_on:
|
||||
- webserver
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
|
||||
networks:
|
||||
laravel:
|
||||
proxy:
|
||||
external: true
|
||||
23
docker-entrypoint.sh
Normal file
23
docker-entrypoint.sh
Normal file
@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Change ownership and permissions
|
||||
chown -R www-data:www-data /var/www
|
||||
chmod -R 755 /var/www/storage
|
||||
chmod -R 755 /var/www/bootstrap/cache
|
||||
|
||||
# Run migrations on first boot if AUTO_MIGRATE=1.
|
||||
# We wait for the DB to be reachable (max 60 s) to avoid race with the db container.
|
||||
if [ "${AUTO_MIGRATE:-0}" = "1" ]; then
|
||||
echo "[entrypoint] AUTO_MIGRATE=1 -> waiting for DB..."
|
||||
for i in $(seq 1 60); do
|
||||
if php -r "try { new PDO('mysql:host='.getenv('DB_HOST').';port='.getenv('DB_PORT').';dbname='.getenv('DB_DATABASE'), getenv('DB_USERNAME'), getenv('DB_PASSWORD')); exit(0); } catch (Exception \$e) { exit(1); }" 2>/dev/null; then
|
||||
echo "[entrypoint] DB ready after ${i}s -> running migrations"
|
||||
php artisan migrate --force --no-interaction
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
exec "$@"
|
||||
28
nginx.conf
Normal file
28
nginx.conf
Normal file
@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name dummybot.local _;
|
||||
|
||||
index index.php index.html;
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
root /var/www/public;
|
||||
|
||||
location ~ \.php$ {
|
||||
try_files $uri =404;
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
|
||||
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;
|
||||
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
|
||||
fastcgi_param HTTP_X_REAL_IP $http_x_real_ip;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
gzip_static on;
|
||||
}
|
||||
}
|
||||
16
package.json
Normal file
16
package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
36
phpunit.xml
Normal file
36
phpunit.xml
Normal file
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
25
public/.htaccess
Normal file
25
public/.htaccess
Normal file
@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
0
public/favicon.ico
Normal file
0
public/favicon.ico
Normal file
20
public/index.php
Normal file
20
public/index.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
9
resources/css/app.css
Normal file
9
resources/css/app.css
Normal file
@ -0,0 +1,9 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
1
resources/js/app.js
Normal file
1
resources/js/app.js
Normal file
@ -0,0 +1 @@
|
||||
//
|
||||
58
resources/views/layouts/base.blade.php
Normal file
58
resources/views/layouts/base.blade.php
Normal file
@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'DummyBot')</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>🤖</text></svg>">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
|
||||
body { background: #0a0a0f; color: #f4f4f5; }
|
||||
.bg-mesh {
|
||||
background:
|
||||
radial-gradient(at 12% 20%, rgba(99, 102, 241, 0.18) 0px, transparent 50%),
|
||||
radial-gradient(at 90% 10%, rgba(168, 85, 247, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 80% 90%, rgba(56, 189, 248, 0.12) 0px, transparent 50%),
|
||||
#0a0a0f;
|
||||
}
|
||||
.glass { background: rgba(24, 24, 27, 0.6); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.06); }
|
||||
.btn { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; border-radius: 0.5rem; font-weight: 500; transition: all .15s; cursor: pointer; }
|
||||
.btn-primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; border: none; box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35); }
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5); }
|
||||
.btn-ghost { background: rgba(255,255,255,0.04); color: #d4d4d8; border: 1px solid rgba(255,255,255,0.06); }
|
||||
.btn-ghost:hover { background: rgba(255,255,255,0.08); }
|
||||
.btn-danger { background: rgba(239, 68, 68, 0.15); color: #fca5a5; border: 1px solid rgba(239, 68, 68, 0.3); }
|
||||
.btn-danger:hover { background: rgba(239, 68, 68, 0.25); }
|
||||
.input { background: rgba(9, 9, 11, 0.6); border: 1px solid rgba(255,255,255,0.08); color: #f4f4f5; border-radius: 0.5rem; padding: 0.55rem 0.75rem; width: 100%; transition: all .15s; }
|
||||
.input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); }
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.6rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
|
||||
.prio-high { background: rgba(239, 68, 68, 0.15); color: #fca5a5; }
|
||||
.prio-med { background: rgba(250, 204, 21, 0.12); color: #fde68a; }
|
||||
.prio-low { background: rgba(34, 197, 94, 0.12); color: #86efac; }
|
||||
.task-card { background: rgba(24, 24, 27, 0.4); border: 1px solid rgba(255,255,255,0.05); border-radius: 0.75rem; padding: 1rem; transition: all .2s; }
|
||||
.task-card:hover { background: rgba(24, 24, 27, 0.7); border-color: rgba(99, 102, 241, 0.3); }
|
||||
.task-done { opacity: 0.4; text-decoration: line-through; }
|
||||
.kbd { display: inline-block; padding: 0.1rem 0.4rem; border: 1px solid rgba(255,255,255,0.15); border-radius: 0.3rem; font-size: 0.7rem; font-family: monospace; background: rgba(255,255,255,0.04); }
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.anim-in { animation: slideIn 0.25s ease-out; }
|
||||
.toast { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 50; padding: 0.75rem 1rem; border-radius: 0.75rem; background: rgba(24, 24, 27, 0.95); border: 1px solid rgba(99, 102, 241, 0.4); color: #f4f4f5; box-shadow: 0 10px 40px rgba(0,0,0,0.4); animation: slideIn 0.25s ease-out; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
||||
</style>
|
||||
@stack('head')
|
||||
</head>
|
||||
<body class="bg-mesh min-h-screen antialiased">
|
||||
@yield('content')
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
398
resources/views/pages/app.blade.php
Normal file
398
resources/views/pages/app.blade.php
Normal file
@ -0,0 +1,398 @@
|
||||
@extends('layouts.base')
|
||||
@section('content')
|
||||
<div x-data="dummybotApp()" x-init="init()" class="min-h-screen flex flex-col">
|
||||
{{-- NAV --}}
|
||||
<nav class="border-b border-white/5 backdrop-blur-md sticky top-0 z-40">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between gap-3">
|
||||
<a href="{{ route('app') }}" class="flex items-center gap-2 text-lg font-bold shrink-0">
|
||||
<span class="text-2xl">🤖</span>
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
|
||||
</a>
|
||||
|
||||
{{-- Buscador --}}
|
||||
<div class="hidden sm:flex flex-1 max-w-md mx-4">
|
||||
<div class="relative w-full">
|
||||
<input x-model="search" type="search" placeholder="Buscar... (pulsa /)"
|
||||
class="input pl-9">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="newTaskOpen = true" class="btn btn-primary text-sm" title="Nueva (n)">
|
||||
<span>+</span> <span class="hidden sm:inline">Nueva</span>
|
||||
</button>
|
||||
<a href="{{ route('settings') }}" class="btn btn-ghost text-sm" title="Ajustes">⚙️</a>
|
||||
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||
@csrf
|
||||
<button class="btn btn-ghost text-sm" title="Salir">↪</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{-- STATS --}}
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<template x-for="(s, i) in stats" :key="i">
|
||||
<div class="glass rounded-xl p-4">
|
||||
<div class="text-xs text-zinc-500 uppercase tracking-wide" x-text="s.label"></div>
|
||||
<div class="mt-1 text-2xl font-extrabold" :class="s.color" x-text="s.value"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- FILTROS --}}
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 pt-4 flex flex-wrap items-center gap-2">
|
||||
<button @click="filter = 'all'"
|
||||
:class="filter === 'all' ? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/40' : 'btn-ghost'"
|
||||
class="btn text-sm border border-white/5">Todas</button>
|
||||
<button @click="filter = 'pending'"
|
||||
:class="filter === 'pending' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/40' : 'btn-ghost'"
|
||||
class="btn text-sm border border-white/5">Pendientes</button>
|
||||
<button @click="filter = 'today'"
|
||||
:class="filter === 'today' ? 'bg-purple-500/20 text-purple-300 border-purple-500/40' : 'btn-ghost'"
|
||||
class="btn text-sm border border-white/5">Hoy</button>
|
||||
<button @click="filter = 'overdue'"
|
||||
:class="filter === 'overdue' ? 'bg-red-500/20 text-red-300 border-red-500/40' : 'btn-ghost'"
|
||||
class="btn text-sm border border-white/5">Vencidas</button>
|
||||
<button @click="filter = 'done'"
|
||||
:class="filter === 'done' ? 'bg-green-500/20 text-green-300 border-green-500/40' : 'btn-ghost'"
|
||||
class="btn text-sm border border-white/5">Hechas</button>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<select x-model="projectFilter" class="input text-sm py-1 w-auto">
|
||||
<option value="">Todos los proyectos</option>
|
||||
<template x-for="p in projects" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- LISTA DE TAREAS --}}
|
||||
<main class="max-w-6xl mx-auto px-4 sm:px-6 py-6 flex-1 w-full">
|
||||
<template x-if="loading">
|
||||
<div class="text-center text-zinc-500 py-12">Cargando...</div>
|
||||
</template>
|
||||
<template x-if="!loading && filteredTasks.length === 0">
|
||||
<div class="text-center py-16 anim-in">
|
||||
<div class="text-6xl mb-3">🎉</div>
|
||||
<h2 class="text-xl font-bold mb-1">¡Nada que hacer!</h2>
|
||||
<p class="text-zinc-500">Ponte a procrastinar con la conciencia tranquila.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-2">
|
||||
<template x-for="t in filteredTasks" :key="t.id">
|
||||
<div class="task-card anim-in flex items-start gap-3"
|
||||
:class="t.done ? 'task-done' : ''">
|
||||
<button @click="toggle(t)" class="shrink-0 mt-1 w-6 h-6 rounded-full border-2 transition flex items-center justify-center"
|
||||
:class="t.done ? 'bg-green-500/80 border-green-500 text-white' : 'border-zinc-600 hover:border-indigo-400'">
|
||||
<span x-show="t.done" class="text-xs">✓</span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-semibold" x-text="t.title"></span>
|
||||
<span class="chip" :class="'prio-' + t.priority" x-text="t.priority"></span>
|
||||
<template x-if="t.project">
|
||||
<span class="chip" :style="'background:' + t.project.color + '20; color:' + t.project.color"
|
||||
x-text="t.project.name"></span>
|
||||
</template>
|
||||
<template x-for="tag in t.tags" :key="tag.id">
|
||||
<span class="chip text-xs" :style="'background:' + tag.color + '20; color:' + tag.color"
|
||||
x-text="'#' + tag.name"></span>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="t.description" class="text-sm text-zinc-400 mt-1" x-text="t.description"></p>
|
||||
<div class="flex items-center gap-3 text-xs text-zinc-500 mt-2">
|
||||
<template x-if="t.estimate_min">
|
||||
<span>⏱ <span x-text="t.estimate_min"></span> min</span>
|
||||
</template>
|
||||
<template x-if="t.deadline">
|
||||
<span :class="isOverdue(t) ? 'text-red-400' : ''">📅 <span x-text="formatDate(t.deadline)"></span></span>
|
||||
</template>
|
||||
<template x-if="t.recurrence">
|
||||
<span>🔁 <span x-text="t.recurrence"></span></span>
|
||||
</template>
|
||||
<span x-show="t.done_at" class="text-green-400">✓ hecha <span x-text="ago(t.done_at)"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button @click="snooze(t)" class="btn btn-ghost text-xs" title="Snooze 5 min (s)">💤</button>
|
||||
<button @click="edit(t)" class="btn btn-ghost text-xs" title="Editar">✎</button>
|
||||
<button @click="del(t)" class="btn btn-danger text-xs" title="Borrar">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{-- MODAL NUEVA / EDITAR TAREA --}}
|
||||
<div x-show="newTaskOpen || editing" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
@keydown.escape.window="closeModal()">
|
||||
<div class="glass rounded-2xl p-6 w-full max-w-lg anim-in" @click.outside="closeModal()">
|
||||
<h2 class="text-xl font-bold mb-4" x-text="editing ? 'Editar tarea' : 'Nueva tarea'"></h2>
|
||||
<form @submit.prevent="editing ? saveEdit() : create()" class="space-y-3">
|
||||
<input x-model="form.title" type="text" placeholder="¿Qué tienes que hacer?" required maxlength="120" class="input" autofocus>
|
||||
|
||||
<textarea x-model="form.description" placeholder="Descripción (opcional)" class="input min-h-[80px]"></textarea>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="text-xs text-zinc-500">Prioridad</label>
|
||||
<select x-model="form.priority" class="input">
|
||||
<option value="low">Baja</option>
|
||||
<option value="med" selected>Media</option>
|
||||
<option value="high">Alta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-zinc-500">Estimación (min)</label>
|
||||
<input x-model.number="form.estimate_min" type="number" min="1" max="9999" class="input" placeholder="30">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-zinc-500">Fecha límite</label>
|
||||
<input x-model="form.deadline" type="datetime-local" class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-zinc-500">Repetir</label>
|
||||
<select x-model="form.recurrence" class="input">
|
||||
<option value="">Nunca</option>
|
||||
<option value="daily">Diario</option>
|
||||
<option value="weekly">Semanal</option>
|
||||
<option value="monthly">Mensual</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-zinc-500">Proyecto</label>
|
||||
<select x-model="form.project_id" class="input">
|
||||
<option value="">Sin proyecto</option>
|
||||
<template x-for="p in projects" :key="p.id">
|
||||
<option :value="p.id" x-text="p.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-zinc-500">Tags (separados por coma)</label>
|
||||
<input x-model="form.tagsInput" type="text" class="input" placeholder="cliente-x, urgente">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary flex-1 justify-center">
|
||||
<span x-text="editing ? 'Guardar' : 'Crear'"></span>
|
||||
</button>
|
||||
<button type="button" @click="closeModal()" class="btn btn-ghost">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- TOAST --}}
|
||||
<div x-show="toast" x-cloak class="toast" x-text="toast"></div>
|
||||
</div>
|
||||
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
|
||||
<script>
|
||||
function dummybotApp() {
|
||||
return {
|
||||
tasks: [],
|
||||
projects: [],
|
||||
search: '',
|
||||
filter: 'all',
|
||||
projectFilter: '',
|
||||
loading: true,
|
||||
newTaskOpen: false,
|
||||
editing: null,
|
||||
form: {},
|
||||
toast: '',
|
||||
csrf: document.querySelector('meta[name=csrf-token]').content,
|
||||
stats: [
|
||||
{label: 'Pendientes', value: '—', color: 'text-yellow-300'},
|
||||
{label: 'Hoy', value: '—', color: 'text-indigo-300'},
|
||||
{label: 'Hechas hoy', value: '—', color: 'text-green-300'},
|
||||
{label: 'Vencidas', value: '—', color: 'text-red-300'},
|
||||
],
|
||||
|
||||
async init() {
|
||||
await this.loadAll();
|
||||
this.bindKeys();
|
||||
// Polling cada 15s
|
||||
setInterval(() => this.loadAll(true), 15000);
|
||||
},
|
||||
|
||||
bindKeys() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
||||
if (e.key === 'n') { this.newTaskOpen = true; this.resetForm(); }
|
||||
if (e.key === '/') { e.preventDefault(); document.querySelector('input[type=search]')?.focus(); }
|
||||
if (e.key === 'Escape') this.closeModal();
|
||||
if (e.key === '1') this.filter = 'all';
|
||||
if (e.key === '2') this.filter = 'pending';
|
||||
if (e.key === '3') this.filter = 'today';
|
||||
if (e.key === '4') this.filter = 'done';
|
||||
});
|
||||
},
|
||||
|
||||
async loadAll(silent = false) {
|
||||
if (!silent) this.loading = true;
|
||||
try {
|
||||
const [t, p, s] = await Promise.all([
|
||||
this.api('/api/tasks'),
|
||||
this.api('/api/projects'),
|
||||
this.api('/api/tasks/stats'),
|
||||
]);
|
||||
this.tasks = t;
|
||||
this.projects = p;
|
||||
this.stats = [
|
||||
{label: 'Pendientes', value: s.pending, color: 'text-yellow-300'},
|
||||
{label: 'Hoy', value: s.today, color: 'text-indigo-300'},
|
||||
{label: 'Hechas hoy', value: s.done_today, color: 'text-green-300'},
|
||||
{label: 'Vencidas', value: s.overdue, color: 'text-red-300'},
|
||||
];
|
||||
} catch (err) {
|
||||
this.flash('Error cargando: ' + err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
get filteredTasks() {
|
||||
let t = this.tasks;
|
||||
if (this.search) {
|
||||
const q = this.search.toLowerCase();
|
||||
t = t.filter(x => x.title.toLowerCase().includes(q)
|
||||
|| (x.description || '').toLowerCase().includes(q)
|
||||
|| x.tags?.some(tag => tag.name.toLowerCase().includes(q)));
|
||||
}
|
||||
if (this.filter === 'pending') t = t.filter(x => !x.done && !this.isSnoozed(x));
|
||||
if (this.filter === 'today') t = t.filter(x => !x.done && this.isToday(x));
|
||||
if (this.filter === 'overdue') t = t.filter(x => !x.done && this.isOverdue(x));
|
||||
if (this.filter === 'done') t = t.filter(x => x.done);
|
||||
if (this.projectFilter) t = t.filter(x => x.project_id == this.projectFilter);
|
||||
return t;
|
||||
},
|
||||
|
||||
isOverdue(t) { return t.deadline && new Date(t.deadline) < new Date() && !t.done; },
|
||||
isSnoozed(t) { return t.snooze_until && new Date(t.snooze_until) > new Date(); },
|
||||
isToday(t) { return t.deadline && new Date(t.deadline).toDateString() === new Date().toDateString(); },
|
||||
|
||||
formatDate(s) { return new Date(s).toLocaleString('es-ES', {day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit'}); },
|
||||
ago(s) {
|
||||
const d = (Date.now() - new Date(s).getTime()) / 1000;
|
||||
if (d < 60) return 'hace un momento';
|
||||
if (d < 3600) return `hace ${Math.floor(d/60)} min`;
|
||||
if (d < 86400) return `hace ${Math.floor(d/3600)} h`;
|
||||
return `hace ${Math.floor(d/86400)} d`;
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.form = {
|
||||
title: '', description: '', priority: 'med',
|
||||
estimate_min: null, deadline: '', project_id: '',
|
||||
recurrence: '', tagsInput: '',
|
||||
};
|
||||
this.editing = null;
|
||||
},
|
||||
closeModal() { this.newTaskOpen = false; this.editing = null; },
|
||||
|
||||
edit(t) {
|
||||
this.editing = t;
|
||||
this.form = {
|
||||
title: t.title,
|
||||
description: t.description || '',
|
||||
priority: t.priority,
|
||||
estimate_min: t.estimate_min,
|
||||
deadline: t.deadline ? t.deadline.substring(0, 16) : '',
|
||||
project_id: t.project_id || '',
|
||||
recurrence: t.recurrence || '',
|
||||
tagsInput: (t.tags || []).map(x => x.name).join(', '),
|
||||
};
|
||||
},
|
||||
|
||||
async create() {
|
||||
try {
|
||||
await this.api('/api/tasks', 'POST', this.payload());
|
||||
this.flash('✓ Tarea creada');
|
||||
this.closeModal();
|
||||
this.resetForm();
|
||||
await this.loadAll();
|
||||
} catch (err) { this.flash('Error: ' + err.message); }
|
||||
},
|
||||
async saveEdit() {
|
||||
try {
|
||||
await this.api(`/api/tasks/${this.editing.id}`, 'PUT', this.payload());
|
||||
this.flash('✓ Tarea actualizada');
|
||||
this.closeModal();
|
||||
await this.loadAll();
|
||||
} catch (err) { this.flash('Error: ' + err.message); }
|
||||
},
|
||||
payload() {
|
||||
return {
|
||||
title: this.form.title,
|
||||
description: this.form.description || null,
|
||||
priority: this.form.priority,
|
||||
estimate_min: this.form.estimate_min || null,
|
||||
deadline: this.form.deadline ? new Date(this.form.deadline).toISOString() : null,
|
||||
project_id: this.form.project_id || null,
|
||||
recurrence: this.form.recurrence || null,
|
||||
tags: this.form.tagsInput.split(',').map(s => s.trim()).filter(Boolean),
|
||||
};
|
||||
},
|
||||
|
||||
async toggle(t) {
|
||||
if (t.done) {
|
||||
await this.api(`/api/tasks/${t.id}/restore`, 'POST');
|
||||
} else {
|
||||
await this.api(`/api/tasks/${t.id}/done`, 'POST');
|
||||
this.flash('✓ ¡Hecha!');
|
||||
}
|
||||
await this.loadAll();
|
||||
},
|
||||
async snooze(t) {
|
||||
await this.api(`/api/tasks/${t.id}/snooze`, 'POST', {minutes: 5});
|
||||
this.flash('💤 Pospuesta 5 min');
|
||||
await this.loadAll();
|
||||
},
|
||||
async del(t) {
|
||||
if (!confirm(`¿Borrar "${t.title}"?`)) return;
|
||||
await this.api(`/api/tasks/${t.id}`, 'DELETE');
|
||||
this.flash('🗑 Borrada');
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
async api(url, method = 'GET', body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
if (body) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) {
|
||||
const txt = await r.text();
|
||||
throw new Error(`${r.status}: ${txt.substring(0, 100)}`);
|
||||
}
|
||||
return r.json();
|
||||
},
|
||||
flash(msg) {
|
||||
this.toast = msg;
|
||||
setTimeout(() => this.toast = '', 2500);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@endsection
|
||||
141
resources/views/pages/home.blade.php
Normal file
141
resources/views/pages/home.blade.php
Normal file
@ -0,0 +1,141 @@
|
||||
@extends('layouts.base')
|
||||
@section('content')
|
||||
<div class="min-h-screen flex flex-col">
|
||||
{{-- NAV --}}
|
||||
<nav class="border-b border-white/5 backdrop-blur-md sticky top-0 z-40">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<a href="{{ route('home') }}" class="flex items-center gap-2 text-lg font-bold">
|
||||
<span class="text-2xl">🤖</span>
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
|
||||
</a>
|
||||
@auth
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('app') }}" class="btn btn-ghost text-sm">App</a>
|
||||
<a href="{{ route('settings') }}" class="btn btn-ghost text-sm">Ajustes</a>
|
||||
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||
@csrf
|
||||
<button class="btn btn-ghost text-sm">Salir</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('login') }}" class="btn btn-ghost text-sm">Login</a>
|
||||
<a href="{{ route('register') }}" class="btn btn-primary text-sm">Empezar</a>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{-- HERO --}}
|
||||
<main class="flex-1 flex items-center">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-16 grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div class="anim-in">
|
||||
<div class="inline-block chip prio-low mb-4">Para cerebros que van a 200 km/h</div>
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight tracking-tight">
|
||||
Tus tareas, en una
|
||||
<span class="bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">pantalla</span>
|
||||
con
|
||||
<span class="bg-gradient-to-r from-pink-400 to-yellow-400 bg-clip-text text-transparent">cara</span>.
|
||||
</h1>
|
||||
<p class="mt-6 text-lg text-zinc-300 max-w-xl">
|
||||
Un bot físico que te recuerda lo que tienes que hacer, vibrando y haciendo muecas.
|
||||
Diseñado para personas con TDAH que se pierden entre 14 pestañas del navegador.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<a href="{{ route('register') }}" class="btn btn-primary">
|
||||
Crear cuenta gratis
|
||||
<span class="text-xs opacity-80 ml-1">→</span>
|
||||
</a>
|
||||
<a href="{{ route('login') }}" class="btn btn-ghost">Ya tengo cuenta</a>
|
||||
</div>
|
||||
<div class="mt-10 flex items-center gap-6 text-sm text-zinc-400">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-green-400"></span> Open source
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-indigo-400"></span> Self-hosted
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-pink-400"></span> Hecho con cariño
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- PREVIEW --}}
|
||||
<div class="anim-in" style="animation-delay: .15s">
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-8 bg-gradient-to-tr from-indigo-500/20 via-purple-500/20 to-pink-500/20 blur-3xl"></div>
|
||||
<div class="relative glass rounded-2xl p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-3 h-3 rounded-full bg-red-400/70"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-400/70"></div>
|
||||
<div class="w-3 h-3 rounded-full bg-green-400/70"></div>
|
||||
<div class="ml-auto text-xs text-zinc-500">dummybot.local</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="task-card flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-red-400 to-pink-500 flex items-center justify-center text-xl">😰</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold">Revisar PR de cliente X</div>
|
||||
<div class="text-xs text-zinc-500 flex gap-2 mt-1">
|
||||
<span class="chip prio-high">alta</span>
|
||||
<span>~25 min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-card flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-yellow-300 to-orange-400 flex items-center justify-center text-xl">😐</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold">Llamar al dentista</div>
|
||||
<div class="text-xs text-zinc-500 flex gap-2 mt-1">
|
||||
<span class="chip prio-med">media</span>
|
||||
<span>5 min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-card flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-green-300 to-emerald-500 flex items-center justify-center text-xl">😊</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold line-through text-zinc-500">Comprar café</div>
|
||||
<div class="text-xs text-zinc-500 mt-1">✓ hecha hace 12 min</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{-- FEATURES --}}
|
||||
<section class="border-t border-white/5 py-16 bg-black/20">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@php
|
||||
$features = [
|
||||
['icon' => '⚡', 'title' => 'Sin fricción', 'desc' => 'Abre la app, escribe la tarea, listo. Tu DummyBot la muestra en la pantalla.'],
|
||||
['icon' => '🔁', 'title' => 'Tareas recurrentes', 'desc' => 'Llamar al dentista cada lunes. Se autogeneran al marcarlas como hechas.'],
|
||||
['icon' => '🏷️', 'title' => 'Proyectos y tags', 'desc' => 'Agrupa por cliente, contexto o lo que sea. Filtros al vuelo.'],
|
||||
['icon' => '⌨️', 'title' => 'Atajos de teclado', 'desc' => 'n = nueva · d = done · s = snooze · / = buscar. Sin tocar el ratón.'],
|
||||
['icon' => '📊', 'title' => 'Stats honestas', 'desc' => 'Sin gamificación tóxica. Solo datos para que veas cómo vas.'],
|
||||
['icon' => '🔌', 'title' => 'API abierta', 'desc' => 'Webhook, JSON, importa de Notion, etc. Si lo puedes describir, lo puedes hacer.'],
|
||||
];
|
||||
@endphp
|
||||
@foreach ($features as $f)
|
||||
<div class="glass rounded-xl p-6">
|
||||
<div class="text-3xl">{{ $f['icon'] }}</div>
|
||||
<h3 class="mt-3 font-semibold text-lg">{{ $f['title'] }}</h3>
|
||||
<p class="mt-1 text-sm text-zinc-400">{{ $f['desc'] }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="border-t border-white/5 py-6 text-center text-xs text-zinc-500">
|
||||
DummyBot · Hecho con
|
||||
<span class="text-pink-400">♥</span>
|
||||
para cerebros no neurotípicos
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@endsection
|
||||
39
resources/views/pages/login.blade.php
Normal file
39
resources/views/pages/login.blade.php
Normal file
@ -0,0 +1,39 @@
|
||||
@extends('layouts.base')
|
||||
@section('content')
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="glass rounded-2xl p-8 w-full max-w-md anim-in">
|
||||
<a href="{{ route('home') }}" class="flex items-center gap-2 mb-6 text-lg font-bold">
|
||||
<span class="text-2xl">🤖</span>
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold mb-1">Bienvenido de vuelta</h1>
|
||||
<p class="text-zinc-400 text-sm mb-6">Entra para ver tus tareas</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-300 text-sm">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Email</label>
|
||||
<input type="email" name="email" required autofocus class="input"
|
||||
value="{{ old('email') }}" autocomplete="email">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Contraseña</label>
|
||||
<input type="password" name="password" required class="input" autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full justify-center">Entrar</button>
|
||||
</form>
|
||||
<p class="mt-6 text-center text-sm text-zinc-500">
|
||||
¿No tienes cuenta?
|
||||
<a href="{{ route('register') }}" class="text-indigo-400 hover:underline">Crea una</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@endsection
|
||||
46
resources/views/pages/register.blade.php
Normal file
46
resources/views/pages/register.blade.php
Normal file
@ -0,0 +1,46 @@
|
||||
@extends('layouts.base')
|
||||
@section('content')
|
||||
<div class="min-h-screen flex items-center justify-center px-4 py-12">
|
||||
<div class="glass rounded-2xl p-8 w-full max-w-md anim-in">
|
||||
<a href="{{ route('home') }}" class="flex items-center gap-2 mb-6 text-lg font-bold">
|
||||
<span class="text-2xl">🤖</span>
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold mb-1">Crea tu cuenta</h1>
|
||||
<p class="text-zinc-400 text-sm mb-6">Tus tareas, en un bot que se enfurruña si las dejas</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-300 text-sm">
|
||||
<ul class="list-disc list-inside">@foreach ($errors->all() as $e)<li>{{ $e }}</li>@endforeach</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('register') }}" class="space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Nombre</label>
|
||||
<input type="text" name="name" required autofocus class="input" value="{{ old('name') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Email</label>
|
||||
<input type="email" name="email" required class="input" value="{{ old('email') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Contraseña (mín. 8)</label>
|
||||
<input type="password" name="password" required minlength="8" class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Repite la contraseña</label>
|
||||
<input type="password" name="password_confirmation" required minlength="8" class="input">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full justify-center">Crear cuenta</button>
|
||||
</form>
|
||||
<p class="mt-6 text-center text-sm text-zinc-500">
|
||||
¿Ya tienes cuenta?
|
||||
<a href="{{ route('login') }}" class="text-indigo-400 hover:underline">Entrar</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@endsection
|
||||
260
resources/views/pages/settings.blade.php
Normal file
260
resources/views/pages/settings.blade.php
Normal file
@ -0,0 +1,260 @@
|
||||
@extends('layouts.base')
|
||||
@section('content')
|
||||
<div x-data="settingsApp()" x-init="init()" class="max-w-4xl mx-auto px-4 sm:px-6 py-8">
|
||||
<a href="{{ route('app') }}" class="text-sm text-zinc-500 hover:text-zinc-300">← Volver</a>
|
||||
<h1 class="text-3xl font-bold mt-2 mb-6">Ajustes</h1>
|
||||
|
||||
{{-- TABS --}}
|
||||
<div class="flex gap-1 border-b border-white/5 mb-6">
|
||||
<button @click="tab = 'devices'" :class="tab==='devices' ? 'border-indigo-400 text-white' : 'border-transparent text-zinc-500'" class="px-4 py-2 border-b-2">Dispositivos</button>
|
||||
<button @click="tab = 'projects'" :class="tab==='projects' ? 'border-indigo-400 text-white' : 'border-transparent text-zinc-500'" class="px-4 py-2 border-b-2">Proyectos</button>
|
||||
<button @click="tab = 'tags'" :class="tab==='tags' ? 'border-indigo-400 text-white' : 'border-transparent text-zinc-500'" class="px-4 py-2 border-b-2">Tags</button>
|
||||
<button @click="tab = 'api'" :class="tab==='api' ? 'border-indigo-400 text-white' : 'border-transparent text-zinc-500'" class="px-4 py-2">API</button>
|
||||
</div>
|
||||
|
||||
{{-- DEVICES --}}
|
||||
<div x-show="tab === 'devices'" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-zinc-400 text-sm">Tus DummyBots físicos. Cada uno tiene su propio token.</p>
|
||||
<button @click="newDeviceOpen = true" class="btn btn-primary text-sm">+ Nuevo dispositivo</button>
|
||||
</div>
|
||||
|
||||
<template x-for="d in devices" :key="d.id">
|
||||
<div class="task-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-2xl">🤖</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold flex items-center gap-2">
|
||||
<span x-text="d.name"></span>
|
||||
<span x-show="!d.last_seen_at" class="chip prio-low text-xs">nunca visto</span>
|
||||
<span x-show="d.last_seen_at" class="chip prio-low text-xs">visto <span x-text="ago(d.last_seen_at)"></span></span>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500 font-mono mt-1 truncate" x-text="d.token.substring(0, 16) + '…'"></div>
|
||||
</div>
|
||||
<button @click="rotate(d)" class="btn btn-ghost text-xs">🔄 Rotar token</button>
|
||||
<button @click="delDevice(d)" class="btn btn-danger text-xs">🗑</button>
|
||||
</div>
|
||||
<template x-if="d.tokenShown">
|
||||
<div class="mt-3 p-3 rounded bg-yellow-500/10 border border-yellow-500/30 text-sm">
|
||||
<strong>⚠️ Guarda este token, no se mostrará otra vez:</strong>
|
||||
<div class="font-mono text-xs mt-1 break-all" x-text="d.tokenShown"></div>
|
||||
<div class="mt-2 text-xs text-zinc-400">Pégalo en el firmware del ESP32 en <code>BACKEND_URL</code> + header <code>Authorization: Bearer …</code></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{{-- PROJECTS --}}
|
||||
<div x-show="tab === 'projects'" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-zinc-400 text-sm">Agrupa tareas por proyecto (cliente, contexto, etc.)</p>
|
||||
<button @click="newProjectOpen = true" class="btn btn-primary text-sm">+ Nuevo proyecto</button>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-2">
|
||||
<template x-for="p in projects" :key="p.id">
|
||||
<div class="task-card flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded" :style="'background:' + p.color"></div>
|
||||
<div class="flex-1 font-semibold" x-text="p.name"></div>
|
||||
<button @click="delProject(p)" class="btn btn-danger text-xs">🗑</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- TAGS --}}
|
||||
<div x-show="tab === 'tags'" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-zinc-400 text-sm">Etiquetas transversales para filtrar</p>
|
||||
<button @click="newTagOpen = true" class="btn btn-primary text-sm">+ Nuevo tag</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template x-for="t in tags" :key="t.id">
|
||||
<div class="chip flex items-center gap-2" :style="'background:' + t.color + '20; color:' + t.color">
|
||||
<span x-text="'#' + t.name"></span>
|
||||
<button @click="delTag(t)" class="hover:text-red-400">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- API DOC --}}
|
||||
<div x-show="tab === 'api'" class="space-y-4 text-sm">
|
||||
<p class="text-zinc-400">API REST completa. Todas las rutas devuelven JSON.</p>
|
||||
<pre class="glass rounded-lg p-4 overflow-x-auto text-xs leading-relaxed"><code>GET /api/tasks Listar tareas
|
||||
POST /api/tasks Crear tarea
|
||||
PUT /api/tasks/{id} Editar
|
||||
DELETE /api/tasks/{id} Borrar
|
||||
POST /api/tasks/{id}/done Marcar como hecha
|
||||
POST /api/tasks/{id}/snooze Posponer {minutes}
|
||||
POST /api/tasks/{id}/restore Reabrir
|
||||
|
||||
GET /api/projects Listar proyectos
|
||||
POST /api/projects Crear {name, color}
|
||||
...
|
||||
|
||||
GET /api/tags Listar tags
|
||||
POST /api/tags Crear {name, color}
|
||||
|
||||
GET /api/devices Tus dispositivos
|
||||
POST /api/devices Crear dispositivo (devuelve token)
|
||||
POST /api/devices/{id}/rotate Rotar token
|
||||
|
||||
# Para el DummyBot físico:
|
||||
GET /api/device/tasks/pending (auth.device)
|
||||
POST /api/device/tasks (auth.device)
|
||||
POST /api/device/heartbeat (auth.device) ping
|
||||
|
||||
Auth: Authorization: Bearer <token></code></pre>
|
||||
</div>
|
||||
|
||||
{{-- MODAL NUEVO DISPOSITIVO --}}
|
||||
<div x-show="newDeviceOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
@keydown.escape.window="newDeviceOpen = false">
|
||||
<div class="glass rounded-2xl p-6 w-full max-w-md" @click.outside="newDeviceOpen = false">
|
||||
<h2 class="text-xl font-bold mb-4">Nuevo DummyBot</h2>
|
||||
<form @submit.prevent="createDevice()" class="space-y-3">
|
||||
<input x-model="newDevice.name" type="text" placeholder="Nombre (ej: ESP32 del curro)" required class="input">
|
||||
<input x-model="newDevice.firmware_version" type="text" placeholder="Versión firmware (opcional)" class="input">
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear</button>
|
||||
<button type="button" @click="newDeviceOpen = false" class="btn btn-ghost">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODALES PROYECTOS Y TAGS --}}
|
||||
<div x-show="newProjectOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
@keydown.escape.window="newProjectOpen = false">
|
||||
<div class="glass rounded-2xl p-6 w-full max-w-md" @click.outside="newProjectOpen = false">
|
||||
<h2 class="text-xl font-bold mb-4">Nuevo proyecto</h2>
|
||||
<form @submit.prevent="createProject()" class="space-y-3">
|
||||
<input x-model="newProject.name" type="text" placeholder="Nombre" required class="input">
|
||||
<input x-model="newProject.color" type="color" class="input h-12">
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear</button>
|
||||
<button type="button" @click="newProjectOpen = false" class="btn btn-ghost">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="newTagOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
@keydown.escape.window="newTagOpen = false">
|
||||
<div class="glass rounded-2xl p-6 w-full max-w-md" @click.outside="newTagOpen = false">
|
||||
<h2 class="text-xl font-bold mb-4">Nuevo tag</h2>
|
||||
<form @submit.prevent="createTag()" class="space-y-3">
|
||||
<input x-model="newTag.name" type="text" placeholder="Nombre" required class="input">
|
||||
<input x-model="newTag.color" type="color" class="input h-12">
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear</button>
|
||||
<button type="button" @click="newTagOpen = false" class="btn btn-ghost">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="toast" x-cloak class="toast" x-text="toast"></div>
|
||||
</div>
|
||||
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
|
||||
<script>
|
||||
function settingsApp() {
|
||||
return {
|
||||
tab: 'devices',
|
||||
devices: [], projects: [], tags: [],
|
||||
newDeviceOpen: false, newProjectOpen: false, newTagOpen: false,
|
||||
newDevice: {name: '', firmware_version: ''},
|
||||
newProject: {name: '', color: '#6366f1'},
|
||||
newTag: {name: '', color: '#a3a3a3'},
|
||||
csrf: document.querySelector('meta[name=csrf-token]').content,
|
||||
toast: '',
|
||||
|
||||
async init() { await this.loadAll(); },
|
||||
|
||||
async loadAll() {
|
||||
this.devices = await this.api('/api/devices');
|
||||
this.projects = await this.api('/api/projects');
|
||||
this.tags = await this.api('/api/tags');
|
||||
},
|
||||
|
||||
async createDevice() {
|
||||
const d = await this.api('/api/devices', 'POST', this.newDevice);
|
||||
d.tokenShown = d.token;
|
||||
this.devices.push(d);
|
||||
this.newDeviceOpen = false;
|
||||
this.newDevice = {name: '', firmware_version: ''};
|
||||
this.flash('✓ Dispositivo creado, guarda el token');
|
||||
},
|
||||
async rotate(d) {
|
||||
if (!confirm('Rotar token invalidará el actual. ¿Continuar?')) return;
|
||||
const upd = await this.api(`/api/devices/${d.id}/rotate`, 'POST');
|
||||
upd.tokenShown = upd.token;
|
||||
Object.assign(d, upd);
|
||||
this.flash('🔄 Token rotado, guárdalo');
|
||||
},
|
||||
async delDevice(d) {
|
||||
if (!confirm('¿Borrar dispositivo?')) return;
|
||||
await this.api(`/api/devices/${d.id}`, 'DELETE');
|
||||
this.devices = this.devices.filter(x => x.id !== d.id);
|
||||
},
|
||||
|
||||
async createProject() {
|
||||
const p = await this.api('/api/projects', 'POST', this.newProject);
|
||||
this.projects.push(p);
|
||||
this.newProjectOpen = false;
|
||||
this.newProject = {name: '', color: '#6366f1'};
|
||||
},
|
||||
async delProject(p) {
|
||||
if (!confirm('¿Borrar proyecto?')) return;
|
||||
await this.api(`/api/projects/${p.id}`, 'DELETE');
|
||||
this.projects = this.projects.filter(x => x.id !== p.id);
|
||||
},
|
||||
|
||||
async createTag() {
|
||||
const t = await this.api('/api/tags', 'POST', this.newTag);
|
||||
this.tags.push(t);
|
||||
this.newTagOpen = false;
|
||||
this.newTag = {name: '', color: '#a3a3a3'};
|
||||
},
|
||||
async delTag(t) {
|
||||
await this.api(`/api/tags/${t.id}`, 'DELETE');
|
||||
this.tags = this.tags.filter(x => x.id !== t.id);
|
||||
},
|
||||
|
||||
ago(s) {
|
||||
if (!s) return 'nunca';
|
||||
const d = (Date.now() - new Date(s).getTime()) / 1000;
|
||||
if (d < 60) return 'hace un momento';
|
||||
if (d < 3600) return `hace ${Math.floor(d/60)} min`;
|
||||
if (d < 86400) return `hace ${Math.floor(d/3600)} h`;
|
||||
return `hace ${Math.floor(d/86400)} d`;
|
||||
},
|
||||
|
||||
async api(url, method = 'GET', body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
if (body) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
},
|
||||
flash(msg) { this.toast = msg; setTimeout(() => this.toast = '', 2500); },
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@endsection
|
||||
34
routes/api.php
Normal file
34
routes/api.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Auth\AuthController;
|
||||
use App\Http\Controllers\TaskController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// ─── Auth (JSON, devuelve token Sanctum para integraciones externas) ───
|
||||
Route::post('auth/register', [AuthController::class, 'register']);
|
||||
Route::post('auth/login', [AuthController::class, 'login']);
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get ('auth/me', [AuthController::class, 'me']);
|
||||
Route::post('auth/logout', [AuthController::class, 'logout']);
|
||||
});
|
||||
|
||||
// ─── Endpoints del DummyBot (ESP32) ───
|
||||
// Usa el token del dispositivo en Authorization: Bearer <token>
|
||||
Route::middleware('auth.device')->prefix('device')->group(function () {
|
||||
Route::get ('tasks/pending', [TaskController::class, 'pending']);
|
||||
Route::post ('tasks', [TaskController::class, 'store']);
|
||||
Route::post ('tasks/{task}/done', [TaskController::class, 'done']);
|
||||
Route::post ('tasks/{task}/snooze', [TaskController::class, 'snooze']);
|
||||
Route::delete('tasks/{task}', [TaskController::class, 'destroy']);
|
||||
Route::post ('heartbeat', function (Request $r) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'server_time' => now()->toIso8601String(),
|
||||
'device' => $r->attributes->get('device')?->only('id','name'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Las rutas web JSON (/api/tasks, /api/devices, etc.) están en routes/web.php
|
||||
// porque necesitan sesión + Sanctum web guard para que la UI funcione sin tokens.
|
||||
8
routes/console.php
Normal file
8
routes/console.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
48
routes/web.php
Normal file
48
routes/web.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PageController;
|
||||
use App\Http\Controllers\TaskController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\Api\DeviceController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\TagController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// ─── Páginas públicas ───
|
||||
Route::get ('/', [PageController::class, 'home'])->name('home');
|
||||
Route::get ('/login', [LoginController::class, 'showLogin'])->name('login');
|
||||
Route::get ('/register',[LoginController::class, 'showRegister'])->name('register');
|
||||
Route::post('/login', [LoginController::class, 'login']);
|
||||
Route::post('/register',[LoginController::class, 'register']);
|
||||
Route::post('/logout', [LoginController::class, 'logout'])->middleware('auth:sanctum')->name('logout');
|
||||
|
||||
// ─── Páginas protegidas ───
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::get('/app', [PageController::class, 'app'])->name('app');
|
||||
Route::get('/app/settings', [PageController::class, 'settings'])->name('settings');
|
||||
|
||||
// API web (con sesión, usa el guard 'web' de Sanctum)
|
||||
// Tareas
|
||||
Route::get ('/api/tasks', [TaskController::class, 'index']);
|
||||
Route::post ('/api/tasks', [TaskController::class, 'store']);
|
||||
Route::put ('/api/tasks/{task}', [TaskController::class, 'update']);
|
||||
Route::delete('/api/tasks/{task}', [TaskController::class, 'destroy']);
|
||||
Route::post ('/api/tasks/{task}/done', [TaskController::class, 'done']);
|
||||
Route::post ('/api/tasks/{task}/snooze', [TaskController::class, 'snooze']);
|
||||
Route::post ('/api/tasks/{task}/restore', [TaskController::class, 'restore']);
|
||||
Route::get ('/api/tasks/stats', [TaskController::class, 'stats']);
|
||||
// Proyectos
|
||||
Route::get ('/api/projects', [ProjectController::class, 'index']);
|
||||
Route::post ('/api/projects', [ProjectController::class, 'store']);
|
||||
Route::put ('/api/projects/{project}', [ProjectController::class, 'update']);
|
||||
Route::delete('/api/projects/{project}', [ProjectController::class, 'destroy']);
|
||||
// Tags
|
||||
Route::get ('/api/tags', [TagController::class, 'index']);
|
||||
Route::post ('/api/tags', [TagController::class, 'store']);
|
||||
Route::delete('/api/tags/{tag}', [TagController::class, 'destroy']);
|
||||
// Devices
|
||||
Route::get ('/api/devices', [DeviceController::class, 'index']);
|
||||
Route::post ('/api/devices', [DeviceController::class, 'store']);
|
||||
Route::post ('/api/devices/{device}/rotate', [DeviceController::class, 'rotate']);
|
||||
Route::delete('/api/devices/{device}', [DeviceController::class, 'destroy']);
|
||||
});
|
||||
4
storage/app/.gitignore
vendored
Normal file
4
storage/app/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
*
|
||||
!private/
|
||||
!public/
|
||||
!.gitignore
|
||||
2
storage/app/private/.gitignore
vendored
Normal file
2
storage/app/private/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
storage/app/public/.gitignore
vendored
Normal file
2
storage/app/public/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
9
storage/framework/.gitignore
vendored
Normal file
9
storage/framework/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
3
storage/framework/cache/.gitignore
vendored
Normal file
3
storage/framework/cache/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
*
|
||||
!data/
|
||||
!.gitignore
|
||||
2
storage/framework/cache/data/.gitignore
vendored
Normal file
2
storage/framework/cache/data/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
storage/framework/sessions/.gitignore
vendored
Normal file
2
storage/framework/sessions/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
storage/framework/testing/.gitignore
vendored
Normal file
2
storage/framework/testing/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
storage/framework/views/.gitignore
vendored
Normal file
2
storage/framework/views/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
storage/logs/.gitignore
vendored
Normal file
2
storage/logs/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
19
tests/Feature/ExampleTest.php
Normal file
19
tests/Feature/ExampleTest.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
10
tests/TestCase.php
Normal file
10
tests/TestCase.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
16
tests/Unit/ExampleTest.php
Normal file
16
tests/Unit/ExampleTest.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
24
vite.config.js
Normal file
24
vite.config.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import { bunny } from 'laravel-vite-plugin/fonts';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
refresh: true,
|
||||
fonts: [
|
||||
bunny('Instrument Sans', {
|
||||
weights: [400, 500, 600],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user