@geekity/cms 0.38.0 → 0.39.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.
Files changed (40) hide show
  1. package/admin/components/button.njk +3 -2
  2. package/admin/components/dropdown.njk +3 -3
  3. package/admin/pages/dashboard/home.njk +69 -11
  4. package/admin/src/admin.css +1 -0
  5. package/admin/static/admin.css +24 -1
  6. package/admin/static/dashboard.js +109 -0
  7. package/dist/admin/dashboard.d.ts +13 -0
  8. package/dist/admin/dashboard.d.ts.map +1 -0
  9. package/dist/admin/dashboard.js +137 -0
  10. package/dist/admin/dashboard.js.map +1 -0
  11. package/dist/admin/routes.d.ts.map +1 -1
  12. package/dist/admin/routes.js +5 -45
  13. package/dist/admin/routes.js.map +1 -1
  14. package/dist/admin/widgets.d.ts +2 -0
  15. package/dist/admin/widgets.d.ts.map +1 -1
  16. package/dist/admin/widgets.js +4 -0
  17. package/dist/admin/widgets.js.map +1 -1
  18. package/dist/site/dashboard-layout.d.ts +48 -0
  19. package/dist/site/dashboard-layout.d.ts.map +1 -0
  20. package/dist/site/dashboard-layout.js +95 -0
  21. package/dist/site/dashboard-layout.js.map +1 -0
  22. package/dist/site/users.d.ts +15 -0
  23. package/dist/site/users.d.ts.map +1 -1
  24. package/dist/site/users.js +12 -0
  25. package/dist/site/users.js.map +1 -1
  26. package/dist/web/archive.d.ts +2 -0
  27. package/dist/web/archive.d.ts.map +1 -1
  28. package/dist/web/archive.js +6 -1
  29. package/dist/web/archive.js.map +1 -1
  30. package/dist/web/markdown-page.d.ts +5 -0
  31. package/dist/web/markdown-page.d.ts.map +1 -1
  32. package/dist/web/markdown-page.js.map +1 -1
  33. package/dist/web/markdown-representation.js +3 -3
  34. package/dist/web/markdown-representation.js.map +1 -1
  35. package/dist/web/render.d.ts +4 -1
  36. package/dist/web/render.d.ts.map +1 -1
  37. package/dist/web/render.js +27 -7
  38. package/dist/web/render.js.map +1 -1
  39. package/package.json +1 -1
  40. package/themes/default/partials/markdown-footer.md.njk +16 -1
@@ -14,7 +14,7 @@
14
14
  A value outside its set is a render error.
15
15
  `button` also takes `type` (button, unless it submits), `id`, `name`,
16
16
  `value`, `formaction`, `formtarget`, `formnovalidate`, `popovertarget`,
17
- `disabled` and `hidden`. The label is escaped. #}
17
+ `ariaLabel`, `disabled` and `hidden`. The label is escaped. #}
18
18
 
19
19
  {% set COLORS = {
20
20
  neutral: 'btn-neutral',
@@ -45,10 +45,11 @@
45
45
  formtarget='',
46
46
  formnovalidate=false,
47
47
  popovertarget='',
48
+ ariaLabel='',
48
49
  disabled=false,
49
50
  hidden=false
50
51
  ) -%}
51
- <button type="{{ type }}"{% if id %} id="{{ id }}"{% endif %} class="btn{{ COLORS | modifier(color) }}{{ STYLES | modifier(style) }}{{ SIZES | modifier(size) }}{{ SHAPES | modifier(shape) }}{% if active %} btn-active {%- endif %}"{% if name %} name="{{ name }}"{% endif %}{% if value %} value="{{ value }}"{% endif %}{% if formaction %} formaction="{{ formaction }}"{% endif %}{% if formtarget %} formtarget="{{ formtarget }}"{% endif %}{% if formnovalidate %} formnovalidate{% endif %}{% if popovertarget %} popovertarget="{{ popovertarget }}"{% endif %}{% if disabled %} disabled{% endif %}{% if hidden %} hidden{% endif %}>{{ label }}</button>
52
+ <button type="{{ type }}"{% if id %} id="{{ id }}"{% endif %} class="btn{{ COLORS | modifier(color) }}{{ STYLES | modifier(style) }}{{ SIZES | modifier(size) }}{{ SHAPES | modifier(shape) }}{% if active %} btn-active {%- endif %}"{% if name %} name="{{ name }}"{% endif %}{% if value %} value="{{ value }}"{% endif %}{% if formaction %} formaction="{{ formaction }}"{% endif %}{% if formtarget %} formtarget="{{ formtarget }}"{% endif %}{% if formnovalidate %} formnovalidate{% endif %}{% if popovertarget %} popovertarget="{{ popovertarget }}"{% endif %}{% if ariaLabel %} aria-label="{{ ariaLabel }}"{% endif %}{% if disabled %} disabled{% endif %}{% if hidden %} hidden{% endif %}>{{ label }}</button>
52
53
  {%- endmacro %}
