studio-engine 0.98.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df70057457219c86f48e88b9c2f59ab83c3829a9b254901ef9a0ad3dd159875a
4
- data.tar.gz: 04e7219348362718aed9a77ca418f562b2bfc71219089080c254cdd8c0f17c2c
3
+ metadata.gz: 5c5140413bf4edd1a888bea60b99a3ad688930302c6ea037dceef808cb233705
4
+ data.tar.gz: 31c5c7205a47e8c2a6366f9262c5e61425732fde284e4618450e6825e2a1a3af
5
5
  SHA512:
6
- metadata.gz: cfd4f6c32ea4b45960e42acc4d678dd7ef0a7e6ee9924b32afd8ae99c9ae050a1bab09de2f8972946b6f837a248857d9ab55c985bddb6719d444eb1c0ef374f6
7
- data.tar.gz: fd502cba3059ad7506989575e7c8229373857f3fc78b63917b3dfba415e13ce516771ceba9807c69dba23a1b652fa441b75b7185d07b6287d879ed13b02fee5e
6
+ metadata.gz: 7d7b3b42b70bccae520be5c5db88d0d3544e30aa6810349e17c056b142856f1bcde44afb2edb7e49df4fdc727a3fa750ec9ed26213df24c64dc8d825f8527eca
7
+ data.tar.gz: 2d55e2f7f597bbfeb3e0f628d3e435f0d77aff729b341724c3eb975eea924aa0dc72dce1c0758e48f457d52c3676185bb45af9c9d5efd03f1f61a096952ec5cd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.99.0 — 2026-10-09
8
+
9
+ ### Fixed
10
+
11
+ - **A modal left open no longer comes back on Back when `studio/application`
12
+ fails to load.** `studio/modal_host` binds the stale-modal sweep itself, on
13
+ a bfcache `pageshow` and on `turbo:before-cache`, once per document, for
14
+ every host on the page. The `modal-host` controller bound it before, so a
15
+ failed boot left the stores registered and the sweep unbound. The controller
16
+ now registers its host's store and nothing else, so each event sweeps a
17
+ store once. `clearStaleModalsIn(doc, win)` is exported beside
18
+ `clearStaleModals(win, name)`. No markup changes.
19
+
7
20
  ## 0.98.0 — 2026-10-09
8
21
 
9
22
  ### Changed
@@ -352,6 +365,70 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
352
365
  before. A non-http(s) String raises `ArgumentError` at boot; a callable
353
366
  answering one draws no link. The link opens in a new tab with
354
367
  `rel="noopener noreferrer"`. See README, *The image generator link*.
