Persist web navigation across reloads via hash routes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+154
-12
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { api } from './lib/api.js'
|
import { api } from './lib/api.js'
|
||||||
|
import { parseRoute, buildHash, currentHash } from './lib/route.js'
|
||||||
|
|
||||||
let user = $state(null)
|
let user = $state(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
let newTokenSecret = $state('')
|
let newTokenSecret = $state('')
|
||||||
let renameName = $state('')
|
let renameName = $state('')
|
||||||
let editGroupId = $state('')
|
let editGroupId = $state('')
|
||||||
|
let applyingRoute = false
|
||||||
|
|
||||||
let loginUser = $state('')
|
let loginUser = $state('')
|
||||||
let loginPass = $state('')
|
let loginPass = $state('')
|
||||||
@@ -49,16 +51,95 @@
|
|||||||
let tokenName = $state('tui')
|
let tokenName = $state('tui')
|
||||||
let tokenScope = $state('write')
|
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 () => {
|
onMount(async () => {
|
||||||
|
const onHash = () => {
|
||||||
|
if (!user) return
|
||||||
|
applyRoute()
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', onHash)
|
||||||
try {
|
try {
|
||||||
user = await api.me()
|
user = await api.me()
|
||||||
await openDashboard()
|
await applyRoute()
|
||||||
await refreshTimer()
|
await refreshTimer()
|
||||||
} catch {
|
} catch {
|
||||||
user = null
|
user = null
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
return () => window.removeEventListener('hashchange', onHash)
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDuration(ns) {
|
function formatDuration(ns) {
|
||||||
@@ -132,7 +213,8 @@
|
|||||||
error = ''
|
error = ''
|
||||||
try {
|
try {
|
||||||
user = await api.login(loginUser, loginPass)
|
user = await api.login(loginUser, loginPass)
|
||||||
await openDashboard()
|
await applyRoute()
|
||||||
|
await refreshTimer()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message
|
error = e.message
|
||||||
}
|
}
|
||||||
@@ -143,6 +225,10 @@
|
|||||||
user = null
|
user = null
|
||||||
projects = []
|
projects = []
|
||||||
groups = []
|
groups = []
|
||||||
|
selected = null
|
||||||
|
projectId = ''
|
||||||
|
projectView = ''
|
||||||
|
topTab = 'dashboard'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshProjects() {
|
async function refreshProjects() {
|
||||||
@@ -188,7 +274,7 @@
|
|||||||
timer = data.timer
|
timer = data.timer
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openDashboard() {
|
async function showDashboard() {
|
||||||
topTab = 'dashboard'
|
topTab = 'dashboard'
|
||||||
projectId = ''
|
projectId = ''
|
||||||
projectView = ''
|
projectView = ''
|
||||||
@@ -197,16 +283,30 @@
|
|||||||
await refreshProjects()
|
await refreshProjects()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProjects() {
|
async function openDashboard() {
|
||||||
|
await showDashboard()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showProjects() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
|
projectId = ''
|
||||||
|
projectView = ''
|
||||||
|
selected = null
|
||||||
error = ''
|
error = ''
|
||||||
msg = ''
|
msg = ''
|
||||||
await refreshProjects()
|
await refreshProjects()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProject(id, name = '') {
|
async function openProjects() {
|
||||||
|
await showProjects()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showProject(id, name = '') {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectId = id
|
projectId = id
|
||||||
|
if (!projects.length) await refreshProjects()
|
||||||
projectName = name || projects.find((p) => p.id === id)?.name || id
|
projectName = name || projects.find((p) => p.id === id)?.name || id
|
||||||
projectView = 'board'
|
projectView = 'board'
|
||||||
selected = null
|
selected = null
|
||||||
@@ -214,6 +314,11 @@
|
|||||||
tickets = await api.listTickets(id)
|
tickets = await api.listTickets(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openProject(id, name = '') {
|
||||||
|
await showProject(id, name)
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function createProject() {
|
async function createProject() {
|
||||||
error = ''
|
error = ''
|
||||||
const name = newProjectName.trim()
|
const name = newProjectName.trim()
|
||||||
@@ -238,7 +343,7 @@
|
|||||||
tickets = await api.listTickets(projectId)
|
tickets = await api.listTickets(projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openTicket(t) {
|
async function showTicket(t) {
|
||||||
selected = t
|
selected = t
|
||||||
editTitle = t.title
|
editTitle = t.title
|
||||||
editDesc = t.description || ''
|
editDesc = t.description || ''
|
||||||
@@ -252,6 +357,11 @@
|
|||||||
projectView = 'ticket'
|
projectView = 'ticket'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openTicket(t) {
|
||||||
|
await showTicket(t)
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTicket() {
|
async function saveTicket() {
|
||||||
selected = await api.patchTicket(projectId, selected.id, {
|
selected = await api.patchTicket(projectId, selected.id, {
|
||||||
title: editTitle,
|
title: editTitle,
|
||||||
@@ -269,6 +379,7 @@
|
|||||||
selected = null
|
selected = null
|
||||||
projectView = 'board'
|
projectView = 'board'
|
||||||
tickets = await api.listTickets(projectId)
|
tickets = await api.listTickets(projectId)
|
||||||
|
syncRoute()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreRev(rid) {
|
async function restoreRev(rid) {
|
||||||
@@ -303,19 +414,24 @@
|
|||||||
await refreshTicketTime()
|
await refreshTicketTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReport() {
|
async function showReport() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectView = 'report'
|
projectView = 'report'
|
||||||
error = ''
|
error = ''
|
||||||
report = await api.report(projectId, reportGroupBy)
|
report = await api.report(projectId, reportGroupBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openReport() {
|
||||||
|
await showReport()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function changeReportGroup(g) {
|
async function changeReportGroup(g) {
|
||||||
reportGroupBy = g
|
reportGroupBy = g
|
||||||
report = await api.report(projectId, reportGroupBy)
|
report = await api.report(projectId, reportGroupBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProjectSettings() {
|
async function showProjectSettings() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectView = 'settings'
|
projectView = 'settings'
|
||||||
renameName = projectName
|
renameName = projectName
|
||||||
@@ -325,6 +441,11 @@
|
|||||||
await refreshProjects()
|
await refreshProjects()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openProjectSettings() {
|
||||||
|
await showProjectSettings()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveProjectGroup() {
|
async function saveProjectGroup() {
|
||||||
const p = await api.patchProject(projectId, { groupId: editGroupId })
|
const p = await api.patchProject(projectId, { groupId: editGroupId })
|
||||||
msg = 'Gruppe gespeichert'
|
msg = 'Gruppe gespeichert'
|
||||||
@@ -332,12 +453,20 @@
|
|||||||
if (p.groupId !== undefined) editGroupId = p.groupId || ''
|
if (p.groupId !== undefined) editGroupId = p.groupId || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function openGlobalSettings() {
|
function showGlobalSettings() {
|
||||||
topTab = 'settings'
|
topTab = 'settings'
|
||||||
|
projectId = ''
|
||||||
|
projectView = ''
|
||||||
|
selected = null
|
||||||
error = ''
|
error = ''
|
||||||
msg = ''
|
msg = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openGlobalSettings() {
|
||||||
|
showGlobalSettings()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRename() {
|
async function saveRename() {
|
||||||
const name = renameName.trim()
|
const name = renameName.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
@@ -359,13 +488,21 @@
|
|||||||
tokens = await api.listTokens(projectId)
|
tokens = await api.listTokens(projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAdmin() {
|
async function showAdmin() {
|
||||||
topTab = 'users'
|
topTab = 'users'
|
||||||
|
projectId = ''
|
||||||
|
projectView = ''
|
||||||
|
selected = null
|
||||||
error = ''
|
error = ''
|
||||||
msg = ''
|
msg = ''
|
||||||
users = await api.listUsers()
|
users = await api.listUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAdmin() {
|
||||||
|
await showAdmin()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function createUser() {
|
async function createUser() {
|
||||||
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
|
await api.createUser({ username: newUserName, password: newUserPass, isAdmin: newUserAdmin })
|
||||||
newUserName = ''
|
newUserName = ''
|
||||||
@@ -390,13 +527,18 @@
|
|||||||
users = await api.listUsers()
|
users = await api.listUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openMembers() {
|
async function showMembers() {
|
||||||
topTab = 'projects'
|
topTab = 'projects'
|
||||||
projectView = 'members'
|
projectView = 'members'
|
||||||
members = await api.listMembers(projectId)
|
members = await api.listMembers(projectId)
|
||||||
users = await api.listUsers()
|
users = await api.listUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openMembers() {
|
||||||
|
await showMembers()
|
||||||
|
syncRoute()
|
||||||
|
}
|
||||||
|
|
||||||
async function addMember() {
|
async function addMember() {
|
||||||
await api.setMember(projectId, memberUserId, memberRole)
|
await api.setMember(projectId, memberUserId, memberRole)
|
||||||
members = await api.listMembers(projectId)
|
members = await api.listMembers(projectId)
|
||||||
@@ -605,7 +747,7 @@
|
|||||||
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
|
{:else if topTab === 'projects' && projectView === 'ticket' && selected}
|
||||||
<section class="panel stack">
|
<section class="panel stack">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button onclick={() => (projectView = 'board')}>← Board</button>
|
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
|
||||||
<button class="danger" onclick={removeTicket}>Delete</button>
|
<button class="danger" onclick={removeTicket}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<label>Title<input bind:value={editTitle} /></label>
|
<label>Title<input bind:value={editTitle} /></label>
|
||||||
|
|||||||
@@ -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(/^#/, '')}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user