fontastic 1.0.0 → 1.0.2

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 (55) hide show
  1. package/.github/workflows/macos.yml +0 -7
  2. package/.github/workflows/ubuntu.yml +0 -7
  3. package/.github/workflows/windows.yml +0 -7
  4. package/README.md +1 -0
  5. package/app/core/ConnectionManager.js +7 -1
  6. package/app/core/ConnectionManager.ts +11 -3
  7. package/app/core/MessageHandler.js +24 -0
  8. package/app/core/MessageHandler.ts +29 -0
  9. package/app/core/menu/templates/DarwinTemplate.js +16 -1
  10. package/app/core/menu/templates/DarwinTemplate.ts +16 -1
  11. package/app/core/menu/templates/SystemTemplate.js +16 -1
  12. package/app/core/menu/templates/SystemTemplate.ts +16 -1
  13. package/app/database/entity/SmartCollection.schema.js +66 -0
  14. package/app/database/entity/SmartCollection.schema.ts +39 -0
  15. package/app/database/entity/index.js +1 -0
  16. package/app/database/entity/index.ts +2 -1
  17. package/app/database/repository/SmartCollection.repository.js +47 -0
  18. package/app/database/repository/SmartCollection.repository.ts +30 -0
  19. package/app/database/repository/Store.repository.js +107 -0
  20. package/app/database/repository/Store.repository.ts +106 -0
  21. package/app/database/repository/index.js +1 -0
  22. package/app/database/repository/index.ts +2 -1
  23. package/app/enums/ChannelType.js +5 -0
  24. package/app/enums/ChannelType.ts +6 -0
  25. package/app/enums/StorageType.js +1 -0
  26. package/app/enums/StorageType.ts +1 -0
  27. package/app/package.json +1 -1
  28. package/app/types/FontMetrics.js +3 -0
  29. package/app/types/SmartCollection.js +3 -0
  30. package/app/types/SmartCollection.ts +5 -0
  31. package/app/types/SystemConfig.ts +2 -1
  32. package/app/types/index.js +2 -0
  33. package/app/types/index.ts +1 -0
  34. package/package.json +1 -1
  35. package/src/app/core/services/database/database.service.ts +70 -1
  36. package/src/app/core/services/message/message.service.ts +23 -0
  37. package/src/app/core/services/presentation/presentation.service.ts +62 -9
  38. package/src/app/layout/header/header.component.html +5 -5
  39. package/src/app/layout/layout.component.html +1 -1
  40. package/src/app/layout/main/main.component.html +3 -3
  41. package/src/app/layout/main/main.component.ts +2 -2
  42. package/src/app/layout/navigation/navigation.component.html +70 -4
  43. package/src/app/layout/navigation/navigation.component.ts +131 -27
  44. package/src/app/shared/components/context-menu/context-menu.component.ts +70 -21
  45. package/src/app/shared/components/datagrid/datagrid.component.html +82 -2
  46. package/src/app/shared/components/{inspector/inspector.component.html → glyphs/glyphs.component.html} +1 -1
  47. package/src/app/shared/components/glyphs/glyphs.component.ts +60 -0
  48. package/src/app/shared/components/index.ts +2 -1
  49. package/src/app/shared/components/rule-builder/rule-builder.component.html +94 -0
  50. package/src/app/shared/components/rule-builder/rule-builder.component.ts +136 -0
  51. package/src/app/shared/components/toolbar/toolbar.component.html +0 -81
  52. package/src/app/shared/components/toolbar/toolbar.component.ts +1 -2
  53. package/src/styles/base/variables.css +16 -0
  54. package/src/styles/components/spinner.css +4 -3
  55. package/src/app/shared/components/inspector/inspector.component.ts +0 -41
@@ -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 { DatabaseService, NewsService, PresentationService } from '../../../core/services';
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
  --------------------------------------------------------------------------- */
@@ -1,7 +1,8 @@
1
1
  .app-spinner {
2
2
  @apply relative block rounded-full w-[19px] h-[19px] m-0 p-0 opacity-0 overflow-hidden border-4 border-[#00a0be];
3
3
  border-left: 4px solid #fff;
4
- &.is-animating {
5
- @apply opacity-100 animate-spin;
6
- }
4
+ }
5
+
6
+ .app-spinner.is-animating {
7
+ @apply opacity-100 animate-spin;
7
8
  }
@@ -1,41 +0,0 @@
1
- import { Component, inject, signal, computed, effect } from '@angular/core';
2
- import { DatabaseService } from '../../../core/services';
3
-
4
- const PAGE_SIZE = 200;
5
-
6
- @Component({
7
- selector: 'app-inspector',
8
- standalone: true,
9
- templateUrl: './inspector.component.html',
10
- })
11
- export class InspectorComponent {
12
- readonly db = inject(DatabaseService);
13
-
14
- readonly currentPage = signal(1);
15
- readonly selectedGlyph = signal<number | null>(null);
16
-
17
- readonly totalPages = computed(() => Math.max(1, Math.ceil(this.db.glyphs().length / PAGE_SIZE)));
18
-
19
- readonly pageGlyphs = computed(() => {
20
- const glyphs = this.db.glyphs();
21
- const start = (this.currentPage() - 1) * PAGE_SIZE;
22
- return glyphs.slice(start, start + PAGE_SIZE);
23
- });
24
-
25
- readonly fontFamily = computed(() => {
26
- const store = this.db.store();
27
- return store ? store.full_name || store.font_family || '' : '';
28
- });
29
-
30
- constructor() {
31
- effect(() => {
32
- this.db.glyphs();
33
- this.currentPage.set(1);
34
- this.selectedGlyph.set(null);
35
- });
36
- }
37
-
38
- toChar(codePoint: number): string {
39
- return String.fromCodePoint(codePoint);
40
- }
41
- }