368
+ - **Knowledge documents preview in the page.** `/admin/knowledge/:id` gains a
369
+ Preview section, so reading a document no longer means downloading it:
370
+ - `.xlsx` and `.xlsm` workbooks render as tables, one tab per sheet. A formula
371
+ shows the value Excel last calculated for it, and numbers render through
372
+ their number format (dates, percentages, currency, accounting negatives).
373
+ - `.csv` and `.tsv` render as one table.
374
+ - PDFs and images (`png`, `jpg`, `gif`, `webp`) display inline, fetched by the
375
+ browser from a 15-minute signed URL.
376
+ - Text, markdown, JSON and caption files display as preformatted text. Stored
377
+ content is always escaped, never rendered as HTML.
378
+ - Anything else (`.xls`, `.docx`, SVG), anything over a cap, and any file that
379
+ will not parse shows one sentence saying why, beside the download link. The
380
+ page never fails on a bad file.
381
+
382
+ The section is a lazy `<turbo-frame>` over a new admin-only endpoint,
383
+ `GET /admin/knowledge/:id/preview` (`admin_knowledge_doc_preview_path`), so the
384
+ show page itself reads nothing from the bucket. It rides the existing
385
+ `Studio.draw_knowledge_routes` opt-in; there is no migration and no new setting.
386
+
387
+ The caps, all constants on `Studio::KnowledgePreview`:
388
+ - A sheet: 500 rows and 50 columns. A cell: 32,767 characters (Excel's own
389
+ limit; cut with a mark past it).
390
+ - A workbook: 20 sheets, 50,000 filled cells, 100,000 rendered cells (empty
391
+ ones included) and 2 MB of cell text. The sheet that spends the last of one
392
+ of these is cut there and says so; later sheets are counted in a notice.
393
+ - A workbook read as a whole: a 20 MB file; 64 MB inflated across every part
394
+ read (`INFLATE_BUDGET`, a part read twice counted twice); 16 MB of text handed
395
+ over by the XML parser, kept or not (`MAX_PARSED_TEXT_BYTES`); and 10 seconds
396
+ of wall-clock time (`SPREADSHEET_DEADLINE_SECONDS`). Past any of these the
397
+ preview falls back to the download link.
398
+ - What the XML parser is given, enforced on the byte stream before it
399
+ (`Studio::KnowledgePreview::XmlGuard`): a part must be UTF-8 and open with
400
+ `<`; a part with a `<!DOCTYPE` is refused; a text node or CDATA section
401
+ stops at 1 MB; the start tags of all elements open at once stop at 64 KB
402
+ together; comments and processing instructions stop at 64 KB a part;
403
+ nesting stops at 256 and namespace declarations at 1,024. A workbook part in
404
+ UTF-16, or one carrying megabyte comments, falls back to the download link.
405
+ - A workbook's tables: 4,096 relationships and 4,096 sheets (more is refused);
406
+ 4,096 custom number formats and 512 distinct formats in use (past either, a
407
+ number renders as General). A number format longer than Excel's 255
408
+ characters is ignored, and no number renders past thirty decimals.
409
+ - The first 1 MB of a CSV, the first 256 KB of a text file, and 100 MB for an
410
+ inline PDF or image.
411
+
412
+ A sheet or file cut by a cap says so and links the download.
413
+ - `Studio::KnowledgePreview` (`lib/studio/knowledge_preview.rb`), loaded on
414
+ first use and not by `require "studio"`: `kind_for(filename:, mime_type:)`,
415
+ `for(doc)` returning a `Result`, `read_spreadsheet(bytes)` and
416
+ `read_delimited(bytes)`. The workbook reader is the engine's own, on Zlib and
417
+ nokogiri; it adds no spreadsheet gem.
418
+ - `Studio::KnowledgeDoc#preview`, `#preview_kind` and `#filename`, and
419
+ `#signed_url(inline_as: "application/pdf")`.
420
+ - `Studio::S3.download(key:, max_bytes:)` reads only the head of an object (a
421
+ ranged GET; `max_bytes` must be a positive Integer), and `Studio::S3.signed_url`
422
+ takes `response_content_disposition:` and `response_content_type:`, signed into
423
+ the URL. Both are optional; existing calls are unchanged. The signing is tested
424
+ against an R2-style endpoint; that R2 serves the overridden headers is
425
+ documented by Cloudflare and not yet verified against a live bucket.
426
+
427
+ ### Changed
428
+
429
+ - The gemspec declares `csv` (`>= 3.0`). It left Ruby's default gems in 3.4 and
430
+ the CSV preview requires it. Apps whose lockfile already names `csv` see no
431
+ change; the rest gain one pure-Ruby gem on their next engine bump.
355
432
 
356
433
  ### Changed
357
434
 
data/README.md CHANGED
@@ -967,6 +967,16 @@ set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
967
967
  endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
968
968
  `upload` writes but returns `nil`; serve private objects with `signed_url`.
969
969
 
970
+ `signed_url` also takes `response_content_disposition:` and
971
+ `response_content_type:`, which are signed into the URL. On S3 they override the
972
+ headers the object is served with, and Cloudflare documents the same for R2; the
973
+ engine's tests cover the signing against an R2-style endpoint, and nobody has yet
974
+ verified the served headers against a live R2 bucket. `"inline"` plus a content
975
+ type the caller chose is how the knowledge layer shows a private PDF or image in
976
+ the page (`Studio::KnowledgeDoc#signed_url(inline_as:)`).
977
+ `download(key:, max_bytes:)` reads only the head of an object; `max_bytes` must
978
+ be a positive Integer.
979
+
970
980
  `Studio::S3.delete` moves the object to `trash/` for three days rather than
