Persist web navigation across reloads via hash routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 23:09:17 +01:00
co-authored by Cursor
parent b311a355fd
commit a594183310
2 changed files with 218 additions and 12 deletions
+154 -12
View File
@@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte'
import { api } from './lib/api.js'
import { parseRoute, buildHash, currentHash } from './lib/route.js'
let user = $state(null)
let loading = $state(true)
@@ -27,6 +28,7 @@
let newTokenSecret = $state('')
let renameName = $state('')
let editGroupId = $state('')
let applyingRoute = false
let loginUser = $state('')
let loginPass = $state('')
@@ -49,16 +51,95 @@
let tokenName = $state('tui')
let tokenScope = $state('write')
function desiredHash() {
return buildHash({
topTab,
projectId,
projectView,
ticketId: selected?.id || '',
})
}
function routeMatches(r) {
if (r.topTab !== topTab) return false
if (r.topTab !== 'projects') return true
if (r.projectId !== projectId) return false
if (r.projectView === 'ticket') {
return projectView === 'ticket' && selected?.id === r.ticketId
}
return projectView === r.projectView
}
function syncRoute() {
if (applyingRoute || !user) return
const next = desiredHash()
if (currentHash() === next) return
location.hash = next
}
async function applyRoute() {
if (!user || applyingRoute) return
const r = parseRoute()
if (routeMatches(r) && !loading) return
applyingRoute = true
error = ''
try {
if (r.topTab === 'dashboard') {
await showDashboard()
} else if (r.topTab === 'users') {
if (user.isAdmin) await showAdmin()
else await showDashboard()
} else if (r.topTab === 'settings') {
showGlobalSettings()
} else if (r.topTab === 'projects') {
if (!r.projectId) {
await showProjects()
} else if (r.projectView === 'ticket' && r.ticketId) {
await showProject(r.projectId)
const t = tickets.find((x) => x.id === r.ticketId)
if (t) await showTicket(t)
else await showProject(r.projectId)
} else if (r.projectView === 'report') {
await showProject(r.projectId)
await showReport()
} else if (r.projectView === 'members') {
await showProject(r.projectId)
await showMembers()
} else if (r.projectView === 'settings') {
await showProject(r.projectId)
await showProjectSettings()
} else {
await showProject(r.projectId)
}
} else {
await showDashboard()
}
} catch (e) {
error = e.message || String(e)
await showDashboard()
} finally {
const next = desiredHash()
if (currentHash() !== next) history.replaceState(null, '', next)
applyingRoute = false
}
}
onMount(async () => {
const onHash = () => {
if (!user) return
applyRoute()
}
window.addEventListener('hashchange', onHash)
try {
user = await api.me()
await openDashboard()
await applyRoute()
await refreshTimer()
} catch {
user = null
} finally {
loading = false
}
return () => window.removeEventListener('hashchange', onHash)
})
function formatDuration(ns) {
@@ -132,7 +213,8 @@
error = ''
try {
user = await api.login(loginUser, loginPass)
await openDashboard()
await applyRoute()
await refreshTimer()
} catch (e) {
error = e.message
}
@@ -143,6 +225,10 @@
user = null
projects = []
groups = []
selected = null
projectId = ''
projectView = ''
topTab = 'dashboard'
}
async function refreshProjects() {
@@ -188,7 +274,7 @@
timer = data.timer
}
async function openDashboard() {
async function showDashboard() {
topTab = 'dashboard'
projectId = ''
projectView = ''
@@ -197,16 +283,30 @@
await refreshProjects()
}
async function openProjects() {
async function openDashboard() {
await showDashboard()
syncRoute()
}
async function showProjects() {
topTab = 'projects'
projectId = ''
projectView = ''
selected = null
error = ''
msg = ''
await refreshProjects()
}
async function openProject(id, name = '') {
async function openProjects() {
await showProjects()
syncRoute()
}
async function showProject(id, name = '') {
topTab = 'projects'
projectId = id
if (!projects.length) await refreshProjects()
projectName = name || projects.find((p) => p.id === id)?.name || id
projectView = 'board'
selected = null
@@ -214,6 +314,11 @@
tickets = await api.listTickets(id)
}
async function openProject(id, name = '') {
await showProject(id, name)
syncRoute()
}
async function createProject() {
error = ''
const name = newProjectName.trim()
@@ -238,7 +343,7 @@
tickets = await api.listTickets(projectId)
}
async function openTicket(t) {
async function showTicket(t) {
selected = t
editTitle = t.title
editDesc = t.description || ''
@@ -252,6 +357,11 @@
projectView = 'ticket'
}
async function openTicket(t) {
await showTicket(t)
syncRoute()
}
async function saveTicket() {
selected = await api.patchTicket(projectId, selected.id, {
title: editTitle,
@@ -269,6 +379,7 @@
selected = null
projectView = 'board'
tickets = await api.listTickets(projectId)
syncRoute()
}
async function restoreRev(rid) {
@@ -303,19 +414,24 @@
await refreshTicketTime()
}
async function openReport() {
async function showReport() {
topTab = 'projects'
projectView = 'report'
error = ''
report = await api.report(projectId, reportGroupBy)
}
async function openReport() {
await showReport()
syncRoute()
}
async function changeReportGroup(g) {
reportGroupBy = g
report = await api.report(projectId, reportGroupBy)
}
async function openProjectSettings() {
async function showProjectSettings() {
topTab = 'projects'
projectView = 'settings'
renameName = projectName
@@ -325,6 +441,11 @@
await refreshProjects()
}
async function openProjectSettings() {
await showProjectSettings()
syncRoute()
}
async function saveProjectGroup() {
const p = await api.patchProject(projectId, { groupId: editGroupId })
msg = 'Gruppe gespeichert'
@@ -332,12 +453,20 @@
if (p.groupId !== undefined) editGroupId = p.groupId || ''
}
function openGlobalSettings() {
function showGlobalSettings() {
topTab = 'settings'
projectId = ''
projectView = ''
selected = null
error = ''
msg = ''
}
function openGlobalSettings() {
showGlobalSettings()
syncRoute()
}
async function saveRename() {
const name = renameName.trim()
if (!name) return
@@ -359,13 +488,21 @@
tokens = await api.listTokens(projectId)
}
async function openAdmin() {
async function showAdmin() {
topTab = 'users'
projectId = ''
projectView = ''
selected = null
error = ''
msg = ''
users = await api.listUsers()
}
async function openAdmin() {
await showAdmin()
syncRoute()
}
async function createUser() {
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
newUserName = ''
@@ -390,13 +527,18 @@
users = await api.listUsers()
}
async function openMembers() {
async function showMembers() {
topTab = 'projects'
projectView = 'members'
members = await api.listMembers(projectId)
users = await api.listUsers()
}
async function openMembers() {
await showMembers()
syncRoute()
}
async function addMember() {
await api.setMember(projectId, memberUserId, memberRole)
members = await api.listMembers(projectId)
@@ -605,7 +747,7 @@
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
<section class="panel stack">
<div class="row">
<button onclick={() => (projectView = 'board')}>← Board</button>
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
<button class="danger" onclick={removeTicket}>Delete</button>
</div>
<label>Title<input bind:value={editTitle} /></label>
+64
View File
@@ -0,0 +1,64 @@
/** Hash routes for the SPA. Examples:
* #/dashboard
* #/projects
* #/projects/:id
* #/projects/:id/tickets/:ticketId
* #/projects/:id/report|members|settings
* #/users
* #/settings
*/
export function parseRoute(hash = typeof location !== 'undefined' ? location.hash : '') {
const raw = String(hash || '')
.replace(/^#\/?/, '')
.replace(/\/+$/, '')
const parts = raw ? raw.split('/').filter(Boolean) : []
if (parts.length === 0 || parts[0] === 'dashboard') {
return { topTab: 'dashboard', projectId: '', projectView: '', ticketId: '' }
}
if (parts[0] === 'users') {
return { topTab: 'users', projectId: '', projectView: '', ticketId: '' }
}
if (parts[0] === 'settings') {
return { topTab: 'settings', projectId: '', projectView: '', ticketId: '' }
}
if (parts[0] === 'projects') {
const projectId = parts[1] || ''
if (!projectId) {
return { topTab: 'projects', projectId: '', projectView: '', ticketId: '' }
}
const sub = parts[2] || 'board'
if (sub === 'tickets' && parts[3]) {
return { topTab: 'projects', projectId, projectView: 'ticket', ticketId: parts[3] }
}
if (sub === 'report' || sub === 'members' || sub === 'settings' || sub === 'board') {
return { topTab: 'projects', projectId, projectView: sub, ticketId: '' }
}
return { topTab: 'projects', projectId, projectView: 'board', ticketId: '' }
}
return { topTab: 'dashboard', projectId: '', projectView: '', ticketId: '' }
}
export function buildHash({ topTab, projectId, projectView, ticketId }) {
if (topTab === 'users') return '#/users'
if (topTab === 'settings') return '#/settings'
if (topTab === 'projects') {
if (!projectId) return '#/projects'
if (projectView === 'ticket' && ticketId) {
return `#/projects/${projectId}/tickets/${ticketId}`
}
if (projectView === 'report') return `#/projects/${projectId}/report`
if (projectView === 'members') return `#/projects/${projectId}/members`
if (projectView === 'settings') return `#/projects/${projectId}/settings`
return `#/projects/${projectId}`
}
return '#/dashboard'
}
export function currentHash() {
const h = location.hash || ''
if (!h || h === '#') return '#/dashboard'
return h.startsWith('#/') ? h : `#/${h.replace(/^#/, '')}`
}