53
54
 
54
55
  {% macro buttonLink(label, href, color=none, style=none, size='sm', shape=none, active=false) -%}
@@ -10,7 +10,7 @@
10
10
  `id` must be unique on the page. The body is markup. The label is escaped.
11
11
 
12
12
  The trigger is `button` from `components/button.njk` and takes its
13
- `color`, `style` and `size` (sm by default). `placement` is start, center,
13
+ `color`, `style`, `size` (sm by default) and `ariaLabel`. `placement` is start, center,
14
14
  end, top, bottom, left or right, each mapping to its `dropdown-*` class. A
15
15
  value outside its set is a render error. #}
16
16
 
@@ -26,7 +26,7 @@
26
26
  right: 'dropdown-right'
27
27
  } %}
28
28
 
29
- {% macro dropdown(id, label, color=none, style=none, size='sm', placement=none) -%}
30
- {{ button(label, color=color, style=style, size=size, popovertarget=id) }}
29
+ {% macro dropdown(id, label, color=none, style=none, size='sm', placement=none, ariaLabel='') -%}
30
+ {{ button(label, color=color, style=style, size=size, popovertarget=id, ariaLabel=ariaLabel) }}
31
31
  <div class="dropdown rounded-box bg-base-100 z-1 w-52 shadow-sm{{ PLACEMENTS | modifier(placement) }}" popover id="{{ id }}">{{ caller() }}</div>
32
32
  {%- endmacro %}
@@ -4,20 +4,53 @@
4
4
  {% from "components/badge.njk" import badge %}
5
5
  {% from "components/blocks.njk" import block %}
6
6
  {% from "components/tabs.njk" import tabs %}
7
+ {% from "components/button.njk" import button %}
8
+ {% from "components/dropdown.njk" import dropdown %}
7
9
 
8
10
  {% block title %}Dashboard{% endblock %}
9
11
 
12
+ {% macro widgetBody(widget) -%}
13
+ {% if widget.status == 'stale' %}
14
+ <p class="text-sm">{{ badge('Stale', color='warning') }} The latest load failed. These figures are from {{ widget.loadedAt }}.</p>
15
+ {% elif widget.status == 'unavailable' %}
16
+ <p>These stats are unavailable right now.</p>
17
+ {% endif %}
18
+ {% for data in widget.blocks %}{{ block(data) }}{% endfor %}
19
+ {%- endmacro %}
20
+
10
21
  {% macro widgetCard(widget, tone=none) -%}
11
22
  {% call card(title=widget.title, tone=tone) %}
12
- {% if widget.status == 'stale' %}
13
- <p class="text-sm">{{ badge('Stale', color='warning') }} The latest load failed. These figures are from {{ widget.loadedAt }}.</p>
14
- {% elif widget.status == 'unavailable' %}
15
- <p>These stats are unavailable right now.</p>
16
- {% endif %}
17
- {% for data in widget.blocks %}{{ block(data) }}{% endfor %}
23
+ {{ widgetBody(widget) }}
18
24
  {% endcall %}
19
25
  {%- endmacro %}
20
26
 
