PageSourceSearch

https://oscelab.com/assets/controllers/content_library_controller-c2dd41a4.js

js oscelab.com collected 2026-09-25 03:42:08 UTC 11,232 bytes, 321 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Selection + cross-type bulk actions for the unified admin Content Library.
4// Each row checkbox carries a `type:id` token so a single selection can span
5// multiple content types; the server groups tokens by type before applying.
6//
7// Two selection modes (mirrors the ai-requests bulk-select controller):
8//   - page:     the explicitly ticked rows (items: [type:id, ...]).
9//   - filtered: every item matching the current filters, across all pages.
10//               Entered via "Select all N matching this filter" once the whole
11//               page is ticked; bulk requests then send select_all_filtered +
12//               the filter params so the server re-derives the scope. Sets
13//               larger than the server's cap require a chunk confirmation.
14export default class extends Controller {
15  static targets = [
16    "selection",
17    "selectAll",
18    "selectionCount",
19    "selectFilteredButton",
20    "clearFilteredButton",
21    "editButton",
22    "deleteButton",
23    "editModal",
24    "deleteModal",
25    "visibility",
26    "accessAction",
27    "accessTags",
28    "deleteCount",
29    "deleteConfirm",
30    "deleteConfirmButton",
31    "status",
32    "error",
33    "deleteStatus",
34    "deleteError"
35  ]
36
37  static values = {
38    updateUrl: String,
39    deleteUrl: String,
40    confirmPhrase: { type: String, default: "DELETE" },
41    totalCount: { type: Number, default: 0 },
42    filterParams: { type: Object, default: {} }
43  }
44
45  connect() {
46    this.allFiltered = false
47    this.updateSelectionCount()
48  }
49
50  toggleSelection() {
51    this.allFiltered = false
52    this.syncSelectAllState()
53    this.updateSelectionCount()
54  }
55
56  toggleAll(event) {
57    const checked = event.target.checked
58    if (!checked) this.allFiltered = false
59    this.selectionTargets.forEach((checkbox) => { checkbox.checked = checked })
60    this.updateSelectionCount()
61  }
62
63  // "Select all N matching this filter" → act on the whole filtered scope.
64  selectAllFiltered() {
65    this.allFiltered = true
66    this.updateSelectionCount()
67  }
68
69  clearFilteredSelection() {
70    this.allFiltered = false
71    this.selectionTargets.forEach((checkbox) => { checkbox.checked = false })
72    this.syncSelectAllState()
73    this.updateSelectionCount()
74  }
75
76  syncSelectAllState() {
77    if (!this.hasSelectAllTarget) return
78    const total = this.selectionTargets.length
79    const selected = this.selectedTokens().length
80    this.selectAllTarget.checked = total > 0 && selected === total
81    this.selectAllTarget.indeterminate = selected > 0 && selected < total
82  }
83
84  // Number of records a bulk action would actually touch.
85  effectiveCount() {
86    return this.allFiltered ? this.totalCountValue : this.selectedTokens().length
87  }
88
89  updateSelectionCount() {
90    const count = this.selectedTokens().length
91    const effective = this.effectiveCount()
92
93    if (this.hasSelectionCountTarget) {
94      this.selectionCountTarget.textContent = this.allFiltered
95        ? `All ${this.totalCountValue} matching this filter selected`
96        : `${count} selected`
97    }
98    if (this.hasEditButtonTarget) this.editButtonTarget.disabled = effective === 0
99    if (this.hasDeleteButtonTarget) this.deleteButtonTarget.disabled = effective === 0
100
101    // Offer the cross-page expand only when the whole page is ticked and more
102    // rows match the filter beyond it.
103    if (this.hasSelectFilteredButtonTarget) {
104      const offerExpand = !this.allFiltered && count > 0 &&
105        count === this.selectionTargets.length && this.totalCountValue > count
106      this.selectFilteredButtonTarget.textContent = `Select all ${this.totalCountValue} matching this filter`
107      this.selectFilteredButtonTarget.classList.toggle("hidden", !offerExpand)
108    }
109    if (this.hasClearFilteredButtonTarget) {
110      this.clearFilteredButtonTarget.classList.toggle("hidden", !this.allFiltered)
111    }
112  }
113
114  selectedTokens() {
115    return this.selectionTargets
116      .filter((checkbox) => checkbox.checked)
117      .map((checkbox) => checkbox.dataset.contentLibraryItemToken)
118      .filter((token) => token)
119  }
120
121  // The selection portion of a bulk request body.
122  selectionParams() {
123    if (this.allFiltered) {
124      return { select_all_filtered: true, filters: this.filterParamsValue }
125    }
126    return { items: this.selectedTokens() }
127  }
128
129  // ── Edit (visibility + access tags) modal ───────────────────────────
130
131  openEditModal() {
132    if (this.effectiveCount() === 0) return
133    this.clearMessages()
134    this.editModalTarget.classList.remove("hidden")
135    this.editModalTarget.setAttribute("aria-hidden", "false")
136  }
137
138  closeEditModal() {
139    this.editModalTarget.classList.add("hidden")
140    this.editModalTarget.setAttribute("aria-hidden", "true")
141  }
142
143  async applyChanges() {
144    this.clearMessages()
145    if (this.effectiveCount() === 0) {
146      this.showError("Select at least one item.")
147      return
148    }
149
150    const visibility = this.hasVisibilityTarget ? this.visibilityTarget.value : ""
151    const accessAction = this.hasAccessActionTarget ? this.accessActionTarget.value : ""
152    const accessTags = this.parsedTags()
153
154    if (!visibility && !accessAction) {
155      this.showError("Choose a visibility or access tag update.")
156      return
157    }
158    if (["set", "add", "remove"].includes(accessAction) && accessTags.length === 0) {
159      this.showError("Enter at least one access tag for that action.")
160      return
161    }
162
163    const body = this.selectionParams()
164    if (visibility) body.visibility = visibility
165    if (accessAction) {
166      body.access_tag_action = accessAction
167      body.access_tags = accessTags
168    }
169
170    this.setStatus("Applying…")
171    try {
172      const payload = await this.submitBulk(this.updateUrlValue, "PATCH", body)
173      if (!payload) {
174        this.setStatus("")
175        return
176      }
177
178      const parts = []
179      if (payload.visibility_updated != null) parts.push(`${payload.visibility_updated} visibility`)
180      if (payload.tags_updated != null) parts.push(`${payload.tags_updated} tag`)
181      const remaining = payload.remaining > 0 ? ` ${payload.remaining} matching items remain — rerun for the rest.` : ""
182      this.setStatus(`Updated ${parts.join(", ")}.${remaining} Reloading…`)
183      window.location.reload()
184    } catch (error) {
185      this.showError(error.message || "Unable to apply changes.")
186    }
187  }
188
189  parsedTags() {
190    if (!this.hasAccessTagsTarget) return []
191    return this.accessTagsTarget.value
192      .split(/[\n,]/)
193      .map((tag) => tag.trim())
194      .filter((tag) => tag)
195  }
196
197  // ── Delete modal ────────────────────────────────────────────────────
198
199  openDeleteModal() {
200    const count = this.effectiveCount()
201    if (count === 0) return
202    this.clearDeleteMessages()
203    if (this.hasDeleteCountTarget) {
204      this.deleteCountTarget.textContent = `${count} ${count === 1 ? "item" : "items"}`
205    }
206    if (this.hasDeleteConfirmTarget) this.deleteConfirmTarget.value = ""
207    this.refreshDeleteButton()
208    this.deleteModalTarget.classList.remove("hidden")
209    this.deleteModalTarget.setAttribute("aria-hidden", "false")
210  }
211
212  closeDeleteModal() {
213    this.deleteModalTarget.classList.add("hidden")
214    this.deleteModalTarget.setAttribute("aria-hidden", "true")
215  }
216
217  refreshDeleteButton() {
218    if (!this.hasDeleteConfirmButtonTarget) return
219    const value = this.hasDeleteConfirmTarget ? this.deleteConfirmTarget.value.trim() : ""
220    this.deleteConfirmButtonTarget.disabled = value !== this.confirmPhraseValue
221  }
222
223  async confirmDelete() {
224    this.clearDeleteMessages()
225    if (this.effectiveCount() === 0) {
226      this.showDeleteError("Select at least one item.")
227      return
228    }
229    if (this.hasDeleteConfirmTarget && this.deleteConfirmTarget.value.trim() !== this.confirmPhraseValue) {
230      this.showDeleteError(`Type ${this.confirmPhraseValue} to confirm.`)
231      return
232    }
233
234    if (this.hasDeleteConfirmButtonTarget) this.deleteConfirmButtonTarget.disabled = true
235    this.setDeleteStatus("Deleting…")
236    try {
237      const payload = await this.submitBulk(this.deleteUrlValue, "DELETE", this.selectionParams())
238      if (!payload) {
239        this.setDeleteStatus("")
240        this.refreshDeleteButton()
241        return
242      }
243
244      const deleted = payload.deleted ?? 0
245      const remaining = payload.remaining > 0 ? ` ${payload.remaining} matching items remain — rerun for the rest.` : ""
246      this.setDeleteStatus(`Deleted ${deleted}.${remaining} Reloading…`)
247      window.location.reload()
248    } catch (error) {
249      this.showDeleteError(error.message || "Unable to delete the selected items.")
250      this.refreshDeleteButton()
251    }
252  }
253
254  // POSTs a bulk request. When a whole-filter selection exceeds the server's
255  // per-run cap, the server answers 422 + requires_chunk_confirmation; ask the
256  // admin, then retry once with confirm_chunk so the oldest chunk is
257  // processed. Returns the response payload, or null when the admin declines
258  // the chunk confirmation.
259  async submitBulk(url, method, body) {
260    let response = await fetch(url, {
261      method,
262      headers: this.requestHeaders(),
263      body: JSON.stringify(body)
264    })
265    let payload = await response.json().catch(() => ({}))
266
267    if (!response.ok && payload.requires_chunk_confirmation) {
268      const proceed = window.confirm(
269        `${payload.matching} items match this filter — more than the ${payload.cap}-item cap for one run. ` +
270        `Process the oldest ${payload.cap} now? Rerun the action afterwards for the rest.`
271      )
272      if (!proceed) return null
273
274      response = await fetch(url, {
275        method,
276        headers: this.requestHeaders(),
277        body: JSON.stringify({ ...body, confirm_chunk: true })
278      })
279      payload = await response.json().catch(() => ({}))
280    }
281
282    if (!response.ok) throw new Error(payload.error || "Request failed.")
283    return payload
284  }
285
286  // ── Helpers ─────────────────────────────────────────────────────────
287
288  stopPropagation(event) {
289    event.stopPropagation()
290  }
291
292  setStatus(message) { this.toggleMessage(this.statusTarget, this.hasStatusTarget, message) }
293  showError(message) { this.toggleMessage(this.errorTarget, this.hasErrorTarget, message) }
294  setDeleteStatus(message) { this.toggleMessage(this.deleteStatusTarget, this.hasDeleteStatusTarget, message) }
295  showDeleteError(message) { this.toggleMessage(this.deleteErrorTarget, this.hasDeleteErrorTarget, message) }
296
297  toggleMessage(target, present, message) {
298    if (!present) return
299    target.textContent = message
300    target.classList.toggle("hidden", !message)
301  }
302
303  clearMessages() {
304    this.setStatus("")
305    this.showError("")
306  }
307
308  clearDeleteMessages() {
309    this.setDeleteStatus("")
310    this.showDeleteError("")
311  }
312
313  requestHeaders() {
314    const token = document.querySelector('meta[name="csrf-token"]')?.content
315    return {
316      "Content-Type": "application/json",
317      Accept: "application/json",
318      "X-CSRF-Token": token
319    }
320  }
321}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.