recourse 4.4.4 → 4.6.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 +41 -0
- data/README.md +6 -1
- data/app/javascript/recourse/density_controller.js +17 -0
- data/app/javascript/recourse/scheme_controller.js +64 -16
- data/app/stylesheets/recourse/themes/bootstrap.css +11 -0
- data/app/stylesheets/recourse/themes/dawn.css +189 -0
- data/app/stylesheets/recourse/themes/dracula.css +189 -0
- data/app/stylesheets/recourse/themes/gruvbox.css +189 -0
- data/app/stylesheets/recourse/themes/monokai.css +190 -0
- data/app/stylesheets/recourse/themes/nord.css +189 -0
- data/app/stylesheets/recourse/themes/one_dark.css +189 -0
- data/app/stylesheets/recourse/themes/solarized.css +188 -0
- data/app/stylesheets/recourse/themes/tokyo_night.css +189 -0
- data/app/views/layouts/recourses.html.erb +105 -83
- data/app/views/recourses/_footer.html.erb +3 -2
- data/app/views/recourses/_scheme.html.erb +18 -4
- data/app/views/recourses/_sidebar.html.erb +18 -1
- data/config/locales/recourse.en.yml +7 -2
- data/lib/recourse/colors.rb +7 -1
- data/lib/recourse/controllers.rb +31 -4
- data/lib/recourse/densities.rb +7 -0
- data/lib/recourse/engine.rb +6 -2
- data/lib/recourse/helpers/bookmarks.rb +3 -1
- data/lib/recourse/helpers/densities.rb +17 -0
- data/lib/recourse/helpers/formats.rb +11 -5
- data/lib/recourse/helpers/navigation.rb +5 -3
- data/lib/recourse/helpers/themes.rb +36 -0
- data/lib/recourse/helpers.rb +4 -3
- data/lib/recourse/routes/nested.rb +4 -3
- data/lib/recourse/themes.rb +49 -0
- data/lib/recourse/version.rb +1 -1
- data/lib/recourse.rb +2 -1
- metadata +17 -5
- data/lib/recourse/helpers/schemes.rb +0 -14
- data/lib/recourse/schemes.rb +0 -6
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
<%# Preloaded from the head instead of discovered at the end of the body, so the
|
|
16
16
|
module the toast controller and the dismiss X import is already in flight. -%>
|
|
17
17
|
<link rel='modulepreload' href='/recourse/bootstrap.bundle.min.js'>
|
|
18
|
-
<%# After the
|
|
18
|
+
<%# After the Bootstrap link, which is what makes either win: every selector involved
|
|
19
|
+
is `:root`. The palette the host named, then the one the reader chose last time. -%>
|
|
20
|
+
<%= theme_stylesheet_link %>
|
|
19
21
|
<%= render 'recourses/scheme' %>
|
|
20
22
|
<%= primary_color_style %>
|
|
21
23
|
|
|
@@ -34,6 +36,7 @@
|
|
|
34
36
|
import BookmarkController from '/recourse/bookmark_controller.js'
|
|
35
37
|
import ClearController from '/recourse/clear_controller.js'
|
|
36
38
|
import ComboboxController from '/recourse/combobox_controller.js'
|
|
39
|
+
import DensityController from '/recourse/density_controller.js'
|
|
37
40
|
import DeselectController from '/recourse/deselect_controller.js'
|
|
38
41
|
import LimitController from '/recourse/limit_controller.js'
|
|
39
42
|
import PhoneController from '/recourse/phone_controller.js'
|
|
@@ -54,6 +57,7 @@
|
|
|
54
57
|
window.Stimulus.register('bookmark', BookmarkController)
|
|
55
58
|
window.Stimulus.register('clear', ClearController)
|
|
56
59
|
window.Stimulus.register('combobox', ComboboxController)
|
|
60
|
+
window.Stimulus.register('density', DensityController)
|
|
57
61
|
window.Stimulus.register('deselect', DeselectController)
|
|
58
62
|
window.Stimulus.register('limit', LimitController)
|
|
59
63
|
window.Stimulus.register('phone', PhoneController)
|
|
@@ -118,11 +122,9 @@
|
|
|
118
122
|
.nav-link:hover:not(.active) { background-color: transparent; }
|
|
119
123
|
}
|
|
120
124
|
|
|
121
|
-
/* The navbar stacks over the
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
.recourse-sidebar { position: relative; z-index: 2; }
|
|
125
|
-
.recourse-shell > nav { position: relative; z-index: 3; }
|
|
125
|
+
/* The navbar stacks over the content: a menu opened from a filter drops over the
|
|
126
|
+
first rows of the table rather than under them. */
|
|
127
|
+
.recourse-shell > nav { position: relative; z-index: 1; }
|
|
126
128
|
|
|
127
129
|
/* An action column is as wide as a row is tall — an icon in a square — and a
|
|
128
130
|
counter column starts at the same square, widening only when a figure like
|
|
@@ -203,43 +205,37 @@
|
|
|
203
205
|
min-height: 0;
|
|
204
206
|
}
|
|
205
207
|
|
|
206
|
-
/*
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
container.
|
|
223
|
-
`main` is the only thing that scrolls inside the shell, and the sidebar
|
|
224
|
-
deliberately is not. A window dragged shorter than the sidebar itself is the one
|
|
225
|
-
case the viewport cannot hold, and there the sidebar is allowed to be taller
|
|
226
|
-
than the line it was stretched to, spill past the shell, and let the browser
|
|
227
|
-
scroll the page — which is what a reader expects of something taller than the
|
|
228
|
-
screen. Were it to scroll on its own instead, the same overflow would be hidden
|
|
229
|
-
inside it and the page would look whole while its foot was unreachable. */
|
|
208
|
+
/* The shell is three things — the sidebar, the navbar, the content — written in
|
|
209
|
+
the order a phone reads them: a row of icons at the very top, the trail and the
|
|
210
|
+
search under it, the page under that, scrolling as one. From the width the
|
|
211
|
+
sidebar becomes a column at, a grid seats them the other way round. The navbar
|
|
212
|
+
runs across the top, the sidebar takes the column under it and the content the
|
|
213
|
+
rest, the shell is exactly the viewport, and `main` is the only thing in it that
|
|
214
|
+
scrolls — so the navbar and the sidebar stay where they are however far down the
|
|
215
|
+
table a reader is. `minmax(0, 1fr)` is what lets the second row be shorter than
|
|
216
|
+
what is in it; a `1fr` alone is as tall as its content, and the page would scroll
|
|
217
|
+
after all.
|
|
218
|
+
The sidebar deliberately does not scroll. A window dragged shorter than the
|
|
219
|
+
sidebar itself is the one case the viewport cannot hold, and there the sidebar
|
|
220
|
+
is allowed to spill past the shell and let the browser scroll the page — which is
|
|
221
|
+
what a reader expects of something taller than the screen. Were it to scroll on
|
|
222
|
+
its own instead, the same overflow would be hidden inside it and the page would
|
|
223
|
+
look whole while its foot was unreachable. */
|
|
230
224
|
@media (width >= 768px) {
|
|
231
|
-
.recourse-shell {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.recourse-shell
|
|
225
|
+
.recourse-shell {
|
|
226
|
+
display: grid;
|
|
227
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
228
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
229
|
+
grid-template-areas: 'nav nav' 'aside main';
|
|
230
|
+
height: 100dvh;
|
|
231
|
+
}
|
|
232
|
+
.recourse-shell > nav { grid-area: nav; }
|
|
233
|
+
.recourse-sidebar { grid-area: aside; }
|
|
234
|
+
.recourse-shell > main { grid-area: main; overflow-y: auto; }
|
|
239
235
|
}
|
|
240
236
|
|
|
241
|
-
/* The sidebar rules off the
|
|
242
|
-
and off the content beside it once it is a column
|
|
237
|
+
/* The sidebar rules off the navbar below it while it is the band at the top of a
|
|
238
|
+
phone, and off the content beside it once it is a column. v6 generates no
|
|
243
239
|
responsive border utilities, so the switch is written out. */
|
|
244
240
|
.recourse-sidebar {
|
|
245
241
|
border-block-end: var(--bs-border-width) solid var(--bs-border-color);
|
|
@@ -265,6 +261,10 @@
|
|
|
265
261
|
next to one, so it takes the line's height and not `.btn`'s own minimum —
|
|
266
262
|
which would leave the row under every table taller than the words in it. */
|
|
267
263
|
.recourse-limit { min-height: 0; }
|
|
264
|
+
/* The bookmark square is an icon in a row of text, not a control in a form, so it
|
|
265
|
+
takes the line's height too: `.btn`'s minimum made every row with one taller
|
|
266
|
+
than the rows without. */
|
|
267
|
+
.recourse-bookmark { min-height: 0; }
|
|
268
268
|
/* Stacked, each control is a row of its own rather than a fragment of one:
|
|
269
269
|
there is no second control beside it to share the line with. */
|
|
270
270
|
@media (width < 768px) {
|
|
@@ -278,6 +278,13 @@
|
|
|
278
278
|
small controls are 14px, and so is the body on a phone, so the controls say
|
|
279
279
|
16px themselves and focusing one moves nothing. */
|
|
280
280
|
.recourse-search .form-control { font-size: 16px; }
|
|
281
|
+
/* A page under a record has a trail two or three crumbs long, and what sat beside
|
|
282
|
+
the trail — the search, its filters, a button — was squeezed into what those
|
|
283
|
+
left: `Filter by na`. Under such a trail each takes a row of its own, the form
|
|
284
|
+
the whole width and every control in it a row in turn, and the margin that set
|
|
285
|
+
a button apart from the trail goes, there being no trail beside it. An index at
|
|
286
|
+
the top keeps its form beside its one crumb, where it fits. */
|
|
287
|
+
.recourse-trailed > :not(nav) { flex: 0 0 100%; margin-inline-start: 0; }
|
|
281
288
|
}
|
|
282
289
|
|
|
283
290
|
/* Held Option marks the letter each sidebar link answers to. Marked rather than
|
|
@@ -404,20 +411,29 @@
|
|
|
404
411
|
.recourse-foot .nav-link:hover { background-color: var(--bs-bg-1); color: var(--bs-fg-body); }
|
|
405
412
|
}
|
|
406
413
|
|
|
414
|
+
/* `.nav-item` ships `flex: auto`: in a row every entry would take an equal share
|
|
415
|
+
of the width and in a full-height column an equal share of the height, and the
|
|
416
|
+
foot's auto margin would never have anything left to push against. Every item is
|
|
417
|
+
its own size instead, in both directions. */
|
|
418
|
+
.recourse-sidebar .nav-item { flex: 0 0 auto; }
|
|
419
|
+
|
|
420
|
+
/* On a phone the sidebar is one row of icons across the top, centered, each entry
|
|
421
|
+
with less room around it than a column gives — a dozen have to fit — and wrapping
|
|
422
|
+
to a second line only where a host has more than a screen's width of them. */
|
|
423
|
+
@media (width < 768px) {
|
|
424
|
+
.recourse-sidebar .nav { justify-content: center; --bs-nav-link-padding-x: .5rem; }
|
|
425
|
+
}
|
|
426
|
+
|
|
407
427
|
/* The foot sits at the bottom of the sidebar and stretches across it while the
|
|
408
428
|
sidebar is a column, and is simply the last item of the row while it is not
|
|
409
|
-
— the query matches the `md:` the sidebar's own classes use.
|
|
410
|
-
needed: `.nav-item` ships `flex: auto`, so every item would take an equal share
|
|
411
|
-
of a full-height nav and the auto margin would never have anything left to
|
|
412
|
-
push against. Pin the items to their own height and the foot takes the rest. */
|
|
429
|
+
— the query matches the `md:` the sidebar's own classes use. */
|
|
413
430
|
@media (width >= 768px) {
|
|
414
431
|
/* A floor rather than a height, and never wrapped. `.nav` ships `flex-wrap:
|
|
415
432
|
wrap`, and a column that may wrap, whose items are pinned to their own height
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
433
|
+
above, and which is told exactly how tall to be has one way to place a link
|
|
434
|
+
that does not fit: a second column. Which is what a short window used to draw.
|
|
435
|
+
The floor lets it be taller than the space it was given. */
|
|
419
436
|
.recourse-sidebar .nav { min-height: 100%; flex-wrap: nowrap; }
|
|
420
|
-
.recourse-sidebar .nav-item { flex: 0 0 auto; }
|
|
421
437
|
/* And it rides at the foot of the screen, which the auto margin alone is
|
|
422
438
|
enough to do: the nav's floor is the sidebar's full height, so while the
|
|
423
439
|
links leave room the margin takes all of it and the foot sits on the fold,
|
|
@@ -434,31 +450,40 @@
|
|
|
434
450
|
}
|
|
435
451
|
}
|
|
436
452
|
|
|
437
|
-
/* A phone
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
and
|
|
453
|
+
/* A phone reads the chrome as pictures. A sidebar entry is its icon, a crumb its
|
|
454
|
+
icon, a tab its icon and its figure — `17` rather than `17 franchises` — and the
|
|
455
|
+
foot's controls their icons, so the sidebar is one row of squares and the trail
|
|
456
|
+
leaves the search its room. The words are there all the same, read but not seen
|
|
457
|
+
the way Bootstrap's `.visually-hidden` does it, and the arrows at the foot put
|
|
458
|
+
them back: the cookie they write has the server draw the shell `.recourse-expanded`,
|
|
459
|
+
and then none of this applies. A tab or a crumb with no icon keeps its word at
|
|
460
|
+
every width, there being nothing else to stand for it.
|
|
461
|
+
The same hiding, at every width, for the foot's words once the sidebar is a
|
|
462
|
+
column: there is room for an icon and not for a word beside it. */
|
|
463
|
+
.recourse-foot-word { margin-inline-start: .4em; }
|
|
447
464
|
@media (width < 768px) {
|
|
448
|
-
.recourse-
|
|
465
|
+
.recourse-shell:not(.recourse-expanded) :is(.recourse-nav-word, .recourse-crumb-word,
|
|
466
|
+
.recourse-tab-word, .recourse-foot-word) {
|
|
467
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
468
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
469
|
+
}
|
|
449
470
|
}
|
|
450
|
-
|
|
451
|
-
/* While the sidebar is a row its foot has room for words, so each icon is followed
|
|
452
|
-
by its own; as a column the words are read but not seen, the way Bootstrap's
|
|
453
|
-
`.visually-hidden` does it. */
|
|
454
|
-
.recourse-foot-word { margin-inline-start: .4em; }
|
|
455
471
|
@media (width >= 768px) {
|
|
456
472
|
.recourse-foot-word {
|
|
457
473
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
458
474
|
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
459
475
|
}
|
|
476
|
+
/* Nothing to expand: a column has its words already. */
|
|
477
|
+
.recourse-density { display: none; }
|
|
460
478
|
}
|
|
461
479
|
|
|
480
|
+
/* The arrows say where a tap goes, like the moon: parting, with `Expand`, while
|
|
481
|
+
the chrome is icons; meeting, with `Compact`, once the words are out. Both faces
|
|
482
|
+
are drawn and the shell's class picks, so the button needs no script to redraw. */
|
|
483
|
+
.recourse-density-expanded { display: none; }
|
|
484
|
+
.recourse-expanded .recourse-density-compact { display: none; }
|
|
485
|
+
.recourse-expanded .recourse-density-expanded { display: inline; }
|
|
486
|
+
|
|
462
487
|
/* The toggle draws both icons and shows the one that names where a click
|
|
463
488
|
would go: a moon on a light page, a sun on a dark one. Three states rather than
|
|
464
489
|
two, because a page nobody has chosen for carries no attribute at all, and the
|
|
@@ -486,35 +511,32 @@
|
|
|
486
511
|
|
|
487
512
|
<%# The reader's zone is told to the server here rather than from a page of its
|
|
488
513
|
own: every screen the gem draws is one to read against their clock. -%>
|
|
489
|
-
<body class='recourse-shell
|
|
514
|
+
<body class='<%= class_names 'recourse-shell', 'recourse-expanded': expanded? %>'
|
|
490
515
|
data-controller='timezone' data-timezone-storage-value='<%= Recourse::ZONE_STORAGE %>'>
|
|
516
|
+
<%# First in the page, since a phone reads it first: the row of icons at the top,
|
|
517
|
+
above the trail. From 768px the layout's grid seats it beside the content instead,
|
|
518
|
+
under the navbar, whatever order it was written in. -%>
|
|
519
|
+
<aside class='recourse-sidebar py-3 px-3'>
|
|
520
|
+
<%= render 'recourses/sidebar' %>
|
|
521
|
+
</aside>
|
|
522
|
+
|
|
491
523
|
<nav class='navbar border-bottom'>
|
|
492
524
|
<%# `row-gap` rather than a margin on the search: a margin is there whether the
|
|
493
525
|
form wrapped or not, and on the line it shares with the breadcrumb it drops
|
|
494
|
-
the field a few pixels below it. A row gap only exists between lines.
|
|
495
|
-
|
|
526
|
+
the field a few pixels below it. A row gap only exists between lines. Under a
|
|
527
|
+
trail of more than one crumb a phone gives the form and the buttons rows of
|
|
528
|
+
their own, which `recourse-trailed` is what the stylesheet reads. -%>
|
|
529
|
+
<div class='<%= class_names 'container-fluid px-3 justify-content-start row-gap-2',
|
|
530
|
+
'recourse-trailed': resource_breadcrumbs.size > 1 %>'>
|
|
496
531
|
<%= render 'recourses/breadcrumb' %>
|
|
497
532
|
<%= yield :actions %>
|
|
498
533
|
<%= yield :search %>
|
|
499
534
|
</div>
|
|
500
535
|
</nav>
|
|
501
536
|
|
|
502
|
-
<
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
page has left over — an empty gap under a dozen links. Packed to the top
|
|
506
|
-
below 768px, and stretched again above it, where the single line being full
|
|
507
|
-
height is what draws the sidebar's rule the whole way down. -%>
|
|
508
|
-
<div class='row flex-grow-1 align-content-start md:align-content-stretch'>
|
|
509
|
-
<aside class='recourse-sidebar col-12 md:col-auto py-3 px-3 mx-0'>
|
|
510
|
-
<%= render 'recourses/sidebar' %>
|
|
511
|
-
</aside>
|
|
512
|
-
|
|
513
|
-
<main class='col-12 md:col py-4 px-3'>
|
|
514
|
-
<%= yield %>
|
|
515
|
-
</main>
|
|
516
|
-
</div>
|
|
517
|
-
</div>
|
|
537
|
+
<main class='py-4 px-3'>
|
|
538
|
+
<%= yield %>
|
|
539
|
+
</main>
|
|
518
540
|
|
|
519
541
|
<%= render 'recourses/flash' %>
|
|
520
542
|
<%= render 'recourses/confirm' %>
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
<%# locals: (pagy:) %>
|
|
2
2
|
<% if pagy %>
|
|
3
|
-
<%# A column on a phone, the sentence above the pages
|
|
3
|
+
<%# A column on a phone, the sentence centered above the pages the way the pages are
|
|
4
|
+
centered under it, and one row from `sm` up, the sentence at its start. -%>
|
|
4
5
|
<div class='d-flex flex-column sm:flex-row sm:align-items-center' <%= tag.attributes data: limit_data(pagy) %>>
|
|
5
6
|
<%# The gem's own sentence from pagy's public readers, every figure delimited —
|
|
6
7
|
rewriting pagy's English with a regex delimited one figure, only in English.
|
|
7
8
|
The switch follows it after a dot: how much of the table is showing, then the
|
|
8
9
|
one other amount it could show. Only while there is a second page — a table
|
|
9
10
|
that fits on one is not being paginated, and there is nothing to switch. -%>
|
|
10
|
-
<div class='ps-2 flex-grow-1'>
|
|
11
|
+
<div class='ps-2 flex-grow-1 text-center sm:text-start'>
|
|
11
12
|
<% if pagy.last > 1 %>
|
|
12
13
|
<%= t 'recourse.displaying_slice', from: number_with_delimiter(pagy.from),
|
|
13
14
|
to: number_with_delimiter(pagy.to),
|
|
@@ -1,14 +1,28 @@
|
|
|
1
|
-
<%# The mode a reader chose last time, back before anything is painted.
|
|
2
|
-
inline rather than a module or a Stimulus controller: both of those run
|
|
3
|
-
first paint, which would show the
|
|
1
|
+
<%# The palette and mode a reader chose last time, back before anything is painted.
|
|
2
|
+
Classic and inline rather than a module or a Stimulus controller: both of those run
|
|
3
|
+
after the first paint, which would show the palette the server chose for an instant
|
|
4
|
+
and then swap it. The name is checked against the shape a palette's name has before
|
|
5
|
+
it reaches a URL — the storage is the reader's own, but a value read back out of it
|
|
6
|
+
is still not a value to trust. -%>
|
|
4
7
|
<script>
|
|
5
8
|
(function () {
|
|
6
9
|
var stored
|
|
7
10
|
try { stored = JSON.parse(localStorage.getItem('<%= Recourse::SCHEME_STORAGE %>')) }
|
|
8
11
|
catch (error) { return }
|
|
12
|
+
if (!stored) { return }
|
|
9
13
|
|
|
10
|
-
if (stored
|
|
14
|
+
if (stored.mode === 'light' || stored.mode === 'dark') {
|
|
11
15
|
document.documentElement.dataset.bsTheme = stored.mode
|
|
12
16
|
}
|
|
17
|
+
if (!/^[a-z_]+$/.test(stored.theme || '')) { return }
|
|
18
|
+
|
|
19
|
+
var link = document.querySelector('link[data-recourse-theme]')
|
|
20
|
+
if (!link) {
|
|
21
|
+
link = document.createElement('link')
|
|
22
|
+
link.rel = 'stylesheet'
|
|
23
|
+
link.dataset.recourseTheme = ''
|
|
24
|
+
document.head.appendChild(link)
|
|
25
|
+
}
|
|
26
|
+
link.href = '<%= Recourse::THEMES_PATH %>/' + stored.theme + '.css'
|
|
13
27
|
})()
|
|
14
28
|
</script>
|
|
@@ -17,7 +17,24 @@
|
|
|
17
17
|
is which of the two mode icons shows — the mode may be the system's, which only CSS
|
|
18
18
|
knows, so both are drawn. -%>
|
|
19
19
|
<li class='nav-item recourse-foot'>
|
|
20
|
-
<%#
|
|
20
|
+
<%# For a phone alone, whose chrome is icons until a reader asks for the words: a tap
|
|
21
|
+
puts them back beside every icon, and the next takes them away. Both faces are
|
|
22
|
+
drawn — arrows parting to expand, meeting to compact — and the shell's class shows
|
|
23
|
+
the one that says where a tap goes, the way the moon and the sun are both drawn. -%>
|
|
24
|
+
<div class='recourse-density'>
|
|
25
|
+
<%= tag.button type: 'button', class: 'nav-link border-0 bg-transparent',
|
|
26
|
+
data: density_data do %>
|
|
27
|
+
<span class='recourse-density-compact'>
|
|
28
|
+
<%= icon_tag :expand %>
|
|
29
|
+
<span class='recourse-foot-word'><%= t 'recourse.expand' %></span>
|
|
30
|
+
</span>
|
|
31
|
+
<span class='recourse-density-expanded'>
|
|
32
|
+
<%= icon_tag :collapse %>
|
|
33
|
+
<span class='recourse-foot-word'><%= t 'recourse.compact' %></span>
|
|
34
|
+
</span>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
37
|
+
<%# In a wrapper of its own, as the way out is in its form: the wrapper is the share
|
|
21
38
|
of the row, and the button is only as wide as its icon. -%>
|
|
22
39
|
<div>
|
|
23
40
|
<%= tag.button type: 'button', class: 'nav-link border-0 bg-transparent',
|
|
@@ -18,10 +18,11 @@ en:
|
|
|
18
18
|
bookmark_removed: Bookmark removed
|
|
19
19
|
cancel: Cancel
|
|
20
20
|
clear: Clear search
|
|
21
|
+
compact: Compact
|
|
21
22
|
create: Create
|
|
22
23
|
created: "%{model} was created."
|
|
23
24
|
created_error: "%{model} could not be created."
|
|
24
|
-
darken: Dark
|
|
25
|
+
darken: Dark
|
|
25
26
|
delete: Delete %{model}
|
|
26
27
|
deleted: "%{model} was deleted."
|
|
27
28
|
exit: Exit
|
|
@@ -30,6 +31,7 @@ en:
|
|
|
30
31
|
other: Displaying %{count} items
|
|
31
32
|
displaying_slice: Displaying items %{from}-%{to} of %{total}
|
|
32
33
|
edit: Edit
|
|
34
|
+
expand: Expand
|
|
33
35
|
format: Please match the format %{example}
|
|
34
36
|
from_now: "in %{distance}"
|
|
35
37
|
# How many values a list holds, which is what its summary reads before anybody
|
|
@@ -37,7 +39,7 @@ en:
|
|
|
37
39
|
items:
|
|
38
40
|
one: 1 item
|
|
39
41
|
other: "%{count} items"
|
|
40
|
-
lighten: Light
|
|
42
|
+
lighten: Light
|
|
41
43
|
missing_model: You declared `recourses :%{name}` in your routes file, but this app
|
|
42
44
|
has no %{model} model.
|
|
43
45
|
nested: You nested `recourses %{names}` inside `resources :%{parent}`, which does
|
|
@@ -45,6 +47,7 @@ en:
|
|
|
45
47
|
# What a menu with several picks reads as, the first named and the rest counted.
|
|
46
48
|
more: "%{first} + %{count} more"
|
|
47
49
|
new: New %{model}
|
|
50
|
+
new_tab: Open in a new tab
|
|
48
51
|
no_results: No results found
|
|
49
52
|
none: "No %{models}."
|
|
50
53
|
optional: Optional
|
|
@@ -60,6 +63,8 @@ en:
|
|
|
60
63
|
unbookmark: Remove bookmark
|
|
61
64
|
unknown_color: "`Recourse.color` is one of %{colors}, or nil for Bootstrap's own
|
|
62
65
|
blue. It cannot be %{color}."
|
|
66
|
+
unknown_theme: "`Recourse.theme` is one of %{themes}, or nil for Bootstrap's own
|
|
67
|
+
palette. It cannot be %{theme}."
|
|
63
68
|
updated: "%{model} was updated."
|
|
64
69
|
updated_error: "%{model} could not be updated."
|
|
65
70
|
unset: None
|
data/lib/recourse/colors.rb
CHANGED
|
@@ -28,5 +28,11 @@ module Recourse
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
# Which text a family's 500 step carries, named as the token upstream spells it with.
|
|
31
|
-
|
|
31
|
+
# A palette repaints the step, so it may move a family across the line and answers
|
|
32
|
+
# first where it does.
|
|
33
|
+
def self.ink(family)
|
|
34
|
+
inks = THEMES[theme] || DARK_INKS
|
|
35
|
+
|
|
36
|
+
inks.include?(family) ? 'gray-975' : 'white'
|
|
37
|
+
end
|
|
32
38
|
end
|
data/lib/recourse/controllers.rb
CHANGED
|
@@ -1,16 +1,43 @@
|
|
|
1
1
|
module Recourse
|
|
2
2
|
# Defines the controller classes a host app has not written for itself.
|
|
3
3
|
module Controllers
|
|
4
|
+
@held = []
|
|
5
|
+
@lock = Mutex.new
|
|
6
|
+
|
|
4
7
|
# Creates a controller for the named resource unless the host app has one. The
|
|
5
8
|
# name arrives with whatever namespace it was drawn in — `admin/contacts` — so
|
|
6
|
-
# the constant lands where Rails will look for it.
|
|
7
|
-
|
|
9
|
+
# the constant lands where Rails will look for it. A block names the superclass,
|
|
10
|
+
# `RecoursesController` by default, and is called only once the class is made.
|
|
11
|
+
def self.define_missing(path, &base)
|
|
8
12
|
class_name = "#{path.camelize}Controller"
|
|
9
13
|
# const_defined? is true for a Zeitwerk autoload, so files on disk count too.
|
|
10
14
|
return if Object.const_defined? class_name
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
booting? ? hold(class_name, base) : define(class_name, base)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Makes the controllers held back while the app booted. The executor runs this
|
|
20
|
+
# ahead of the app's first request or job, once every class may be loaded.
|
|
21
|
+
def self.define_held
|
|
22
|
+
@lock.synchronize { define(*@held.shift) until @held.empty? }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private_class_method def self.define(class_name, base)
|
|
26
|
+
superclass = base ? base.call : RecoursesController
|
|
27
|
+
namespace(class_name.deconstantize).const_set class_name.demodulize, Class.new(superclass)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Rails 8.2 warns when Action Controller is loaded during the boot of an app that
|
|
31
|
+
# will not eager load — a rake task's, which draws its routes before it is done
|
|
32
|
+
# and never dispatches to a controller — so the class waits for the first run.
|
|
33
|
+
private_class_method def self.booting?
|
|
34
|
+
app = Rails.application
|
|
35
|
+
app.present? && !app.config.eager_load && !app.initialized?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private_class_method def self.hold(class_name, base)
|
|
39
|
+
Rails.application.executor.to_run { Controllers.define_held } if @held.empty?
|
|
40
|
+
@held << [class_name, base]
|
|
14
41
|
end
|
|
15
42
|
|
|
16
43
|
# The module a namespaced controller belongs in, made where the host has none of
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Reopened for the one thing a reader says about how much a phone spells out.
|
|
2
|
+
module Recourse
|
|
3
|
+
# Where a reader's say on a phone's chrome is kept: a cookie, since the server reads it
|
|
4
|
+
# back and draws the words in or leaves them out, rather than drawing and then hiding.
|
|
5
|
+
# `expanded` puts the words beside every icon; anything else, or nothing, is compact.
|
|
6
|
+
DENSITY_STORAGE = 'recourse-density'
|
|
7
|
+
end
|
data/lib/recourse/engine.rb
CHANGED
|
@@ -23,7 +23,8 @@ module Recourse
|
|
|
23
23
|
# prefix keeps its slash: without it `/recourses` would be served as a file.
|
|
24
24
|
# Turbo answers first, by exact path — matched on the prefix alone, the statics
|
|
25
25
|
# below would swallow it with a 404 instead of passing it on. Then vendored
|
|
26
|
-
# files,
|
|
26
|
+
# files, then our own JavaScript, each cascading so the next shares the URL, and
|
|
27
|
+
# finally our own stylesheets — the palettes. All of it sits before
|
|
27
28
|
# `Rails::Rack::Logger`, so fetching a stylesheet writes no `Started GET` line:
|
|
28
29
|
# a file is not what a log is about, and placement says so without the gem
|
|
29
30
|
# touching a host's logging.
|
|
@@ -38,7 +39,10 @@ module Recourse
|
|
|
38
39
|
urls: STATIC_URLS, root: Engine.root.join('vendor'),
|
|
39
40
|
cascade: true
|
|
40
41
|
app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
|
|
41
|
-
urls: STATIC_URLS, root: Engine.root.join('app/javascript')
|
|
42
|
+
urls: STATIC_URLS, root: Engine.root.join('app/javascript'),
|
|
43
|
+
cascade: true
|
|
44
|
+
app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
|
|
45
|
+
urls: STATIC_URLS, root: Engine.root.join('app/stylesheets')
|
|
42
46
|
end
|
|
43
47
|
end
|
|
44
48
|
end
|
|
@@ -50,13 +50,15 @@ module Recourse
|
|
|
50
50
|
|
|
51
51
|
# Kept or not, one square either way: the same path with the verb reversed, so
|
|
52
52
|
# the button toggles by flipping the method Rails already wrote into the form.
|
|
53
|
+
# `recourse-bookmark` takes back the height `.btn` claims, so the row stays as
|
|
54
|
+
# tall as one with no square in it.
|
|
53
55
|
def bookmark_button(record)
|
|
54
56
|
kept = bookmarked_ids.include? record.id
|
|
55
57
|
label = t "recourse.#{kept ? 'unbookmark' : 'bookmark'}"
|
|
56
58
|
|
|
57
59
|
button_to bookmark_icon(kept, label), bookmark_url(record),
|
|
58
60
|
method: kept ? :delete : :post, form_class: 'd-inline-block',
|
|
59
|
-
class: 'btn btn-sm btn-link p-0 border-0 lh-1',
|
|
61
|
+
class: 'btn btn-sm btn-link p-0 border-0 lh-1 recourse-bookmark',
|
|
60
62
|
**bookmark_data(kept, label)
|
|
61
63
|
end
|
|
62
64
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# Whether a phone reads the chrome as icons alone or as icons with their words.
|
|
4
|
+
module Densities
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# Whether the reader asked for the words: the cookie the arrows write, read back so
|
|
8
|
+
# the page arrives expanded rather than compact and then widened by a script.
|
|
9
|
+
def expanded? = cookies[Recourse::DENSITY_STORAGE] == 'expanded'
|
|
10
|
+
|
|
11
|
+
# What the arrows need to switch: the class they toggle and the cookie they write.
|
|
12
|
+
def density_data
|
|
13
|
+
{ controller: 'density', action: 'density#toggle', density_storage_value: Recourse::DENSITY_STORAGE }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -79,15 +79,21 @@ module Recourse
|
|
|
79
79
|
value.is_a?(String) && value.match?(WEB_URL)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
# a
|
|
82
|
+
# The words lead where the value points, in this tab, the way any link does; the
|
|
83
|
+
# arrow after them — Bootstrap's icon link, stepping under the cursor — opens the
|
|
84
|
+
# same address in a new tab. Either reads as the host, and an ellipsis where the
|
|
85
|
+
# address goes further: a path is how a machine finds the page, and the href has it.
|
|
85
86
|
def url_link(value)
|
|
86
87
|
host, rest = value.match(WEB_URL).captures
|
|
87
88
|
said = rest.delete_suffix('/').empty? ? host : "#{host}/…"
|
|
88
89
|
|
|
89
|
-
tag.a
|
|
90
|
-
|
|
90
|
+
safe_join [tag.a(said, href: value), new_tab_arrow(value)], ' '
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def new_tab_arrow(value)
|
|
94
|
+
tag.a icon_tag(:point_right), href: value, target: '_blank', rel: 'noopener',
|
|
95
|
+
class: 'icon-link icon-link-hover',
|
|
96
|
+
aria: { label: t('recourse.new_tab') }
|
|
91
97
|
end
|
|
92
98
|
end
|
|
93
99
|
end
|
|
@@ -54,13 +54,15 @@ module Recourse
|
|
|
54
54
|
new_resource_path.nil? && routed_action?('create')
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# Label for a link
|
|
58
|
-
#
|
|
57
|
+
# Label for a sidebar link: the icon its model picked, then its title, with the
|
|
58
|
+
# letter at `key` marked where the link answers to one. The words are wrapped where
|
|
59
|
+
# an icon stands beside them, so a phone can keep the icon and drop them.
|
|
59
60
|
def resource_label(resource, title, key = nil)
|
|
60
61
|
name = key ? shortcut_title(title, key) : title
|
|
61
62
|
icon = Recourse.known_icon resource
|
|
63
|
+
return name unless icon
|
|
62
64
|
|
|
63
|
-
safe_join [
|
|
65
|
+
safe_join [tag.i(class: "bi bi-#{icon}"), tag.span(name, class: 'recourse-nav-word')], ' '
|
|
64
66
|
end
|
|
65
67
|
|
|
66
68
|
# The same for a crumb, whose words a phone drops where the icon stands for them:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The color scheme a host named, and the reader's own say in which one it is.
|
|
4
|
+
module Themes
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# The stylesheet that repaints Bootstrap's ramps in `Recourse.theme`, or nothing at
|
|
8
|
+
# all where none is set — the stylesheet's own palette is already there. A file
|
|
9
|
+
# rather than a block in the page: it is the same bytes on every request, so a
|
|
10
|
+
# browser is asked for it once, and one attribute swap moves the whole page to
|
|
11
|
+
# another palette. After the Bootstrap link and never before it, both blocks being
|
|
12
|
+
# `:root`, so it wins on being later.
|
|
13
|
+
def theme_stylesheet_link
|
|
14
|
+
theme = Recourse.theme
|
|
15
|
+
return unless theme
|
|
16
|
+
|
|
17
|
+
tag.link rel: 'stylesheet', href: theme_stylesheet_path(theme),
|
|
18
|
+
data: { recourse_theme: '' }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Where one palette is served from.
|
|
22
|
+
def theme_stylesheet_path(theme) = "#{Recourse::THEMES_PATH}/#{theme}.css"
|
|
23
|
+
|
|
24
|
+
# What the sidebar's toggle needs to rotate: every palette there is, where they
|
|
25
|
+
# are served from, and where to keep what the reader picked.
|
|
26
|
+
def scheme_data
|
|
27
|
+
{
|
|
28
|
+
controller: 'scheme', action: 'scheme#rotate',
|
|
29
|
+
scheme_themes_value: Recourse::THEMES.keys.to_json,
|
|
30
|
+
scheme_path_value: Recourse::THEMES_PATH,
|
|
31
|
+
scheme_storage_value: Recourse::SCHEME_STORAGE,
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|