27
+ {% macro arrangedCard(widget) -%}
28
+ <div class="card bg-base-100 shadow-sm">
29
+ <div class="card-body">
30
+ <div class="flex flex-wrap items-start justify-between gap-2">
31
+ <h2 class="card-title">{{ widget.title }}</h2>
32
+ <form method="post" action="{{ layoutUrl }}" class="flex shrink-0 items-center gap-1">
33
+ <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
34
+ <input type="hidden" name="widget" value="{{ widget.key }}" />
35
+ {% if period %}<input type="hidden" name="period" value="{{ period }}" />{% endif %}
36
+ {% for key in widget.shown %}<input type="hidden" name="shown" value="{{ key }}" />{% endfor %}
37
+ {{ button('↑', type='submit', name='action', value='earlier', style='ghost', size='xs', shape='square', ariaLabel='Move ' + widget.title + ' earlier', disabled=widget.first) }}
38
+ {{ button('↓', type='submit', name='action', value='later', style='ghost', size='xs', shape='square', ariaLabel='Move ' + widget.title + ' later', disabled=widget.last) }}
39
+ {% if widget.collapsed %}
40
+ {{ button('+', type='submit', name='action', value='expand', style='ghost', size='xs', shape='square', ariaLabel='Expand ' + widget.title) }}
41
+ {% else %}
42
+ {{ button('−', type='submit', name='action', value='collapse', style='ghost', size='xs', shape='square', ariaLabel='Collapse ' + widget.title) }}
43
+ {% endif %}
44
+ {{ button('✕', type='submit', name='action', value='remove', style='ghost', size='xs', shape='square', ariaLabel='Remove ' + widget.title) }}
45
+ </form>
46
+ </div>
47
+ {% if not widget.collapsed %}
48
+ {{ widgetBody(widget) }}
49
+ {% endif %}
50
+ </div>
51
+ </div>
52
+ {%- endmacro %}
53
+
21
54
  {% block content %}
22
55
  <h1 class="mb-6 text-2xl font-bold">Dashboard</h1>
23
56
 
@@ -43,19 +76,44 @@
43
76
  <section class="mb-8">{{ widgetCard(widget, tone='warning') }}</section>
44
77
  {% endfor %}
45
78
 
46
- {% if widgets.length %}
79
+ {% if arrangeable %}
47
80
  <div class="@container">
48
81
  <div class="mb-3 flex flex-wrap items-center justify-between gap-3">
49
82
  <h2 class="text-lg font-semibold">Activity</h2>
50
- {{ tabs(periods, 'Period', size='sm') }}
83
+ <div class="flex items-center gap-2">
84
+ {{ tabs(periods, 'Period', size='sm') }}
85
+ {% call dropdown('dashboard-cards', '⚙', style='ghost', placement='end', ariaLabel='Removed cards') %}
86
+ <div class="p-3">
87
+ <p class="mb-2 text-sm font-semibold">Removed cards</p>
88
+ {% if removed.length %}
89
+ <ul class="space-y-2">
90
+ {% for card in removed %}
91
+ <li>
92
+ <form method="post" action="{{ layoutUrl }}" class="flex items-center justify-between gap-2">
93
+ <input type="hidden" name="csrf_token" value="{{ csrfToken }}" />
94
+ <input type="hidden" name="widget" value="{{ card.key }}" />
95
+ {% if period %}<input type="hidden" name="period" value="{{ period }}" />{% endif %}
96
+ <span class="min-w-0 text-sm">{{ card.title }}</span>
97
+ {{ button('Add back', type='submit', name='action', value='restore', size='xs', ariaLabel='Add back ' + card.title) }}
98
+ </form>
99
+ </li>
100
+ {% endfor %}
101
+ </ul>
102
+ {% else %}
103
+ <p class="text-sm">No cards are removed.</p>
104
+ {% endif %}
105
+ </div>
106
+ {% endcall %}
107
+ </div>
51
108
  </div>
52
- <div class="grid grid-cols-1 gap-6 @3xl:grid-cols-2">
109
+ <div class="grid grid-cols-1 gap-6 @3xl:grid-cols-2" data-dashboard-cards data-layout-url="{{ layoutUrl }}">
53
110
  {% for widget in widgets %}
54
- <div class="min-w-0{% if widget.width == 'full' %} @3xl:col-span-2{% endif %}">
55
- {{ widgetCard(widget) }}
111
+ <div class="min-w-0{% if widget.width == 'full' %} @3xl:col-span-2{% endif %}" data-dashboard-card="{{ widget.key }}">
112
+ {{ arrangedCard(widget) }}
56
113
  </div>
57
114
  {% endfor %}
58
115
  </div>
59
116
  </div>
