Add global web nav with project groups and tree sidebar.
Restructure the UI into top-level tabs and a scrollable project tree, and persist optional project groups so demos and settings can organize many projects. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+440
-270
@@ -5,8 +5,11 @@
|
||||
let user = $state(null)
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
let view = $state('dashboard')
|
||||
let topTab = $state('dashboard') // dashboard | projects | users | settings
|
||||
let projectView = $state('') // '' | board | ticket | report | members | settings
|
||||
let projects = $state([])
|
||||
let groups = $state([])
|
||||
let collapsedGroups = $state({})
|
||||
let projectId = $state('')
|
||||
let projectName = $state('')
|
||||
let tickets = $state([])
|
||||
@@ -23,10 +26,13 @@
|
||||
let tokens = $state([])
|
||||
let newTokenSecret = $state('')
|
||||
let renameName = $state('')
|
||||
let editGroupId = $state('')
|
||||
|
||||
let loginUser = $state('')
|
||||
let loginPass = $state('')
|
||||
let newProjectName = $state('')
|
||||
let newProjectGroupId = $state('')
|
||||
let newGroupName = $state('')
|
||||
let newTicketTitle = $state('')
|
||||
let editTitle = $state('')
|
||||
let editDesc = $state('')
|
||||
@@ -75,10 +81,45 @@
|
||||
await api.logout()
|
||||
user = null
|
||||
projects = []
|
||||
groups = []
|
||||
}
|
||||
|
||||
async function refreshProjects() {
|
||||
projects = await api.listProjects()
|
||||
;[projects, groups] = await Promise.all([api.listProjects(), api.listGroups()])
|
||||
}
|
||||
|
||||
function projectsInGroup(groupId) {
|
||||
return projects.filter((p) => (p.groupId || '') === groupId)
|
||||
}
|
||||
|
||||
function ungroupedProjects() {
|
||||
const ids = new Set(groups.map((g) => g.id))
|
||||
return projects.filter((p) => !p.groupId || !ids.has(p.groupId))
|
||||
}
|
||||
|
||||
function toggleGroup(id) {
|
||||
collapsedGroups = { ...collapsedGroups, [id]: !collapsedGroups[id] }
|
||||
}
|
||||
|
||||
async function createGroup() {
|
||||
error = ''
|
||||
const name = newGroupName.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
await api.createGroup(name)
|
||||
newGroupName = ''
|
||||
await refreshProjects()
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup(id) {
|
||||
if (!confirm('Gruppe löschen? Projekte werden ungeordnet.')) return
|
||||
await api.deleteGroup(id)
|
||||
if (newProjectGroupId === id) newProjectGroupId = ''
|
||||
if (editGroupId === id) editGroupId = ''
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function refreshTimer() {
|
||||
@@ -87,16 +128,27 @@
|
||||
}
|
||||
|
||||
async function openDashboard() {
|
||||
view = 'dashboard'
|
||||
topTab = 'dashboard'
|
||||
projectId = ''
|
||||
projectView = ''
|
||||
selected = null
|
||||
dash = await api.dashboard()
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function openProjects() {
|
||||
topTab = 'projects'
|
||||
error = ''
|
||||
msg = ''
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function openProject(id, name = '') {
|
||||
topTab = 'projects'
|
||||
projectId = id
|
||||
projectName = name || projects.find((p) => p.id === id)?.name || id
|
||||
view = 'board'
|
||||
projectView = 'board'
|
||||
selected = null
|
||||
error = ''
|
||||
tickets = await api.listTickets(id)
|
||||
}
|
||||
@@ -109,9 +161,10 @@
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.createProject(name)
|
||||
const p = await api.createProject(name, newProjectGroupId)
|
||||
newProjectName = ''
|
||||
await openDashboard()
|
||||
await refreshProjects()
|
||||
await openProject(p.id, p.name)
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
@@ -133,7 +186,8 @@
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === t.id)
|
||||
await refreshTimer()
|
||||
view = 'ticket'
|
||||
topTab = 'projects'
|
||||
projectView = 'ticket'
|
||||
}
|
||||
|
||||
async function saveTicket() {
|
||||
@@ -151,7 +205,7 @@
|
||||
if (!confirm('Delete ticket?')) return
|
||||
await api.deleteTicket(projectId, selected.id)
|
||||
selected = null
|
||||
view = 'board'
|
||||
projectView = 'board'
|
||||
tickets = await api.listTickets(projectId)
|
||||
}
|
||||
|
||||
@@ -190,7 +244,8 @@
|
||||
}
|
||||
|
||||
async function openReport() {
|
||||
view = 'report'
|
||||
topTab = 'projects'
|
||||
projectView = 'report'
|
||||
error = ''
|
||||
report = await api.report(projectId, reportGroupBy)
|
||||
}
|
||||
@@ -200,11 +255,27 @@
|
||||
report = await api.report(projectId, reportGroupBy)
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
view = 'settings'
|
||||
async function openProjectSettings() {
|
||||
topTab = 'projects'
|
||||
projectView = 'settings'
|
||||
renameName = projectName
|
||||
editGroupId = projects.find((p) => p.id === projectId)?.groupId || ''
|
||||
newTokenSecret = ''
|
||||
tokens = await api.listTokens(projectId)
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function saveProjectGroup() {
|
||||
const p = await api.patchProject(projectId, { groupId: editGroupId })
|
||||
msg = 'Gruppe gespeichert'
|
||||
await refreshProjects()
|
||||
if (p.groupId !== undefined) editGroupId = p.groupId || ''
|
||||
}
|
||||
|
||||
function openGlobalSettings() {
|
||||
topTab = 'settings'
|
||||
error = ''
|
||||
msg = ''
|
||||
}
|
||||
|
||||
async function saveRename() {
|
||||
@@ -229,7 +300,9 @@
|
||||
}
|
||||
|
||||
async function openAdmin() {
|
||||
view = 'admin'
|
||||
topTab = 'users'
|
||||
error = ''
|
||||
msg = ''
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
@@ -258,7 +331,8 @@
|
||||
}
|
||||
|
||||
async function openMembers() {
|
||||
view = 'members'
|
||||
topTab = 'projects'
|
||||
projectView = 'members'
|
||||
members = await api.listMembers(projectId)
|
||||
users = await api.listUsers()
|
||||
}
|
||||
@@ -280,6 +354,11 @@
|
||||
msg = text
|
||||
}
|
||||
}
|
||||
|
||||
function sidebarActive(v) {
|
||||
if (v === 'board') return projectView === 'board' || projectView === 'ticket'
|
||||
return projectView === v
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -308,16 +387,12 @@
|
||||
<header class="topbar">
|
||||
<div class="brand">task-master</div>
|
||||
<nav class="nav">
|
||||
<button class:active={view === 'dashboard'} onclick={openDashboard}>Dashboard</button>
|
||||
{#if projectId}
|
||||
<button class:active={view === 'board'} onclick={() => openProject(projectId, projectName)}>Board</button>
|
||||
<button class:active={view === 'report'} onclick={openReport}>Report</button>
|
||||
<button class:active={view === 'members'} onclick={openMembers}>Members</button>
|
||||
<button class:active={view === 'settings'} onclick={openSettings}>Settings</button>
|
||||
{/if}
|
||||
<button class:active={topTab === 'dashboard'} onclick={openDashboard}>Dashboard</button>
|
||||
<button class:active={topTab === 'projects'} onclick={openProjects}>Projekte</button>
|
||||
{#if user.isAdmin}
|
||||
<button class:active={view === 'admin'} onclick={openAdmin}>Users</button>
|
||||
<button class:active={topTab === 'users'} onclick={openAdmin}>User</button>
|
||||
{/if}
|
||||
<button class:active={topTab === 'settings'} onclick={openGlobalSettings}>Settings</button>
|
||||
</nav>
|
||||
<div class="row">
|
||||
{#if timer}
|
||||
@@ -328,267 +403,362 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if msg}<p class="muted">{msg}</p>{/if}
|
||||
|
||||
{#if view === 'dashboard'}
|
||||
<div class="grid" style="grid-template-columns: 1.2fr 1fr; gap: 1rem;">
|
||||
<section class="panel stack">
|
||||
<h2>Projects</h2>
|
||||
{#each dash.projects as p}
|
||||
<button class="ticket" onclick={() => openProject(p.id, p.name)}>
|
||||
<strong>{p.name}</strong>
|
||||
<div class="muted">{p.openCount} open · {formatDuration(p.durationNs)}</div>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">No projects yet.</p>
|
||||
{/each}
|
||||
<h3>New project</h3>
|
||||
<div class="row">
|
||||
<input placeholder="Project name" bind:value={newProjectName} />
|
||||
<button class="primary" onclick={createProject}>Create</button>
|
||||
<div class="app-body" class:with-sidebar={topTab === 'projects'}>
|
||||
{#if topTab === 'projects'}
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-section sidebar-projects">
|
||||
<p class="sidebar-label">Projekte</p>
|
||||
<div class="sidebar-project-list">
|
||||
{#each groups as g}
|
||||
<button type="button" class="sidebar-group" onclick={() => toggleGroup(g.id)}>
|
||||
<span class="sidebar-twist">{collapsedGroups[g.id] ? '▸' : '▾'}</span>
|
||||
<span class="sidebar-group-name">{g.name}</span>
|
||||
<span class="muted">{projectsInGroup(g.id).length}</span>
|
||||
</button>
|
||||
{#if !collapsedGroups[g.id]}
|
||||
{#each projectsInGroup(g.id) as p}
|
||||
<button
|
||||
class="sidebar-item sidebar-child"
|
||||
class:active={projectId === p.id}
|
||||
onclick={() => openProject(p.id, p.name)}
|
||||
>{p.name}</button>
|
||||
{:else}
|
||||
<p class="muted sidebar-child">leer</p>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if ungroupedProjects().length}
|
||||
{#if groups.length}
|
||||
<p class="sidebar-label sidebar-ungrouped-label">Ohne Gruppe</p>
|
||||
{/if}
|
||||
{#each ungroupedProjects() as p}
|
||||
<button
|
||||
class="sidebar-item"
|
||||
class:active={projectId === p.id}
|
||||
onclick={() => openProject(p.id, p.name)}
|
||||
>{p.name}</button>
|
||||
{/each}
|
||||
{:else if !groups.length && !projects.length}
|
||||
<p class="muted">Noch keine Projekte.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sidebar-create stack">
|
||||
<input placeholder="Neues Projekt" bind:value={newProjectName} />
|
||||
{#if groups.length}
|
||||
<select bind:value={newProjectGroupId}>
|
||||
<option value="">Ohne Gruppe</option>
|
||||
{#each groups as g}<option value={g.id}>{g.name}</option>{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button class="primary" onclick={createProject}>Anlegen</button>
|
||||
<div class="row" style="margin-top: 0.35rem;">
|
||||
<input placeholder="Neue Gruppe" bind:value={newGroupName} />
|
||||
<button onclick={createGroup}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="stack">
|
||||
{#if projectId}
|
||||
<div class="sidebar-section sidebar-subnav">
|
||||
<p class="sidebar-label">{projectName}</p>
|
||||
<button class="sidebar-item" class:active={sidebarActive('board')} onclick={() => openProject(projectId, projectName)}>Board</button>
|
||||
<button class="sidebar-item" class:active={sidebarActive('report')} onclick={openReport}>Report</button>
|
||||
<button class="sidebar-item" class:active={sidebarActive('members')} onclick={openMembers}>Members</button>
|
||||
<button class="sidebar-item" class:active={sidebarActive('settings')} onclick={openProjectSettings}>Settings</button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<main class="content">
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if msg}<p class="muted">{msg}</p>{/if}
|
||||
|
||||
{#if topTab === 'dashboard'}
|
||||
<div class="grid" style="grid-template-columns: 1.2fr 1fr; gap: 1rem;">
|
||||
<section class="panel stack">
|
||||
<h2>Projects</h2>
|
||||
{#each dash.projects as p}
|
||||
<button class="ticket" onclick={() => openProject(p.id, p.name)}>
|
||||
<strong>{p.name}</strong>
|
||||
<div class="muted">{p.openCount} open · {formatDuration(p.durationNs)}</div>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">No projects yet.</p>
|
||||
{/each}
|
||||
<button onclick={openProjects}>Alle Projekte →</button>
|
||||
</section>
|
||||
<div class="stack">
|
||||
<section class="panel stack">
|
||||
<h2>Open tickets</h2>
|
||||
{#each dash.openTickets as t}
|
||||
<button class="ticket" onclick={async () => { await openProject(t.projectId, t.projectName); const full = (await api.listTickets(t.projectId)).find(x => x.id === t.id); if (full) openTicket(full) }}>
|
||||
<span class="badge {t.status}">{t.status}</span>
|
||||
<div>{t.title}</div>
|
||||
<div class="muted">{t.projectName}</div>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">Nothing open.</p>
|
||||
{/each}
|
||||
</section>
|
||||
<section class="panel stack">
|
||||
<h2>Time by project</h2>
|
||||
<table>
|
||||
<thead><tr><th>Project</th><th>Time</th></tr></thead>
|
||||
<tbody>
|
||||
{#each dash.projects as p}
|
||||
<tr><td>{p.name}</td><td>{formatDuration(p.durationNs)}</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{:else if topTab === 'projects' && !projectId}
|
||||
<section class="panel stack">
|
||||
<h2>Open tickets</h2>
|
||||
{#each dash.openTickets as t}
|
||||
<button class="ticket" onclick={async () => { await openProject(t.projectId, t.projectName); const full = (await api.listTickets(t.projectId)).find(x => x.id === t.id); if (full) openTicket(full) }}>
|
||||
<span class="badge {t.status}">{t.status}</span>
|
||||
<div>{t.title}</div>
|
||||
<div class="muted">{t.projectName}</div>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="muted">Nothing open.</p>
|
||||
{/each}
|
||||
<h2>Projekte</h2>
|
||||
<p class="muted">Wähle links ein Projekt oder lege ein neues an.</p>
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'board'}
|
||||
<section class="panel stack">
|
||||
<h2>Time by project</h2>
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<h2>{projectName}</h2>
|
||||
<div class="row">
|
||||
<input placeholder="New ticket title" bind:value={newTicketTitle} style="width: 240px;" />
|
||||
<button class="primary" onclick={createTicket}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid cols-3">
|
||||
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
|
||||
<div class="stack">
|
||||
<h3>{label}</h3>
|
||||
{#each byStatus(st) as t}
|
||||
<button class="ticket" onclick={() => openTicket(t)}>
|
||||
<span class="badge {t.status}">{label}</span>
|
||||
<div>{t.title}</div>
|
||||
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
|
||||
<section class="panel stack">
|
||||
<div class="row">
|
||||
<button onclick={() => (projectView = 'board')}>← Board</button>
|
||||
<button class="danger" onclick={removeTicket}>Delete</button>
|
||||
</div>
|
||||
<label>Title<input bind:value={editTitle} /></label>
|
||||
<label>Status
|
||||
<select bind:value={editStatus}>
|
||||
<option value="todo">OPEN</option>
|
||||
<option value="in_progress">WIP</option>
|
||||
<option value="done">DONE</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Description<textarea bind:value={editDesc}></textarea></label>
|
||||
<button class="primary" onclick={saveTicket}>Save</button>
|
||||
|
||||
<h3>Time</h3>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={toggleTimer}>
|
||||
{timer && timer.ticketId === selected.id ? 'Stop timer' : 'Start timer'}
|
||||
</button>
|
||||
<label class="row" style="width: auto;">Minutes
|
||||
<input type="number" min="1" bind:value={timeMinutes} style="width: 5rem;" />
|
||||
</label>
|
||||
<button onclick={bookTime}>Book</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Project</th><th>Time</th></tr></thead>
|
||||
<thead><tr><th>When</th><th>Duration</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
{#each dash.projects as p}
|
||||
<tr><td>{p.name}</td><td>{formatDuration(p.durationNs)}</td></tr>
|
||||
{#each ticketTime as e}
|
||||
<tr>
|
||||
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
||||
<td>{formatDuration(e.duration)}</td>
|
||||
<td>{e.source}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="3" class="muted">No time yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>History</h3>
|
||||
<table>
|
||||
<thead><tr><th>Rev</th><th>Op</th><th>At</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each revisions as rev}
|
||||
<tr>
|
||||
<td>{rev.rev}</td>
|
||||
<td>{rev.op}</td>
|
||||
<td class="muted">{new Date(rev.at).toLocaleString()}</td>
|
||||
<td><button onclick={() => restoreRev(rev.id)}>Restore</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{:else if view === 'board'}
|
||||
<section class="panel stack">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<h2>{projectName}</h2>
|
||||
<div class="row">
|
||||
<input placeholder="New ticket title" bind:value={newTicketTitle} style="width: 240px;" />
|
||||
<button class="primary" onclick={createTicket}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid cols-3">
|
||||
{#each [['todo', 'OPEN'], ['in_progress', 'WIP'], ['done', 'DONE']] as [st, label]}
|
||||
<div class="stack">
|
||||
<h3>{label}</h3>
|
||||
{#each byStatus(st) as t}
|
||||
<button class="ticket" onclick={() => openTicket(t)}>
|
||||
<span class="badge {t.status}">{label}</span>
|
||||
<div>{t.title}</div>
|
||||
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if topTab === 'projects' && projectView === 'report'}
|
||||
<section class="panel stack">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<h2>Report — {projectName}</h2>
|
||||
<div class="row">
|
||||
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
|
||||
<button class:primary={reportGroupBy === g} onclick={() => changeReportGroup(g)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else if view === 'ticket' && selected}
|
||||
<section class="panel stack">
|
||||
<div class="row">
|
||||
<button onclick={() => (view = 'board')}>← Board</button>
|
||||
<button class="danger" onclick={removeTicket}>Delete</button>
|
||||
</div>
|
||||
<label>Title<input bind:value={editTitle} /></label>
|
||||
<label>Status
|
||||
<select bind:value={editStatus}>
|
||||
<option value="todo">OPEN</option>
|
||||
<option value="in_progress">WIP</option>
|
||||
<option value="done">DONE</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Description<textarea bind:value={editDesc}></textarea></label>
|
||||
<button class="primary" onclick={saveTicket}>Save</button>
|
||||
{#if report}
|
||||
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
|
||||
<table>
|
||||
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{#each report.rows as row}
|
||||
<tr><td>{row.label}</td><td>{formatDuration(row.durationNs)}</td></tr>
|
||||
{:else}
|
||||
<tr><td colspan="2" class="muted">No time logged.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'settings'}
|
||||
<section class="panel stack">
|
||||
<h2>Settings — {projectName}</h2>
|
||||
<label>Project ID
|
||||
<div class="row">
|
||||
<code style="flex:1">{projectId}</code>
|
||||
<button onclick={() => copyText(projectId)}>Copy</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>Name
|
||||
<div class="row">
|
||||
<input bind:value={renameName} />
|
||||
<button class="primary" onclick={saveRename}>Rename</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>Gruppe
|
||||
<div class="row">
|
||||
<select bind:value={editGroupId} style="width: auto; flex: 1;">
|
||||
<option value="">Ohne Gruppe</option>
|
||||
{#each groups as g}<option value={g.id}>{g.name}</option>{/each}
|
||||
</select>
|
||||
<button class="primary" onclick={saveProjectGroup}>Speichern</button>
|
||||
</div>
|
||||
</label>
|
||||
{#if groups.length}
|
||||
<div class="stack">
|
||||
<h3>Gruppen verwalten</h3>
|
||||
{#each groups as g}
|
||||
<div class="row">
|
||||
<span style="flex:1">{g.name}</span>
|
||||
<button class="danger" onclick={() => removeGroup(g.id)}>Löschen</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3>Time</h3>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={toggleTimer}>
|
||||
{timer && timer.ticketId === selected.id ? 'Stop timer' : 'Start timer'}
|
||||
</button>
|
||||
<label class="row" style="width: auto;">Minutes
|
||||
<input type="number" min="1" bind:value={timeMinutes} style="width: 5rem;" />
|
||||
</label>
|
||||
<button onclick={bookTime}>Book</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Duration</th><th>Source</th></tr></thead>
|
||||
<tbody>
|
||||
{#each ticketTime as e}
|
||||
<tr>
|
||||
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
||||
<td>{formatDuration(e.duration)}</td>
|
||||
<td>{e.source}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="3" class="muted">No time yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>History</h3>
|
||||
<table>
|
||||
<thead><tr><th>Rev</th><th>Op</th><th>At</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each revisions as rev}
|
||||
<tr>
|
||||
<td>{rev.rev}</td>
|
||||
<td>{rev.op}</td>
|
||||
<td class="muted">{new Date(rev.at).toLocaleString()}</td>
|
||||
<td><button onclick={() => restoreRev(rev.id)}>Restore</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{:else if view === 'report'}
|
||||
<section class="panel stack">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<h2>Report — {projectName}</h2>
|
||||
<div class="row">
|
||||
{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]}
|
||||
<button class:primary={reportGroupBy === g} onclick={() => changeReportGroup(g)}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if report}
|
||||
<p class="muted">{report.entryCount} entries · total {formatDuration(report.totalDurationNs)}</p>
|
||||
<table>
|
||||
<thead><tr><th>Group</th><th>Duration</th></tr></thead>
|
||||
<tbody>
|
||||
{#each report.rows as row}
|
||||
<tr><td>{row.label}</td><td>{formatDuration(row.durationNs)}</td></tr>
|
||||
{:else}
|
||||
<tr><td colspan="2" class="muted">No time logged.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>API tokens (for TUI)</h3>
|
||||
<p class="muted">Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.</p>
|
||||
{#if newTokenSecret}
|
||||
<div class="panel stack" style="border-color: var(--accent);">
|
||||
<strong>New token (shown once)</strong>
|
||||
<code>{newTokenSecret}</code>
|
||||
<button onclick={() => copyText(newTokenSecret)}>Copy token</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<input placeholder="name" bind:value={tokenName} style="width: 8rem;" />
|
||||
<select bind:value={tokenScope} style="width: auto;">
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
</select>
|
||||
<button class="primary" onclick={createToken}>Create token</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Scope</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each tokens as t}
|
||||
<tr>
|
||||
<td>{t.name}{#if t.revokedAt} <span class="muted">(revoked)</span>{/if}</td>
|
||||
<td>{t.scope}</td>
|
||||
<td class="muted">{new Date(t.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
{#if !t.revokedAt}
|
||||
<button class="danger" onclick={() => revokeToken(t.id)}>Revoke</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{:else if topTab === 'projects' && projectView === 'members'}
|
||||
<section class="panel stack">
|
||||
<h2>Members — {projectName}</h2>
|
||||
<table>
|
||||
<thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each members as m}
|
||||
<tr>
|
||||
<td>{m.username}</td>
|
||||
<td>{m.role}</td>
|
||||
<td><button class="danger" onclick={async () => { await api.removeMember(projectId, m.userId); members = await api.listMembers(projectId) }}>Remove</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="row">
|
||||
<select bind:value={memberUserId} style="width: auto;">
|
||||
<option value="">user…</option>
|
||||
{#each users as u}<option value={u.id}>{u.username}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={memberRole} style="width: auto;">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select>
|
||||
<button class="primary" onclick={addMember}>Add</button>
|
||||
</div>
|
||||
</section>
|
||||
{:else if topTab === 'users'}
|
||||
<section class="panel stack">
|
||||
<h2>Users</h2>
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>Admin</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each users as u}
|
||||
<tr>
|
||||
<td>{u.username}</td>
|
||||
<td>
|
||||
<button onclick={() => toggleAdmin(u)} disabled={u.id === user.id}>
|
||||
{u.isAdmin ? 'yes' : 'no'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{#if u.id !== user.id}
|
||||
<button class="danger" onclick={() => deleteUser(u)}>Delete</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Create user</h3>
|
||||
<input placeholder="username" bind:value={newUserName} />
|
||||
<input type="password" placeholder="password" bind:value={newUserPass} />
|
||||
<label class="row"><input type="checkbox" bind:checked={newUserAdmin} /> Admin</label>
|
||||
<button class="primary" onclick={createUser}>Create</button>
|
||||
</section>
|
||||
{:else if topTab === 'settings'}
|
||||
<section class="panel stack">
|
||||
<h2>Settings</h2>
|
||||
<p class="muted">Globale Einstellungen folgen hier später.</p>
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if view === 'settings'}
|
||||
<section class="panel stack">
|
||||
<h2>Settings — {projectName}</h2>
|
||||
<label>Project ID
|
||||
<div class="row">
|
||||
<code style="flex:1">{projectId}</code>
|
||||
<button onclick={() => copyText(projectId)}>Copy</button>
|
||||
</div>
|
||||
</label>
|
||||
<label>Name
|
||||
<div class="row">
|
||||
<input bind:value={renameName} />
|
||||
<button class="primary" onclick={saveRename}>Rename</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<h3>API tokens (for TUI)</h3>
|
||||
<p class="muted">Create a write token, then in `tm`: project edit → backend taskmaster → URL, token, this project id.</p>
|
||||
{#if newTokenSecret}
|
||||
<div class="panel stack" style="border-color: var(--accent);">
|
||||
<strong>New token (shown once)</strong>
|
||||
<code>{newTokenSecret}</code>
|
||||
<button onclick={() => copyText(newTokenSecret)}>Copy token</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="row">
|
||||
<input placeholder="name" bind:value={tokenName} style="width: 8rem;" />
|
||||
<select bind:value={tokenScope} style="width: auto;">
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
</select>
|
||||
<button class="primary" onclick={createToken}>Create token</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Scope</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each tokens as t}
|
||||
<tr>
|
||||
<td>{t.name}{#if t.revokedAt} <span class="muted">(revoked)</span>{/if}</td>
|
||||
<td>{t.scope}</td>
|
||||
<td class="muted">{new Date(t.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
{#if !t.revokedAt}
|
||||
<button class="danger" onclick={() => revokeToken(t.id)}>Revoke</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{:else if view === 'admin'}
|
||||
<section class="panel stack">
|
||||
<h2>Users</h2>
|
||||
<table>
|
||||
<thead><tr><th>Username</th><th>Admin</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each users as u}
|
||||
<tr>
|
||||
<td>{u.username}</td>
|
||||
<td>
|
||||
<button onclick={() => toggleAdmin(u)} disabled={u.id === user.id}>
|
||||
{u.isAdmin ? 'yes' : 'no'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{#if u.id !== user.id}
|
||||
<button class="danger" onclick={() => deleteUser(u)}>Delete</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>Create user</h3>
|
||||
<input placeholder="username" bind:value={newUserName} />
|
||||
<input type="password" placeholder="password" bind:value={newUserPass} />
|
||||
<label class="row"><input type="checkbox" bind:checked={newUserAdmin} /> Admin</label>
|
||||
<button class="primary" onclick={createUser}>Create</button>
|
||||
</section>
|
||||
{:else if view === 'members'}
|
||||
<section class="panel stack">
|
||||
<h2>Members — {projectName}</h2>
|
||||
<table>
|
||||
<thead><tr><th>User</th><th>Role</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each members as m}
|
||||
<tr>
|
||||
<td>{m.username}</td>
|
||||
<td>{m.role}</td>
|
||||
<td><button class="danger" onclick={async () => { await api.removeMember(projectId, m.userId); members = await api.listMembers(projectId) }}>Remove</button></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="row">
|
||||
<select bind:value={memberUserId} style="width: auto;">
|
||||
<option value="">user…</option>
|
||||
{#each users as u}<option value={u.id}>{u.username}</option>{/each}
|
||||
</select>
|
||||
<select bind:value={memberRole} style="width: auto;">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="member">member</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select>
|
||||
<button class="primary" onclick={addMember}>Add</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+135
-4
@@ -69,18 +69,23 @@ code {
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: 1100px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 0.85rem;
|
||||
margin: 0 -1.25rem 1.25rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
@@ -93,6 +98,132 @@ code {
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.app-body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.app-body.with-sidebar {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
.content { min-width: 0; }
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-self: start;
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
border-right: 1px solid var(--line);
|
||||
padding-right: 0.85rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-section {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
align-content: start;
|
||||
}
|
||||
.sidebar-projects {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.sidebar-project-list {
|
||||
--sidebar-row: 2.05rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
flex: 0 1 auto;
|
||||
max-height: calc(12 * var(--sidebar-row));
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
.sidebar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding: 0.3rem 0.5rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.sidebar-group:hover {
|
||||
background: var(--panel-2);
|
||||
border-color: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
.sidebar-twist {
|
||||
display: inline-block;
|
||||
width: 0.9rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sidebar-group-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
.sidebar-child {
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
.sidebar-ungrouped-label {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
.sidebar-subnav {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.sidebar-label {
|
||||
margin: 0.25rem 0 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sidebar-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.sidebar-item:hover {
|
||||
background: var(--panel-2);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar-create {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.app-body.with-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-right: 0;
|
||||
padding-bottom: 0.85rem;
|
||||
}
|
||||
}
|
||||
.panel {
|
||||
background: linear-gradient(180deg, var(--panel) 0%, #14191f 100%);
|
||||
border: 1px solid var(--line);
|
||||
|
||||
+12
-2
@@ -29,11 +29,21 @@ export const api = {
|
||||
dashboard: () => request('/dashboard'),
|
||||
|
||||
listProjects: () => request('/projects'),
|
||||
createProject: (name) =>
|
||||
request('/projects', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
createProject: (name, groupId = '') =>
|
||||
request('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(groupId ? { name, groupId } : { name }),
|
||||
}),
|
||||
patchProject: (id, body) =>
|
||||
request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listGroups: () => request('/project-groups'),
|
||||
createGroup: (name) =>
|
||||
request('/project-groups', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
patchGroup: (id, body) =>
|
||||
request(`/project-groups/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteGroup: (id) => request(`/project-groups/${id}`, { method: 'DELETE' }),
|
||||
listMembers: (id) => request(`/projects/${id}/members`),
|
||||
setMember: (id, userId, role) =>
|
||||
request(`/projects/${id}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||
|
||||
Reference in New Issue
Block a user