feat(frontend): rediseño completo + deploy automático
Some checks are pending
Deploy DummyBot / Build & Deploy to ${{ env.DEPLOY_HOST }} (push) Waiting to run
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
This commit is contained in:
parent
0d4b126945
commit
43534c74e3
130
.gitea/workflows/deploy.yml
Normal file
130
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,130 @@
|
||||
name: Deploy DummyBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DEPLOY_HOST: 157.180.77.232
|
||||
DEPLOY_USER: root
|
||||
DEPLOY_PATH: /root/dummybot
|
||||
APP_PORT: 18080
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build & Deploy to ${{ env.DEPLOY_HOST }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: mbstring, pdo, pdo_mysql, bcmath, curl, zip, intl
|
||||
coverage: none
|
||||
|
||||
- name: Validate composer.json
|
||||
working-directory: .
|
||||
run: composer validate --strict --no-check-publish
|
||||
|
||||
- name: Cache Composer
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json') }}
|
||||
restore-keys: ${{ runner.os }}-php-
|
||||
|
||||
- name: Install Composer dependencies (no-dev)
|
||||
run: composer install --no-dev --no-interaction --no-progress --optimize-autoloader
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci --no-audit --no-fund || npm install --no-audit --no-fund
|
||||
|
||||
- name: Build frontend assets
|
||||
run: npm run build
|
||||
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
|
||||
- name: Setup known_hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -p 22 -H ${{ env.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
ssh -A ${{ env.DEPLOY_USER }}@${{ env.DEPLOY_HOST }} << 'ENDSSH'
|
||||
set -e
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
|
||||
echo "→ Fetching latest code"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
git clean -fd
|
||||
|
||||
echo "→ Pulling env file (preserves local config)"
|
||||
cp -n .env.example .env 2>/dev/null || true
|
||||
if [ -f /root/dummybot.env ]; then
|
||||
cp /root/dummybot.env .env
|
||||
fi
|
||||
|
||||
echo "→ Installing composer dependencies"
|
||||
composer install --no-dev --no-interaction --no-progress --optimize-autoloader
|
||||
|
||||
echo "→ Installing npm dependencies and building assets"
|
||||
npm ci --no-audit --no-fund || npm install --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
echo "→ Caching build state for rollback"
|
||||
DEPLOY_TS=$(date -u +%Y%m%d%H%M%S)
|
||||
echo "$DEPLOY_TS" > .last_deploy
|
||||
|
||||
echo "→ Running migrations"
|
||||
docker compose exec -T app php artisan migrate --force
|
||||
|
||||
echo "→ Clearing caches"
|
||||
docker compose exec -T app php artisan config:clear
|
||||
docker compose exec -T app php artisan route:clear
|
||||
docker compose exec -T app php artisan view:clear
|
||||
docker compose exec -T app php artisan event:clear
|
||||
docker compose exec -T app php artisan optimize
|
||||
|
||||
echo "→ Restarting services"
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
echo "→ Healthcheck"
|
||||
sleep 5
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${{ env.APP_PORT }}/up --max-time 10 || echo "fail")
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "HEALTHCHECK FAILED: $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Healthcheck OK ($HTTP_CODE)"
|
||||
|
||||
echo "→ Cleaning up old images"
|
||||
docker image prune -f >/dev/null 2>&1 || true
|
||||
ENDSSH
|
||||
|
||||
- name: Notify success
|
||||
if: success()
|
||||
run: |
|
||||
echo "✅ Deploy successful"
|
||||
echo "🌐 https://${{ env.DEPLOY_HOST }}:${{ env.APP_PORT }}"
|
||||
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "❌ Deploy failed"
|
||||
echo "Check the logs above for details"
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -28,3 +28,13 @@ Thumbs.db
|
||||
|
||||
# Docker
|
||||
/docker-data/
|
||||
|
||||
# Deploy
|
||||
/.deploy-key*
|
||||
/.deploy.env
|
||||
|
||||
# CI
|
||||
/.gitea/secrets/
|
||||
*.pem
|
||||
*.key
|
||||
!storage/oauth-*.key
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
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 showLogin() { return view('pages.auth', ['mode' => 'login']); }
|
||||
public function showRegister() { return view('pages.auth', ['mode' => 'register']); }
|
||||
|
||||
public function login(Request $r) {
|
||||
$cred = $r->validate([
|
||||
|
||||
2
firmware
2
firmware
@ -1 +1 @@
|
||||
Subproject commit 9fb87a4d623f61748d734e0b10940e7ae0c45e6b
|
||||
Subproject commit 478bb65d32e601ba7bdc0908986b620a8256645b
|
||||
@ -4,55 +4,199 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<meta name="theme-color" content="#09090b">
|
||||
<title>@yield('title', 'DummyBot')</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>🤖</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%236366f1'/><circle cx='16' cy='16' r='6' fill='white'/><circle cx='13' cy='14' r='1.5' fill='%236366f1'/><circle cx='19' cy='14' r='1.5' fill='%236366f1'/></svg>">
|
||||
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
|
||||
body { background: #0a0a0f; color: #f4f4f5; }
|
||||
.bg-mesh {
|
||||
html { font-family: 'Inter', system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
body { background: #09090b; color: #fafafa; }
|
||||
|
||||
.bg-app {
|
||||
background:
|
||||
radial-gradient(at 12% 20%, rgba(99, 102, 241, 0.18) 0px, transparent 50%),
|
||||
radial-gradient(at 90% 10%, rgba(168, 85, 247, 0.15) 0px, transparent 50%),
|
||||
radial-gradient(at 80% 90%, rgba(56, 189, 248, 0.12) 0px, transparent 50%),
|
||||
#0a0a0f;
|
||||
radial-gradient(at 8% 0%, rgba(99, 102, 241, 0.10) 0px, transparent 50%),
|
||||
radial-gradient(at 92% 10%, rgba(168, 85, 247, 0.08) 0px, transparent 50%),
|
||||
radial-gradient(at 50% 100%, rgba(56, 189, 248, 0.05) 0px, transparent 50%),
|
||||
#09090b;
|
||||
}
|
||||
.glass { background: rgba(24, 24, 27, 0.6); backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.06); }
|
||||
.btn { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; border-radius: 0.5rem; font-weight: 500; transition: all .15s; cursor: pointer; }
|
||||
.btn-primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; border: none; box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35); }
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5); }
|
||||
.btn-ghost { background: rgba(255,255,255,0.04); color: #d4d4d8; border: 1px solid rgba(255,255,255,0.06); }
|
||||
.btn-ghost:hover { background: rgba(255,255,255,0.08); }
|
||||
.btn-danger { background: rgba(239, 68, 68, 0.15); color: #fca5a5; border: 1px solid rgba(239, 68, 68, 0.3); }
|
||||
.btn-danger:hover { background: rgba(239, 68, 68, 0.25); }
|
||||
.input { background: rgba(9, 9, 11, 0.6); border: 1px solid rgba(255,255,255,0.08); color: #f4f4f5; border-radius: 0.5rem; padding: 0.55rem 0.75rem; width: 100%; transition: all .15s; }
|
||||
.input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); }
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.6rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
|
||||
.prio-high { background: rgba(239, 68, 68, 0.15); color: #fca5a5; }
|
||||
.prio-med { background: rgba(250, 204, 21, 0.12); color: #fde68a; }
|
||||
.prio-low { background: rgba(34, 197, 94, 0.12); color: #86efac; }
|
||||
.task-card { background: rgba(24, 24, 27, 0.4); border: 1px solid rgba(255,255,255,0.05); border-radius: 0.75rem; padding: 1rem; transition: all .2s; }
|
||||
.task-card:hover { background: rgba(24, 24, 27, 0.7); border-color: rgba(99, 102, 241, 0.3); }
|
||||
.task-done { opacity: 0.4; text-decoration: line-through; }
|
||||
.kbd { display: inline-block; padding: 0.1rem 0.4rem; border: 1px solid rgba(255,255,255,0.15); border-radius: 0.3rem; font-size: 0.7rem; font-family: monospace; background: rgba(255,255,255,0.04); }
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.anim-in { animation: slideIn 0.25s ease-out; }
|
||||
.toast { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 50; padding: 0.75rem 1rem; border-radius: 0.75rem; background: rgba(24, 24, 27, 0.95); border: 1px solid rgba(99, 102, 241, 0.4); color: #f4f4f5; box-shadow: 0 10px 40px rgba(0,0,0,0.4); animation: slideIn 0.25s ease-out; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
|
||||
.glass { background: rgba(24, 24, 27, 0.5); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid rgba(255, 255, 255, 0.06); }
|
||||
.glass-strong { background: rgba(24, 24, 27, 0.8); backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.875rem; border-radius: 0.5rem; font-weight: 500; font-size: 0.875rem; transition: all 0.15s; user-select: none; cursor: pointer; line-height: 1.25rem; }
|
||||
.btn-primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; box-shadow: 0 1px 0 rgba(255,255,255,0.1) inset, 0 1px 3px rgba(0,0,0,0.3); border: none; }
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 1px 0 rgba(255,255,255,0.15) inset, 0 8px 24px -4px rgba(99, 102, 241, 0.5); }
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
.btn-ghost { background: transparent; color: #d4d4d8; border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.btn-ghost:hover { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.12); color: #fafafa; }
|
||||
.btn-danger { background: rgba(239, 68, 68, 0.1); color: #fca5a5; border: 1px solid rgba(239, 68, 68, 0.2); }
|
||||
.btn-danger:hover { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.3); }
|
||||
.btn-icon { width: 2.25rem; height: 2.25rem; border-radius: 0.5rem; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
|
||||
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
|
||||
|
||||
.input, .select, .textarea {
|
||||
width: 100%; padding: 0.5rem 0.75rem; border-radius: 0.5rem; font-size: 0.875rem;
|
||||
background: rgba(9, 9, 11, 0.6); border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #fafafa; transition: all 0.15s; font-family: inherit;
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
outline: none; border-color: #6366f1;
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||
background: rgba(9, 9, 11, 0.8);
|
||||
}
|
||||
.input::placeholder, .textarea::placeholder { color: #52525b; }
|
||||
.label { display: block; font-size: 0.7rem; font-weight: 600; color: #a1a1aa; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.5rem; border-radius: 0.375rem; font-size: 0.7rem; font-weight: 600; line-height: 1rem; }
|
||||
.prio-high { background: rgba(239, 68, 68, 0.12); color: #fca5a5; border: 1px solid rgba(239, 68, 68, 0.2); }
|
||||
.prio-med { background: rgba(250, 204, 21, 0.10); color: #fde68a; border: 1px solid rgba(250, 204, 21, 0.2); }
|
||||
.prio-low { background: rgba(34, 197, 94, 0.10); color: #86efac; border: 1px solid rgba(34, 197, 94, 0.2); }
|
||||
|
||||
.task-card {
|
||||
border-radius: 0.75rem; padding: 1rem;
|
||||
background: rgba(24, 24, 27, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.task-card:hover {
|
||||
background: rgba(24, 24, 27, 0.6);
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.task-done { opacity: 0.5; }
|
||||
.task-done .task-title { text-decoration: line-through; text-decoration-color: rgba(255,255,255,0.3); }
|
||||
|
||||
.sidebar-item {
|
||||
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem; font-size: 0.875rem; font-weight: 500;
|
||||
color: #a1a1aa; transition: all 0.15s; cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.sidebar-item:hover { color: #fafafa; background: rgba(255, 255, 255, 0.04); }
|
||||
.sidebar-item.active {
|
||||
color: #fafafa;
|
||||
background: linear-gradient(135deg, rgba(99,102,241,0.15) 0%, rgba(139,92,246,0.08) 100%);
|
||||
border-color: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
.sidebar-item svg { width: 1rem; height: 1rem; flex-shrink: 0; }
|
||||
|
||||
.check {
|
||||
width: 1.25rem; height: 1.25rem; border-radius: 0.375rem; border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
transition: all 0.15s; cursor: pointer; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.check:hover { border-color: #6366f1; }
|
||||
.check.checked {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.kbd { display: inline-block; padding: 0.1rem 0.4rem; border: 1px solid rgba(255,255,255,0.1); border-radius: 0.25rem; font-size: 0.7rem; font-family: 'JetBrains Mono', monospace; background: rgba(255,255,255,0.04); color: #a1a1aa; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes slideDown { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
|
||||
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.anim-fade { animation: fadeIn 0.2s ease-out; }
|
||||
.anim-up { animation: slideUp 0.25s ease-out; }
|
||||
.anim-down { animation: slideDown 0.2s ease-out; }
|
||||
.anim-scale { animation: scaleIn 0.2s ease-out; }
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.pulse-dot { animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 50;
|
||||
padding: 0.65rem 1rem; border-radius: 0.75rem; font-size: 0.875rem; font-weight: 500;
|
||||
background: rgba(24, 24, 27, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
color: #fafafa;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05);
|
||||
animation: slideUp 0.25s ease-out;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
display: flex; align-items: center; justify-content: center; padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
.modal-card {
|
||||
width: 100%; max-width: 32rem; border-radius: 1rem; padding: 1.5rem;
|
||||
background: #18181b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 30px 90px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 5px; border: 2px solid transparent; background-clip: padding-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); border: 2px solid transparent; background-clip: padding-box; }
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
::selection { background: rgba(99, 102, 241, 0.3); color: #fafafa; }
|
||||
*:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; border-radius: 4px; }
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle { position: relative; display: inline-block; width: 36px; height: 20px; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle .slider {
|
||||
position: absolute; cursor: pointer; inset: 0;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px; transition: 0.2s;
|
||||
}
|
||||
.toggle .slider::before {
|
||||
content: ""; position: absolute; height: 14px; width: 14px;
|
||||
left: 2px; bottom: 2px; background-color: #fafafa;
|
||||
border-radius: 50%; transition: 0.2s;
|
||||
}
|
||||
.toggle input:checked + .slider {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border-color: transparent;
|
||||
}
|
||||
.toggle input:checked + .slider::before { transform: translateX(16px); }
|
||||
</style>
|
||||
|
||||
@stack('head')
|
||||
</head>
|
||||
<body class="bg-mesh min-h-screen antialiased">
|
||||
<body class="bg-app min-h-screen antialiased">
|
||||
@yield('content')
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,398 +1,566 @@
|
||||
@extends('layouts.base')
|
||||
@section('title', 'Tareas · DummyBot')
|
||||
@section('content')
|
||||
<div x-data="dummybotApp()" x-init="init()" class="min-h-screen flex flex-col">
|
||||
{{-- NAV --}}
|
||||
<nav class="border-b border-white/5 backdrop-blur-md sticky top-0 z-40">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between gap-3">
|
||||
<a href="{{ route('app') }}" class="flex items-center gap-2 text-lg font-bold shrink-0">
|
||||
<span class="text-2xl">🤖</span>
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
|
||||
</a>
|
||||
<div x-data="dummybotApp()" x-init="init()" class="min-h-screen flex" :class="{ 'overflow-hidden': modalOpen }">
|
||||
|
||||
{{-- 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>
|
||||
{{-- SIDEBAR --}}
|
||||
<aside class="hidden lg:flex w-60 border-r border-white/5 flex-col p-3 sticky top-0 h-screen">
|
||||
{{-- Logo --}}
|
||||
<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>
|
||||
|
||||
{{-- New task button --}}
|
||||
<button @click="openNew()" class="btn btn-primary w-full justify-center mb-4">
|
||||
<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>
|
||||
Nueva tarea
|
||||
</button>
|
||||
|
||||
{{-- Nav --}}
|
||||
<nav class="space-y-0.5 flex-1">
|
||||
@php
|
||||
$filters = [
|
||||
['key' => 'all', 'label' => 'Todas', 'icon' => '<path d=\"M3 12h18M3 6h18M3 18h18\"/>'],
|
||||
['key' => 'pending', 'label' => 'Pendientes', 'icon' => '<circle cx=\"12\" cy=\"12\" r=\"10\"/><polyline points=\"12 6 12 12 16 14\"/>'],
|
||||
['key' => 'today', 'label' => 'Hoy', 'icon' => '<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\"/><path d=\"M16 2v4M8 2v4M3 10h18\"/>'],
|
||||
['key' => 'overdue', 'label' => 'Vencidas', 'icon' => '<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 6v6l4 2\"/>'],
|
||||
['key' => 'done', 'label' => 'Hechas', 'icon' => '<path d=\"M22 11.08V12a10 10 0 1 1-5.93-9.14\"/><path d=\"M22 4L12 14.01l-3-3\"/>'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($filters as $f)
|
||||
<button @click="filter = '{{ $f['key'] }}'"
|
||||
:class="filter === '{{ $f['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">{!! $f['icon'] !!}</svg>
|
||||
<span class="flex-1 text-left">{{ $f['label'] }}</span>
|
||||
<span class="text-xs text-zinc-500" x-show="filterCounts.{{ $f['key'] }} !== undefined" x-text="filterCounts.{{ $f['key'] }} || ''"></span>
|
||||
</button>
|
||||
@endforeach
|
||||
|
||||
<div class="pt-4 pb-2 px-3 text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">Proyectos</div>
|
||||
|
||||
<button @click="projectFilter = ''" :class="!projectFilter ? 'active' : ''" class="sidebar-item w-full">
|
||||
<span class="w-2 h-2 rounded-full bg-zinc-600"></span>
|
||||
<span class="flex-1 text-left">Todos</span>
|
||||
</button>
|
||||
<template x-for="p in projects" :key="p.id">
|
||||
<button @click="projectFilter = p.id" :class="projectFilter == p.id ? 'active' : ''" class="sidebar-item w-full">
|
||||
<span class="w-2 h-2 rounded-full" :style="'background:' + p.color"></span>
|
||||
<span class="flex-1 text-left truncate" x-text="p.name"></span>
|
||||
</button>
|
||||
</template>
|
||||
</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>
|
||||
{{-- Bottom: settings + user --}}
|
||||
<div class="border-t border-white/5 pt-2 mt-2 space-y-0.5">
|
||||
<a href="{{ route('settings') }}" class="sidebar-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>
|
||||
<span class="flex-1">Ajustes</span>
|
||||
</a>
|
||||
<form method="POST" action="{{ route('logout') }}" class="contents">
|
||||
@csrf
|
||||
<button class="sidebar-item w-full text-left">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
<span class="flex-1 text-left">Cerrar sesión</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{{-- 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>
|
||||
{{-- MAIN --}}
|
||||
<main class="flex-1 min-w-0 flex flex-col">
|
||||
|
||||
<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>
|
||||
{{-- 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">
|
||||
<button @click="mobileMenu = !mobileMenu" 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="M3 12h18M3 6h18M3 18h18"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 max-w-xl relative">
|
||||
<input x-model="search" type="search" placeholder="Buscar tareas... (pulsa /)"
|
||||
class="input pl-10">
|
||||
<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
</div>
|
||||
|
||||
<kbd class="hidden sm:inline-flex kbd">/</kbd>
|
||||
|
||||
<button @click="openNew()" class="lg:hidden btn btn-primary btn-sm">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- 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>
|
||||
{{-- STATS --}}
|
||||
<div class="px-4 sm:px-6 py-6 grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<template x-for="(s, i) in stats" :key="i">
|
||||
<div class="task-card">
|
||||
<div class="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider" x-text="s.label"></div>
|
||||
<div class="mt-1.5 flex items-baseline gap-1.5">
|
||||
<div class="text-2xl font-extrabold tabular-nums" :class="s.color" x-text="s.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{-- ACTIVE FILTER --}}
|
||||
<div class="px-4 sm:px-6 pb-3 flex items-center gap-2 text-sm">
|
||||
<span class="text-zinc-500">Mostrando</span>
|
||||
<span class="font-semibold" x-text="filterLabel"></span>
|
||||
<template x-if="projectFilter">
|
||||
<span class="chip" :style="'background:' + (currentProject?.color || '#52525b') + '20; color:' + (currentProject?.color || '#a1a1aa')">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :style="'background:' + (currentProject?.color || '#a1a1aa')"></span>
|
||||
<span x-text="currentProject?.name"></span>
|
||||
</span>
|
||||
</template>
|
||||
<span class="text-zinc-500 ml-auto text-xs">
|
||||
<span x-text="filteredTasks.length"></span> resultado<span x-show="filteredTasks.length !== 1">s</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- LIST --}}
|
||||
<div class="px-4 sm:px-6 pb-12 flex-1">
|
||||
|
||||
{{-- Loading --}}
|
||||
<template x-if="loading">
|
||||
<div class="space-y-2">
|
||||
<template x-for="i in 4">
|
||||
<div class="task-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="skeleton w-5 h-5 rounded"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="skeleton h-4 w-2/3"></div>
|
||||
<div class="skeleton h-3 w-1/3"></div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Empty --}}
|
||||
<template x-if="!loading && filteredTasks.length === 0">
|
||||
<div class="text-center py-20 anim-up">
|
||||
<div class="inline-flex w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-indigo-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 12l2 2 4-4M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/></svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold mb-1" x-text="emptyTitle"></h2>
|
||||
<p class="text-zinc-500 text-sm mb-6" x-text="emptyDesc"></p>
|
||||
<button @click="openNew()" class="btn btn-primary">Crear primera tarea</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Tasks --}}
|
||||
<div class="space-y-2" x-show="!loading && filteredTasks.length > 0">
|
||||
<template x-for="t in filteredTasks" :key="t.id">
|
||||
<div class="task-card anim-up" :class="t.done ? 'task-done' : ''">
|
||||
<div class="flex items-start gap-3">
|
||||
<button @click="toggle(t)" class="check mt-0.5" :class="t.done ? 'checked' : ''">
|
||||
<svg x-show="t.done" class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="task-title font-semibold" x-text="t.title"></span>
|
||||
<span class="chip" :class="'prio-' + t.priority" x-text="priorityLabel(t.priority)"></span>
|
||||
<template x-if="t.project">
|
||||
<span class="chip" :style="'background:' + t.project.color + '18; color:' + t.project.color">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :style="'background:' + t.project.color"></span>
|
||||
<span x-text="t.project.name"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-for="tag in (t.tags || [])" :key="tag.id">
|
||||
<span class="chip text-[10px]" :style="'background:' + tag.color + '18; color:' + tag.color">
|
||||
#<span x-text="tag.name"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p x-show="t.description" class="text-sm text-zinc-400 mt-1.5 line-clamp-2" x-text="t.description"></p>
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-zinc-500 mt-2 flex-wrap">
|
||||
<template x-if="t.estimate_min">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
<span x-text="t.estimate_min"></span> min
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="t.deadline">
|
||||
<span class="inline-flex items-center gap-1" :class="isOverdue(t) ? 'text-red-400' : ''">
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>
|
||||
<span x-text="formatDate(t.deadline)"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="t.recurrence">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 1l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
<span x-text="recurrenceLabel(t.recurrence)"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="t.snooze_until && new Date(t.snooze_until) > new Date()">
|
||||
<span class="inline-flex items-center gap-1 text-yellow-400">
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
pospuesta
|
||||
</span>
|
||||
</template>
|
||||
<span x-show="t.done_at" class="inline-flex items-center gap-1 text-green-400">
|
||||
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
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)" x-show="!t.done" class="btn btn-ghost btn-icon" title="Snooze 5 min (s)">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
<button @click="edit(t)" class="btn btn-ghost btn-icon" title="Editar">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button @click="del(t)" 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
</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>
|
||||
{{-- MODAL --}}
|
||||
<div x-show="modalOpen" x-cloak class="modal-backdrop" @keydown.escape.window="closeModal()">
|
||||
<div class="modal-card" @click.outside="closeModal()">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-bold" x-text="editing ? 'Editar tarea' : 'Nueva tarea'"></h2>
|
||||
<button @click="closeModal()" 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>
|
||||
|
||||
<textarea x-model="form.description" placeholder="Descripción (opcional)" class="input min-h-[80px]"></textarea>
|
||||
<form @submit.prevent="editing ? saveEdit() : create()" class="space-y-4">
|
||||
<div>
|
||||
<input x-model="form.title" type="text" placeholder="¿Qué tienes que hacer?" required maxlength="120" class="input text-base" autofocus>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<textarea x-model="form.description" placeholder="Descripción (opcional)" class="textarea min-h-[80px]"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
<label class="label">Prioridad</label>
|
||||
<select x-model="form.priority" class="select">
|
||||
<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">Tags (separados por coma)</label>
|
||||
<input x-model="form.tagsInput" type="text" class="input" placeholder="cliente-x, urgente">
|
||||
<label class="label">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="label">Fecha límite</label>
|
||||
<input x-model="form.deadline" type="datetime-local" class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Repetir</label>
|
||||
<select x-model="form.recurrence" class="select">
|
||||
<option value="">Nunca</option>
|
||||
<option value="daily">Diario</option>
|
||||
<option value="weekly">Semanal</option>
|
||||
<option value="monthly">Mensual</option>
|
||||
</select>
|
||||
</div>
|
||||
</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>
|
||||
<label class="label">Proyecto</label>
|
||||
<select x-model="form.project_id" class="select">
|
||||
<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="label">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-3 border-t border-white/5">
|
||||
<button type="submit" class="btn btn-primary flex-1 justify-center">
|
||||
<span x-text="editing ? 'Guardar cambios' : 'Crear tarea'"></span>
|
||||
</button>
|
||||
<button type="button" @click="closeModal()" class="btn btn-ghost">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- TOAST --}}
|
||||
<div x-show="toast" x-cloak class="toast" x-text="toast"></div>
|
||||
</div>
|
||||
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
{{-- TOAST --}}
|
||||
<div x-show="toast" x-cloak x-transition.opacity class="toast" x-text="toast"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function dummybotApp() {
|
||||
<script>
|
||||
function dummybotApp() {
|
||||
return {
|
||||
tasks: [],
|
||||
projects: [],
|
||||
search: '',
|
||||
filter: 'all',
|
||||
projectFilter: '',
|
||||
loading: true,
|
||||
modalOpen: false,
|
||||
editing: null,
|
||||
form: {},
|
||||
toast: '',
|
||||
mobileMenu: false,
|
||||
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();
|
||||
setInterval(() => this.loadAll(true), 15000);
|
||||
},
|
||||
|
||||
bindKeys() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key === 'n') { e.preventDefault(); this.openNew(); }
|
||||
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 = 'overdue';
|
||||
if (e.key === '5') 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;
|
||||
},
|
||||
|
||||
get filterCounts() {
|
||||
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);
|
||||
},
|
||||
all: this.tasks.length,
|
||||
pending: this.tasks.filter(x => !x.done && !this.isSnoozed(x)).length,
|
||||
today: this.tasks.filter(x => !x.done && this.isToday(x)).length,
|
||||
overdue: this.tasks.filter(x => !x.done && this.isOverdue(x)).length,
|
||||
done: this.tasks.filter(x => x.done).length,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
},
|
||||
|
||||
get currentProject() {
|
||||
return this.projects.find(p => p.id == this.projectFilter);
|
||||
},
|
||||
|
||||
get filterLabel() {
|
||||
return {
|
||||
all: 'todas las tareas',
|
||||
pending: 'pendientes',
|
||||
today: 'para hoy',
|
||||
overdue: 'vencidas',
|
||||
done: 'completadas',
|
||||
}[this.filter] || '';
|
||||
},
|
||||
|
||||
get emptyTitle() {
|
||||
if (this.filter === 'done') return 'Aún no has hecho nada';
|
||||
if (this.filter === 'today') return 'Nada para hoy';
|
||||
if (this.filter === 'overdue') return 'Sin tareas vencidas 🎉';
|
||||
if (this.filter === 'pending') return '¡Buen trabajo!';
|
||||
return this.search ? 'Sin resultados' : 'Lista vacía';
|
||||
},
|
||||
|
||||
get emptyDesc() {
|
||||
if (this.search) return `No hay tareas que coincidan con "${this.search}"`;
|
||||
if (this.filter === 'pending') return 'Ponte a procrastinar con la conciencia tranquila.';
|
||||
if (this.filter === 'done') return 'Las tareas que completes aparecerán aquí.';
|
||||
return 'Crea tu primera tarea con el botón + o pulsa N.';
|
||||
},
|
||||
|
||||
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(); },
|
||||
|
||||
priorityLabel(p) { return { high: 'Alta', med: 'Media', low: 'Baja' }[p] || p; },
|
||||
recurrenceLabel(r) { return { daily: 'Diario', weekly: 'Semanal', monthly: 'Mensual' }[r] || r; },
|
||||
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`;
|
||||
},
|
||||
|
||||
openNew() {
|
||||
this.editing = null;
|
||||
this.form = {
|
||||
title: '', description: '', priority: 'med',
|
||||
estimate_min: null, deadline: '', project_id: '',
|
||||
recurrence: '', tagsInput: '',
|
||||
};
|
||||
this.modalOpen = true;
|
||||
},
|
||||
closeModal() { this.modalOpen = 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(', '),
|
||||
};
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
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 create() {
|
||||
try {
|
||||
await this.api('/api/tasks', 'POST', this.payload());
|
||||
this.flash('✓ Tarea creada');
|
||||
this.closeModal();
|
||||
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); }
|
||||
},
|
||||
|
||||
async toggle(t) {
|
||||
if (t.done) {
|
||||
await this.api(`/api/tasks/${t.id}/restore`, 'POST');
|
||||
this.flash('Reabierta');
|
||||
} else {
|
||||
await this.api(`/api/tasks/${t.id}/done`, 'POST');
|
||||
this.flash('✓ ¡Hecha!');
|
||||
}
|
||||
await this.loadAll();
|
||||
},
|
||||
async snooze(t) {
|
||||
await this.api(`/api/tasks/${t.id}/snooze`, 'POST', {minutes: 5});
|
||||
this.flash('💤 Pospuesta 5 min');
|
||||
await this.loadAll();
|
||||
},
|
||||
async del(t) {
|
||||
if (!confirm(`¿Borrar "${t.title}"?`)) return;
|
||||
await this.api(`/api/tasks/${t.id}`, 'DELETE');
|
||||
this.flash('🗑 Borrada');
|
||||
await this.loadAll();
|
||||
},
|
||||
|
||||
async api(url, method = 'GET', body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': this.csrf,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
if (body) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) {
|
||||
const txt = await r.text();
|
||||
throw new Error(`${r.status}: ${txt.substring(0, 100)}`);
|
||||
}
|
||||
return r.json();
|
||||
},
|
||||
flash(msg) {
|
||||
this.toast = msg;
|
||||
setTimeout(() => this.toast = '', 2500);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
113
resources/views/pages/auth.blade.php
Normal file
113
resources/views/pages/auth.blade.php
Normal file
@ -0,0 +1,113 @@
|
||||
@extends('layouts.base')
|
||||
@section('title', $mode === 'register' ? 'Crear cuenta · DummyBot' : 'Entrar · DummyBot')
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen flex">
|
||||
{{-- LEFT: Form --}}
|
||||
<div class="flex-1 flex items-center justify-center px-6 py-12">
|
||||
<div class="w-full max-w-sm anim-up">
|
||||
|
||||
{{-- Logo --}}
|
||||
<a href="{{ route('home') }}" class="inline-flex items-center gap-2.5 mb-10">
|
||||
<div class="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center shadow-glow">
|
||||
<svg class="w-5 h-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-lg">DummyBot</span>
|
||||
</a>
|
||||
|
||||
<h1 class="text-2xl font-bold mb-1">
|
||||
{{ $mode === 'register' ? 'Crea tu cuenta' : 'Bienvenido de vuelta' }}
|
||||
</h1>
|
||||
<p class="text-zinc-400 text-sm mb-8">
|
||||
{{ $mode === 'register' ? 'Tus tareas, en un bot con carita kawaii.' : 'Entra para ver qué tienes pendiente.' }}
|
||||
</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-5 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-300 text-sm anim-down">
|
||||
@if($errors->count() === 1)
|
||||
{{ $errors->first() }}
|
||||
@else
|
||||
<ul class="list-disc list-inside space-y-0.5">
|
||||
@foreach ($errors->all() as $e)<li>{{ $e }}</li>@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ $mode === 'register' ? route('register') : route('login') }}" class="space-y-4">
|
||||
@csrf
|
||||
|
||||
@if($mode === 'register')
|
||||
<div>
|
||||
<label class="label">Nombre</label>
|
||||
<input type="text" name="name" required autofocus class="input" value="{{ old('name') }}" autocomplete="name">
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input type="email" name="email" required class="input" value="{{ old('email') }}" autocomplete="email" {{ $mode === 'login' ? 'autofocus' : '' }}>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Contraseña{{ $mode === 'register' ? ' (mín. 8)' : '' }}</label>
|
||||
<input type="password" name="password" required minlength="{{ $mode === 'register' ? 8 : 1 }}" class="input" autocomplete="{{ $mode === 'register' ? 'new-password' : 'current-password' }}">
|
||||
</div>
|
||||
|
||||
@if($mode === 'register')
|
||||
<div>
|
||||
<label class="label">Repite la contraseña</label>
|
||||
<input type="password" name="password_confirmation" required minlength="8" class="input" autocomplete="new-password">
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full justify-center mt-6">
|
||||
{{ $mode === 'register' ? 'Crear cuenta' : 'Entrar' }}
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-white/5 text-center text-sm text-zinc-500">
|
||||
@if($mode === 'register')
|
||||
¿Ya tienes cuenta? <a href="{{ route('login') }}" class="text-indigo-400 hover:text-indigo-300 font-medium">Entrar</a>
|
||||
@else
|
||||
¿No tienes cuenta? <a href="{{ route('register') }}" class="text-indigo-400 hover:text-indigo-300 font-medium">Crear una gratis</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- RIGHT: Decoration --}}
|
||||
<div class="hidden lg:flex flex-1 relative items-center justify-center p-12 border-l border-white/5 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-purple-500/5 to-transparent"></div>
|
||||
<div class="absolute inset-0" style="background-image: radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px); background-size: 24px 24px;"></div>
|
||||
|
||||
<div class="relative max-w-md anim-up" style="animation-delay: 0.1s">
|
||||
<div class="mx-auto w-64 h-64 rounded-3xl bg-gradient-to-br from-zinc-800 to-zinc-900 shadow-2xl border border-white/10 flex items-center justify-center p-6 mb-8">
|
||||
<div class="w-full aspect-square rounded-2xl bg-black border-4 border-zinc-800 flex flex-col items-center justify-center p-3 text-center">
|
||||
<div class="text-[8px] text-zinc-500 mb-1">DummyBot</div>
|
||||
<div class="w-12 h-px bg-zinc-800 mb-2"></div>
|
||||
<div class="text-lg font-bold text-white leading-tight">Hola,<br>listo para<br>empezar</div>
|
||||
<div class="text-[8px] text-zinc-500 mt-2">👋</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/5 border border-white/10 text-xs text-zinc-300 mb-4">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 pulse-dot"></span>
|
||||
Open source · MIT
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-2">Hecho con cariño</h2>
|
||||
<p class="text-zinc-400 text-sm leading-relaxed">
|
||||
Un bot físico para personas con TDAH. Sin nubes invasivas, sin notificaciones, sin agobio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@ -1,141 +1,232 @@
|
||||
@extends('layouts.base')
|
||||
@section('title', 'DummyBot — Tu compañero de tareas')
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen flex flex-col">
|
||||
{{-- NAV --}}
|
||||
<nav class="border-b border-white/5 backdrop-blur-md sticky top-0 z-40">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<a href="{{ route('home') }}" class="flex items-center gap-2 text-lg font-bold">
|
||||
<span class="text-2xl">🤖</span>
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
|
||||
</a>
|
||||
<div x-data="{ mobileOpen: false }" class="min-h-screen flex flex-col">
|
||||
|
||||
{{-- NAV --}}
|
||||
<nav class="sticky top-0 z-40 border-b border-white/5 glass-strong">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2.5">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center shadow-glow">
|
||||
<svg class="w-4 h-4 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-base">DummyBot</span>
|
||||
</a>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
<a href="#features" class="btn btn-ghost btn-sm">Funciones</a>
|
||||
<a href="#how" class="btn btn-ghost btn-sm">Cómo funciona</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@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>
|
||||
<a href="{{ route('app') }}" class="btn btn-primary btn-sm">Abrir app →</a>
|
||||
@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>
|
||||
<a href="{{ route('login') }}" class="btn btn-ghost btn-sm">Entrar</a>
|
||||
<a href="{{ route('register') }}" class="btn btn-primary btn-sm">Empezar gratis</a>
|
||||
@endauth
|
||||
</div>
|
||||
</nav>
|
||||
</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>
|
||||
{{-- HERO --}}
|
||||
<section class="flex-1 flex items-center">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-20 md:py-32 grid md:grid-cols-2 gap-12 items-center">
|
||||
<div class="anim-up">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-indigo-500/10 border border-indigo-500/20 text-indigo-300 text-xs font-medium mb-6">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-indigo-400 pulse-dot"></span>
|
||||
Para personas con TDAH
|
||||
</div>
|
||||
|
||||
{{-- PREVIEW --}}
|
||||
<div class="anim-in" style="animation-delay: .15s">
|
||||
<h1 class="text-4xl md:text-6xl font-extrabold leading-[1.05] tracking-tight mb-6">
|
||||
Tu lista de tareas, <span class="bg-gradient-to-br from-indigo-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">hecha física.</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-lg text-zinc-400 leading-relaxed mb-8 max-w-lg">
|
||||
Un bot con carita kawaii y una pantalla OLED que vive en tu escritorio.
|
||||
Te recuerda lo que tienes que hacer, sin pantallas, sin notificaciones,
|
||||
sin agobio. Solo lo importante.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
@auth
|
||||
<a href="{{ route('app') }}" class="btn btn-primary">Ir a la app →</a>
|
||||
@else
|
||||
<a href="{{ route('register') }}" class="btn btn-primary">Crear cuenta gratis</a>
|
||||
<a href="#how" class="btn btn-ghost">Ver cómo funciona</a>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex items-center gap-4 text-xs text-zinc-500">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 text-green-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Sin gamificación tóxica
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 text-green-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Open source
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 text-green-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Privado por defecto
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Hero device mockup --}}
|
||||
<div class="anim-up" style="animation-delay: 0.1s">
|
||||
<div class="relative">
|
||||
{{-- Glow --}}
|
||||
<div class="absolute -inset-8 bg-gradient-to-br from-indigo-500/20 via-purple-500/20 to-pink-500/10 blur-3xl rounded-full"></div>
|
||||
|
||||
{{-- Device --}}
|
||||
<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 class="mx-auto w-72 h-72 rounded-3xl bg-gradient-to-br from-zinc-800 to-zinc-900 shadow-2xl border border-white/10 flex items-center justify-center p-6">
|
||||
<div class="w-full aspect-square rounded-2xl bg-black border-4 border-zinc-800 flex flex-col items-center justify-center p-3 text-center">
|
||||
<div class="text-[8px] text-zinc-500 mb-1">DummyBot [high]</div>
|
||||
<div class="w-12 h-px bg-zinc-800 mb-2"></div>
|
||||
<div class="text-xl font-bold text-white">Revisar</div>
|
||||
<div class="text-xl font-bold text-white">PR urgente</div>
|
||||
<div class="text-[8px] text-zinc-500 mt-2">Vence: 12 min</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>
|
||||
{{-- Buttons --}}
|
||||
<div class="absolute -bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5">
|
||||
<div class="w-8 h-2 rounded-full bg-zinc-700"></div>
|
||||
<div class="w-8 h-2 rounded-full bg-zinc-700"></div>
|
||||
<div class="w-8 h-2 rounded-full bg-zinc-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Floating notifications --}}
|
||||
<div class="hidden md:block absolute -left-12 top-12 glass rounded-xl p-3 anim-fade" style="animation-delay: 0.5s">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-green-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium">Tarea hecha</div>
|
||||
<div class="text-[10px] text-zinc-500">hace 2 min</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block absolute -right-8 top-32 glass rounded-xl p-3 anim-fade" style="animation-delay: 0.7s">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-indigo-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium">Snooze 5 min</div>
|
||||
<div class="text-[10px] text-zinc-500">Nueva tarea</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 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">
|
||||
{{-- FEATURES --}}
|
||||
<section id="features" class="border-t border-white/5">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-20">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-3">Diseñado para tu cerebro</h2>
|
||||
<p class="text-zinc-400 max-w-xl mx-auto">No más apps infinitas. Solo lo que necesitas, cuando lo necesitas.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@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.'],
|
||||
];
|
||||
$features = [
|
||||
['icon' => 'check', 'color' => 'indigo', 'title' => 'Sin agobio', 'desc' => 'Solo ves la siguiente tarea. Sin scroll infinito ni listas de 47 elementos compitiendo por tu atención.'],
|
||||
['icon' => 'eye', 'color' => 'purple', 'title' => 'Sin pantallas', 'desc' => 'Un OLED pequeño, sin notificaciones, sin badges rojos. Cuando miras, miras con intención.'],
|
||||
['icon' => 'heart', 'color' => 'pink', 'title' => 'No judgemental', 'desc' => '¿Sin tareas? Emoji festivo, no mensaje triste. Snooze es válido. Hecho es celebrado.'],
|
||||
['icon' => 'keyboard', 'color' => 'blue', 'title' => 'Atajos de teclado', 'desc' => 'n para nueva, / para buscar, s para snooze. Sin tocar el ratón. Hecho para teclado.'],
|
||||
['icon' => 'zap', 'color' => 'amber', 'title' => 'Sincronización instantánea', 'desc' => 'Crea en la web → aparece en el bot en menos de 30s. Marca en el bot → se ve en la web al instante.'],
|
||||
['icon' => 'lock', 'color' => 'green', 'title' => 'Tus datos son tuyos', 'desc' => 'Open source, self-hostable, sin tracking. Tu lista de tareas no es un producto.'],
|
||||
];
|
||||
$icons = [
|
||||
'check' => '<path d="M20 6L9 17l-5-5"/>',
|
||||
'eye' => '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'heart' => '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>',
|
||||
'keyboard' => '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M6 16h.01M18 16h.01M10 16h4"/>',
|
||||
'zap' => '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
];
|
||||
@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>
|
||||
@foreach($features as $f)
|
||||
<div class="task-card group">
|
||||
<div class="w-10 h-10 rounded-lg bg-{{ $f['color'] }}-500/10 border border-{{ $f['color'] }}-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-5 h-5 text-{{ $f['color'] }}-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{!! $icons[$f['icon']] !!}
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1.5">{{ $f['title'] }}</h3>
|
||||
<p class="text-sm text-zinc-400 leading-relaxed">{{ $f['desc'] }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
{{-- HOW IT WORKS --}}
|
||||
<section id="how" class="border-t border-white/5">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-20">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-3">Cómo funciona</h2>
|
||||
<p class="text-zinc-400 max-w-xl mx-auto">De la web al escritorio en 3 pasos.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
@php
|
||||
$steps = [
|
||||
['n' => 1, 'title' => 'Crea tareas en la web', 'desc' => 'Desde cualquier navegador, con teclado rápido. Sin instalar nada.'],
|
||||
['n' => 2, 'title' => 'Tu DummyBot se sincroniza', 'desc' => 'Cada 30 segundos el bot pide nuevas tareas. Sin empuje de notificaciones.'],
|
||||
['n' => 3, 'title' => 'Pulsa un botón. Hecho.', 'desc' => 'El bot tiene 3 botones: siguiente, hecha, snooze. Vibra cuando llega algo nuevo.'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($steps as $s)
|
||||
<div class="relative">
|
||||
<div class="task-card">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-sm font-bold">{{ $s['n'] }}</div>
|
||||
<h3 class="font-semibold">{{ $s['title'] }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400 leading-relaxed">{{ $s['desc'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- CTA --}}
|
||||
<section class="border-t border-white/5">
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-20 text-center">
|
||||
<h2 class="text-3xl md:text-4xl font-bold mb-4">¿Listo para tener menos pantallas?</h2>
|
||||
<p class="text-zinc-400 mb-8 max-w-lg mx-auto">Empieza gratis. Sin tarjeta. Sin tracking. Open source.</p>
|
||||
@guest
|
||||
<a href="{{ route('register') }}" class="btn btn-primary">Crear cuenta gratis →</a>
|
||||
@endguest
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- FOOTER --}}
|
||||
<footer class="border-t border-white/5">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-zinc-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 rounded bg-gradient-to-br from-indigo-500 to-purple-500"></div>
|
||||
<span>DummyBot · Open source · MIT</span>
|
||||
</div>
|
||||
<div>Hecho con 🧠 para cerebros que olvidan</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
@extends('layouts.base')
|
||||
@section('content')
|
||||
<div class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="glass rounded-2xl p-8 w-full max-w-md anim-in">
|
||||
<a href="{{ route('home') }}" class="flex items-center gap-2 mb-6 text-lg font-bold">
|
||||
<span class="text-2xl">🤖</span>
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold mb-1">Bienvenido de vuelta</h1>
|
||||
<p class="text-zinc-400 text-sm mb-6">Entra para ver tus tareas</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-300 text-sm">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Email</label>
|
||||
<input type="email" name="email" required autofocus class="input"
|
||||
value="{{ old('email') }}" autocomplete="email">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Contraseña</label>
|
||||
<input type="password" name="password" required class="input" autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full justify-center">Entrar</button>
|
||||
</form>
|
||||
<p class="mt-6 text-center text-sm text-zinc-500">
|
||||
¿No tienes cuenta?
|
||||
<a href="{{ route('register') }}" class="text-indigo-400 hover:underline">Crea una</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@endsection
|
||||
@ -1,46 +0,0 @@
|
||||
@extends('layouts.base')
|
||||
@section('content')
|
||||
<div class="min-h-screen flex items-center justify-center px-4 py-12">
|
||||
<div class="glass rounded-2xl p-8 w-full max-w-md anim-in">
|
||||
<a href="{{ route('home') }}" class="flex items-center gap-2 mb-6 text-lg font-bold">
|
||||
<span class="text-2xl">🤖</span>
|
||||
<span class="bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">DummyBot</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold mb-1">Crea tu cuenta</h1>
|
||||
<p class="text-zinc-400 text-sm mb-6">Tus tareas, en un bot que se enfurruña si las dejas</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-300 text-sm">
|
||||
<ul class="list-disc list-inside">@foreach ($errors->all() as $e)<li>{{ $e }}</li>@endforeach</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('register') }}" class="space-y-4">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Nombre</label>
|
||||
<input type="text" name="name" required autofocus class="input" value="{{ old('name') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Email</label>
|
||||
<input type="email" name="email" required class="input" value="{{ old('email') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Contraseña (mín. 8)</label>
|
||||
<input type="password" name="password" required minlength="8" class="input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1">Repite la contraseña</label>
|
||||
<input type="password" name="password_confirmation" required minlength="8" class="input">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-full justify-center">Crear cuenta</button>
|
||||
</form>
|
||||
<p class="mt-6 text-center text-sm text-zinc-500">
|
||||
¿Ya tienes cuenta?
|
||||
<a href="{{ route('login') }}" class="text-indigo-400 hover:underline">Entrar</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@endsection
|
||||
@ -1,260 +1,455 @@
|
||||
@extends('layouts.base')
|
||||
@section('title', 'Ajustes · DummyBot')
|
||||
@section('content')
|
||||
<div x-data="settingsApp()" x-init="init()" class="max-w-4xl mx-auto px-4 sm:px-6 py-8">
|
||||
<a href="{{ route('app') }}" class="text-sm text-zinc-500 hover:text-zinc-300">← Volver</a>
|
||||
<h1 class="text-3xl font-bold mt-2 mb-6">Ajustes</h1>
|
||||
<div x-data="settingsApp()" x-init="init()" class="min-h-screen flex">
|
||||
|
||||
{{-- 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>
|
||||
{{-- 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>
|
||||
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{{-- 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>
|
||||
</template>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{{-- 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 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>
|
||||
</template>
|
||||
<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>
|
||||
</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>
|
||||
{{-- 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>
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{{-- 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 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-zinc-400 text-sm">Sin tags aún</p>
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
<div x-show="toast" x-cloak class="toast" x-text="toast"></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>
|
||||
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
{{-- 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>
|
||||
|
||||
<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: '',
|
||||
{{-- 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>
|
||||
|
||||
async init() { await this.loadAll(); },
|
||||
<div x-show="toast" x-cloak x-transition.opacity class="toast" x-text="toast"></div>
|
||||
</div>
|
||||
|
||||
async loadAll() {
|
||||
this.devices = await this.api('/api/devices');
|
||||
this.projects = await this.api('/api/projects');
|
||||
this.tags = await this.api('/api/tags');
|
||||
},
|
||||
<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 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 init() { await this.loadAll(); },
|
||||
|
||||
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 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 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);
|
||||
},
|
||||
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');
|
||||
},
|
||||
|
||||
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 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 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();
|
||||
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',
|
||||
},
|
||||
flash(msg) { this.toast = msg; setTimeout(() => this.toast = '', 2500); },
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user