117
+ <script defer src="{{ adminAsset('dashboard.js') }}"></script>
60
118
  {% endif %}
61
119
  {% endblock %}
@@ -5,6 +5,7 @@
5
5
  @source '../../editor/look.ts';
6
6
  @source '../static/media.js';
7
7
  @source '../static/upload-drop.js';
8
+ @source '../static/dashboard.js';
8
9
 
9
10
  @plugin 'daisyui' {
10
11
  themes:
@@ -7500,6 +7500,10 @@
7500
7500
  rotate: 45deg;
7501
7501
  }
7502
7502
 
7503
+ .cursor-grab {
7504
+ cursor: grab;
7505
+ }
7506
+
7503
7507
  .cursor-pointer {
7504
7508
  cursor: pointer;
7505
7509
  }
@@ -7998,7 +8002,17 @@
7998
8002
  white-space: pre-wrap;
7999
8003
  }
8000
8004
 
8001
- .text-base-content, .text-base-content\/70 {
8005
+ .text-base-content, .text-base-content\/60 {
8006
+ color: var(--color-base-content);
8007
+ }
8008
+
8009
+ @supports (color: color-mix(in lab, red, red)) {
8010
+ .text-base-content\/60 {
8011
+ color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
8012
+ }
8013
+ }
8014
+
8015
+ .text-base-content\/70 {
8002
8016
  color: var(--color-base-content);
8003
8017
  }
8004
8018
 
@@ -8029,6 +8043,10 @@
8029
8043
  font-variant-numeric: var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, );
8030
8044
  }
8031
8045
 
8046
+ .opacity-50 {
8047
+ opacity: .5;
8048
+ }
8049
+
8032
8050
  .opacity-70 {
8033
8051
  opacity: .7;
8034
8052
  }
@@ -8097,6 +8115,11 @@
8097
8115
  outline-style: dashed;
8098
8116
  }
8099
8117
 
8118
+ .select-none {
8119
+ -webkit-user-select: none;
8120
+ user-select: none;
8121
+ }
8122
+
8100
8123
  :is(.\*\:align-top > *) {
8101
8124
  vertical-align: top;
8102
8125
  }
