@things-factory/board-ui 10.1.41 → 10.1.44

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 (81) hide show
  1. package/dist-client/board-list/board-thumb.d.ts +33 -0
  2. package/dist-client/board-list/board-thumb.js +119 -0
  3. package/dist-client/board-list/board-thumb.js.map +1 -0
  4. package/dist-client/graphql/favorite-board.js +3 -0
  5. package/dist-client/graphql/favorite-board.js.map +1 -1
  6. package/dist-client/graphql/my-board.js +3 -0
  7. package/dist-client/graphql/my-board.js.map +1 -1
  8. package/dist-client/graphql/play-group.js +2 -0
  9. package/dist-client/graphql/play-group.js.map +1 -1
  10. package/dist-client/index.d.ts +1 -0
  11. package/dist-client/index.js +1 -0
  12. package/dist-client/index.js.map +1 -1
  13. package/dist-client/pages/board-list-page.d.ts +39 -42
  14. package/dist-client/pages/board-list-page.js +388 -284
  15. package/dist-client/pages/board-list-page.js.map +1 -1
  16. package/dist-client/pages/board-modeller-page.d.ts +2 -0
  17. package/dist-client/pages/board-modeller-page.js +18 -1
  18. package/dist-client/pages/board-modeller-page.js.map +1 -1
  19. package/dist-client/pages/board-template/board-template-list-page.d.ts +21 -10
  20. package/dist-client/pages/board-template/board-template-list-page.js +147 -136
  21. package/dist-client/pages/board-template/board-template-list-page.js.map +1 -1
  22. package/dist-client/pages/play-list-page.d.ts +22 -12
  23. package/dist-client/pages/play-list-page.js +264 -113
  24. package/dist-client/pages/play-list-page.js.map +1 -1
  25. package/dist-client/route.d.ts +1 -1
  26. package/dist-client/setting-let/board-modeller-setting-let.d.ts +21 -0
  27. package/dist-client/setting-let/board-modeller-setting-let.js +144 -0
  28. package/dist-client/setting-let/board-modeller-setting-let.js.map +1 -0
  29. package/dist-client/styles/board-card-styles.d.ts +11 -0
  30. package/dist-client/styles/board-card-styles.js +90 -0
  31. package/dist-client/styles/board-card-styles.js.map +1 -0
  32. package/dist-client/themes/board-theme.css +32 -19
  33. package/dist-client/tsconfig.tsbuildinfo +1 -1
  34. package/dist-client/utils/board-meta.d.ts +10 -0
  35. package/dist-client/utils/board-meta.js +34 -0
  36. package/dist-client/utils/board-meta.js.map +1 -0
  37. package/dist-client/viewparts/board-basic-info.d.ts +38 -7
  38. package/dist-client/viewparts/board-basic-info.js +378 -349
  39. package/dist-client/viewparts/board-basic-info.js.map +1 -1
  40. package/dist-client/viewparts/board-info-link.d.ts +5 -1
  41. package/dist-client/viewparts/board-info-link.js +79 -21
  42. package/dist-client/viewparts/board-info-link.js.map +1 -1
  43. package/dist-client/viewparts/board-info-template.d.ts +15 -0
  44. package/dist-client/viewparts/board-info-template.js +97 -0
  45. package/dist-client/viewparts/board-info-template.js.map +1 -0
  46. package/dist-client/viewparts/board-info.d.ts +5 -0
  47. package/dist-client/viewparts/board-info.js +24 -73
  48. package/dist-client/viewparts/board-info.js.map +1 -1
  49. package/dist-client/viewparts/board-template-builder.d.ts +1 -0
  50. package/dist-client/viewparts/board-template-builder.js +37 -65
  51. package/dist-client/viewparts/board-template-builder.js.map +1 -1
  52. package/dist-client/viewparts/board-template-info.d.ts +3 -0
  53. package/dist-client/viewparts/board-template-info.js +75 -134
  54. package/dist-client/viewparts/board-template-info.js.map +1 -1
  55. package/dist-client/viewparts/group-info-basic.d.ts +8 -3
  56. package/dist-client/viewparts/group-info-basic.js +54 -190
  57. package/dist-client/viewparts/group-info-basic.js.map +1 -1
  58. package/dist-client/viewparts/group-info-import.d.ts +3 -1
  59. package/dist-client/viewparts/group-info-import.js +29 -62
  60. package/dist-client/viewparts/group-info-import.js.map +1 -1
  61. package/dist-client/viewparts/group-info.d.ts +4 -0
  62. package/dist-client/viewparts/group-info.js +21 -55
  63. package/dist-client/viewparts/group-info.js.map +1 -1
  64. package/dist-client/viewparts/info-dialog-styles.d.ts +19 -0
  65. package/dist-client/viewparts/info-dialog-styles.js +194 -0
  66. package/dist-client/viewparts/info-dialog-styles.js.map +1 -0
  67. package/dist-client/viewparts/link-builder.d.ts +3 -0
  68. package/dist-client/viewparts/link-builder.js +87 -81
  69. package/dist-client/viewparts/link-builder.js.map +1 -1
  70. package/dist-client/viewparts/play-group-info-basic.d.ts +4 -3
  71. package/dist-client/viewparts/play-group-info-basic.js +49 -188
  72. package/dist-client/viewparts/play-group-info-basic.js.map +1 -1
  73. package/dist-client/viewparts/play-group-info.d.ts +4 -0
  74. package/dist-client/viewparts/play-group-info.js +22 -53
  75. package/dist-client/viewparts/play-group-info.js.map +1 -1
  76. package/package.json +6 -6
  77. package/translations/en.json +30 -0
  78. package/translations/ja.json +9 -0
  79. package/translations/ko.json +35 -5
  80. package/translations/ms.json +8 -1
  81. package/translations/zh.json +9 -0
