@things-factory/figure-ui 10.1.70 → 10.1.71
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.
- package/client/graphql/index.ts +28 -69
- package/client/modeller/figure-v3-parts.ts +16 -136
- package/client/modeller/figure-v3-recipe-catalog.ts +348 -0
- package/client/modeller/v3-editor.ts +25 -5
- package/client/modeller/v3-fields.ts +21 -16
- package/client/modeller/v3-gizmo.ts +1 -1
- package/client/modeller/v3-links.ts +7 -66
- package/client/modeller/v3-palette.ts +3 -0
- package/client/modeller/v3-recipe-source.ts +15 -0
- package/client/modeller/v3-recipes.ts +50 -27
- package/client/pages/figure-v3-modeller-page.ts +16 -13
- package/client/route.ts +0 -4
- package/dist-client/graphql/index.d.ts +11 -18
- package/dist-client/graphql/index.js +18 -59
- package/dist-client/graphql/index.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.d.ts +5 -12
- package/dist-client/modeller/figure-v3-parts.js +17 -147
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-recipe-catalog.d.ts +36 -0
- package/dist-client/modeller/figure-v3-recipe-catalog.js +382 -0
- package/dist-client/modeller/figure-v3-recipe-catalog.js.map +1 -0
- package/dist-client/modeller/v3-editor.d.ts +8 -3
- package/dist-client/modeller/v3-editor.js +35 -6
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-fields.d.ts +2 -2
- package/dist-client/modeller/v3-fields.js +20 -18
- package/dist-client/modeller/v3-fields.js.map +1 -1
- package/dist-client/modeller/v3-gizmo.js +1 -1
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +8 -25
- package/dist-client/modeller/v3-links.js +4 -66
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-palette.js +3 -0
- package/dist-client/modeller/v3-palette.js.map +1 -1
- package/dist-client/modeller/v3-recipe-source.d.ts +8 -0
- package/dist-client/modeller/v3-recipe-source.js +14 -0
- package/dist-client/modeller/v3-recipe-source.js.map +1 -0
- package/dist-client/modeller/v3-recipes.d.ts +25 -14
- package/dist-client/modeller/v3-recipes.js +42 -28
- package/dist-client/modeller/v3-recipes.js.map +1 -1
- package/dist-client/pages/figure-v3-modeller-page.d.ts +5 -3
- package/dist-client/pages/figure-v3-modeller-page.js +16 -13
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +0 -3
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.fui.tsbuildinfo +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/scripts/template-thumbnails.mjs +53 -0
- package/test/browser/build.mjs +3 -3
- package/test/browser/modeller-page.js +25 -3
- package/test/browser/modeller.check.mjs +57 -47
- package/test/browser/thumbnail-page.js +14 -0
- package/test/v3-fields.test.ts +5 -1
- package/test/v3-links.test.ts +12 -0
- package/test/v3-recipes.test.ts +42 -25
- package/things-factory.config.js +0 -1
- package/translations/en.json +38 -23
- package/translations/ja.json +38 -23
- package/translations/ko.json +38 -23
- package/translations/ms.json +38 -23
- package/translations/zh.json +38 -23
- package/client/pages/figure-recipe-page.ts +0 -338
- package/dist-client/pages/figure-recipe-page.d.ts +0 -41
- package/dist-client/pages/figure-recipe-page.js +0 -352
- package/dist-client/pages/figure-recipe-page.js.map +0 -1
package/client/graphql/index.ts
CHANGED
|
@@ -333,91 +333,50 @@ export async function fetchFigureVersions(id: string): Promise<FigureVersion[]>
|
|
|
333
333
|
return unwrap<FigureVersion[]>(response, 'figureVersions') ?? []
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
-
/**
|
|
337
|
-
export
|
|
338
|
-
const response = await client.query({
|
|
339
|
-
query: gql`
|
|
340
|
-
query {
|
|
341
|
-
figureRecipes {
|
|
342
|
-
name
|
|
343
|
-
source
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
`,
|
|
347
|
-
fetchPolicy: 'network-only'
|
|
348
|
-
})
|
|
349
|
-
return unwrap<Array<{ name: string; source: string }> | null>(response, 'figureRecipes') ?? []
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** A recipe row as the management screen reads it: whose it is and whether it is released. */
|
|
353
|
-
export interface FigureRecipeRow {
|
|
336
|
+
/** A recipe template as the server gives it (ADR-0095, amended): words in the language asked, the recipe as JSON. */
|
|
337
|
+
export interface RecipeTemplateRow {
|
|
354
338
|
name: string
|
|
339
|
+
category: string
|
|
340
|
+
tags: string[]
|
|
341
|
+
target: 'part' | 'figure'
|
|
342
|
+
title: string
|
|
343
|
+
description: string
|
|
355
344
|
source: string
|
|
356
|
-
|
|
357
|
-
updatedAt?: string
|
|
358
|
-
domain?: { id: string; subdomain?: string }
|
|
345
|
+
hasThumbnail: boolean
|
|
359
346
|
}
|
|
360
347
|
|
|
361
|
-
|
|
362
|
-
export async function fetchFigureRecipeRows(): Promise<FigureRecipeRow[]> {
|
|
348
|
+
export async function fetchRecipeTemplates(lang: string): Promise<RecipeTemplateRow[]> {
|
|
363
349
|
const response = await client.query({
|
|
364
350
|
query: gql`
|
|
365
|
-
query {
|
|
366
|
-
|
|
367
|
-
name
|
|
368
|
-
source
|
|
369
|
-
state
|
|
370
|
-
updatedAt
|
|
371
|
-
domain {
|
|
372
|
-
id
|
|
373
|
-
subdomain
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
`,
|
|
378
|
-
fetchPolicy: 'network-only'
|
|
379
|
-
})
|
|
380
|
-
return unwrap<FigureRecipeRow[] | null>(response, 'figureRecipes') ?? []
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
export async function saveFigureRecipe(source: string, state: 'draft' | 'released'): Promise<FigureRecipeRow> {
|
|
384
|
-
const response = await client.mutate({
|
|
385
|
-
mutation: gql`
|
|
386
|
-
mutation ($source: String!, $state: String) {
|
|
387
|
-
saveFigureRecipe(source: $source, state: $state) {
|
|
351
|
+
query ($lang: String) {
|
|
352
|
+
figureRecipeTemplates(lang: $lang) {
|
|
388
353
|
name
|
|
354
|
+
category
|
|
355
|
+
tags
|
|
356
|
+
target
|
|
357
|
+
title
|
|
358
|
+
description
|
|
389
359
|
source
|
|
390
|
-
|
|
360
|
+
hasThumbnail
|
|
391
361
|
}
|
|
392
362
|
}
|
|
393
363
|
`,
|
|
394
|
-
variables: {
|
|
364
|
+
variables: { lang }
|
|
395
365
|
})
|
|
396
|
-
return unwrap<
|
|
366
|
+
return unwrap<RecipeTemplateRow[] | null>(response, 'figureRecipeTemplates') ?? []
|
|
397
367
|
}
|
|
398
368
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
369
|
+
/** One template's guide in the language asked, or '' while none is written. */
|
|
370
|
+
export async function fetchRecipeGuide(name: string, lang: string): Promise<string> {
|
|
371
|
+
const response = await client.query({
|
|
372
|
+
query: gql`
|
|
373
|
+
query ($name: String!, $lang: String) {
|
|
374
|
+
figureRecipeTemplate(name: $name, lang: $lang) {
|
|
375
|
+
guide
|
|
405
376
|
}
|
|
406
377
|
}
|
|
407
378
|
`,
|
|
408
|
-
variables: { name,
|
|
409
|
-
})
|
|
410
|
-
unwrap(response, 'setFigureRecipeState')
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export async function deleteFigureRecipe(name: string): Promise<void> {
|
|
414
|
-
const response = await client.mutate({
|
|
415
|
-
mutation: gql`
|
|
416
|
-
mutation ($name: String!) {
|
|
417
|
-
deleteFigureRecipe(name: $name)
|
|
418
|
-
}
|
|
419
|
-
`,
|
|
420
|
-
variables: { name }
|
|
379
|
+
variables: { name, lang }
|
|
421
380
|
})
|
|
422
|
-
unwrap(response, '
|
|
381
|
+
return unwrap<{ guide?: string } | null>(response, 'figureRecipeTemplate')?.guide ?? ''
|
|
423
382
|
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import '@material/web/icon/icon.js'
|
|
2
2
|
import { css, html, LitElement, nothing } from 'lit'
|
|
3
|
-
import { customElement, property
|
|
3
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
4
4
|
import { i18next, localize } from '@operato/i18n'
|
|
5
5
|
import { ScrollbarStyles } from '@operato/styles'
|
|
6
6
|
import { PART_LIMIT } from '@hatiolab/figure-model'
|
|
7
|
-
import type { V3PrimitiveCreation
|
|
8
|
-
import { recipeFieldOf, recipeReady, recipeValueOf, recipeWord } from './v3-recipes.js'
|
|
7
|
+
import type { V3PrimitiveCreation } from '@hatiolab/figure-model'
|
|
9
8
|
import type { V3BudgetLine } from './v3-maturity.js'
|
|
10
9
|
import { partLabelOf } from './v3-fields.js'
|
|
11
10
|
|
|
@@ -61,7 +60,8 @@ const RECIPES: Array<{ kind: V3PaletteKind; icon: string; label: string }> = [
|
|
|
61
60
|
{ kind: 'polygon', icon: 'hexagon', label: 'figure.label.primitive-polygon' },
|
|
62
61
|
{ kind: 'plate', icon: 'check_box_outline_blank', label: 'figure.label.primitive-plate' },
|
|
63
62
|
{ kind: 'wall', icon: 'crop_portrait', label: 'figure.label.primitive-wall' },
|
|
64
|
-
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' }
|
|
63
|
+
{ kind: 'portal', icon: 'door_front', label: 'figure.label.primitive-portal' },
|
|
64
|
+
{ kind: 'hollow-box', icon: 'inventory_2', label: 'figure.label.primitive-hollow-box' }
|
|
65
65
|
]
|
|
66
66
|
|
|
67
67
|
@customElement('figure-v3-parts')
|
|
@@ -209,83 +209,19 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
209
209
|
border-radius: 6px;
|
|
210
210
|
cursor: pointer;
|
|
211
211
|
}
|
|
212
|
-
|
|
213
|
-
display: flex;
|
|
214
|
-
flex-wrap: wrap;
|
|
215
|
-
gap: 3px;
|
|
216
|
-
}
|
|
217
|
-
button[recipe] {
|
|
218
|
-
padding: 4px 8px;
|
|
219
|
-
font: inherit;
|
|
220
|
-
font-size: 0.75rem;
|
|
221
|
-
color: var(--md-sys-color-on-surface-variant, var(--md-sys-color-on-surface));
|
|
222
|
-
background: none;
|
|
223
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
224
|
-
border-radius: 999em;
|
|
225
|
-
cursor: pointer;
|
|
226
|
-
}
|
|
227
|
-
button[recipe][on] {
|
|
228
|
-
color: var(--md-sys-color-on-secondary-container);
|
|
229
|
-
background: var(--md-sys-color-secondary-container);
|
|
230
|
-
border-color: transparent;
|
|
231
|
-
}
|
|
232
|
-
button[recipe]:disabled {
|
|
233
|
-
opacity: 0.38;
|
|
234
|
-
cursor: default;
|
|
235
|
-
}
|
|
236
|
-
div[recipe-form] {
|
|
237
|
-
display: grid;
|
|
238
|
-
gap: 6px;
|
|
239
|
-
margin-top: 8px;
|
|
240
|
-
padding: 8px;
|
|
241
|
-
border: 1px solid var(--md-sys-color-outline-variant);
|
|
242
|
-
border-radius: 8px;
|
|
243
|
-
font-size: 0.75rem;
|
|
244
|
-
}
|
|
245
|
-
/* The side pane is narrow (200 px, 160 under 860): the name above its field, not beside it. */
|
|
246
|
-
div[recipe-form] label[field] {
|
|
247
|
-
display: grid;
|
|
248
|
-
gap: 2px;
|
|
249
|
-
}
|
|
250
|
-
div[recipe-form] input[type='number'],
|
|
251
|
-
div[recipe-form] select {
|
|
252
|
-
min-width: 0;
|
|
253
|
-
font: inherit;
|
|
254
|
-
}
|
|
255
|
-
div[recipe-form] p[hint] {
|
|
256
|
-
margin: 0;
|
|
257
|
-
color: var(--md-sys-color-on-surface-variant);
|
|
258
|
-
}
|
|
259
|
-
div[recipe-form] p[error] {
|
|
260
|
-
margin: 0;
|
|
261
|
-
padding: 6px;
|
|
262
|
-
white-space: pre-wrap;
|
|
263
|
-
color: var(--md-sys-color-on-error-container);
|
|
264
|
-
background: var(--md-sys-color-error-container);
|
|
265
|
-
border-radius: 6px;
|
|
266
|
-
}
|
|
267
|
-
div[recipe-actions] {
|
|
268
|
-
display: flex;
|
|
269
|
-
gap: 6px;
|
|
270
|
-
}
|
|
271
|
-
div[recipe-actions] button {
|
|
272
|
-
white-space: nowrap;
|
|
212
|
+
button[recipe-catalog] {
|
|
273
213
|
display: inline-flex;
|
|
274
214
|
align-items: center;
|
|
275
215
|
gap: 4px;
|
|
276
216
|
padding: 4px 10px;
|
|
277
217
|
font: inherit;
|
|
218
|
+
font-size: 0.75rem;
|
|
219
|
+
color: var(--md-sys-color-on-surface);
|
|
220
|
+
background: none;
|
|
278
221
|
border: 1px solid var(--md-sys-color-outline-variant);
|
|
279
222
|
border-radius: 999em;
|
|
280
|
-
background: none;
|
|
281
|
-
color: var(--md-sys-color-on-surface);
|
|
282
223
|
cursor: pointer;
|
|
283
224
|
}
|
|
284
|
-
div[recipe-actions] button[insert] {
|
|
285
|
-
border-color: transparent;
|
|
286
|
-
color: var(--md-sys-color-on-primary);
|
|
287
|
-
background: var(--md-sys-color-primary);
|
|
288
|
-
}
|
|
289
225
|
button[shape]:hover:not([disabled]) {
|
|
290
226
|
color: var(--md-sys-color-on-primary-container);
|
|
291
227
|
background-color: var(--md-sys-color-primary-container);
|
|
@@ -442,13 +378,8 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
442
378
|
* 잠긴다. 되풀이는 한 부품으로 센다. 등급을 안 정했으면 한도는 없다.
|
|
443
379
|
*/
|
|
444
380
|
@property({ attribute: false }) partBudget?: { level: 'S' | 'M' | 'L' | null; used: number; limit: number | null }
|
|
445
|
-
/**
|
|
446
|
-
@property({
|
|
447
|
-
/** Why the last recipe put in was refused -- the kernel's sentence, as it is (ADR-0095 condition 3). */
|
|
448
|
-
@property({ type: String }) recipeError = ''
|
|
449
|
-
/** The recipe whose form is open, and the values in it. */
|
|
450
|
-
@state() private openRecipe = ''
|
|
451
|
-
@state() private recipeValues: Record<string, V3RecipeValue> = {}
|
|
381
|
+
/** How many recipe templates the catalog holds; none, and the section is not drawn. */
|
|
382
|
+
@property({ type: Number }) recipeCount = 0
|
|
452
383
|
|
|
453
384
|
render() {
|
|
454
385
|
const b = this.partBudget
|
|
@@ -626,70 +557,19 @@ export class FigureV3Parts extends localize(i18next)(LitElement) {
|
|
|
626
557
|
}
|
|
627
558
|
|
|
628
559
|
/**
|
|
629
|
-
* Recipes (ADR-0095): a door, a roller row --
|
|
630
|
-
* part
|
|
560
|
+
* Recipes (ADR-0095): the catalog of templates -- a door, a roller row -- opened over the modeller, put onto the chosen
|
|
561
|
+
* part in one step.
|
|
631
562
|
*/
|
|
632
563
|
private renderRecipes(full: boolean) {
|
|
633
|
-
if (!this.
|
|
634
|
-
const language = i18next.language || 'en'
|
|
635
|
-
const open = this.recipes.find(r => r.name === this.openRecipe)
|
|
564
|
+
if (!this.recipeCount) return nothing
|
|
636
565
|
return html`<section recipes>
|
|
637
566
|
<h3>${i18next.t('figure.label.recipes')}</h3>
|
|
638
|
-
<
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
return html`<button
|
|
642
|
-
recipe=${recipe.name}
|
|
643
|
-
?on=${recipe.name === this.openRecipe}
|
|
644
|
-
?disabled=${this.locked || full || !ready}
|
|
645
|
-
title=${ready ? recipeWord(recipe.description, language) : i18next.t('figure.text.recipe-needs-a-part')}
|
|
646
|
-
@click=${() => this.openFor(recipe)}
|
|
647
|
-
>
|
|
648
|
-
${recipeWord(recipe.title, language)}
|
|
649
|
-
</button>`
|
|
650
|
-
})}
|
|
651
|
-
</div>
|
|
652
|
-
${this.selected || !this.recipes.some(r => r.target === 'part') ? nothing : html`<p hint>${i18next.t('figure.text.recipe-needs-a-part')}</p>`}
|
|
653
|
-
${open && recipeReady(open, this.selected) ? this.renderRecipeForm(open, language) : nothing}
|
|
567
|
+
<button recipe-catalog ?disabled=${this.locked || full} @click=${() => this.ask('open-recipe-catalog', {})}>
|
|
568
|
+
<md-icon>widgets</md-icon>${i18next.t('figure.button.open-recipe-catalog', { count: this.recipeCount })}
|
|
569
|
+
</button>
|
|
654
570
|
</section>`
|
|
655
571
|
}
|
|
656
572
|
|
|
657
|
-
private openFor(recipe: V3Recipe) {
|
|
658
|
-
if (this.openRecipe === recipe.name) {
|
|
659
|
-
this.openRecipe = ''
|
|
660
|
-
return
|
|
661
|
-
}
|
|
662
|
-
this.openRecipe = recipe.name
|
|
663
|
-
this.recipeValues = Object.fromEntries(recipe.params.map(p => [p.name, p.default]))
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
private renderRecipeForm(recipe: V3Recipe, language: string) {
|
|
667
|
-
const set = (name: string, value: V3RecipeValue) => (this.recipeValues = { ...this.recipeValues, [name]: value })
|
|
668
|
-
return html`<div recipe-form>
|
|
669
|
-
${recipe.target === 'part' ? html`<p hint>${i18next.t('figure.text.recipe-onto', { part: this.selected })}</p>` : nothing}
|
|
670
|
-
${recipe.params.map(param => {
|
|
671
|
-
const label = recipeWord(param.label, language) || param.name
|
|
672
|
-
const value = this.recipeValues[param.name] ?? param.default
|
|
673
|
-
if (param.kind === 'flag')
|
|
674
|
-
return html`<label toggle><input type="checkbox" .checked=${value === true} @change=${(e: Event) => set(param.name, (e.target as HTMLInputElement).checked)} />${label}</label>`
|
|
675
|
-
if (param.kind === 'choice' || param.kind === 'face')
|
|
676
|
-
return html`<label field><span>${label}</span>
|
|
677
|
-
<select @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLSelectElement).value))}>
|
|
678
|
-
${(param.options ?? []).map(o => html`<option value=${String(o)} ?selected=${o === value}>${String(o)}</option>`)}
|
|
679
|
-
</select></label>`
|
|
680
|
-
return html`<label field><span>${label}${param.kind === 'ratio' ? ' (%)' : ''}</span>
|
|
681
|
-
<input type="number" step="any" param=${param.name} .value=${recipeFieldOf(param, value)} @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLInputElement).value))} /></label>`
|
|
682
|
-
})}
|
|
683
|
-
${this.recipeError ? html`<p role="alert" error>${this.recipeError}</p>` : nothing}
|
|
684
|
-
<div recipe-actions>
|
|
685
|
-
<button act insert ?disabled=${this.locked} @click=${() => this.ask('insert-recipe', { recipe, values: this.recipeValues })}>
|
|
686
|
-
<md-icon>add</md-icon>${i18next.t('figure.button.insert-recipe')}
|
|
687
|
-
</button>
|
|
688
|
-
<button act @click=${() => (this.openRecipe = '')}>${i18next.t('figure.button.close')}</button>
|
|
689
|
-
</div>
|
|
690
|
-
</div>`
|
|
691
|
-
}
|
|
692
|
-
|
|
693
573
|
/** 정본을 고치지 않는다 — 무엇을 하겠다는 것만 알린다. */
|
|
694
574
|
private ask(what: string, detail: object) {
|
|
695
575
|
this.dispatchEvent(new CustomEvent(what, { detail, bubbles: true, composed: true }))
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
2
|
+
import '@operato/markdown'
|
|
3
|
+
|
|
4
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
5
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
6
|
+
|
|
7
|
+
import { i18next, localize } from '@operato/i18n'
|
|
8
|
+
import { ScrollbarStyles } from '@operato/styles'
|
|
9
|
+
import { describeV3Recipe } from '@hatiolab/figure-model'
|
|
10
|
+
import type { V3Recipe, V3RecipeValue } from '@hatiolab/figure-model'
|
|
11
|
+
|
|
12
|
+
import { recipeFieldOf, recipeReady, recipeValueOf, recipeWord, recipeWorkings, recipesMatching, type RecipeTemplate } from './v3-recipes.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The recipe catalog (ADR-0095): the templates the product ships, by category, found by words, each told in full --
|
|
16
|
+
* what it puts in, how many parts, how they move, what each value does, and how to use it -- and put onto the chosen
|
|
17
|
+
* part in one step. The numbers and the ways of moving are worked out from the recipe (`describeV3Recipe`), so they say
|
|
18
|
+
* what it does; only the descriptions and the guide are written.
|
|
19
|
+
*/
|
|
20
|
+
@customElement('figure-v3-recipe-catalog')
|
|
21
|
+
export class FigureV3RecipeCatalog extends localize(i18next)(LitElement) {
|
|
22
|
+
static styles = [
|
|
23
|
+
ScrollbarStyles,
|
|
24
|
+
css`
|
|
25
|
+
:host {
|
|
26
|
+
position: fixed;
|
|
27
|
+
inset: 0;
|
|
28
|
+
z-index: 20;
|
|
29
|
+
display: grid;
|
|
30
|
+
place-items: center;
|
|
31
|
+
background: color-mix(in srgb, var(--md-sys-color-scrim, #000) 32%, transparent);
|
|
32
|
+
font-size: 0.82rem;
|
|
33
|
+
color: var(--md-sys-color-on-surface);
|
|
34
|
+
}
|
|
35
|
+
div[panel] {
|
|
36
|
+
display: grid;
|
|
37
|
+
grid-template-rows: auto 1fr;
|
|
38
|
+
width: min(1100px, calc(100vw - 32px));
|
|
39
|
+
height: min(720px, calc(100vh - 32px));
|
|
40
|
+
background: var(--md-sys-color-surface);
|
|
41
|
+
border-radius: 12px;
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
|
|
44
|
+
}
|
|
45
|
+
header {
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 8px;
|
|
49
|
+
padding: 8px 12px;
|
|
50
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
51
|
+
}
|
|
52
|
+
header h2 {
|
|
53
|
+
margin: 0;
|
|
54
|
+
font-size: var(--ui-t-title, 16px);
|
|
55
|
+
flex: 1;
|
|
56
|
+
}
|
|
57
|
+
header input {
|
|
58
|
+
width: 220px;
|
|
59
|
+
padding: 4px 8px;
|
|
60
|
+
font: inherit;
|
|
61
|
+
}
|
|
62
|
+
button[close] {
|
|
63
|
+
display: inline-grid;
|
|
64
|
+
place-items: center;
|
|
65
|
+
width: 32px;
|
|
66
|
+
height: 32px;
|
|
67
|
+
border: none;
|
|
68
|
+
border-radius: 8px;
|
|
69
|
+
background: none;
|
|
70
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
71
|
+
cursor: pointer;
|
|
72
|
+
}
|
|
73
|
+
div[body] {
|
|
74
|
+
display: grid;
|
|
75
|
+
grid-template-columns: 170px minmax(0, 1fr) minmax(300px, 380px);
|
|
76
|
+
min-height: 0;
|
|
77
|
+
}
|
|
78
|
+
@media (max-width: 760px) {
|
|
79
|
+
div[body] {
|
|
80
|
+
grid-template-columns: 1fr;
|
|
81
|
+
overflow: auto;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
nav,
|
|
85
|
+
section[list],
|
|
86
|
+
section[detail] {
|
|
87
|
+
overflow: auto;
|
|
88
|
+
min-height: 0;
|
|
89
|
+
padding: 8px;
|
|
90
|
+
}
|
|
91
|
+
nav {
|
|
92
|
+
border-right: 1px solid var(--md-sys-color-outline-variant);
|
|
93
|
+
}
|
|
94
|
+
section[detail] {
|
|
95
|
+
border-left: 1px solid var(--md-sys-color-outline-variant);
|
|
96
|
+
display: grid;
|
|
97
|
+
gap: 10px;
|
|
98
|
+
align-content: start;
|
|
99
|
+
}
|
|
100
|
+
button[category] {
|
|
101
|
+
display: flex;
|
|
102
|
+
justify-content: space-between;
|
|
103
|
+
width: 100%;
|
|
104
|
+
padding: 6px 8px;
|
|
105
|
+
font: inherit;
|
|
106
|
+
color: inherit;
|
|
107
|
+
background: none;
|
|
108
|
+
border: none;
|
|
109
|
+
border-radius: 6px;
|
|
110
|
+
text-align: left;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
}
|
|
113
|
+
button[category][on],
|
|
114
|
+
button[card][on] {
|
|
115
|
+
background: var(--md-sys-color-secondary-container);
|
|
116
|
+
color: var(--md-sys-color-on-secondary-container);
|
|
117
|
+
}
|
|
118
|
+
section[list] {
|
|
119
|
+
display: grid;
|
|
120
|
+
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
|
121
|
+
gap: 8px;
|
|
122
|
+
align-content: start;
|
|
123
|
+
}
|
|
124
|
+
button[card] {
|
|
125
|
+
display: grid;
|
|
126
|
+
gap: 4px;
|
|
127
|
+
padding: 8px;
|
|
128
|
+
font: inherit;
|
|
129
|
+
color: inherit;
|
|
130
|
+
text-align: left;
|
|
131
|
+
background: none;
|
|
132
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
133
|
+
border-radius: 8px;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
}
|
|
136
|
+
button[card] img {
|
|
137
|
+
width: 100%;
|
|
138
|
+
aspect-ratio: 4 / 3;
|
|
139
|
+
object-fit: contain;
|
|
140
|
+
background: var(--md-sys-color-surface-container);
|
|
141
|
+
border-radius: 4px;
|
|
142
|
+
}
|
|
143
|
+
button[card] small,
|
|
144
|
+
p[muted] {
|
|
145
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
146
|
+
}
|
|
147
|
+
h3 {
|
|
148
|
+
margin: 0;
|
|
149
|
+
font-size: var(--ui-t-body, 14px);
|
|
150
|
+
}
|
|
151
|
+
p {
|
|
152
|
+
margin: 0;
|
|
153
|
+
line-height: 1.5;
|
|
154
|
+
}
|
|
155
|
+
ul {
|
|
156
|
+
margin: 0;
|
|
157
|
+
padding-left: 18px;
|
|
158
|
+
line-height: 1.6;
|
|
159
|
+
}
|
|
160
|
+
table {
|
|
161
|
+
border-collapse: collapse;
|
|
162
|
+
width: 100%;
|
|
163
|
+
}
|
|
164
|
+
th,
|
|
165
|
+
td {
|
|
166
|
+
text-align: left;
|
|
167
|
+
vertical-align: top;
|
|
168
|
+
padding: 3px 4px;
|
|
169
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
|
170
|
+
}
|
|
171
|
+
td small {
|
|
172
|
+
display: block;
|
|
173
|
+
color: var(--md-sys-color-on-surface-variant);
|
|
174
|
+
}
|
|
175
|
+
div[form] {
|
|
176
|
+
display: grid;
|
|
177
|
+
gap: 6px;
|
|
178
|
+
padding: 8px;
|
|
179
|
+
border: 1px solid var(--md-sys-color-outline-variant);
|
|
180
|
+
border-radius: 8px;
|
|
181
|
+
}
|
|
182
|
+
div[form] label {
|
|
183
|
+
display: grid;
|
|
184
|
+
gap: 2px;
|
|
185
|
+
}
|
|
186
|
+
div[form] label[toggle] {
|
|
187
|
+
display: flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
gap: 6px;
|
|
190
|
+
}
|
|
191
|
+
div[form] input,
|
|
192
|
+
div[form] select {
|
|
193
|
+
font: inherit;
|
|
194
|
+
min-width: 0;
|
|
195
|
+
}
|
|
196
|
+
button[insert] {
|
|
197
|
+
justify-self: start;
|
|
198
|
+
display: inline-flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
gap: 4px;
|
|
201
|
+
padding: 6px 14px;
|
|
202
|
+
font: inherit;
|
|
203
|
+
white-space: nowrap;
|
|
204
|
+
color: var(--md-sys-color-on-primary);
|
|
205
|
+
background: var(--md-sys-color-primary);
|
|
206
|
+
border: none;
|
|
207
|
+
border-radius: 999em;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
}
|
|
210
|
+
button[insert]:disabled {
|
|
211
|
+
opacity: 0.38;
|
|
212
|
+
cursor: default;
|
|
213
|
+
}
|
|
214
|
+
p[role='alert'] {
|
|
215
|
+
padding: 6px;
|
|
216
|
+
white-space: pre-wrap;
|
|
217
|
+
color: var(--md-sys-color-on-error-container);
|
|
218
|
+
background: var(--md-sys-color-error-container);
|
|
219
|
+
border-radius: 6px;
|
|
220
|
+
}
|
|
221
|
+
ox-markdown {
|
|
222
|
+
display: block;
|
|
223
|
+
}
|
|
224
|
+
`
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
@property({ attribute: false }) templates: RecipeTemplate[] = []
|
|
228
|
+
/** The part it would be put onto. */
|
|
229
|
+
@property({ type: String }) selected = ''
|
|
230
|
+
@property({ type: Boolean }) locked = false
|
|
231
|
+
/** Why the last one put in was refused, in the kernel's words. */
|
|
232
|
+
@property({ type: String }) error = ''
|
|
233
|
+
/** Reads a template's guide; absent, no guide is shown. */
|
|
234
|
+
@property({ attribute: false }) guideOf?: (name: string) => Promise<string>
|
|
235
|
+
/** Where a template's thumbnail is read from; absent, cards show no picture. */
|
|
236
|
+
@property({ attribute: false }) thumbnailOf?: (name: string) => string
|
|
237
|
+
|
|
238
|
+
@state() private category = ''
|
|
239
|
+
@state() private query = ''
|
|
240
|
+
@state() private chosen = ''
|
|
241
|
+
@state() private values: Record<string, V3RecipeValue> = {}
|
|
242
|
+
@state() private guide = ''
|
|
243
|
+
|
|
244
|
+
render() {
|
|
245
|
+
const language = i18next.language || 'en'
|
|
246
|
+
const categories = [...new Set(this.templates.map(t => t.category))].sort()
|
|
247
|
+
const shown = recipesMatching(this.templates, this.category, this.query)
|
|
248
|
+
const chosen = this.templates.find(t => t.name === this.chosen) ?? shown[0]
|
|
249
|
+
return html`<div panel role="dialog" aria-modal="true" aria-label=${i18next.t('figure.label.recipe-catalog')}>
|
|
250
|
+
<header>
|
|
251
|
+
<h2>${i18next.t('figure.label.recipe-catalog')}</h2>
|
|
252
|
+
<input search type="search" placeholder=${i18next.t('figure.text.recipe-search')} .value=${this.query} @input=${(e: Event) => (this.query = (e.target as HTMLInputElement).value)} />
|
|
253
|
+
<button close aria-label=${i18next.t('figure.button.close')} @click=${() => this.dispatchEvent(new CustomEvent('close-catalog', { bubbles: true, composed: true }))}>
|
|
254
|
+
<md-icon>close</md-icon>
|
|
255
|
+
</button>
|
|
256
|
+
</header>
|
|
257
|
+
<div body>
|
|
258
|
+
<nav>
|
|
259
|
+
<button category ?on=${!this.category} @click=${() => (this.category = '')}>
|
|
260
|
+
<span>${i18next.t('figure.label.recipe-all')}</span><small>${this.templates.length}</small>
|
|
261
|
+
</button>
|
|
262
|
+
${categories.map(
|
|
263
|
+
c => html`<button category=${c} ?on=${this.category === c} @click=${() => (this.category = c)}>
|
|
264
|
+
<span>${i18next.t('figure.label.recipe-category-' + c)}</span><small>${this.templates.filter(t => t.category === c).length}</small>
|
|
265
|
+
</button>`
|
|
266
|
+
)}
|
|
267
|
+
</nav>
|
|
268
|
+
<section list>
|
|
269
|
+
${shown.map(
|
|
270
|
+
t => html`<button card=${t.name} ?on=${t.name === chosen?.name} @click=${() => this.choose(t)}>
|
|
271
|
+
${t.hasThumbnail && this.thumbnailOf ? html`<img alt="" loading="lazy" src=${this.thumbnailOf(t.name)} />` : nothing}
|
|
272
|
+
<b>${t.title}</b>
|
|
273
|
+
<small>${t.description}</small>
|
|
274
|
+
</button>`
|
|
275
|
+
)}
|
|
276
|
+
</section>
|
|
277
|
+
<section detail>${chosen ? this.renderDetail(chosen, language) : nothing}</section>
|
|
278
|
+
</div>
|
|
279
|
+
</div>`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private choose(t: RecipeTemplate) {
|
|
283
|
+
if (this.chosen === t.name) return
|
|
284
|
+
this.chosen = t.name
|
|
285
|
+
this.values = Object.fromEntries(t.recipe.params.map(p => [p.name, p.default]))
|
|
286
|
+
this.guide = ''
|
|
287
|
+
this.guideOf?.(t.name)
|
|
288
|
+
.then(text => this.chosen === t.name && (this.guide = text))
|
|
289
|
+
.catch(() => {})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
protected willUpdate() {
|
|
293
|
+
/* The first card is chosen while nothing is, so the detail is never empty. */
|
|
294
|
+
const shown = recipesMatching(this.templates, this.category, this.query)
|
|
295
|
+
if (!this.templates.some(t => t.name === this.chosen) && shown[0]) this.choose(shown[0])
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private renderDetail(t: RecipeTemplate, language: string) {
|
|
299
|
+
const d = describeV3Recipe(t.recipe)
|
|
300
|
+
const count = d.parts.min === d.parts.max ? String(d.parts.min) : `${d.parts.min}–${d.parts.max}`
|
|
301
|
+
const ready = recipeReady(t.recipe, this.selected)
|
|
302
|
+
return html`
|
|
303
|
+
<h3>${t.title}</h3>
|
|
304
|
+
<p>${t.description}</p>
|
|
305
|
+
<div>
|
|
306
|
+
<h3>${i18next.t('figure.label.recipe-adds')}</h3>
|
|
307
|
+
<ul>
|
|
308
|
+
<li parts=${count}>${i18next.t("figure.text.recipe-parts", { parts: count })}</li>
|
|
309
|
+
${recipeWorkings(t.recipe).map(line => html`<li>${i18next.t(line.key, line.words)}</li>`)}
|
|
310
|
+
<li>${i18next.t(t.recipe.target === 'part' ? 'figure.text.recipe-onto-chosen' : 'figure.text.recipe-onto-figure')}</li>
|
|
311
|
+
</ul>
|
|
312
|
+
</div>
|
|
313
|
+
${t.recipe.params.length ? this.renderForm(t.recipe, language) : nothing}
|
|
314
|
+
${this.error ? html`<p role="alert">${this.error}</p>` : nothing}
|
|
315
|
+
${ready ? nothing : html`<p muted>${i18next.t('figure.text.recipe-needs-a-part')}</p>`}
|
|
316
|
+
<button insert ?disabled=${this.locked || !ready} @click=${() => this.dispatchEvent(new CustomEvent('insert-recipe', { detail: { recipe: t.recipe, values: this.values }, bubbles: true, composed: true }))}>
|
|
317
|
+
<md-icon>add</md-icon>${this.selected && t.recipe.target === 'part' ? i18next.t('figure.button.insert-recipe-onto', { part: this.selected }) : i18next.t('figure.button.insert-recipe')}
|
|
318
|
+
</button>
|
|
319
|
+
${this.guide ? html`<ox-markdown .src=${this.guide}></ox-markdown>` : nothing}
|
|
320
|
+
`
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Each value: its name, what it does, and the field to set it -- a share typed as a percentage. */
|
|
324
|
+
private renderForm(recipe: V3Recipe, language: string) {
|
|
325
|
+
const set = (name: string, value: V3RecipeValue) => (this.values = { ...this.values, [name]: value })
|
|
326
|
+
return html`<div form>
|
|
327
|
+
<h3>${i18next.t('figure.label.recipe-values')}</h3>
|
|
328
|
+
${recipe.params.map(param => {
|
|
329
|
+
const label = recipeWord(param.label, language) || param.name
|
|
330
|
+
const hint = recipeWord(param.hint, language)
|
|
331
|
+
const value = this.values[param.name] ?? param.default
|
|
332
|
+
const help = hint ? html`<small>${hint}</small>` : nothing
|
|
333
|
+
if (param.kind === 'flag')
|
|
334
|
+
return html`<label toggle><input type="checkbox" param=${param.name} .checked=${value === true} @change=${(e: Event) => set(param.name, (e.target as HTMLInputElement).checked)} />${label}</label>${help}`
|
|
335
|
+
if (param.kind === 'choice' || param.kind === 'face')
|
|
336
|
+
return html`<label><span>${label}</span>
|
|
337
|
+
<select param=${param.name} @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLSelectElement).value))}>
|
|
338
|
+
${(param.options ?? []).map(o => html`<option value=${String(o)} ?selected=${o === value}>${String(o)}</option>`)}
|
|
339
|
+
</select></label
|
|
340
|
+
>${help}`
|
|
341
|
+
const range = param.kind === 'ratio' ? `${recipeFieldOf(param, param.min ?? 0)}–${recipeFieldOf(param, param.max ?? 1)} %` : `${param.min ?? ''}–${param.max ?? ''} mm`
|
|
342
|
+
return html`<label><span>${label} (${range})</span>
|
|
343
|
+
<input type="number" step="any" param=${param.name} .value=${recipeFieldOf(param, value)} @change=${(e: Event) => set(param.name, recipeValueOf(param, (e.target as HTMLInputElement).value))} /></label
|
|
344
|
+
>${help}`
|
|
345
|
+
})}
|
|
346
|
+
</div>`
|
|
347
|
+
}
|
|
348
|
+
}
|