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>
|
||||
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>
|
||||
|
||||
@@ -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