@@ -1,164 +1,233 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
2
  import '@material/web/icon/icon.js';
3
- import '@material/web/textfield/filled-text-field.js';
4
- import '@material/web/select/outlined-select';
5
- import '@material/web/button/elevated-button.js';
3
+ import '@material/web/iconbutton/icon-button.js';
4
+ import '@material/web/button/filled-button.js';
5
+ import '@material/web/button/outlined-button.js';
6
+ import '@material/web/button/text-button.js';
6
7
  import '@material/web/chips/chip-set.js';
7
- import '@material/web/chips/assist-chip.js';
8
8
  import '@material/web/chips/filter-chip.js';
9
- import '@material/web/radio/radio.js';
9
+ import '@material/web/menu/menu.js';
10
+ import '@material/web/menu/menu-item.js';
11
+ import '@material/web/divider/divider.js';
12
+ import '@operato/input/ox-input-select-buttons.js';
10
13
  import gql from 'graphql-tag';
11
14
  import { css, html, LitElement, nothing } from 'lit';
12
- import { customElement, property, state } from 'lit/decorators.js';
15
+ import { customElement, property, query, state } from 'lit/decorators.js';
13
16
  import { i18next } from '@operato/i18n';
14
17
  import { client } from '@operato/graphql';
15
18
  import { navigate, AppController } from '@operato/shell';
16
19
  import { OxPrompt } from '@operato/popup/ox-prompt.js';
20
+ import { CommonPopupStyles } from '@operato/styles';
17
21
  import { hasPrivilege } from '@things-factory/auth-base/dist-client';
18
- import { styles as MDTypeScaleStyles } from '@material/web/typography/md-typescale-styles';
19
22
  import { notify } from '../utils/notify-helper.js';
