houseaccount 0.10.1
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.
- package/MIT-LICENSE +22 -0
- package/README.md +296 -0
- package/app/fonts/bootstrap-icons.woff2 +0 -0
- package/app/javascript/houseaccount/bookmark_controller.js +97 -0
- package/app/javascript/houseaccount/clear_controller.js +22 -0
- package/app/javascript/houseaccount/combobox/menu.js +93 -0
- package/app/javascript/houseaccount/combobox_controller.js +82 -0
- package/app/javascript/houseaccount/confirm.js +89 -0
- package/app/javascript/houseaccount/density_controller.js +21 -0
- package/app/javascript/houseaccount/deselect_controller.js +37 -0
- package/app/javascript/houseaccount/flash.js +35 -0
- package/app/javascript/houseaccount/limit_controller.js +23 -0
- package/app/javascript/houseaccount/map/loader.js +20 -0
- package/app/javascript/houseaccount/map_controller.js +88 -0
- package/app/javascript/houseaccount/otp/slots.js +27 -0
- package/app/javascript/houseaccount/otp_controller.js +98 -0
- package/app/javascript/houseaccount/phone_controller.js +89 -0
- package/app/javascript/houseaccount/placeholder_controller.js +28 -0
- package/app/javascript/houseaccount/relative_time_controller.js +45 -0
- package/app/javascript/houseaccount/require_controller.js +18 -0
- package/app/javascript/houseaccount/reveal_controller.js +16 -0
- package/app/javascript/houseaccount/scheme_controller.js +99 -0
- package/app/javascript/houseaccount/search_controller.js +78 -0
- package/app/javascript/houseaccount/shortcuts_controller.js +42 -0
- package/app/javascript/houseaccount/thread_controller.js +40 -0
- package/app/javascript/houseaccount/timezone_controller.js +27 -0
- package/app/javascript/houseaccount/toast_controller.js +35 -0
- package/app/javascript/houseaccount/tooltip_controller.js +23 -0
- package/app/javascript/houseaccount/wall/marks.js +8 -0
- package/app/javascript/houseaccount/wall/tools.json +75 -0
- package/app/javascript/houseaccount/wall_controller.js +93 -0
- package/app/javascript/houseaccount/written.js +20 -0
- package/app/javascript/houseaccount/written_controller.js +28 -0
- package/app/javascript/houseaccount.js +60 -0
- package/app/stylesheets/houseaccount/base.css +33 -0
- package/app/stylesheets/houseaccount/chat.css +73 -0
- package/app/stylesheets/houseaccount/flow.css +47 -0
- package/app/stylesheets/houseaccount/icons.css +186 -0
- package/app/stylesheets/houseaccount/lockup.css +58 -0
- package/app/stylesheets/houseaccount/map.css +3 -0
- package/app/stylesheets/houseaccount/pin.css +34 -0
- package/app/stylesheets/houseaccount/search.css +60 -0
- package/app/stylesheets/houseaccount/shell.css +197 -0
- package/app/stylesheets/houseaccount/table.css +105 -0
- package/app/stylesheets/houseaccount/values.css +41 -0
- package/app/stylesheets/houseaccount/wall.css +123 -0
- package/app/stylesheets/houseaccount.css +16 -0
- package/app/stylesheets/theme/bootstrap.css +11 -0
- package/app/stylesheets/theme/dawn.css +189 -0
- package/app/stylesheets/theme/dracula.css +189 -0
- package/app/stylesheets/theme/gruvbox.css +189 -0
- package/app/stylesheets/theme/monokai.css +190 -0
- package/app/stylesheets/theme/nord.css +189 -0
- package/app/stylesheets/theme/one_dark.css +189 -0
- package/app/stylesheets/theme/solarized.css +188 -0
- package/app/stylesheets/theme/tokyo_night.css +189 -0
- package/package.json +33 -0
- package/vendor/bootstrap-icons.min.css +5 -0
- package/vendor/bootstrap.bundle.min.js +9 -0
- package/vendor/bootstrap.min.css +2 -0
- package/vendor/fonts/OFL-quicksand.txt +93 -0
- package/vendor/fonts/bootstrap-icons.woff +0 -0
- package/vendor/fonts/bootstrap-icons.woff2 +0 -0
- package/vendor/fonts/quicksand-500-latin-ext.woff2 +0 -0
- package/vendor/fonts/quicksand-500-latin.woff2 +0 -0
- package/vendor/fonts/quicksand-500-vietnamese.woff2 +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// The sidebar's one control over how a page looks. A click moves it to another palette
|
|
4
|
+
// and into the mode it is not in, which is what the icon promises: a moon while the page
|
|
5
|
+
// is light, a sun while it is dark. The choice belongs to the reader rather than to the
|
|
6
|
+
// app, so it is kept in their browser — the layout's own script is what puts it back
|
|
7
|
+
// before the first paint, a controller connecting far too late for that.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static values = { themes: Array, path: String, storage: String }
|
|
10
|
+
|
|
11
|
+
// Turbo merges the head on a visit, which puts the palette the server chose back over
|
|
12
|
+
// the one the reader picked, and the layout's script runs only on a full load. The
|
|
13
|
+
// sidebar is drawn again on every visit, so connecting is the moment to say it again.
|
|
14
|
+
connect() {
|
|
15
|
+
const stored = this.#stored()
|
|
16
|
+
|
|
17
|
+
if (stored) { this.#apply(stored.theme, stored.mode) }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Another palette, in the other mode. Random rather than in order: the eight have no
|
|
21
|
+
// order that means anything. Excluding the one showing is what stops a click looking
|
|
22
|
+
// like it did nothing, which is the whole risk of picking at random.
|
|
23
|
+
rotate() {
|
|
24
|
+
const mode = this.#mode() === 'dark' ? 'light' : 'dark'
|
|
25
|
+
const others = this.themesValue.filter((theme) => theme !== this.#theme())
|
|
26
|
+
const theme = others[Math.floor(Math.random() * others.length)]
|
|
27
|
+
|
|
28
|
+
this.#apply(theme, mode)
|
|
29
|
+
localStorage.setItem(this.storageValue, JSON.stringify({ theme, mode }))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The mode onto the element every `light-dark()` on the page is resolved against, and
|
|
33
|
+
// the palette onto the one link that serves one. A name is checked against the list
|
|
34
|
+
// the server sent: the storage is the reader's own, but what comes back out of it is
|
|
35
|
+
// still not something to put in a URL unread.
|
|
36
|
+
#apply(theme, mode) {
|
|
37
|
+
if (mode === 'light' || mode === 'dark') {
|
|
38
|
+
document.documentElement.dataset.bsTheme = mode
|
|
39
|
+
}
|
|
40
|
+
if (!this.themesValue.includes(theme)) { return }
|
|
41
|
+
|
|
42
|
+
this.#link().href = `${this.pathValue}/${theme}.css`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// What the reader picked last, or nothing at all where they never have.
|
|
46
|
+
#stored() {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(localStorage.getItem(this.storageValue))
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Which mode the page is in: whatever has been forced onto it, and otherwise whatever
|
|
55
|
+
// the system asks for, since a page nobody has chosen for follows that.
|
|
56
|
+
#mode() {
|
|
57
|
+
return document.documentElement.dataset.bsTheme ||
|
|
58
|
+
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Which palette is showing, read off the link rather than remembered, so a reader who
|
|
62
|
+
// cleared their storage still moves on from the one in front of them.
|
|
63
|
+
#theme() {
|
|
64
|
+
const link = this.#current()
|
|
65
|
+
|
|
66
|
+
return link ? link.href.split('/').pop().replace('.css', '') : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The palette the page is drawn in, which is the *last* of these links rather than the
|
|
70
|
+
// first. Turbo's head merge appends a stylesheet the new head has and the old one does
|
|
71
|
+
// not — it never replaces one — and the server names the same palette on every page,
|
|
72
|
+
// so a visit made after the reader chose leaves two links and the browser obeys the
|
|
73
|
+
// one at the end. Writing to the other would change nothing anybody can see.
|
|
74
|
+
#current() {
|
|
75
|
+
const links = document.querySelectorAll('link[data-recourse-theme]')
|
|
76
|
+
|
|
77
|
+
return links[links.length - 1]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The one link a palette is served through: whichever is in force, with any the merge
|
|
81
|
+
// left behind it taken away, and a new one where a host named no palette at all and
|
|
82
|
+
// there is none in the head to find.
|
|
83
|
+
#link() {
|
|
84
|
+
const links = [...document.querySelectorAll('link[data-recourse-theme]')]
|
|
85
|
+
const link = links.pop() || this.#appended()
|
|
86
|
+
links.forEach((stale) => stale.remove())
|
|
87
|
+
|
|
88
|
+
return link
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#appended() {
|
|
92
|
+
const link = document.createElement('link')
|
|
93
|
+
link.rel = 'stylesheet'
|
|
94
|
+
link.dataset.recourseTheme = ''
|
|
95
|
+
document.head.appendChild(link)
|
|
96
|
+
|
|
97
|
+
return link
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
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 pick in a combobox is written to its select and said as the select's own
|
|
21
|
+
// `change`, which bubbles, so one listener on the form hears every menu in it and
|
|
22
|
+
// each tick or untick narrows the table straight away. Coalesced to the end of the
|
|
23
|
+
// turn, because emptying a filter unticks every option in it — one request for the
|
|
24
|
+
// 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', 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', 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 '@hotwired/stimulus'
|
|
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,40 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
// A thread opens at its newest message with the field ready, and stays at the newest as
|
|
4
|
+
// answers arrive. Every render undoes both -- `autofocus` included, which is why sending a
|
|
5
|
+
// question landed back here with nothing focused -- so it is said again on each one rather
|
|
6
|
+
// than only when Stimulus connects, which a morphed-in answer never triggers.
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static targets = ['messages', 'field']
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this.following = true
|
|
12
|
+
this.follow = () => { this.following = this.#atBottom() }
|
|
13
|
+
this.settle = () => requestAnimationFrame(() => this.#settle())
|
|
14
|
+
|
|
15
|
+
if (this.hasMessagesTarget) { this.messagesTarget.addEventListener('scroll', this.follow) }
|
|
16
|
+
document.addEventListener('turbo:render', this.settle)
|
|
17
|
+
this.settle()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
if (this.hasMessagesTarget) { this.messagesTarget.removeEventListener('scroll', this.follow) }
|
|
22
|
+
document.removeEventListener('turbo:render', this.settle)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Left where the reader put it once they have scrolled up to read back: an answer landing is
|
|
26
|
+
// no reason to pull the thread out from under them. The field takes focus without the page
|
|
27
|
+
// scrolling to it: a chat below the fold, the showcase's say, must not drag the page down
|
|
28
|
+
// on load, and a chat page is sized to the window, so there the field is in view already.
|
|
29
|
+
#settle() {
|
|
30
|
+
if (this.hasFieldTarget) { this.fieldTarget.focus({ preventScroll: true }) }
|
|
31
|
+
if (this.hasMessagesTarget && this.following) {
|
|
32
|
+
this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#atBottom() {
|
|
37
|
+
const { scrollTop, scrollHeight, clientHeight } = this.messagesTarget
|
|
38
|
+
return scrollHeight - scrollTop - clientHeight < 24
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
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 '@hotwired/stimulus'
|
|
2
|
+
import { Toast } from 'bootstrap'
|
|
3
|
+
import { DELAY } from './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,23 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import { Tooltip } from 'bootstrap'
|
|
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
|
+
//
|
|
9
|
+
// None where nothing can hover: on a touch screen the tap that would open a link
|
|
10
|
+
// is also what shows a tooltip, and a word popping up over a tap reads as a hitch.
|
|
11
|
+
// Asked two ways, since an iPhone answered the first with a tooltip all the same: a
|
|
12
|
+
// screen that cannot hover, or a pointer too coarse to rest on one word of a row.
|
|
13
|
+
export default class extends Controller {
|
|
14
|
+
connect() {
|
|
15
|
+
if (matchMedia('(hover: none), (pointer: coarse)').matches) return
|
|
16
|
+
|
|
17
|
+
this.tooltip = Tooltip.getOrCreateInstance(this.element)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
disconnect() {
|
|
21
|
+
this.tooltip?.dispose()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// What the wall is made of: the marks it deals out, the house's own among the trades, the
|
|
2
|
+
// rate each column drifts at and how many marks fall down one.
|
|
3
|
+
export const marks = ['roller', 'ladder', 'frame', 'plunger', 'can', 'hardhat', 'spray',
|
|
4
|
+
'lock', 'plug', 'broom', 'nut', 'level', 'cube']
|
|
5
|
+
// One rate per column, each a fraction of the base, so the wall reads as depth.
|
|
6
|
+
export const rates = [0.94, 1.02, 0.86, 1.06, 0.82, 0.98, 0.9]
|
|
7
|
+
// Five marks down a column, drawn twice, so half a column's height lands on identical art.
|
|
8
|
+
export const rows = 5
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"roller": [
|
|
3
|
+
"M152 132 L360 132 L360 216 L152 216 Z",
|
|
4
|
+
"M256 216 L256 300",
|
|
5
|
+
"M212 300 L300 300 L300 456 L212 456 Z"
|
|
6
|
+
],
|
|
7
|
+
"ladder": [
|
|
8
|
+
"M160 40 L160 472",
|
|
9
|
+
"M352 40 L352 472",
|
|
10
|
+
"M160 128 L352 128",
|
|
11
|
+
"M160 216 L352 216",
|
|
12
|
+
"M160 304 L352 304",
|
|
13
|
+
"M160 392 L352 392"
|
|
14
|
+
],
|
|
15
|
+
"frame": [
|
|
16
|
+
"M176 144 L256 72 L336 144",
|
|
17
|
+
"M96 144 L416 144 L416 432 L96 432 Z",
|
|
18
|
+
"M148 196 L364 196 L364 380 L148 380 Z",
|
|
19
|
+
"M176 344 L240 280 L288 328 L340 276"
|
|
20
|
+
],
|
|
21
|
+
"plunger": [
|
|
22
|
+
"M256 52 L256 300",
|
|
23
|
+
"M164 300 L348 300 L312 424 L200 424 Z"
|
|
24
|
+
],
|
|
25
|
+
"can": [
|
|
26
|
+
"M144 228 L328 228 L328 432 L144 432 Z",
|
|
27
|
+
"M188 228 L188 164 L284 164 L284 228",
|
|
28
|
+
"M328 300 L400 300 L400 228 L456 228",
|
|
29
|
+
"M456 200 L456 256"
|
|
30
|
+
],
|
|
31
|
+
"hardhat": [
|
|
32
|
+
"M76 336 L436 336",
|
|
33
|
+
"M116 336 Q116 146 256 146 Q396 146 396 336",
|
|
34
|
+
"M256 166 L256 266"
|
|
35
|
+
],
|
|
36
|
+
"spray": [
|
|
37
|
+
"M176 240 L336 240 L336 456 L176 456 Z",
|
|
38
|
+
"M256 240 L256 200",
|
|
39
|
+
"M136 128 L308 128 L308 200 L204 200 L204 160 L136 160 Z",
|
|
40
|
+
"M176 312 L336 312",
|
|
41
|
+
"M176 384 L336 384"
|
|
42
|
+
],
|
|
43
|
+
"lock": [
|
|
44
|
+
"M128 248 L384 248 L384 440 L128 440 Z",
|
|
45
|
+
"M184 248 L184 152 L328 152 L328 248",
|
|
46
|
+
"M256 308 L256 372"
|
|
47
|
+
],
|
|
48
|
+
"plug": [
|
|
49
|
+
"M148 216 L364 216 L364 396 L148 396 Z",
|
|
50
|
+
"M200 216 L200 120",
|
|
51
|
+
"M312 216 L312 120",
|
|
52
|
+
"M256 396 L256 464"
|
|
53
|
+
],
|
|
54
|
+
"broom": [
|
|
55
|
+
"M256 48 L256 268",
|
|
56
|
+
"M168 268 L344 268 L380 452 L132 452 Z",
|
|
57
|
+
"M212 268 L196 452",
|
|
58
|
+
"M300 268 L316 452"
|
|
59
|
+
],
|
|
60
|
+
"nut": [
|
|
61
|
+
"M256 108 L372 176 L372 312 L256 380 L140 312 L140 176 Z",
|
|
62
|
+
"M256 184 L308 214 L308 274 L256 304 L204 274 L204 214 Z"
|
|
63
|
+
],
|
|
64
|
+
"level": [
|
|
65
|
+
"M56 208 L456 208 L456 304 L56 304 Z",
|
|
66
|
+
"M196 232 L316 232 L316 280 L196 280 Z",
|
|
67
|
+
"M256 232 L256 280"
|
|
68
|
+
],
|
|
69
|
+
"cube": [
|
|
70
|
+
"M216.2 59.6 Q256 36.6 295.8 59.6 L406.2 123.3 Q446 146.3 446 192.3 L446 319.7 Q446 365.7 406.2 388.7 L295.8 452.4 Q256 475.4 216.2 452.4 L105.8 388.7 Q66 365.7 66 319.7 L66 192.3 Q66 146.3 105.8 123.3 Z",
|
|
71
|
+
"M256 256 L95.4 163.3",
|
|
72
|
+
"M256 256 L416.6 163.3",
|
|
73
|
+
"M256 256 L256 441.4"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
// The geometry of every mark on the wall, as data: one stroke each on a 512 grid, nothing
|
|
3
|
+
// filled, and nothing about how it is drawn — fill, stroke and caps are the stylesheet's.
|
|
4
|
+
import tools from './wall/tools.json'
|
|
5
|
+
// The trades the columns deal out, the marks among them, and how the columns drift.
|
|
6
|
+
import { marks, rates, rows } from './wall/marks.js'
|
|
7
|
+
|
|
8
|
+
// The wall behind a page of the signup flow: a cyanotype of the trades a homeowner books,
|
|
9
|
+
// drifting up in columns, with the family's own marks drifting past among them. One
|
|
10
|
+
// attribute on the shell draws it — `data-controller='wall'` — and the stylesheet paints
|
|
11
|
+
// the ground from that attribute at once, so the columns arrive on blue rather than on
|
|
12
|
+
// white. Built here rather than shipped in the page: seventy marks are a lot of markup to
|
|
13
|
+
// send, and the drawing belongs to the house, not to the page that wears it.
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
// Drawn again after a morph: a page Turbo refreshes in place is told what the server sent,
|
|
16
|
+
// and the server sends no wall, so idiomorph takes down what was built here while the
|
|
17
|
+
// controller itself stays connected and never hears of it.
|
|
18
|
+
connect() {
|
|
19
|
+
this.draw = () => this.#draw()
|
|
20
|
+
document.addEventListener('turbo:morph', this.draw)
|
|
21
|
+
this.#draw()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
disconnect() { document.removeEventListener('turbo:morph', this.draw) }
|
|
25
|
+
|
|
26
|
+
#draw() {
|
|
27
|
+
if (this.element.querySelector(':scope > .tools')) { return }
|
|
28
|
+
|
|
29
|
+
this.element.prepend(this.#wall())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#wall() {
|
|
33
|
+
const wall = this.#element('div', 'tools')
|
|
34
|
+
wall.setAttribute('aria-hidden', 'true')
|
|
35
|
+
wall.append(this.#element('div', 'tools-grid'), this.#columns())
|
|
36
|
+
|
|
37
|
+
return wall
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#columns() {
|
|
41
|
+
const columns = this.#element('div', 'tools-cols')
|
|
42
|
+
rates.forEach((rate, column) => columns.append(this.#column(rate, column)))
|
|
43
|
+
|
|
44
|
+
return columns
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#column(rate, column) {
|
|
48
|
+
const element = this.#element('div', 'tools-col')
|
|
49
|
+
element.style.setProperty('--col-rate', rate)
|
|
50
|
+
for (let slot = 0; slot < rows * 2; slot++) { element.append(this.#mark(column, slot)) }
|
|
51
|
+
|
|
52
|
+
return element
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Alternate columns are half-dropped, or the marks band into rows, and mirrored, so the
|
|
56
|
+
// same thirteen drawings read as more than thirteen.
|
|
57
|
+
#mark(column, slot) {
|
|
58
|
+
const row = slot % rows
|
|
59
|
+
const drop = column % 2 ? 0.5 / rows : 0
|
|
60
|
+
const top = (((row + 0.5) / rows + drop) % 1) * 50 + Math.floor(slot / rows) * 50
|
|
61
|
+
const name = marks[(column * 5 + row * 3) % marks.length]
|
|
62
|
+
const svg = this.#drawing(name, (column + row) % 2 ? 'tool tool-alt' : 'tool')
|
|
63
|
+
svg.style.top = `${top.toFixed(3)}%`
|
|
64
|
+
if (column % 2) { svg.style.scale = '-1 1' }
|
|
65
|
+
|
|
66
|
+
return svg
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#drawing(name, classes) {
|
|
70
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
71
|
+
svg.setAttribute('viewBox', '0 0 512 512')
|
|
72
|
+
svg.setAttribute('class', classes)
|
|
73
|
+
tools[name].forEach((shape) => svg.append(this.#shape(shape)))
|
|
74
|
+
|
|
75
|
+
return svg
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#shape(shape) {
|
|
79
|
+
const circle = typeof shape === 'object'
|
|
80
|
+
const element = document.createElementNS('http://www.w3.org/2000/svg', circle ? 'circle' : 'path')
|
|
81
|
+
if (circle) { Object.entries(shape).forEach(([key, value]) => element.setAttribute(key, value)) }
|
|
82
|
+
else { element.setAttribute('d', shape) }
|
|
83
|
+
|
|
84
|
+
return element
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#element(tag, classes) {
|
|
88
|
+
const element = document.createElement(tag)
|
|
89
|
+
element.className = classes
|
|
90
|
+
|
|
91
|
+
return element
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The mark a create or an update leaves on the row it landed on, and how long the toast
|
|
2
|
+
// saying so stands — the one clock, which the toast controller reads too.
|
|
3
|
+
export const DELAY = 2000
|
|
4
|
+
|
|
5
|
+
// Marked, and handed back the way to let it go. Letting go swaps one class for the
|
|
6
|
+
// other rather than adding to it: the fade is an animation, and while the first class
|
|
7
|
+
// is still on the row it would paint the tint straight back the moment that animation
|
|
8
|
+
// ended. Off the row entirely, what paints it afterwards is whatever else the cascade
|
|
9
|
+
// says — the tint of a kept row, or nothing.
|
|
10
|
+
export function mark(row) {
|
|
11
|
+
if (!row) { return () => {} }
|
|
12
|
+
|
|
13
|
+
row.classList.remove('recourse-written-out')
|
|
14
|
+
row.classList.add('recourse-written')
|
|
15
|
+
|
|
16
|
+
return () => {
|
|
17
|
+
row.classList.remove('recourse-written')
|
|
18
|
+
row.classList.add('recourse-written-out')
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
import { mark } from './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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Everything a HouseAccount page runs, in one module: Turbo with the cable element
|
|
2
|
+
// turbo-rails signs its streams for, Bootstrap 6, and a Stimulus application with every
|
|
3
|
+
// controller the house's markup names. Built by esbuild into /js/houseaccount.js.
|
|
4
|
+
import '@hotwired/turbo-rails'
|
|
5
|
+
import 'bootstrap'
|
|
6
|
+
import { Application } from '@hotwired/stimulus'
|
|
7
|
+
import confirm from './houseaccount/confirm.js'
|
|
8
|
+
import BookmarkController from './houseaccount/bookmark_controller.js'
|
|
9
|
+
import ClearController from './houseaccount/clear_controller.js'
|
|
10
|
+
import ComboboxController from './houseaccount/combobox_controller.js'
|
|
11
|
+
import DensityController from './houseaccount/density_controller.js'
|
|
12
|
+
import DeselectController from './houseaccount/deselect_controller.js'
|
|
13
|
+
import LimitController from './houseaccount/limit_controller.js'
|
|
14
|
+
import MapController from './houseaccount/map_controller.js'
|
|
15
|
+
import OtpController from './houseaccount/otp_controller.js'
|
|
16
|
+
import PhoneController from './houseaccount/phone_controller.js'
|
|
17
|
+
import PlaceholderController from './houseaccount/placeholder_controller.js'
|
|
18
|
+
import RelativeTimeController from './houseaccount/relative_time_controller.js'
|
|
19
|
+
import RequireController from './houseaccount/require_controller.js'
|
|
20
|
+
import RevealController from './houseaccount/reveal_controller.js'
|
|
21
|
+
import SchemeController from './houseaccount/scheme_controller.js'
|
|
22
|
+
import SearchController from './houseaccount/search_controller.js'
|
|
23
|
+
import ShortcutsController from './houseaccount/shortcuts_controller.js'
|
|
24
|
+
import ThreadController from './houseaccount/thread_controller.js'
|
|
25
|
+
import TimezoneController from './houseaccount/timezone_controller.js'
|
|
26
|
+
import ToastController from './houseaccount/toast_controller.js'
|
|
27
|
+
import TooltipController from './houseaccount/tooltip_controller.js'
|
|
28
|
+
import WallController from './houseaccount/wall_controller.js'
|
|
29
|
+
import WrittenController from './houseaccount/written_controller.js'
|
|
30
|
+
|
|
31
|
+
// Guarded: a module runs once, but a host that also loads this file from its own
|
|
32
|
+
// layout on a Turbo visit would otherwise connect every controller a second time.
|
|
33
|
+
if (!window.Stimulus) {
|
|
34
|
+
window.Stimulus = Application.start()
|
|
35
|
+
window.Stimulus.register('bookmark', BookmarkController)
|
|
36
|
+
window.Stimulus.register('clear', ClearController)
|
|
37
|
+
window.Stimulus.register('combobox', ComboboxController)
|
|
38
|
+
window.Stimulus.register('density', DensityController)
|
|
39
|
+
window.Stimulus.register('deselect', DeselectController)
|
|
40
|
+
window.Stimulus.register('limit', LimitController)
|
|
41
|
+
window.Stimulus.register('map', MapController)
|
|
42
|
+
window.Stimulus.register('otp', OtpController)
|
|
43
|
+
window.Stimulus.register('phone', PhoneController)
|
|
44
|
+
window.Stimulus.register('placeholder', PlaceholderController)
|
|
45
|
+
window.Stimulus.register('relative-time', RelativeTimeController)
|
|
46
|
+
window.Stimulus.register('require', RequireController)
|
|
47
|
+
window.Stimulus.register('reveal', RevealController)
|
|
48
|
+
window.Stimulus.register('scheme', SchemeController)
|
|
49
|
+
window.Stimulus.register('search', SearchController)
|
|
50
|
+
window.Stimulus.register('shortcuts', ShortcutsController)
|
|
51
|
+
window.Stimulus.register('thread', ThreadController)
|
|
52
|
+
window.Stimulus.register('timezone', TimezoneController)
|
|
53
|
+
window.Stimulus.register('toast', ToastController)
|
|
54
|
+
window.Stimulus.register('tooltip', TooltipController)
|
|
55
|
+
window.Stimulus.register('wall', WallController)
|
|
56
|
+
window.Stimulus.register('written', WrittenController)
|
|
57
|
+
|
|
58
|
+
// The browser's confirm() becomes a Bootstrap dialog the module draws on the first ask.
|
|
59
|
+
window.Turbo.config.forms.confirm = confirm
|
|
60
|
+
}
|