Some checks are pending
Deploy DummyBot / Build & Deploy to ${{ env.DEPLOY_HOST }} (push) Waiting to run
Frontend: - Layout base con design system (Inter+JetBrains Mono, glass, mesh, animaciones) - Landing con hero, mockup OLED, 6 features, 3 pasos - Auth unificado (login+register) con split-screen - App con sidebar fijo, filtros con contadores, stats, modal rediseñado - Settings con 4 tabs (devices/projects/tags/API) - Iconos SVG inline (sin emojis excepto el logo) - Skeletons, pulse-dot, toggle switch, toasts - Unificada vista auth.blade.php (elimina login/register separados) Deploy: - Workflow de Gitea Actions: build + push a /root/dummybot en 157.180.77.232 - Trigger: push a main (también workflow_dispatch) - SSH key dedicada ed25519 dummybot_deploy - /root/dummybot.env preserva .env real (no se commitea) - Healthcheck en /up tras migrar - Rollback state en .last_deploy Backend: - LoginController usa nueva vista auth con mode=login|register
456 lines
27 KiB
PHP
456 lines
27 KiB
PHP
@extends('layouts.base')
|
||
@section('title', 'Ajustes · DummyBot')
|
||
@section('content')
|
||
<div x-data="settingsApp()" x-init="init()" class="min-h-screen flex">
|
||
|
||
{{-- SIDEBAR --}}
|
||
<aside class="hidden lg:flex w-60 border-r border-white/5 flex-col p-3 sticky top-0 h-screen">
|
||
<a href="{{ route('app') }}" class="flex items-center gap-2.5 px-3 py-2.5 mb-3">
|
||
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center shadow-glow">
|
||
<svg class="w-3.5 h-3.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||
<rect x="4" y="8" width="16" height="12" rx="2"></rect>
|
||
<circle cx="9" cy="14" r="1.5" fill="currentColor"></circle>
|
||
<circle cx="15" cy="14" r="1.5" fill="currentColor"></circle>
|
||
<path d="M12 4v4"></path>
|
||
</svg>
|
||
</div>
|
||
<span class="font-bold text-sm">DummyBot</span>
|
||
</a>
|
||
|
||
<div class="px-3 pt-3 pb-2 text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">Ajustes</div>
|
||
|
||
<nav class="space-y-0.5 flex-1">
|
||
@php
|
||
$tabs = [
|
||
['key' => 'devices', 'label' => 'Dispositivos', 'icon' => '<rect x=\"2\" y=\"6\" width=\"20\" height=\"12\" rx=\"2\"/><path d=\"M6 10h.01M6 14h.01M10 10h4M10 14h4\"/>'],
|
||
['key' => 'projects', 'label' => 'Proyectos', 'icon' => '<path d=\"M3 7h6l2 2h10v11H3z\"/>'],
|
||
['key' => 'tags', 'label' => 'Tags', 'icon' => '<path d=\"M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z\"/><line x1=\"7\" y1=\"7\" x2=\"7.01\" y2=\"7\"/>'],
|
||
['key' => 'api', 'label' => 'API', 'icon' => '<polyline points=\"16 18 22 12 16 6\"/><polyline points=\"8 6 2 12 8 18\"/>'],
|
||
];
|
||
@endphp
|
||
@foreach($tabs as $t)
|
||
<button @click="tab = '{{ $t['key'] }}'"
|
||
:class="tab === '{{ $t['key'] }}' ? 'active' : ''"
|
||
class="sidebar-item w-full">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{!! $t['icon'] !!}</svg>
|
||
<span class="flex-1 text-left">{{ $t['label'] }}</span>
|
||
</button>
|
||
@endforeach
|
||
</nav>
|
||
|
||
<div class="border-t border-white/5 pt-2 mt-2 space-y-0.5">
|
||
<a href="{{ route('app') }}" class="sidebar-item">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||
<span class="flex-1">Volver a tareas</span>
|
||
</a>
|
||
</div>
|
||
</aside>
|
||
|
||
{{-- MAIN --}}
|
||
<main class="flex-1 min-w-0">
|
||
|
||
{{-- TOPBAR --}}
|
||
<header class="border-b border-white/5 sticky top-0 z-30 glass-strong">
|
||
<div class="px-4 sm:px-6 h-16 flex items-center gap-3">
|
||
<a href="{{ route('app') }}" class="lg:hidden btn btn-icon btn-ghost">
|
||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||
</a>
|
||
<h1 class="text-lg font-semibold">Ajustes</h1>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-8">
|
||
|
||
{{-- DEVICES --}}
|
||
<div x-show="tab === 'devices'" x-cloak class="space-y-6 anim-fade">
|
||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||
<div>
|
||
<h2 class="text-2xl font-bold mb-1">Dispositivos</h2>
|
||
<p class="text-zinc-400 text-sm">Tus DummyBots físicos. Cada uno tiene su propio token.</p>
|
||
</div>
|
||
<button @click="newDeviceOpen = true" class="btn btn-primary">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
||
Nuevo dispositivo
|
||
</button>
|
||
</div>
|
||
|
||
<div x-show="devices.length === 0" class="task-card text-center py-12">
|
||
<div class="inline-flex w-12 h-12 rounded-xl bg-white/5 items-center justify-center mb-3">
|
||
<svg class="w-6 h-6 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M6 14h.01M10 10h4M10 14h4"/></svg>
|
||
</div>
|
||
<p class="text-zinc-400 text-sm mb-4">Aún no tienes DummyBots</p>
|
||
<button @click="newDeviceOpen = true" class="btn btn-primary">Crear el primero</button>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<template x-for="d in devices" :key="d.id">
|
||
<div class="task-card anim-up">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/20 flex items-center justify-center">
|
||
<svg class="w-5 h-5 text-indigo-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="8" width="16" height="12" rx="2"/><circle cx="9" cy="14" r="1.5" fill="currentColor"/><circle cx="15" cy="14" r="1.5" fill="currentColor"/><path d="M12 4v4"/></svg>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="font-semibold flex items-center gap-2 flex-wrap">
|
||
<span x-text="d.name"></span>
|
||
<span class="chip text-[10px]" :class="d.last_seen_at ? 'prio-low' : 'prio-high'">
|
||
<span class="w-1.5 h-1.5 rounded-full" :class="d.last_seen_at ? 'bg-green-400 pulse-dot' : 'bg-zinc-500'"></span>
|
||
<span x-text="ago(d.last_seen_at)"></span>
|
||
</span>
|
||
</div>
|
||
<div class="text-xs text-zinc-500 font-mono mt-1" x-text="d.token.substring(0, 12) + '…' + d.token.substring(d.token.length-4)"></div>
|
||
</div>
|
||
<button @click="rotate(d)" class="btn btn-ghost btn-sm" title="Rotar token">
|
||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8M21 3v5h-5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16M3 21v-5h5"/></svg>
|
||
<span class="hidden sm:inline">Rotar</span>
|
||
</button>
|
||
<button @click="delDevice(d)" class="btn btn-ghost btn-icon hover:text-red-400" title="Borrar">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||
</button>
|
||
</div>
|
||
<template x-if="d.tokenShown">
|
||
<div class="mt-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||
<div class="flex items-start gap-2">
|
||
<svg class="w-4 h-4 text-amber-400 mt-0.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/></svg>
|
||
<div class="flex-1 min-w-0">
|
||
<strong class="text-amber-200 text-sm">Guarda este token, no se mostrará otra vez.</strong>
|
||
<div class="font-mono text-xs mt-1.5 break-all text-amber-100" x-text="d.tokenShown"></div>
|
||
<div class="mt-2 text-xs text-zinc-400">Pégalo en el firmware del ESP32.</div>
|
||
</div>
|
||
<button @click="copy(d.tokenShown)" class="btn btn-ghost btn-sm shrink-0">
|
||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- PROJECTS --}}
|
||
<div x-show="tab === 'projects'" x-cloak class="space-y-6 anim-fade">
|
||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||
<div>
|
||
<h2 class="text-2xl font-bold mb-1">Proyectos</h2>
|
||
<p class="text-zinc-400 text-sm">Agrupa tareas por contexto, cliente o área.</p>
|
||
</div>
|
||
<button @click="newProjectOpen = true" class="btn btn-primary">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
||
Nuevo proyecto
|
||
</button>
|
||
</div>
|
||
|
||
<div x-show="projects.length === 0" class="task-card text-center py-12">
|
||
<div class="inline-flex w-12 h-12 rounded-xl bg-white/5 items-center justify-center mb-3">
|
||
<svg class="w-6 h-6 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 7h6l2 2h10v11H3z"/></svg>
|
||
</div>
|
||
<p class="text-zinc-400 text-sm mb-4">Sin proyectos todavía</p>
|
||
<button @click="newProjectOpen = true" class="btn btn-primary">Crear el primero</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-10 h-10 rounded-lg shrink-0" :style="'background:' + p.color + '20; border: 1px solid ' + p.color + '40'">
|
||
<div class="w-full h-full rounded-lg flex items-center justify-center">
|
||
<span class="text-lg font-bold" :style="'color:' + p.color" x-text="p.name[0].toUpperCase()"></span>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1 font-semibold truncate" x-text="p.name"></div>
|
||
<button @click="delProject(p)" class="btn btn-ghost btn-icon hover:text-red-400" title="Borrar">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- TAGS --}}
|
||
<div x-show="tab === 'tags'" x-cloak class="space-y-6 anim-fade">
|
||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||
<div>
|
||
<h2 class="text-2xl font-bold mb-1">Tags</h2>
|
||
<p class="text-zinc-400 text-sm">Etiquetas transversales para filtrar tareas.</p>
|
||
</div>
|
||
<button @click="newTagOpen = true" class="btn btn-primary">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
||
Nuevo tag
|
||
</button>
|
||
</div>
|
||
|
||
<div x-show="tags.length === 0" class="task-card text-center py-12">
|
||
<div class="inline-flex w-12 h-12 rounded-xl bg-white/5 items-center justify-center mb-3">
|
||
<svg class="w-6 h-6 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/></svg>
|
||
</div>
|
||
<p class="text-zinc-400 text-sm">Sin tags aún</p>
|
||
</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 px-3 py-1.5 text-sm" :style="'background:' + t.color + '20; color:' + t.color + '; border: 1px solid ' + t.color + '40'">
|
||
<span x-text="'#' + t.name"></span>
|
||
<button @click="delTag(t)" class="hover:opacity-70 ml-1">×</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- API --}}
|
||
<div x-show="tab === 'api'" x-cloak class="space-y-6 anim-fade">
|
||
<div>
|
||
<h2 class="text-2xl font-bold mb-1">API REST</h2>
|
||
<p class="text-zinc-400 text-sm">Integra DummyBot con Notion, Zapier, n8n, o lo que quieras.</p>
|
||
</div>
|
||
|
||
<div class="task-card">
|
||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||
<svg class="w-4 h-4 text-indigo-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||
Endpoints
|
||
</h3>
|
||
<pre class="bg-bg-0 border border-white/5 rounded-lg p-4 overflow-x-auto text-xs leading-relaxed text-zinc-300 font-mono"><span class="text-zinc-500"># Auth (web + Bearer)</span>
|
||
<span class="text-blue-400">POST</span> /api/auth/register {name, email, password, password_confirmation}
|
||
<span class="text-blue-400">POST</span> /api/auth/login {email, password}
|
||
<span class="text-blue-400">POST</span> /api/auth/logout (auth)
|
||
<span class="text-blue-400">GET</span> /api/auth/me (auth)
|
||
|
||
<span class="text-zinc-500"># Tareas</span>
|
||
<span class="text-blue-400">GET</span> /api/tasks Listar (con project + tags)
|
||
<span class="text-blue-400">POST</span> /api/tasks Crear
|
||
<span class="text-blue-400">PUT</span> /api/tasks/{id} Editar
|
||
<span class="text-blue-400">DELETE</span> /api/tasks/{id} Borrar
|
||
<span class="text-blue-400">POST</span> /api/tasks/{id}/done Marcar como hecha
|
||
<span class="text-blue-400">POST</span> /api/tasks/{id}/snooze Posponer {minutes: N}
|
||
<span class="text-blue-400">POST</span> /api/tasks/{id}/restore Reabrir
|
||
<span class="text-blue-400">GET</span> /api/tasks/stats {pending, today, done_today, overdue, total_done}
|
||
|
||
<span class="text-zinc-500"># Proyectos</span>
|
||
<span class="text-blue-400">GET</span> /api/projects
|
||
<span class="text-blue-400">POST</span> /api/projects {name, color, icon}
|
||
<span class="text-blue-400">PUT</span> /api/projects/{id}
|
||
<span class="text-blue-400">DELETE</span> /api/projects/{id}
|
||
|
||
<span class="text-zinc-500"># Tags</span>
|
||
<span class="text-blue-400">GET</span> /api/tags
|
||
<span class="text-blue-400">POST</span> /api/tags {name, color}
|
||
<span class="text-blue-400">DELETE</span> /api/tags/{id}
|
||
|
||
<span class="text-zinc-500"># Dispositivos (DummyBots)</span>
|
||
<span class="text-blue-400">GET</span> /api/devices Tus DummyBots
|
||
<span class="text-blue-400">POST</span> /api/devices {name, hardware?, firmware_version?}
|
||
<span class="text-blue-400">POST</span> /api/devices/{id}/rotate Invalida y crea nuevo token
|
||
<span class="text-blue-400">DELETE</span> /api/devices/{id}
|
||
|
||
<span class="text-zinc-500"># Para el ESP32 (auth.device, con token del dispositivo)</span>
|
||
<span class="text-blue-400">GET</span> /api/device/tasks/pending Polling cada 30s
|
||
<span class="text-blue-400">POST</span> /api/device/tasks Crear tarea
|
||
<span class="text-blue-400">POST</span> /api/device/tasks/{id}/done
|
||
<span class="text-blue-400">POST</span> /api/device/tasks/{id}/snooze
|
||
<span class="text-blue-400">DELETE</span> /api/device/tasks/{id}
|
||
<span class="text-blue-400">POST</span> /api/device/heartbeat Ping
|
||
|
||
<span class="text-zinc-500"># Auth header</span>
|
||
Authorization: Bearer <token></pre>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</main>
|
||
|
||
{{-- MODAL: nuevo device --}}
|
||
<div x-show="newDeviceOpen" x-cloak class="modal-backdrop" @keydown.escape.window="newDeviceOpen = false">
|
||
<div class="modal-card" @click.outside="newDeviceOpen = false">
|
||
<div class="flex items-center justify-between mb-5">
|
||
<h2 class="text-lg font-bold">Nuevo DummyBot</h2>
|
||
<button @click="newDeviceOpen = false" class="btn btn-icon btn-ghost -mr-2">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<form @submit.prevent="createDevice()" class="space-y-3">
|
||
<div>
|
||
<label class="label">Nombre</label>
|
||
<input x-model="newDevice.name" type="text" placeholder="ej: ESP32 del escritorio" required class="input" autofocus>
|
||
</div>
|
||
<div>
|
||
<label class="label">Versión firmware <span class="text-zinc-600 normal-case font-normal">(opcional)</span></label>
|
||
<input x-model="newDevice.firmware_version" type="text" placeholder="v6.1" class="input">
|
||
</div>
|
||
<div class="flex gap-2 pt-3 border-t border-white/5">
|
||
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear dispositivo</button>
|
||
<button type="button" @click="newDeviceOpen = false" class="btn btn-ghost">Cancelar</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- MODAL: nuevo proyecto --}}
|
||
<div x-show="newProjectOpen" x-cloak class="modal-backdrop" @keydown.escape.window="newProjectOpen = false">
|
||
<div class="modal-card" @click.outside="newProjectOpen = false">
|
||
<div class="flex items-center justify-between mb-5">
|
||
<h2 class="text-lg font-bold">Nuevo proyecto</h2>
|
||
<button @click="newProjectOpen = false" class="btn btn-icon btn-ghost -mr-2">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<form @submit.prevent="createProject()" class="space-y-3">
|
||
<div>
|
||
<label class="label">Nombre</label>
|
||
<input x-model="newProject.name" type="text" placeholder="ej: Cliente Acme" required class="input" autofocus>
|
||
</div>
|
||
<div>
|
||
<label class="label">Color</label>
|
||
<div class="flex items-center gap-2">
|
||
<input x-model="newProject.color" type="color" class="input h-10 w-20 cursor-pointer p-1">
|
||
<input x-model="newProject.color" type="text" class="input flex-1 font-mono">
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 pt-3 border-t border-white/5">
|
||
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear proyecto</button>
|
||
<button type="button" @click="newProjectOpen = false" class="btn btn-ghost">Cancelar</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- MODAL: nuevo tag --}}
|
||
<div x-show="newTagOpen" x-cloak class="modal-backdrop" @keydown.escape.window="newTagOpen = false">
|
||
<div class="modal-card" @click.outside="newTagOpen = false">
|
||
<div class="flex items-center justify-between mb-5">
|
||
<h2 class="text-lg font-bold">Nuevo tag</h2>
|
||
<button @click="newTagOpen = false" class="btn btn-icon btn-ghost -mr-2">
|
||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<form @submit.prevent="createTag()" class="space-y-3">
|
||
<div>
|
||
<label class="label">Nombre</label>
|
||
<input x-model="newTag.name" type="text" placeholder="ej: urgente" required class="input" autofocus>
|
||
</div>
|
||
<div>
|
||
<label class="label">Color</label>
|
||
<div class="flex items-center gap-2">
|
||
<input x-model="newTag.color" type="color" class="input h-10 w-20 cursor-pointer p-1">
|
||
<input x-model="newTag.color" type="text" class="input flex-1 font-mono">
|
||
</div>
|
||
</div>
|
||
<div class="flex gap-2 pt-3 border-t border-white/5">
|
||
<button type="submit" class="btn btn-primary flex-1 justify-center">Crear tag</button>
|
||
<button type="button" @click="newTagOpen = false" class="btn btn-ghost">Cancelar</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-show="toast" x-cloak x-transition.opacity class="toast" x-text="toast"></div>
|
||
</div>
|
||
|
||
<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() {
|
||
try {
|
||
const [d, p, t] = await Promise.all([
|
||
this.api('/api/devices'),
|
||
this.api('/api/projects'),
|
||
this.api('/api/tags'),
|
||
]);
|
||
this.devices = d;
|
||
this.projects = p;
|
||
this.tags = t;
|
||
} catch (err) { this.flash('Error cargando'); }
|
||
},
|
||
|
||
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 "${d.name}"?`)) return;
|
||
await this.api(`/api/devices/${d.id}`, 'DELETE');
|
||
this.devices = this.devices.filter(x => x.id !== d.id);
|
||
this.flash('🗑 Borrado');
|
||
},
|
||
|
||
async createProject() {
|
||
const p = await this.api('/api/projects', 'POST', this.newProject);
|
||
this.projects.push(p);
|
||
this.newProjectOpen = false;
|
||
this.newProject = {name: '', color: '#6366f1'};
|
||
this.flash('✓ Proyecto creado');
|
||
},
|
||
async delProject(p) {
|
||
if (!confirm(`¿Borrar proyecto "${p.name}"?`)) 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) {
|
||
if (!confirm(`¿Borrar tag "${t.name}"?`)) return;
|
||
await this.api(`/api/tags/${t.id}`, 'DELETE');
|
||
this.tags = this.tags.filter(x => x.id !== t.id);
|
||
},
|
||
|
||
copy(text) {
|
||
navigator.clipboard.writeText(text).then(() => this.flash('✓ Token copiado'));
|
||
},
|
||
|
||
ago(s) {
|
||
if (!s) return 'nunca visto';
|
||
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
|