notificare 0.1.0.alpha.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +899 -0
- data/app/assets/stylesheets/active_job/notificare/engine.css +425 -0
- data/app/controllers/active_job/notificare/application_controller.rb +7 -0
- data/app/controllers/active_job/notificare/executions_controller.rb +41 -0
- data/app/controllers/active_job/notificare/notifications_controller.rb +72 -0
- data/app/helpers/active_job/notificare/view_helpers.rb +43 -0
- data/app/models/active_job/notificare/application_record.rb +7 -0
- data/app/models/active_job/notificare/execution.rb +20 -0
- data/app/models/active_job/notificare/notification.rb +50 -0
- data/app/views/active_job/notificare/_notification.html.erb +19 -0
- data/app/views/active_job/notificare/_notifications.html.erb +7 -0
- data/app/views/active_job/notificare/_progress.html.erb +17 -0
- data/app/views/active_job/notificare/executions/index.html.erb +75 -0
- data/app/views/active_job/notificare/executions/show.html.erb +66 -0
- data/app/views/active_job/notificare/notifications/clear.turbo_stream.erb +3 -0
- data/app/views/active_job/notificare/notifications/dismiss.turbo_stream.erb +1 -0
- data/app/views/active_job/notificare/notifications/read.turbo_stream.erb +3 -0
- data/app/views/layouts/active_job/notificare/application.html.erb +42 -0
- data/config/locales/en.yml +7 -0
- data/config/routes.rb +13 -0
- data/lib/active_job/notificare/concern.rb +78 -0
- data/lib/active_job/notificare/engine.rb +28 -0
- data/lib/active_job/notificare/progress_handle.rb +23 -0
- data/lib/active_job/notificare/projection.rb +145 -0
- data/lib/active_job/notificare/recipient.rb +39 -0
- data/lib/active_job/notificare/step_dsl.rb +42 -0
- data/lib/active_job/notificare/version.rb +5 -0
- data/lib/active_job/notificare.rb +14 -0
- data/lib/generators/active_job/notificare/install/install_generator.rb +56 -0
- data/lib/generators/active_job/notificare/install/templates/_notification.html.erb.tt +19 -0
- data/lib/generators/active_job/notificare/install/templates/_notifications.html.erb.tt +7 -0
- data/lib/generators/active_job/notificare/install/templates/_progress.html.erb.tt +17 -0
- data/lib/generators/active_job/notificare/install/templates/create_active_job_notificare_tables.rb.tt +36 -0
- data/lib/generators/active_job/notificare/install/templates/initializer.rb.tt +24 -0
- data/lib/generators/active_job/notificare/scaffold/scaffold_generator.rb +74 -0
- data/lib/generators/active_job/notificare/scaffold/templates/controller.rb.tt +31 -0
- data/lib/generators/active_job/notificare/scaffold/templates/index.html.erb.tt +26 -0
- data/lib/generators/active_job/notificare/scaffold/templates/locale.en.yml.tt +18 -0
- data/lib/generators/active_job/notificare/scaffold/templates/show.html.erb.tt +39 -0
- data/lib/notificare.rb +4 -0
- metadata +118 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/* ActiveJob::Notificare · engine.css
|
|
2
|
+
Design: "Doina" — Romanian folk-art palette
|
|
3
|
+
Dacian sun-wheels · Byzantine gold · madder-dyed wool · Carpathian night */
|
|
4
|
+
|
|
5
|
+
/* ══ Tokens — light theme (warm parchment & crimson) ════════════════════════ */
|
|
6
|
+
:root {
|
|
7
|
+
--nf-bg: #F2EACC; /* linen/bumbac */
|
|
8
|
+
--nf-surface: #FEFBEE; /* ivory */
|
|
9
|
+
--nf-border: #D4BF8A; /* flax */
|
|
10
|
+
--nf-text: #291407; /* walnut */
|
|
11
|
+
--nf-muted: #7D5530; /* earth */
|
|
12
|
+
--nf-accent: #7C1416; /* madder crimson */
|
|
13
|
+
--nf-gold: #AA7A10; /* Byzantine gold */
|
|
14
|
+
--nf-accent-fg: #FEFBEE;
|
|
15
|
+
--nf-radius: 2px;
|
|
16
|
+
--nf-shadow: 0 1px 6px rgba(41, 20, 7, 0.10);
|
|
17
|
+
|
|
18
|
+
/* status — folk-textile hues */
|
|
19
|
+
--nf-enqueued-bg: #E8DFCC; --nf-enqueued-fg: #4E3518;
|
|
20
|
+
--nf-running-bg: #C4D8EE; --nf-running-fg: #0A2840;
|
|
21
|
+
--nf-done-bg: #BAD8C4; --nf-done-fg: #0C3018;
|
|
22
|
+
--nf-fail-bg: #ECC4C0; --nf-fail-fg: #480A0A;
|
|
23
|
+
--nf-custom-bg: #ECD8A4; --nf-custom-fg: #483800;
|
|
24
|
+
|
|
25
|
+
color-scheme: light;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* ══ Tokens — dark theme (Carpathian night & embers) ════════════════════════ */
|
|
29
|
+
[data-theme="dark"] {
|
|
30
|
+
--nf-bg: #150A02; /* night */
|
|
31
|
+
--nf-surface: #221208; /* dark wood */
|
|
32
|
+
--nf-border: #4A2E12; /* wood grain */
|
|
33
|
+
--nf-text: #EEDDBB; /* candlelight */
|
|
34
|
+
--nf-muted: #B88A50; /* amber */
|
|
35
|
+
--nf-accent: #C83C3E; /* ember crimson */
|
|
36
|
+
--nf-gold: #D09E18; /* golden icon */
|
|
37
|
+
--nf-accent-fg: #150A02;
|
|
38
|
+
--nf-shadow: 0 1px 8px rgba(0, 0, 0, 0.55);
|
|
39
|
+
|
|
40
|
+
--nf-enqueued-bg: #382810; --nf-enqueued-fg: #C8A868;
|
|
41
|
+
--nf-running-bg: #081C38; --nf-running-fg: #70B8E8;
|
|
42
|
+
--nf-done-bg: #0A2818; --nf-done-fg: #72C888;
|
|
43
|
+
--nf-fail-bg: #380808; --nf-fail-fg: #E87878;
|
|
44
|
+
--nf-custom-bg: #381E00; --nf-custom-fg: #E8C060;
|
|
45
|
+
|
|
46
|
+
color-scheme: dark;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ══ Reset ═══════════════════════════════════════════════════════════════════ */
|
|
50
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
51
|
+
|
|
52
|
+
/* ══ Base ════════════════════════════════════════════════════════════════════ */
|
|
53
|
+
body {
|
|
54
|
+
background: var(--nf-bg);
|
|
55
|
+
color: var(--nf-text);
|
|
56
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
57
|
+
font-size: 14px;
|
|
58
|
+
line-height: 1.6;
|
|
59
|
+
min-height: 100vh;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
a { color: var(--nf-accent); text-decoration: none; }
|
|
63
|
+
a:hover { color: var(--nf-gold); text-decoration: underline; }
|
|
64
|
+
|
|
65
|
+
/* ══ Header ══════════════════════════════════════════════════════════════════ */
|
|
66
|
+
.nf-header {
|
|
67
|
+
background: var(--nf-surface);
|
|
68
|
+
border-bottom: 1px solid var(--nf-border);
|
|
69
|
+
padding: 0 24px;
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
height: 48px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.nf-brand {
|
|
77
|
+
display: flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
gap: 8px;
|
|
80
|
+
font-weight: 700;
|
|
81
|
+
font-size: 15px;
|
|
82
|
+
color: var(--nf-text);
|
|
83
|
+
letter-spacing: 0.02em;
|
|
84
|
+
text-decoration: none;
|
|
85
|
+
}
|
|
86
|
+
.nf-brand:hover { color: var(--nf-accent); text-decoration: none; }
|
|
87
|
+
.nf-brand__mark { color: var(--nf-gold); font-size: 13px; line-height: 1; }
|
|
88
|
+
|
|
89
|
+
/* ══ Folk strip ══════════════════════════════════════════════════════════════
|
|
90
|
+
Repeating diamond (argyle) — dominant motif in Romanian weaving & embroidery.
|
|
91
|
+
Crimson diamonds on Byzantine gold, echoing the "ie" blouse border patterns. */
|
|
92
|
+
.nf-folk-strip {
|
|
93
|
+
height: 10px;
|
|
94
|
+
background-color: var(--nf-gold);
|
|
95
|
+
background-image:
|
|
96
|
+
linear-gradient( 45deg, var(--nf-accent) 25%, transparent 25%),
|
|
97
|
+
linear-gradient(-45deg, var(--nf-accent) 25%, transparent 25%),
|
|
98
|
+
linear-gradient( 45deg, transparent 75%, var(--nf-accent) 75%),
|
|
99
|
+
linear-gradient(-45deg, transparent 75%, var(--nf-accent) 75%);
|
|
100
|
+
background-size: 10px 10px;
|
|
101
|
+
background-position: 0 0, 0 5px, 5px -5px, -5px 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ══ Theme toggle ════════════════════════════════════════════════════════════ */
|
|
105
|
+
.nf-theme-toggle {
|
|
106
|
+
background: none;
|
|
107
|
+
border: 1px solid var(--nf-border);
|
|
108
|
+
border-radius: var(--nf-radius);
|
|
109
|
+
color: var(--nf-muted);
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
font-size: 15px;
|
|
112
|
+
height: 30px;
|
|
113
|
+
width: 30px;
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
flex-shrink: 0;
|
|
118
|
+
}
|
|
119
|
+
.nf-theme-toggle:hover { border-color: var(--nf-gold); color: var(--nf-gold); }
|
|
120
|
+
|
|
121
|
+
/* ══ Layout ══════════════════════════════════════════════════════════════════ */
|
|
122
|
+
.nf-main { max-width: 1200px; margin: 0 auto; padding: 28px 24px; }
|
|
123
|
+
|
|
124
|
+
h1 {
|
|
125
|
+
font-size: 20px;
|
|
126
|
+
font-weight: 700;
|
|
127
|
+
margin-bottom: 4px;
|
|
128
|
+
letter-spacing: -0.01em;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.nf-subtitle {
|
|
132
|
+
color: var(--nf-muted);
|
|
133
|
+
font-size: 13px;
|
|
134
|
+
margin-bottom: 20px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ══ Card ════════════════════════════════════════════════════════════════════ */
|
|
138
|
+
.nf-card {
|
|
139
|
+
background: var(--nf-surface);
|
|
140
|
+
border: 1px solid var(--nf-border);
|
|
141
|
+
border-radius: var(--nf-radius);
|
|
142
|
+
box-shadow: var(--nf-shadow);
|
|
143
|
+
margin-bottom: 16px;
|
|
144
|
+
overflow: hidden;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.nf-card__header {
|
|
148
|
+
padding: 10px 16px;
|
|
149
|
+
border-bottom: 1px solid var(--nf-border);
|
|
150
|
+
border-left: 3px solid var(--nf-gold);
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
font-size: 11px;
|
|
153
|
+
text-transform: uppercase;
|
|
154
|
+
letter-spacing: 0.08em;
|
|
155
|
+
color: var(--nf-muted);
|
|
156
|
+
background: var(--nf-bg);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.nf-card__body { padding: 16px; }
|
|
160
|
+
|
|
161
|
+
/* ══ Table ═══════════════════════════════════════════════════════════════════ */
|
|
162
|
+
.nf-table { width: 100%; border-collapse: collapse; }
|
|
163
|
+
|
|
164
|
+
.nf-table th {
|
|
165
|
+
background: var(--nf-bg);
|
|
166
|
+
border-bottom: 2px solid var(--nf-border);
|
|
167
|
+
padding: 9px 12px;
|
|
168
|
+
text-align: left;
|
|
169
|
+
font-weight: 600;
|
|
170
|
+
font-size: 11px;
|
|
171
|
+
text-transform: uppercase;
|
|
172
|
+
letter-spacing: 0.08em;
|
|
173
|
+
color: var(--nf-muted);
|
|
174
|
+
white-space: nowrap;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.nf-table td {
|
|
178
|
+
border-bottom: 1px solid var(--nf-border);
|
|
179
|
+
padding: 10px 12px;
|
|
180
|
+
vertical-align: middle;
|
|
181
|
+
}
|
|
182
|
+
.nf-table tr:last-child td { border-bottom: 0; }
|
|
183
|
+
.nf-table tbody tr:hover td { background: var(--nf-bg); }
|
|
184
|
+
|
|
185
|
+
/* ══ Monospace ═══════════════════════════════════════════════════════════════ */
|
|
186
|
+
.nf-mono {
|
|
187
|
+
font-family: ui-monospace, 'Cascadia Code', monospace;
|
|
188
|
+
font-size: 12px;
|
|
189
|
+
color: var(--nf-muted);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ══ Status badge ════════════════════════════════════════════════════════════ */
|
|
193
|
+
.nf-badge {
|
|
194
|
+
display: inline-block;
|
|
195
|
+
padding: 2px 8px;
|
|
196
|
+
border-radius: var(--nf-radius);
|
|
197
|
+
font-size: 11px;
|
|
198
|
+
font-weight: 700;
|
|
199
|
+
text-transform: uppercase;
|
|
200
|
+
letter-spacing: 0.06em;
|
|
201
|
+
}
|
|
202
|
+
.nf-badge--enqueued { background: var(--nf-enqueued-bg); color: var(--nf-enqueued-fg); }
|
|
203
|
+
.nf-badge--running { background: var(--nf-running-bg); color: var(--nf-running-fg); }
|
|
204
|
+
.nf-badge--completed { background: var(--nf-done-bg); color: var(--nf-done-fg); }
|
|
205
|
+
.nf-badge--failed { background: var(--nf-fail-bg); color: var(--nf-fail-fg); }
|
|
206
|
+
.nf-badge--custom { background: var(--nf-custom-bg); color: var(--nf-custom-fg); }
|
|
207
|
+
|
|
208
|
+
/* ══ Filter form ═════════════════════════════════════════════════════════════ */
|
|
209
|
+
.nf-filters {
|
|
210
|
+
display: flex;
|
|
211
|
+
gap: 8px;
|
|
212
|
+
align-items: center;
|
|
213
|
+
margin-bottom: 16px;
|
|
214
|
+
flex-wrap: wrap;
|
|
215
|
+
}
|
|
216
|
+
.nf-filters select {
|
|
217
|
+
padding: 6px 10px;
|
|
218
|
+
border: 1px solid var(--nf-border);
|
|
219
|
+
border-radius: var(--nf-radius);
|
|
220
|
+
background: var(--nf-surface);
|
|
221
|
+
color: var(--nf-text);
|
|
222
|
+
font-size: 13px;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
}
|
|
225
|
+
.nf-filters select:focus { outline: 2px solid var(--nf-gold); outline-offset: 1px; }
|
|
226
|
+
.nf-filters button {
|
|
227
|
+
padding: 6px 16px;
|
|
228
|
+
background: var(--nf-accent);
|
|
229
|
+
color: var(--nf-accent-fg);
|
|
230
|
+
border: none;
|
|
231
|
+
border-radius: var(--nf-radius);
|
|
232
|
+
cursor: pointer;
|
|
233
|
+
font-size: 13px;
|
|
234
|
+
font-weight: 600;
|
|
235
|
+
}
|
|
236
|
+
.nf-filters button:hover { opacity: 0.88; }
|
|
237
|
+
.nf-filters .nf-clear { padding: 6px 10px; color: var(--nf-muted); font-size: 13px; }
|
|
238
|
+
.nf-filters .nf-clear:hover { color: var(--nf-accent); text-decoration: none; }
|
|
239
|
+
|
|
240
|
+
/* ══ Pagination ══════════════════════════════════════════════════════════════ */
|
|
241
|
+
.nf-pagination {
|
|
242
|
+
display: flex;
|
|
243
|
+
gap: 4px;
|
|
244
|
+
align-items: center;
|
|
245
|
+
margin-top: 20px;
|
|
246
|
+
justify-content: center;
|
|
247
|
+
}
|
|
248
|
+
.nf-pagination a,
|
|
249
|
+
.nf-pagination span {
|
|
250
|
+
padding: 5px 12px;
|
|
251
|
+
border: 1px solid var(--nf-border);
|
|
252
|
+
border-radius: var(--nf-radius);
|
|
253
|
+
font-size: 13px;
|
|
254
|
+
}
|
|
255
|
+
.nf-pagination a { color: var(--nf-accent); }
|
|
256
|
+
.nf-pagination a:hover { background: var(--nf-bg); border-color: var(--nf-gold); text-decoration: none; }
|
|
257
|
+
.nf-pagination__current {
|
|
258
|
+
background: var(--nf-accent) !important;
|
|
259
|
+
color: var(--nf-accent-fg) !important;
|
|
260
|
+
border-color: var(--nf-accent) !important;
|
|
261
|
+
}
|
|
262
|
+
.nf-pagination__info { margin-top: 8px; text-align: center; color: var(--nf-muted); font-size: 12px; }
|
|
263
|
+
|
|
264
|
+
/* ══ Detail layout ═══════════════════════════════════════════════════════════ */
|
|
265
|
+
.nf-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
|
266
|
+
@media (max-width: 700px) { .nf-detail { grid-template-columns: 1fr; } }
|
|
267
|
+
|
|
268
|
+
/* ══ Key/value list ══════════════════════════════════════════════════════════ */
|
|
269
|
+
.nf-kv {
|
|
270
|
+
display: grid;
|
|
271
|
+
grid-template-columns: max-content 1fr;
|
|
272
|
+
gap: 8px 16px;
|
|
273
|
+
align-items: baseline;
|
|
274
|
+
}
|
|
275
|
+
.nf-kv dt {
|
|
276
|
+
color: var(--nf-muted);
|
|
277
|
+
font-size: 11px;
|
|
278
|
+
font-weight: 600;
|
|
279
|
+
text-transform: uppercase;
|
|
280
|
+
letter-spacing: 0.06em;
|
|
281
|
+
white-space: nowrap;
|
|
282
|
+
}
|
|
283
|
+
.nf-kv dd {
|
|
284
|
+
font-family: ui-monospace, monospace;
|
|
285
|
+
font-size: 12px;
|
|
286
|
+
word-break: break-all;
|
|
287
|
+
color: var(--nf-text);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ══ Error block ═════════════════════════════════════════════════════════════ */
|
|
291
|
+
.nf-error {
|
|
292
|
+
background: var(--nf-fail-bg);
|
|
293
|
+
color: var(--nf-fail-fg);
|
|
294
|
+
border-left: 3px solid var(--nf-fail-fg);
|
|
295
|
+
border-radius: 0 var(--nf-radius) var(--nf-radius) 0;
|
|
296
|
+
padding: 10px 14px;
|
|
297
|
+
font-family: ui-monospace, monospace;
|
|
298
|
+
font-size: 12px;
|
|
299
|
+
margin-top: 14px;
|
|
300
|
+
word-break: break-word;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ══ Empty state ═════════════════════════════════════════════════════════════ */
|
|
304
|
+
.nf-empty {
|
|
305
|
+
padding: 48px 16px;
|
|
306
|
+
text-align: center;
|
|
307
|
+
color: var(--nf-muted);
|
|
308
|
+
}
|
|
309
|
+
.nf-empty::before {
|
|
310
|
+
content: '✦';
|
|
311
|
+
display: block;
|
|
312
|
+
font-size: 22px;
|
|
313
|
+
color: var(--nf-border);
|
|
314
|
+
margin-bottom: 12px;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ══ Back link ═══════════════════════════════════════════════════════════════ */
|
|
318
|
+
.nf-back {
|
|
319
|
+
display: inline-flex;
|
|
320
|
+
align-items: center;
|
|
321
|
+
gap: 4px;
|
|
322
|
+
margin-bottom: 16px;
|
|
323
|
+
color: var(--nf-muted);
|
|
324
|
+
font-size: 13px;
|
|
325
|
+
}
|
|
326
|
+
.nf-back:hover { color: var(--nf-accent); text-decoration: none; }
|
|
327
|
+
|
|
328
|
+
/* ══ Progress widget (notificare-progress__*) ════════════════════════════════ */
|
|
329
|
+
.notificare-progress {
|
|
330
|
+
display: flex;
|
|
331
|
+
flex-direction: column;
|
|
332
|
+
gap: 8px;
|
|
333
|
+
}
|
|
334
|
+
.notificare-progress__bar {
|
|
335
|
+
width: 100%;
|
|
336
|
+
height: 6px;
|
|
337
|
+
border-radius: 3px;
|
|
338
|
+
border: none;
|
|
339
|
+
background: var(--nf-border);
|
|
340
|
+
appearance: none;
|
|
341
|
+
}
|
|
342
|
+
.notificare-progress__bar::-webkit-progress-bar { background: var(--nf-border); border-radius: 3px; }
|
|
343
|
+
.notificare-progress__bar::-webkit-progress-value { background: var(--nf-accent); border-radius: 3px; }
|
|
344
|
+
.notificare-progress__bar::-moz-progress-bar { background: var(--nf-accent); border-radius: 3px; }
|
|
345
|
+
.notificare-progress__label {
|
|
346
|
+
font-size: 12px;
|
|
347
|
+
color: var(--nf-muted);
|
|
348
|
+
font-family: ui-monospace, monospace;
|
|
349
|
+
}
|
|
350
|
+
.notificare-progress__step {
|
|
351
|
+
font-size: 12px;
|
|
352
|
+
color: var(--nf-text);
|
|
353
|
+
font-weight: 500;
|
|
354
|
+
}
|
|
355
|
+
.notificare-progress__spinner {
|
|
356
|
+
display: inline-block;
|
|
357
|
+
width: 18px;
|
|
358
|
+
height: 18px;
|
|
359
|
+
border: 2px solid var(--nf-border);
|
|
360
|
+
border-top-color: var(--nf-accent);
|
|
361
|
+
border-radius: 50%;
|
|
362
|
+
animation: nf-spin 0.7s linear infinite;
|
|
363
|
+
}
|
|
364
|
+
@keyframes nf-spin { to { transform: rotate(360deg); } }
|
|
365
|
+
|
|
366
|
+
/* ══ Notification inbox (notificare-inbox / notificare-notification__*) ══════ */
|
|
367
|
+
.notificare-inbox {
|
|
368
|
+
display: flex;
|
|
369
|
+
flex-direction: column;
|
|
370
|
+
gap: 8px;
|
|
371
|
+
}
|
|
372
|
+
.notificare-inbox > form > button {
|
|
373
|
+
background: none;
|
|
374
|
+
border: 1px solid var(--nf-border);
|
|
375
|
+
border-radius: var(--nf-radius);
|
|
376
|
+
color: var(--nf-muted);
|
|
377
|
+
cursor: pointer;
|
|
378
|
+
font-size: 12px;
|
|
379
|
+
padding: 4px 12px;
|
|
380
|
+
margin-bottom: 4px;
|
|
381
|
+
}
|
|
382
|
+
.notificare-inbox > form > button:hover { border-color: var(--nf-fail-fg); color: var(--nf-fail-fg); }
|
|
383
|
+
|
|
384
|
+
.notificare-notification {
|
|
385
|
+
border: 1px solid var(--nf-border);
|
|
386
|
+
border-radius: var(--nf-radius);
|
|
387
|
+
padding: 12px 14px;
|
|
388
|
+
background: var(--nf-surface);
|
|
389
|
+
}
|
|
390
|
+
.notificare-notification--unread { border-left: 3px solid var(--nf-accent); }
|
|
391
|
+
|
|
392
|
+
.notificare-notification__title {
|
|
393
|
+
display: block;
|
|
394
|
+
font-size: 13px;
|
|
395
|
+
font-weight: 600;
|
|
396
|
+
color: var(--nf-text);
|
|
397
|
+
margin-bottom: 4px;
|
|
398
|
+
}
|
|
399
|
+
.notificare-notification__description {
|
|
400
|
+
font-size: 12px;
|
|
401
|
+
color: var(--nf-muted);
|
|
402
|
+
margin-bottom: 8px;
|
|
403
|
+
}
|
|
404
|
+
.notificare-notification__actions {
|
|
405
|
+
display: flex;
|
|
406
|
+
gap: 6px;
|
|
407
|
+
flex-wrap: wrap;
|
|
408
|
+
}
|
|
409
|
+
.notificare-notification__actions button,
|
|
410
|
+
.notificare-notification__actions a {
|
|
411
|
+
background: none;
|
|
412
|
+
border: 1px solid var(--nf-border);
|
|
413
|
+
border-radius: var(--nf-radius);
|
|
414
|
+
color: var(--nf-muted);
|
|
415
|
+
cursor: pointer;
|
|
416
|
+
font-size: 11px;
|
|
417
|
+
font-weight: 500;
|
|
418
|
+
padding: 3px 10px;
|
|
419
|
+
text-decoration: none;
|
|
420
|
+
}
|
|
421
|
+
.notificare-notification__actions button:hover,
|
|
422
|
+
.notificare-notification__actions a:hover {
|
|
423
|
+
border-color: var(--nf-accent);
|
|
424
|
+
color: var(--nf-accent);
|
|
425
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module ActiveJob
|
|
2
|
+
module Notificare
|
|
3
|
+
class ExecutionsController < ApplicationController
|
|
4
|
+
PER_PAGE = 25
|
|
5
|
+
|
|
6
|
+
before_action :authenticate_notificare!
|
|
7
|
+
|
|
8
|
+
def index
|
|
9
|
+
scope = Execution.recent
|
|
10
|
+
scope = scope.where(status: params[:status]) if params[:status].present?
|
|
11
|
+
scope = scope.where(job_class: params[:job_class]) if params[:job_class].present?
|
|
12
|
+
|
|
13
|
+
@page = [ params.fetch(:page, 1).to_i, 1 ].max
|
|
14
|
+
@total_count = scope.count
|
|
15
|
+
@total_pages = [ (@total_count.to_f / PER_PAGE).ceil, 1 ].max
|
|
16
|
+
@page = [ @page, @total_pages ].min
|
|
17
|
+
@executions = scope.limit(PER_PAGE).offset((@page - 1) * PER_PAGE)
|
|
18
|
+
@statuses = Execution.statuses.keys
|
|
19
|
+
@job_classes = Execution.distinct.pluck(:job_class).sort
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def show
|
|
23
|
+
@execution = Execution.find(params[:id])
|
|
24
|
+
@notifications = Notification.where(job_id: @execution.job_id).limit(50)
|
|
25
|
+
rescue ActiveRecord::RecordNotFound
|
|
26
|
+
head :not_found
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def authenticate_notificare!
|
|
32
|
+
proc = ActiveJob::Notificare.authenticate_with
|
|
33
|
+
if proc.nil?
|
|
34
|
+
head :forbidden if Rails.env.production?
|
|
35
|
+
elsif !instance_exec(&proc)
|
|
36
|
+
head :forbidden
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module ActiveJob
|
|
2
|
+
module Notificare
|
|
3
|
+
class NotificationsController < ApplicationController
|
|
4
|
+
before_action :set_current_recipient
|
|
5
|
+
before_action :set_notification, only: [ :read, :dismiss ]
|
|
6
|
+
|
|
7
|
+
def read
|
|
8
|
+
@notification.mark_read!
|
|
9
|
+
respond_to do |format|
|
|
10
|
+
format.turbo_stream
|
|
11
|
+
format.html { head :ok }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dismiss
|
|
16
|
+
@notification.dismiss!
|
|
17
|
+
respond_to do |format|
|
|
18
|
+
format.turbo_stream
|
|
19
|
+
format.html { head :ok }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear
|
|
24
|
+
@notifications = Notification.where(recipient: @current_recipient).visible.to_a
|
|
25
|
+
Notification.where(id: @notifications.map(&:id)).destroy_all
|
|
26
|
+
respond_to do |format|
|
|
27
|
+
format.turbo_stream
|
|
28
|
+
format.html { head :ok }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def set_current_recipient
|
|
35
|
+
@current_recipient = resolve_current_recipient
|
|
36
|
+
head :unauthorized if @current_recipient.nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve_current_recipient
|
|
40
|
+
if (proc = ActiveJob::Notificare.current_recipient_proc)
|
|
41
|
+
instance_exec(&proc)
|
|
42
|
+
elsif respond_to?(:current_notificare_recipient, true)
|
|
43
|
+
current_notificare_recipient
|
|
44
|
+
elsif respond_to?(:current_user, true)
|
|
45
|
+
current_user
|
|
46
|
+
else
|
|
47
|
+
raise NotImplementedError, <<~MSG
|
|
48
|
+
Could not resolve the current recipient for ActiveJob::Notificare.
|
|
49
|
+
|
|
50
|
+
To fix this, do one of the following:
|
|
51
|
+
|
|
52
|
+
1. Override `current_notificare_recipient` in your ApplicationController:
|
|
53
|
+
|
|
54
|
+
def current_notificare_recipient
|
|
55
|
+
current_user # or however you expose the signed-in user
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
2. Set a proc in an initializer:
|
|
59
|
+
|
|
60
|
+
ActiveJob::Notificare.current_recipient_proc = -> { current_user }
|
|
61
|
+
MSG
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def set_notification
|
|
66
|
+
@notification = Notification.where(recipient: @current_recipient).find(params[:id])
|
|
67
|
+
rescue ActiveRecord::RecordNotFound
|
|
68
|
+
head :not_found
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module ActiveJob
|
|
2
|
+
module Notificare
|
|
3
|
+
module ViewHelpers
|
|
4
|
+
def active_job_notificare(execution)
|
|
5
|
+
render partial: "active_job/notificare/progress", locals: { execution: execution }
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def active_job_notifications(for: nil)
|
|
9
|
+
recipient = binding.local_variable_get(:for)
|
|
10
|
+
notifications = Notification.where(recipient: recipient).visible
|
|
11
|
+
render partial: "active_job/notificare/notifications", locals: { notifications: notifications, recipient: recipient }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# In engine context (engine's own controller views or tests with engine routes included),
|
|
15
|
+
# the bare route helpers like read_notification_path are defined on self.
|
|
16
|
+
# In host app views they are not, so we fall back to url_for with the full controller
|
|
17
|
+
# path, which the host app's route set resolves to the correct mounted prefix.
|
|
18
|
+
def notificare_read_notification_path(notification)
|
|
19
|
+
if respond_to?(:read_notification_path)
|
|
20
|
+
read_notification_path(notification)
|
|
21
|
+
else
|
|
22
|
+
url_for(controller: "active_job/notificare/notifications", action: "read", id: notification.to_param, only_path: true)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def notificare_dismiss_notification_path(notification)
|
|
27
|
+
if respond_to?(:dismiss_notification_path)
|
|
28
|
+
dismiss_notification_path(notification)
|
|
29
|
+
else
|
|
30
|
+
url_for(controller: "active_job/notificare/notifications", action: "dismiss", id: notification.to_param, only_path: true)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def notificare_clear_notifications_path
|
|
35
|
+
if respond_to?(:clear_notifications_path)
|
|
36
|
+
clear_notifications_path
|
|
37
|
+
else
|
|
38
|
+
url_for(controller: "active_job/notificare/notifications", action: "clear", only_path: true)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module ActiveJob
|
|
2
|
+
module Notificare
|
|
3
|
+
class Execution < ApplicationRecord
|
|
4
|
+
self.table_name = "active_job_executions"
|
|
5
|
+
|
|
6
|
+
if defined?(Turbo::Broadcastable)
|
|
7
|
+
include Turbo::Broadcastable
|
|
8
|
+
broadcasts_refreshes_to ->(execution) { [ "active_job_progress", execution.job_id ] }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
enum :status, { enqueued: "enqueued", running: "running", completed: "completed", failed: "failed" }
|
|
12
|
+
|
|
13
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
14
|
+
|
|
15
|
+
validates :job_id, presence: true, uniqueness: true
|
|
16
|
+
validates :job_class, presence: true
|
|
17
|
+
validates :status, presence: true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module ActiveJob
|
|
2
|
+
module Notificare
|
|
3
|
+
class Notification < ApplicationRecord
|
|
4
|
+
self.table_name = "active_job_notifications"
|
|
5
|
+
|
|
6
|
+
if defined?(Turbo::Broadcastable)
|
|
7
|
+
include Turbo::Broadcastable
|
|
8
|
+
after_commit :broadcast_notification_refresh
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
belongs_to :recipient, polymorphic: true
|
|
12
|
+
|
|
13
|
+
enum :event_type, { completed: "completed", failed: "failed", custom: "custom" }
|
|
14
|
+
|
|
15
|
+
attribute :metadata, :json, default: nil
|
|
16
|
+
attribute :actions, :json, default: nil
|
|
17
|
+
|
|
18
|
+
default_scope { order(created_at: :desc) }
|
|
19
|
+
|
|
20
|
+
scope :unread, -> { where(read_at: nil) }
|
|
21
|
+
scope :visible, -> { where(dismissed_at: nil) }
|
|
22
|
+
|
|
23
|
+
validates :event_type, presence: true
|
|
24
|
+
validates :title, presence: true
|
|
25
|
+
|
|
26
|
+
def read?
|
|
27
|
+
read_at.present?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dismissed?
|
|
31
|
+
dismissed_at.present?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mark_read!
|
|
35
|
+
update!(read_at: Time.current) unless read?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def dismiss!
|
|
39
|
+
update!(dismissed_at: Time.current) unless dismissed?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def broadcast_notification_refresh
|
|
45
|
+
return unless recipient
|
|
46
|
+
broadcast_refresh_later_to "active_job_notifications", recipient.to_gid_param
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%= turbo_frame_tag dom_id(notification) do %>
|
|
2
|
+
<div class="notificare-notification<%= notification.read? ? "" : " notificare-notification--unread" %>">
|
|
3
|
+
<strong class="notificare-notification__title"><%= notification.title %></strong>
|
|
4
|
+
<% if notification.description.present? %>
|
|
5
|
+
<p class="notificare-notification__description"><%= notification.description %></p>
|
|
6
|
+
<% end %>
|
|
7
|
+
<div class="notificare-notification__actions">
|
|
8
|
+
<% unless notification.read? %>
|
|
9
|
+
<%= button_to t("active_job.notificare.notifications.mark_as_read"), notificare.read_notification_path(notification), method: :patch %>
|
|
10
|
+
<% end %>
|
|
11
|
+
<%= button_to t("active_job.notificare.notifications.dismiss"), notificare.dismiss_notification_path(notification), method: :patch %>
|
|
12
|
+
<% if notification.actions.present? %>
|
|
13
|
+
<% notification.actions.each do |action| %>
|
|
14
|
+
<%= link_to action["label"], action["url"] %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|