recourse 3.0.4 → 4.0.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 +4 -4
- data/CHANGELOG.md +234 -0
- data/README.md +150 -20
- data/app/controllers/bookmarks_controller.rb +7 -0
- data/app/controllers/concerns/recourse/landing.rb +49 -0
- data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
- data/app/controllers/concerns/recourse/paging.rb +17 -0
- data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
- data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
- data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
- data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
- data/app/controllers/concerns/recourse/zoning.rb +29 -0
- data/app/controllers/recourse/base_controller.rb +85 -0
- data/app/controllers/recourse/bookmarks_controller.rb +76 -0
- data/app/controllers/recourses_controller.rb +6 -25
- data/app/javascript/recourse/bookmark_controller.js +100 -0
- data/app/javascript/recourse/clear_controller.js +22 -0
- data/app/javascript/recourse/combobox_controller.js +68 -0
- data/app/javascript/recourse/confirm.js +46 -0
- data/app/javascript/recourse/deselect_controller.js +37 -0
- data/app/javascript/recourse/flash.js +35 -0
- data/app/javascript/recourse/limit_controller.js +23 -0
- data/app/javascript/recourse/phone_controller.js +33 -0
- data/app/javascript/recourse/relative_time_controller.js +45 -0
- data/app/javascript/recourse/reveal_controller.js +16 -0
- data/app/javascript/recourse/scheme_controller.js +51 -0
- data/app/javascript/recourse/search_controller.js +78 -0
- data/app/javascript/recourse/shortcuts_controller.js +42 -0
- data/app/javascript/recourse/timezone_controller.js +27 -0
- data/app/javascript/recourse/toast_controller.js +35 -0
- data/app/javascript/recourse/tooltip_controller.js +16 -0
- data/app/javascript/recourse/written.js +29 -0
- data/app/javascript/recourse/written_controller.js +28 -0
- data/app/views/layouts/recourses.html.erb +442 -0
- data/app/views/recourses/_breadcrumb.html.erb +35 -0
- data/app/views/recourses/_card.html.erb +33 -0
- data/app/views/recourses/_color.html.erb +20 -0
- data/app/views/recourses/_combobox.html.erb +68 -0
- data/app/views/recourses/_confirm.html.erb +14 -0
- data/app/views/recourses/_fields.html.erb +3 -0
- data/app/views/recourses/_flash.html.erb +26 -0
- data/app/views/recourses/_footer.html.erb +22 -0
- data/app/views/recourses/_form.html.erb +11 -0
- data/app/views/recourses/_none.html.erb +1 -0
- data/app/views/recourses/_results.html.erb +10 -0
- data/app/views/recourses/_row.html.erb +4 -10
- data/app/views/recourses/_scheme.html.erb +14 -0
- data/app/views/recourses/_search.html.erb +24 -0
- data/app/views/recourses/_sidebar.html.erb +40 -0
- data/app/views/recourses/_table.html.erb +47 -42
- data/app/views/recourses/_values.html.erb +3 -0
- data/app/views/recourses/edit.html.erb +10 -0
- data/app/views/recourses/index.html.erb +27 -24
- data/app/views/recourses/new.html.erb +3 -0
- data/app/views/recourses/show.html.erb +10 -0
- data/config/locales/recourse.en.yml +72 -0
- data/lib/recourse/bookmarks.rb +51 -0
- data/lib/recourse/broadcasting.rb +33 -0
- data/lib/recourse/colors.rb +32 -0
- data/lib/recourse/columns.rb +80 -0
- data/lib/recourse/controllers.rb +26 -0
- data/lib/recourse/engine.rb +33 -13
- data/lib/recourse/helpers/actions.rb +56 -0
- data/lib/recourse/helpers/bookmarks.rb +88 -0
- data/lib/recourse/helpers/buttons.rb +65 -0
- data/lib/recourse/helpers/cards.rb +67 -0
- data/lib/recourse/helpers/cells.rb +89 -0
- data/lib/recourse/helpers/choices.rb +49 -0
- data/lib/recourse/helpers/colors.rb +20 -0
- data/lib/recourse/helpers/comboboxes.rb +87 -0
- data/lib/recourse/helpers/constraints.rb +92 -0
- data/lib/recourse/helpers/counters.rb +75 -0
- data/lib/recourse/helpers/deletions.rb +87 -0
- data/lib/recourse/helpers/details.rb +41 -0
- data/lib/recourse/helpers/examples.rb +35 -0
- data/lib/recourse/helpers/fields.rb +78 -0
- data/lib/recourse/helpers/filters.rb +91 -0
- data/lib/recourse/helpers/formats.rb +94 -0
- data/lib/recourse/helpers/inputs.rb +92 -0
- data/lib/recourse/helpers/kinds.rb +87 -0
- data/lib/recourse/helpers/limits.rb +37 -0
- data/lib/recourse/helpers/names.rb +44 -0
- data/lib/recourse/helpers/navigation.rb +75 -0
- data/lib/recourse/helpers/parents.rb +82 -0
- data/lib/recourse/helpers/pictures.rb +33 -0
- data/lib/recourse/helpers/references.rb +99 -0
- data/lib/recourse/helpers/refreshes.rb +27 -0
- data/lib/recourse/helpers/resources.rb +47 -0
- data/lib/recourse/helpers/routing.rb +28 -0
- data/lib/recourse/helpers/rows.rb +33 -0
- data/lib/recourse/helpers/schemes.rb +14 -0
- data/lib/recourse/helpers/searches.rb +57 -0
- data/lib/recourse/helpers/shortcuts.rb +30 -0
- data/lib/recourse/helpers/sidebars.rb +39 -0
- data/lib/recourse/helpers/sorts.rb +62 -0
- data/lib/recourse/helpers/tabs.rb +82 -0
- data/lib/recourse/helpers/times.rb +43 -0
- data/lib/recourse/helpers/values.rb +64 -0
- data/lib/recourse/helpers/zones.rb +38 -0
- data/lib/recourse/helpers.rb +81 -0
- data/lib/recourse/icons.rb +21 -0
- data/lib/recourse/limits.rb +12 -0
- data/lib/recourse/recoursive.rb +66 -19
- data/lib/recourse/registry.rb +36 -0
- data/lib/recourse/routes/nested.rb +36 -0
- data/lib/recourse/routes.rb +57 -0
- data/lib/recourse/routing.rb +11 -32
- data/lib/recourse/schemes.rb +6 -0
- data/lib/recourse/scopes.rb +37 -0
- data/lib/recourse/search.rb +65 -0
- data/lib/recourse/searchable/columns.rb +99 -0
- data/lib/recourse/searchable/filters.rb +58 -0
- data/lib/recourse/searchable/terms.rb +56 -0
- data/lib/recourse/searchable.rb +47 -22
- data/lib/recourse/titles.rb +56 -0
- data/lib/recourse/version.rb +2 -1
- data/lib/recourse/writes.rb +12 -0
- data/lib/recourse/zones.rb +6 -0
- data/lib/recourse.rb +79 -7
- data/vendor/recourse/bootstrap-icons.min.css +5 -0
- data/vendor/recourse/bootstrap.bundle.min.js +9 -0
- data/vendor/recourse/bootstrap.min.css +2 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- data/vendor/recourse/stimulus.js +2563 -0
- metadata +169 -30
- data/Rakefile +0 -6
- data/app/helpers/navigable_helper.rb +0 -36
- data/app/helpers/recoursive_helper.rb +0 -10
- data/app/helpers/searchable_helper.rb +0 -54
- data/db/migrate/20260323234318_add_baby_to_posts.rb +0 -5
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
|
|
3
|
+
// How much of a table one page shows. The choice belongs to the reader rather than
|
|
4
|
+
// to the app, so it is kept in their browser — but in a cookie rather than in local
|
|
5
|
+
// storage, which is where the scheme goes: pagy decides the page on the server, and
|
|
6
|
+
// a cookie is the only storage the server is sent.
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static values = { storage: String, to: Number }
|
|
9
|
+
|
|
10
|
+
// Back to the first page, always: page five of twenty is past the end of a hundred
|
|
11
|
+
// to a page, and pagy answers that with an empty table rather than an error. And
|
|
12
|
+
// only the frame, so what is redrawn is the table and the row under it — the answer
|
|
13
|
+
// brings the button back naming the size a click would go to next.
|
|
14
|
+
toggle() {
|
|
15
|
+
document.cookie =
|
|
16
|
+
`${this.storageValue}=${this.toValue}; path=/; max-age=31536000; samesite=lax`
|
|
17
|
+
|
|
18
|
+
const url = new URL(window.location.href)
|
|
19
|
+
url.searchParams.delete('page')
|
|
20
|
+
|
|
21
|
+
window.Turbo.visit(url.href, { frame: 'results', action: 'replace' })
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
connect() {
|
|
5
|
+
this.#format(this.element)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
down(event) {
|
|
9
|
+
if (!event.key) { return }
|
|
10
|
+
if (event.ctrlKey) { return }
|
|
11
|
+
if (event.metaKey) { return }
|
|
12
|
+
if (event.key.length > 1) { return }
|
|
13
|
+
if (/[0-9.]/.test(event.key)) { return }
|
|
14
|
+
event.preventDefault()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
input(event) {
|
|
18
|
+
if (event.inputType === 'deleteContentBackward') { return }
|
|
19
|
+
if (/[\d-]{12}/.test(event.target.value)) { event.preventDefault() }
|
|
20
|
+
this.#format(event.target)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#format(element) {
|
|
24
|
+
const digits = element.value.replace(/\D/g,'').substring(0, 10)
|
|
25
|
+
const areaCode = digits.substring(0, 3)
|
|
26
|
+
const prefix = digits.substring(3, 6)
|
|
27
|
+
const suffix = digits.substring(6, 10)
|
|
28
|
+
|
|
29
|
+
if (digits.length > 5) { element.value = `${areaCode}-${prefix}-${suffix}` }
|
|
30
|
+
else if (digits.length > 2) { element.value = `${areaCode}-${prefix}` }
|
|
31
|
+
else if (digits.length > 0) { element.value = `${areaCode}` }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
import { Tooltip } from '/recourse/bootstrap.bundle.min.js'
|
|
3
|
+
|
|
4
|
+
// Largest first, so the first one an instant clears is the one it is said in.
|
|
5
|
+
const UNITS = [
|
|
6
|
+
['year', 31536000000], ['month', 2592000000], ['week', 604800000],
|
|
7
|
+
['day', 86400000], ['hour', 3600000], ['minute', 60000],
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
// How long ago a timestamp was, said again at the moment somebody asks. The server
|
|
11
|
+
// writes the same words with Rails' own helper, which is what a reader without
|
|
12
|
+
// JavaScript gets — but a table is cached and a page is left open, so those words are
|
|
13
|
+
// only true when they are drawn. These are true when they are read.
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
// Before the `tooltip` controller beside it, which is what makes the instance: this
|
|
16
|
+
// listener is registered first and so runs before Bootstrap's own.
|
|
17
|
+
connect() {
|
|
18
|
+
this.entered = () => this.#refresh()
|
|
19
|
+
this.element.addEventListener('mouseenter', this.entered)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
disconnect() {
|
|
23
|
+
this.element.removeEventListener('mouseenter', this.entered)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Through `setContent`, since Bootstrap reads a tooltip's words once when it is made
|
|
27
|
+
// and never looks at the attribute again.
|
|
28
|
+
#refresh() {
|
|
29
|
+
const at = new Date(this.element.getAttribute('datetime'))
|
|
30
|
+
if (isNaN(at.getTime())) { return }
|
|
31
|
+
|
|
32
|
+
Tooltip.getInstance(this.element)?.setContent({ '.tooltip-inner': this.#words(at) })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#words(at) {
|
|
36
|
+
const format = new Intl.RelativeTimeFormat(document.documentElement.lang || 'en')
|
|
37
|
+
const ms = at - new Date()
|
|
38
|
+
|
|
39
|
+
for (const [unit, size] of UNITS) {
|
|
40
|
+
if (Math.abs(ms) >= size) { return format.format(Math.round(ms / size), unit) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return format.format(Math.round(ms / 1000), 'second')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
|
|
3
|
+
// One masked value and the click that unmasks it. The plaintext arrives with the
|
|
4
|
+
// page rather than being fetched: the mask is against a screenshot, not against
|
|
5
|
+
// whoever is already reading the record.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static targets = ['mask', 'button']
|
|
8
|
+
static values = { plain: String }
|
|
9
|
+
|
|
10
|
+
show() {
|
|
11
|
+
this.maskTarget.textContent = this.plainValue
|
|
12
|
+
// Nothing left for it to do, and a link that reveals what is already revealed
|
|
13
|
+
// reads as though there were more to see.
|
|
14
|
+
this.buttonTarget.remove()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
|
|
3
|
+
// The sidebar's one control over how a page looks. A click moves it into the mode it is
|
|
4
|
+
// not in, which is what the icon promises: a moon while the page is light, a sun while
|
|
5
|
+
// it is dark. The choice belongs to the reader rather than to the app, so it is kept in
|
|
6
|
+
// their browser — the layout's own script is what puts it back before the first paint,
|
|
7
|
+
// a controller connecting far too late for that.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static values = { storage: String }
|
|
10
|
+
|
|
11
|
+
// Turbo merges the head on a visit, and the layout's script runs only on a full load.
|
|
12
|
+
// The sidebar is drawn again on every visit, so connecting is the moment to say it again.
|
|
13
|
+
connect() {
|
|
14
|
+
const stored = this.#stored()
|
|
15
|
+
|
|
16
|
+
if (stored) { this.#apply(stored.mode) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The other mode, remembered.
|
|
20
|
+
rotate() {
|
|
21
|
+
const mode = this.#mode() === 'dark' ? 'light' : 'dark'
|
|
22
|
+
|
|
23
|
+
this.#apply(mode)
|
|
24
|
+
localStorage.setItem(this.storageValue, JSON.stringify({ mode }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The mode goes onto the element every `light-dark()` on the page is resolved against.
|
|
28
|
+
// Checked first: the storage is the reader's own, but what comes back out of it is
|
|
29
|
+
// still not something to write onto the page unread.
|
|
30
|
+
#apply(mode) {
|
|
31
|
+
if (mode === 'light' || mode === 'dark') {
|
|
32
|
+
document.documentElement.dataset.bsTheme = mode
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// What the reader picked last, or nothing at all where they never have.
|
|
37
|
+
#stored() {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(localStorage.getItem(this.storageValue))
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Which mode the page is in: whatever has been forced onto it, and otherwise whatever
|
|
46
|
+
// the system asks for, since a page nobody has chosen for follows that.
|
|
47
|
+
#mode() {
|
|
48
|
+
return document.documentElement.dataset.bsTheme ||
|
|
49
|
+
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
|
|
3
|
+
// Survives the visit that a submit starts, since the module is not reloaded with
|
|
4
|
+
// the page: it is how the next controller knows the search box was being typed in.
|
|
5
|
+
// Only a submit that replaces the whole page needs it — a frame leaves the form,
|
|
6
|
+
// and the caret in it, exactly where they were.
|
|
7
|
+
let typing = false
|
|
8
|
+
|
|
9
|
+
export default class extends Controller {
|
|
10
|
+
static targets = ['field']
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
// Not on a cached preview: the real render connects a second time, and it is
|
|
14
|
+
// the one whose field can still be typed into.
|
|
15
|
+
if (typing && !document.documentElement.hasAttribute('data-turbo-preview')) {
|
|
16
|
+
typing = false
|
|
17
|
+
this.#restore()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A combobox writes its hidden input from JavaScript, which fires no `change`
|
|
21
|
+
// of its own. Bootstrap's own event bubbles, so one listener on the form hears
|
|
22
|
+
// every menu in it, and each tick or untick narrows the table straight away.
|
|
23
|
+
// Coalesced to the end of the turn, because emptying a filter unticks every
|
|
24
|
+
// option in it — one request for the table that leaves, not one per option.
|
|
25
|
+
this.picked = () => {
|
|
26
|
+
clearTimeout(this.pick)
|
|
27
|
+
this.pick = setTimeout(() => this.#submit(), 0)
|
|
28
|
+
}
|
|
29
|
+
this.element.addEventListener('change.bs.combobox', this.picked)
|
|
30
|
+
|
|
31
|
+
// The frame is not inside the form, so its events never reach it.
|
|
32
|
+
this.reloaded = () => { typing = false; this.#syncSort() }
|
|
33
|
+
document.addEventListener('turbo:frame-load', this.reloaded)
|
|
34
|
+
|
|
35
|
+
// A refresh morph would write the fetched page's older query over what is
|
|
36
|
+
// mid-typing, so the form's subtree sits a morph out — the text, the caret
|
|
37
|
+
// and any open filter menu stay. Only a morph: a page visit still renders
|
|
38
|
+
// every page's own form, which `turbo-permanent` here would carry across.
|
|
39
|
+
this.morphing = (event) => { if (this.element.contains(event.target)) event.preventDefault() }
|
|
40
|
+
document.addEventListener('turbo:before-morph-element', this.morphing)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
disconnect() {
|
|
44
|
+
this.element.removeEventListener('change.bs.combobox', this.picked)
|
|
45
|
+
document.removeEventListener('turbo:frame-load', this.reloaded)
|
|
46
|
+
document.removeEventListener('turbo:before-morph-element', this.morphing)
|
|
47
|
+
clearTimeout(this.timer)
|
|
48
|
+
clearTimeout(this.pick)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
submit() {
|
|
52
|
+
clearTimeout(this.timer)
|
|
53
|
+
this.timer = setTimeout(() => { typing = true; this.#submit() }, 300)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#submit() {
|
|
57
|
+
this.element.requestSubmit()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A heading sorts by navigating the frame, which advances the address bar but
|
|
61
|
+
// leaves this form alone — so the sort it carries has to be read back off the URL,
|
|
62
|
+
// or the next search would reorder the table by whatever was in force before.
|
|
63
|
+
#syncSort() {
|
|
64
|
+
const field = this.element.querySelector('input[name="q[s]"]')
|
|
65
|
+
|
|
66
|
+
if (field) { field.value = new URL(window.location.href).searchParams.get('q[s]') || '' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The value comes back from the server, so only the caret has to be put back,
|
|
70
|
+
// and at the end of it — typing carries on where it left off.
|
|
71
|
+
#restore() {
|
|
72
|
+
if (!this.hasFieldTarget) { return }
|
|
73
|
+
|
|
74
|
+
const field = this.fieldTarget
|
|
75
|
+
field.focus({ preventScroll: true })
|
|
76
|
+
field.setSelectionRange(field.value.length, field.value.length)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
|
|
3
|
+
// Option is the modifier. Control and Command are spoken for — Control+C and
|
|
4
|
+
// Command+C are both copy — while Option is what a browser's own accesskey reaches
|
|
5
|
+
// for on most platforms, and it collides with almost nothing a page would want.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
connect() {
|
|
8
|
+
this.reveal = (event) => this.element.classList.toggle('recourse-keys', event.altKey)
|
|
9
|
+
this.hide = () => this.element.classList.remove('recourse-keys')
|
|
10
|
+
this.press = (event) => this.#press(event)
|
|
11
|
+
|
|
12
|
+
document.addEventListener('keydown', this.reveal)
|
|
13
|
+
document.addEventListener('keyup', this.reveal)
|
|
14
|
+
document.addEventListener('keydown', this.press)
|
|
15
|
+
// Holding Option and switching windows would otherwise leave the hints up.
|
|
16
|
+
window.addEventListener('blur', this.hide)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
document.removeEventListener('keydown', this.reveal)
|
|
21
|
+
document.removeEventListener('keyup', this.reveal)
|
|
22
|
+
document.removeEventListener('keydown', this.press)
|
|
23
|
+
window.removeEventListener('blur', this.hide)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#press(event) {
|
|
27
|
+
if (!event.altKey || event.ctrlKey || event.metaKey) { return }
|
|
28
|
+
|
|
29
|
+
// On a Mac, Option+c is 'ç', so the key pressed is read from the keyboard's own
|
|
30
|
+
// layout-independent code rather than from the character it produced.
|
|
31
|
+
const pressed = event.code.match(/^Key([A-Z])$/)
|
|
32
|
+
if (!pressed) { return }
|
|
33
|
+
|
|
34
|
+
const link = this.element.querySelector(`[data-key="${pressed[1].toLowerCase()}"]`)
|
|
35
|
+
if (!link) { return }
|
|
36
|
+
|
|
37
|
+
event.preventDefault()
|
|
38
|
+
this.hide()
|
|
39
|
+
// Clicked rather than assigned to, so Turbo takes the visit like any other link.
|
|
40
|
+
link.click()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
|
|
3
|
+
// The reader's own zone, told to the server so it can draw their pages against their
|
|
4
|
+
// clock. A cookie rather than local storage, for the reason the page size uses one:
|
|
5
|
+
// the server does the converting, and a cookie is the only storage the server is sent.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static values = { storage: String }
|
|
8
|
+
|
|
9
|
+
// The page in front of the reader was drawn before this cookie existed, so the first
|
|
10
|
+
// visit from a browser — and the first after they travel — is drawn against the
|
|
11
|
+
// host's zone and then asked for again. Only when the cookie disagrees, so every
|
|
12
|
+
// other visit costs nothing: this writes what is already there and stops.
|
|
13
|
+
connect() {
|
|
14
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
15
|
+
if (!zone || zone === this.#stored()) { return }
|
|
16
|
+
|
|
17
|
+
document.cookie =
|
|
18
|
+
`${this.storageValue}=${encodeURIComponent(zone)}; path=/; max-age=31536000; samesite=lax`
|
|
19
|
+
window.Turbo.visit(window.location.href, { action: 'replace' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#stored() {
|
|
23
|
+
const row = document.cookie.split(/;\s*/).find((one) => one.startsWith(`${this.storageValue}=`))
|
|
24
|
+
|
|
25
|
+
return row ? decodeURIComponent(row.split('=')[1]) : null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
import { Toast } from '/recourse/bootstrap.bundle.min.js'
|
|
3
|
+
import { DELAY } from '/recourse/written.js'
|
|
4
|
+
|
|
5
|
+
// The toast arrives from the server already shown, so Bootstrap's show() must never
|
|
6
|
+
// run: it re-adds `showing` and blinks the toast through transparent. But show() is
|
|
7
|
+
// also the only place Bootstrap arms its autohide, so the timer lives here instead,
|
|
8
|
+
// and only the hiding is Bootstrap's — the timed hide and the dismiss X then share
|
|
9
|
+
// one code path and one fade.
|
|
10
|
+
export default class extends Controller {
|
|
11
|
+
static values = { delay: { type: Number, default: DELAY } }
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
// `autohide: false` keeps Bootstrap from arming a rival timer if anything ever
|
|
15
|
+
// does call show() on this element.
|
|
16
|
+
this.toast = Toast.getOrCreateInstance(this.element, { autohide: false })
|
|
17
|
+
this.startTimer()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
this.stopTimer()
|
|
22
|
+
this.toast.dispose()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Reading the message, or aiming for the X, holds the toast open — Bootstrap's
|
|
26
|
+
// own pause-on-hover only guards the timer *it* armed, so it is redone here.
|
|
27
|
+
stopTimer() {
|
|
28
|
+
clearTimeout(this.timeout)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A fresh full delay on leave, which is also what Bootstrap re-arms.
|
|
32
|
+
startTimer() {
|
|
33
|
+
this.timeout = setTimeout(() => this.toast.hide(), this.delayValue)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
import { Tooltip } from '/recourse/bootstrap.bundle.min.js'
|
|
3
|
+
|
|
4
|
+
// The tooltip naming an icon-only heading. Bootstrap never wires one on its own,
|
|
5
|
+
// and a Stimulus lifecycle is what survives Turbo redrawing the table: connect
|
|
6
|
+
// makes it, disconnect takes it down before the element goes, so a sorted or
|
|
7
|
+
// searched table never strands one over an element that left.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
connect() {
|
|
10
|
+
this.tooltip = Tooltip.getOrCreateInstance(this.element)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
disconnect() {
|
|
14
|
+
this.tooltip.dispose()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The mark a write leaves on the row it landed on, and how long it stands for. Two
|
|
2
|
+
// things write rows here and both say so this way: a create or an update, which has a
|
|
3
|
+
// toast to keep time with, and the square that keeps a row, which has none — so the
|
|
4
|
+
// marking is here and the clock belongs to whoever calls it.
|
|
5
|
+
export const DELAY = 2000
|
|
6
|
+
|
|
7
|
+
// Marked, and handed back the way to let it go. Letting go swaps one class for the
|
|
8
|
+
// other rather than adding to it: the fade is an animation, and while the first class
|
|
9
|
+
// is still on the row it would paint the tint straight back the moment that animation
|
|
10
|
+
// ended. Off the row entirely, what paints it afterwards is whatever else the cascade
|
|
11
|
+
// says — the tint of a kept row, or nothing.
|
|
12
|
+
export function mark(row) {
|
|
13
|
+
if (!row) { return () => {} }
|
|
14
|
+
|
|
15
|
+
row.classList.remove('recourse-written-out')
|
|
16
|
+
row.classList.add('recourse-written')
|
|
17
|
+
|
|
18
|
+
return () => {
|
|
19
|
+
row.classList.remove('recourse-written')
|
|
20
|
+
row.classList.add('recourse-written-out')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// And the same, on a clock of its own, for a write with no message to keep time with.
|
|
25
|
+
export function marked(row) {
|
|
26
|
+
const fade = mark(row)
|
|
27
|
+
|
|
28
|
+
setTimeout(fade, DELAY)
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Controller } from '/recourse/stimulus.js'
|
|
2
|
+
import { mark } from '/recourse/written.js'
|
|
3
|
+
|
|
4
|
+
// The row a create or an update just landed on, marked for exactly as long as the
|
|
5
|
+
// toast that says so. One clock rather than two: the mark goes when the message goes,
|
|
6
|
+
// however long somebody held the toast open by reading it.
|
|
7
|
+
//
|
|
8
|
+
// `hide`, never `hidden`. Bootstrap fires the first as the toast begins to fade and the
|
|
9
|
+
// second only once it has finished, a whole second later — which is a mark still lit
|
|
10
|
+
// over a message that has already gone.
|
|
11
|
+
export default class extends Controller {
|
|
12
|
+
static values = { row: String }
|
|
13
|
+
|
|
14
|
+
// Only where the row is on this page — a record can be written onto a page it does
|
|
15
|
+
// not appear on, sorted or filtered or paged away — and only where a toast is here
|
|
16
|
+
// to end it, since nothing else would.
|
|
17
|
+
connect() {
|
|
18
|
+
const row = document.getElementById(this.rowValue)
|
|
19
|
+
if (!row || !this.element.querySelector('.toast')) { return }
|
|
20
|
+
|
|
21
|
+
this.fade = mark(row)
|
|
22
|
+
this.element.addEventListener('hide.bs.toast', this.fade)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
if (this.fade) { this.element.removeEventListener('hide.bs.toast', this.fade) }
|
|
27
|
+
}
|
|
28
|
+
}
|