971
981
  deleting it; see [Trash and restore](#trash-and-restore).
972
982
 
@@ -13,7 +13,7 @@ module Studio
13
13
  # deliberate second step, not a side effect of upload.
14
14
  class KnowledgeDocsController < ApplicationController
15
15
  before_action :require_admin
16
- before_action :set_doc, only: [:show, :update, :download]
16
+ before_action :set_doc, only: [:show, :update, :download, :preview]
17
17
 
18
18
  VIEWS = %w[folders flat].freeze
19
19
 
@@ -112,8 +112,31 @@ module Studio
112
112
  redirect_to @doc.signed_url, allow_other_host: true
113
113
  end
114
114
 
115
+ # GET /admin/knowledge/:id/preview — the document read in the page. The
116
+ # show page loads this into a <turbo-frame>, so the bucket read and the
117
+ # parse happen here and never hold up the page itself.
118
+ #
119
+ # Always a 200: Studio::KnowledgePreview answers every failure (unknown
120
+ # type, over a cap, damaged, storage down) as a fallback Result carrying
121
+ # one sentence, rendered beside the download link. The response is
122
+ # no-store because it holds either the document's contents or a signed
123
+ # URL to them.
124
+ def preview
125
+ @preview = @doc.preview
126
+ log_preview_failure(@preview.error) if @preview.error
127
+ response.headers["Cache-Control"] = "no-store"
128
+ end
129
+
115
130
  private
116
131
 
132
+ # An unexpected failure behind a fallback is worth a record; a failure to
133
+ # record it is not worth the page.
134
+ def log_preview_failure(error)
135
+ create_error_log(error)
136
+ rescue StandardError => e
137
+ Rails.logger.warn "[knowledge preview] could not log #{error.class}: #{e.class}: #{e.message}"
138
+ end
139
+
117
140
  def expectation_params
118
141
  params.require(:knowledge_expectation)
119
142
  .permit(:entity, :title, :path, :category, :cadence, :start_on,
@@ -14,27 +14,17 @@
14
14
  // The stack, the focus trap and the registries are studio/modal_host, which
15
15
  // registers each host's store on alpine:init (a full load, where a controller
16
16
  // connects too late) and before a Turbo render. connect() registers it too, for
17
- // a Turbo visit and for markup that arrived any other way, and binds the
18
- // bfcache and Turbo snapshot cleanup to this host while it is on the page.
19
- // Registered by studio/application.
17
+ // a Turbo visit and for markup that arrived any other way. The bfcache and
18
+ // Turbo snapshot cleanup is studio/modal_host's own binding, one per document,
19
+ // so it holds when this controller never loads; binding it here as well would
20
+ // sweep each store twice. Registered by studio/application.
20
21
  import { Controller } from "@hotwired/stimulus"
21
- import { registerModalStore, clearStaleModals } from "studio/modal_host"
22
+ import { registerModalStore } from "studio/modal_host"
22
23
 
23
24
  export default class extends Controller {
24
25
  static values = { store: { type: String, default: "modals" }, scoped: Boolean }
25
26
 
26
27
  connect() {
27
- const name = this.storeValue
28
- registerModalStore(window.Alpine, { store: name, scoped: this.scopedValue }, { doc: document, win: window })
29
-
30
- this.onPageShow = (event) => { if (event.persisted) clearStaleModals(window, name) }
31
- this.onBeforeCache = () => clearStaleModals(window, name)
32
- window.addEventListener("pageshow", this.onPageShow)
33
- document.addEventListener("turbo:before-cache", this.onBeforeCache)
34
- }
35
-
36
- disconnect() {
37
- window.removeEventListener("pageshow", this.onPageShow)
38
- document.removeEventListener("turbo:before-cache", this.onBeforeCache)
28
+ registerModalStore(window.Alpine, { store: this.storeValue, scoped: this.scopedValue }, { doc: document, win: window })
39
29
  }
40
30
  }
@@ -12,7 +12,8 @@
12
12
  // data-modal-host-store-value="modals" (the scoped host: its own name)
13
13
  // data-modal-host-scoped-value="true" (the scoped host only)
14
14
  //
15
- // and studio/controllers/modal_host_controller binds the element's lifetime.
15
+ // and studio/controllers/modal_host_controller registers the host's store when
16
+ // the element connects.
16
17
  //
17
18
  // WHEN A STORE REGISTERS, AND WHY NOT IN connect(). Alpine evaluates
18
19
  // $store.<name>.current() as it starts, which is BEFORE Stimulus connects any
@@ -538,9 +539,9 @@ export function registerHostsIn(root, env) {
538
539
  for (var i = 0; i < hosts.length; i++) registerModalStore(env.win.Alpine, hosts[i], env)
539
540
  }
540
541
 
541
- // The bfcache and Turbo snapshot cleanup: a modal left open does not reappear
542
- // on the next visit, but a dismissible: false card survives (see
543
- // closeAllDismissible). Bound per host by the controller.
542
+ // The bfcache and Turbo snapshot cleanup for one store: a modal left open does
543
+ // not reappear on the next visit, but a dismissible: false card survives (see
544
+ // closeAllDismissible).
544
545
  export function clearStaleModals(win, name) {
545
546
  if (!win.Alpine || !win.Alpine.store) return
546
547
  var store = win.Alpine.store(name)
@@ -548,7 +549,19 @@ export function clearStaleModals(win, name) {
548
549
  else if (store && typeof store.closeAll === 'function') store.closeAll()
549
550
  }
550
551
 
551
- // Installs the globals and the three registration paths, once per document.
552
+ // Sweeps the store of every host on the page, each store once.
553
+ export function clearStaleModalsIn(doc, win) {
554
+ var hosts = hostsIn(doc)
555
+ var swept = []
556
+ for (var i = 0; i < hosts.length; i++) {
557
+ if (swept.indexOf(hosts[i].store) !== -1) continue
558
+ swept.push(hosts[i].store)
559
+ clearStaleModals(win, hosts[i].store)
560
+ }
561
+ }
562
+
563
+ // Installs the globals, the three registration paths and the stale-modal
564
+ // sweep, once per document.
552
565
  var installedOn = new WeakSet()
553
566
 
554
567
  export function installModalHost(doc, win) {
@@ -565,6 +578,13 @@ export function installModalHost(doc, win) {
565
578
  doc.addEventListener('turbo:before-frame-render', function (event) {
566
579
  if (event.detail) registerHostsIn(event.detail.newFrame, env)
567
580
  })
581
+ // THE SWEEP IS BOUND HERE, the one place, and not by the controller: this
582
+ // module loads by its own tag, so the sweep survives a boot that fails to
583
+ // load, and one binding per document sweeps each store once per event.
584
+ win.addEventListener('pageshow', function (event) {
585
+ if (event.persisted) clearStaleModalsIn(doc, win)
586
+ })
587
+ doc.addEventListener('turbo:before-cache', function () { clearStaleModalsIn(doc, win) })
568
588
  // Alpine already running (a host that loads it ahead of the module tags).
569
589
  if (win.Alpine && win.Alpine.version) registerHostsIn(doc, env)
570
590
  }
@@ -167,10 +167,45 @@ module Studio
167
167
  end
168
168
 
169
169
  # 15-minute presigned GET — the only way a private object leaves the bucket.
170
- def signed_url(expires_in: 900)
170
+ #
171
+ # inline_as: a content type. The URL then serves the object with
172
+ # Content-Disposition: inline and THAT type, whatever was stored, which is
173
+ # what lets the show page embed a PDF or an image (Studio::KnowledgePreview
174
+ # picks the type from the file's kind, never from mime_type).
175
+ def signed_url(expires_in: 900, inline_as: nil)
171
176
  raise Studio::S3::Error, "no file attached to #{title.inspect}" unless file?
172
177
 
173
- Studio::S3.signed_url(key: s3_key, expires_in: expires_in)
178
+ if inline_as
179
+ Studio::S3.signed_url(key: s3_key, expires_in: expires_in,
180
+ response_content_disposition: "inline", response_content_type: inline_as)
181
+ else
182
+ Studio::S3.signed_url(key: s3_key, expires_in: expires_in)
183
+ end
184
+ end
185
+
186
+ # The stored object's file name (the upload's name, sanitized, behind the
187
+ # key's timestamp prefix). There is no filename column; the key carries it.
188
+ def filename
189
+ File.basename(s3_key.to_s)
190
+ end
191
+
192
+ # --- preview ----------------------------------------------------------------
193
+
194
+ # :spreadsheet, :delimited, :pdf, :image, :text, or nil when the show page
195
+ # can only offer the download. Reads nothing from the bucket.
196
+ def preview_kind
197
+ return nil unless file?
198
+
199
+ require "studio/knowledge_preview"
200
+ Studio::KnowledgePreview.kind_for(filename: filename, mime_type: mime_type)
201
+ end
202
+
203
+ # The Studio::KnowledgePreview::Result for this document. Reads the
204
+ # bucket, so the show page loads it lazily (KnowledgeDocsController#preview)
205
+ # rather than calling it inline. Never raises.
206
+ def preview
207
+ require "studio/knowledge_preview"
208
+ Studio::KnowledgePreview.for(self)
174
209
  end
175
210
 
176
211
  # --- lifecycle ------------------------------------------------------------
@@ -0,0 +1,66 @@
1
+ <%# A document's inline preview, by kind. locals: preview (a
2
+ Studio::KnowledgePreview::Result), doc. Stored content is only ever
3
+ emitted as escaped text: a table cell, a <pre>, or (for a PDF or image)
4
+ not at all, the browser fetching it from a signed URL. %>
5
+ <%= render "studio/knowledge_docs/preview_styles" %>
6
+ <div id="knowledge-preview-body" data-preview-kind="<%= preview.kind %>">
7
+ <% case preview.kind %>
8
+ <% when :table %>
9
+ <% sheets = preview.sheets %>
10
+ <% if sheets.size > 1 || sheets.first.name.present? %>
11
+ <%# One radio per sheet, then the tabs, then the panels: all siblings,
12
+ so :checked can reach both the tab's look and the panel's display. %>
13
+ <style>
14
+ <% sheets.each_index do |index| %>
15
+ #knowledge-sheet-radio-<%= index %>:checked ~ .knowledge-sheet-tabs label[for="knowledge-sheet-radio-<%= index %>"] { opacity: 1; font-weight: 600; background: rgb(148 163 184 / .18); }
16
+ #knowledge-sheet-radio-<%= index %>:checked ~ .knowledge-sheet-panels #knowledge-sheet-panel-<%= index %> { display: block; }
17
+ <% end %>
18
+ </style>
19
+ <% sheets.each_index do |index| %>
20
+ <input type="radio" name="knowledge-sheet" class="knowledge-sheet-radio"
21
+ id="knowledge-sheet-radio-<%= index %>"<%= " checked".html_safe if index.zero? %>>
22
+ <% end %>
23
+ <div class="knowledge-sheet-tabs" id="knowledge-sheet-tabs">
24
+ <% sheets.each_with_index do |sheet, index| %>
25
+ <label class="knowledge-sheet-tab" for="knowledge-sheet-radio-<%= index %>"><%= sheet.name.presence || "Sheet #{index + 1}" %><%= " (hidden)" if sheet.hidden %></label>
26
+ <% end %>
27
+ </div>
28
+ <% if preview.omitted_sheets.to_i.positive? %>
29
+ <p class="knowledge-preview-note" id="knowledge-preview-omitted-sheets">
30
+ <%= pluralize(preview.omitted_sheets, "more sheet") %> not shown;
31
+ <%= link_to "download", admin_knowledge_doc_download_path(doc), data: { turbo: false } %> for the full file.
32
+ </p>
33
+ <% end %>
34
+ <div class="knowledge-sheet-panels">
35
+ <% sheets.each_with_index do |sheet, index| %>
36
+ <div class="knowledge-sheet-panel" id="knowledge-sheet-panel-<%= index %>">
37
+ <%= render "studio/knowledge_docs/preview_sheet", sheet: sheet, doc: doc %>
38
+ </div>
39
+ <% end %>
40
+ </div>
41
+ <% else %>
42
+ <%= render "studio/knowledge_docs/preview_sheet", sheet: sheets.first, doc: doc %>
43
+ <% end %>
44
+ <% when :text %>
45
+ <% if preview.truncated %>
46
+ <p class="knowledge-preview-note" data-preview-truncated>
47
+ Showing the start of this file;
48
+ <%= link_to "download", admin_knowledge_doc_download_path(doc), data: { turbo: false } %> for the full file.
49
+ </p>
50
+ <% end %>
51
+ <pre class="knowledge-preview-text" id="knowledge-preview-text"><%= preview.text %></pre>
52
+ <% when :pdf %>
53
+ <%# No sandbox attribute, on purpose: a sandboxed frame does not get
54
+ Chromium's built-in PDF viewer, so the document would not show. What
55
+ guards this frame is that the bucket serves the object as
56
+ application/pdf whatever was stored (signed into the URL), from the
57
+ bucket's own origin, which holds no session of this app. %>
58
+ <iframe class="knowledge-preview-pdf" id="knowledge-preview-pdf" src="<%= preview.url %>"
59
+ title="<%= doc.title %>" referrerpolicy="no-referrer" loading="lazy"></iframe>
60
+ <% when :image %>
61
+ <img class="knowledge-preview-image" id="knowledge-preview-image" src="<%= preview.url %>"
62
+ alt="<%= doc.title %>" referrerpolicy="no-referrer">
63
+ <% else %>
64
+ <%= render "studio/knowledge_docs/preview_fallback", reason: preview.reason, doc: doc %>
65
+ <% end %>
66
+ </div>
@@ -0,0 +1,9 @@
1
+ <%# The preview that could not be shown: one plain sentence and the same
2
+ download link the page header carries. locals: reason, doc. %>
3
+ <p id="knowledge-preview-fallback" class="knowledge-preview-note">
4
+ <%= reason %>
5
+ <% if doc.file? %>
6
+ <%= link_to "Download it instead (15-min link)", admin_knowledge_doc_download_path(doc),
7
+ id: "knowledge-preview-download", data: { turbo: false } %>
8
+ <% end %>
9
+ </p>
@@ -0,0 +1,43 @@
1
+ <%# One sheet (or a whole CSV) as a grid: column letters across the top and
2
+ row numbers down the side, as the spreadsheet itself shows them, so a
3
+ figure can be named by its cell. locals: sheet (KnowledgePreview::Sheet),
4
+ doc. Every cell is escaped text. %>
5
+ <% width = sheet.rows.first.to_a.size %>
6
+ <% if sheet.truncated_rows || sheet.truncated_columns %>
7
+ <p class="knowledge-preview-note" data-preview-truncated>
8
+ Showing the first
9
+ <%= [ ("#{number_with_delimiter(sheet.row_limit)} rows" if sheet.truncated_rows),
10
+ ("#{width} columns" if sheet.truncated_columns) ].compact.to_sentence %>;
11
+ <%= link_to "download", admin_knowledge_doc_download_path(doc), data: { turbo: false } %> for the full file.
12
+ </p>
13
+ <% end %>
14
+ <% if sheet.rows.empty? %>
15
+ <p class="knowledge-preview-note" data-preview-empty>This sheet is empty.</p>
16
+ <% else %>
17
+ <div class="knowledge-preview-scroll">
18
+ <table class="knowledge-preview-table">
19
+ <thead>
20
+ <tr>
21
+ <th></th>
22
+ <% width.times do |index| %>
23
+ <th scope="col"><%= Studio::KnowledgePreview.column_letter(index) %></th>
24
+ <% end %>
25
+ </tr>
26
+ </thead>
27
+ <tbody>
28
+ <% sheet.rows.each_with_index do |row, index| %>
29
+ <tr>
30
+ <th scope="row"><%= index + 1 %></th>
31
+ <% row.each do |cell| %>
32
+ <% if cell %>
33
+ <td<%= ' class="knowledge-cell-number"'.html_safe if cell.numeric %>><%= cell.text %></td>
34
+ <% else %>
35
+ <td></td>
36
+ <% end %>
37
+ <% end %>
38
+ </tr>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <%# The preview's look. A scoped style block, like the chip styles, because a
2
+ host's Tailwind build may not scan this gem's views. Colours are slate at
3
+ low alpha over whatever the host paints, so one definition reads in a
4
+ light theme and a dark one. The sheet tabs are radio buttons and sibling
5
+ selectors: no script, so no dependency on the host's Alpine or its CSP. %>
6
+ <style>
7
+ .knowledge-preview-note { font-size: .8rem; opacity: .75; margin: 0 0 .5rem; }
8
+ .knowledge-preview-scroll { overflow: auto; max-height: 70vh; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
9
+ .knowledge-preview-table { border-collapse: collapse; font-size: .8rem; line-height: 1.3; font-variant-numeric: tabular-nums; }
10
+ .knowledge-preview-table th, .knowledge-preview-table td { border: 1px solid rgb(148 163 184 / .3); padding: .2rem .5rem;
11
+ white-space: pre; max-width: 28rem; overflow: hidden; text-overflow: ellipsis; text-align: left; vertical-align: top; }
12
+ .knowledge-preview-table th { background: rgb(148 163 184 / .14); font-weight: 500; opacity: .8; text-align: center; }
13
+ .knowledge-preview-table td.knowledge-cell-number { text-align: right; }
14
+ .knowledge-preview-text { overflow: auto; max-height: 70vh; margin: 0; padding: .75rem; font-size: .8rem; line-height: 1.45;
15
+ white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
16
+ .knowledge-preview-pdf { display: block; width: 100%; height: 80vh; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
17
+ .knowledge-preview-image { display: block; max-width: 100%; height: auto; border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; }
18
+ .knowledge-sheet-radio { position: absolute; opacity: 0; pointer-events: none; }
19
+ .knowledge-sheet-tabs { display: flex; flex-wrap: wrap; gap: .25rem; margin-bottom: .5rem; }
20
+ .knowledge-sheet-tab { padding: .2rem .65rem; font-size: .8rem; cursor: pointer; white-space: nowrap;
21
+ border: 1px solid rgb(148 163 184 / .4); border-radius: .375rem; opacity: .7; }
22
+ .knowledge-sheet-panel { display: none; }
23
+ .knowledge-sheet-radio:focus-visible ~ .knowledge-sheet-tabs { outline: 2px solid rgb(59 130 246 / .7); outline-offset: 2px; }
24
+ </style>
@@ -0,0 +1,12 @@
1
+ <%# /admin/knowledge/:id/preview — the body the show page's turbo-frame loads.
2
+ The frame tag is written by hand so the view needs no Turbo helper. Opened
3
+ directly (a browser without Turbo follows the frame's own link here), it
4
+ is a small page of its own with a way back. %>
5
+ <% unless request.headers["Turbo-Frame"].present? %>
6
+ <div class="mb-4 text-sm">
7
+ <%= link_to "← #{@doc.title}", admin_knowledge_doc_path(@doc) %>
8
+ </div>
9
+ <% end %>
10
+ <turbo-frame id="knowledge-preview">
11
+ <%= render "studio/knowledge_docs/preview", preview: @preview, doc: @doc %>
12
+ </turbo-frame>
@@ -1,5 +1,5 @@
1
- <%# /admin/knowledge/:id — one document: metadata, access, download, and the
2
- triage form that files it out of the inbox. %>
1
+ <%# /admin/knowledge/:id — one document: metadata, access, download, the
2
+ inline preview, and the triage form that files it out of the inbox. %>
3
3
  <%= render "studio/knowledge_docs/chip_styles" %>
4
4
 
5
5
  <div class="mb-4 text-sm">
@@ -34,6 +34,30 @@
34
34
  </div>
35
35
  <% end %>
36
36
 
37
+ <% if @doc.file? %>
38
+ <div id="knowledge-preview-section" class="mb-6">
39
+ <h2 class="font-semibold mb-2">Preview</h2>
40
+ <% if @doc.preview_kind %>
41
+ <%# Lazy: the frame fetches the preview once it scrolls into view, so
42
+ this page never waits on a bucket read or a parse. Without Turbo
43
+ the link inside is the way in. %>
44
+ <turbo-frame id="knowledge-preview" loading="lazy" src="<%= admin_knowledge_doc_preview_path(@doc) %>">
45
+ <p class="text-sm opacity-70">
46
+ Loading the preview…
47
+ <%= link_to "Open it", admin_knowledge_doc_preview_path(@doc), id: "knowledge-preview-open" %>
48
+ </p>
49
+ </turbo-frame>
50
+ <% else %>
51
+ <%# A kind with no preview is known from the row alone: say so here
52
+ rather than spend a request to learn it. %>
53
+ <p id="knowledge-preview-fallback" class="text-sm opacity-70">
54
+ There is no inline preview for this kind of file.
55
+ <%= link_to "Download it instead (15-min link)", admin_knowledge_doc_download_path(@doc), id: "knowledge-preview-download" %>
56
+ </p>
57
+ <% end %>
58
+ </div>
59
+ <% end %>
60
+
37
61
  <div id="knowledge-triage" class="p-4 border rounded">
38
62
  <h2 class="font-semibold mb-3">File it</h2>
39
63
  <%= form_with url: admin_knowledge_doc_path(@doc), method: :patch, local: true do |form| %>