fontastic 1.2.0 → 1.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 (75) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +6 -0
  4. package/app/Application.js +4 -4
  5. package/app/Application.ts +13 -13
  6. package/app/config/database.js +1 -1
  7. package/app/config/database.ts +1 -1
  8. package/app/config/mimes.js +23 -23
  9. package/app/config/mimes.ts +35 -29
  10. package/app/core/ConfigManager.js +27 -0
  11. package/app/core/ConfigManager.ts +28 -0
  12. package/app/core/FontFinder.js +66 -15
  13. package/app/core/FontFinder.ts +81 -22
  14. package/app/core/FontManager.js +20 -18
  15. package/app/core/FontManager.ts +21 -19
  16. package/app/core/FontObject.js +44 -24
  17. package/app/core/FontObject.ts +47 -27
  18. package/app/core/MessageHandler.js +70 -19
  19. package/app/core/MessageHandler.ts +82 -21
  20. package/app/core/SystemManager.js +5 -1
  21. package/app/core/SystemManager.ts +5 -1
  22. package/app/database/entity/Collection.schema.js +20 -18
  23. package/app/database/entity/Collection.schema.ts +22 -21
  24. package/app/database/repository/Collection.repository.js +17 -18
  25. package/app/database/repository/Collection.repository.ts +27 -18
  26. package/app/database/repository/Store.repository.js +13 -18
  27. package/app/database/repository/Store.repository.ts +13 -21
  28. package/app/enums/ChannelType.js +18 -0
  29. package/app/enums/ChannelType.ts +24 -0
  30. package/app/main.js +79 -2
  31. package/app/main.ts +100 -3
  32. package/app/package.json +1 -1
  33. package/app/types/NativeThemeState.js +3 -0
  34. package/app/types/NativeThemeState.ts +4 -0
  35. package/app/types/ScanProgress.js +3 -0
  36. package/app/types/ScanProgress.ts +6 -0
  37. package/app/types/SystemPreferencesState.js +3 -0
  38. package/app/types/SystemPreferencesState.ts +4 -0
  39. package/app/types/index.js +3 -0
  40. package/app/types/index.ts +3 -0
  41. package/package.json +1 -1
  42. package/src/app/core/services/database/database.service.ts +6 -0
  43. package/src/app/core/services/message/message.service.ts +33 -1
  44. package/src/app/core/services/presentation/presentation.service.ts +93 -1
  45. package/src/app/layout/footer/footer.component.html +13 -2
  46. package/src/app/layout/footer/footer.component.ts +18 -2
  47. package/src/app/layout/navigation/navigation.component.html +11 -9
  48. package/src/app/layout/navigation/navigation.component.ts +35 -0
  49. package/src/app/settings/ai-keys/ai-keys.component.ts +13 -18
  50. package/src/app/settings/danger-zone/danger-zone.component.html +8 -0
  51. package/src/app/settings/danger-zone/danger-zone.component.ts +12 -0
  52. package/src/app/settings/news-api/news-api.component.ts +6 -8
  53. package/src/app/settings/theme/theme.component.html +15 -2
  54. package/src/app/settings/theme/theme.component.ts +4 -0
  55. package/src/app/shared/components/datagrid/datagrid.component.html +8 -17
  56. package/src/app/shared/components/datagrid/datagrid.component.ts +6 -10
  57. package/src/app/shared/components/glyphs/glyphs.component.html +5 -15
  58. package/src/app/shared/components/glyphs/glyphs.component.ts +3 -0
  59. package/src/app/shared/components/preview/preview.component.html +1 -1
  60. package/src/app/shared/components/preview/preview.component.ts +3 -8
  61. package/src/app/shared/components/prompt-dialog/prompt-dialog.component.html +2 -2
  62. package/src/app/shared/components/prompt-dialog/prompt-dialog.component.ts +2 -1
  63. package/src/app/shared/components/rule-builder/rule-builder.component.html +18 -6
  64. package/src/app/shared/components/rule-builder/rule-builder.component.ts +34 -2
  65. package/src/app/shared/components/search/search.component.html +9 -36
  66. package/src/app/shared/components/search/search.component.ts +2 -1
  67. package/src/app/shared/components/waterfall/waterfall.component.html +1 -3
  68. package/src/app/shared/components/waterfall/waterfall.component.ts +2 -1
  69. package/src/app/shared/directives/disabled-opacity/disabled-opacity.directive.ts +18 -0
  70. package/src/app/shared/directives/hover-highlight/hover-highlight.directive.ts +38 -0
  71. package/src/app/shared/directives/index.ts +5 -0
  72. package/src/app/shared/directives/modal-backdrop/modal-backdrop.directive.ts +18 -0
  73. package/src/app/shared/directives/scroll-reset/scroll-reset.directive.ts +15 -0
  74. package/src/app/shared/directives/stop-propagation/stop-propagation.directive.ts +12 -0
  75. package/src/index.html +1 -1
