fontastic 1.1.0 → 1.3.0

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 (87) hide show
  1. package/.github/workflows/claude-code-review.yml +0 -6
  2. package/.github/workflows/release-please.yml +25 -0
  3. package/.github/workflows/release.yml +5 -109
  4. package/.release-please-manifest.json +3 -0
  5. package/CHANGELOG.md +39 -0
  6. package/README.md +6 -0
  7. package/app/Application.js +4 -4
  8. package/app/Application.ts +13 -13
  9. package/app/config/database.js +1 -1
  10. package/app/config/database.ts +1 -1
  11. package/app/config/mimes.js +23 -23
  12. package/app/config/mimes.ts +35 -29
  13. package/app/core/ConfigManager.js +27 -0
  14. package/app/core/ConfigManager.ts +28 -0
  15. package/app/core/FontFinder.js +66 -15
  16. package/app/core/FontFinder.ts +81 -22
  17. package/app/core/FontManager.js +20 -18
  18. package/app/core/FontManager.ts +21 -19
  19. package/app/core/FontObject.js +44 -24
  20. package/app/core/FontObject.ts +47 -27
  21. package/app/core/MessageHandler.js +70 -19
  22. package/app/core/MessageHandler.ts +82 -21
  23. package/app/core/SystemManager.js +5 -1
  24. package/app/core/SystemManager.ts +5 -1
  25. package/app/database/entity/Collection.schema.js +20 -18
  26. package/app/database/entity/Collection.schema.ts +22 -21
  27. package/app/database/repository/Collection.repository.js +17 -18
  28. package/app/database/repository/Collection.repository.ts +27 -18
  29. package/app/database/repository/Store.repository.js +13 -18
  30. package/app/database/repository/Store.repository.ts +13 -21
  31. package/app/enums/ChannelType.js +18 -0
  32. package/app/enums/ChannelType.ts +24 -0
  33. package/app/main.js +98 -10
  34. package/app/main.ts +126 -19
  35. package/app/package.json +1 -1
  36. package/app/types/NativeThemeState.js +3 -0
  37. package/app/types/NativeThemeState.ts +4 -0
  38. package/app/types/ScanProgress.js +3 -0
  39. package/app/types/ScanProgress.ts +6 -0
  40. package/app/types/SystemPreferencesState.js +3 -0
  41. package/app/types/SystemPreferencesState.ts +4 -0
  42. package/app/types/index.js +3 -0
  43. package/app/types/index.ts +3 -0
  44. package/package.json +2 -2
  45. package/release-please-config.json +20 -0
  46. package/scripts/patch-electron-plist.js +41 -0
  47. package/src/app/core/services/database/database.service.ts +6 -0
  48. package/src/app/core/services/message/message.service.ts +33 -1
  49. package/src/app/core/services/presentation/presentation.service.ts +100 -1
  50. package/src/app/layout/footer/footer.component.html +13 -2
  51. package/src/app/layout/footer/footer.component.ts +18 -2
  52. package/src/app/layout/header/header.component.html +0 -10
  53. package/src/app/layout/header/header.component.ts +4 -23
  54. package/src/app/layout/navigation/navigation.component.html +66 -16
  55. package/src/app/layout/navigation/navigation.component.ts +65 -12
  56. package/src/app/settings/ai-keys/ai-keys.component.ts +13 -18
  57. package/src/app/settings/danger-zone/danger-zone.component.html +8 -0
  58. package/src/app/settings/danger-zone/danger-zone.component.ts +12 -0
  59. package/src/app/settings/news-api/news-api.component.ts +6 -8
  60. package/src/app/settings/theme/theme.component.html +15 -2
  61. package/src/app/settings/theme/theme.component.ts +4 -0
  62. package/src/app/shared/components/datagrid/datagrid.component.html +8 -17
  63. package/src/app/shared/components/datagrid/datagrid.component.ts +6 -10
  64. package/src/app/shared/components/glyphs/glyphs.component.html +5 -15
  65. package/src/app/shared/components/glyphs/glyphs.component.ts +3 -0
  66. package/src/app/shared/components/preview/preview.component.html +1 -1
  67. package/src/app/shared/components/preview/preview.component.ts +3 -8
  68. package/src/app/shared/components/prompt-dialog/prompt-dialog.component.html +2 -2
  69. package/src/app/shared/components/prompt-dialog/prompt-dialog.component.ts +2 -1
  70. package/src/app/shared/components/rule-builder/rule-builder.component.html +18 -6
  71. package/src/app/shared/components/rule-builder/rule-builder.component.ts +34 -2
  72. package/src/app/shared/components/search/search.component.html +9 -36
  73. package/src/app/shared/components/search/search.component.ts +2 -1
  74. package/src/app/shared/components/waterfall/waterfall.component.html +1 -3
  75. package/src/app/shared/components/waterfall/waterfall.component.ts +2 -1
  76. package/src/app/shared/directives/disabled-opacity/disabled-opacity.directive.ts +18 -0
  77. package/src/app/shared/directives/hover-highlight/hover-highlight.directive.ts +38 -0
  78. package/src/app/shared/directives/index.ts +5 -0
  79. package/src/app/shared/directives/modal-backdrop/modal-backdrop.directive.ts +18 -0
  80. package/src/app/shared/directives/scroll-reset/scroll-reset.directive.ts +15 -0
  81. package/src/app/shared/directives/stop-propagation/stop-propagation.directive.ts +12 -0
  82. package/src/assets/icons/favicon.256x256.png +0 -0
  83. package/src/assets/icons/favicon.512x512.png +0 -0
  84. package/src/assets/icons/favicon.icns +0 -0
  85. package/src/assets/icons/favicon.ico +0 -0
  86. package/src/assets/icons/favicon.png +0 -0
  87. package/src/favicon.ico +0 -0
