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:
2026-10-27 22:23:11 +01:00
co-authored by Cursor
parent 98d2253f44
commit 4adfcdb95a
67 changed files with 7188 additions and 129 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+43
View File
@@ -0,0 +1,43 @@
# Svelte + Vite
This template should help get you started developing with Svelte in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>task-master</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}
+1107
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"svelte": "^5.57.2",
"vite": "^8.3.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+586
View File
@@ -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
View File
@@ -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

+1
View File
@@ -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

+5
View File
@@ -0,0 +1,5 @@
<script>
let count = $state(0)
</script>
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>
+78
View File
@@ -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' }),
}
+5
View File
@@ -0,0 +1,5 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
mount(App, { target: document.getElementById('app') })
+2
View File
@@ -0,0 +1,2 @@
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
build: {
outDir: '../internal/webembed/dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})