@@ -0,0 +1,109 @@
1
+ (function () {
2
+ var grid = document.querySelector('[data-dashboard-cards]');
3
+ if (!grid) return;
4
+ var url = grid.getAttribute('data-layout-url');
5
+
6
+ var live = document.createElement('div');
7
+ live.className = 'sr-only';
8
+ live.setAttribute('role', 'status');
9
+ document.body.appendChild(live);
10
+
11
+ var dragged = null;
12
+ var before = '';
13
+
14
+ Array.prototype.forEach.call(cards(), function (card) {
15
+ var title = card.querySelector('.card-title');
16
+ var handle = document.createElement('span');
17
+ handle.className = 'cursor-grab select-none text-base-content/60';
18
+ handle.setAttribute('aria-hidden', 'true');
19
+ handle.setAttribute('title', 'Drag to move');
20
+ handle.textContent = '⠿';
21
+ title.insertBefore(handle, title.firstChild);
22
+
23
+ /* Only the handle starts a drag, so text in the card still selects. */
24
+ handle.addEventListener('pointerdown', function () {
25
+ card.draggable = true;
26
+ });
27
+ card.addEventListener('dragstart', function (event) {
28
+ dragged = card;
29
+ before = order().join('\n');
30
+ event.dataTransfer.effectAllowed = 'move';
31
+ event.dataTransfer.setData('text/plain', card.getAttribute('data-dashboard-card'));
32
+ card.classList.add('opacity-50');
33
+ });
34
+ card.addEventListener('dragend', function () {
35
+ card.draggable = false;
36
+ card.classList.remove('opacity-50');
37
+ dragged = null;
38
+ if (order().join('\n') !== before) save(card);
39
+ });
40
+ });
41
+
42
+ grid.addEventListener('dragover', function (event) {
43
+ if (!dragged) return;
44
+ event.preventDefault();
45
+ var over = event.target.closest('[data-dashboard-card]');
46
+ if (!over || over === dragged) return;
47
+ var past = isPastDiagonal(over.getBoundingClientRect(), event);
48
+ if (past && over.nextElementSibling !== dragged) over.after(dragged);
49
+ else if (!past && over.previousElementSibling !== dragged) over.before(dragged);
50
+ });
51
+ grid.addEventListener('drop', function (event) {
52
+ if (dragged) event.preventDefault();
53
+ });
54
+
55
+ function isPastDiagonal(box, event) {
56
+ return (event.clientX - box.left) / box.width + (event.clientY - box.top) / box.height > 1;
57
+ }
58
+
59
+ function cards() {
60
+ return Array.prototype.slice.call(grid.querySelectorAll('[data-dashboard-card]'));
61
+ }
62
+
63
+ function order() {
64
+ return cards().map(function (card) {
65
+ return card.getAttribute('data-dashboard-card');
66
+ });
67
+ }
68
+
69
+ function save(card) {
70
+ var form = card.querySelector('form');
71
+ var body = new URLSearchParams();
72
+ body.append('csrf_token', form.elements['csrf_token'].value);
73
+ body.append('action', 'order');
74
+ order().forEach(function (key) {
75
+ body.append('order', key);
76
+ });
77
+ fetch(url, {
78
+ method: 'POST',
79
+ body: body,
80
+ credentials: 'same-origin',
81
+ redirect: 'manual',
82
+ })
83
+ .then(function (response) {
84
+ if (response.type !== 'opaqueredirect' && !response.ok) {
85
+ throw new Error(String(response.status));
86
+ }
87
+ moveButtons();
88
+ var all = cards();
89
+ live.textContent =
90
+ card.querySelector('.card-title').textContent.replace('⠿', '').trim() +
91
+ ' moved to position ' +
92
+ String(all.indexOf(card) + 1) +
93
+ ' of ' +
94
+ String(all.length) +
95
+ '.';
96
+ })
97
+ .catch(function () {
98
+ window.location.reload();
99
+ });
100
+ }
101
+
102
+ function moveButtons() {
103
+ var all = cards();
104
+ all.forEach(function (card, index) {
105
+ card.querySelector('button[value="earlier"]').disabled = index === 0;
106
+ card.querySelector('button[value="later"]').disabled = index === all.length - 1;
107
+ });
108
+ }
109
+ })();
@@ -0,0 +1,13 @@
1
+ import type { Hono } from 'hono';
2
+ import type { GeekityEnv } from '../web/env.ts';
3
+ import type { AdminRender } from './documents.ts';
4
+ export declare const DASHBOARD_LAYOUT_PATH = "/admin/dashboard/layout";
5
+ /**
6
+ * The dashboard: the stat tiles, the alert cards, and the activity cards in
7
+ * the order the signed-in user arranged them. Collapsed and removed cards are
8
+ * never handed to the widget board, so their widgets do not load.
9
+ */
10
+ export declare function mountDashboard(app: Hono<GeekityEnv>, options: {
11
+ render: AdminRender;
12
+ }): void;
13
+ //# sourceMappingURL=dashboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard.d.ts","sourceRoot":"","sources":["../../src/admin/dashboard.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,IAAI,EAAE,MAAM,MAAM,CAAC;AAG1C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAahD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAelD,eAAO,MAAM,qBAAqB,4BAAqC,CAAC;AAExE;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GAAG,IAAI,CA2F5F"}
@@ -0,0 +1,137 @@
1
+ import { ADMIN_PREFIX } from '../site/admin-paths.js';
2
+ import { arrangeCards, changeLayout, EMPTY_LAYOUT, LAYOUT_ACTIONS, } from '../site/dashboard-layout.js';
3
+ import { findUserById, setUserDashboardLayout } from '../site/users.js';
4
+ import { stringOrEmpty } from '../util/forms.js';
5
+ import { COMMENTS_PATH, pendingComments } from './comments.js';
6
+ import { CORE_WIDGETS } from './core-widgets.js';
7
+ import { FEDERATION_PATH } from './federation.js';
8
+ import { formatInTimezone } from './formatting.js';
9
+ import { MESSAGES_PATH, unreadMessages } from './messages.js';
10
+ import { ADMIN_TEMPLATES } from './templates.js';
11
+ import { createWidgetBoard, DASHBOARD_PERIODS, dashboardPeriod, dashboardWidgets, WIDGET_TIME_LIMIT_MS, widgetKey, } from './widgets.js';
12
+ export const DASHBOARD_LAYOUT_PATH = `${ADMIN_PREFIX}/dashboard/layout`;
13
+ /**
14
+ * The dashboard: the stat tiles, the alert cards, and the activity cards in
15
+ * the order the signed-in user arranged them. Collapsed and removed cards are
16
+ * never handed to the widget board, so their widgets do not load.
17
+ */
18
+ export function mountDashboard(app, options) {
19
+ const { render } = options;
20
+ const widgetBoard = createWidgetBoard({ timeLimitMs: WIDGET_TIME_LIMIT_MS });
21
+ app.get(ADMIN_PREFIX, async (c) => {
22
+ const timezone = c.var.siteAtRequestStart.timezone;
23
+ const period = dashboardPeriod(c.req.query('period'));
24
+ const layout = layoutOf(c);
25
+ const all = dashboardWidgets(c.var, CORE_WIDGETS, period);
26
+ const activity = all.filter((entry) => entry.placement === 'activity');
27
+ const byKey = new Map(activity.map((entry) => [keyOf(entry), entry]));
28
+ const arranged = arrangeCards([...byKey.keys()], layout);
29
+ const shown = arranged.filter((key) => !layout.removed.includes(key));
30
+ const loading = all.filter((entry) => entry.placement === 'alerts' ||
31
+ (shown.includes(keyOf(entry)) && !layout.collapsed.includes(keyOf(entry))));
32
+ const loaded = new Map((await widgetBoard.load(loading, c.var.config.now)).map((widget) => [widget.key, widget]));
33
+ const drawn = (widget) => ({
34
+ ...widget,
35
+ loadedAt: widget.loadedAt === undefined
36
+ ? undefined
37
+ : formatInTimezone(widget.loadedAt.toISOString(), timezone),
38
+ });
39
+ const cards = shown.flatMap((key) => {
40
+ const { widget } = byKey.get(key);
41
+ if (layout.collapsed.includes(key)) {
42
+ return [{ key, title: widget.title, width: widget.width, collapsed: true }];
43
+ }
44
+ const result = loaded.get(key);
45
+ return result === undefined ? [] : [{ ...drawn(result), collapsed: false }];
46
+ });
47
+ const placed = positionedAmongDrawn(cards);
48
+ return render(c, ADMIN_TEMPLATES.dashboard, {
49
+ section: 'dashboard',
50
+ child: 'home',
51
+ periods: DASHBOARD_PERIODS.map((days) => ({
52
+ label: `${String(days)} days`,
53
+ url: periodUrl(days),
54
+ current: days === period,
55
+ })),
56
+ period: period === 7 ? '' : String(period),
57
+ counts: c.var.store.counts(),
58
+ // The one number on the dashboard that is not about the content
59
+ // directory: it is what says whether publishing is reaching anybody.
60
+ followers: c.var.admin.countFollowers(),
61
+ federationUrl: FEDERATION_PATH,
62
+ // The other number that is not about the content directory: with no
63
+ // email in this milestone, the dashboard is how a moderator finds out
64
+ // that somebody is waiting (TASK-50).
65
+ pendingComments: pendingComments(c.var.admin),
66
+ commentsUrl: COMMENTS_PATH,
67
+ // And what the contact form has left waiting, which is the same kind of
68
+ // number for the same reason (TASK-56).
69
+ unreadMessages: unreadMessages(c.var.config.dataDir),
70
+ messagesUrl: MESSAGES_PATH,
71
+ alerts: [...loaded.values()].filter((widget) => widget.placement === 'alerts').map(drawn),
72
+ arrangeable: byKey.size > 0,
73
+ widgets: placed,
74
+ removed: arranged
75
+ .filter((key) => layout.removed.includes(key))
76
+ .map((key) => ({ key, title: byKey.get(key).widget.title })),
77
+ layoutUrl: DASHBOARD_LAYOUT_PATH,
78
+ });
79
+ });
80
+ app.post(DASHBOARD_LAYOUT_PATH, async (c) => {
81
+ const body = await c.req.parseBody({ all: true });
82
+ const back = periodUrl(dashboardPeriod(stringOrEmpty(body['period'])));
83
+ const userId = c.var.session?.userId;
84
+ if (userId == null)
85
+ return c.redirect(back, 303);
86
+ const change = layoutChange(body);
87
+ if (change !== undefined) {
88
+ const keys = dashboardWidgets(c.var, CORE_WIDGETS, 7)
89
+ .filter((entry) => entry.placement === 'activity')
90
+ .map(keyOf);
91
+ await setUserDashboardLayout({
92
+ dataDir: c.var.config.dataDir,
93
+ userId,
94
+ layout: changeLayout(layoutOf(c), change, keys),
95
+ });
96
+ }
97
+ return c.redirect(back, 303);
98
+ });
99
+ }
100
+ function positionedAmongDrawn(cards) {
101
+ const drawnKeys = cards.map((card) => card.key);
102
+ return cards.map((card, index) => ({
103
+ shown: drawnKeys,
104
+ ...card,
105
+ first: index === 0,
106
+ last: index === cards.length - 1,
107
+ }));
108
+ }
109
+ function keyOf(entry) {
110
+ return widgetKey(entry.owner, entry.widget.id);
111
+ }
112
+ function periodUrl(days) {
113
+ return days === 7 ? ADMIN_PREFIX : `${ADMIN_PREFIX}?period=${String(days)}`;
114
+ }
115
+ function layoutOf(c) {
116
+ const userId = c.var.session?.userId;
117
+ if (userId == null)
118
+ return EMPTY_LAYOUT;
119
+ return findUserById(c.var.config.dataDir, userId)?.dashboardLayout ?? EMPTY_LAYOUT;
120
+ }
121
+ function layoutChange(body) {
122
+ const action = LAYOUT_ACTIONS.find((each) => each === stringOrEmpty(body['action']));
123
+ if (action === undefined)
124
+ return undefined;
125
+ if (action === 'order')
126
+ return { action, keys: listOf(body['order']) };
127
+ const key = stringOrEmpty(body['widget']);
128
+ if (action === 'earlier' || action === 'later') {
129
+ const shown = listOf(body['shown']);
130
+ return shown.length === 0 ? { action, key } : { action, key, shown };
131
+ }
132
+ return { action, key };
133
+ }
134
+ function listOf(value) {
135
+ return (Array.isArray(value) ? value : [value]).filter((key) => typeof key === 'string');
136
+ }
137
+ //# sourceMappingURL=dashboard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dashboard.js","sourceRoot":"","sources":["../../src/admin/dashboard.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/admin/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,IAAI,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAI7D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAyEhD,sCAAsC;AACtC,eAAO,MAAM,UAAU,iBAA0B,CAAC;AAClD,mCAAmC;AACnC,eAAO,MAAM,WAAW,kBAA2B,CAAC;AAEpD,yCAAyC;AACzC,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,CAAC,GAAG,IAAI,CA4YtD;AA8CD;;;;;GAKG;AACH,eAAO,MAAM,KAAK,EAAE,iBAAiB,CAAC,UAAU,CAsD/C,CAAC"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/admin/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,IAAI,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAI7D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAgEhD,sCAAsC;AACtC,eAAO,MAAM,UAAU,iBAA0B,CAAC;AAClD,mCAAmC;AACnC,eAAO,MAAM,WAAW,kBAA2B,CAAC;AAEpD,yCAAyC;AACzC,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,UAAU,CAAC,GAAG,IAAI,CAgWtD;AA8CD;;;;;GAKG;AACH,eAAO,MAAM,KAAK,EAAE,iBAAiB,CAAC,UAAU,CAsD/C,CAAC"}
@@ -8,13 +8,13 @@ import { adminAssetResponse } from './assets.js';
8
8
  import { ADMIN_ASSET_PREFIX, ADMIN_PREFIX, editUserPath, LOGIN_PATH } from '../site/admin-paths.js';
