@things-factory/figure-ui 10.1.177 → 10.1.179

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 (91) hide show
  1. package/client/figure-released.ts +6 -0
  2. package/client/graphql/index.ts +101 -25
  3. package/client/graphql/store.ts +2 -1
  4. package/client/index.ts +1 -0
  5. package/client/modeller/editor.ts +10 -2
  6. package/client/modeller/figure-first-save.ts +23 -3
  7. package/client/modeller/figure-parts.ts +53 -23
  8. package/client/modeller/figure-properties.ts +41 -7
  9. package/client/modeller/first-save.ts +2 -0
  10. package/client/modeller/product-session.ts +5 -3
  11. package/client/modeller/property-sections.ts +45 -0
  12. package/client/pages/figure-list-page.ts +52 -10
  13. package/client/pages/figure-modeller-page.ts +15 -3
  14. package/client/setting-let/figure-namespace-setting-let.ts +113 -31
  15. package/client/types.ts +8 -0
  16. package/client/viewparts/figure-detail-dock.ts +124 -9
  17. package/client/viewparts/figure-thumb.ts +6 -3
  18. package/dist-client/figure-released.d.ts +3 -0
  19. package/dist-client/figure-released.js +5 -0
  20. package/dist-client/figure-released.js.map +1 -0
  21. package/dist-client/graphql/index.d.ts +25 -13
  22. package/dist-client/graphql/index.js +85 -16
  23. package/dist-client/graphql/index.js.map +1 -1
  24. package/dist-client/graphql/store.js +2 -1
  25. package/dist-client/graphql/store.js.map +1 -1
  26. package/dist-client/index.d.ts +1 -0
  27. package/dist-client/index.js +1 -0
  28. package/dist-client/index.js.map +1 -1
  29. package/dist-client/modeller/editor.d.ts +2 -0
  30. package/dist-client/modeller/editor.js +13 -2
  31. package/dist-client/modeller/editor.js.map +1 -1
  32. package/dist-client/modeller/figure-first-save.d.ts +8 -0
  33. package/dist-client/modeller/figure-first-save.js +28 -3
  34. package/dist-client/modeller/figure-first-save.js.map +1 -1
  35. package/dist-client/modeller/figure-links.d.ts +1 -1
  36. package/dist-client/modeller/figure-parts.d.ts +5 -0
  37. package/dist-client/modeller/figure-parts.js +54 -22
  38. package/dist-client/modeller/figure-parts.js.map +1 -1
  39. package/dist-client/modeller/figure-properties.d.ts +4 -0
  40. package/dist-client/modeller/figure-properties.js +45 -7
  41. package/dist-client/modeller/figure-properties.js.map +1 -1
  42. package/dist-client/modeller/first-save.d.ts +2 -0
  43. package/dist-client/modeller/first-save.js.map +1 -1
  44. package/dist-client/modeller/product-session.d.ts +4 -1
  45. package/dist-client/modeller/product-session.js +3 -3
  46. package/dist-client/modeller/product-session.js.map +1 -1
  47. package/dist-client/modeller/property-sections.d.ts +26 -0
  48. package/dist-client/modeller/property-sections.js +17 -0
  49. package/dist-client/modeller/property-sections.js.map +1 -0
  50. package/dist-client/pages/figure-list-page.d.ts +4 -1
  51. package/dist-client/pages/figure-list-page.js +56 -10
  52. package/dist-client/pages/figure-list-page.js.map +1 -1
  53. package/dist-client/pages/figure-modeller-page.d.ts +2 -0
  54. package/dist-client/pages/figure-modeller-page.js +19 -3
  55. package/dist-client/pages/figure-modeller-page.js.map +1 -1
  56. package/dist-client/route.d.ts +1 -1
  57. package/dist-client/setting-let/figure-namespace-setting-let.d.ts +16 -5
  58. package/dist-client/setting-let/figure-namespace-setting-let.js +129 -31
  59. package/dist-client/setting-let/figure-namespace-setting-let.js.map +1 -1
  60. package/dist-client/tsconfig.tsbuildinfo +1 -1
  61. package/dist-client/types.d.ts +12 -0
  62. package/dist-client/types.js.map +1 -1
  63. package/dist-client/viewparts/figure-detail-dock.d.ts +33 -2
  64. package/dist-client/viewparts/figure-detail-dock.js +171 -11
  65. package/dist-client/viewparts/figure-detail-dock.js.map +1 -1
  66. package/dist-client/viewparts/figure-thumb.d.ts +2 -0
  67. package/dist-client/viewparts/figure-thumb.js +13 -3
  68. package/dist-client/viewparts/figure-thumb.js.map +1 -1
  69. package/package.json +8 -7
  70. package/test/browser/category-page.js +28 -0
  71. package/test/browser/category.check.mjs +54 -0
  72. package/test/browser/mesh-select.check.mjs +1 -1
  73. package/test/browser/namespace-page.js +75 -0
  74. package/test/browser/namespace.check.mjs +104 -0
  75. package/test/browser/parts-page.js +8 -2
  76. package/test/browser/places-mesh.check.mjs +30 -2
  77. package/test/browser/property-section-page.js +45 -0
  78. package/test/browser/property-section.check.mjs +48 -0
  79. package/test/browser/props.check.mjs +13 -2
  80. package/test/browser/public-page.js +39 -0
  81. package/test/browser/public.check.mjs +79 -0
  82. package/test/browser/retire-page.js +37 -0
  83. package/test/browser/retire.check.mjs +96 -0
  84. package/test/figure-released.test.ts +54 -0
  85. package/test/modeller-route.test.ts +2 -1
  86. package/translations/en.json +20 -5
  87. package/translations/es.json +20 -5
  88. package/translations/ja.json +20 -5
  89. package/translations/ko.json +20 -5
  90. package/translations/ms.json +20 -5
  91. package/translations/zh.json +20 -5
