fontastic 1.0.0 → 1.0.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.
- package/.github/workflows/macos.yml +0 -7
- package/.github/workflows/ubuntu.yml +0 -7
- package/.github/workflows/windows.yml +0 -7
- package/README.md +1 -0
- package/app/core/ConnectionManager.js +7 -1
- package/app/core/ConnectionManager.ts +11 -3
- package/app/core/MessageHandler.js +24 -0
- package/app/core/MessageHandler.ts +29 -0
- package/app/core/menu/templates/DarwinTemplate.js +15 -0
- package/app/core/menu/templates/DarwinTemplate.ts +15 -0
- package/app/core/menu/templates/SystemTemplate.js +15 -0
- package/app/core/menu/templates/SystemTemplate.ts +15 -0
- package/app/database/entity/SmartCollection.schema.js +66 -0
- package/app/database/entity/SmartCollection.schema.ts +39 -0
- package/app/database/entity/index.js +1 -0
- package/app/database/entity/index.ts +2 -1
- package/app/database/repository/SmartCollection.repository.js +47 -0
- package/app/database/repository/SmartCollection.repository.ts +30 -0
- package/app/database/repository/Store.repository.js +107 -0
- package/app/database/repository/Store.repository.ts +106 -0
- package/app/database/repository/index.js +1 -0
- package/app/database/repository/index.ts +2 -1
- package/app/enums/ChannelType.js +5 -0
- package/app/enums/ChannelType.ts +6 -0
- package/app/enums/StorageType.js +1 -0
- package/app/enums/StorageType.ts +1 -0
- package/app/package.json +1 -1
- package/app/types/FontMetrics.js +3 -0
- package/app/types/SmartCollection.js +3 -0
- package/app/types/SmartCollection.ts +5 -0
- package/app/types/index.js +2 -0
- package/app/types/index.ts +1 -0
- package/package.json +1 -1
- package/src/app/core/services/database/database.service.ts +70 -1
- package/src/app/core/services/message/message.service.ts +23 -0
- package/src/app/core/services/presentation/presentation.service.ts +47 -0
- package/src/app/layout/header/header.component.html +1 -1
- package/src/app/layout/layout.component.html +1 -1
- package/src/app/layout/navigation/navigation.component.html +70 -4
- package/src/app/layout/navigation/navigation.component.ts +129 -25
- package/src/app/shared/components/datagrid/datagrid.component.html +82 -2
- package/src/app/shared/components/index.ts +1 -0
- package/src/app/shared/components/rule-builder/rule-builder.component.html +94 -0
- package/src/app/shared/components/rule-builder/rule-builder.component.ts +136 -0
- package/src/app/shared/components/toolbar/toolbar.component.html +0 -81
- package/src/app/shared/components/toolbar/toolbar.component.ts +1 -2
- package/src/styles/base/variables.css +16 -0
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { Component, inject, computed } from '@angular/core';
|
|
1
|
+
import { Component, inject, computed, OnInit, OnDestroy } from '@angular/core';
|
|
2
2
|
import { CommonModule } from '@angular/common';
|
|
3
3
|
import { FormsModule } from '@angular/forms';
|
|
4
4
|
import { DatabaseService, MessageService, PresentationService } from '../../core/services';
|
|
5
5
|
import { CollapsiblePanelComponent } from '../../shared/components/collapsible-panel/collapsible-panel.component';
|
|
6
6
|
import { ContextMenuComponent, ContextMenuItem } from '../../shared/components/context-menu/context-menu.component';
|
|
7
|
-
import { PromptDialogComponent } from '../../shared/components';
|
|
7
|
+
import { PromptDialogComponent, RuleBuilderComponent } from '../../shared/components';
|
|
8
8
|
import { AutofocusDirective } from '../../shared/directives/autofocus/autofocus.directive';
|
|
9
9
|
import { LibraryComponent } from './library/library.component';
|
|
10
10
|
import { NewsStatsComponent } from './stats/stats.component';
|
|
11
11
|
import type { Collection } from '@main/database/entity/Collection.schema';
|
|
12
|
+
import type { SmartCollection } from '@main/database/entity/SmartCollection.schema';
|
|
13
|
+
import type { SmartCollectionRule } from '@main/types';
|
|
14
|
+
import { ChannelType } from '@main/enums';
|
|
12
15
|
|
|
13
16
|
export interface TreeNode {
|
|
14
17
|
collection: Collection;
|
|
@@ -24,17 +27,34 @@ export interface TreeNode {
|
|
|
24
27
|
CollapsiblePanelComponent,
|
|
25
28
|
ContextMenuComponent,
|
|
26
29
|
PromptDialogComponent,
|
|
30
|
+
RuleBuilderComponent,
|
|
27
31
|
AutofocusDirective,
|
|
28
32
|
LibraryComponent,
|
|
29
33
|
NewsStatsComponent,
|
|
30
34
|
],
|
|
31
35
|
templateUrl: './navigation.component.html',
|
|
32
36
|
})
|
|
33
|
-
export class NavigationComponent {
|
|
37
|
+
export class NavigationComponent implements OnInit, OnDestroy {
|
|
34
38
|
readonly db = inject(DatabaseService);
|
|
35
39
|
private message = inject(MessageService);
|
|
36
40
|
private presentation = inject(PresentationService);
|
|
37
41
|
|
|
42
|
+
private menuToggleListener = (_event: any, panel: string) => {
|
|
43
|
+
if (panel === 'expand-collections') {
|
|
44
|
+
this.expandAll();
|
|
45
|
+
} else if (panel === 'collapse-collections') {
|
|
46
|
+
this.collapseAll();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
ngOnInit() {
|
|
51
|
+
this.message.on(ChannelType.IPC_TOGGLE_PANEL, this.menuToggleListener);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ngOnDestroy() {
|
|
55
|
+
this.message.removeListener(ChannelType.IPC_TOGGLE_PANEL, this.menuToggleListener);
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
readonly tree = computed<TreeNode[]>(() => {
|
|
39
59
|
const all = this.db.collections();
|
|
40
60
|
const buildChildren = (parentId: number): TreeNode[] =>
|
|
@@ -50,12 +70,17 @@ export class NavigationComponent {
|
|
|
50
70
|
contextMenu: { x: number; y: number; collection: Collection } | null = null;
|
|
51
71
|
editingId: number | null = null;
|
|
52
72
|
editingTitle = '';
|
|
53
|
-
expandedIds = new Set<number>();
|
|
54
73
|
allExpanded = false;
|
|
55
74
|
|
|
56
75
|
showCollectionDialog = false;
|
|
57
76
|
pendingParentId: number | null = null;
|
|
58
77
|
|
|
78
|
+
// Smart Collection state
|
|
79
|
+
showRuleBuilder = false;
|
|
80
|
+
editingSmartCollection: SmartCollection | null = null;
|
|
81
|
+
smartContextMenu: { x: number; y: number; smartCollection: SmartCollection } | null = null;
|
|
82
|
+
smartContextMenuItems: ContextMenuItem[] = [];
|
|
83
|
+
|
|
59
84
|
// Drag state
|
|
60
85
|
draggedNode: TreeNode | null = null;
|
|
61
86
|
dropTargetId: number | null = null;
|
|
@@ -187,7 +212,7 @@ export class NavigationComponent {
|
|
|
187
212
|
this.db.collections.set(updated);
|
|
188
213
|
|
|
189
214
|
if (newParentId !== 0) {
|
|
190
|
-
this.
|
|
215
|
+
this.presentation.expandNavigationId(newParentId);
|
|
191
216
|
}
|
|
192
217
|
|
|
193
218
|
try {
|
|
@@ -246,6 +271,11 @@ export class NavigationComponent {
|
|
|
246
271
|
}
|
|
247
272
|
}
|
|
248
273
|
|
|
274
|
+
openCreateRootCollection() {
|
|
275
|
+
this.pendingParentId = 0;
|
|
276
|
+
this.showCollectionDialog = true;
|
|
277
|
+
}
|
|
278
|
+
|
|
249
279
|
onCollectionConfirmed(name: string) {
|
|
250
280
|
this.db.collectionCreate({ title: name, parentId: this.pendingParentId });
|
|
251
281
|
this.showCollectionDialog = false;
|
|
@@ -302,32 +332,30 @@ export class NavigationComponent {
|
|
|
302
332
|
}
|
|
303
333
|
|
|
304
334
|
isExpanded(id: number): boolean {
|
|
305
|
-
return this.
|
|
335
|
+
return this.presentation.isNavigationExpanded(id);
|
|
306
336
|
}
|
|
307
337
|
|
|
308
338
|
toggleExpand(event: Event, id: number) {
|
|
309
339
|
event.stopPropagation();
|
|
310
|
-
|
|
311
|
-
this.expandedIds.delete(id);
|
|
312
|
-
} else {
|
|
313
|
-
this.expandedIds.add(id);
|
|
314
|
-
}
|
|
340
|
+
this.presentation.toggleNavigationExpanded(id);
|
|
315
341
|
}
|
|
316
342
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
343
|
+
expandAll() {
|
|
344
|
+
const allIds: number[] = [];
|
|
345
|
+
const collectIds = (nodes: TreeNode[]) => {
|
|
346
|
+
for (const node of nodes) {
|
|
347
|
+
allIds.push(node.collection.id);
|
|
348
|
+
collectIds(node.children);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
collectIds(this.tree());
|
|
352
|
+
this.presentation.setAllNavigationExpanded(allIds);
|
|
353
|
+
this.allExpanded = true;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
collapseAll() {
|
|
357
|
+
this.presentation.clearAllNavigationExpanded();
|
|
358
|
+
this.allExpanded = false;
|
|
331
359
|
}
|
|
332
360
|
|
|
333
361
|
isSelected(collection: Collection): boolean {
|
|
@@ -359,6 +387,82 @@ export class NavigationComponent {
|
|
|
359
387
|
this.editingTitle = '';
|
|
360
388
|
}
|
|
361
389
|
|
|
390
|
+
// Smart Collection methods
|
|
391
|
+
|
|
392
|
+
selectSmartCollection(sc: SmartCollection) {
|
|
393
|
+
this.db.selectSmartCollection(sc.id);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
isSmartSelected(sc: SmartCollection): boolean {
|
|
397
|
+
return this.db.activeSmartCollectionId() === sc.id;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
openCreateSmartCollection() {
|
|
401
|
+
this.editingSmartCollection = null;
|
|
402
|
+
this.showRuleBuilder = true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
editSmartCollection(sc: SmartCollection) {
|
|
406
|
+
this.editingSmartCollection = sc;
|
|
407
|
+
this.showRuleBuilder = true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
onSmartContextMenu(event: MouseEvent, sc: SmartCollection) {
|
|
411
|
+
event.preventDefault();
|
|
412
|
+
event.stopPropagation();
|
|
413
|
+
|
|
414
|
+
this.smartContextMenuItems = [
|
|
415
|
+
{ label: 'Edit Rules', action: 'edit-rules', icon: 'tune' },
|
|
416
|
+
{ label: 'Delete', action: 'delete' },
|
|
417
|
+
];
|
|
418
|
+
|
|
419
|
+
this.smartContextMenu = { x: event.clientX, y: event.clientY, smartCollection: sc };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
onSmartContextMenuSelect(action: string) {
|
|
423
|
+
if (!this.smartContextMenu) return;
|
|
424
|
+
const sc = this.smartContextMenu.smartCollection;
|
|
425
|
+
this.smartContextMenu = null;
|
|
426
|
+
|
|
427
|
+
switch (action) {
|
|
428
|
+
case 'edit-rules':
|
|
429
|
+
this.editingSmartCollection = sc;
|
|
430
|
+
this.showRuleBuilder = true;
|
|
431
|
+
break;
|
|
432
|
+
case 'delete':
|
|
433
|
+
this.db.smartCollectionDelete(sc.id);
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
closeSmartContextMenu() {
|
|
439
|
+
this.smartContextMenu = null;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
onRuleBuilderSaved(data: { title: string; rules: SmartCollectionRule[]; match_type: string }) {
|
|
443
|
+
const rulesJson = JSON.stringify(data.rules);
|
|
444
|
+
if (this.editingSmartCollection) {
|
|
445
|
+
this.db.smartCollectionUpdate(this.editingSmartCollection.id, {
|
|
446
|
+
title: data.title,
|
|
447
|
+
rules: rulesJson,
|
|
448
|
+
match_type: data.match_type,
|
|
449
|
+
});
|
|
450
|
+
} else {
|
|
451
|
+
this.db.smartCollectionCreate({
|
|
452
|
+
title: data.title,
|
|
453
|
+
rules: rulesJson,
|
|
454
|
+
match_type: data.match_type,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
this.showRuleBuilder = false;
|
|
458
|
+
this.editingSmartCollection = null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
onRuleBuilderCancelled() {
|
|
462
|
+
this.showRuleBuilder = false;
|
|
463
|
+
this.editingSmartCollection = null;
|
|
464
|
+
}
|
|
465
|
+
|
|
362
466
|
findRootParentId(collection: Collection): number {
|
|
363
467
|
const all = this.db.collections();
|
|
364
468
|
let current = collection;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
<div class="panel">
|
|
2
|
-
<div class="
|
|
1
|
+
<div class="panel grid" [style.grid-template-rows]="db.totalPages() > 1 ? '1fr 32px' : '1fr'">
|
|
2
|
+
<div class="overflow-hidden scrollbox-y">
|
|
3
3
|
<table class="w-full border-separate border-spacing-0">
|
|
4
4
|
<thead
|
|
5
5
|
class="sticky top-0 z-10"
|
|
@@ -93,4 +93,84 @@
|
|
|
93
93
|
</tbody>
|
|
94
94
|
</table>
|
|
95
95
|
</div>
|
|
96
|
+
@if (db.totalPages() > 1) {
|
|
97
|
+
<div
|
|
98
|
+
class="flex items-center gap-2 px-3"
|
|
99
|
+
[style.background-color]="'var(--surface-2)'"
|
|
100
|
+
[style.border-top]="'1px solid var(--border-subtle)'"
|
|
101
|
+
>
|
|
102
|
+
<div class="flex items-center gap-0.5 text-[11px]">
|
|
103
|
+
<button
|
|
104
|
+
class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
|
|
105
|
+
[style.color]="'var(--text-secondary)'"
|
|
106
|
+
[disabled]="db.currentPage() === 1"
|
|
107
|
+
[class.opacity-30]="db.currentPage() === 1"
|
|
108
|
+
(click)="db.firstPage()"
|
|
109
|
+
>
|
|
110
|
+
<span
|
|
111
|
+
class="material-symbols-outlined text-sm"
|
|
112
|
+
style="
|
|
113
|
+
font-variation-settings:
|
|
114
|
+
'opsz' 20,
|
|
115
|
+
'wght' 300;
|
|
116
|
+
"
|
|
117
|
+
>first_page</span
|
|
118
|
+
>
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
|
|
122
|
+
[style.color]="'var(--text-secondary)'"
|
|
123
|
+
[disabled]="db.currentPage() === 1"
|
|
124
|
+
[class.opacity-30]="db.currentPage() === 1"
|
|
125
|
+
(click)="db.prevPage()"
|
|
126
|
+
>
|
|
127
|
+
<span
|
|
128
|
+
class="material-symbols-outlined text-sm"
|
|
129
|
+
style="
|
|
130
|
+
font-variation-settings:
|
|
131
|
+
'opsz' 20,
|
|
132
|
+
'wght' 300;
|
|
133
|
+
"
|
|
134
|
+
>chevron_left</span
|
|
135
|
+
>
|
|
136
|
+
</button>
|
|
137
|
+
<span class="px-1.5 select-none" [style.color]="'var(--text-muted)'">{{ db.currentPage() }} / {{ db.totalPages() }}</span>
|
|
138
|
+
<button
|
|
139
|
+
class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
|
|
140
|
+
[style.color]="'var(--text-secondary)'"
|
|
141
|
+
[disabled]="db.currentPage() === db.totalPages()"
|
|
142
|
+
[class.opacity-30]="db.currentPage() === db.totalPages()"
|
|
143
|
+
(click)="db.nextPage()"
|
|
144
|
+
>
|
|
145
|
+
<span
|
|
146
|
+
class="material-symbols-outlined text-sm"
|
|
147
|
+
style="
|
|
148
|
+
font-variation-settings:
|
|
149
|
+
'opsz' 20,
|
|
150
|
+
'wght' 300;
|
|
151
|
+
"
|
|
152
|
+
>chevron_right</span
|
|
153
|
+
>
|
|
154
|
+
</button>
|
|
155
|
+
<button
|
|
156
|
+
class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
|
|
157
|
+
[style.color]="'var(--text-secondary)'"
|
|
158
|
+
[disabled]="db.currentPage() === db.totalPages()"
|
|
159
|
+
[class.opacity-30]="db.currentPage() === db.totalPages()"
|
|
160
|
+
(click)="db.lastPage()"
|
|
161
|
+
>
|
|
162
|
+
<span
|
|
163
|
+
class="material-symbols-outlined text-sm"
|
|
164
|
+
style="
|
|
165
|
+
font-variation-settings:
|
|
166
|
+
'opsz' 20,
|
|
167
|
+
'wght' 300;
|
|
168
|
+
"
|
|
169
|
+
>last_page</span
|
|
170
|
+
>
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
<span class="text-[11px]" [style.color]="'var(--text-muted)'"> {{ db.storeCount() }} fonts </span>
|
|
174
|
+
</div>
|
|
175
|
+
}
|
|
96
176
|
</div>
|
|
@@ -4,6 +4,7 @@ export * from './page-not-found/page-not-found.component';
|
|
|
4
4
|
export * from './panel/panel.component';
|
|
5
5
|
export * from './datagrid/datagrid.component';
|
|
6
6
|
export * from './prompt-dialog/prompt-dialog.component';
|
|
7
|
+
export * from './rule-builder/rule-builder.component';
|
|
7
8
|
export * from './toolbar/toolbar.component';
|
|
8
9
|
export * from './inspector/inspector.component';
|
|
9
10
|
export * from './search/search.component';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" (click)="cancel()">
|
|
2
|
+
<div class="card mb-0 w-[560px]" style="box-shadow: var(--context-shadow)" (click)="$event.stopPropagation()">
|
|
3
|
+
<div class="card-header">
|
|
4
|
+
<div class="card-title">
|
|
5
|
+
<h3>{{ smartCollection ? 'Edit Smart Collection' : 'New Smart Collection' }}</h3>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="card-tools"></div>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="card-body">
|
|
10
|
+
<div class="mb-4">
|
|
11
|
+
<label class="form-label" for="scTitle">Name</label>
|
|
12
|
+
<input
|
|
13
|
+
type="text"
|
|
14
|
+
class="form-input w-full"
|
|
15
|
+
id="scTitle"
|
|
16
|
+
placeholder="Smart Collection name"
|
|
17
|
+
[(ngModel)]="title"
|
|
18
|
+
(keyup.escape)="cancel()"
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="mb-4">
|
|
23
|
+
<label class="form-label">Match</label>
|
|
24
|
+
<select class="form-input" [(ngModel)]="matchType">
|
|
25
|
+
<option value="AND">All rules (AND)</option>
|
|
26
|
+
<option value="OR">Any rule (OR)</option>
|
|
27
|
+
</select>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="mb-2">
|
|
31
|
+
<label class="form-label">Rules</label>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="space-y-2 mb-4 max-h-[300px] overflow-y-auto">
|
|
35
|
+
@for (rule of rules; track $index; let i = $index) {
|
|
36
|
+
<div class="flex items-center gap-2">
|
|
37
|
+
<select class="form-input flex-1" [(ngModel)]="rule.field" (ngModelChange)="onFieldChange(rule)">
|
|
38
|
+
@for (f of fieldOptions; track f.value) {
|
|
39
|
+
<option [value]="f.value">{{ f.label }}</option>
|
|
40
|
+
}
|
|
41
|
+
</select>
|
|
42
|
+
|
|
43
|
+
<select class="form-input w-[120px]" [(ngModel)]="rule.operator">
|
|
44
|
+
@for (op of getOperators(rule.field); track op.value) {
|
|
45
|
+
<option [value]="op.value">{{ op.label }}</option>
|
|
46
|
+
}
|
|
47
|
+
</select>
|
|
48
|
+
|
|
49
|
+
@switch (getFieldType(rule.field)) {
|
|
50
|
+
@case ('enum') {
|
|
51
|
+
<select class="form-input flex-1" [(ngModel)]="rule.value">
|
|
52
|
+
@for (opt of getEnumOptions(rule.field); track opt.value) {
|
|
53
|
+
<option [value]="opt.value">{{ opt.label }}</option>
|
|
54
|
+
}
|
|
55
|
+
</select>
|
|
56
|
+
}
|
|
57
|
+
@case ('boolean') {
|
|
58
|
+
<select class="form-input flex-1" [(ngModel)]="rule.value">
|
|
59
|
+
<option [ngValue]="1">Yes</option>
|
|
60
|
+
<option [ngValue]="0">No</option>
|
|
61
|
+
</select>
|
|
62
|
+
}
|
|
63
|
+
@case ('numeric') {
|
|
64
|
+
<input type="number" class="form-input flex-1" [(ngModel)]="rule.value" placeholder="Value" />
|
|
65
|
+
}
|
|
66
|
+
@case ('date') {
|
|
67
|
+
<input type="date" class="form-input flex-1" [(ngModel)]="rule.value" />
|
|
68
|
+
}
|
|
69
|
+
@default {
|
|
70
|
+
<input type="text" class="form-input flex-1" [(ngModel)]="rule.value" placeholder="Value" />
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
<button class="btn btn-sm btn-default px-2" (click)="removeRule(i)" [disabled]="rules.length <= 1" title="Remove rule">
|
|
75
|
+
<span class="material-symbols-outlined" style="font-size: 16px">remove</span>
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<button class="btn btn-sm btn-default" (click)="addRule()">
|
|
82
|
+
<span class="material-symbols-outlined" style="font-size: 16px; vertical-align: middle">add</span>
|
|
83
|
+
Add Rule
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="card-footer">
|
|
87
|
+
<div></div>
|
|
88
|
+
<div class="card-tools">
|
|
89
|
+
<button class="btn btn-sm btn-default" (click)="cancel()">Cancel</button>
|
|
90
|
+
<button class="btn btn-sm btn-theme" (click)="save()">Save</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
|
2
|
+
import { FormsModule } from '@angular/forms';
|
|
3
|
+
import type { SmartCollection } from '@main/database/entity/SmartCollection.schema';
|
|
4
|
+
import type { SmartCollectionRule } from '@main/types';
|
|
5
|
+
|
|
6
|
+
interface FieldOption {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
type: 'text' | 'boolean' | 'numeric' | 'enum' | 'date';
|
|
10
|
+
options?: { value: string; label: string }[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const FIELD_OPTIONS: FieldOption[] = [
|
|
14
|
+
{ value: 'font_family', label: 'Font Family', type: 'text' },
|
|
15
|
+
{ value: 'font_subfamily', label: 'Font Subfamily', type: 'text' },
|
|
16
|
+
{ value: 'full_name', label: 'Full Name', type: 'text' },
|
|
17
|
+
{ value: 'designer', label: 'Designer', type: 'text' },
|
|
18
|
+
{ value: 'manufacturer', label: 'Manufacturer', type: 'text' },
|
|
19
|
+
{ value: 'copyright', label: 'Copyright', type: 'text' },
|
|
20
|
+
{ value: 'license', label: 'License', type: 'text' },
|
|
21
|
+
{ value: 'version', label: 'Version', type: 'text' },
|
|
22
|
+
{ value: 'post_script_name', label: 'PostScript Name', type: 'text' },
|
|
23
|
+
{ value: 'file_name', label: 'File Name', type: 'text' },
|
|
24
|
+
{
|
|
25
|
+
value: 'file_type',
|
|
26
|
+
label: 'File Type',
|
|
27
|
+
type: 'enum',
|
|
28
|
+
options: [
|
|
29
|
+
{ value: 'font/ttf', label: 'TrueType (TTF)' },
|
|
30
|
+
{ value: 'font/otf', label: 'OpenType (OTF)' },
|
|
31
|
+
{ value: 'font/woff', label: 'WOFF' },
|
|
32
|
+
{ value: 'font/woff2', label: 'WOFF2' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{ value: 'favorite', label: 'Favorite', type: 'boolean' },
|
|
36
|
+
{ value: 'system', label: 'System Font', type: 'boolean' },
|
|
37
|
+
{ value: 'installable', label: 'Installable', type: 'boolean' },
|
|
38
|
+
{ value: 'file_size', label: 'File Size (bytes)', type: 'numeric' },
|
|
39
|
+
{ value: 'created', label: 'Date Added', type: 'date' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const OPERATORS_BY_TYPE: Record<string, { value: string; label: string }[]> = {
|
|
43
|
+
text: [
|
|
44
|
+
{ value: 'contains', label: 'contains' },
|
|
45
|
+
{ value: 'equals', label: 'equals' },
|
|
46
|
+
{ value: 'starts_with', label: 'starts with' },
|
|
47
|
+
{ value: 'ends_with', label: 'ends with' },
|
|
48
|
+
],
|
|
49
|
+
boolean: [
|
|
50
|
+
{ value: 'is', label: 'is' },
|
|
51
|
+
{ value: 'is_not', label: 'is not' },
|
|
52
|
+
],
|
|
53
|
+
numeric: [
|
|
54
|
+
{ value: 'greater_than', label: 'greater than' },
|
|
55
|
+
{ value: 'less_than', label: 'less than' },
|
|
56
|
+
{ value: 'equals', label: 'equals' },
|
|
57
|
+
],
|
|
58
|
+
enum: [{ value: 'equals', label: 'equals' }],
|
|
59
|
+
date: [
|
|
60
|
+
{ value: 'greater_than', label: 'after' },
|
|
61
|
+
{ value: 'less_than', label: 'before' },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
@Component({
|
|
66
|
+
selector: 'app-rule-builder',
|
|
67
|
+
standalone: true,
|
|
68
|
+
imports: [FormsModule],
|
|
69
|
+
templateUrl: './rule-builder.component.html',
|
|
70
|
+
})
|
|
71
|
+
export class RuleBuilderComponent implements OnInit {
|
|
72
|
+
@Input() smartCollection: SmartCollection | null = null;
|
|
73
|
+
@Output() saved = new EventEmitter<{ title: string; rules: SmartCollectionRule[]; match_type: string }>();
|
|
74
|
+
@Output() cancelled = new EventEmitter<void>();
|
|
75
|
+
|
|
76
|
+
title = '';
|
|
77
|
+
matchType: string = 'AND';
|
|
78
|
+
rules: SmartCollectionRule[] = [];
|
|
79
|
+
|
|
80
|
+
readonly fieldOptions = FIELD_OPTIONS;
|
|
81
|
+
|
|
82
|
+
ngOnInit(): void {
|
|
83
|
+
if (this.smartCollection) {
|
|
84
|
+
this.title = this.smartCollection.title;
|
|
85
|
+
this.matchType = this.smartCollection.match_type;
|
|
86
|
+
this.rules = JSON.parse(this.smartCollection.rules);
|
|
87
|
+
} else {
|
|
88
|
+
this.rules = [{ field: 'font_family', operator: 'contains', value: '' }];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getOperators(fieldValue: string): { value: string; label: string }[] {
|
|
93
|
+
const field = FIELD_OPTIONS.find((f) => f.value === fieldValue);
|
|
94
|
+
return OPERATORS_BY_TYPE[field?.type ?? 'text'] ?? OPERATORS_BY_TYPE['text'];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getFieldType(fieldValue: string): string {
|
|
98
|
+
return FIELD_OPTIONS.find((f) => f.value === fieldValue)?.type ?? 'text';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getEnumOptions(fieldValue: string): { value: string; label: string }[] {
|
|
102
|
+
return FIELD_OPTIONS.find((f) => f.value === fieldValue)?.options ?? [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onFieldChange(rule: SmartCollectionRule): void {
|
|
106
|
+
const operators = this.getOperators(rule.field);
|
|
107
|
+
rule.operator = operators[0]?.value ?? 'contains';
|
|
108
|
+
const type = this.getFieldType(rule.field);
|
|
109
|
+
if (type === 'boolean') {
|
|
110
|
+
rule.value = 1;
|
|
111
|
+
} else if (type === 'enum') {
|
|
112
|
+
const options = this.getEnumOptions(rule.field);
|
|
113
|
+
rule.value = options[0]?.value ?? '';
|
|
114
|
+
} else {
|
|
115
|
+
rule.value = '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
addRule(): void {
|
|
120
|
+
this.rules.push({ field: 'font_family', operator: 'contains', value: '' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
removeRule(index: number): void {
|
|
124
|
+
this.rules.splice(index, 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
save(): void {
|
|
128
|
+
const trimmedTitle = this.title.trim();
|
|
129
|
+
if (!trimmedTitle || this.rules.length === 0) return;
|
|
130
|
+
this.saved.emit({ title: trimmedTitle, rules: this.rules, match_type: this.matchType });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
cancel(): void {
|
|
134
|
+
this.cancelled.emit();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -1,86 +1,5 @@
|
|
|
1
1
|
<div class="panel">
|
|
2
2
|
<div class="h-full flex items-center justify-around px-3">
|
|
3
|
-
<div class="flex-1 flex items-center gap-3">
|
|
4
|
-
@if (db.totalPages() > 1) {
|
|
5
|
-
<div class="flex items-center gap-0.5 text-[11px]">
|
|
6
|
-
<button
|
|
7
|
-
class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
|
|
8
|
-
[style.color]="'var(--text-secondary)'"
|
|
9
|
-
[disabled]="db.currentPage() === 1"
|
|
10
|
-
[class.opacity-30]="db.currentPage() === 1"
|
|
11
|
-
(click)="db.firstPage()"
|
|
12
|
-
>
|
|
13
|
-
<span
|
|
14
|
-
class="material-symbols-outlined text-sm"
|
|
15
|
-
style="
|
|
16
|
-
font-variation-settings:
|
|
17
|
-
'opsz' 20,
|
|
18
|
-
'wght' 300;
|
|
19
|
-
"
|
|
20
|
-
>first_page</span
|
|
21
|
-
>
|
|
22
|
-
</button>
|
|
23
|
-
<button
|
|
24
|
-
class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
|
|
25
|
-
[style.color]="'var(--text-secondary)'"
|
|
26
|
-
[disabled]="db.currentPage() === 1"
|
|
27
|
-
[class.opacity-30]="db.currentPage() === 1"
|
|
28
|
-
(click)="db.prevPage()"
|
|
29
|
-
>
|
|
30
|
-
<span
|
|
31
|
-
class="material-symbols-outlined text-sm"
|
|
32
|
-
style="
|
|
33
|
-
font-variation-settings:
|
|
34
|
-
'opsz' 20,
|
|
35
|
-
'wght' 300;
|
|
36
|
-
"
|
|
37
|
-
>chevron_left</span
|
|
38
|
-
>
|
|
39
|
-
</button>
|
|
40
|
-
<span class="px-1.5 select-none" [style.color]="'var(--text-muted)'">{{ db.currentPage() }} / {{ db.totalPages() }}</span>
|
|
41
|
-
<button
|
|
42
|
-
class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
|
|
43
|
-
[style.color]="'var(--text-secondary)'"
|
|
44
|
-
[disabled]="db.currentPage() === db.totalPages()"
|
|
45
|
-
[class.opacity-30]="db.currentPage() === db.totalPages()"
|
|
46
|
-
(click)="db.nextPage()"
|
|
47
|
-
>
|
|
48
|
-
<span
|
|
49
|
-
class="material-symbols-outlined text-sm"
|
|
50
|
-
style="
|
|
51
|
-
font-variation-settings:
|
|
52
|
-
'opsz' 20,
|
|
53
|
-
'wght' 300;
|
|
54
|
-
"
|
|
55
|
-
>chevron_right</span
|
|
56
|
-
>
|
|
57
|
-
</button>
|
|
58
|
-
<button
|
|
59
|
-
class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
|
|
60
|
-
[style.color]="'var(--text-secondary)'"
|
|
61
|
-
[disabled]="db.currentPage() === db.totalPages()"
|
|
62
|
-
[class.opacity-30]="db.currentPage() === db.totalPages()"
|
|
63
|
-
(click)="db.lastPage()"
|
|
64
|
-
>
|
|
65
|
-
<span
|
|
66
|
-
class="material-symbols-outlined text-sm"
|
|
67
|
-
style="
|
|
68
|
-
font-variation-settings:
|
|
69
|
-
'opsz' 20,
|
|
70
|
-
'wght' 300;
|
|
71
|
-
"
|
|
72
|
-
>last_page</span
|
|
73
|
-
>
|
|
74
|
-
</button>
|
|
75
|
-
</div>
|
|
76
|
-
}
|
|
77
|
-
<span class="text-[11px]" [style.color]="'var(--text-muted)'">
|
|
78
|
-
@if (db.storeCount()) {
|
|
79
|
-
{{ db.storeCount() }} fonts
|
|
80
|
-
}
|
|
81
|
-
</span>
|
|
82
|
-
</div>
|
|
83
|
-
|
|
84
3
|
<div class="flex-1 flex items-center justify-center gap-3">
|
|
85
4
|
<input
|
|
86
5
|
type="text"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Component, inject, computed, ElementRef } from '@angular/core';
|
|
2
|
-
import {
|
|
2
|
+
import { NewsService, PresentationService } from '../../../core/services';
|
|
3
3
|
|
|
4
4
|
@Component({
|
|
5
5
|
selector: 'app-toolbar',
|
|
@@ -7,7 +7,6 @@ import { DatabaseService, NewsService, PresentationService } from '../../../core
|
|
|
7
7
|
templateUrl: './toolbar.component.html',
|
|
8
8
|
})
|
|
9
9
|
export class ToolbarComponent {
|
|
10
|
-
readonly db = inject(DatabaseService);
|
|
11
10
|
readonly presentation = inject(PresentationService);
|
|
12
11
|
readonly news = inject(NewsService);
|
|
13
12
|
private el = inject(ElementRef);
|
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
--font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
:root {
|
|
7
|
+
--panel-width: 200px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@media (min-width: 1440px) {
|
|
11
|
+
:root {
|
|
12
|
+
--panel-width: 250px;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@media (min-width: 1920px) {
|
|
17
|
+
:root {
|
|
18
|
+
--panel-width: 300px;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
/* ---------------------------------------------------------------------------
|
|
7
23
|
Light mode — warm stone neutrals
|
|
8
24
|
--------------------------------------------------------------------------- */
|