9
9
  import { credentialProblem } from './credentials.js';
10
10
  import { editorPath, mountDocumentScreens, newEditorPath, PAGE_SCREENS, POST_SCREENS, } from './documents.js';
11
- import { FEDERATION_PATH, mountFederationScreen } from './federation.js';
11
+ import { mountFederationScreen } from './federation.js';
12
12
  import { takeFlash } from './flash.js';
13
13
  import { adminSecurityHeaders } from './headers.js';
14
- import { COMMENTS_PATH, mountCommentsScreen, pendingComments } from './comments.js';
14
+ import { mountCommentsScreen } from './comments.js';
15
15
  import { MEDIA_UPLOAD_PATH, mountMediaScreen } from './media.js';
16
16
  import { adminMenu } from './menu.js';
17
- import { MESSAGES_PATH, mountMessagesScreen, unreadMessages } from './messages.js';
17
+ import { mountMessagesScreen } from './messages.js';
18
18
  import { mountNavigationScreen } from './navigation.js';
19
19
  import { mountPreview } from './preview.js';
20
20
  import { FORGOT_PATH, mountRecovery, RESET_PATH } from './recovery.js';
@@ -36,9 +36,7 @@ import { mountItemLookup, mountItemSearch } from './item-lookup.js';
36
36
  import { mountPluginsScreen, pluginScreens } from './plugins.js';
