v2: auth (Sanctum) + devices tokens + projects + tags + UI web épica con Tailwind+Alpine
This commit is contained in:
parent
cb35da3787
commit
50e512c155
41
app/Http/Controllers/Api/DeviceController.php
Normal file
41
app/Http/Controllers/Api/DeviceController.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Device;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DeviceController extends Controller {
|
||||||
|
public function index(Request $r): JsonResponse {
|
||||||
|
return response()->json($r->user()->devices()->orderBy('name')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $r): JsonResponse {
|
||||||
|
$data = $r->validate([
|
||||||
|
'name' => 'required|string|max:60',
|
||||||
|
'hardware' => 'nullable|string|max:60',
|
||||||
|
'firmware_version' => 'nullable|string|max:30',
|
||||||
|
]);
|
||||||
|
$device = $r->user()->devices()->create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'hardware' => $data['hardware'] ?? 'esp32',
|
||||||
|
'firmware_version' => $data['firmware_version'] ?? null,
|
||||||
|
'token' => Device::makeToken(),
|
||||||
|
]);
|
||||||
|
return response()->json($device, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rotate(Request $r, Device $device): JsonResponse {
|
||||||
|
abort_unless($device->user_id === $r->user()->id, 403);
|
||||||
|
$device->update(['token' => Device::makeToken()]);
|
||||||
|
return response()->json($device);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $r, Device $device): JsonResponse {
|
||||||
|
abort_unless($device->user_id === $r->user()->id, 403);
|
||||||
|
$device->delete();
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Http/Controllers/Api/ProjectController.php
Normal file
43
app/Http/Controllers/Api/ProjectController.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ProjectController extends Controller {
|
||||||
|
public function index(Request $r): JsonResponse {
|
||||||
|
return response()->json($r->user()->projects()->orderBy('name')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $r): JsonResponse {
|
||||||
|
$data = $r->validate([
|
||||||
|
'name' => 'required|string|max:80',
|
||||||
|
'color' => 'nullable|string|max:7',
|
||||||
|
'icon' => 'nullable|string|max:40',
|
||||||
|
]);
|
||||||
|
return response()->json(
|
||||||
|
$r->user()->projects()->create($data + ['color' => $data['color'] ?? '#6366f1']),
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $r, Project $project): JsonResponse {
|
||||||
|
abort_unless($project->user_id === $r->user()->id, 403);
|
||||||
|
$data = $r->validate([
|
||||||
|
'name' => 'sometimes|required|string|max:80',
|
||||||
|
'color' => 'nullable|string|max:7',
|
||||||
|
'icon' => 'nullable|string|max:40',
|
||||||
|
]);
|
||||||
|
$project->update($data);
|
||||||
|
return response()->json($project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $r, Project $project): JsonResponse {
|
||||||
|
abort_unless($project->user_id === $r->user()->id, 403);
|
||||||
|
$project->delete();
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Controllers/Api/TagController.php
Normal file
31
app/Http/Controllers/Api/TagController.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TagController extends Controller {
|
||||||
|
public function index(Request $r): JsonResponse {
|
||||||
|
return response()->json($r->user()->tags()->orderBy('name')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $r): JsonResponse {
|
||||||
|
$data = $r->validate([
|
||||||
|
'name' => 'required|string|max:30',
|
||||||
|
'color' => 'nullable|string|max:7',
|
||||||
|
]);
|
||||||
|
return response()->json(
|
||||||
|
$r->user()->tags()->create($data + ['color' => $data['color'] ?? '#a3a3a3']),
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $r, Tag $tag): JsonResponse {
|
||||||
|
abort_unless($tag->user_id === $r->user()->id, 403);
|
||||||
|
$tag->delete();
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Controllers/Auth/AuthController.php
Normal file
50
app/Http/Controllers/Auth/AuthController.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class AuthController extends Controller {
|
||||||
|
public function register(Request $r): JsonResponse {
|
||||||
|
$data = $r->validate([
|
||||||
|
'name' => 'required|string|max:60',
|
||||||
|
'email' => 'required|email|max:120|unique:users',
|
||||||
|
'password' => 'required|string|min:8|max:120',
|
||||||
|
]);
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => Hash::make($data['password']),
|
||||||
|
]);
|
||||||
|
$token = $user->createToken('web')->plainTextToken;
|
||||||
|
return response()->json(['user' => $user, 'token' => $token], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(Request $r): JsonResponse {
|
||||||
|
$data = $r->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required|string',
|
||||||
|
]);
|
||||||
|
$user = User::where('email', $data['email'])->first();
|
||||||
|
if (! $user || ! Hash::check($data['password'], $user->password)) {
|
||||||
|
return response()->json(['message' => 'Credenciales inválidas'], 401);
|
||||||
|
}
|
||||||
|
$token = $user->createToken('web')->plainTextToken;
|
||||||
|
return response()->json(['user' => $user, 'token' => $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $r): JsonResponse {
|
||||||
|
$r->user()->currentAccessToken()->delete();
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function me(Request $r): JsonResponse {
|
||||||
|
return response()->json($r->user());
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Http/Controllers/Auth/LoginController.php
Normal file
48
app/Http/Controllers/Auth/LoginController.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class LoginController extends Controller {
|
||||||
|
public function showLogin() { return view('pages.login'); }
|
||||||
|
public function showRegister() { return view('pages.register'); }
|
||||||
|
|
||||||
|
public function login(Request $r) {
|
||||||
|
$cred = $r->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required|string',
|
||||||
|
]);
|
||||||
|
if (! Auth::attempt($cred, true)) {
|
||||||
|
return back()->withErrors(['email' => 'Credenciales inválidas'])->withInput();
|
||||||
|
}
|
||||||
|
$r->session()->regenerate();
|
||||||
|
return redirect()->route('app');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Request $r) {
|
||||||
|
$data = $r->validate([
|
||||||
|
'name' => 'required|string|max:60',
|
||||||
|
'email' => 'required|email|unique:users,email',
|
||||||
|
'password' => 'required|string|min:8|confirmed',
|
||||||
|
]);
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => Hash::make($data['password']),
|
||||||
|
]);
|
||||||
|
Auth::login($user);
|
||||||
|
return redirect()->route('app');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $r) {
|
||||||
|
Auth::logout();
|
||||||
|
$r->session()->invalidate();
|
||||||
|
$r->session()->regenerateToken();
|
||||||
|
return redirect()->route('home');
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Http/Controllers/PageController.php
Normal file
16
app/Http/Controllers/PageController.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
class PageController extends Controller {
|
||||||
|
public function home() {
|
||||||
|
if (auth()->check()) return redirect()->route('app');
|
||||||
|
return view('pages.home');
|
||||||
|
}
|
||||||
|
public function app() {
|
||||||
|
return view('pages.app');
|
||||||
|
}
|
||||||
|
public function settings() {
|
||||||
|
return view('pages.settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,92 +5,152 @@
|
|||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class TaskController extends Controller
|
class TaskController extends Controller {
|
||||||
{
|
public function index(Request $r) {
|
||||||
/** UI web (lista + formulario). */
|
$tasks = $r->user()->tasks()
|
||||||
public function index(): View
|
->with(['project:id,name,color', 'tags:id,name,color'])
|
||||||
{
|
->orderBy('done')
|
||||||
$tasks = Task::orderBy('done')
|
->orderByRaw("FIELD(priority,'high','med','low')")
|
||||||
->orderByRaw("FIELD(priority,'high','med','low')")
|
->orderBy('created_at')
|
||||||
->orderBy('created_at')
|
->get();
|
||||||
->get();
|
return response()->json($tasks);
|
||||||
return view('tasks.index', compact('tasks'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/tasks/pending -> JSON para el ESP32 */
|
public function pending(Request $r): JsonResponse {
|
||||||
public function pending(): JsonResponse
|
$tasks = $r->user()->tasks()
|
||||||
{
|
->active()
|
||||||
$tasks = Task::active()
|
->with('project:id,name,color')
|
||||||
->orderByRaw("FIELD(priority,'high','med','low')")
|
->orderByRaw("FIELD(priority,'high','med','low')")
|
||||||
->orderBy('created_at')
|
->orderBy('created_at')
|
||||||
->limit(20)
|
->limit(20)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return response()->json($tasks->map(fn ($t) => [
|
return response()->json($tasks->map(fn ($t) => [
|
||||||
'id' => $t->id,
|
'id' => $t->id,
|
||||||
'title' => $t->title,
|
'title' => $t->title,
|
||||||
'priority' => $t->priority,
|
'priority' => $t->priority,
|
||||||
|
'project' => $t->project?->name,
|
||||||
'next_due_in' => $t->next_due_in,
|
'next_due_in' => $t->next_due_in,
|
||||||
|
'estimate_min' => $t->estimate_min,
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/tasks -> crear (también desde la web) */
|
public function store(Request $r): JsonResponse {
|
||||||
public function store(Request $request): JsonResponse|\Illuminate\Http\RedirectResponse
|
$data = $r->validate([
|
||||||
{
|
'title' => 'required|string|max:120',
|
||||||
$data = $request->validate([
|
'description' => 'nullable|string',
|
||||||
'title' => 'required|string|max:120',
|
'priority' => 'nullable|in:low,med,high',
|
||||||
'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([
|
||||||
$task = Task::create([
|
'title' => $data['title'],
|
||||||
'title' => $data['title'],
|
'description' => $data['description'] ?? null,
|
||||||
'priority' => $data['priority'] ?? 'med',
|
'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'])) {
|
||||||
if ($request->wantsJson()) {
|
$this->syncTags($task, $data['tags']);
|
||||||
return response()->json($task, 201);
|
|
||||||
}
|
}
|
||||||
return redirect()->route('tasks.index');
|
return response()->json($task->load('project', 'tags'), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/tasks/{task}/done -> marcar como hecha */
|
public function update(Request $r, Task $task): JsonResponse {
|
||||||
public function done(Task $task): JsonResponse
|
abort_unless($task->user_id === $r->user()->id, 403);
|
||||||
{
|
$data = $r->validate([
|
||||||
$task->update([
|
'title' => 'sometimes|required|string|max:120',
|
||||||
'done' => true,
|
'description' => 'nullable|string',
|
||||||
'done_at' => now(),
|
'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]);
|
return response()->json(['ok' => true, 'id' => $task->id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/tasks/{task}/snooze -> posponer X minutos */
|
public function snooze(Request $r, Task $task): JsonResponse {
|
||||||
public function snooze(Request $request, Task $task): JsonResponse
|
abort_unless($task->user_id === $r->user()->id, 403);
|
||||||
{
|
$minutes = (int) $r->input('minutes', 5);
|
||||||
$minutes = (int) $request->input('minutes', 5);
|
$minutes = max(1, min(720, $minutes));
|
||||||
$minutes = max(1, min(120, $minutes));
|
$task->update(['snooze_until' => now()->addMinutes($minutes)]);
|
||||||
|
|
||||||
$task->update([
|
|
||||||
'snooze_until' => now()->addMinutes($minutes),
|
|
||||||
]);
|
|
||||||
return response()->json(['ok' => true, 'id' => $task->id, 'minutes' => $minutes]);
|
return response()->json(['ok' => true, 'id' => $task->id, 'minutes' => $minutes]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE /api/tasks/{task} -> borrar */
|
public function destroy(Request $r, Task $task): JsonResponse {
|
||||||
public function destroy(Task $task): JsonResponse|\Illuminate\Http\RedirectResponse
|
abort_unless($task->user_id === $r->user()->id, 403);
|
||||||
{
|
|
||||||
$task->delete();
|
$task->delete();
|
||||||
if (request()->wantsJson()) {
|
return response()->json(['ok' => true]);
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
return redirect()->route('tasks.index');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /tasks/{task}/restore -> reabrir */
|
public function restore(Request $r, Task $task): JsonResponse {
|
||||||
public function restore(Task $task): \Illuminate\Http\RedirectResponse
|
abort_unless($task->user_id === $r->user()->id, 403);
|
||||||
{
|
|
||||||
$task->update(['done' => false, 'done_at' => null, 'snooze_until' => null]);
|
$task->update(['done' => false, 'done_at' => null, 'snooze_until' => null]);
|
||||||
return redirect()->route('tasks.index');
|
return response()->json($task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estadísticas rápidas para el dashboard */
|
||||||
|
public function stats(Request $r): JsonResponse {
|
||||||
|
$userId = $r->user()->id;
|
||||||
|
$today = now()->startOfDay();
|
||||||
|
return response()->json([
|
||||||
|
'pending' => Task::forUser($userId)->active()->count(),
|
||||||
|
'today' => Task::forUser($userId)->active()
|
||||||
|
->where('created_at', '>=', $today)->count(),
|
||||||
|
'done_today'=> Task::forUser($userId)->where('done', true)
|
||||||
|
->where('done_at', '>=', $today)->count(),
|
||||||
|
'overdue' => Task::forUser($userId)->active()
|
||||||
|
->where('deadline', '<', now())->count(),
|
||||||
|
'total_done'=> Task::forUser($userId)->where('done', true)->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncTags(Task $task, array $tagNames): void {
|
||||||
|
$userId = $task->user_id;
|
||||||
|
$ids = [];
|
||||||
|
foreach ($tagNames as $name) {
|
||||||
|
$tag = \App\Models\Tag::firstOrCreate(
|
||||||
|
['user_id' => $userId, 'name' => $name],
|
||||||
|
['color' => '#a3a3a3']
|
||||||
|
);
|
||||||
|
$ids[] = $tag->id;
|
||||||
|
}
|
||||||
|
$task->tags()->sync($ids);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/Http/Middleware/AuthenticateDevice.php
Normal file
25
app/Http/Middleware/AuthenticateDevice.php
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AuthenticateDevice {
|
||||||
|
public function handle(Request $request, Closure $next): Response {
|
||||||
|
$token = $request->bearerToken();
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['error' => 'No device token'], 401);
|
||||||
|
}
|
||||||
|
$device = Device::where('token', $token)->first();
|
||||||
|
if (! $device) {
|
||||||
|
return response()->json(['error' => 'Invalid device token'], 401);
|
||||||
|
}
|
||||||
|
$device->touchSeen();
|
||||||
|
$request->setUserResolver(fn () => $device->user);
|
||||||
|
$request->attributes->set('device', $device);
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Models/Device.php
Normal file
33
app/Models/Device.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Device extends Model {
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id', 'name', 'token', 'hardware',
|
||||||
|
'firmware_version', 'last_seen_at', 'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'last_seen_at' => 'datetime',
|
||||||
|
'settings' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||||
|
|
||||||
|
/** Genera un token nuevo seguro */
|
||||||
|
public static function makeToken(): string {
|
||||||
|
return Str::random(48);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touchSeen(): void {
|
||||||
|
$this->update(['last_seen_at' => now()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Models/Project.php
Normal file
17
app/Models/Project.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Project extends Model {
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = ['user_id', 'name', 'color', 'icon'];
|
||||||
|
|
||||||
|
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||||
|
public function tasks(): HasMany { return $this->hasMany(Task::class); }
|
||||||
|
}
|
||||||
17
app/Models/Tag.php
Normal file
17
app/Models/Tag.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class Tag extends Model {
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = ['user_id', 'name', 'color'];
|
||||||
|
|
||||||
|
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||||
|
public function tasks(): BelongsToMany { return $this->belongsToMany(Task::class); }
|
||||||
|
}
|
||||||
@ -4,41 +4,47 @@
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class Task extends Model
|
class Task extends Model {
|
||||||
{
|
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'title',
|
'user_id', 'project_id', 'title', 'description', 'priority',
|
||||||
'priority',
|
'estimate_min', 'deadline', 'recurrence',
|
||||||
'done',
|
'done', 'done_at', 'snooze_until',
|
||||||
'snooze_until',
|
|
||||||
'done_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'done' => 'boolean',
|
'done' => 'boolean',
|
||||||
'snooze_until' => 'datetime',
|
|
||||||
'done_at' => 'datetime',
|
'done_at' => 'datetime',
|
||||||
|
'snooze_until' => 'datetime',
|
||||||
|
'deadline' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Tareas "activas": no hechas y no snoozed. */
|
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
||||||
public function scopeActive(Builder $q): Builder
|
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)
|
return $q->where('done', false)
|
||||||
->where(function ($q2) {
|
->where(function ($q2) {
|
||||||
$q2->whereNull('snooze_until')
|
$q2->whereNull('snooze_until')
|
||||||
->orWhere('snooze_until', '<=', now());
|
->orWhere('snooze_until', '<=', now());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getNextDueInAttribute(): int
|
public function scopeForUser(Builder $q, int $userId): Builder {
|
||||||
{
|
return $q->where('user_id', $userId);
|
||||||
if (! $this->snooze_until || $this->snooze_until->isPast()) {
|
}
|
||||||
return 0;
|
|
||||||
}
|
public function getNextDueInAttribute(): int {
|
||||||
return max(0, now()->diffInSeconds($this->snooze_until, false));
|
if (! $this->deadline) return 0;
|
||||||
|
if ($this->deadline->isPast()) return 0;
|
||||||
|
return max(0, now()->diffInSeconds($this->deadline, false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,10 +13,12 @@
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->alias([
|
||||||
|
'auth.device' => \App\Http\Middleware\AuthenticateDevice::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
fn (Request $request) => $request->is('api/*'),
|
fn (Request $request) => $request->is('api/*') || $request->is('api/device/*'),
|
||||||
);
|
);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
24
database/factories/DeviceFactory.php
Normal file
24
database/factories/DeviceFactory.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Device>
|
||||||
|
*/
|
||||||
|
class DeviceFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
database/factories/ProjectFactory.php
Normal file
24
database/factories/ProjectFactory.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Project>
|
||||||
|
*/
|
||||||
|
class ProjectFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,23 +4,25 @@
|
|||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration {
|
||||||
{
|
public function up(): void {
|
||||||
public function up(): void
|
Schema::create('tasks', function (Blueprint $t) {
|
||||||
{
|
$t->id();
|
||||||
Schema::create('tasks', function (Blueprint $table) {
|
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
$table->id();
|
$t->foreignId('project_id')->nullable()->constrained()->nullOnDelete();
|
||||||
$table->string('title', 120);
|
$t->string('title', 120);
|
||||||
$table->enum('priority', ['low', 'med', 'high'])->default('med');
|
$t->text('description')->nullable();
|
||||||
$table->boolean('done')->default(false);
|
$t->enum('priority', ['low', 'med', 'high'])->default('med');
|
||||||
$table->timestamp('snooze_until')->nullable();
|
$t->unsignedSmallInteger('estimate_min')->nullable(); // estimación en min
|
||||||
$table->timestamp('done_at')->nullable();
|
$t->timestamp('deadline')->nullable();
|
||||||
$table->timestamps();
|
$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'); }
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('tasks');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void {
|
||||||
|
Schema::create('projects', function (Blueprint $t) {
|
||||||
|
$t->id();
|
||||||
|
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$t->string('name', 80);
|
||||||
|
$t->string('color', 7)->default('#6366f1'); // hex color
|
||||||
|
$t->string('icon', 40)->nullable(); // emoji o nombre
|
||||||
|
$t->timestamps();
|
||||||
|
$t->unique(['user_id', 'name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public function down(): void { Schema::dropIfExists('projects'); }
|
||||||
|
};
|
||||||
30
database/migrations/2026_06_16_234920_create_tags_table.php
Normal file
30
database/migrations/2026_06_16_234920_create_tags_table.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void {
|
||||||
|
Schema::create('tags', function (Blueprint $t) {
|
||||||
|
$t->id();
|
||||||
|
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$t->string('name', 30);
|
||||||
|
$t->string('color', 7)->default('#a3a3a3');
|
||||||
|
$t->timestamps();
|
||||||
|
$t->unique(['user_id', 'name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pivot task <-> tag
|
||||||
|
Schema::create('tag_task', function (Blueprint $t) {
|
||||||
|
$t->id();
|
||||||
|
$t->foreignId('task_id')->constrained()->cascadeOnDelete();
|
||||||
|
$t->foreignId('tag_id')->constrained()->cascadeOnDelete();
|
||||||
|
$t->unique(['task_id', 'tag_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public function down(): void {
|
||||||
|
Schema::dropIfExists('tag_task');
|
||||||
|
Schema::dropIfExists('tags');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void {
|
||||||
|
Schema::create('devices', function (Blueprint $t) {
|
||||||
|
$t->id();
|
||||||
|
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$t->string('name', 60); // "ESP32 del curro"
|
||||||
|
$t->string('token', 64)->unique(); // token dedicado
|
||||||
|
$t->string('hardware', 60)->default('esp32');
|
||||||
|
$t->string('firmware_version', 30)->nullable();
|
||||||
|
$t->timestamp('last_seen_at')->nullable();
|
||||||
|
$t->json('settings')->nullable(); // config del bot
|
||||||
|
$t->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public function down(): void { Schema::dropIfExists('devices'); }
|
||||||
|
};
|
||||||
@ -5,11 +5,18 @@ chown -R www-data:www-data /var/www
|
|||||||
chmod -R 755 /var/www/storage
|
chmod -R 755 /var/www/storage
|
||||||
chmod -R 755 /var/www/bootstrap/cache
|
chmod -R 755 /var/www/bootstrap/cache
|
||||||
|
|
||||||
# Run migrations + seed on first boot if DB is empty
|
# Run migrations on first boot if AUTO_MIGRATE=1.
|
||||||
# (only if explicitly enabled; off by default to avoid surprise migrations)
|
# We wait for the DB to be reachable (max 60 s) to avoid race with the db container.
|
||||||
if [ "${AUTO_MIGRATE:-0}" = "1" ]; then
|
if [ "${AUTO_MIGRATE:-0}" = "1" ]; then
|
||||||
echo "[entrypoint] AUTO_MIGRATE=1 -> running migrations"
|
echo "[entrypoint] AUTO_MIGRATE=1 -> waiting for DB..."
|
||||||
php artisan migrate --force --no-interaction
|
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
|
fi
|
||||||
|
|
||||||
# Execute the main command
|
# Execute the main command
|
||||||
|
|||||||
395
resources/views/pages/app.blade.php
Normal file
395
resources/views/pages/app.blade.php
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
<x-layouts.base>
|
||||||
|
<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>
|
||||||
|
</x-layouts.base>
|
||||||
138
resources/views/pages/home.blade.php
Normal file
138
resources/views/pages/home.blade.php
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<x-layouts.base>
|
||||||
|
<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>
|
||||||
|
</x-layouts.base>
|
||||||
36
resources/views/pages/login.blade.php
Normal file
36
resources/views/pages/login.blade.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<x-layouts.base>
|
||||||
|
<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>
|
||||||
|
</x-layouts.base>
|
||||||
43
resources/views/pages/register.blade.php
Normal file
43
resources/views/pages/register.blade.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<x-layouts.base>
|
||||||
|
<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>
|
||||||
|
</x-layouts.base>
|
||||||
257
resources/views/pages/settings.blade.php
Normal file
257
resources/views/pages/settings.blade.php
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
<x-layouts.base>
|
||||||
|
<div x-data="settingsApp()" x-init="init()" class="max-w-4xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<a href="{{ route('app') }}" class="text-sm text-zinc-500 hover:text-zinc-300">← Volver</a>
|
||||||
|
<h1 class="text-3xl font-bold mt-2 mb-6">Ajustes</h1>
|
||||||
|
|
||||||
|
{{-- TABS --}}
|
||||||
|
<div class="flex gap-1 border-b border-white/5 mb-6">
|
||||||
|
<button @click="tab = 'devices'" :class="tab==='devices' ? 'border-indigo-400 text-white' : 'border-transparent text-zinc-500'" class="px-4 py-2 border-b-2">Dispositivos</button>
|
||||||
|
<button @click="tab = 'projects'" :class="tab==='projects' ? 'border-indigo-400 text-white' : 'border-transparent text-zinc-500'" class="px-4 py-2 border-b-2">Proyectos</button>
|
||||||
|
<button @click="tab = 'tags'" :class="tab==='tags' ? 'border-indigo-400 text-white' : 'border-transparent text-zinc-500'" class="px-4 py-2 border-b-2">Tags</button>
|
||||||
|
<button @click="tab = 'api'" :class="tab==='api' ? 'border-indigo-400 text-white' : 'border-transparent text-zinc-500'" class="px-4 py-2">API</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- DEVICES --}}
|
||||||
|
<div x-show="tab === 'devices'" class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-zinc-400 text-sm">Tus DummyBots físicos. Cada uno tiene su propio token.</p>
|
||||||
|
<button @click="newDeviceOpen = true" class="btn btn-primary text-sm">+ Nuevo dispositivo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="d in devices" :key="d.id">
|
||||||
|
<div class="task-card">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="text-2xl">🤖</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-semibold flex items-center gap-2">
|
||||||
|
<span x-text="d.name"></span>
|
||||||
|
<span x-show="!d.last_seen_at" class="chip prio-low text-xs">nunca visto</span>
|
||||||
|
<span x-show="d.last_seen_at" class="chip prio-low text-xs">visto <span x-text="ago(d.last_seen_at)"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-zinc-500 font-mono mt-1 truncate" x-text="d.token.substring(0, 16) + '…'"></div>
|
||||||
|
</div>
|
||||||
|
<button @click="rotate(d)" class="btn btn-ghost text-xs">🔄 Rotar token</button>
|
||||||
|
<button @click="delDevice(d)" class="btn btn-danger text-xs">🗑</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="d.tokenShown">
|
||||||
|
<div class="mt-3 p-3 rounded bg-yellow-500/10 border border-yellow-500/30 text-sm">
|
||||||
|
<strong>⚠️ Guarda este token, no se mostrará otra vez:</strong>
|
||||||
|
<div class="font-mono text-xs mt-1 break-all" x-text="d.tokenShown"></div>
|
||||||
|
<div class="mt-2 text-xs text-zinc-400">Pégalo en el firmware del ESP32 en <code>BACKEND_URL</code> + header <code>Authorization: Bearer …</code></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- PROJECTS --}}
|
||||||
|
<div x-show="tab === 'projects'" class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-zinc-400 text-sm">Agrupa tareas por proyecto (cliente, contexto, etc.)</p>
|
||||||
|
<button @click="newProjectOpen = true" class="btn btn-primary text-sm">+ Nuevo proyecto</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-2">
|
||||||
|
<template x-for="p in projects" :key="p.id">
|
||||||
|
<div class="task-card flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded" :style="'background:' + p.color"></div>
|
||||||
|
<div class="flex-1 font-semibold" x-text="p.name"></div>
|
||||||
|
<button @click="delProject(p)" class="btn btn-danger text-xs">🗑</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- TAGS --}}
|
||||||
|
<div x-show="tab === 'tags'" class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-zinc-400 text-sm">Etiquetas transversales para filtrar</p>
|
||||||
|
<button @click="newTagOpen = true" class="btn btn-primary text-sm">+ Nuevo tag</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<template x-for="t in tags" :key="t.id">
|
||||||
|
<div class="chip flex items-center gap-2" :style="'background:' + t.color + '20; color:' + t.color">
|
||||||
|
<span x-text="'#' + t.name"></span>
|
||||||
|
<button @click="delTag(t)" class="hover:text-red-400">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- API DOC --}}
|
||||||
|
<div x-show="tab === 'api'" class="space-y-4 text-sm">
|
||||||
|
<p class="text-zinc-400">API REST completa. Todas las rutas devuelven JSON.</p>
|
||||||
|
<pre class="glass rounded-lg p-4 overflow-x-auto text-xs leading-relaxed"><code>GET /api/tasks Listar tareas
|
||||||
|
POST /api/tasks Crear tarea
|
||||||
|
PUT /api/tasks/{id} Editar
|
||||||
|
DELETE /api/tasks/{id} Borrar
|
||||||
|
POST /api/tasks/{id}/done Marcar como hecha
|
||||||
|
POST /api/tasks/{id}/snooze Posponer {minutes}
|
||||||
|
POST /api/tasks/{id}/restore Reabrir
|
||||||
|
|
||||||
|
GET /api/projects Listar proyectos
|
||||||
|
POST /api/projects Crear {name, color}
|
||||||
|
...
|
||||||
|
|
||||||
|
GET /api/tags Listar tags
|
||||||
|
POST /api/tags Crear {name, color}
|
||||||
|
|
||||||
|
GET /api/devices Tus dispositivos
|
||||||
|
POST /api/devices Crear dispositivo (devuelve token)
|
||||||
|
POST /api/devices/{id}/rotate Rotar token
|
||||||
|
|
||||||
|
# Para el DummyBot físico:
|
||||||
|
GET /api/device/tasks/pending (auth.device)
|
||||||
|
POST /api/device/tasks (auth.device)
|
||||||
|
POST /api/device/heartbeat (auth.device) ping
|
||||||
|
|
||||||
|
Auth: Authorization: Bearer <token></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- MODAL NUEVO DISPOSITIVO --}}
|
||||||
|
<div x-show="newDeviceOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||||
|
@keydown.escape.window="newDeviceOpen = false">
|
||||||
|
<div class="glass rounded-2xl p-6 w-full max-w-md" @click.outside="newDeviceOpen = false">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Nuevo DummyBot</h2>
|
||||||
|
<form @submit.prevent="createDevice()" class="space-y-3">
|
||||||
|
<input x-model="newDevice.name" type="text" placeholder="Nombre (ej: ESP32 del curro)" required class="input">
|
||||||
|
<input x-model="newDevice.firmware_version" type="text" placeholder="Versión firmware (opcional)" class="input">
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear</button>
|
||||||
|
<button type="button" @click="newDeviceOpen = false" class="btn btn-ghost">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- MODALES PROYECTOS Y TAGS --}}
|
||||||
|
<div x-show="newProjectOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||||
|
@keydown.escape.window="newProjectOpen = false">
|
||||||
|
<div class="glass rounded-2xl p-6 w-full max-w-md" @click.outside="newProjectOpen = false">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Nuevo proyecto</h2>
|
||||||
|
<form @submit.prevent="createProject()" class="space-y-3">
|
||||||
|
<input x-model="newProject.name" type="text" placeholder="Nombre" required class="input">
|
||||||
|
<input x-model="newProject.color" type="color" class="input h-12">
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear</button>
|
||||||
|
<button type="button" @click="newProjectOpen = false" class="btn btn-ghost">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="newTagOpen" x-cloak class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||||
|
@keydown.escape.window="newTagOpen = false">
|
||||||
|
<div class="glass rounded-2xl p-6 w-full max-w-md" @click.outside="newTagOpen = false">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Nuevo tag</h2>
|
||||||
|
<form @submit.prevent="createTag()" class="space-y-3">
|
||||||
|
<input x-model="newTag.name" type="text" placeholder="Nombre" required class="input">
|
||||||
|
<input x-model="newTag.color" type="color" class="input h-12">
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear</button>
|
||||||
|
<button type="button" @click="newTagOpen = false" class="btn btn-ghost">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="toast" x-cloak class="toast" x-text="toast"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>[x-cloak]{display:none!important}</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function settingsApp() {
|
||||||
|
return {
|
||||||
|
tab: 'devices',
|
||||||
|
devices: [], projects: [], tags: [],
|
||||||
|
newDeviceOpen: false, newProjectOpen: false, newTagOpen: false,
|
||||||
|
newDevice: {name: '', firmware_version: ''},
|
||||||
|
newProject: {name: '', color: '#6366f1'},
|
||||||
|
newTag: {name: '', color: '#a3a3a3'},
|
||||||
|
csrf: document.querySelector('meta[name=csrf-token]').content,
|
||||||
|
toast: '',
|
||||||
|
|
||||||
|
async init() { await this.loadAll(); },
|
||||||
|
|
||||||
|
async loadAll() {
|
||||||
|
this.devices = await this.api('/api/devices');
|
||||||
|
this.projects = await this.api('/api/projects');
|
||||||
|
this.tags = await this.api('/api/tags');
|
||||||
|
},
|
||||||
|
|
||||||
|
async createDevice() {
|
||||||
|
const d = await this.api('/api/devices', 'POST', this.newDevice);
|
||||||
|
d.tokenShown = d.token;
|
||||||
|
this.devices.push(d);
|
||||||
|
this.newDeviceOpen = false;
|
||||||
|
this.newDevice = {name: '', firmware_version: ''};
|
||||||
|
this.flash('✓ Dispositivo creado, guarda el token');
|
||||||
|
},
|
||||||
|
async rotate(d) {
|
||||||
|
if (!confirm('Rotar token invalidará el actual. ¿Continuar?')) return;
|
||||||
|
const upd = await this.api(`/api/devices/${d.id}/rotate`, 'POST');
|
||||||
|
upd.tokenShown = upd.token;
|
||||||
|
Object.assign(d, upd);
|
||||||
|
this.flash('🔄 Token rotado, guárdalo');
|
||||||
|
},
|
||||||
|
async delDevice(d) {
|
||||||
|
if (!confirm('¿Borrar dispositivo?')) return;
|
||||||
|
await this.api(`/api/devices/${d.id}`, 'DELETE');
|
||||||
|
this.devices = this.devices.filter(x => x.id !== d.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createProject() {
|
||||||
|
const p = await this.api('/api/projects', 'POST', this.newProject);
|
||||||
|
this.projects.push(p);
|
||||||
|
this.newProjectOpen = false;
|
||||||
|
this.newProject = {name: '', color: '#6366f1'};
|
||||||
|
},
|
||||||
|
async delProject(p) {
|
||||||
|
if (!confirm('¿Borrar proyecto?')) return;
|
||||||
|
await this.api(`/api/projects/${p.id}`, 'DELETE');
|
||||||
|
this.projects = this.projects.filter(x => x.id !== p.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTag() {
|
||||||
|
const t = await this.api('/api/tags', 'POST', this.newTag);
|
||||||
|
this.tags.push(t);
|
||||||
|
this.newTagOpen = false;
|
||||||
|
this.newTag = {name: '', color: '#a3a3a3'};
|
||||||
|
},
|
||||||
|
async delTag(t) {
|
||||||
|
await this.api(`/api/tags/${t.id}`, 'DELETE');
|
||||||
|
this.tags = this.tags.filter(x => x.id !== t.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
ago(s) {
|
||||||
|
if (!s) return 'nunca';
|
||||||
|
const d = (Date.now() - new Date(s).getTime()) / 1000;
|
||||||
|
if (d < 60) return 'hace un momento';
|
||||||
|
if (d < 3600) return `hace ${Math.floor(d/60)} min`;
|
||||||
|
if (d < 86400) return `hace ${Math.floor(d/3600)} h`;
|
||||||
|
return `hace ${Math.floor(d/86400)} d`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async api(url, method = 'GET', body = null) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': this.csrf,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
};
|
||||||
|
if (body) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const r = await fetch(url, opts);
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
return r.json();
|
||||||
|
},
|
||||||
|
flash(msg) { this.toast = msg; setTimeout(() => this.toast = '', 2500); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</x-layouts.base>
|
||||||
@ -1,145 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
||||||
<title>DummyBot – tareas</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #0f172a;
|
|
||||||
--card: #1e293b;
|
|
||||||
--text: #e2e8f0;
|
|
||||||
--muted: #94a3b8;
|
|
||||||
--accent: #38bdf8;
|
|
||||||
--high: #ef4444;
|
|
||||||
--med: #facc15;
|
|
||||||
--low: #22c55e;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
margin: 0;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
max-width: 720px;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
h1 { margin: 0 0 1.5rem; font-size: 1.8rem; }
|
|
||||||
h1 small { font-size: 0.9rem; color: var(--muted); font-weight: 400; }
|
|
||||||
|
|
||||||
form.add {
|
|
||||||
background: var(--card);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
form.add input[type="text"] {
|
|
||||||
background: #0b1224;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
form.add select, form.add button {
|
|
||||||
background: #0b1224;
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid #334155;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
form.add button { background: var(--accent); color: #002030; border-color: var(--accent); font-weight: 600; }
|
|
||||||
form.add button:hover { filter: brightness(1.1); }
|
|
||||||
|
|
||||||
ul.tasks { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
||||||
ul.tasks li {
|
|
||||||
background: var(--card);
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
ul.tasks li.done { opacity: 0.5; text-decoration: line-through; }
|
|
||||||
ul.tasks li.snoozed { border-left: 4px solid var(--med); }
|
|
||||||
.badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.badge.high { background: var(--high); color: #fff; }
|
|
||||||
.badge.med { background: var(--med); color: #000; }
|
|
||||||
.badge.low { background: var(--low); color: #000; }
|
|
||||||
.title { flex: 1; }
|
|
||||||
.meta { font-size: 0.8rem; color: var(--muted); }
|
|
||||||
form.inline { display: inline; }
|
|
||||||
form.inline button {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
form.inline button:hover { border-color: var(--accent); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>DummyBot <small>· gestor de tareas TDAH-friendly</small></h1>
|
|
||||||
|
|
||||||
<form class="add" method="POST" action="{{ route('tasks.store') }}">
|
|
||||||
@csrf
|
|
||||||
<input type="text" name="title" placeholder="¿Qué tienes que hacer?" required maxlength="120" autofocus>
|
|
||||||
<select name="priority">
|
|
||||||
<option value="med">Media</option>
|
|
||||||
<option value="high">Alta</option>
|
|
||||||
<option value="low">Baja</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit">Añadir</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<ul class="tasks">
|
|
||||||
@forelse($tasks as $task)
|
|
||||||
<li class="{{ $task->done ? 'done' : '' }} {{ $task->snooze_until && $task->snooze_until->isFuture() ? 'snoozed' : '' }}">
|
|
||||||
<span class="badge {{ $task->priority }}">{{ $task->priority }}</span>
|
|
||||||
<span class="title">{{ $task->title }}</span>
|
|
||||||
<span class="meta">
|
|
||||||
@if($task->done)
|
|
||||||
hecha {{ $task->done_at?->diffForHumans() }}
|
|
||||||
@elseif($task->snooze_until && $task->snooze_until->isFuture())
|
|
||||||
snooze hasta {{ $task->snooze_until->format('H:i') }}
|
|
||||||
@else
|
|
||||||
creada {{ $task->created_at->diffForHumans() }}
|
|
||||||
@endif
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if($task->done)
|
|
||||||
<form class="inline" method="POST" action="{{ route('tasks.restore', $task) }}">
|
|
||||||
@csrf
|
|
||||||
<button>Reabrir</button>
|
|
||||||
</form>
|
|
||||||
@else
|
|
||||||
<form class="inline" method="POST" action="{{ route('tasks.destroy', $task) }}">
|
|
||||||
@csrf @method('DELETE')
|
|
||||||
<button>Borrar</button>
|
|
||||||
</form>
|
|
||||||
@endif
|
|
||||||
</li>
|
|
||||||
@empty
|
|
||||||
<li style="background:transparent;color:var(--muted);text-align:center;padding:2rem;">
|
|
||||||
Aún no hay tareas. Añade la primera arriba ☝️
|
|
||||||
</li>
|
|
||||||
@endforelse
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because one or more lines are too long
@ -1,12 +1,58 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Auth\AuthController;
|
||||||
use App\Http\Controllers\TaskController;
|
use App\Http\Controllers\TaskController;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// Endpoints consumidos por el ESP32 (sin auth – la idea es que esté
|
// ─── Auth ───
|
||||||
// en LAN o detrás de un .htpasswd / VPN; añade middleware si lo expones a internet).
|
Route::post('auth/register', [AuthController::class, 'register']);
|
||||||
Route::get('tasks/pending', [TaskController::class, 'pending']);
|
Route::post('auth/login', [AuthController::class, 'login']);
|
||||||
Route::post('tasks', [TaskController::class, 'store']);
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
Route::post('tasks/{task}/done', [TaskController::class, 'done']);
|
Route::get ('auth/me', [AuthController::class, 'me']);
|
||||||
Route::post('tasks/{task}/snooze', [TaskController::class, 'snooze']);
|
Route::post('auth/logout', [AuthController::class, 'logout']);
|
||||||
Route::delete('tasks/{task}', [TaskController::class, 'destroy']);
|
});
|
||||||
|
|
||||||
|
// ─── 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'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Web UI (mismo backend; usa sesiones + Sanctum) ───
|
||||||
|
// Tareas
|
||||||
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
|
Route::get ('/tasks', [TaskController::class, 'index']);
|
||||||
|
Route::post ('/tasks', [TaskController::class, 'store']);
|
||||||
|
Route::put ('/tasks/{task}', [TaskController::class, 'update']);
|
||||||
|
Route::delete('/tasks/{task}', [TaskController::class, 'destroy']);
|
||||||
|
Route::post ('/tasks/{task}/done', [TaskController::class, 'done']);
|
||||||
|
Route::post ('/tasks/{task}/snooze', [TaskController::class, 'snooze']);
|
||||||
|
Route::post ('/tasks/{task}/restore',[TaskController::class, 'restore']);
|
||||||
|
Route::get ('/tasks/stats', [TaskController::class, 'stats']);
|
||||||
|
// Proyectos
|
||||||
|
Route::get ('/projects', [\App\Http\Controllers\Api\ProjectController::class, 'index']);
|
||||||
|
Route::post ('/projects', [\App\Http\Controllers\Api\ProjectController::class, 'store']);
|
||||||
|
Route::put ('/projects/{project}',[\App\Http\Controllers\Api\ProjectController::class, 'update']);
|
||||||
|
Route::delete('/projects/{project}',[\App\Http\Controllers\Api\ProjectController::class, 'destroy']);
|
||||||
|
// Tags
|
||||||
|
Route::get ('/tags', [\App\Http\Controllers\Api\TagController::class, 'index']);
|
||||||
|
Route::post ('/tags', [\App\Http\Controllers\Api\TagController::class, 'store']);
|
||||||
|
Route::delete('/tags/{tag}', [\App\Http\Controllers\Api\TagController::class, 'destroy']);
|
||||||
|
// Devices
|
||||||
|
Route::get ('/devices', [\App\Http\Controllers\Api\DeviceController::class, 'index']);
|
||||||
|
Route::post ('/devices', [\App\Http\Controllers\Api\DeviceController::class, 'store']);
|
||||||
|
Route::post ('/devices/{device}/rotate', [\App\Http\Controllers\Api\DeviceController::class, 'rotate']);
|
||||||
|
Route::delete('/devices/{device}',[\App\Http\Controllers\Api\DeviceController::class, 'destroy']);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,9 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\PageController;
|
||||||
use App\Http\Controllers\TaskController;
|
use App\Http\Controllers\TaskController;
|
||||||
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', [TaskController::class, 'index'])->name('tasks.index');
|
Route::get ('/', [PageController::class, 'home'])->name('home');
|
||||||
Route::post('/tasks', [TaskController::class, 'store'])->name('tasks.store');
|
Route::get ('/login', [LoginController::class, 'showLogin'])->name('login');
|
||||||
Route::delete('/tasks/{task}', [TaskController::class, 'destroy'])->name('tasks.destroy');
|
Route::get ('/register',[LoginController::class, 'showRegister'])->name('register');
|
||||||
Route::post('/tasks/{task}/restore', [TaskController::class, 'restore'])->name('tasks.restore');
|
Route::post('/login', [LoginController::class, 'login']);
|
||||||
|
Route::post('/register',[LoginController::class, 'register']);
|
||||||
|
Route::post('/logout', [LoginController::class, 'logout'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
|
Route::get('/app', [PageController::class, 'app'])->name('app');
|
||||||
|
Route::get('/app/settings', [PageController::class, 'settings'])->name('settings');
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user