@@ -16,7 +16,8 @@
16
16
  'wght' 300;
17
17
  "
18
18
  title="New Smart Collection"
19
- (click)="$event.stopPropagation(); openCreateSmartCollection()"
19
+ appStopPropagation
20
+ (click)="openCreateSmartCollection()"
20
21
  >add</span
21
22
  >
22
23
  <ul class="flex flex-col py-0.5">
@@ -24,13 +25,12 @@
24
25
  <li>
25
26
  <a
26
27
  class="flex items-center px-3 py-1 text-xs font-normal cursor-pointer transition-colors"
27
- [style.background-color]="isSmartSelected(sc) ? 'var(--selected-bg)' : 'transparent'"
28
- [style.color]="isSmartSelected(sc) ? 'var(--text-primary)' : 'inherit'"
28
+ [appHoverHighlight]="isSmartSelected(sc)"
29
+ selectedColor="var(--text-primary)"
30
+ normalColor="inherit"
29
31
  (click)="selectSmartCollection(sc)"
30
32
  (dblclick)="editSmartCollection(sc)"
31
33
  (contextmenu)="onSmartContextMenu($event, sc)"
32
- (mouseenter)="$any($event.target).style.backgroundColor = isSmartSelected(sc) ? 'var(--selected-bg)' : 'var(--hover-bg)'"
33
- (mouseleave)="$any($event.target).style.backgroundColor = isSmartSelected(sc) ? 'var(--selected-bg)' : 'transparent'"
34
34
  >
35
35
  <span
36
36
  class="material-symbols-outlined icon-sm mr-1"
@@ -64,7 +64,8 @@
64
64
  'wght' 300;
65
65
  "
66
66
  title="New Collection"
67
- (click)="$event.stopPropagation(); openCreateRootCollection()"
67
+ appStopPropagation
68
+ (click)="openCreateRootCollection()"
68
69
  >add</span
69
70
  >
70
71
  <ul class="flex-1 overflow-auto flex flex-col py-0.5">
@@ -90,7 +91,7 @@
90
91
  (blur)="saveCreating()"
91
92
  (keydown.enter)="saveCreating()"
92
93
  (keydown.escape)="cancelCreating()"
93
- (click)="$event.stopPropagation()"
94
+ appStopPropagation
94
95
  placeholder="Collection name"
95
96
  appAutofocus
96
97
  />
@@ -164,7 +165,7 @@
164
165
  (blur)="saveEditing(node.collection.id)"
165
166
  (keydown.enter)="saveEditing(node.collection.id)"
166
167
  (keydown.escape)="cancelEditing()"
167
- (click)="$event.stopPropagation()"
168
+ appStopPropagation
168
169
  appAutofocus
169
170
  />
170
171
  } @else {
@@ -195,7 +196,7 @@
195
196
  (blur)="saveCreating()"
196
197
  (keydown.enter)="saveCreating()"
197
198
  (keydown.escape)="cancelCreating()"
198
- (click)="$event.stopPropagation()"
199
+ appStopPropagation
199
200
  placeholder="Collection name"
200
201
  appAutofocus
201
202
  />
@@ -233,6 +234,7 @@
233
234
  <app-rule-builder
234
235
  [smartCollection]="editingSmartCollection"
235
236
  (saved)="onRuleBuilderSaved($event)"
237
+ (savedAndSync)="onRuleBuilderSavedAndSync($event)"
236
238
  (cancelled)="onRuleBuilderCancelled()"
237
239
  />
238
240
  }
@@ -6,6 +6,8 @@ import { CollapsiblePanelComponent } from '../../shared/components/collapsible-p
6
6
  import { ContextMenuComponent, ContextMenuItem } from '../../shared/components/context-menu/context-menu.component';
7
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';
@@ -28,6 +30,8 @@ export interface TreeNode {
28
30
  ContextMenuComponent,
29
31
  RuleBuilderComponent,
30
32
  AutofocusDirective,
33
+ HoverHighlightDirective,
34
+ StopPropagationDirective,
31
35
  LibraryComponent,
32
36
  NewsStatsComponent,
33
37
  ],
@@ -476,6 +480,37 @@ export class NavigationComponent implements OnInit, OnDestroy {
476
480
  this.editingSmartCollection = null;
477
481
  }
478
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
+
479
514
  onRuleBuilderCancelled() {
480
515
  this.showRuleBuilder = false;
481
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
  }