@@ -3,20 +3,22 @@ import '@material/web/icon/icon.js'
3
3
  import '../viewparts/figure-thumb.js'
4
4
 
5
5
  import { css, html } from 'lit'
6
- import { customElement, property, query } from 'lit/decorators.js'
6
+ import { customElement, property, query, state } from 'lit/decorators.js'
7
7
 
8
8
  import type { DataGrist, FetchOption } from '@operato/data-grist'
9
9
  import { i18next, localize } from '@operato/i18n'
10
10
  import { p13n } from '@operato/p13n'
11
11
  import { auth } from '@things-factory/auth-base/dist-client'
12
12
  import { navigate, PageView } from '@operato/shell'
13
+ import { notify } from '@operato/layout'
13
14
  import { CardCatalogStyles, CommonGristStyles, CommonHeaderStyles, ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles'
14
15
 
15
16
  import { capacityFrom } from '../modeller/scene-budget.js'
16
17
  import { deleteFigure, fetchFigureList } from '../graphql/index.js'
17
18
  import type { Figure } from '../types.js'
19
+ import { hasBeenReleased } from '../figure-released.js'
18
20
  import { modellerPathOf } from '../modeller/modeller-route.js'
19
- import { FIGURE_THUMBNAIL_CHANGED, openFigureDetail } from '../viewparts/figure-detail-dock.js'
21
+ import { FIGURE_PUBLIC_CHANGED, FIGURE_RETIREMENT_CHANGED, FIGURE_THUMBNAIL_CHANGED, openFigureDetail } from '../viewparts/figure-detail-dock.js'
20
22
 
21
23
  /**
22
24
  * 이 값 아래면 점수를 색으로 눈에 띄게 한다.
@@ -95,6 +97,23 @@ export class FigureListPage extends FigureListPageBase {
95
97
  카드 · 그림 칸 · 만들기 FAB 는 모든 카드 카탈로그와 같다(@operato/styles CardCatalogStyles,
96
98
  ADR-0117 개정 2026-10-13). 여기에는 이 화면에만 있는 것만 둔다.
97
99
  */
100
+ /* Which figures are listed: quiet words beside the filters, coloured when they are on. */
101
+ button[scope] {
102
+ font: inherit;
103
+ padding: 0 8px;
104
+ border: none;
105
+ background: none;
106
+ color: var(--md-sys-color-on-surface-variant);
107
+ cursor: pointer;
108
+ white-space: nowrap;
109
+ }
110
+ button[scope]:hover {
111
+ color: var(--md-sys-color-on-surface);
112
+ }
113
+ button[scope][aria-pressed='true'] {
114
+ color: var(--md-sys-color-primary);
115
+ font-weight: 600;
116
+ }
98
117
  ox-grist {
99
118
  /* 상태와 성숙도는 짧은 표식이다 — 두 줄로 세우지 않고 한 줄에 뉜다 */
100
119
  --data-card-detail-direction: row;
@@ -108,6 +127,9 @@ export class FigureListPage extends FigureListPageBase {
108
127
 
109
128
  @query('ox-grist') private grist!: DataGrist
110
129
 
130
+ /** The domain's own figures, or other domains' public ones (LV figure-server design G2), read only. */
131
+ @state() private scope: 'own' | 'public' = 'own'
132
+
111
133
  private listen?: () => void
112
134
 
113
135
  get context() {
@@ -149,6 +171,7 @@ export class FigureListPage extends FigureListPageBase {
149
171
  <div class="filters">
150
172
  <ox-filters-form autofocus without-search></ox-filters-form>
151
173
  </div>
174
+ <button scope aria-pressed=${String(this.scope === 'public')} @click=${() => this.toggleScope()}>${i18next.t('figure.button.public-figures')}</button>
152
175
  </div>
153
176
 
154
177
  <ox-grist-personalizer slot="setting"></ox-grist-personalizer>
@@ -427,7 +450,7 @@ export class FigureListPage extends FigureListPageBase {
427
450
  wide: true,
428
451
  renderEmpty: true,
429
452
  renderer: (_value: unknown, _column: any, record: Figure) => html`
430
- <figure-thumb .figure=${record} @remove=${() => this.removeFigure(record)}></figure-thumb>
453
+ <figure-thumb .figure=${record} ?readonly=${this.scope === 'public'} @remove=${() => this.removeFigure(record)}></figure-thumb>
431
454
  `
432
455
  },
433
456
  hidden: true,
@@ -466,7 +489,7 @@ export class FigureListPage extends FigureListPageBase {
466
489
  handlers: {
467
490
  click: (_columns: any, _data: any, _column: any, record: Figure) => {
468
491
  if (record?.id) {
469
- openFigureDetail(record)
492
+ openFigureDetail(record, this.scope === 'public')
470
493
  }
471
494
  }
472
495
  }
@@ -493,10 +516,12 @@ export class FigureListPage extends FigureListPageBase {
493
516
  }
494
517
  }
495
518
  auth.on('profile', this.listen)
496
- /* A picture chosen in the preview: the cards are read again, with the picture's new stamp. */
497
- window.addEventListener(FIGURE_THUMBNAIL_CHANGED, this.pictureChanged)
519
+ /* A picture chosen in the preview, or a figure retired or brought back: the cards are read again. */
520
+ window.addEventListener(FIGURE_THUMBNAIL_CHANGED, this.cardsChanged)
521
+ window.addEventListener(FIGURE_RETIREMENT_CHANGED, this.cardsChanged)
522
+ window.addEventListener(FIGURE_PUBLIC_CHANGED, this.cardsChanged)
498
523
  }
499
- private pictureChanged = () => {
524
+ private cardsChanged = () => {
500
525
  if (this.active) this.grist?.fetch()
501
526
  }
502
527
 
@@ -505,7 +530,9 @@ export class FigureListPage extends FigureListPageBase {
505
530
  auth.off?.('profile', this.listen)
506
531
  this.listen = undefined
507
532
  }
508
- window.removeEventListener(FIGURE_THUMBNAIL_CHANGED, this.pictureChanged)
533
+ window.removeEventListener(FIGURE_THUMBNAIL_CHANGED, this.cardsChanged)
534
+ window.removeEventListener(FIGURE_RETIREMENT_CHANGED, this.cardsChanged)
535
+ window.removeEventListener(FIGURE_PUBLIC_CHANGED, this.cardsChanged)
509
536
  super.disconnectedCallback()
510
537
  }
511
538
 
@@ -522,7 +549,7 @@ export class FigureListPage extends FigureListPageBase {
522
549
  * 받아 화면에 낸다 — 목록이 비어 있는 것과 못 불러온 것은 다른 사실이다.
523
550
  */
524
551
  async fetchHandler({ page = 1, limit = 24, sortings = [], filters = [] }: FetchOption) {
525
- const { items, total } = await fetchFigureList({ page, limit, filters, sortings })
552
+ const { items, total } = await fetchFigureList({ page, limit, filters, sortings, scope: this.scope })
526
553
 
527
554
  return { total: total || 0, records: items || [] }
528
555
  }
@@ -532,15 +559,30 @@ export class FigureListPage extends FigureListPageBase {
532
559
  navigate(modellerPathOf(figure))
533
560
  }
534
561
 
562
+ private toggleScope() {
563
+ this.scope = this.scope === 'public' ? 'own' : 'public'
564
+ this.grist?.fetch()
565
+ }
566
+
535
567
  /** `remove` 는 HTMLElement 의 메서드다 — 가리면 조용한 결함이 된다. */
536
568
  private async removeFigure(figure: Figure) {
569
+ if (this.scope === 'public') return
537
570
  // 되돌릴 수 없는 조작이므로 무엇이 사라지는지 이름으로 확인한다
571
+ if (hasBeenReleased(figure)) {
572
+ notify({ level: 'info', message: i18next.t('figure.text.released-figure-not-deletable', { name: figure.name }) })
573
+ return
574
+ }
538
575
  const message = i18next.t('figure.text.delete-figure-confirm', { name: figure.name })
539
576
  if (!confirm(message)) {
540
577
  return
541
578
  }
542
579
 
543
- await deleteFigure(figure.id)
580
+ try {
581
+ await deleteFigure(figure.id)
582
+ } catch (e) {
583
+ notify({ level: 'error', message: (e as Error).message })
584
+ return
585
+ }
544
586
  this.grist.fetch()
545
587
  }
546
588
  }
@@ -6,7 +6,7 @@ import { modellerPathOf } from '../modeller/modeller-route.js'
6
6
  import { createFigureProductSession } from '../modeller/product-session.js'
7
7
  import { figureProductStore } from '../graphql/store.js'
8
8
  import { i18next } from '@operato/i18n'
9
- import { fetchFigure, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
9
+ import { fetchFigure, fetchFigureCategories, fetchFigureNamespaces, fetchFigureTypeNames, updateFigure } from '../graphql/index.js'
10
10
  import { figureProposalOutcome } from '../figure-assistant.js'
11
11
  import { availableRecipes, recipeGuide } from '../modeller/recipe-source.js'
12
12
  import type { RecipeTemplate } from '../modeller/recipes.js'
@@ -137,6 +137,8 @@ export class FigureModellerPage extends PageView {
137
137
  * `figure-modeller` 가 이 화면으로 바뀌면 저작자는 발행을 할 수 없었다. 새 도형은 공간을 선언하기 전에는 전부
138
138
  * 발행 판정에 막히는데 그 선언을 할 단추도 없었다(2026-09-24 확인). 그래서 발행 패널을 건다.
139
139
  */
140
+ /** The categories the server allows, or null when a category is free text (G5). */
141
+ @state() private categories: string[] | null = null
140
142
  @state() private row: { state?: 'draft' | 'released'; version: number; namespace?: string; type?: string; category?: string; tags?: string[] } = {
141
143
  version: 0
142
144
  }
@@ -398,6 +400,10 @@ export class FigureModellerPage extends PageView {
398
400
  }
399
401
  connectedCallback() {
400
402
  super.connectedCallback()
403
+ /* Read once: the server's category vocabulary does not change while it runs. Free text when it has none or cannot say. */
404
+ fetchFigureCategories()
405
+ .then(codes => (this.categories = codes))
406
+ .catch(() => (this.categories = null))
401
407
  window.addEventListener('figure-apply', this.applyAI)
402
408
  window.addEventListener('beforeunload', this.holdUnsaved)
403
409
  }
@@ -481,8 +487,13 @@ export class FigureModellerPage extends PageView {
481
487
  }
482
488
  /** The first save's question, over the names this domain already uses. */
483
489
  private askFirstSave = async (name: string): Promise<FirstSave | undefined> => {
484
- const taken = await fetchFigureTypeNames().catch(() => [] as string[])
485
- const answer = await openPopup<FirstSave>(html`<figure-first-save .type=${this.row.type ?? ''} .name=${name} .taken=${taken}></figure-first-save>`, {
490
+ const [taken, namespaces] = await Promise.all([
491
+ fetchFigureTypeNames().catch(() => [] as string[]),
492
+ fetchFigureNamespaces()
493
+ .then(all => all.filter(namespace => namespace.state === 'active'))
494
+ .catch(() => [])
495
+ ])
496
+ const answer = await openPopup<FirstSave>(html`<figure-first-save .type=${this.row.type ?? ''} .name=${name} .taken=${taken} .namespaces=${namespaces}></figure-first-save>`, {
486
497
  backdrop: true,
487
498
  size: 'small',
488
499
  title: i18next.t('figure.title.save-new-figure')
@@ -522,6 +533,7 @@ export class FigureModellerPage extends PageView {
522
533
  .revision=${this.session.revisionNow()}
523
534
  .figureId=${view.id}
524
535
  .figureCategory=${this.row.category}
536
+ .figureCategories=${this.categories}
525
537
  .figureTags=${this.row.tags}
526
538
  @command=${this.onCommand}
527
539
  @catalog=${this.onCatalog}
@@ -1,17 +1,24 @@
1
1
  import '@material/web/textfield/filled-text-field.js'
2
+ import '@material/web/button/text-button.js'
2
3
  import '@things-factory/setting-base'
3
4
 
4
- import { css, html, LitElement } from 'lit'
5
- import { customElement, state } from 'lit/decorators.js'
5
+ import { css, html, LitElement, nothing } from 'lit'
6
+ import { customElement, property, state } from 'lit/decorators.js'
6
7
 
7
8
  import { i18next, localize } from '@operato/i18n'
8
9
 
9
- import { fetchFigureNamespace, setFigureNamespace, type FigureNamespaceState } from '../graphql/index.js'
10
+ import {
11
+ createFigureNamespace,
12
+ fetchFigureNamespaces,
13
+ setDefaultFigureNamespace,
14
+ setFigureNamespaceState,
15
+ type FigureNamespace
16
+ } from '../graphql/index.js'
10
17
 
11
18
  /**
12
- * The domain's figure namespace (ADR-0114): the `{namespace}` of every full name `{namespace}/{type}` a board stores.
13
- * Empty takes the subdomain. It is set once: when the domain's figures carry it, it is shown and no longer edited --
14
- * the server refuses a change either way, and says why.
19
+ * The namespaces the domain owns (LV figure-server design G1): the `{namespace}` of every full name `{namespace}/{type}`
20
+ * a board stores. A namespace is added once and never renamed; the default is the one new figures are created under;
21
+ * a suspended one takes no new figure and no release. The server refuses what it must and says why.
15
22
  */
16
23
  @customElement('figure-namespace-setting-let')
17
24
  export class FigureNamespaceSettingLet extends localize(i18next)(LitElement) {
@@ -23,63 +30,138 @@ export class FigureNamespaceSettingLet extends localize(i18next)(LitElement) {
23
30
  gap: var(--spacing-small, 4px);
24
31
  text-transform: none;
25
32
  }
26
- md-filled-text-field {
27
- width: 100%;
33
+ ul {
34
+ list-style: none;
35
+ margin: 0;
36
+ padding: 0;
37
+ display: grid;
38
+ gap: 2px;
39
+ }
40
+ li {
41
+ display: flex;
42
+ flex-wrap: wrap;
43
+ align-items: center;
44
+ gap: 4px 12px;
45
+ min-height: 32px;
28
46
  }
29
- strong {
30
- font: var(--label-font);
47
+ code {
48
+ font-family: var(--ui-mono, ui-monospace, monospace);
31
49
  color: var(--md-sys-color-on-surface);
32
50
  }
33
- p {
34
- margin: 0;
51
+ [mark] {
35
52
  font-size: 0.8rem;
36
53
  color: var(--md-sys-color-on-surface-variant);
37
54
  }
55
+ li[suspended] code {
56
+ color: var(--md-sys-color-on-surface-variant);
57
+ }
58
+ [actions] {
59
+ margin-left: auto;
60
+ display: flex;
61
+ gap: 4px;
62
+ }
63
+ [add] {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ }
68
+ md-filled-text-field {
69
+ flex: 1;
70
+ }
38
71
  `
39
72
  ]
40
73
 
41
- @state() private now?: FigureNamespaceState
74
+ /* The server's calls; a check gives its own. */
75
+ @property({ attribute: false }) list: () => Promise<FigureNamespace[]> = fetchFigureNamespaces
76
+ @property({ attribute: false }) add: (name: string) => Promise<FigureNamespace> = name => createFigureNamespace(name)
77
+ @property({ attribute: false }) makeDefault: (name: string) => Promise<FigureNamespace> = setDefaultFigureNamespace
78
+ @property({ attribute: false }) setState: (name: string, state: 'active' | 'suspended') => Promise<FigureNamespace> = setFigureNamespaceState
79
+
80
+ @state() private namespaces?: FigureNamespace[]
81
+ @state() private draft = ''
42
82
  @state() private refused = ''
83
+ @state() private busy = false
43
84
 
44
85
  render() {
45
- const now = this.now
86
+ const namespaces = this.namespaces
46
87
  return html`
47
88
  <setting-let>
48
89
  <span slot="title">${i18next.t('figure.title.namespace-setting')}</span>
49
90
  <div slot="content">
50
- ${!now
51
- ? html``
52
- : now.fixed
53
- ? html`<strong>${now.namespace}</strong>
54
- <p>${i18next.t('figure.text.namespace-fixed')}</p>`
55
- : html`<md-filled-text-field
56
- .label=${i18next.t('figure.label.namespace')}
57
- .value=${now.written}
58
- placeholder=${now.subdomain}
59
- ?error=${!!this.refused || !now.namespace}
60
- error-text=${this.refused || i18next.t('figure.text.namespace-subdomain-unusable', { subdomain: now.subdomain })}
61
- supporting-text=${i18next.t('figure.text.namespace-empty-takes-subdomain', { subdomain: now.subdomain })}
62
- @change=${(e: Event) => this.save((e.target as HTMLInputElement).value)}
63
- ></md-filled-text-field>`}
91
+ ${namespaces?.length
92
+ ? html`<ul>
93
+ ${namespaces.map(
94
+ namespace => html`<li ?suspended=${namespace.state !== 'active'}>
95
+ <code>${namespace.name}</code>
96
+ ${namespace.isDefault ? html`<span mark>${i18next.t('figure.label.namespace-default')}</span>` : nothing}
97
+ ${namespace.state !== 'active' ? html`<span mark>${i18next.t('figure.label.namespace-suspended')}</span>` : nothing}
98
+ ${namespace.isDefault
99
+ ? nothing
100
+ : html`<span actions>
101
+ ${namespace.state === 'active'
102
+ ? html`<md-text-button ?disabled=${this.busy} @click=${() => this.run(() => this.makeDefault(namespace.name!))}
103
+ >${i18next.t('figure.button.namespace-make-default')}</md-text-button
104
+ ><md-text-button ?disabled=${this.busy} @click=${() => this.run(() => this.setState(namespace.name!, 'suspended'))}
105
+ >${i18next.t('figure.button.namespace-suspend')}</md-text-button
106
+ >`
107
+ : html`<md-text-button ?disabled=${this.busy} @click=${() => this.run(() => this.setState(namespace.name!, 'active'))}
108
+ >${i18next.t('figure.button.namespace-activate')}</md-text-button
109
+ >`}
110
+ </span>`}
111
+ </li>`
112
+ )}
113
+ </ul>`
114
+ : nothing}
115
+ <div add>
116
+ <md-filled-text-field
117
+ .label=${i18next.t('figure.label.namespace')}
118
+ .value=${this.draft}
119
+ ?error=${!!this.refused}
120
+ error-text=${this.refused}
121
+ @input=${(e: Event) => {
122
+ this.draft = (e.target as HTMLInputElement).value
123
+ this.refused = ''
124
+ }}
125
+ @keydown=${(e: KeyboardEvent) => e.key === 'Enter' && !e.isComposing && this.addDraft()}
126
+ ></md-filled-text-field>
127
+ <md-text-button ?disabled=${this.busy || !this.draft.trim()} @click=${() => this.addDraft()}>${i18next.t('figure.button.namespace-add')}</md-text-button>
128
+ </div>
64
129
  </div>
65
130
  </setting-let>
66
131
  `
67
132
  }
68
133
 
69
134
  async firstUpdated() {
135
+ await this.reload()
136
+ }
137
+
138
+ private async reload() {
70
139
  try {
71
- this.now = await fetchFigureNamespace()
140
+ this.namespaces = await this.list()
72
141
  } catch (e) {
73
142
  this.refused = (e as Error).message
74
143
  }
75
144
  }
76
145
 
77
- private async save(value: string) {
146
+ private async addDraft() {
147
+ const name = this.draft.trim()
148
+ if (!name || this.busy) return
149
+ if (await this.run(() => this.add(name))) this.draft = ''
150
+ }
151
+
152
+ /** One change, then the list read again. False when the server refused it; its reason is shown. */
153
+ private async run(change: () => Promise<unknown>): Promise<boolean> {
154
+ this.busy = true
78
155
  try {
79
- this.now = await setFigureNamespace(value)
156
+ await change()
80
157
  this.refused = ''
158
+ await this.reload()
159
+ return true
81
160
  } catch (e) {
82
161
  this.refused = (e as Error).message
162
+ return false
163
+ } finally {
164
+ this.busy = false
83
165
  }
84
166
  }
85
167
  }
package/client/types.ts CHANGED
@@ -28,6 +28,14 @@ export interface Figure {
28
28
  source?: string
29
29
  properties?: string
30
30
  version?: number
31
+ /** Retired: still released and served, no longer offered for new use. */
32
+ deprecated?: boolean
33
+ /** The type to use instead of a retired figure, in the same domain. */
34
+ supersededBy?: string | null
35
+ /** Public: other domains read its releases and draw it by full name (LV figure-server design G2). */
36
+ public?: boolean
37
+ /** The domain it belongs to; carried by another domain's public figure, so a screen says whose it is. */
38
+ domain?: { id: string; name?: string; subdomain?: string } | null
31
39
  }
32
40
 
33
41
  /**
@@ -9,7 +9,8 @@ import { ASSISTANT_DOCK_VIEWPART } from '@things-factory/ai-assistant/dist-clien
9
9
  import { figureCostOf, parseFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
10
10
  import { capacityFrom } from '../modeller/scene-budget.js'
11
11
  import { SidePanelStyles } from '@things-factory/figure-viewer/dist-client/side-panel-styles.js'
12
- import { fetchFigure, fetchReleasedFigure, updateFigure } from '../graphql/index.js'
12
+ import { fetchFigure, fetchFigureTypeNames, fetchReleasedFigure, setFigureDeprecation, setFigurePublic, updateFigure } from '../graphql/index.js'
13
+ import { hasBeenReleased } from '../figure-released.js'
13
14
  import { modellerPathOf } from '../modeller/modeller-route.js'
14
15
  import { leftOpeningPage } from './detail-route.js'
15
16
  import type { Figure } from '../types.js'
@@ -19,9 +20,15 @@ import '@things-factory/figure-viewer/dist-client/figure-facts-list.js'
19
20
  const VIEWPART = 'figure-detail'
20
21
  /** Said on window when a figure's card picture was replaced from the preview: `{ detail: { id } }`. */
21
22
  export const FIGURE_THUMBNAIL_CHANGED = 'figure-thumbnail-changed'
23
+ /** Said on window when a figure was retired or brought back: `{ detail: { id } }`. */
24
+ export const FIGURE_RETIREMENT_CHANGED = 'figure-retirement-changed'
25
+ /** Said on window when a figure was made public or private: `{ detail: { id } }`. */
26
+ export const FIGURE_PUBLIC_CHANGED = 'figure-public-changed'
22
27
  export interface FigureDetailRecord {
23
28
  saved: Figure
24
29
  released?: { version: number; source: string }
30
+ /** Another domain's public figure (G2): only its release is shown, and nothing on it is changed from here. */
31
+ foreign?: boolean
25
32
  }
26
33
 
27
34
  /** Catalogue details stay while the catalogue is shown; choosing a size or a pose has no write path. */
@@ -68,6 +75,16 @@ export class FigureDetailDock extends localize(i18next)(LitElement) {
68
75
  p { margin: 0; line-height: 1.5; }
69
76
  [description] { padding: 10px 16px 0; color: var(--md-sys-color-on-surface-variant); }
70
77
  [status] { display: grid; gap: 8px; justify-items: start; padding: 12px 16px; }
78
+ /* Retiring: quiet words in the line under what is shown, like the source switch above it. */
79
+ [retire], [publish] { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; color: var(--md-sys-color-on-surface-variant); }
80
+ [retire] button, [publish] button { padding: 0; border: none; background: none; color: var(--md-sys-color-on-surface-variant); }
81
+ [retire] button:hover:not(:disabled), [publish] button:hover:not(:disabled) { color: var(--md-sys-color-on-surface); }
82
+ [publish] [published] { color: var(--md-sys-color-on-surface); font-weight: 600; }
83
+ [retire] button[primary] { color: var(--md-sys-color-primary); font-weight: 600; }
84
+ [retire] input { font: inherit; min-width: 0; flex: 1 1 160px; padding: 2px 6px; border: 1px solid var(--md-sys-color-outline-variant);
85
+ border-radius: 6px; background: var(--md-sys-color-surface); color: var(--md-sys-color-on-surface); }
86
+ [retire] [retired] { color: var(--md-sys-color-on-surface); font-weight: 600; }
87
+ [retire-error] { color: var(--md-sys-color-error); }
71
88
  @media (max-width: 1439.98px) {
72
89
  :host { position: fixed; inset: var(--figure-detail-top, 44px) 0 0 auto; height: calc(100dvh - var(--figure-detail-top, 44px)); z-index: 11; box-shadow: -4px 0 16px rgb(0 0 0 / 20%); }
73
90
  }
@@ -85,6 +102,19 @@ export class FigureDetailDock extends localize(i18next)(LitElement) {
85
102
  await updateFigure(id, { thumbnail: dataUrl })
86
103
  window.dispatchEvent(new CustomEvent(FIGURE_THUMBNAIL_CHANGED, { detail: { id } }))
87
104
  }
105
+ /** Retires a released figure or brings it back (`setFigureDeprecation`); a check gives its own. */
106
+ @property({ attribute: false }) retire: (id: string, deprecated: boolean, supersededBy: string | null) => Promise<Figure> = setFigureDeprecation
107
+ /** The types a retired figure may name instead: the domain's other figures. */
108
+ @property({ attribute: false }) typeNamesOf: () => Promise<string[]> = fetchFigureTypeNames
109
+ /** Makes a released figure public or private (`setFigurePublic`); a check gives its own. */
110
+ @property({ attribute: false }) publish: (id: string, isPublic: boolean) => Promise<Figure> = setFigurePublic
111
+ /** Another domain's public figure as the catalogue listed it, with its latest release; its draft is not this domain's to open. */
112
+ @property({ attribute: false }) loadPublic: (row: Figure) => Promise<FigureDetailRecord> = async row => ({
113
+ saved: row,
114
+ released: await fetchReleasedFigure(row.id),
115
+ foreign: true
116
+ })
117
+ @state() private publishError = ''
88
118
  @state() private figureId = ''
89
119
  @state() private record?: FigureDetailRecord
90
120
  @state() private asset?: FigureAsset
@@ -92,12 +122,17 @@ export class FigureDetailDock extends localize(i18next)(LitElement) {
92
122
  @state() private loading = false
93
123
  @state() private error = ''
94
124
  @state() private cost?: ReturnType<typeof figureCostOf>
125
+ @state() private retiring = false
126
+ @state() private instead = ''
127
+ @state() private typeNames: string[] = []
128
+ @state() private retireError = ''
129
+ @state() private retireBusy = false
95
130
  private ticket = 0
96
131
  private unsubscribe?: () => void
97
132
  private boundary?: Element
98
133
  private boundaryObserver?: ResizeObserver
99
134
 
100
- async show(id: string) {
135
+ async show(id: string, publicRow?: Figure) {
101
136
  const ticket = ++this.ticket
102
137
  this.figureId = id
103
138
  this.record = undefined
@@ -105,8 +140,11 @@ export class FigureDetailDock extends localize(i18next)(LitElement) {
105
140
  this.cost = undefined
106
141
  this.loading = true
107
142
  this.error = ''
143
+ this.retiring = false
144
+ this.retireError = ''
145
+ this.publishError = ''
108
146
  try {
109
- const record = await this.load(id)
147
+ const record = publicRow ? await this.loadPublic(publicRow) : await this.load(id)
110
148
  if (ticket !== this.ticket) return
111
149
  this.record = record
112
150
  this.choose(record.released ? 'released' : 'saved')
@@ -168,6 +206,78 @@ export class FigureDetailDock extends localize(i18next)(LitElement) {
168
206
  private escape = (e: KeyboardEvent) => {
169
207
  if (e.key === 'Escape') hideDetail()
170
208
  }
209
+ /**
210
+ * Retiring a released figure (it stays released and served; boards already using it keep it), naming the type to use
211
+ * instead, or bringing it back. A figure never released is deleted from the catalogue instead.
212
+ */
213
+ private retireRow(saved: Figure) {
214
+ if (saved.deprecated) {
215
+ return html`<div retire>
216
+ <span retired>${saved.supersededBy
217
+ ? i18next.t('figure.text.retired-instead', { type: saved.supersededBy })
218
+ : i18next.t('figure.label.retired')}</span>
219
+ <button ?disabled=${this.retireBusy} @click=${() => this.setRetired(saved, false)}>${i18next.t('figure.button.unretire')}</button>
220
+ </div>
221
+ ${this.retireError ? html`<p retire-error role="alert">${this.retireError}</p>` : nothing}`
222
+ }
223
+ if (!this.retiring) {
224
+ return html`<div retire><button @click=${() => this.openRetire(saved)}>${i18next.t('figure.button.retire')}</button></div>`
225
+ }
226
+ return html`<div retire>
227
+ <input list="figure-retire-instead" aria-label=${i18next.t('figure.label.superseded-by')}
228
+ placeholder=${i18next.t('figure.label.superseded-by')} .value=${this.instead}
229
+ @input=${(e: Event) => (this.instead = (e.target as HTMLInputElement).value)} />
230
+ <datalist id="figure-retire-instead">${this.typeNames.map(name => html`<option value=${name}></option>`)}</datalist>
231
+ <button primary ?disabled=${this.retireBusy} @click=${() => this.setRetired(saved, true)}>${i18next.t('figure.button.retire')}</button>
232
+ <button ?disabled=${this.retireBusy} @click=${() => { this.retiring = false; this.retireError = '' }}>${i18next.t('figure.button.cancel')}</button>
233
+ </div>
234
+ ${this.retireError ? html`<p retire-error role="alert">${this.retireError}</p>` : nothing}`
235
+ }
236
+ /** Public to other domains, or not (G2): they read its releases and draw it by full name; its draft stays here. */
237
+ private publishRow(saved: Figure) {
238
+ return html`<div publish>
239
+ ${saved.public ? html`<span published>${i18next.t('figure.label.public')}</span>` : nothing}
240
+ <button ?disabled=${this.retireBusy} @click=${() => this.setPublic(saved, !saved.public)}>${i18next.t(saved.public ? 'figure.button.make-private' : 'figure.button.make-public')}</button>
241
+ </div>
242
+ ${this.publishError ? html`<p retire-error role="alert">${this.publishError}</p>` : nothing}`
243
+ }
244
+ private async setPublic(saved: Figure, isPublic: boolean) {
245
+ this.retireBusy = true
246
+ this.publishError = ''
247
+ try {
248
+ const figure = await this.publish(saved.id, isPublic)
249
+ if (this.record?.saved.id === saved.id) this.record = { ...this.record, saved: { ...this.record.saved, ...figure } }
250
+ window.dispatchEvent(new CustomEvent(FIGURE_PUBLIC_CHANGED, { detail: { id: saved.id } }))
251
+ } catch (e) {
252
+ this.publishError = (e as Error).message
253
+ } finally {
254
+ this.retireBusy = false
255
+ }
256
+ }
257
+ private async openRetire(saved: Figure) {
258
+ this.retiring = true
259
+ this.instead = ''
260
+ this.retireError = ''
261
+ try {
262
+ this.typeNames = (await this.typeNamesOf()).filter(name => name !== saved.type)
263
+ } catch {
264
+ this.typeNames = []
265
+ }
266
+ }
267
+ private async setRetired(saved: Figure, deprecated: boolean) {
268
+ this.retireBusy = true
269
+ this.retireError = ''
270
+ try {
271
+ const figure = await this.retire(saved.id, deprecated, deprecated ? this.instead.trim() || null : null)
272
+ if (this.record?.saved.id === saved.id) this.record = { ...this.record, saved: { ...this.record.saved, ...figure } }
273
+ this.retiring = false
274
+ window.dispatchEvent(new CustomEvent(FIGURE_RETIREMENT_CHANGED, { detail: { id: saved.id } }))
275
+ } catch (e) {
276
+ this.retireError = (e as Error).message
277
+ } finally {
278
+ this.retireBusy = false
279
+ }
280
+ }
171
281
  render() {
172
282
  const saved = this.record?.saved
173
283
  const released = this.record?.released
@@ -179,19 +289,21 @@ export class FigureDetailDock extends localize(i18next)(LitElement) {
179
289
  return html`<header>
180
290
  <div title-block><h2 title=${saved?.name || ''}>${saved?.name || i18next.t('figure.title.detail')}</h2>
181
291
  ${saved ? html`<code title=${fullName(saved)}>${fullName(saved)}</code>` : nothing}</div>
182
- <button icon edit aria-label=${i18next.t('figure.button.detail-edit')} title=${i18next.t('figure.button.detail-edit')} ?disabled=${!saved}
292
+ ${this.record?.foreign ? nothing : html`<button icon edit aria-label=${i18next.t('figure.button.detail-edit')} title=${i18next.t('figure.button.detail-edit')} ?disabled=${!saved}
183
293
  @click=${() => {
184
294
  // The modeller shows the figure itself; a preview of it beside the editor only covers the work.
185
295
  hideDetail()
186
296
  navigate(modellerPathOf(saved))
187
- }}><md-icon>edit</md-icon></button>
297
+ }}><md-icon>edit</md-icon></button>`}
188
298
  <button icon aria-label=${i18next.t('figure.button.close')} title=${i18next.t('figure.button.close')} @click=${() => hideDetail()}><md-icon>close</md-icon></button>
189
299
  </header>
190
300
  <div body>
191
301
  ${this.loading ? html`<p status role="status">${i18next.t('figure.text.detail-loading')}</p>` : nothing}
192
302
  ${this.error ? html`<div status role="alert"><p>${this.error}</p><button act @click=${() => this.show(this.figureId)}><md-icon>refresh</md-icon>${i18next.t('figure.button.detail-retry')}</button></div>` : nothing}
193
303
  ${saved ? html`<section meta>
194
- ${released
304
+ ${released && this.record?.foreign
305
+ ? html`<div source><span>${i18next.t('figure.label.detail-released', { version: released.version })}</span>${saved.domain?.name ? html`<span owner>${saved.domain.name}</span>` : nothing}</div>`
306
+ : released
195
307
  ? html`<div source role="group" aria-label=${i18next.t('figure.label.detail-source')}>
196
308
  <button aria-pressed=${String(this.sourceKind === 'released')} @click=${() => this.choose('released')}>${i18next.t('figure.label.detail-released', { version: released.version })}</button>
197
309
  <button aria-pressed=${String(this.sourceKind === 'saved')} @click=${() => this.choose('saved')}>${savedLabel}</button>
@@ -205,9 +317,11 @@ export class FigureDetailDock extends localize(i18next)(LitElement) {
205
317
  <div><dt>${i18next.t('figure.label.budget-groups')}</dt><dd>${cost?.groups ?? '—'}</dd></div>
206
318
  <div><dt>${i18next.t('figure.label.budget-parts')}</dt><dd>${this.asset.appearance.length}</dd></div>
207
319
  </dl>` : nothing}
320
+ ${hasBeenReleased(saved) && !this.record?.foreign ? this.retireRow(saved) : nothing}
321
+ ${hasBeenReleased(saved) && !this.record?.foreign ? this.publishRow(saved) : nothing}
208
322
  </section>` : nothing}
209
323
  ${this.asset ? html`<figure-asset-viewer .asset=${this.asset}
210
- .keepShot=${saved?.id ? (dataUrl: string) => this.keepThumbnail(saved.id, dataUrl) : undefined}>
324
+ .keepShot=${saved?.id && !this.record?.foreign ? (dataUrl: string) => this.keepThumbnail(saved.id, dataUrl) : undefined}>
211
325
  ${saved?.description ? html`<p slot="facts" description>${saved.description}</p>` : nothing}
212
326
  ${saved ? html`<figure-facts-list slot="facts" .asset=${this.asset} .row=${saved}></figure-facts-list>` : nothing}
213
327
  </figure-asset-viewer>` : nothing}
@@ -236,7 +350,8 @@ let openedOn: string | undefined
236
350
  function hideDetail() {
237
351
  store.dispatch({ type: UPDATE_VIEWPART, name: VIEWPART, override: { show: false } })
238
352
  }
239
- export function openFigureDetail(figure: Figure) {
353
+ /** Opens the preview of a figure; `publicRow` for another domain's public figure, shown by its release alone. */
354
+ export function openFigureDetail(figure: Figure, publicRow = false) {
240
355
  if (!dock) {
241
356
  dock = document.createElement('figure-detail-dock') as FigureDetailDock
242
357
  }
@@ -244,5 +359,5 @@ export function openFigureDetail(figure: Figure) {
244
359
  store.dispatch({ type: UPDATE_VIEWPART, name: ASSISTANT_DOCK_VIEWPART, override: { show: false } })
245
360
  appendViewpart({ name: VIEWPART, position: VIEWPART_POSITION.ASIDEBAR,
246
361
  viewpart: { show: true, persistent: true, level: VIEWPART_LEVEL.TOPMOST, template: html`${dock}` } })
247
- void dock.show(figure.id)
362
+ void dock.show(figure.id, publicRow ? figure : undefined)
248
363
  }