merge: unificar README raíz con backend y firmware

This commit is contained in:
javiservices 2026-06-17 14:04:46 +02:00
commit 0d4b126945
87 changed files with 12545 additions and 85 deletions

18
.editorconfig Normal file
View 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
View 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
View 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
View 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/

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
ignore-scripts=true
audit=true

56
Dockerfile Normal file
View 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"]

218
README.md
View File

@ -1,106 +1,154 @@
# 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
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
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)
```
## 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
MIT
MIT

View 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]);
}
}

View 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]);
}
}

View 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]);
}
}

View 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());
}
}

View 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');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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');
}
}

View 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);
}
}

View 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
View 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
View 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
View 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
View 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
View 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); }
}

View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
*
!.gitignore

7
bootstrap/providers.php Normal file
View File

@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

87
composer.json Normal file
View 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

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
*.sqlite*

View 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 [
//
];
}
}

View 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 [
//
];
}
}

View 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 [
//
];
}
}

View 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,
]);
}
}

View 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');
}
};

View 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');
}
};

View 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');
}
};

View 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'); }
};

View File

@ -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');
}
};

View File

@ -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'); }
};

View 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');
}
};

View File

@ -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'); }
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

20
public/index.php Normal file
View 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
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

9
resources/css/app.css Normal file
View 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
View File

@ -0,0 +1 @@
//

View 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>

View 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

View 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

View 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

View 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

View 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 &lt;token&gt;</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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore

2
storage/app/private/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/app/public/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

9
storage/framework/.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
*
!data/
!.gitignore

View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/sessions/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/testing/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/framework/views/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

2
storage/logs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View 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
View File

@ -0,0 +1,10 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}

View 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
View 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/**'],
},
},
});