@@ -1,11 +1,13 @@
1
- import { Component, inject, computed, OnInit, OnDestroy } from '@angular/core';
1
+ import { Component, inject, computed, effect, 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, RuleBuilderComponent } from '../../shared/components';
7
+ import { RuleBuilderComponent } from '../../shared/components';
8
8
  import { AutofocusDirective } from '../../shared/directives/autofocus/autofocus.directive';
9
+ import { HoverHighlightDirective } from '../../shared/directives/hover-highlight/hover-highlight.directive';
10
+ import { StopPropagationDirective } from '../../shared/directives/stop-propagation/stop-propagation.directive';
9
11
  import { LibraryComponent } from './library/library.component';
10
12
  import { NewsStatsComponent } from './stats/stats.component';
11
13
  import type { Collection } from '@main/database/entity/Collection.schema';
@@ -26,9 +28,10 @@ export interface TreeNode {
26
28
  FormsModule,
27
29
  CollapsiblePanelComponent,
28
30
  ContextMenuComponent,
29
- PromptDialogComponent,
30
31
  RuleBuilderComponent,
31
32
  AutofocusDirective,
33
+ HoverHighlightDirective,
34
+ StopPropagationDirective,
32
35
  LibraryComponent,
33
36
  NewsStatsComponent,
34
37
  ],
