258 lines
14 KiB
PHP
258 lines
14 KiB
PHP
<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>
|