Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from './lib/api.js'
|
||||
|
||||
let user = $state(null)
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
let view = $state('dashboard')
|
||||
let projects = $state([])
|
||||
let projectId = $state('')
|
||||
let projectName = $state('')
|
||||
let tickets = $state([])
|
||||
let selected = $state(null)
|
||||
let revisions = $state([])
|
||||
let ticketTime = $state([])
|
||||
let users = $state([])
|
||||
let members = $state([])
|
||||
let timer = $state(null)
|
||||
let msg = $state('')
|
||||
let report = $state(null)
|
||||
let reportGroupBy = $state('ticket')
|
||||
let dash = $state({ projects: [], openTickets: [] })
|
||||
let tokens = $state([])
|
||||
let newTokenSecret = $state('')
|
||||
let renameName = $state('')
|
||||
|
||||
let loginUser = $state('')
|
||||
let loginPass = $state('')
|
||||
let newProjectName = $state('')
|
||||
let newTicketTitle = $state('')
|
||||
let editTitle = $state('')
|
||||
let editDesc = $state('')
|
||||
let editStatus = $state('todo')
|
||||
let newUserName = $state('')
|
||||
let newUserPass = $state('')
|
||||
let newUserAdmin = $state(false)
|
||||
let memberUserId = $state('')
|
||||
let memberRole = $state('member')
|
||||
let timeMinutes = $state(30)
|
||||
let tokenName = $state('tui')
|
||||
let tokenScope = $state('write')
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
user = await api.me()
|
||||
await openDashboard()
|
||||
await refreshTimer()
|
||||
} catch {
|
||||
user = null
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDuration(ns) {
|
||||
const totalMin = Math.max(0, Math.round(Number(ns) / 6e10))
|
||||
const h = Math.floor(totalMin / 60)
|
||||
const m = totalMin % 60
|
||||
if (h > 0 && m > 0) return `${h}h ${m}m`
|
||||
if (h > 0) return `${h}h`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
error = ''
|
||||
try {
|
||||
user = await api.login(loginUser, loginPass)
|
||||
await openDashboard()
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await api.logout()
|
||||
user = null
|
||||
projects = []
|
||||
}
|
||||
|
||||
async function refreshProjects() {
|
||||
projects = await api.listProjects()
|
||||
}
|
||||
|
||||
async function refreshTimer() {
|
||||
const data = await api.getTimer()
|
||||
timer = data.timer
|
||||
}
|
||||
|
||||
async function openDashboard() {
|
||||
view = 'dashboard'
|
||||
projectId = ''
|
||||
dash = await api.dashboard()
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function openProject(id, name = '') {
|
||||
projectId = id
|
||||
projectName = name || projects.find((p) => p.id === id)?.name || id
|
||||
view = 'board'
|
||||
error = ''
|
||||
tickets = await api.listTickets(id)
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
error = ''
|
||||
const name = newProjectName.trim()
|
||||
if (!name) {
|
||||
error = 'Name required'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.createProject(name)
|
||||
newProjectName = ''
|
||||
await openDashboard()
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function createTicket() {
|
||||
if (!newTicketTitle.trim()) return
|
||||
await api.createTicket(projectId, { title: newTicketTitle, status: 'todo' })
|
||||
newTicketTitle = ''
|
||||
tickets = await api.listTickets(projectId)
|
||||
}
|
||||
|
||||
async function openTicket(t) {
|
||||
selected = t
|
||||
editTitle = t.title
|
||||
editDesc = t.description || ''
|
||||
editStatus = t.status
|
||||
revisions = await api.listRevisions(projectId, t.id)
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === t.id)
|
||||
await refreshTimer()
|
||||
view = 'ticket'
|
||||
}
|
||||
|
||||
async function saveTicket() {
|
||||
selected = await api.patchTicket(projectId, selected.id, {
|
||||
title: editTitle,
|
||||
description: editDesc,
|
||||
status: editStatus,
|
||||
})
|
||||
revisions = await api.listRevisions(projectId, selected.id)
|
||||
tickets = await api.listTickets(projectId)
|
||||
msg = 'Saved'
|
||||
}
|
||||
|
||||
async function removeTicket() {
|
||||
if (!confirm('Delete ticket?')) return
|
||||
await api.deleteTicket(projectId, selected.id)
|
||||
selected = null
|
||||
view = 'board'
|
||||
tickets = await api.listTickets(projectId)
|
||||
}
|
||||
|
||||
async function restoreRev(rid) {
|
||||
selected = await api.restoreRevision(projectId, selected.id, rid)
|
||||
editTitle = selected.title
|
||||
editDesc = selected.description || ''
|
||||
editStatus = selected.status
|
||||
revisions = await api.listRevisions(projectId, selected.id)
|
||||
tickets = await api.listTickets(projectId)
|
||||
msg = 'Restored'
|
||||
}
|
||||
|
||||
async function bookTime() {
|
||||
if (!selected) return
|
||||
await api.createTime(projectId, {
|
||||
ticketId: selected.id,
|
||||
durationNs: timeMinutes * 60 * 1e9,
|
||||
source: 'manual',
|
||||
})
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
||||
msg = `Booked ${timeMinutes}m`
|
||||
}
|
||||
|
||||
async function toggleTimer() {
|
||||
if (!selected) return
|
||||
if (timer && timer.ticketId === selected.id) {
|
||||
await api.stopTimer()
|
||||
} else {
|
||||
await api.startTimer(selected.id, projectId)
|
||||
}
|
||||
await refreshTimer()
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
||||
}
|
||||
|
||||
async function openReport() {
|
||||
view = 'report'
|
||||
error = ''
|
||||
report = await api.report(projectId, reportGroupBy)
|
||||
}
|
||||
|
||||
async function changeReportGroup(g) {
|
||||
reportGroupBy = g
|
||||
report = await api.report(projectId, reportGroupBy)
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
view = 'settings'
|
||||
renameName = projectName
|
||||
newTokenSecret = ''
|
||||
tokens = await api.listTokens(projectId)
|
||||
}
|
||||
|
||||
async function saveRename() {
|
||||
const name = renameName.trim()
|
||||
if (!name) return
|
||||
const p = await api.patchProject(projectId, { name })
|
||||
projectName = p.name
|
||||
msg = 'Renamed'
|
||||
await refreshProjects()
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
const t = await api.createToken(projectId, { name: tokenName || 'token', scope: tokenScope })
|
||||
newTokenSecret = t.token
|
||||
tokens = await api.listTokens(projectId)
|
||||
msg = 'Token created — copy it now'
|
||||
}
|
||||
|
||||
async function revokeToken(tid) {
|
||||
await api.revokeToken(projectId, tid)
|
||||
tokens = await api.listTokens(projectId)
|
||||
}
|
||||
|
||||
async function openAdmin() {
|
||||
view = 'admin'
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
|
||||
newUserName = ''
|
||||
newUserPass = ''
|
||||
newUserAdmin = false
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function toggleAdmin(u) {
|
||||
if (u.id === user.id) {
|
||||
error = 'Cannot change your own admin flag here'
|
||||
return
|
||||
}
|
||||
await api.patchUser(u.id, { isAdmin: !u.isAdmin })
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function deleteUser(u) {
|
||||
if (u.id === user.id) return
|
||||
if (!confirm(`Delete user ${u.username}?`)) return
|
||||
await api.deleteUser(u.id)
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function openMembers() {
|
||||
view = 'members'
|
||||
members = await api.listMembers(projectId)
|
||||
users = await api.listUsers()
|
||||
}
|
||||
|
||||
async function addMember() {
|
||||
await api.setMember(projectId, memberUserId, memberRole)
|
||||
members = await api.listMembers(projectId)
|
||||
}
|
||||
|
||||
function byStatus(status) {
|
||||
return tickets.filter((t) => t.status === status)
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
msg = 'Copied'
|
||||
} catch {
|
||||
msg = text
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="login-wrap"><p class="muted">Loading…</p></div>
|
||||
{:else if !user}
|
||||
<div class="login-wrap">
|
||||
<div class="panel login-card stack">
|
||||
<h1>task-master</h1>
|
||||
<p class="muted">Sign in to manage projects and tickets.</p>
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
<label>Username<input bind:value={loginUser} autocomplete="username" /></label>
|
||||
<label>Password<input type="password" bind:value={loginPass} autocomplete="current-password" /></label>
|
||||
<button class="primary" onclick={doLogin}>Login</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="app-shell">
|
||||
<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}
|
||||
{#if user.isAdmin}
|
||||
<button class:active={view === 'admin'} onclick={openAdmin}>Users</button>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="row">
|
||||
{#if timer}
|
||||
<span class="muted">⏱ {timer.ticketId.slice(0, 8)}…</span>
|
||||
{/if}
|
||||
<span class="muted">{user.username}{user.isAdmin ? ' (admin)' : ''}</span>
|
||||
<button onclick={doLogout}>Logout</button>
|
||||
</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>
|
||||
</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 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}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
{/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}
|
||||
</div>
|
||||
{/if}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
:root {
|
||||
--bg: #0f1216;
|
||||
--bg-glow: #182028;
|
||||
--panel: #171c22;
|
||||
--panel-2: #1d242c;
|
||||
--ink: #e6edf3;
|
||||
--muted: #8b97a5;
|
||||
--line: #2a333d;
|
||||
--accent: #3d9a80;
|
||||
--accent-ink: #061510;
|
||||
--danger: #e07070;
|
||||
--todo: #9aa5b1;
|
||||
--wip: #d4a017;
|
||||
--done: #3d9a80;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(ellipse at top left, #1a2830 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, #152018 0%, transparent 45%),
|
||||
var(--bg);
|
||||
line-height: 1.45;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: transparent; }
|
||||
button, input, select, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
button:hover { border-color: #3d4a58; }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
border-color: #5a3030;
|
||||
background: #241818;
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: #10151a;
|
||||
color: var(--ink);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: #5f6b78; }
|
||||
textarea { min-height: 8rem; resize: vertical; }
|
||||
a { color: var(--accent); }
|
||||
code {
|
||||
background: #10151a;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 0.05em 0.35em;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: #c9d6e0;
|
||||
}
|
||||
.nav { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.nav button.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.panel {
|
||||
background: linear-gradient(180deg, var(--panel) 0%, #14191f 100%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.grid { display: grid; gap: 1rem; }
|
||||
.cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
@media (max-width: 800px) { .cols-3 { grid-template-columns: 1fr; } }
|
||||
.muted { color: var(--muted); font-size: 0.92rem; }
|
||||
.error { color: var(--danger); margin: 0.5rem 0; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.stack { display: grid; gap: 0.6rem; }
|
||||
.ticket {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: var(--panel-2);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.ticket:hover {
|
||||
border-color: var(--accent);
|
||||
background: #222b34;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: #10151a;
|
||||
}
|
||||
.badge.todo { color: var(--todo); }
|
||||
.badge.in_progress { color: var(--wip); }
|
||||
.badge.done { color: var(--done); }
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.login-card { width: min(380px, 100%); }
|
||||
h1, h2, h3 { margin: 0 0 0.75rem; font-weight: 650; color: #f0f4f8; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
td, th {
|
||||
text-align: left;
|
||||
padding: 0.4rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
th { color: var(--muted); font-weight: 600; }
|
||||
label { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.9rem; }
|
||||
label > :is(input, select, textarea) { color: var(--ink); }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
let count = $state(0)
|
||||
</script>
|
||||
|
||||
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>
|
||||
@@ -0,0 +1,78 @@
|
||||
async function request(path, opts = {}) {
|
||||
const res = await fetch(`/api/v1${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
...opts,
|
||||
})
|
||||
const text = await res.text()
|
||||
let data = null
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
data = { error: text }
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error((data && data.error) || res.statusText)
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request('/auth/me'),
|
||||
login: (username, password) =>
|
||||
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
|
||||
logout: () => request('/auth/logout', { method: 'POST', body: '{}' }),
|
||||
|
||||
dashboard: () => request('/dashboard'),
|
||||
|
||||
listProjects: () => request('/projects'),
|
||||
createProject: (name) =>
|
||||
request('/projects', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
patchProject: (id, body) =>
|
||||
request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteProject: (id) => request(`/projects/${id}`, { method: 'DELETE' }),
|
||||
listMembers: (id) => request(`/projects/${id}/members`),
|
||||
setMember: (id, userId, role) =>
|
||||
request(`/projects/${id}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||
removeMember: (id, userId) =>
|
||||
request(`/projects/${id}/members/${userId}`, { method: 'DELETE' }),
|
||||
|
||||
listTokens: (id) => request(`/projects/${id}/tokens`),
|
||||
createToken: (id, body) =>
|
||||
request(`/projects/${id}/tokens`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
revokeToken: (id, tid) => request(`/projects/${id}/tokens/${tid}`, { method: 'DELETE' }),
|
||||
|
||||
listTickets: (projectId) => request(`/projects/${projectId}/tickets`),
|
||||
createTicket: (projectId, body) =>
|
||||
request(`/projects/${projectId}/tickets`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
getTicket: (projectId, tid) => request(`/projects/${projectId}/tickets/${tid}`),
|
||||
patchTicket: (projectId, tid, body) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteTicket: (projectId, tid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}`, { method: 'DELETE' }),
|
||||
listRevisions: (projectId, tid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/revisions`),
|
||||
restoreRevision: (projectId, tid, rid) =>
|
||||
request(`/projects/${projectId}/tickets/${tid}/revisions/${rid}/restore`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
}),
|
||||
|
||||
report: (projectId, groupBy = 'ticket') =>
|
||||
request(`/projects/${projectId}/report?groupBy=${encodeURIComponent(groupBy)}`),
|
||||
listTime: (projectId) => request(`/projects/${projectId}/time`),
|
||||
createTime: (projectId, body) =>
|
||||
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
getTimer: () => request('/timer'),
|
||||
startTimer: (ticketId, projectId) =>
|
||||
request('/timer/start', { method: 'POST', body: JSON.stringify({ ticketId, projectId }) }),
|
||||
stopTimer: () => request('/timer/stop', { method: 'POST', body: '{}' }),
|
||||
|
||||
listUsers: () => request('/users'),
|
||||
createUser: (body) => request('/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||
patchUser: (id, body) => request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mount } from 'svelte'
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
mount(App, { target: document.getElementById('app') })
|
||||
Reference in New Issue
Block a user