@quoin-cms/admin 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +5 -1
  2. package/src/AdminRoot.svelte +5 -1
  3. package/src/lib/api/auth.ts +3 -6
  4. package/src/lib/api/files.ts +3 -2
  5. package/src/lib/components/AdminSidebar.svelte +23 -2
  6. package/src/lib/components/DocumentEditLayout.svelte +17 -0
  7. package/src/lib/components/DynamicForm.svelte +3 -3
  8. package/src/lib/components/MediaLibrary.svelte +55 -7
  9. package/src/lib/components/UploadCreateView.svelte +173 -0
  10. package/src/lib/components/doc/ApiView.svelte +95 -103
  11. package/src/lib/components/doc/RenderJson.svelte +93 -0
  12. package/src/lib/components/fields/RichTextField.svelte +5 -0
  13. package/src/lib/components/fields/UploadField.svelte +26 -34
  14. package/src/lib/components/fields/UploadGalleryField.svelte +28 -37
  15. package/src/lib/components/lexical/BlockField.svelte +41 -0
  16. package/src/lib/components/lexical/BlockHost.svelte +85 -0
  17. package/src/lib/components/lexical/BlockNode.ts +102 -0
  18. package/src/lib/components/lexical/block-defaults.ts +40 -0
  19. package/src/lib/components/lexical/lexical-helpers.ts +3 -0
  20. package/src/lib/components/lexical/nodes.ts +2 -0
  21. package/src/lib/components/lexical/toolbar/InsertBlockDropdown.svelte +27 -2
  22. package/src/lib/context.svelte.ts +1 -0
  23. package/src/lib/types/schema.ts +5 -0
  24. package/src/views/CollectionNewView.svelte +3 -0
  25. package/src/views/LoginView.svelte +47 -23
  26. package/biome.json +0 -62
  27. package/dist/assets/index-BaOy5Of3.js +0 -32
  28. package/dist/assets/index-DINUk481.css +0 -1
  29. package/dist/index.html +0 -20
  30. package/tsconfig.json +0 -25
  31. package/vite.config.ts +0 -80
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte'
3
+ import { getContext } from 'svelte'
4
+ import { getEditor } from 'svelte-lexical'
5
+ import { getQuoinContext, loadComponent } from '$lib/context.svelte.js'
6
+ import { getNodeByKey, isBlockNode } from './lexical-helpers.js'
7
+ import BlockField from './BlockField.svelte'
8
+ import type { BlockDef } from './block-defaults.js'
9
+
10
+ let {
11
+ blockType = '',
12
+ data = {},
13
+ nodeKey = '',
14
+ }: { blockType?: string; data?: Record<string, unknown>; nodeKey?: string } = $props()
15
+
16
+ const editor = getEditor()
17
+ const ctx = getQuoinContext()
18
+ const blocks = getContext<BlockDef[]>('quoin.lexical.blocks') ?? []
19
+
20
+ const def = $derived(blocks.find((b) => b.slug === blockType))
21
+ const customPath = $derived(def ? ctx.config.editorBlocks?.[blockType] : undefined)
22
+
23
+ // svelte-ignore state_referenced_locally
24
+ let model = $state<Record<string, unknown>>(structuredClone($state.snapshot(data)) ?? {})
25
+
26
+ function commit(snapshot: Record<string, unknown>): void {
27
+ editor.update(() => {
28
+ const node = getNodeByKey(nodeKey)
29
+ if (isBlockNode(node)) node.setData(snapshot)
30
+ })
31
+ }
32
+
33
+ let primed = false
34
+ $effect(() => {
35
+ const snap = $state.snapshot(model)
36
+ if (!primed) {
37
+ primed = true
38
+ return
39
+ }
40
+ const id = setTimeout(() => commit(snap), 250)
41
+ return () => clearTimeout(id)
42
+ })
43
+
44
+ let CustomComp = $state<Component<any> | null>(null)
45
+ let customError = $state(false)
46
+ $effect(() => {
47
+ if (customPath) {
48
+ customError = false
49
+ loadComponent(customPath, ctx.importMap)
50
+ .then((c) => (CustomComp = c))
51
+ .catch(() => {
52
+ CustomComp = null
53
+ customError = true
54
+ })
55
+ }
56
+ })
57
+
58
+ function setData(next: Record<string, unknown>): void {
59
+ model = next
60
+ }
61
+ </script>
62
+
63
+ <div class="my-3 rounded-md border bg-card p-3">
64
+ {#if !def}
65
+ <p class="text-sm text-destructive">Unknown block: {blockType}</p>
66
+ {:else}
67
+ <div class="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
68
+ {def.label}
69
+ </div>
70
+ {#if customPath}
71
+ {#if CustomComp}
72
+ {@const C = CustomComp}
73
+ <C schema={def.fields} data={model} {setData} {BlockField} />
74
+ {:else if customError}
75
+ <p class="text-sm text-destructive">Failed to load editor component for block "{blockType}".</p>
76
+ {/if}
77
+ {:else}
78
+ <div class="space-y-2">
79
+ {#each def.fields as f (f.name)}
80
+ <BlockField field={f} bind:data={model} />
81
+ {/each}
82
+ </div>
83
+ {/if}
84
+ {/if}
85
+ </div>
@@ -0,0 +1,102 @@
1
+ import { DecoratorNode } from 'lexical';
2
+ import type {
3
+ EditorConfig,
4
+ LexicalEditor,
5
+ LexicalNode,
6
+ NodeKey,
7
+ SerializedLexicalNode,
8
+ Spread,
9
+ } from 'lexical';
10
+ import BlockHost from './BlockHost.svelte';
11
+
12
+ export type SerializedBlockNode = Spread<
13
+ {
14
+ type: 'block';
15
+ version: 1;
16
+ blockType: string;
17
+ data: Record<string, unknown>;
18
+ },
19
+ SerializedLexicalNode
20
+ >;
21
+
22
+ /**
23
+ * BlockNode is the single generic decorator node for all custom blocks.
24
+ * __blockType holds the block's slug; the block schema + optional editor
25
+ * component are resolved at render time by BlockHost.
26
+ */
27
+ export class BlockNode extends DecoratorNode<unknown> {
28
+ __blockType: string;
29
+ __data: Record<string, unknown>;
30
+
31
+ static getType(): string {
32
+ return 'block';
33
+ }
34
+
35
+ static clone(node: BlockNode): BlockNode {
36
+ return new BlockNode(node.__blockType, node.__data, node.__key);
37
+ }
38
+
39
+ constructor(blockType: string, data: Record<string, unknown>, key?: NodeKey) {
40
+ super(key);
41
+ this.__blockType = blockType;
42
+ this.__data = data ?? {};
43
+ }
44
+
45
+ createDOM(_config: EditorConfig): HTMLElement {
46
+ const div = document.createElement('div');
47
+ div.className = 'lexical-block';
48
+ div.setAttribute('contenteditable', 'false');
49
+ return div;
50
+ }
51
+
52
+ updateDOM(): boolean {
53
+ return false;
54
+ }
55
+
56
+ static importJSON(json: SerializedBlockNode): BlockNode {
57
+ return new BlockNode(json.blockType, json.data ?? {});
58
+ }
59
+
60
+ exportJSON(): SerializedBlockNode {
61
+ return {
62
+ ...super.exportJSON(),
63
+ type: 'block',
64
+ version: 1,
65
+ blockType: this.__blockType,
66
+ data: this.__data,
67
+ };
68
+ }
69
+
70
+ decorate(_editor: LexicalEditor, _config: EditorConfig) {
71
+ return {
72
+ componentClass: BlockHost,
73
+ updateProps: (props: Record<string, unknown>) => {
74
+ props.blockType = this.__blockType;
75
+ props.data = this.__data;
76
+ props.nodeKey = this.__key;
77
+ },
78
+ };
79
+ }
80
+
81
+ isInline(): boolean {
82
+ return false;
83
+ }
84
+
85
+ getData(): Record<string, unknown> {
86
+ return this.__data;
87
+ }
88
+
89
+ setData(data: Record<string, unknown>): this {
90
+ const writable = this.getWritable();
91
+ writable.__data = data;
92
+ return writable;
93
+ }
94
+ }
95
+
96
+ export function $createBlockNode(blockType: string, data: Record<string, unknown> = {}): BlockNode {
97
+ return new BlockNode(blockType, data);
98
+ }
99
+
100
+ export function $isBlockNode(node: LexicalNode | null | undefined): node is BlockNode {
101
+ return node instanceof BlockNode;
102
+ }
@@ -0,0 +1,40 @@
1
+ import type { FieldSchema } from '$lib/types/schema.js'
2
+
3
+ export interface BlockDef {
4
+ slug: string
5
+ label: string
6
+ icon?: string
7
+ fields: FieldSchema[]
8
+ }
9
+
10
+ /**
11
+ * Build an empty data object for a block from its field schema. Each field
12
+ * gets a type-appropriate zero value (or its declared defaultValue). Used when
13
+ * inserting a fresh block node.
14
+ */
15
+ export function defaultBlockData(fields: FieldSchema[]): Record<string, unknown> {
16
+ const out: Record<string, unknown> = {}
17
+ for (const f of fields) {
18
+ const dv = (f as { defaultValue?: unknown }).defaultValue
19
+ if (dv !== undefined) {
20
+ out[f.name] = dv
21
+ continue
22
+ }
23
+ switch (f.type as string) {
24
+ case 'checkbox':
25
+ out[f.name] = false
26
+ break
27
+ case 'number':
28
+ out[f.name] = null
29
+ break
30
+ case 'array':
31
+ case 'blocks':
32
+ case 'tags':
33
+ out[f.name] = []
34
+ break
35
+ default:
36
+ out[f.name] = ''
37
+ }
38
+ }
39
+ return out
40
+ }
@@ -15,6 +15,9 @@ export { $isCustomHTMLNode as isCustomHTMLNode } from './CustomHTMLNode.js';
15
15
  export { $createPullQuoteNode as createPullQuoteNode } from './PullQuoteNode.js';
16
16
  export { $createCustomHTMLNode as createCustomHTMLNode } from './CustomHTMLNode.js';
17
17
 
18
+ export { $isBlockNode as isBlockNode } from './BlockNode.js';
19
+ export { $createBlockNode as createBlockNode } from './BlockNode.js';
20
+
18
21
  export { $createYouTubeNode as createYouTubeNode } from 'svelte-lexical';
19
22
  export { $createImageNode as createImageNode } from 'svelte-lexical';
20
23
 
@@ -1,8 +1,10 @@
1
1
  import type { Klass, LexicalNode } from 'lexical';
2
2
  import { PullQuoteNode } from './PullQuoteNode.js';
3
3
  import { CustomHTMLNode } from './CustomHTMLNode.js';
4
+ import { BlockNode } from './BlockNode.js';
4
5
 
5
6
  export const customNodes: Array<Klass<LexicalNode>> = [
6
7
  PullQuoteNode,
7
8
  CustomHTMLNode,
9
+ BlockNode,
8
10
  ];
@@ -1,11 +1,26 @@
1
1
  <script lang="ts">
2
+ import { getContext } from 'svelte';
2
3
  import { getEditor } from 'svelte-lexical';
3
- import { insertNodes, createParagraphNode, createPullQuoteNode, createCustomHTMLNode, createYouTubeNode, createImageNode } from '../lexical-helpers.js';
4
- import { Plus, Youtube, Quote, Code2, Image as ImageIcon } from 'lucide-svelte';
4
+ import { insertNodes, createParagraphNode, createPullQuoteNode, createCustomHTMLNode, createYouTubeNode, createImageNode, createBlockNode } from '../lexical-helpers.js';
5
+ import { defaultBlockData } from '../block-defaults.js';
6
+ import type { BlockDef } from '../block-defaults.js';
7
+ import { Plus, Youtube, Quote, Code2, Image as ImageIcon, Blocks as BlocksIcon } from 'lucide-svelte';
5
8
  import MediaLibrary from '../../MediaLibrary.svelte';
6
9
 
7
10
  const editor = getEditor();
8
11
 
12
+ const customBlocks = getContext<BlockDef[]>('quoin.lexical.blocks') ?? [];
13
+
14
+ function insertBlock(b: BlockDef) {
15
+ editor.update(() => {
16
+ const node = createBlockNode(b.slug, defaultBlockData(b.fields));
17
+ insertNodes([node]);
18
+ const paragraph = createParagraphNode();
19
+ node.insertAfter(paragraph);
20
+ });
21
+ closeDropdown();
22
+ }
23
+
9
24
  let isOpen = $state(false);
10
25
  let showYoutubeDialog = $state(false);
11
26
  let showImageDialog = $state(false);
@@ -188,6 +203,16 @@ function handleImageKeydown(e: KeyboardEvent) {
188
203
  <Code2 class="h-4 w-4" />
189
204
  Custom HTML
190
205
  </button>
206
+ {#each customBlocks as b (b.slug)}
207
+ <button
208
+ type="button"
209
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
210
+ onclick={() => insertBlock(b)}
211
+ >
212
+ <BlocksIcon class="h-4 w-4" />
213
+ {b.label}
214
+ </button>
215
+ {/each}
191
216
  </div>
192
217
  {/if}
193
218
  </div>
@@ -42,6 +42,7 @@ export interface ResolvedQuoinConfig {
42
42
  slots: Record<string, string>
43
43
  views: Record<string, string>
44
44
  pages: Record<string, PageEntry>
45
+ editorBlocks: Record<string, string>
45
46
  }
46
47
 
47
48
  /** importMap entries are thunked dynamic imports. */
@@ -25,6 +25,8 @@ export interface FieldSchema {
25
25
  label: string
26
26
  required: boolean
27
27
  hidden?: boolean
28
+ /** Hides the field from admin edit/list views (API still returns it). */
29
+ adminHidden?: boolean
28
30
  /** Sub-variant for text fields (e.g. "password"). Phase 21 D-06.
29
31
  * Emitted by field.Text.ToJSON() when Text.Type != "". */
30
32
  variant?: string
@@ -93,6 +95,9 @@ export interface CollectionSchema {
93
95
  fields: FieldSchema[]
94
96
  admin?: AdminConfig
95
97
  versions?: VersionsSchema
98
+ /** Present when the collection is an upload collection (col.Upload != nil).
99
+ * Signals the admin to render a file dropzone on create/edit. */
100
+ upload?: { accept?: string[]; imageVariants?: boolean }
96
101
  }
97
102
 
98
103
  export interface GlobalSchema {
@@ -5,6 +5,7 @@ import { resolve } from '$lib/router/index.svelte.js'
5
5
  import { schema, getCollectionByKey } from '$lib/stores/schema.svelte.js'
6
6
  import { createRecord } from '$lib/api/records.js'
7
7
  import DocumentEditLayout from '$lib/components/DocumentEditLayout.svelte'
8
+ import UploadCreateView from '$lib/components/UploadCreateView.svelte'
8
9
  import { toast } from 'svelte-sonner'
9
10
  import { onMount } from 'svelte'
10
11
 
@@ -57,6 +58,8 @@ async function handleSave(data: Record<string, any>) {
57
58
  <p class="text-sm text-muted-foreground">Creating draft...</p>
58
59
  </div>
59
60
  </div>
61
+ {:else if collection.upload}
62
+ <UploadCreateView {collection} />
60
63
  {:else}
61
64
  <DocumentEditLayout
62
65
  {collection}
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { goto } from '$lib/router/index.svelte.js'
3
3
  import { resolve } from '$lib/router/index.svelte.js'
4
- import { login, register, getMe, getSetupStatus } from '$lib/api/auth.js'
4
+ import { login, register, getMe } from '$lib/api/auth.js'
5
5
  import { toast } from 'svelte-sonner'
6
6
  import { onMount } from 'svelte'
7
7
  import { BookOpen } from 'lucide-svelte'
@@ -9,9 +9,10 @@ import { branding, loadBranding } from '$lib/stores/branding.svelte.js'
9
9
  import Slot from '$lib/Slot.svelte'
10
10
 
11
11
  let email = $state('')
12
+ let name = $state('')
12
13
  let password = $state('')
13
14
  let confirmPassword = $state('')
14
- let isSetup = $state(false)
15
+ let isCreatingAccount = $state(false)
15
16
  let isLoading = $state(false)
16
17
  let isCheckingAuth = $state(true)
17
18
 
@@ -22,21 +23,19 @@ onMount(async () => {
22
23
  await goto(resolve('/'))
23
24
  return
24
25
  }
25
- const status = await getSetupStatus()
26
- if (status.ok && status.data.needsSetup) {
27
- isSetup = true
28
- }
29
26
  isCheckingAuth = false
30
27
  })
31
28
 
32
29
  async function handleLogin() {
33
- if (!email || !password) {
30
+ const trimmedEmail = email.trim()
31
+ const trimmedPassword = password.trim()
32
+ if (!trimmedEmail || !trimmedPassword) {
34
33
  toast.error('Please enter email and password')
35
34
  return
36
35
  }
37
36
 
38
37
  isLoading = true
39
- const result = await login(email, password)
38
+ const result = await login(trimmedEmail, trimmedPassword)
40
39
  isLoading = false
41
40
 
42
41
  if (result.ok) {
@@ -50,32 +49,41 @@ async function handleLogin() {
50
49
  }
51
50
  }
52
51
 
53
- async function handleSetup() {
54
- if (!email || !password) {
52
+ async function handleCreateAccount() {
53
+ const trimmedName = name.trim()
54
+ const trimmedEmail = email.trim()
55
+ const trimmedPassword = password.trim()
56
+ const trimmedConfirmPassword = confirmPassword.trim()
57
+
58
+ if (!trimmedName) {
59
+ toast.error('Please enter your name')
60
+ return
61
+ }
62
+ if (!trimmedEmail || !trimmedPassword) {
55
63
  toast.error('Please enter email and password')
56
64
  return
57
65
  }
58
- if (password !== confirmPassword) {
66
+ if (trimmedPassword !== trimmedConfirmPassword) {
59
67
  toast.error('Passwords do not match')
60
68
  return
61
69
  }
62
- if (password.length < 6) {
70
+ if (trimmedPassword.length < 6) {
63
71
  toast.error('Password must be at least 6 characters')
64
72
  return
65
73
  }
66
74
 
67
75
  isLoading = true
68
- const result = await register(email, password)
76
+ const result = await register(trimmedEmail, trimmedPassword, trimmedName)
69
77
  isLoading = false
70
78
 
71
79
  if (result.ok) {
72
80
  toast.success('Admin account created! Logging in...')
73
- const loginResult = await login(email, password)
81
+ const loginResult = await login(trimmedEmail, trimmedPassword)
74
82
  if (loginResult.ok) {
75
83
  await goto(resolve('/'))
76
84
  } else {
77
85
  toast.error('Account created but login failed. Please try logging in.')
78
- isSetup = false
86
+ isCreatingAccount = false
79
87
  }
80
88
  } else {
81
89
  toast.error(result.error)
@@ -83,7 +91,7 @@ async function handleSetup() {
83
91
  }
84
92
 
85
93
  function toggleMode() {
86
- isSetup = !isSetup
94
+ isCreatingAccount = !isCreatingAccount
87
95
  confirmPassword = ''
88
96
  }
89
97
  </script>
@@ -149,20 +157,36 @@ function toggleMode() {
149
157
 
150
158
  <div class="mb-8">
151
159
  <h2 class="text-xl font-semibold tracking-tight" style="font-family: var(--font-display);">
152
- {isSetup ? 'Create your account' : 'Sign in'}
160
+ {isCreatingAccount ? 'Create your account' : 'Sign in'}
153
161
  </h2>
154
162
  <p class="mt-1.5 text-sm text-muted-foreground">
155
- {isSetup ? 'Set up the first admin account to get started.' : 'Enter your credentials to continue.'}
163
+ {isCreatingAccount ? 'Create the first admin account to get started.' : 'Enter your credentials to continue.'}
156
164
  </p>
157
165
  </div>
158
166
 
159
167
  <form
160
168
  onsubmit={(e) => {
161
169
  e.preventDefault();
162
- isSetup ? handleSetup() : handleLogin();
170
+ isCreatingAccount ? handleCreateAccount() : handleLogin();
163
171
  }}
164
172
  class="space-y-5"
165
173
  >
174
+ {#if isCreatingAccount}
175
+ <div>
176
+ <label for="name" class="mb-2 block text-[13px] font-medium text-foreground">
177
+ Name
178
+ </label>
179
+ <input
180
+ id="name"
181
+ type="text"
182
+ bind:value={name}
183
+ class="h-11 w-full rounded-lg border border-border bg-card px-3.5 text-sm shadow-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"
184
+ placeholder="Your name"
185
+ autocomplete="name"
186
+ />
187
+ </div>
188
+ {/if}
189
+
166
190
  <div>
167
191
  <label for="email" class="mb-2 block text-[13px] font-medium text-foreground">
168
192
  Email
@@ -187,11 +211,11 @@ function toggleMode() {
187
211
  bind:value={password}
188
212
  class="h-11 w-full rounded-lg border border-border bg-card px-3.5 text-sm shadow-sm transition-all placeholder:text-muted-foreground/50 focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/10"
189
213
  placeholder="Enter password"
190
- autocomplete={isSetup ? 'new-password' : 'current-password'}
214
+ autocomplete={isCreatingAccount ? 'new-password' : 'current-password'}
191
215
  />
192
216
  </div>
193
217
 
194
- {#if isSetup}
218
+ {#if isCreatingAccount}
195
219
  <div>
196
220
  <label for="confirmPassword" class="mb-2 block text-[13px] font-medium text-foreground">
197
221
  Confirm Password
@@ -212,7 +236,7 @@ function toggleMode() {
212
236
  disabled={isLoading}
213
237
  class="h-11 w-full rounded-lg bg-primary text-sm font-medium text-primary-foreground shadow-sm transition-all hover:bg-primary/90 hover:shadow-md active:scale-[0.99] disabled:opacity-50 disabled:shadow-none"
214
238
  >
215
- {isLoading ? 'Please wait...' : isSetup ? 'Create Account' : 'Sign In'}
239
+ {isLoading ? 'Please wait...' : isCreatingAccount ? 'Create Account' : 'Sign In'}
216
240
  </button>
217
241
  </form>
218
242
 
@@ -222,7 +246,7 @@ function toggleMode() {
222
246
  onclick={toggleMode}
223
247
  class="text-[13px] text-muted-foreground transition-colors hover:text-primary"
224
248
  >
225
- {isSetup ? 'Already have an account? Sign in' : 'First time? Create admin account'}
249
+ {isCreatingAccount ? 'Already have an account? Sign in' : 'First time? Create admin account'}
226
250
  </button>
227
251
  </div>
228
252
  </div>
package/biome.json DELETED
@@ -1,62 +0,0 @@
1
- {
2
- "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
3
- "vcs": {
4
- "enabled": true,
5
- "clientKind": "git",
6
- "useIgnoreFile": false,
7
- "defaultBranch": "main"
8
- },
9
- "files": {
10
- "ignoreUnknown": false
11
- },
12
- "formatter": {
13
- "enabled": true,
14
- "indentStyle": "tab",
15
- "lineWidth": 100
16
- },
17
- "linter": {
18
- "enabled": true,
19
- "rules": {
20
- "recommended": true,
21
- "suspicious": {
22
- "noExplicitAny": "off"
23
- },
24
- "style": {
25
- "noNonNullAssertion": "off"
26
- },
27
- "correctness": {
28
- "noUnusedVariables": "warn"
29
- }
30
- }
31
- },
32
- "javascript": {
33
- "formatter": {
34
- "quoteStyle": "single",
35
- "semicolons": "asNeeded",
36
- "trailingCommas": "es5",
37
- "arrowParentheses": "always"
38
- }
39
- },
40
- "css": {
41
- "parser": {
42
- "cssModules": false,
43
- "tailwindDirectives": true
44
- },
45
- "linter": {
46
- "enabled": true
47
- }
48
- },
49
- "overrides": [
50
- {
51
- "includes": ["**/*.svelte"],
52
- "linter": {
53
- "rules": {
54
- "correctness": {
55
- "noUnusedVariables": "off",
56
- "noUnusedImports": "off"
57
- }
58
- }
59
- }
60
- }
61
- ]
62
- }