23
+ import { timeAgo } from '../utils/board-meta.js';
24
+ import { InfoFormStyles } from './info-dialog-styles.js';
25
+ /**
26
+ * The basic tab of the board dialog.
27
+ *
28
+ * ## The shape, and why
29
+ *
30
+ * Three kinds of thing used to share one scrolling form: fields that wait for Save, actions that
31
+ * happen the moment you touch them (release, join a play group), and a copy-to-domain panel hidden
32
+ * behind a caret next to the name. The Save button sat in the middle of all that and scrolled out
33
+ * of view on a short screen.
34
+ *
35
+ * Now the dialog reads top to bottom as: what this is (a status band), what you can change (the
36
+ * form, committed by Save), where it plays (membership chips, which apply at once and say so), and
37
+ * a footer that never scrolls away: the primary Save, the way into the modeller, and everything
38
+ * else - duplicate, export, release, delete - behind one "more" menu so the footer stays two
39
+ * buttons wide. Duplicate opens its own small panel with its own Copy and Cancel.
40
+ *
41
+ * ## Colour
42
+ *
43
+ * The accent (board's earth brown) is spent on three things only: the filled Save, the selected
44
+ * segment, and the pressed play-group chip. Everything else is the neutral scale - the status band
45
+ * is a low surface, the draft pill a warm secondary container, released a soft tertiary green -
46
+ * so the one thing you are meant to press is the one thing in colour.
47
+ */
20
48
  let BoardInfo = class BoardInfo extends LitElement {
21
49
  constructor() {
22
50
  super(...arguments);
23
51
  this.boardGroupList = [];
24
52
  this.playGroupList = [];
25
- this.showTarget = false;
53
+ this.duplicating = false;
26
54
  this.enableModeller = false;
27
55
  this._appCtrl = new AppController(this);
28
56
  this.targetDomains = [];
29
57
  this.targetGroups = [];
30
58
  }
31
59
  static { this.styles = [
32
- MDTypeScaleStyles,
60
+ CommonPopupStyles,
61
+ InfoFormStyles,
33
62
  css `
34
63
  :host {
35
- position: relative;
36
- display: block;
37
- background-color: var(--md-sys-color-surface);
38
- color: var(--md-sys-color-on-surface);
64
+ display: flex;
65
+ flex-direction: column;
66
+ height: 100%;
67
+ min-height: 0;
68
+ padding: 0;
69
+ gap: 0;
70
+ background: var(--info-dialog-surface, var(--md-sys-color-surface-container-lowest, var(--md-sys-color-surface)));
71
+ }
39
72
 
40
- --md-filled-field-top-space: 12px;
41
- --md-filled-field-bottom-space: 12px;
42
- --md-filled-field-leading-space: 12px;
43
- --md-filled-field-trailing-space: 12px;
73
+ /* What this is: state, version, freshness, home group - one quiet line under the tabs. */
74
+ .what {
75
+ flex: none;
76
+ display: flex;
77
+ flex-wrap: wrap;
78
+ align-items: center;
79
+ gap: 6px 10px;
80
+ margin: var(--spacing-medium, 8px) var(--popup-padding) 0;
81
+ padding: 7px 10px;
82
+ border-radius: var(--md-sys-shape-corner-small, 6px);
83
+ background: var(--md-sys-color-surface-container-low);
84
+ color: var(--md-sys-color-on-surface-variant);
85
+ font-size: var(--popup-font-size-small);
86
+ }
44
87
 
45
- --md-outlined-field-top-space: 12px;
46
- --md-outlined-field-bottom-space: 12px;
47
- --md-outlined-field-leading-space: 12px;
48
- --md-outlined-field-trailing-space: 12px;
88
+ .what [pill] {
89
+ padding: 1px 8px;
90
+ border-radius: 999px;
91
+ font-size: 0.66rem;
92
+ font-weight: 600;
93
+ letter-spacing: 0.06em;
94
+ text-transform: uppercase;
95
+ color: var(--md-sys-color-on-secondary-container);
96
+ background: var(--md-sys-color-secondary-container);
49
97
  }
50
98
 
51
- /*
52
- * 썸네일은 **일정한 자리**를 차지해야 한다.
53
- *
54
- * 이전에는 max-width: 100% 만 있어서, 원본이 패널보다 작은 보드는 원본 크기로 그려졌다.
55
- * 그래서 보드마다 이미지 폭이 달라지고 오른쪽에 빈 공간이 남았다(꽉 찬 보드도 있고
56
- * 절반만 차는 보드도 있었다).
57
- *
58
- * width: 100% + aspect-ratio 로 자리를 고정하고, 이미지는 그 안에서 **가운데** 놓는다.
59
- * 남는 여백은 흰색이 아니라 표면 토큰으로 채운다.
60
- *
61
- * contain 이 아니라 scale-down 인 이유: contain 은 작은 이미지를 자리에 맞게 **확대**해서
62
- * 흐려진다. scale-down 은 큰 이미지는 줄이고 작은 이미지는 원본 크기를 지키며, 어느 쪽이든
63
- * object-position 기본값(50% 50%)으로 가운데 놓인다.
64
- */
65
- img {
66
- display: block;
67
- width: 100%;
68
- aspect-ratio: 16 / 9;
69
- object-fit: scale-down;
70
- object-position: center;
71
- background-color: var(--md-sys-color-surface-container-low);
72
- /* rgba(0,0,0,.1) 은 다크에서 보이지 않는다 */
73
- border-bottom: 1px solid var(--md-sys-color-outline-variant);
99
+ .what [pill][released] {
100
+ color: var(--md-sys-color-on-tertiary-container);
101
+ background: var(--md-sys-color-tertiary-container);
74
102
  }
75
103
 
76
- form {
77
- display: grid;
78
- grid-template-columns: repeat(12, 1fr);
79
- grid-gap: 10px 0;
80
- grid-auto-rows: minmax(24px, auto);
81
- padding: var(--spacing-large);
82
- align-items: center;
104
+ .what [dot]::before {
105
+ content: '·';
106
+ margin-right: 10px;
107
+ color: var(--md-sys-color-outline);
83
108
  }
84
109
 
85
- md-outlined-select,
86
- md-filled-text-field {
87
- flex: 1;
88
- padding: 4px 0;
89
- min-width: unset;
110
+ .what b {
111
+ font-weight: 500;
112
+ color: var(--md-sys-color-on-surface);
90
113
  }
91
114
 
92
- md-chip-set {
115
+ [body] {
93
116
  flex: 1;
117
+ min-height: 0;
118
+ overflow-y: auto;
94
119
  display: flex;
95
- gap: 4px;
120
+ flex-direction: column;
121
+ gap: var(--spacing-medium, 8px);
122
+ padding: var(--spacing-medium, 8px) var(--popup-padding) var(--popup-padding);
96
123
  }
97
124
 
98
- fieldset {
99
- display: contents;
125
+ .fields .row {
126
+ display: flex;
127
+ gap: var(--spacing-medium, 8px);
100
128
  }
101
129
 
102
- legend {
103
- grid-column: span 12;
104
- padding: var(--legend-padding);
105
- color: var(--md-sys-color-on-surface);
106
- text-transform: capitalize;
130
+ .fields .row > label {
131
+ flex: 1;
132
+ min-width: 0;
107
133
  }
108
134
 
109
- fieldset:not(:first-of-type) > legend {
110
- border-top: 1px solid gray;
135
+ /* A choice of three reads faster as a segmented row than as three radios with labels. */
136
+ ox-input-select-buttons {
137
+ --md-filter-chip-container-height: 30px;
138
+ --md-filter-chip-label-text-size: 0.76rem;
111
139
  }
112
140
 
113
- label {
114
- text-transform: capitalize;
141
+ .chips {
142
+ --md-filter-chip-container-height: 28px;
143
+ --md-filter-chip-label-text-size: 0.74rem;
144
+ --md-filter-chip-outline-color: var(--md-sys-color-outline-variant);
145
+ --md-filter-chip-label-text-color: var(--md-sys-color-on-surface-variant);
146
+ --md-filter-chip-selected-container-color: var(--md-sys-color-primary-container);
147
+ --md-filter-chip-selected-label-text-color: var(--md-sys-color-on-primary-container);
148
+ --md-filter-chip-selected-leading-icon-color: var(--md-sys-color-on-primary-container);
115
149
  }
116
150
 
117
- md-icon[dim] {
118
- font-variation-settings: 'FILL' 1;
119
- --md-icon-size: 14px;
120
- color: var(--md-sys-color-outline-variant);
151
+ /* The duplicate panel is a small task inside the dialog: inset, with its own commit. */
152
+ [duplicate] {
153
+ display: flex;
154
+ flex-direction: column;
155
+ gap: var(--spacing-medium, 8px);
156
+ padding: var(--spacing-medium, 8px) var(--spacing-medium, 8px) var(--spacing-small, 4px);
157
+ border: 1px solid var(--md-sys-color-outline-variant);
158
+ border-radius: var(--md-sys-shape-corner-small, 6px);
159
+ background: var(--md-sys-color-surface-container-low);
121
160
  }
122
161
 
123
- [name] {
124
- white-space: nowrap;
125
- overflow: hidden;
126
- text-overflow: ellipsis;
162
+ [duplicate] .section {
163
+ margin-top: 0;
127
164
  }
128
165
 
129
- [danger] {
130
- --md-assist-chip-elevated-container-color: var(--md-sys-color-error);
131
- --md-assist-chip-label-text-color: var(--md-sys-color-on-error);
132
- --md-assist-chip-leading-icon-color: var(--md-sys-color-on-error);
166
+ [duplicate] .buttons {
167
+ border-top: 0;
168
+ padding-top: 0;
133
169
  }
134
170
 
135
- [fullwidth] {
136
- grid-column: span 12 / auto;
137
- display: flex;
138
- flex-direction: column;
139
- gap: 4px;
171
+ /* The footer stays put while the body scrolls. */
172
+ [footer] {
173
+ flex: none;
174
+ padding: var(--spacing-medium, 8px) var(--popup-padding);
175
+ border-top: 1px solid var(--md-sys-color-outline-variant);
176
+ background: var(--info-dialog-surface, var(--md-sys-color-surface-container-lowest, var(--md-sys-color-surface)));
140
177
  }
141
178
 
142
- [oneline] {
143
- grid-column: span 12 / auto;
144
- display: flex;
145
- flex-direction: row;
146
- gap: 4px;
179
+ [footer] .buttons {
180
+ border-top: 0;
181
+ padding-top: 0;
182
+ margin-top: 0;
147
183
  align-items: center;
148
184
  }
149
185
 
150
- [oneline] md-filled-text-field,
151
- [oneline] select {
152
- flex: 1;
186
+ [footer] [more] {
187
+ position: relative;
153
188
  }
154
189
 
155
- [right] {
156
- margin-left: auto;
190
+ md-menu {
191
+ min-width: 220px;
192
+ --md-menu-container-color: var(--md-sys-color-surface-container-lowest, #fff);
193
+ --md-menu-container-shape: 10px;
194
+ --md-menu-item-one-line-container-height: 40px;
195
+ --md-menu-item-two-line-container-height: 52px;
196
+ --md-menu-item-label-text-size: 0.84rem;
197
+ --md-menu-item-supporting-text-size: 0.72rem;
198
+ --md-menu-item-leading-space: 14px;
199
+ --md-menu-item-trailing-space: 14px;
157
200
  }
158
201
 
159
- @media screen and (max-width: 460px) {
160
- :host {
161
- width: 100vw;
202
+ md-menu-item[cap] [slot='headline'] {
203
+ text-transform: capitalize;
204
+ }
205
+
206
+ md-menu-item md-icon[slot='start'] {
207
+ --md-icon-size: 18px;
208
+ }
209
+
210
+ md-menu-item[danger] {
211
+ --md-menu-item-label-text-color: var(--md-sys-color-error);
212
+ --md-menu-item-leading-icon-color: var(--md-sys-color-error);
213
+ }
214
+
215
+ md-icon-button {
216
+ --md-icon-button-icon-color: var(--md-sys-color-on-surface-variant);
217
+ }
218
+
219
+ @media (pointer: coarse) {
220
+ [footer] {
221
+ padding: 0;
222
+ }
223
+
224
+ [footer] .buttons {
225
+ margin: 0;
226
+ border-top: 0;
227
+ }
228
+
229
+ [footer] [more] {
230
+ flex: 0 0 auto;
162
231
  }
163
232
  }
164
233
  `
@@ -166,255 +235,200 @@ let BoardInfo = class BoardInfo extends LitElement {
166
235
  render() {
167
236
  const board = this.board || { name: '', description: '', playGroups: [] };
168
237
  const boardGroupList = this.boardGroupList || [];
169
- const playGroupList = (this.playGroupList || []).map(group => {
170
- return {
171
- ...group,
172
- checked: false
173
- };
174
- });
175
- (board.playGroups || []).map(group => {
176
- const playGroup = playGroupList.find(g => g.id == group.id);
177
- if (playGroup) {
178
- playGroup.checked = true;
179
- }
180
- });
238
+ const memberOf = new Set((board.playGroups || []).map(group => group.id));
239
+ const editable = !!this.enableModeller;
181
240
  return html `
182
- ${board.thumbnail ? html ` <img src=${board.thumbnail} /> ` : html ``}
183
-
184
- <form class="md-typescale-body-medium">
185
- <fieldset>
186
- <legend class="md-typescale-label-large-prominent">${i18next.t('label.information')}</legend>
187
- <div oneline>
188
- <md-filled-text-field
189
- label=${String(i18next.t('label.name'))}
241
+ ${this.renderWhat(board, boardGroupList)}
242
+
243
+ <div body>
244
+ <div class="fields">
245
+ <label>
246
+ ${i18next.t('label.name')}
247
+ <input
190
248
  type="text"
191
- .value=${board.name}
249
+ .value=${board.name || ''}
192
250
  @change=${e => (this.board = { ...this.board, name: e.target.value })}
193
- ?disabled=${!this.enableModeller}
194
- ></md-filled-text-field>
195
-
196
- ${this.enableModeller
197
- ? html `
198
- <md-icon
199
- @click=${() => {
200
- this.showTarget = !this.showTarget;
201
- }}
202
- tail
203
- >${this.showTarget ? 'arrow_drop_up' : 'arrow_drop_down'}</md-icon
204
- >
205
- `
206
- : nothing}
207
- </div>
208
- ${this.showTarget
209
- ? html `
210
- <div fullwidth target>
211
- <label class="md-typescale-label-meium-prominent">${i18next.t('label.board-copy')}</label>
212
- <div oneline>
213
- <md-outlined-select
214
- label=${String(i18next.t('label.domain'))}
215
- @change=${e => {
216
- this.targetSubdomain = e.target.value;
217
- this.getGroupsByDomain(this.targetSubdomain);
218
- }}
219
- .value=${this.targetSubdomain || ''}
220
- ?disabled=${!this.enableModeller}
221
- >
222
- <md-select-option aria-label="blank"></md-select-option>
223
- ${this.targetDomains.map(domain => html `
224
- <md-select-option
225
- value=${domain.subdomain}
226
- ?selected=${domain.subdomain == this.targetSubdomain}
227
- >
228
- <div slot="headline">${domain.name}</div>
229
- </md-select-option>
230
- `)}
231
- </md-outlined-select>
232
-
233
- <md-outlined-select
234
- label=${String(i18next.t('label.group'))}
235
- @change=${e => (this.targetGroupId = e.target.value)}
236
- .value=${this.targetGroupId || ''}
237
- ?disabled=${!this.enableModeller}
238
- >
239
- <md-select-option aria-label="blank"></md-select-option>
240
- ${this.targetGroups.map(group => html `
241
- <md-select-option value=${group.id} ?selected=${group.id == this.targetGroupId}>
242
- <div slot="headline">${group.name}</div>
243
- </md-select-option>
244
- `)}
245
- </md-outlined-select>
246
- </div>
247
-
248
- <md-chip-set oneline>
249
- <md-assist-chip
250
- elevated
251
- label=${String(i18next.t('button.copy'))}
252
- ?disabled=${!this.enableModeller ||
253
- !this.original?.name ||
254
- !this.board.name ||
255
- !this.targetSubdomain ||
256
- (this.original.name == this.board.name && this._appCtrl.domain?.subdomain == this.targetSubdomain)}
257
- @click=${this.cloneBoard.bind(this)}
258
- right
259
- >
260
- <md-icon slot="icon">content_copy</md-icon>
261
- </md-assist-chip>
262
-
263
- <md-assist-chip
264
- elevated
265
- label=${String(i18next.t('button.export'))}
266
- @click=${this.exportBoard.bind(this)}
267
- >
268
- <md-icon slot="icon">download</md-icon>
269
- </md-assist-chip>
270
- </md-chip-set>
271
- </div>
272
- `
273
- : nothing}
274
-
275
- <md-filled-text-field
276
- oneline
277
- label=${String(i18next.t('label.description'))}
278
- .value=${board.description}
279
- @change=${e => (this.board = { ...this.board, description: e.target.value })}
280
- ?disabled=${!this.enableModeller}
281
- ></md-filled-text-field>
282
-
283
- <div oneline>
284
- <label>${i18next.t('label.board-type')}</label>
285
- <div role="radiogroup" aria-labelledby="board-type" right>
286
- <md-radio
287
- id="board-type-main"
288
- name="board-type"
289
- value="main"
290
- ?checked=${board.type === 'main'}
291
- @change=${e => (this.board = { ...this.board, type: e.target.value })}
292
- ?disabled=${!this.enableModeller}
293
- ></md-radio>
294
- <label for="board-type-main">${i18next.t('label.board-type-main')}</label>
295
-
296
- <md-radio
297
- id="board-type-sub"
298
- name="board-type"
299
- value="sub"
300
- ?checked=${board.type === 'sub'}
301
- @change=${e => (this.board = { ...this.board, type: e.target.value })}
302
- ?disabled=${!this.enableModeller}
303
- ></md-radio>
304
- <label for="board-type-sub">${i18next.t('label.board-type-sub')}</label>
305
-
306
- <md-radio
307
- id="board-type-popup"
308
- name="board-type"
309
- value="popup"
310
- ?checked=${board.type === 'popup'}
311
- @change=${e => (this.board = { ...this.board, type: e.target.value })}
312
- ?disabled=${!this.enableModeller}
313
- ></md-radio>
314
- <label for="board-type-popup">${i18next.t('label.board-type-popup')}</label>
315
- </div>
316
- </div>
251
+ ?disabled=${!editable}
252
+ />
253
+ </label>
317
254
 
318
- <md-outlined-select
319
- oneline
320
- label=${String(i18next.t('label.group'))}
321
- @change=${e => (this.board = { ...this.board, groupId: e.target.value })}
322
- .value=${this.groupId || ''}
323
- .displayText=${boardGroupList?.find(item => item.id == this.groupId)?.name}
324
- ?disabled=${!this.enableModeller}
325
- >
326
- <md-select-option aria-label="blank"></md-select-option>
327
- ${boardGroupList.map(item => html `
328
- <md-select-option value=${item.id} ?selected=${item.id == this.groupId}>
329
- <div slot="headline">${item.name}</div>
330
- </md-select-option>
331
- `)}
332
- </md-outlined-select>
333
-
334
- <md-chip-set oneline>
335
- <md-assist-chip
336
- danger
337
- elevated
338
- ?disabled=${!this.enableModeller}
339
- label=${String(i18next.t('button.delete'))}
340
- @click=${this.deleteBoard.bind(this)}
341
- >
342
- <md-icon slot="icon">delete_outline</md-icon>
343
- </md-assist-chip>
344
-
345
- <md-assist-chip
346
- elevated
347
- ?disabled=${!this.enableModeller}
348
- label=${String(i18next.t('button.save'))}
349
- @click=${this.updateBoard.bind(this)}
350
- right
351
- >
352
- <md-icon slot="icon">save</md-icon>
353
- </md-assist-chip>
354
- <md-assist-chip
355
- elevated
356
- ?disabled=${!this.enableModeller}
357
- label=${String(i18next.t('button.edit'))}
358
- @click=${() => navigate(`${'board-modeller/' + this.boardId}`)}
359
- >
360
- <md-icon slot="icon">drive_file_rename_outline</md-icon>
361
- </md-assist-chip>
362
- </md-chip-set>
363
- </fieldset>
364
-
365
- <fieldset>
366
- <legend class="md-typescale-label-large-prominent">${i18next.t('label.status')}</legend>
367
- <div oneline>
368
- <label class="md-typescale-label-meium">${i18next.t('label.creator')}</label>
369
- <md-icon dim>person</md-icon>
370
- <div name title=${(board.creator && board.creator.name) || 'anonymous'}>
371
- ${(board.creator && board.creator.name) || 'anonymous'}
372
- </div>
373
- <md-icon dim right>schedule</md-icon> ${new Date(board.createdAt).toLocaleString()}
374
- </div>
375
-
376
- <div oneline>
377
- <label class="md-typescale-label-meium">${i18next.t('label.updater')}</label>
378
- <md-icon dim>person</md-icon>
379
- <div name title=${(board.updater && board.updater.name) || 'anonymouse'}>
380
- ${(board.updater && board.updater.name) || 'anonymouse'}
381
- </div>
382
- <md-icon dim right>schedule</md-icon> ${new Date(board.updatedAt).toLocaleString()}
383
- </div>
384
-
385
- <div oneline>
386
- <label class="md-typescale-label-meium">${i18next.t('label.state')}</label>
387
- <md-icon dim>signpost</md-icon> ${(board.state || '').toUpperCase()}
388
- <md-icon dim>pin</md-icon> ${board.version}
389
- <md-chip-set>
390
- <md-assist-chip
391
- elevated
392
- label=${String(i18next.t('button.release'))}
393
- ?disabled=${!this.enableModeller || !this.isReleasable}
394
- @click=${this.releaseBoard.bind(this)}
395
- right
255
+ <label>
256
+ ${i18next.t('label.description')}
257
+ <input
258
+ type="text"
259
+ .value=${board.description || ''}
260
+ @change=${e => (this.board = { ...this.board, description: e.target.value })}
261
+ ?disabled=${!editable}
262
+ />
263
+ </label>
264
+
265
+ <div class="row">
266
+ <label>
267
+ ${i18next.t('label.board-type')}
268
+ <ox-input-select-buttons
269
+ .value=${board.type || 'main'}
270
+ .options=${[
271
+ { display: i18next.t('label.board-type-main'), value: 'main' },
272
+ { display: i18next.t('label.board-type-sub'), value: 'sub' },
273
+ { display: i18next.t('label.board-type-popup'), value: 'popup' }
274
+ ]}
275
+ @change=${(e) => e.detail && (this.board = { ...this.board, type: e.detail })}
276
+ ?disabled=${!editable}
277
+ ></ox-input-select-buttons>
278
+ </label>
279
+
280
+ <label>
281
+ ${i18next.t('label.group')}
282
+ <select
283
+ .value=${this.board?.groupId || ''}
284
+ @change=${e => (this.board = { ...this.board, groupId: e.target.value })}
285
+ ?disabled=${!editable}
396
286
  >
397
- <md-icon slot="icon">new_releases</md-icon>
398
- </md-assist-chip>
399
- </md-chip-set>
287
+ <option value=""></option>
288
+ ${boardGroupList.map(item => html `<option value=${item.id} ?selected=${item.id == this.board?.groupId}>${item.name}</option>`)}
289
+ </select>
290
+ </label>
400
291
  </div>
401
- </fieldset>
402
-
403
- <fieldset>
404
- <legend class="md-typescale-label-large-prominent">${i18next.t('label.play-group')}</legend>
405
- <md-chip-set oneline>
406
- ${playGroupList.map(item => html `
407
- <md-filter-chip
408
- label=${item.name}
409
- ?selected=${item.checked}
410
- @click=${e => {
292
+ </div>
293
+
294
+ ${this.duplicating ? this.renderDuplicate(board) : nothing}
295
+
296
+ <div class="section">${i18next.t('text.plays in', { defaultValue: 'Plays in' })}</div>
297
+ <div class="note">
298
+ ${i18next.t('text.play-group membership applies at once', {
299
+ defaultValue: 'Tap a play group to add or remove this board. This applies right away.'
300
+ })}
301
+ </div>
302
+ <md-chip-set class="chips">
303
+ ${(this.playGroupList || []).map(item => html `
304
+ <md-filter-chip
305
+ label=${item.name}
306
+ ?selected=${memberOf.has(item.id)}
307
+ @click=${e => {
411
308
  e.target.selected ? this.joinPlayGroup(item) : this.leavePlayGroup(item);
412
309
  }}
413
- ></md-filter-chip>
414
- `)}
415
- </md-chip-set>
416
- </fieldset>
417
- </form>
310
+ ></md-filter-chip>
311
+ `)}
312
+ </md-chip-set>
313
+ </div>
314
+
315
+ <div footer>
316
+ <div class="buttons">
317
+ <span more>
318
+ <md-icon-button id="more-anchor" ?disabled=${!editable} @click=${() => (this.menu.open = !this.menu.open)}>
319
+ <md-icon>more_vert</md-icon>
320
+ </md-icon-button>
321
+ <md-menu anchor="more-anchor" positioning="popover" anchor-corner="start-start" menu-corner="end-start">
322
+ <md-menu-item @click=${() => (this.duplicating = true)}>
323
+ <md-icon slot="start">content_copy</md-icon>
324
+ <div slot="headline">${i18next.t('button.duplicate', { defaultValue: 'Duplicate…' })}</div>
325
+ </md-menu-item>
326
+ <md-menu-item @click=${this.exportBoard.bind(this)}>
327
+ <md-icon slot="start">download</md-icon>
328
+ <div slot="headline">${i18next.t('button.export-json', { defaultValue: 'Export as JSON' })}</div>
329
+ </md-menu-item>
330
+ <md-menu-item cap ?disabled=${!this.isReleasable} @click=${this.releaseBoard.bind(this)}>
331
+ <md-icon slot="start">new_releases</md-icon>
332
+ <div slot="headline">${i18next.t('button.release')}</div>
333
+ ${this.isReleasable || board.state === 'released'
334
+ ? nothing
335
+ : html `<div slot="supporting-text">
336
+ ${i18next.t('text.save before release', { defaultValue: 'Save your changes first' })}
337
+ </div>`}
338
+ </md-menu-item>
339
+ <md-divider></md-divider>
340
+ <md-menu-item cap danger @click=${this.deleteBoard.bind(this)}>
341
+ <md-icon slot="start">delete_outline</md-icon>
342
+ <div slot="headline">${i18next.t('button.delete')}</div>
343
+ </md-menu-item>
344
+ </md-menu>
345
+ </span>
346
+ <div filler></div>
347
+ <md-outlined-button ?disabled=${!editable} @click=${() => navigate(`board-modeller/${this.boardId}`)}>
348
+ <md-icon slot="icon">design_services</md-icon>${i18next.t('button.open-modeller', {
349
+ defaultValue: 'Open in modeller'
350
+ })}
351
+ </md-outlined-button>
352
+ <md-filled-button cap ?disabled=${!editable || !this.isDirty} @click=${this.updateBoard.bind(this)}>
353
+ <md-icon slot="icon">save</md-icon>${i18next.t('button.save')}
354
+ </md-filled-button>
355
+ </div>
356
+ </div>
357
+ `;
358
+ }
359
+ renderWhat(board, boardGroupList) {
360
+ if (!this.board) {
361
+ return nothing;
362
+ }
363
+ const released = board.state === 'released';
364
+ const group = boardGroupList.find(item => item.id == this.original?.groupId);
365
+ const updater = board.updater?.name || board.creator?.name || '';
366
+ const ago = timeAgo(board.updatedAt || board.createdAt);
367
+ return html `
368
+ <div class="what">
369
+ <span pill ?released=${released}>
370
+ ${released
371
+ ? i18next.t('label.released', { defaultValue: 'released' })
372
+ : i18next.t('label.draft', { defaultValue: 'draft' })}
373
+ </span>
374
+ <span>v${board.version ?? 0}</span>
375
+ ${group ? html `<span dot><b>${group.name}</b></span>` : nothing}
376
+ ${updater
377
+ ? html `<span dot>${i18next.t('text.updated by', { defaultValue: 'updated by' })} <b>${updater}</b> ${ago}</span>`
378
+ : ago
379
+ ? html `<span dot>${i18next.t('text.updated', { defaultValue: 'updated' })} ${ago}</span>`
380
+ : nothing}
381
+ </div>
382
+ `;
383
+ }
384
+ renderDuplicate(board) {
385
+ const sameAsSource = this.original?.name == board.name && this._appCtrl.domain?.subdomain == this.targetSubdomain;
386
+ return html `
387
+ <div duplicate>
388
+ <div class="section">${i18next.t('text.duplicate', { defaultValue: 'Duplicate' })}</div>
389
+ <div class="note">
390
+ ${i18next.t('text.duplicate hint', {
391
+ defaultValue: 'Copies this board into a domain and group. Give it a new name when copying within this domain.'
392
+ })}
393
+ </div>
394
+ <div class="fields">
395
+ <div class="row">
396
+ <label>
397
+ ${i18next.t('label.domain')}
398
+ <select
399
+ @change=${e => {
400
+ this.targetSubdomain = e.target.value;
401
+ this.getGroupsByDomain(this.targetSubdomain);
402
+ }}
403
+ .value=${this.targetSubdomain || ''}
404
+ >
405
+ <option value=""></option>
406
+ ${this.targetDomains.map(domain => html `<option value=${domain.subdomain} ?selected=${domain.subdomain == this.targetSubdomain}>
407
+ ${domain.name}
408
+ </option>`)}
409
+ </select>
410
+ </label>
411
+ <label>
412
+ ${i18next.t('label.group')}
413
+ <select @change=${e => (this.targetGroupId = e.target.value)} .value=${this.targetGroupId || ''}>
414
+ <option value=""></option>
415
+ ${this.targetGroups.map(group => html `<option value=${group.id} ?selected=${group.id == this.targetGroupId}>${group.name}</option>`)}
416
+ </select>
417
+ </label>
418
+ </div>
419
+ </div>
420
+ <div class="buttons">
421
+ <div filler></div>
422
+ <md-text-button cap @click=${() => (this.duplicating = false)}>${i18next.t('button.cancel')}</md-text-button>
423
+ <md-filled-button
424
+ cap
425
+ ?disabled=${!this.original?.name || !board.name || !this.targetSubdomain || sameAsSource}
426
+ @click=${this.cloneBoard.bind(this)}
427
+ >
428
+ <md-icon slot="icon">content_copy</md-icon>${i18next.t('button.copy')}
429
+ </md-filled-button>
430
+ </div>
431
+ </div>
418
432
  `;
419
433
  }
420
434
  async firstUpdated() {
@@ -480,11 +494,12 @@ let BoardInfo = class BoardInfo extends LitElement {
480
494
  `,
481
495
  variables: { id: this.boardId }
482
496
  })).data;
483
- this.board = response.board;
497
+ const board = response.board;
498
+ this.groupId = board.group?.id;
499
+ this.board = { ...board, groupId: this.groupId };
500
+ this.original = { ...this.board };
484
501
  this.boardGroupList = response.groups.items;
485
502
  this.playGroupList = response.playGroups.items;
486
- this.original = { ...this.board };
487
- this.groupId = this.board.group?.id;
488
503
  this.targetDomains = response.domainsWithPrivilege;
489
504
  this.targetGroups = response.groups.items;
490
505
  this.targetGroupId = this.groupId;
@@ -516,15 +531,22 @@ let BoardInfo = class BoardInfo extends LitElement {
516
531
  })).data;
517
532
  this.targetGroups = response2.groups.items;
518
533
  }
534
+ /** Something in the form differs from what the server has. */
535
+ get isDirty() {
536
+ if (!this.board || !this.original) {
537
+ return false;
538
+ }
539
+ const { name, description, groupId, type } = this.board;
540
+ return (this.original.name != name ||
541
+ (this.original.description || '') != (description || '') ||
542
+ (this.original.groupId || '') != (groupId || '') ||
543
+ this.original.type != type);
544
+ }
519
545
  get isReleasable() {
520
546
  if (!this.board || this.board.state == 'released') {
521
547
  return false;
522
548
  }
523
- const { name, description, groupId } = this.board;
524
- return (this.original &&
525
- this.original.name == name &&
526
- this.original.description == description &&
527
- this.original.groupId == groupId);
549
+ return !this.isDirty;
528
550
  }
529
551
  async cloneBoard() {
530
552
  if (!this.original.name || (this.board.name == this.original.name && this._appCtrl.domain?.subdomain == this.targetSubdomain)) {
@@ -588,6 +610,9 @@ let BoardInfo = class BoardInfo extends LitElement {
588
610
  bubbles: true,
589
611
  composed: true
590
612
  }));
613
+ /* The form is what the server now has; Save goes quiet until the next edit. */
614
+ this.original = { ...this.board };
615
+ this.requestUpdate();
591
616
  }
592
617
  async deleteBoard() {
593
618
  if (await OxPrompt.open({
@@ -651,7 +676,7 @@ __decorate([
651
676
  __decorate([
652
677
  state(),
653
678
  __metadata("design:type", Boolean)
654
- ], BoardInfo.prototype, "showTarget", void 0);
679
+ ], BoardInfo.prototype, "duplicating", void 0);
655
680
  __decorate([
656
681
  state(),
657
682
  __metadata("design:type", Boolean)
@@ -672,6 +697,10 @@ __decorate([
672
697
  state(),
673
698
  __metadata("design:type", String)
674
699
  ], BoardInfo.prototype, "targetGroupId", void 0);
700
+ __decorate([
701
+ query('md-menu'),
702
+ __metadata("design:type", Object)
703
+ ], BoardInfo.prototype, "menu", void 0);
675
704
  BoardInfo = __decorate([
676
705
  customElement('board-basic-info')
677
706
  ], BoardInfo);