@@ -39,6 +42,15 @@ export class NavigationComponent implements OnInit, OnDestroy {
39
42
  private message = inject(MessageService);
40
43
  private presentation = inject(PresentationService);
41
44
 
45
+ constructor() {
46
+ effect(() => {
47
+ const req = this.presentation.createRootCollectionRequest();
48
+ if (req > 0) {
49
+ this.openCreateRootCollection();
50
+ }
51
+ });
52
+ }
53
+
42
54
  private menuToggleListener = (_event: any, panel: string) => {
43
55
  if (panel === 'expand-collections') {
44
56
  this.expandAll();
@@ -72,7 +84,8 @@ export class NavigationComponent implements OnInit, OnDestroy {
72
84
  editingTitle = '';
73
85
  allExpanded = false;
74
86
 
75
- showCollectionDialog = false;
87
+ isCreating = false;
88
+ creatingTitle = '';
76
89
  pendingParentId: number | null = null;
77
90
 
78
91
  // Smart Collection state
@@ -256,7 +269,9 @@ export class NavigationComponent implements OnInit, OnDestroy {
256
269
  switch (action) {
257
270
  case 'add-collection':
258
271
  this.pendingParentId = collection.id;
259
- this.showCollectionDialog = true;
272
+ this.isCreating = true;
273
+ this.creatingTitle = '';
274
+ this.presentation.expandNavigationId(collection.id);
260
275
  break;
261
276
  case 'add-fonts':
262
277
  this.handleAddFonts(collection);
@@ -273,17 +288,24 @@ export class NavigationComponent implements OnInit, OnDestroy {
273
288
 
274
289
  openCreateRootCollection() {
275
290
  this.pendingParentId = 0;
276
- this.showCollectionDialog = true;
291
+ this.isCreating = true;
292
+ this.creatingTitle = '';
277
293
  }
278
294
 
279
- onCollectionConfirmed(name: string) {
280
- this.db.collectionCreate({ title: name, parentId: this.pendingParentId });
281
- this.showCollectionDialog = false;
282
- this.pendingParentId = null;
295
+ saveCreating() {
296
+ const title = this.creatingTitle.trim();
297
+ if (title) {
298
+ this.db.collectionCreate({ title, parentId: this.pendingParentId });
299
+ if (this.pendingParentId) {
300
+ this.presentation.expandNavigationId(this.pendingParentId);
301
+ }
302
+ }
303
+ this.cancelCreating();
283
304
  }
284
305
 
285
- onCollectionCancelled() {
286
- this.showCollectionDialog = false;
306
+ cancelCreating() {
307
+ this.isCreating = false;
308
+ this.creatingTitle = '';
287
309
  this.pendingParentId = null;
288
310
  }
289
311
 
@@ -458,6 +480,37 @@ export class NavigationComponent implements OnInit, OnDestroy {
458
480
  this.editingSmartCollection = null;
459
481
  }
460
482
 
483
+ onRuleBuilderSavedAndSync(data: { title: string; rules: SmartCollectionRule[]; match_type: string }) {
484
+ const rulesJson = JSON.stringify(data.rules);
485
+ if (this.editingSmartCollection) {
486
+ const id = this.editingSmartCollection.id;
487
+ this.db
488
+ .smartCollectionUpdate(id, {
489
+ title: data.title,
490
+ rules: rulesJson,
491
+ match_type: data.match_type,
492
+ })
493
+ .then(() => {
494
+ this.db.selectSmartCollection(id);
495
+ });
496
+ } else {
497
+ this.db
498
+ .smartCollectionCreate({
499
+ title: data.title,
500
+ rules: rulesJson,
501
+ match_type: data.match_type,
502
+ })
503
+ .then((collections) => {
504
+ const created = collections.find((c) => c.title === data.title);
505
+ if (created) {
506
+ this.db.selectSmartCollection(created.id);
507
+ }
508
+ });
509
+ }
510
+ this.showRuleBuilder = false;
511
+ this.editingSmartCollection = null;
512
+ }
513
+
461
514
  onRuleBuilderCancelled() {
462
515
  this.showRuleBuilder = false;
463
516
  this.editingSmartCollection = null;
@@ -1,13 +1,6 @@
1
1
  import { Component, inject, signal, OnInit } from '@angular/core';
2
2
  import { FormsModule } from '@angular/forms';
3
3
  import { MessageService } from '../../core/services';
4
- import { StorageType } from '@main/enums';
5
-
6
- interface AiKeys {
7
- anthropic: string;
8
- google: string;
9
- openai: string;
10
- }
11
4
 
12
5
  @Component({
13
6
  selector: 'app-settings-ai-keys',
@@ -24,20 +17,22 @@ export class SettingsAiKeysComponent implements OnInit {
24
17
  readonly saveStatus = signal<'idle' | 'saved'>('idle');
25
18
 
26
19
  async ngOnInit() {
27
- const keys = (await this.message.get(StorageType.AiKeys, null)) as AiKeys | null;
28
- if (keys) {
29
- this.anthropicKey.set(keys.anthropic ?? '');
30
- this.googleKey.set(keys.google ?? '');
31
- this.openaiKey.set(keys.openai ?? '');
32
- }
20
+ const [anthropic, google, openai] = await Promise.all([
21
+ this.message.safeRetrieve('secure.ai.anthropic'),
22
+ this.message.safeRetrieve('secure.ai.google'),
23
+ this.message.safeRetrieve('secure.ai.openai'),
24
+ ]);
25
+ if (anthropic) this.anthropicKey.set(anthropic);
26
+ if (google) this.googleKey.set(google);
27
+ if (openai) this.openaiKey.set(openai);
33
28
  }
34
29
 
35
30
  async onSave() {
36
- await this.message.set(StorageType.AiKeys, {
37
- anthropic: this.anthropicKey(),
38
- google: this.googleKey(),
39
- openai: this.openaiKey(),
40
- });
31
+ await Promise.all([
32
+ this.message.safeStore('secure.ai.anthropic', this.anthropicKey()),
33
+ this.message.safeStore('secure.ai.google', this.googleKey()),
34
+ this.message.safeStore('secure.ai.openai', this.openaiKey()),
35
+ ]);
41
36
  this.saveStatus.set('saved');
42
37
  setTimeout(() => this.saveStatus.set('idle'), 2000);
43
38
  }
@@ -18,5 +18,13 @@
18
18
  </div>
19
19
  <button class="btn btn-sm btn-danger" (click)="onClearStore()">Clear</button>
20
20
  </div>
21
+
22
+ <div class="flex items-center justify-between">
23
+ <div>
24
+ <p class="text-xs font-semibold" [style.color]="'var(--text-primary)'">Clear application cache</p>
25
+ <p class="text-xs" [style.color]="'var(--text-muted)'">Clears the Chromium session cache.</p>
26
+ </div>
27
+ <button class="btn btn-sm btn-danger" (click)="onClearCache()">Clear Cache</button>
28
+ </div>
21
29
  </div>
22
30
  </div>
@@ -34,4 +34,16 @@ export class SettingsDangerZoneComponent {
34
34
  this.message.reloadWindow();
35
35
  }
36
36
  }
37
+
38
+ async onClearCache() {
39
+ const response = await this.message.showMessageBox({
40
+ type: 'question',
41
+ buttons: ['Yes', 'No'],
42
+ title: 'Confirm',
43
+ message: 'Are you sure you want to clear the application cache?',
44
+ });
45
+ if (response?.response === 0) {
46
+ await this.message.clearCache();
47
+ }
48
+ }
37
49
  }
@@ -1,8 +1,6 @@
1
1
  import { Component, inject, signal, OnInit } from '@angular/core';
2
2
  import { FormsModule } from '@angular/forms';
3
3
  import { MessageService, NewsService } from '../../core/services';
4
- import { StorageType } from '@main/enums';
5
- import type { NewsType } from '@main/types';
6
4
 
7
5
  @Component({
8
6
  selector: 'app-settings-news-api',
@@ -20,16 +18,16 @@ export class SettingsNewsApiComponent implements OnInit {
20
18
  readonly fetchMessage = signal('');
21
19
 
22
20
  async ngOnInit() {
23
- const config = (await this.message.get(StorageType.News, null)) as NewsType | null;
24
- if (config?.apiKey) {
25
- this.newsApiKey.set(config.apiKey);
21
+ const apiKey = await this.message.safeRetrieve('secure.news.apiKey');
22
+ if (apiKey) {
23
+ this.newsApiKey.set(apiKey);
26
24
  }
27
25
  }
28
26
 
29
27
  async onSaveNewsApiKey() {
30
28
  const apiKey = this.newsApiKey();
31
29
  if (!apiKey) return;
32
- await this.message.set(StorageType.News, { apiKey });
30
+ await this.message.safeStore('secure.news.apiKey', apiKey);
33
31
  this.saveStatus.set('saved');
34
32
  await this.news.refresh();
35
33
  setTimeout(() => this.saveStatus.set('idle'), 2000);
@@ -39,8 +37,8 @@ export class SettingsNewsApiComponent implements OnInit {
39
37
  this.fetchStatus.set('fetching');
40
38
  this.fetchMessage.set('');
41
39
  try {
42
- const endpoint = `https://newsapi.org/v2/top-headlines?country=us&apiKey=${this.newsApiKey()}`;
43
- const result = await this.message.fetchLatestNews({ endpoint });
40
+ // API key is retrieved securely on the main process side
41
+ const result = await this.message.fetchLatestNews({ country: 'us' });
44
42
  const count = result?.articles?.length ?? 0;
45
43
  this.fetchMessage.set(`Fetched ${count} article${count !== 1 ? 's' : ''}.`);
46
44
  this.fetchStatus.set('done');
@@ -2,10 +2,23 @@
2
2
  <div class="card-header">
3
3
  <div class="card-title"><h3>Theme</h3></div>
4
4
  </div>
5
- <div class="card-body">
5
+ <div class="card-body space-y-4">
6
+ <div>
7
+ <label class="flex items-center gap-2 cursor-pointer">
8
+ <input type="checkbox" class="accent-current" [ngModel]="presentation.autoTheme()" (ngModelChange)="onAutoThemeChange($event)" />
9
+ <span class="text-xs" [style.color]="'var(--text-secondary)'">Match system appearance</span>
10
+ </label>
11
+ </div>
6
12
  <div>
7
13
  <label class="form-label" for="theme">Selected theme</label>
8
- <select id="theme" class="form-select w-full" [ngModel]="presentation.theme()" (ngModelChange)="onThemeChange($event)" name="theme">
14
+ <select
15
+ id="theme"
16
+ class="form-select w-full"
17
+ [ngModel]="presentation.theme()"
18
+ (ngModelChange)="onThemeChange($event)"
19
+ [disabled]="presentation.autoTheme()"
20
+ name="theme"
21
+ >
9
22
  @for (t of themes; track t.key) {
10
23
  <option [value]="t.key">{{ t.label }}</option>
11
24
  }
@@ -19,4 +19,8 @@ export class SettingsThemeComponent {
19
19
  onThemeChange(value: string) {
20
20
  this.presentation.theme.set(value);
21
21
  }
22
+
23
+ onAutoThemeChange(enabled: boolean) {
24
+ this.presentation.setAutoTheme(enabled);
25
+ }
22
26
  }
@@ -1,5 +1,5 @@
1
1
  <div class="panel grid" [style.grid-template-rows]="db.totalPages() > 1 ? '1fr 32px' : '1fr'">
2
- <div class="overflow-hidden scrollbox-y">
2
+ <div class="overflow-hidden scrollbox-y" [appScrollReset]="db.currentPage()">
3
3
  <table class="w-full border-separate border-spacing-0">
4
4
  <thead
5
5
  class="sticky top-0 z-10"
@@ -44,14 +44,8 @@
44
44
  class="cursor-pointer transition-colors"
45
45
  [style.border-bottom]="'1px solid var(--border-subtle)'"
46
46
  [id]="'grid-store-' + store.id"
47
- [style.background-color]="db.storeId() === store.id ? 'var(--selected-bg)' : 'transparent'"
47
+ [appHoverHighlight]="db.storeId() === store.id"
48
48
  (click)="selectStore(store.id)"
49
- (mouseenter)="
50
- $any($event.currentTarget).style.backgroundColor = db.storeId() === store.id ? 'var(--selected-bg)' : 'var(--hover-bg)'
51
- "
52
- (mouseleave)="
53
- $any($event.currentTarget).style.backgroundColor = db.storeId() === store.id ? 'var(--selected-bg)' : 'transparent'
54
- "
55
49
  >
56
50
  <td class="w-10 px-2 py-1.5 text-center">
57
51
  <span
@@ -60,7 +54,8 @@
60
54
  [style.background]="
61
55
  db.storeId() === store.id ? 'radial-gradient(circle, var(--accent) 40%, transparent 41%)' : 'transparent'
62
56
  "
63
- (click)="$event.stopPropagation(); selectStore(store.id)"
57
+ appStopPropagation
58
+ (click)="selectStore(store.id)"
64
59
  ></span>
65
60
  </td>
66
61
  <td class="px-4 py-1.5" [style.color]="'var(--text-primary)'">{{ store.full_name || store.file_name }}</td>
@@ -121,8 +116,7 @@
121
116
  <button
122
117
  class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
123
118
  [style.color]="'var(--text-secondary)'"
124
- [disabled]="db.currentPage() === 1"
125
- [class.opacity-30]="db.currentPage() === 1"
119
+ [appDisabledOpacity]="db.currentPage() === 1"
126
120
  (click)="db.firstPage()"
127
121
  >
128
122
  <span
@@ -138,8 +132,7 @@
138
132
  <button
139
133
  class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
140
134
  [style.color]="'var(--text-secondary)'"
141
- [disabled]="db.currentPage() === 1"
142
- [class.opacity-30]="db.currentPage() === 1"
135
+ [appDisabledOpacity]="db.currentPage() === 1"
143
136
  (click)="db.prevPage()"
144
137
  >
145
138
  <span
@@ -156,8 +149,7 @@
156
149
  <button
157
150
  class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
158
151
  [style.color]="'var(--text-secondary)'"
159
- [disabled]="db.currentPage() === db.totalPages()"
160
- [class.opacity-30]="db.currentPage() === db.totalPages()"
152
+ [appDisabledOpacity]="db.currentPage() === db.totalPages()"
161
153
  (click)="db.nextPage()"
162
154
  >
163
155
  <span
@@ -173,8 +165,7 @@
173
165
  <button
174
166
  class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
175
167
  [style.color]="'var(--text-secondary)'"
176
- [disabled]="db.currentPage() === db.totalPages()"
177
- [class.opacity-30]="db.currentPage() === db.totalPages()"
168
+ [appDisabledOpacity]="db.currentPage() === db.totalPages()"
178
169
  (click)="db.lastPage()"
179
170
  >
180
171
  <span
@@ -1,15 +1,19 @@
1
- import { Component, inject, effect, ElementRef } from '@angular/core';
1
+ import { Component, inject } from '@angular/core';
2
2
  import { DatabaseService, MessageService } from '../../../core/services';
3
+ import { DisabledOpacityDirective } from '../../directives/disabled-opacity/disabled-opacity.directive';
4
+ import { HoverHighlightDirective } from '../../directives/hover-highlight/hover-highlight.directive';
5
+ import { ScrollResetDirective } from '../../directives/scroll-reset/scroll-reset.directive';
6
+ import { StopPropagationDirective } from '../../directives/stop-propagation/stop-propagation.directive';
3
7
 
4
8
  @Component({
5
9
  selector: 'app-datagrid',
6
10
  standalone: true,
11
+ imports: [HoverHighlightDirective, StopPropagationDirective, DisabledOpacityDirective, ScrollResetDirective],
7
12
  templateUrl: './datagrid.component.html',
8
13
  })
9
14
  export class DatagridComponent {
10
15
  readonly db = inject(DatabaseService);
11
16
  private messageService = inject(MessageService);
12
- private el = inject(ElementRef);
13
17
 
14
18
  readonly sortableColumns = [
15
19
  { field: 'full_name', label: 'Name' },
@@ -21,14 +25,6 @@ export class DatagridComponent {
21
25
  { field: 'designer', label: 'Designer' },
22
26
  ];
23
27
 
24
- constructor() {
25
- effect(() => {
26
- this.db.currentPage();
27
- const scrollable = this.el.nativeElement.querySelector('.scrollbox-y');
28
- scrollable?.scrollTo(0, 0);
29
- });
30
- }
31
-
32
28
  selectStore(id: number) {
33
29
  this.db.storeId.set(id);
34
30
  document.getElementById('preview-store-' + id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -17,8 +17,7 @@
17
17
  <button
18
18
  class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
19
19
  [style.color]="'var(--text-secondary)'"
20
- [disabled]="currentPage() === 1"
21
- [class.opacity-30]="currentPage() === 1"
20
+ [appDisabledOpacity]="currentPage() === 1"
22
21
  (click)="currentPage.set(1)"
23
22
  >
24
23
  <span
@@ -34,8 +33,7 @@
34
33
  <button
35
34
  class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
36
35
  [style.color]="'var(--text-secondary)'"
37
- [disabled]="currentPage() === 1"
38
- [class.opacity-30]="currentPage() === 1"
36
+ [appDisabledOpacity]="currentPage() === 1"
39
37
  (click)="currentPage.set(currentPage() - 1)"
40
38
  >
41
39
  <span
@@ -52,8 +50,7 @@
52
50
  <button
53
51
  class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
54
52
  [style.color]="'var(--text-secondary)'"
55
- [disabled]="currentPage() === totalPages()"
56
- [class.opacity-30]="currentPage() === totalPages()"
53
+ [appDisabledOpacity]="currentPage() === totalPages()"
57
54
  (click)="currentPage.set(currentPage() + 1)"
58
55
  >
59
56
  <span
@@ -69,8 +66,7 @@
69
66
  <button
70
67
  class="px-1 py-0.5 rounded cursor-pointer disabled:cursor-default transition-colors"
71
68
  [style.color]="'var(--text-secondary)'"
72
- [disabled]="currentPage() === totalPages()"
73
- [class.opacity-30]="currentPage() === totalPages()"
69
+ [appDisabledOpacity]="currentPage() === totalPages()"
74
70
  (click)="currentPage.set(totalPages())"
75
71
  >
76
72
  <span
@@ -117,16 +113,10 @@
117
113
  <div
118
114
  class="flex flex-col items-center justify-center rounded aspect-square cursor-pointer transition-colors"
119
115
  [style.border]="selectedGlyph() === codePoint ? '1px solid var(--selected-border)' : '1px solid var(--border-subtle)'"
120
- [style.background-color]="selectedGlyph() === codePoint ? 'var(--selected-bg)' : 'transparent'"
116
+ [appHoverHighlight]="selectedGlyph() === codePoint"
121
117
  [title]="'U+' + codePoint.toString(16).toUpperCase().padStart(4, '0')"
122
118
  [style.font-family]="fontFamily()"
123
119
  (click)="selectedGlyph.set(codePoint)"
124
- (mouseenter)="
125
- $any($event.currentTarget).style.backgroundColor = selectedGlyph() === codePoint ? 'var(--selected-bg)' : 'var(--hover-bg)'
126
- "
127
- (mouseleave)="
128
- $any($event.currentTarget).style.backgroundColor = selectedGlyph() === codePoint ? 'var(--selected-bg)' : 'transparent'
129
- "
130
120
  >
131
121
  <span class="text-lg leading-none" [style.color]="'var(--text-primary)'">{{ toChar(codePoint) }}</span>
132
122
  </div>
@@ -1,11 +1,14 @@
1
1
  import { Component, inject, signal, computed, effect } from '@angular/core';
2
2
  import { DatabaseService, PresentationService } from '../../../core/services';
3
+ import { DisabledOpacityDirective } from '../../directives/disabled-opacity/disabled-opacity.directive';
4
+ import { HoverHighlightDirective } from '../../directives/hover-highlight/hover-highlight.directive';
3
5
 
4
6
  const PAGE_SIZE = 200;
5
7
 
6
8
  @Component({
7
9
  selector: 'app-glyphs',
8
10
  standalone: true,
11
+ imports: [HoverHighlightDirective, DisabledOpacityDirective],
9
12
  templateUrl: './glyphs.component.html',
10
13
  })
11
14
  export class GlyphsComponent {
@@ -1,5 +1,5 @@
1
1
  <div class="panel">
2
- <div class="h-full scrollbox-y flex flex-col gap-3 p-4">
2
+ <div class="h-full scrollbox-y flex flex-col gap-3 p-4" [appScrollReset]="db.currentPage()">
3
3
  @for (store of db.stores(); track store.id; let last = $last) {
4
4
  <div
5
5
  [class.border-b]="!last"
@@ -1,15 +1,16 @@
1
- import { Component, inject, effect, ElementRef } from '@angular/core';
1
+ import { Component, inject, effect } from '@angular/core';
2
2
  import { DatabaseService, MessageService, PresentationService } from '../../../core/services';
3
+ import { ScrollResetDirective } from '../../directives/scroll-reset/scroll-reset.directive';
3
4
 
4
5
  @Component({
5
6
  selector: 'app-preview',
6
7
  standalone: true,
8
+ imports: [ScrollResetDirective],
7
9
  templateUrl: './preview.component.html',
8
10
  })
9
11
  export class PreviewComponent {
10
12
  readonly db = inject(DatabaseService);
11
13
  readonly presentation = inject(PresentationService);
12
- private el = inject(ElementRef);
13
14
  private messageService = inject(MessageService);
14
15
 
15
16
  private registeredFonts = new Set<string>();
@@ -21,12 +22,6 @@ export class PreviewComponent {
21
22
  stores.forEach((store) => this.registerFont(store));
22
23
  }
23
24
  });
24
-
25
- effect(() => {
26
- this.db.currentPage();
27
- const scrollable = this.el.nativeElement.querySelector('.scrollbox-y');
28
- scrollable?.scrollTo(0, 0);
29
- });
30
25
  }
31
26
 
32
27
  selectStore(id: number) {
@@ -1,5 +1,5 @@
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-[480px]" style="box-shadow: var(--context-shadow)" (click)="$event.stopPropagation()">
1
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" appModalBackdrop (backdropClick)="cancel()">
2
+ <div class="card mb-0 w-[480px]" style="box-shadow: var(--context-shadow)">
3
3
  <div class="card-header">
4
4
  <div class="card-title">
5
5
  <h3>{{ title }}</h3>
@@ -1,10 +1,11 @@
1
1
  import { Component, Input, Output, EventEmitter, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
2
2
  import { FormsModule } from '@angular/forms';
3
+ import { ModalBackdropDirective } from '../../directives/modal-backdrop/modal-backdrop.directive';
3
4
 
4
5
  @Component({
5
6
  selector: 'app-prompt-dialog',
6
7
  standalone: true,
7
- imports: [FormsModule],
8
+ imports: [FormsModule, ModalBackdropDirective],
8
9
  templateUrl: './prompt-dialog.component.html',
9
10
  })
10
11
  export class PromptDialogComponent implements AfterViewInit {
@@ -1,5 +1,5 @@
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()">
1
+ <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" appModalBackdrop (backdropClick)="cancel()">
2
+ <div class="card mb-0 w-[560px]" style="box-shadow: var(--context-shadow)">
3
3
  <div class="card-header">
4
4
  <div class="card-title">
5
5
  <h3>{{ smartCollection ? 'Edit Smart Collection' : 'New Smart Collection' }}</h3>
@@ -78,15 +78,27 @@
78
78
  }
79
79
  </div>
80
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>
81
+ <div class="flex items-center justify-between">
82
+ <button class="btn btn-sm btn-default" (click)="addRule()">
83
+ <span class="material-symbols-outlined" style="font-size: 16px; vertical-align: middle">add</span>
84
+ Add Rule
85
+ </button>
86
+ <div class="flex items-center gap-2">
87
+ @if (previewCount !== null) {
88
+ <span class="text-xs opacity-70">{{ previewCount }} {{ previewCount === 1 ? 'font' : 'fonts' }} matched</span>
89
+ }
90
+ <button class="btn btn-sm btn-default" (click)="preview()" [disabled]="previewing">
91
+ <span class="material-symbols-outlined" style="font-size: 16px; vertical-align: middle">search</span>
92
+ {{ previewing ? 'Checking...' : 'Preview' }}
93
+ </button>
94
+ </div>
95
+ </div>
85
96
  </div>
86
97
  <div class="card-footer">
87
98
  <div></div>
88
99
  <div class="card-tools">
89
100
  <button class="btn btn-sm btn-default" (click)="cancel()">Cancel</button>
101
+ <button class="btn btn-sm btn-default" (click)="saveAndSync()">Save & Sync</button>
90
102
  <button class="btn btn-sm btn-theme" (click)="save()">Save</button>
91
103
  </div>
92
104
  </div>
@@ -1,5 +1,7 @@
1
- import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
1
+ import { Component, Input, Output, EventEmitter, OnInit, inject } from '@angular/core';
2
2
  import { FormsModule } from '@angular/forms';
3
+ import { ModalBackdropDirective } from '../../directives/modal-backdrop/modal-backdrop.directive';
4
+ import { DatabaseService } from '../../../core/services/database/database.service';
3
5
  import type { SmartCollection } from '@main/database/entity/SmartCollection.schema';
4
6
  import type { SmartCollectionRule } from '@main/types';
5
7
 
@@ -65,17 +67,22 @@ const OPERATORS_BY_TYPE: Record<string, { value: string; label: string }[]> = {
65
67
  @Component({
66
68
  selector: 'app-rule-builder',
67
69
  standalone: true,
68
- imports: [FormsModule],
70
+ imports: [FormsModule, ModalBackdropDirective],
69
71
  templateUrl: './rule-builder.component.html',
70
72
  })
71
73
  export class RuleBuilderComponent implements OnInit {
72
74
  @Input() smartCollection: SmartCollection | null = null;
73
75
  @Output() saved = new EventEmitter<{ title: string; rules: SmartCollectionRule[]; match_type: string }>();
76
+ @Output() savedAndSync = new EventEmitter<{ title: string; rules: SmartCollectionRule[]; match_type: string }>();
74
77
  @Output() cancelled = new EventEmitter<void>();
75
78
 
79
+ private db = inject(DatabaseService);
80
+
76
81
  title = '';
77
82
  matchType: string = 'AND';
78
83
  rules: SmartCollectionRule[] = [];
84
+ previewCount: number | null = null;
85
+ previewing = false;
79
86
 
80
87
  readonly fieldOptions = FIELD_OPTIONS;
81
88
 
@@ -114,14 +121,33 @@ export class RuleBuilderComponent implements OnInit {
114
121
  } else {
115
122
  rule.value = '';
116
123
  }
124
+ this.previewCount = null;
117
125
  }
118
126
 
119
127
  addRule(): void {
120
128
  this.rules.push({ field: 'font_family', operator: 'contains', value: '' });
129
+ this.previewCount = null;
121
130
  }
122
131
 
123
132
  removeRule(index: number): void {
124
133
  this.rules.splice(index, 1);
134
+ this.previewCount = null;
135
+ }
136
+
137
+ preview(): void {
138
+ if (this.rules.length === 0 || this.previewing) return;
139
+ this.previewing = true;
140
+ this.previewCount = null;
141
+ this.db
142
+ .smartCollectionPreview(this.rules, this.matchType)
143
+ .then((count) => {
144
+ this.previewCount = count;
145
+ this.previewing = false;
146
+ })
147
+ .catch(() => {
148
+ this.previewCount = null;
149
+ this.previewing = false;
150
+ });
125
151
  }
126
152
 
127
153
  save(): void {
@@ -130,6 +156,12 @@ export class RuleBuilderComponent implements OnInit {
130
156
  this.saved.emit({ title: trimmedTitle, rules: this.rules, match_type: this.matchType });
131
157
  }
132
158
 
159
+ saveAndSync(): void {
160
+ const trimmedTitle = this.title.trim();
161
+ if (!trimmedTitle || this.rules.length === 0) return;
162
+ this.savedAndSync.emit({ title: trimmedTitle, rules: this.rules, match_type: this.matchType });
163
+ }
164
+
133
165
  cancel(): void {
134
166
  this.cancelled.emit();
135
167
  }