37
37
  import { mountUploads, refuseOversizedUpload, UPLOADS_PATH } from './uploads.js';
38
38
  import { mountUsers } from './users.js';
39
- import { CORE_WIDGETS } from './core-widgets.js';
40
- import { formatInTimezone } from './formatting.js';
41
- import { createWidgetBoard, DASHBOARD_PERIODS, dashboardPeriod, dashboardWidgets, WIDGET_TIME_LIMIT_MS, } from './widgets.js';
39
+ import { mountDashboard } from './dashboard.js';
42
40
  /** Where the first-run form lives. */
43
41
  export const SETUP_PATH = `${ADMIN_PREFIX}/setup`;
44
42
  /** Where the logout form posts. */
@@ -96,7 +94,6 @@ export function mountAdmin(app) {
96
94
  });
97
95
  return recoveryThrottled;
98
96
  }
99
- const widgetBoard = createWidgetBoard({ timeLimitMs: WIDGET_TIME_LIMIT_MS });
100
97
  /**
101
98
  * Render one admin template.
102
99
  *
@@ -254,44 +251,7 @@ export function mountAdmin(app) {
254
251
  c.header('Clear-Site-Data', CLEAR_ON_LOGOUT);
255
252
  return c.redirect(LOGIN_PATH, 303);
256
253
  });
257
- app.get(ADMIN_PREFIX, async (c) => {
258
- const timezone = c.var.siteAtRequestStart.timezone;
259
- const period = dashboardPeriod(c.req.query('period'));
260
- const loaded = await widgetBoard.load(dashboardWidgets(c.var, CORE_WIDGETS, period), c.var.config.now);
261
- const drawn = (placement) => loaded
262
- .filter((widget) => widget.placement === placement)
263
- .map((widget) => ({
264
- ...widget,
265
- loadedAt: widget.loadedAt === undefined
266
- ? undefined
267
- : formatInTimezone(widget.loadedAt.toISOString(), timezone),
268
- }));
269
- return render(c, ADMIN_TEMPLATES.dashboard, {
270
- section: 'dashboard',
271
- child: 'home',
272
- periods: DASHBOARD_PERIODS.map((days) => ({
273
- label: `${String(days)} days`,
274
- url: days === 7 ? ADMIN_PREFIX : `${ADMIN_PREFIX}?period=${String(days)}`,
275
- current: days === period,
276
- })),
277
- counts: c.var.store.counts(),
278
- // The one number on the dashboard that is not about the content
279
- // directory: it is what says whether publishing is reaching anybody.
280
- followers: c.var.admin.countFollowers(),
281
- federationUrl: FEDERATION_PATH,
282
- // The other number that is not about the content directory: with no
283
- // email in this milestone, the dashboard is how a moderator finds out
284
- // that somebody is waiting (TASK-50).
285
- pendingComments: pendingComments(c.var.admin),
286
- commentsUrl: COMMENTS_PATH,
287
- // And what the contact form has left waiting, which is the same kind of
288
- // number for the same reason (TASK-56).
289
- unreadMessages: unreadMessages(c.var.config.dataDir),
290
- messagesUrl: MESSAGES_PATH,
291
- alerts: drawn('alerts'),
292
- widgets: drawn('activity'),
293
- });
294
- });
254
+ mountDashboard(app, { render });
295
255
  // The listing and the editor. Posts and pages are the same screens over the
296
256
  // same store; the kind decides what a file is called and which fields the
297
257
  // form has.