dummybot/resources/views/pages/settings.blade.php
javiservices 43534c74e3
Some checks are pending
Deploy DummyBot / Build & Deploy to ${{ env.DEPLOY_HOST }} (push) Waiting to run
feat(frontend): rediseño completo + deploy automático
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
2026-06-18 12:00:39 +02:00

456 lines
27 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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