Files

1178 lines
53 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DevOpsDash</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { brand: '#6366f1' } } }
}
</script>
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
crossorigin="anonymous" referrerpolicy="no-referrer" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
[x-cloak]{display:none!important}
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-thumb{background:#374151;border-radius:3px}
.task-row:hover{background:#111827}
.prose-dark{color:#d1d5db}
.prose-dark h1,.prose-dark h2,.prose-dark h3{color:#f9fafb;margin-top:.75rem;margin-bottom:.25rem;font-weight:600}
.prose-dark h1{font-size:1.2rem}.prose-dark h2{font-size:1.05rem}.prose-dark h3{font-size:.95rem}
.prose-dark code{background:#1f2937;border-radius:3px;padding:1px 4px;font-size:.85em;color:#a5b4fc}
.prose-dark pre{background:#111827;border-radius:6px;padding:.75rem;overflow-x:auto;margin:.5rem 0}
.prose-dark pre code{background:none;padding:0;color:#d1fae5}
.prose-dark a{color:#818cf8}.prose-dark ul{list-style:disc;padding-left:1.25rem}
.prose-dark ol{list-style:decimal;padding-left:1.25rem}.prose-dark li{margin:.15rem 0}
.prose-dark blockquote{border-left:3px solid #4b5563;padding-left:.75rem;color:#9ca3af}
.prose-dark table{width:100%;border-collapse:collapse;font-size:.85em}
.prose-dark th{background:#1f2937;padding:.4rem .6rem;text-align:left}
.prose-dark td{padding:.35rem .6rem;border-top:1px solid #374151}
.prose-dark hr{border-color:#374151;margin:.75rem 0}
.status-pending{background:rgba(120,80,0,.5);color:#fbbf24}
.status-in_progress{background:rgba(30,64,175,.5);color:#93c5fd}
.status-done{background:rgba(6,78,59,.5);color:#6ee7b7}
.status-blocked{background:rgba(127,29,29,.5);color:#fca5a5}
</style>
</head>
<body class="bg-gray-950 text-gray-100 min-h-screen flex flex-col" x-data x-cloak>
<!-- ═══ TOPBAR ═══ -->
<header class="flex items-center justify-between px-5 py-2.5 border-b border-indigo-900/60 bg-indigo-950 sticky top-0 z-50 shrink-0">
<div class="flex items-center gap-3">
<span class="text-indigo-300 text-base font-bold tracking-tight">
<i class="fa-solid fa-bolt text-indigo-400"></i> DevOpsDash
</span>
<span class="text-indigo-800 text-xs hidden sm:block">i80.dk</span>
</div>
<nav class="flex gap-1" x-data>
<template x-for="t in [
{id:'taskz', icon:'fa-layer-group', label:'Taskz'},
{id:'worklog', icon:'fa-clock-rotate-left', label:'Worklog'},
{id:'projects', icon:'fa-folder-open', label:'Projects'},
{id:'knowledge',icon:'fa-book-open', label:'Knowledge'}
]" :key="t.id">
<button @click="$dispatch('switch-tab', t.id)"
class="px-3 py-1.5 rounded text-sm font-medium transition-colors flex items-center gap-1.5"
:class="$store.ui.tab===t.id
? 'bg-indigo-600 text-white'
: 'text-indigo-300 hover:text-white hover:bg-indigo-800/50'">
<i class="fa-solid" :class="t.icon"></i>
<span class="hidden sm:inline" x-text="t.label"></span>
</button>
</template>
</nav>
<div class="flex items-center gap-2">
<span class="text-xs text-indigo-700" x-data x-text="$store.status.msg"></span>
<div class="w-2 h-2 rounded-full"
:class="$store.health.ok ? 'bg-green-400' : 'bg-red-500'"
:title="$store.health.ok ? 'Redis OK' : 'Redis down'"
x-data></div>
</div>
</header>
<div class="flex flex-1 overflow-hidden" x-data @switch-tab.window="$store.ui.tab=$event.detail">
<!-- ═══════════════════════════════════════ TASKZ ═══ -->
<div class="flex flex-1 overflow-hidden" x-show="$store.ui.tab==='taskz'">
<!-- Sidebar -->
<aside class="w-60 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0 overflow-hidden"
x-data="taskzSidebar()">
<div class="p-3 border-b border-gray-800 space-y-2 shrink-0">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Boards</span>
<label class="flex items-center gap-1 text-xs text-gray-600 cursor-pointer" title="Show archived boards">
<input type="checkbox" x-model="showArchived" class="accent-indigo-500 w-3 h-3"/>
<span>arc</span>
</label>
</div>
<input x-model="q" @input.debounce="/* reactive */" placeholder="Search boards…"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300
placeholder-gray-600 outline-none focus:border-indigo-500"/>
</div>
<div class="flex-1 overflow-y-auto py-1">
<!-- All boards shortcut -->
<button @click="selectAll()"
class="w-full text-left px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-2 mb-1"
:class="!activeBoard ? 'text-indigo-400' : 'text-gray-500 hover:text-gray-300'">
<i class="fa-solid fa-layer-group w-3 text-center"></i>
All boards
</button>
<!-- Project groups -->
<template x-for="proj in filteredProjects" :key="proj.name">
<div>
<div class="flex items-center gap-1 px-2 py-1 cursor-pointer select-none group"
@click="toggleProject(proj.name)">
<i class="fa-solid text-gray-700 group-hover:text-gray-400 text-xs w-3 text-center transition-colors"
:class="openProjects.includes(proj.name) ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
<span class="text-xs font-semibold text-gray-400 group-hover:text-gray-200 transition-colors flex-1 truncate"
x-text="proj.name"></span>
<span class="text-xs text-gray-700" x-text="proj.boards.length"></span>
</div>
<template x-if="openProjects.includes(proj.name)">
<div class="ml-3 border-l border-gray-800 mb-1">
<template x-for="b in proj.boards" :key="b.board_id">
<button @click="selectBoard(b.board_id)"
class="w-full text-left pl-3 pr-2 py-1.5 text-xs transition-colors flex items-center gap-1.5 border-l-2 -ml-px"
:class="activeBoard===b.board_id
? 'border-indigo-500 bg-indigo-600/10 text-white'
: 'border-transparent text-gray-500 hover:text-gray-200 hover:bg-gray-800/40'">
<span class="flex-1 truncate" x-text="b.title"></span>
<span class="text-gray-700 shrink-0 tabular-nums"
x-text="(b.done_count||0)+'/'+(b.task_count||0)"></span>
<span x-show="b.status==='archived'"
class="text-gray-700 text-xs shrink-0" title="Archived">arc</span>
</button>
</template>
</div>
</template>
</div>
</template>
</div>
</aside>
<!-- Task list -->
<main class="flex-1 overflow-hidden flex flex-col" x-data="taskzMain()">
<!-- Header bar -->
<div class="border-b border-gray-800 bg-gray-900/50 px-4 py-2.5 flex items-center gap-3 flex-wrap shrink-0">
<div class="mr-auto">
<div class="font-semibold text-sm" x-text="headerTitle"></div>
<div class="text-xs text-gray-500" x-text="headerSub"></div>
</div>
<!-- Status filter pills -->
<template x-for="s in ['pending','in_progress','done','blocked']" :key="s">
<button @click="toggleStatusFilter(s)"
class="px-2 py-0.5 rounded-full text-xs transition-colors border font-medium capitalize"
:class="statusFilter.includes(s)
? 'status-'+s+' border-transparent'
: 'border-gray-700 text-gray-600 hover:text-gray-300 border-gray-800'"
x-text="s.replace('_',' ')"></button>
</template>
<input x-model="taskQ" @input.debounce="filterTasks()" placeholder="Search tasks…"
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300
placeholder-gray-600 outline-none focus:border-indigo-500 w-32"/>
</div>
<!-- Task rows -->
<div class="flex-1 overflow-y-auto">
<template x-if="loading">
<div class="flex items-center justify-center h-32 text-gray-600 text-sm">
<i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading…
</div>
</template>
<template x-if="!loading && tasks.length===0">
<div class="flex items-center justify-center h-32 text-gray-600 text-sm">
<div class="text-center">
<i class="fa-solid fa-layer-group text-2xl mb-2 block"></i>
Select a board or project from the sidebar
</div>
</div>
</template>
<template x-if="!loading && tasks.length>0 && filteredTasks.length===0">
<div class="flex items-center justify-center h-20 text-gray-600 text-sm">No tasks match filter</div>
</template>
<template x-for="task in filteredTasks" :key="task.task_id">
<div class="task-row flex items-center gap-3 px-4 py-2.5 border-b border-gray-800/50 cursor-pointer transition-colors group"
@click="openModal(task)">
<!-- Status pill — click cycles status -->
<span @click.stop="cycleStatus(task)"
class="shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer select-none transition-colors"
:class="'status-'+task.status"
x-text="task.status.replace('_',' ')"></span>
<!-- Title -->
<span class="flex-1 text-sm text-gray-200 truncate" x-text="task.title"></span>
<!-- Board label (visible when showing all) -->
<span x-show="showBoardLabel && task._boardTitle"
class="text-xs text-gray-700 shrink-0 hidden group-hover:block truncate max-w-24"
x-text="task._boardTitle"></span>
<!-- Priority dot -->
<span class="w-2 h-2 rounded-full shrink-0"
:class="{
'bg-red-500':task.priority==='critical',
'bg-orange-400':task.priority==='high',
'bg-yellow-400':task.priority==='medium',
'bg-gray-600':task.priority==='low'||!task.priority
}"
:title="task.priority||'no priority'"></span>
<!-- Tags (first 2) -->
<div class="flex gap-1 shrink-0" x-show="(task.tags||[]).length">
<template x-for="tag in (task.tags||[]).slice(0,2)" :key="tag">
<span class="bg-indigo-900/40 text-indigo-400 rounded px-1.5 py-0.5 text-xs" x-text="tag"></span>
</template>
</div>
<!-- Findings indicator -->
<i x-show="(task.findings||[]).length"
class="fa-solid fa-comment-dots text-gray-600 text-xs shrink-0"
:title="(task.findings||[]).length+' findings'"></i>
</div>
</template>
</div>
</main>
<!-- Task edit modal -->
<template x-if="$store.boards.taskModal" x-data>
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
@click.self="$store.boards.taskModal=null">
<div class="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto p-5 space-y-4"
x-data="taskModalData()">
<div class="flex items-start gap-3">
<input x-model="edit.title"
class="flex-1 bg-transparent text-base font-semibold text-gray-100 border-b border-gray-700
focus:border-indigo-500 outline-none py-0.5"
placeholder="Task title"/>
<button @click="$store.boards.taskModal=null"
class="text-gray-500 hover:text-white text-xl leading-none shrink-0 mt-1">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Task ID + status row -->
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-gray-600 font-mono" x-text="$store.boards.taskModal.task_id"></span>
<div class="flex gap-1.5 ml-auto">
<template x-for="s in ['pending','in_progress','done','blocked']" :key="s">
<button @click="edit.status=s"
class="px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors"
:class="edit.status===s ? 'status-'+s : 'bg-gray-800 text-gray-600 hover:text-gray-300'"
x-text="s.replace('_',' ')"></button>
</template>
</div>
</div>
<!-- Priority -->
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 uppercase tracking-wide">Priority</span>
<div class="flex gap-1.5">
<template x-for="p in ['critical','high','medium','low']" :key="p">
<button @click="edit.priority=p"
class="px-2 py-0.5 rounded text-xs transition-colors"
:class="{
'bg-red-900/60 text-red-300': edit.priority===p && p==='critical',
'bg-orange-900/60 text-orange-300': edit.priority===p && p==='high',
'bg-yellow-900/60 text-yellow-300': edit.priority===p && p==='medium',
'bg-gray-800 text-gray-500': edit.priority===p && p==='low',
'bg-gray-900 text-gray-700 hover:text-gray-400': edit.priority!==p
}"
x-text="p"></button>
</template>
</div>
</div>
<!-- Description -->
<div>
<div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">Description</div>
<textarea x-model="edit.description" rows="3"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300
outline-none focus:border-indigo-500 resize-none"
placeholder="Task description…"></textarea>
</div>
<!-- Findings (append-only) -->
<div>
<div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">
Findings <span class="text-gray-700 normal-case" x-text="'('+(edit.findings||[]).length+')'"></span>
</div>
<div class="space-y-1 mb-2" x-show="(edit.findings||[]).length">
<template x-for="(f,i) in (edit.findings||[])" :key="i">
<div class="border-l-2 border-indigo-600/60 pl-2 text-xs text-gray-400 leading-relaxed">
<span x-text="typeof f==='object' ? f.text : f"></span>
<span class="text-gray-700 ml-2" x-text="typeof f==='object' && f.at ? new Date(f.at).toLocaleDateString('da-DK') : ''"></span>
</div>
</template>
</div>
<div class="flex gap-2">
<input x-model="newFinding" placeholder="Add finding…"
@keydown.enter="addFinding()"
class="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300
outline-none focus:border-indigo-500"/>
<button @click="addFinding()"
class="px-2.5 py-1 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded transition-colors">
Add
</button>
</div>
</div>
<!-- Tags -->
<div>
<div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">Tags</div>
<div class="flex flex-wrap gap-1 mb-2" x-show="(edit.tags||[]).length">
<template x-for="tag in (edit.tags||[])" :key="tag">
<span class="bg-indigo-900/40 text-indigo-400 rounded px-2 py-0.5 text-xs flex items-center gap-1">
<span x-text="tag"></span>
<button @click="removeTag(tag)" class="text-indigo-600 hover:text-white leading-none">
<i class="fa-solid fa-xmark text-xs"></i>
</button>
</span>
</template>
</div>
<input x-model="newTag" placeholder="Add tag + Enter…"
@keydown.enter="addTag()"
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300
outline-none focus:border-indigo-500"/>
</div>
<!-- Save / close -->
<div class="flex items-center justify-between pt-1">
<span class="text-xs text-green-400" x-text="saveMsg" x-show="saveMsg"></span>
<span class="text-xs text-red-400" x-text="errMsg" x-show="errMsg"></span>
<div class="flex gap-2 ml-auto">
<button @click="$store.boards.taskModal=null"
class="px-3 py-1.5 text-sm text-gray-400 hover:text-white transition-colors">Cancel</button>
<button @click="saveAll()" :disabled="saving"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg transition-colors flex items-center gap-1.5">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div>
</div>
</div>
</div>
</template>
</div><!-- /taskz -->
<!-- ═══════════════════════════════════════ WORKLOG ═══ -->
<div class="flex-1 overflow-y-auto p-5" x-show="$store.ui.tab==='worklog'" x-data="worklogTab()">
<div class="max-w-3xl mx-auto space-y-4">
<!-- Controls -->
<div class="flex items-center gap-3 flex-wrap">
<div class="flex rounded-lg overflow-hidden border border-gray-700">
<template x-for="ctx in ['egmont','personal','all']" :key="ctx">
<button @click="context=ctx; load()"
class="px-3 py-1.5 text-sm transition-colors capitalize"
:class="context===ctx ? 'bg-indigo-600 text-white' : 'bg-gray-900 text-gray-400 hover:text-white'"
x-text="ctx"></button>
</template>
</div>
<div class="flex items-center gap-2 text-sm text-gray-400">
<span>Days:</span>
<input type="range" x-model.number="days" min="1" max="30" @change="load()"
class="w-24 accent-indigo-500"/>
<span x-text="days" class="w-6 text-center text-gray-300 tabular-nums"></span>
</div>
<button @click="loadStandup()"
class="px-3 py-1.5 text-sm bg-indigo-600/20 border border-indigo-500/40 text-indigo-300 rounded-lg
hover:bg-indigo-600/30 transition-colors flex items-center gap-1.5">
<i class="fa-solid fa-clipboard-list"></i> Standup
</button>
<button @click="syncModal=true"
class="px-3 py-1.5 text-sm bg-gray-800 border border-gray-700 text-gray-300 rounded-lg
hover:bg-gray-700 transition-colors flex items-center gap-1.5" title="Sync git history">
<i class="fa-solid fa-arrows-rotate"></i> Sync
</button>
</div>
<template x-if="loading">
<div class="text-gray-500 text-sm">
<i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading…
</div>
</template>
<!-- Standup panel -->
<template x-if="standup && !loading">
<div class="bg-gray-900 border border-indigo-500/30 rounded-xl p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-sm font-semibold text-indigo-400">
<i class="fa-solid fa-clipboard-list mr-1.5"></i>Standup Summary
</span>
<button @click="standup=null" class="text-gray-600 hover:text-white">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="prose-dark text-sm"
x-html="renderMd(standup.standup_text||standup.summary||standup.text||JSON.stringify(standup,null,2))">
</div>
</div>
</template>
<!-- Worklog data -->
<template x-if="!loading && data && (data.total_commits>0) && !standup">
<div>
<div class="text-xs text-gray-600 mb-3"
x-text="data.total_commits+' commits · '+data.context+' · last '+data.days_covered+' days'"></div>
<div x-html="renderWorklog(data)" class="prose-dark text-sm"></div>
</div>
</template>
<!-- Empty state -->
<template x-if="!loading && (!data || data.total_commits===0) && !standup">
<div class="bg-gray-900 border border-gray-800 rounded-xl p-6 space-y-4">
<div class="flex items-center gap-2 text-yellow-400">
<i class="fa-solid fa-triangle-exclamation"></i>
<span class="font-semibold text-sm">Ingen git history fundet i DevOpsMCP</span>
</div>
<p class="text-sm text-gray-400 leading-relaxed">
Git history er aldrig synkroniseret til Redis. Kør kommandoen herunder i din terminal
for at importere <code class="text-indigo-400">~/.githistory</code> — eller brug
<strong class="text-gray-300">Sync</strong>-knappen ovenfor.
</p>
<div class="bg-gray-950 rounded-lg p-3 text-xs font-mono text-green-400 overflow-x-auto select-all">
cat ~/.githistory | curl -s -X POST https://dash.i80.dk/api/v1/worklog/sync -H "Content-Type: text/plain" --data-binary @-
</div>
<p class="text-xs text-gray-600">
<i class="fa-solid fa-circle-info mr-1"></i>
Format: <code class="text-gray-500">datetime | repo | branch | sha | author | message</code>
</p>
</div>
</template>
<!-- Sync modal -->
<template x-if="syncModal">
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
@click.self="syncModal=false">
<div class="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg p-5 space-y-4">
<div class="flex items-start justify-between">
<h3 class="font-semibold flex items-center gap-2">
<i class="fa-solid fa-arrows-rotate text-indigo-400"></i> Sync Git History
</h3>
<button @click="syncModal=false" class="text-gray-500 hover:text-white">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<p class="text-sm text-gray-400">
Kør i terminal på din Mac for at synkronisere
<code class="text-indigo-400">~/.githistory</code>:
</p>
<div class="bg-gray-950 rounded-lg p-3 text-xs font-mono text-green-400 overflow-x-auto select-all">
cat ~/.githistory | curl -s -X POST https://dash.i80.dk/api/v1/worklog/sync -H "Content-Type: text/plain" --data-binary @-
</div>
<div class="text-xs text-gray-500">— eller paste indhold af <code>~/.githistory</code> direkte:</div>
<textarea x-model="pasteContent" rows="6" placeholder="Paste ~/.githistory content here…"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-xs font-mono
text-gray-300 outline-none focus:border-indigo-500 resize-none"></textarea>
<div class="flex items-center gap-3 justify-end">
<span class="text-xs" :class="syncMsg.includes('Error')?'text-red-400':'text-green-400'"
x-text="syncMsg" x-show="syncMsg"></span>
<button @click="syncPaste()" :disabled="!pasteContent.trim()||syncing"
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white
text-sm rounded-lg transition-colors flex items-center gap-1.5">
<i class="fa-solid fa-upload"></i> Upload
</button>
</div>
</div>
</div>
</template>
</div>
</div><!-- /worklog -->
<!-- ═══════════════════════════════════════ PROJECTS ═══ -->
<div class="flex-1 overflow-hidden flex" x-show="$store.ui.tab==='projects'" x-data="projectsTab()">
<!-- Context sidebar -->
<aside class="w-48 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0 p-3 gap-1">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Context</span>
<template x-for="c in [
{id:'all', icon:'fa-globe', label:'All'},
{id:'egmont', icon:'fa-building', label:'Egmont'},
{id:'personal', icon:'fa-house', label:'Personal'},
{id:'private', icon:'fa-lock', label:'Private'}
]" :key="c.id">
<button @click="context=c.id; filter()"
class="text-left px-3 py-1.5 rounded text-sm transition-colors flex items-center gap-2"
:class="context===c.id ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800'">
<i class="fa-solid" :class="c.icon"></i>
<span x-text="c.label" class="flex-1"></span>
<span class="text-xs text-gray-600"
x-text="c.id==='all' ? projects.length : projects.filter(p=>p.context===c.id).length || ''"></span>
</button>
</template>
<hr class="border-gray-800 my-1"/>
<label class="flex items-center gap-2 text-xs text-gray-400 cursor-pointer px-1">
<input type="checkbox" x-model="withContextOnly" @change="filter()" class="accent-indigo-500"/>
With work context
</label>
</aside>
<!-- Project list + detail -->
<div class="flex flex-1 overflow-hidden">
<div class="w-72 overflow-y-auto border-r border-gray-800 p-3 space-y-1.5 shrink-0">
<input x-model="q" @input="filter()" placeholder="Search projects, tags…"
class="w-full bg-gray-800 border border-gray-700 rounded px-2.5 py-1.5 text-sm text-gray-300
placeholder-gray-600 outline-none focus:border-indigo-500 mb-1"/>
<div class="text-xs text-gray-600" x-text="filtered.length+' projects'"></div>
<template x-for="p in filtered" :key="p.path||p.name">
<button @click="selected=p"
class="w-full text-left p-2.5 rounded-lg transition-colors border"
:class="selected?.path===p.path && selected?.name===p.name
? 'bg-indigo-600/15 border-indigo-500/40'
: 'hover:bg-gray-900 border-transparent'">
<div class="flex items-center gap-2 mb-0.5">
<span class="font-medium text-sm text-gray-100 flex-1 truncate" x-text="p.name"></span>
<span class="text-xs rounded-full px-1.5 py-0.5 shrink-0"
:class="{
'bg-blue-900/50 text-blue-400': p.context==='egmont',
'bg-green-900/50 text-green-400':p.context==='personal',
'bg-gray-800 text-gray-500': p.context==='private'
}"
x-text="p.context"></span>
</div>
<template x-if="p.work_context">
<div class="text-xs text-gray-600 truncate" x-text="'↳ '+p.work_context.what"></div>
</template>
<div class="flex flex-wrap gap-1 mt-1" x-show="(p.tags||[]).length">
<template x-for="tag in (p.tags||[]).slice(0,3)" :key="tag">
<span class="bg-gray-800 text-gray-500 rounded px-1.5 text-xs" x-text="tag"></span>
</template>
</div>
</button>
</template>
</div>
<!-- Project detail -->
<div class="flex-1 overflow-y-auto p-5">
<template x-if="!selected">
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
<div class="text-center">
<i class="fa-solid fa-folder-open text-3xl mb-2 block"></i>
Select a project
</div>
</div>
</template>
<template x-if="selected">
<div class="max-w-xl space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-xl font-bold text-gray-100" x-text="selected.name"></h2>
<div class="text-xs text-gray-600 mt-0.5 font-mono" x-text="selected.path"></div>
</div>
<span class="text-sm rounded-full px-2.5 py-1 shrink-0"
:class="{
'bg-blue-900/50 text-blue-400': selected.context==='egmont',
'bg-green-900/50 text-green-400':selected.context==='personal',
'bg-gray-800 text-gray-400': selected.context==='private'
}"
x-text="selected.context"></span>
</div>
<template x-if="selected.remote">
<a :href="selected.remote.replace(/^git@([^:]+):/,'https://$1/').replace(/\.git$/,'')"
target="_blank" class="text-indigo-400 text-xs hover:underline truncate block"
x-text="selected.remote"></a>
</template>
<template x-if="selected.work_context">
<div class="bg-gray-900 border border-indigo-500/20 rounded-xl p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-indigo-400 text-sm font-semibold">
<i class="fa-solid fa-brain mr-1"></i>Last worked on
</span>
<span class="text-xs text-gray-600"
x-text="new Date(selected.work_context.saved_at||'').toLocaleDateString('da-DK',{day:'numeric',month:'short'})"></span>
</div>
<div class="text-sm text-gray-300 leading-relaxed" x-text="selected.work_context.what"></div>
<template x-if="selected.work_context.next_steps">
<div class="mt-3 pt-3 border-t border-gray-800">
<div class="text-xs text-gray-500 mb-1">Next steps</div>
<div class="text-sm text-gray-400" x-text="selected.work_context.next_steps"></div>
</div>
</template>
</div>
</template>
<template x-if="(selected.tags||[]).length">
<div>
<div class="text-xs text-gray-500 mb-1">Tags</div>
<div class="flex flex-wrap gap-1.5">
<template x-for="tag in selected.tags" :key="tag">
<span class="bg-indigo-900/30 text-indigo-400 rounded-full px-2.5 py-0.5 text-xs" x-text="tag"></span>
</template>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</div><!-- /projects -->
<!-- ═══════════════════════════════════════ KNOWLEDGE ═══ -->
<div class="flex-1 overflow-hidden flex flex-col" x-show="$store.ui.tab==='knowledge'" x-data="knowledgeTab()">
<!-- Sub-tab bar -->
<div class="bg-gray-900 border-b border-gray-800 px-4 flex items-center gap-0.5 pt-2 shrink-0">
<template x-for="t in [
{id:'docs', icon:'fa-file-lines', label:'Docs'},
{id:'howtos', icon:'fa-book', label:'HOWTOs'},
{id:'agents', icon:'fa-robot', label:'Agents'},
{id:'skills', icon:'fa-wand-magic-sparkles', label:'Skills'},
{id:'adrs', icon:'fa-clipboard', label:'ADRs'},
{id:'memories', icon:'fa-floppy-disk', label:'Memories'}
]" :key="t.id">
<button @click="subTab=t.id; loadList()"
class="px-3 py-1.5 text-sm rounded-t font-medium transition-colors border-b-2 flex items-center gap-1.5 -mb-px"
:class="subTab===t.id
? 'border-indigo-500 text-white bg-gray-800/60'
: 'border-transparent text-gray-500 hover:text-gray-300'">
<i class="fa-solid" :class="t.icon"></i>
<span class="hidden sm:inline" x-text="t.label"></span>
</button>
</template>
<input x-model="q" @input="filterList()" placeholder="Search…"
class="ml-auto mb-1.5 bg-gray-800 border border-gray-700 rounded px-2.5 py-1 text-xs
text-gray-300 placeholder-gray-600 outline-none focus:border-indigo-500 w-44"/>
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Item list -->
<div class="w-72 border-r border-gray-800 overflow-y-auto p-2 space-y-1 shrink-0">
<div class="text-xs text-gray-600 px-2 py-1" x-text="filteredItems.length+' items'"></div>
<template x-if="loading">
<div class="text-gray-600 text-xs text-center py-6">
<i class="fa-solid fa-spinner fa-spin block mb-1 text-lg"></i>Loading…
</div>
</template>
<template x-if="!loading && filteredItems.length===0">
<div class="text-gray-700 text-xs text-center py-6">No items</div>
</template>
<template x-for="item in filteredItems" :key="item.id||item.storage_filename||item.adr_id||item.memory_id||item.name||item.filename||item.title">
<button @click="select(item)"
class="w-full text-left px-3 py-2 rounded-lg transition-colors text-sm border"
:class="isSelected(item) ? 'bg-indigo-600/20 border-indigo-500/40 text-white' : 'hover:bg-gray-900 text-gray-300 border-transparent'">
<div class="font-medium truncate text-sm"
x-text="item.title||item.name||item.storage_filename||item.filename"></div>
<template x-if="item.category||item.entity_type||item.description">
<div class="text-xs text-gray-600 mt-0.5 truncate"
x-text="item.description||item.category||item.entity_type||''"></div>
</template>
<div class="flex flex-wrap gap-1 mt-1" x-show="(item.tags||[]).length">
<template x-for="tag in (item.tags||[]).slice(0,3)" :key="tag">
<span class="bg-gray-800 text-gray-500 rounded px-1.5 text-xs" x-text="tag"></span>
</template>
</div>
</button>
</template>
</div>
<!-- Item detail -->
<div class="flex-1 overflow-y-auto p-5">
<template x-if="!selectedItem && !detailLoading">
<div class="flex items-center justify-center h-full text-gray-700 text-sm">
<div class="text-center">
<i class="fa-solid fa-book-open text-3xl mb-2 block"></i>
Select an item to view
</div>
</div>
</template>
<template x-if="detailLoading">
<div class="text-gray-500 text-sm"><i class="fa-solid fa-spinner fa-spin mr-2"></i>Loading content…</div>
</template>
<template x-if="selectedItem && !detailLoading">
<div>
<!-- ADR detail -->
<template x-if="subTab==='adrs'">
<div class="max-w-xl space-y-4">
<div class="flex items-start justify-between gap-3">
<h2 class="text-lg font-bold"
x-text="'ADR-'+(selectedItem.number||'?')+': '+selectedItem.title"></h2>
<span class="text-xs px-2 py-0.5 rounded-full shrink-0"
:class="{
'bg-green-900/50 text-green-400': selectedItem.status==='Accepted',
'bg-yellow-900/50 text-yellow-400':selectedItem.status==='Proposed',
'bg-gray-800 text-gray-500': !['Accepted','Proposed'].includes(selectedItem.status)
}"
x-text="selectedItem.status"></span>
</div>
<div class="bg-gray-900 rounded-xl p-4 space-y-3 text-sm">
<div>
<div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">Context</div>
<div class="text-gray-300 leading-relaxed" x-text="selectedItem.context"></div>
</div>
<div>
<div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">Decision</div>
<div class="text-gray-300 leading-relaxed" x-text="selectedItem.decision"></div>
</div>
<div>
<div class="text-xs text-gray-500 mb-1 uppercase tracking-wide">Consequences</div>
<div class="text-gray-300 leading-relaxed" x-text="selectedItem.consequences"></div>
</div>
</div>
<div class="flex flex-wrap gap-1.5" x-show="(selectedItem.tags||[]).length">
<template x-for="tag in selectedItem.tags" :key="tag">
<span class="bg-indigo-900/30 text-indigo-400 rounded-full px-2.5 py-0.5 text-xs" x-text="tag"></span>
</template>
</div>
</div>
</template>
<!-- Memories detail -->
<template x-if="subTab==='memories'">
<div class="max-w-xl space-y-3">
<h2 class="text-lg font-bold" x-text="selectedItem.name||'Memory'"></h2>
<div class="bg-gray-900 rounded-xl p-4 text-sm text-gray-300 leading-relaxed"
x-text="selectedItem.content"></div>
<div class="text-xs text-gray-600">
<span x-text="selectedItem.entity_type"></span>
<span class="mx-1">·</span>
<span x-text="new Date(selectedItem.created_at||'').toLocaleDateString('da-DK')"></span>
</div>
</div>
</template>
<!-- Docs / HOWTOs / Agents / Skills: rendered markdown -->
<template x-if="['docs','howtos','agents','skills'].includes(subTab)">
<div>
<h2 class="text-base font-bold mb-3 text-gray-100"
x-text="selectedItem.title||selectedItem.name||selectedItem.storage_filename||selectedItem.filename"></h2>
<div class="prose-dark text-sm leading-relaxed"
x-html="renderMd(extractContent(detail))"></div>
</div>
</template>
</div>
</template>
</div>
</div>
</div><!-- /knowledge -->
</div><!-- /main flex -->
<script>
document.addEventListener('alpine:init', () => {
// ── Stores ──────────────────────────────────────────────────────────────────
Alpine.store('ui', { tab: 'taskz' });
Alpine.store('health', { ok: false });
Alpine.store('status', { msg: '' });
Alpine.store('boards', {
list: [],
taskModal: null,
async init() {
const r = await fetch('/api/v1/boards').then(r => r.json()).catch(() => ({ boards: [] }));
this.list = r.boards || [];
},
async loadBoard(id) {
const idx = this.list.findIndex(b => b.board_id === id);
if (idx < 0) return;
const r = await fetch(`/api/v1/boards/${id}`).then(r => r.json()).catch(() => ({}));
this.list[idx] = { ...this.list[idx], ...r };
},
async updateTaskStatus(task, status) {
const res = await fetch(`/api/v1/tasks/${task.task_id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (res.ok) {
task.status = status;
await this.loadBoard(task.board_id);
}
}
});
fetch('/health').then(r => r.json()).then(d => {
Alpine.store('health').ok = d.redis === 'ok';
}).catch(() => {});
Alpine.store('boards').init();
});
// ── Taskz sidebar ─────────────────────────────────────────────────────────────
function taskzSidebar() {
return {
q: '',
showArchived: false,
openProjects: [],
activeBoard: null,
get filteredProjects() {
let boards = this.$store.boards.list;
if (!this.showArchived) boards = boards.filter(b => b.status !== 'archived');
if (this.q) {
const ql = this.q.toLowerCase();
boards = boards.filter(b => (b.title + (b.project || '')).toLowerCase().includes(ql));
}
const map = {};
for (const b of boards) {
const proj = b.project || 'Uncategorized';
if (!map[proj]) map[proj] = [];
map[proj].push(b);
}
return Object.entries(map)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, boards]) => ({ name, boards }));
},
init() {
this.$watch('$store.boards.list', (list) => {
if (this.openProjects.length === 0 && list.length > 0) {
const projects = [...new Set(list.map(b => b.project || 'Uncategorized'))].sort();
this.openProjects = projects.slice(0, 2);
}
});
},
toggleProject(name) {
if (this.openProjects.includes(name)) {
this.openProjects = this.openProjects.filter(p => p !== name);
} else {
this.openProjects = [...this.openProjects, name];
}
},
selectAll() {
this.activeBoard = null;
this.$dispatch('taskz-select', { type: 'all' });
},
selectBoard(id) {
this.activeBoard = id;
this.$dispatch('taskz-select', { type: 'board', id });
}
};
}
// ── Taskz main ────────────────────────────────────────────────────────────────
function taskzMain() {
return {
tasks: [],
filteredTasks: [],
loading: false,
taskQ: '',
statusFilter: [],
activeBoard: null,
showBoardLabel: false,
get headerTitle() {
if (!this.activeBoard) return 'All Boards';
const b = this.$store.boards.list.find(b => b.board_id === this.activeBoard);
return b ? b.title : 'Board';
},
get headerSub() {
if (!this.activeBoard) {
return this.tasks.length + ' tasks across active boards';
}
const b = this.$store.boards.list.find(b => b.board_id === this.activeBoard);
if (!b) return '';
const done = this.tasks.filter(t => t.status === 'done').length;
return (b.project || '') + ' · ' + done + '/' + this.tasks.length + ' done';
},
async init() {
window.addEventListener('taskz-select', async (e) => {
const { type, id } = e.detail;
this.activeBoard = type === 'board' ? id : null;
this.showBoardLabel = type !== 'board';
await this.loadTasks();
});
},
async loadTasks() {
this.loading = true;
this.tasks = [];
const order = { pending: 0, in_progress: 1, blocked: 2, done: 3 };
if (this.activeBoard) {
const b = this.$store.boards.list.find(b => b.board_id === this.activeBoard);
if (!b || !b.tasks) await this.$store.boards.loadBoard(this.activeBoard);
const board = this.$store.boards.list.find(b => b.board_id === this.activeBoard);
this.tasks = (board?.tasks || []).map(t => ({
...t, _boardTitle: board.title, board_id: board.board_id
})).sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9));
} else {
const all = [];
const activeBoards = this.$store.boards.list.filter(b => b.status !== 'archived');
for (const b of activeBoards) {
if (!b.tasks) await this.$store.boards.loadBoard(b.board_id);
const board = this.$store.boards.list.find(bd => bd.board_id === b.board_id);
for (const t of (board?.tasks || [])) {
all.push({ ...t, _boardTitle: board.title, board_id: board.board_id });
}
}
this.tasks = all.sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9));
}
this.loading = false;
this.filterTasks();
},
toggleStatusFilter(s) {
if (this.statusFilter.includes(s)) {
this.statusFilter = this.statusFilter.filter(x => x !== s);
} else {
this.statusFilter = [...this.statusFilter, s];
}
this.filterTasks();
},
filterTasks() {
let ts = this.tasks;
if (this.statusFilter.length) ts = ts.filter(t => this.statusFilter.includes(t.status));
if (this.taskQ) {
const ql = this.taskQ.toLowerCase();
ts = ts.filter(t =>
(t.title + (t.description || '') + (t.tags || []).join(' ')).toLowerCase().includes(ql)
);
}
this.filteredTasks = ts;
},
async cycleStatus(task) {
const order = ['pending', 'in_progress', 'done', 'blocked'];
const next = order[(order.indexOf(task.status) + 1) % order.length];
await this.$store.boards.updateTaskStatus(task, next);
this.filterTasks();
},
openModal(task) {
this.$store.boards.taskModal = JSON.parse(JSON.stringify(task));
}
};
}
// ── Task modal ────────────────────────────────────────────────────────────────
function taskModalData() {
return {
edit: {},
newFinding: '',
newTag: '',
saving: false,
saveMsg: '',
errMsg: '',
init() {
const t = this.$store.boards.taskModal;
this.edit = {
title: t.title || '',
description: t.description || '',
status: t.status || 'pending',
priority: t.priority || '',
findings: JSON.parse(JSON.stringify(t.findings || [])),
tags: [...(t.tags || [])],
};
},
async addFinding() {
if (!this.newFinding.trim()) return;
// PATCH appends finding server-side; update local copy optimistically
const text = this.newFinding.trim();
const res = await fetch(`/api/v1/tasks/${this.$store.boards.taskModal.task_id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ findings: text })
});
if (res.ok) {
this.edit.findings = [...this.edit.findings, { text, at: new Date().toISOString() }];
this.newFinding = '';
}
},
addTag() {
if (!this.newTag.trim()) return;
this.edit.tags = [...new Set([...this.edit.tags, this.newTag.trim()])];
this.newTag = '';
},
removeTag(tag) {
this.edit.tags = this.edit.tags.filter(t => t !== tag);
},
async saveAll() {
this.saving = true;
this.saveMsg = '';
this.errMsg = '';
const task = this.$store.boards.taskModal;
const res = await fetch(`/api/v1/tasks/${task.task_id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: this.edit.title,
description: this.edit.description,
status: this.edit.status,
priority: this.edit.priority,
tags: this.edit.tags
})
});
if (res.ok) {
// Update in store
const board = this.$store.boards.list.find(b => b.board_id === task.board_id);
if (board?.tasks) {
const idx = board.tasks.findIndex(t => t.task_id === task.task_id);
if (idx >= 0) Object.assign(board.tasks[idx], this.edit);
}
await this.$store.boards.loadBoard(task.board_id);
this.saveMsg = 'Saved';
setTimeout(() => { this.$store.boards.taskModal = null; }, 600);
} else {
this.errMsg = 'Save failed';
}
this.saving = false;
}
};
}
// ── Worklog ───────────────────────────────────────────────────────────────────
function worklogTab() {
return {
context: 'egmont',
days: 7,
data: null,
standup: null,
loading: false,
syncModal: false,
pasteContent: '',
syncing: false,
syncMsg: '',
async init() { await this.load(); },
async load() {
this.loading = true;
this.standup = null;
try {
const r = await fetch(`/api/v1/worklog?context=${this.context}&days=${this.days}`).then(r => r.json());
this.data = r;
} catch (e) { this.data = null; }
this.loading = false;
},
async loadStandup() {
this.loading = true;
this.data = null;
try {
const r = await fetch(`/api/v1/standup?days=${this.days}&context=${this.context}`).then(r => r.json());
this.standup = r;
} catch (e) { this.standup = null; }
this.loading = false;
},
async syncPaste() {
if (!this.pasteContent.trim()) return;
this.syncing = true;
this.syncMsg = 'Uploading…';
try {
const r = await fetch('/api/v1/worklog/sync', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: this.pasteContent
});
const d = await r.json();
this.syncMsg = d.message || 'Synced!';
this.pasteContent = '';
setTimeout(() => { this.syncModal = false; this.load(); }, 1500);
} catch (e) {
this.syncMsg = 'Error: ' + e.message;
}
this.syncing = false;
},
renderMd(text) { return marked.parse(String(text || '')); },
renderWorklog(data) {
if (!data) return '';
let html = '';
if (data.summary) html += `<div class="mb-4">${marked.parse(data.summary)}</div>`;
const byRepo = data.by_repo || data.commits_by_repo || {};
for (const [repo, commits] of Object.entries(byRepo)) {
html += `<div class="mb-4">
<div class="text-indigo-400 font-semibold text-sm mb-1.5">
<i class="fa-solid fa-box mr-1"></i>${repo}
</div><ul class="space-y-0.5 ml-4">`;
for (const c of (commits || [])) {
const sha = (c.sha || '').slice(0, 7);
const msg = c.message || String(c);
html += `<li class="text-xs text-gray-400">
<span class="text-gray-700 mr-2 font-mono">${sha}</span>${msg}</li>`;
}
html += '</ul></div>';
}
if (!html) html = `<pre class="text-xs text-gray-500 overflow-x-auto">${JSON.stringify(data, null, 2)}</pre>`;
return html;
}
};
}
// ── Projects ──────────────────────────────────────────────────────────────────
function projectsTab() {
return {
projects: [],
filtered: [],
context: 'all',
q: '',
selected: null,
withContextOnly: false,
async init() {
const r = await fetch('/api/v1/projects').then(r => r.json()).catch(() => ({ projects: [] }));
this.projects = r.projects || [];
this.filter();
},
filter() {
let ps = this.projects;
if (this.context !== 'all') ps = ps.filter(p => p.context === this.context);
if (this.withContextOnly) ps = ps.filter(p => p.work_context);
if (this.q) {
const ql = this.q.toLowerCase();
ps = ps.filter(p => (p.name + p.path + (p.tags || []).join(' ')).toLowerCase().includes(ql));
}
this.filtered = ps;
}
};
}
// ── Knowledge ─────────────────────────────────────────────────────────────────
function knowledgeTab() {
return {
subTab: 'docs',
items: [],
filteredItems: [],
q: '',
selectedItem: null,
detail: null,
loading: false,
detailLoading: false,
async init() { await this.loadList(); },
async loadList() {
this.loading = true;
this.selectedItem = null;
this.detail = null;
try {
const urls = {
docs: '/api/v1/knowledge',
howtos: '/api/v1/howtos',
agents: '/api/v1/agents',
skills: '/api/v1/skills',
adrs: '/api/v1/adrs',
memories: '/api/v1/memories',
};
const r = await fetch(urls[this.subTab]).then(r => r.json());
this.items =
r.documents ||
r.entries ||
r.adrs ||
r.memories ||
r.available_agents ||
r.available_skills ||
r.agents ||
r.skills ||
(Array.isArray(r.files) ? r.files : null) ||
r.howtos ||
[];
} catch (e) { this.items = []; }
this.loading = false;
this.filterList();
},
filterList() {
if (!this.q) { this.filteredItems = this.items; return; }
const ql = this.q.toLowerCase();
this.filteredItems = this.items.filter(i =>
((i.title || i.name || i.storage_filename || i.filename || '') + ' ' +
(i.category || i.entity_type || i.description || '') + ' ' +
(i.tags || []).join(' ')).toLowerCase().includes(ql)
);
},
isSelected(item) {
if (!this.selectedItem) return false;
return (this.selectedItem.storage_filename && this.selectedItem.storage_filename === item.storage_filename) ||
(this.selectedItem.adr_id && this.selectedItem.adr_id === item.adr_id) ||
(this.selectedItem.memory_id && this.selectedItem.memory_id === item.memory_id) ||
(this.selectedItem.name && this.selectedItem.name === item.name && this.selectedItem.name) ||
(this.selectedItem.filename && this.selectedItem.filename === item.filename);
},
async select(item) {
this.selectedItem = item;
this.detail = null;
if (['docs', 'howtos', 'agents', 'skills'].includes(this.subTab)) {
this.detailLoading = true;
try {
let url;
if (this.subTab === 'docs') url = `/api/v1/knowledge/${encodeURIComponent(item.storage_filename)}`;
else if (this.subTab === 'howtos') url = `/api/v1/howtos/${encodeURIComponent(item.filename || item.name)}`;
else if (this.subTab === 'agents') url = `/api/v1/agents/${encodeURIComponent(item.name)}`;
else if (this.subTab === 'skills') url = `/api/v1/skills/${encodeURIComponent(item.name)}`;
if (url) this.detail = await fetch(url).then(r => r.text());
} catch (e) { this.detail = null; }
this.detailLoading = false;
}
},
renderMd(text) { return marked.parse(String(text || '')); },
extractContent(raw) {
if (!raw) return '';
try {
const j = JSON.parse(raw);
if (j.agents && j.agents[0]) return j.agents[0].content || JSON.stringify(j, null, 2);
if (j.skills && j.skills[0]) return j.skills[0].content || JSON.stringify(j, null, 2);
return j.content || j.raw || j.definition || j.text || JSON.stringify(j, null, 2);
} catch { return raw; }
}
};
}
</script>
</body>
</html>