Switch panel to client-side fetch, extract initPanelEditors
Panel fetches content into persistent shell instead of relying on
server-rendered HTML. OverType setup moved to initPanelEditors() so
it runs after fetch injects the panel DOM.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index 51b6d7c..ab3adcb 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -25,7 +25,11 @@ module Views
) { "✓" }
end
div(class: "task-body") do
- a(href: "/series/#{@task[:series_id]}", class: "task-name") { name }
+ a(
+ href: "/series/#{@task[:series_id]}",
+ class: "task-name",
+ "x-on:click.prevent": "$dispatch('open-panel', { seriesId: #{@task[:series_id]} })"
+ ) { name }
if @overdue
span(class: "task-meta") do
plain meta_text
diff --git a/public/js/app.js b/public/js/app.js
index 492cae1..3351986 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -58,22 +58,111 @@ function saveSeriesField(seriesId, field, value) {
OverType.setTheme({ name: "ketchup", colors: { text: "#1a1a1a" } })
+function initPanelEditors(container) {
+ const noteDetail = container.querySelector("#series-note-detail")
+ if (noteDetail) {
+ const seriesId = noteDetail.dataset.seriesId
+ const initialNote = noteDetail.dataset.value || ""
+
+ const [editor] = new OverType(noteDetail, {
+ value: initialNote,
+ placeholder: "Series note...",
+ autoResize: true,
+ minHeight: 14,
+ padding: "0 4px",
+ })
+
+ const resizeNote = compactOverType(noteDetail)
+
+ const ta = noteDetail.querySelector("textarea")
+ if (ta) {
+ ta.style.pointerEvents = "none"
+ ta.readOnly = true
+
+ ta.addEventListener("blur", () => {
+ const note = editor.getValue().trim()
+ if (note === (initialNote || "").trim()) return
+ saveSeriesField(seriesId, "note", note).then(() => location.reload())
+ })
+
+ container.addEventListener("start-editing", () => {
+ ta.style.pointerEvents = ""
+ ta.readOnly = false
+ ta.focus()
+ if (resizeNote) requestAnimationFrame(resizeNote)
+ })
+
+ container.addEventListener("stop-editing", () => {
+ const note = editor.getValue().trim()
+ if (note !== (initialNote || "").trim()) {
+ saveSeriesField(seriesId, "note", note).then(() => location.reload())
+ return
+ }
+ ta.style.pointerEvents = "none"
+ ta.readOnly = true
+ if (resizeNote) requestAnimationFrame(resizeNote)
+ })
+ }
+ }
+}
+
document.addEventListener("alpine:init", () => {
// Panel open/close
Alpine.data("panel", () => ({
open: false,
+ loading: false,
init() {
- // Open panel if server rendered content into it
- if (this.$el.querySelector(".panel-inner")) {
- this.$nextTick(() => { this.open = true })
+ // Auto-open panel if server set data-open-series on the dashboard
+ const dashboard = document.querySelector("[data-open-series]")
+ if (dashboard) {
+ const seriesId = dashboard.dataset.openSeries
+ if (seriesId) this.show(seriesId)
+ }
+
+ // Auto-open user panel
+ const userDash = document.querySelector("[data-open-user]")
+ if (userDash) {
+ const userId = userDash.dataset.openUser
+ if (userId) this.showUser(userId)
+ }
+
+ // Listen for custom event from task cards
+ window.addEventListener("open-panel", (e) => {
+ this.show(e.detail.seriesId)
+ })
+ },
+
+ async show(seriesId) {
+ this.loading = true
+ this.open = true
+ try {
+ const resp = await fetch(`/series/${seriesId}/panel`)
+ if (!resp.ok) return
+ this.$refs.content.innerHTML = await resp.text()
+ initPanelEditors(this.$refs.content)
+ } finally {
+ this.loading = false
+ }
+ },
+
+ async showUser(userId) {
+ this.loading = true
+ this.open = true
+ try {
+ const resp = await fetch(`/users/${userId}/panel`)
+ if (!resp.ok) return
+ this.$refs.content.innerHTML = await resp.text()
+ } finally {
+ this.loading = false
}
},
close() {
this.open = false
- // Navigate back to root after close animation
- setTimeout(() => { window.location.href = "/" }, 250)
+ setTimeout(() => {
+ if (this.$refs.content) this.$refs.content.innerHTML = ""
+ }, 250)
},
}))
@@ -177,53 +266,6 @@ document.addEventListener("alpine:init", () => {
},
}))
- // Series note detail editor — initialized when a series is selected
- const noteDetail = document.getElementById("series-note-detail")
- if (noteDetail) {
- const seriesId = noteDetail.dataset.seriesId
- const initialNote = noteDetail.dataset.value || ""
-
- const [editor] = new OverType(noteDetail, {
- value: initialNote,
- placeholder: "Series note...",
- autoResize: true,
- minHeight: 14,
- padding: "0 4px",
- })
-
- const resizeNote = compactOverType(noteDetail)
-
- const ta = noteDetail.querySelector("textarea")
- if (ta) {
- ta.style.pointerEvents = "none"
- ta.readOnly = true
-
- ta.addEventListener("blur", () => {
- const note = editor.getValue().trim()
- if (note === (initialNote || "").trim()) return
- saveSeriesField(seriesId, "note", note).then(() => location.reload())
- })
-
- document.addEventListener("start-editing", () => {
- ta.style.pointerEvents = ""
- ta.readOnly = false
- ta.focus()
- if (resizeNote) requestAnimationFrame(resizeNote)
- })
-
- document.addEventListener("stop-editing", () => {
- const note = editor.getValue().trim()
- if (note !== (initialNote || "").trim()) {
- saveSeriesField(seriesId, "note", note).then(() => location.reload())
- return
- }
- ta.style.pointerEvents = "none"
- ta.readOnly = true
- if (resizeNote) requestAnimationFrame(resizeNote)
- })
- }
- }
-
// New series form editor
const newNoteEl = document.getElementById("series-note-editor")
if (newNoteEl) {