@foliokit/cms-core 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/README.md +62 -2
- package/eslint.config.mjs +48 -0
- package/ng-package.json +7 -0
- package/package.json +5 -18
- package/project.json +32 -0
- package/{esm2022/index.js → src/index.ts} +9 -3
- package/src/lib/cms-core/cms-core.html +1 -0
- package/src/lib/cms-core/cms-core.scss +0 -0
- package/src/lib/cms-core/cms-core.spec.ts +44 -0
- package/src/lib/cms-core/cms-core.ts +9 -0
- package/src/lib/firebase/firebase-admin.ts +32 -0
- package/src/lib/firebase/firebase.config.ts +26 -0
- package/src/lib/firebase/firebase.providers.ts +89 -0
- package/src/lib/firebase/foliokit.providers.ts +178 -0
- package/src/lib/models/author.model.ts +16 -0
- package/src/lib/models/page.model.ts +11 -0
- package/src/lib/models/post.model.ts +41 -0
- package/src/lib/models/site-config.model.ts +103 -0
- package/src/lib/models/tag.model.ts +5 -0
- package/src/lib/pipes/tag-label.pipe.ts +16 -0
- package/src/lib/resolvers/about-page.resolver.ts +76 -0
- package/src/lib/resolvers/links-page.resolver.ts +77 -0
- package/src/lib/resolvers/posts.resolver.ts +51 -0
- package/src/lib/services/auth.service.ts +49 -0
- package/src/lib/services/author.service.ts +88 -0
- package/src/lib/services/post.service.spec.ts +255 -0
- package/src/lib/services/post.service.ts +148 -0
- package/src/lib/services/site-config.service.ts +86 -0
- package/src/lib/services/tag.service.ts +24 -0
- package/src/lib/tokens/post-service.token.ts +14 -0
- package/src/lib/tokens/site-config-service.token.ts +12 -0
- package/src/lib/utils/normalize-author.ts +50 -0
- package/src/lib/utils/normalize-post.ts +66 -0
- package/src/lib/utils/normalize-site-config.ts +145 -0
- package/testing/firestore.stub.ts +65 -0
- package/tsconfig.json +31 -0
- package/tsconfig.lib.json +12 -0
- package/tsconfig.lib.prod.json +9 -0
- package/tsconfig.spec.json +8 -0
- package/esm2022/foliokit-cms-core.js +0 -5
- package/esm2022/foliokit-cms-core.js.map +0 -1
- package/esm2022/index.js.map +0 -1
- package/esm2022/lib/firebase/firebase.config.js +0 -8
- package/esm2022/lib/firebase/firebase.config.js.map +0 -1
- package/esm2022/lib/firebase/firebase.providers.js +0 -54
- package/esm2022/lib/firebase/firebase.providers.js.map +0 -1
- package/esm2022/lib/models/author.model.js +0 -1
- package/esm2022/lib/models/author.model.js.map +0 -1
- package/esm2022/lib/models/page.model.js +0 -1
- package/esm2022/lib/models/page.model.js.map +0 -1
- package/esm2022/lib/models/post.model.js +0 -1
- package/esm2022/lib/models/post.model.js.map +0 -1
- package/esm2022/lib/models/site-config.model.js +0 -1
- package/esm2022/lib/models/site-config.model.js.map +0 -1
- package/esm2022/lib/models/tag.model.js +0 -1
- package/esm2022/lib/models/tag.model.js.map +0 -1
- package/esm2022/lib/services/auth.service.js +0 -42
- package/esm2022/lib/services/auth.service.js.map +0 -1
- package/esm2022/lib/services/page.service.js +0 -73
- package/esm2022/lib/services/page.service.js.map +0 -1
- package/esm2022/lib/services/post.service.js +0 -83
- package/esm2022/lib/services/post.service.js.map +0 -1
- package/esm2022/lib/services/site-config.service.js +0 -31
- package/esm2022/lib/services/site-config.service.js.map +0 -1
- package/esm2022/lib/services/tag.service.js +0 -22
- package/esm2022/lib/services/tag.service.js.map +0 -1
- package/esm2022/lib/tokens/page-service.token.js +0 -4
- package/esm2022/lib/tokens/page-service.token.js.map +0 -1
- package/esm2022/lib/tokens/post-service.token.js +0 -5
- package/esm2022/lib/tokens/post-service.token.js.map +0 -1
- package/esm2022/lib/utils/normalize-page.js +0 -74
- package/esm2022/lib/utils/normalize-page.js.map +0 -1
- package/esm2022/lib/utils/normalize-post.js +0 -66
- package/esm2022/lib/utils/normalize-post.js.map +0 -1
- package/esm2022/lib/utils/normalize-site-config.js +0 -62
- package/esm2022/lib/utils/normalize-site-config.js.map +0 -1
- package/foliokit-cms-core.d.ts +0 -5
- package/index.d.ts +0 -14
- package/lib/firebase/firebase.config.d.ts +0 -11
- package/lib/firebase/firebase.providers.d.ts +0 -3
- package/lib/models/author.model.d.ts +0 -14
- package/lib/models/page.model.d.ts +0 -40
- package/lib/models/post.model.d.ts +0 -39
- package/lib/models/site-config.model.d.ts +0 -27
- package/lib/models/tag.model.d.ts +0 -5
- package/lib/services/auth.service.d.ts +0 -13
- package/lib/services/page.service.d.ts +0 -15
- package/lib/services/post.service.d.ts +0 -17
- package/lib/services/site-config.service.d.ts +0 -10
- package/lib/services/tag.service.d.ts +0 -9
- package/lib/tokens/page-service.token.d.ts +0 -9
- package/lib/tokens/post-service.token.d.ts +0 -10
- package/lib/utils/normalize-page.d.ts +0 -2
- package/lib/utils/normalize-post.d.ts +0 -6
- package/lib/utils/normalize-site-config.d.ts +0 -2
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
import { lastValueFrom } from 'rxjs';
|
|
3
|
+
import {
|
|
4
|
+
collection,
|
|
5
|
+
doc,
|
|
6
|
+
getDoc,
|
|
7
|
+
getDocs,
|
|
8
|
+
orderBy,
|
|
9
|
+
query,
|
|
10
|
+
setDoc,
|
|
11
|
+
Timestamp,
|
|
12
|
+
updateDoc,
|
|
13
|
+
} from 'firebase/firestore';
|
|
14
|
+
import { deleteObject, ref } from 'firebase/storage';
|
|
15
|
+
import { FIREBASE_STORAGE, FIRESTORE } from '../firebase/firebase.config';
|
|
16
|
+
import { PostService } from './post.service';
|
|
17
|
+
import {
|
|
18
|
+
fakeTimestamp,
|
|
19
|
+
firestoreStub,
|
|
20
|
+
makeBlogPost,
|
|
21
|
+
mockDocSnapshot,
|
|
22
|
+
mockQuerySnapshot,
|
|
23
|
+
} from '../../../testing/firestore.stub';
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Module mocks — hoisted before all imports by Vitest.
|
|
27
|
+
// Replaces every firebase SDK export with vi.fn() so the real SDK
|
|
28
|
+
// is never imported or initialised.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
vi.mock('firebase/firestore', () => ({
|
|
31
|
+
collection: vi.fn(() => ({})),
|
|
32
|
+
doc: vi.fn(() => ({ id: 'generated-xyz' })),
|
|
33
|
+
getDoc: vi.fn(),
|
|
34
|
+
getDocs: vi.fn(),
|
|
35
|
+
setDoc: vi.fn(),
|
|
36
|
+
updateDoc: vi.fn(),
|
|
37
|
+
query: vi.fn((ref: unknown) => ref),
|
|
38
|
+
orderBy: vi.fn((...args: unknown[]) => args),
|
|
39
|
+
where: vi.fn((...args: unknown[]) => args),
|
|
40
|
+
limit: vi.fn((n: number) => n),
|
|
41
|
+
Timestamp: {
|
|
42
|
+
now: vi.fn(() => fakeTimestamp),
|
|
43
|
+
fromMillis: vi.fn(() => fakeTimestamp),
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
vi.mock('firebase/storage', () => ({
|
|
48
|
+
ref: vi.fn(() => ({})),
|
|
49
|
+
deleteObject: vi.fn(),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
describe('PostService', () => {
|
|
53
|
+
let service: PostService;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
|
|
58
|
+
// collection always returns an opaque ref object; doc and query pass it through
|
|
59
|
+
vi.mocked(collection).mockReturnValue({} as ReturnType<typeof collection>);
|
|
60
|
+
vi.mocked(doc).mockReturnValue({ id: 'generated-xyz' } as ReturnType<typeof doc>);
|
|
61
|
+
vi.mocked(query).mockImplementation((ref) => ref as ReturnType<typeof query>);
|
|
62
|
+
|
|
63
|
+
TestBed.configureTestingModule({
|
|
64
|
+
providers: [
|
|
65
|
+
PostService,
|
|
66
|
+
{ provide: FIRESTORE, useValue: firestoreStub },
|
|
67
|
+
{ provide: FIREBASE_STORAGE, useValue: {} },
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
service = TestBed.inject(PostService);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
describe('getPostById', () => {
|
|
76
|
+
it('returns the mapped BlogPost when the document exists', async () => {
|
|
77
|
+
const raw = {
|
|
78
|
+
slug: 'hello',
|
|
79
|
+
title: 'Hello World',
|
|
80
|
+
status: 'draft',
|
|
81
|
+
content: '',
|
|
82
|
+
tags: [],
|
|
83
|
+
embeddedMedia: {},
|
|
84
|
+
seo: {},
|
|
85
|
+
publishedAt: fakeTimestamp,
|
|
86
|
+
updatedAt: fakeTimestamp,
|
|
87
|
+
createdAt: fakeTimestamp,
|
|
88
|
+
};
|
|
89
|
+
vi.mocked(getDoc).mockResolvedValue(
|
|
90
|
+
mockDocSnapshot('post-abc', raw) as Awaited<ReturnType<typeof getDoc>>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const result = await lastValueFrom(service.getPostById('post-abc'));
|
|
94
|
+
|
|
95
|
+
// normalizePost converts Firestore Timestamps to milliseconds
|
|
96
|
+
const expectedMs = fakeTimestamp.seconds * 1000;
|
|
97
|
+
expect(result).toMatchObject({
|
|
98
|
+
id: 'post-abc',
|
|
99
|
+
slug: 'hello',
|
|
100
|
+
title: 'Hello World',
|
|
101
|
+
status: 'draft',
|
|
102
|
+
publishedAt: expectedMs,
|
|
103
|
+
updatedAt: expectedMs,
|
|
104
|
+
createdAt: expectedMs,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('throws an error when the document does not exist', async () => {
|
|
109
|
+
vi.mocked(getDoc).mockResolvedValue(
|
|
110
|
+
mockDocSnapshot('missing', null) as Awaited<ReturnType<typeof getDoc>>,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await expect(lastValueFrom(service.getPostById('missing'))).rejects.toThrow(
|
|
114
|
+
'Post not found: missing',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// -------------------------------------------------------------------------
|
|
120
|
+
describe('getAllPosts', () => {
|
|
121
|
+
it('returns all posts mapped from the snapshot', async () => {
|
|
122
|
+
const post1 = makeBlogPost({ id: 'a', title: 'Post A' });
|
|
123
|
+
const post2 = makeBlogPost({ id: 'b', title: 'Post B' });
|
|
124
|
+
const { id: _1, ...data1 } = post1;
|
|
125
|
+
const { id: _2, ...data2 } = post2;
|
|
126
|
+
|
|
127
|
+
vi.mocked(getDocs).mockResolvedValue(
|
|
128
|
+
mockQuerySnapshot([
|
|
129
|
+
{ id: 'a', data: data1 as unknown as Record<string, unknown> },
|
|
130
|
+
{ id: 'b', data: data2 as unknown as Record<string, unknown> },
|
|
131
|
+
]) as Awaited<ReturnType<typeof getDocs>>,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const result = await lastValueFrom(service.getAllPosts());
|
|
135
|
+
|
|
136
|
+
expect(result).toHaveLength(2);
|
|
137
|
+
expect(result[0].id).toBe('a');
|
|
138
|
+
expect(result[1].id).toBe('b');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('passes orderBy("updatedAt", "desc") to query', async () => {
|
|
142
|
+
vi.mocked(getDocs).mockResolvedValue(
|
|
143
|
+
mockQuerySnapshot([]) as Awaited<ReturnType<typeof getDocs>>,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await lastValueFrom(service.getAllPosts());
|
|
147
|
+
|
|
148
|
+
expect(vi.mocked(orderBy)).toHaveBeenCalledWith('updatedAt', 'desc');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns an empty array when the collection is empty', async () => {
|
|
152
|
+
vi.mocked(getDocs).mockResolvedValue(
|
|
153
|
+
mockQuerySnapshot([]) as Awaited<ReturnType<typeof getDocs>>,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const result = await lastValueFrom(service.getAllPosts());
|
|
157
|
+
|
|
158
|
+
expect(result).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
describe('savePost — new post (id === "")', () => {
|
|
164
|
+
it('calls setDoc with the generated id and returns the post with that id', async () => {
|
|
165
|
+
const newPost = makeBlogPost({ id: '' });
|
|
166
|
+
vi.mocked(setDoc).mockResolvedValue(undefined);
|
|
167
|
+
|
|
168
|
+
const result = await lastValueFrom(service.savePost(newPost));
|
|
169
|
+
|
|
170
|
+
expect(vi.mocked(setDoc)).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(vi.mocked(updateDoc)).not.toHaveBeenCalled();
|
|
172
|
+
expect(result.id).toBe('generated-xyz');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('stores the id field inside the document payload', async () => {
|
|
176
|
+
const newPost = makeBlogPost({ id: '' });
|
|
177
|
+
vi.mocked(setDoc).mockResolvedValue(undefined);
|
|
178
|
+
|
|
179
|
+
await lastValueFrom(service.savePost(newPost));
|
|
180
|
+
|
|
181
|
+
expect(vi.mocked(setDoc)).toHaveBeenCalledWith(
|
|
182
|
+
expect.anything(),
|
|
183
|
+
expect.objectContaining({ id: 'generated-xyz' }),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('stamps createdAt and updatedAt as numeric milliseconds', async () => {
|
|
188
|
+
const newPost = makeBlogPost({ id: '' });
|
|
189
|
+
vi.mocked(setDoc).mockResolvedValue(undefined);
|
|
190
|
+
|
|
191
|
+
const result = await lastValueFrom(service.savePost(newPost));
|
|
192
|
+
|
|
193
|
+
expect(typeof result.createdAt).toBe('number');
|
|
194
|
+
expect(typeof result.updatedAt).toBe('number');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// -------------------------------------------------------------------------
|
|
199
|
+
describe('savePost — existing post (id !== "")', () => {
|
|
200
|
+
it('calls updateDoc and returns the post with updated updatedAt', async () => {
|
|
201
|
+
const existingPost = makeBlogPost({ id: 'post-existing' });
|
|
202
|
+
vi.mocked(updateDoc).mockResolvedValue(undefined);
|
|
203
|
+
|
|
204
|
+
const result = await lastValueFrom(service.savePost(existingPost));
|
|
205
|
+
|
|
206
|
+
expect(vi.mocked(updateDoc)).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(vi.mocked(updateDoc)).toHaveBeenCalledWith(
|
|
208
|
+
expect.anything(),
|
|
209
|
+
expect.objectContaining({ updatedAt: fakeTimestamp }),
|
|
210
|
+
);
|
|
211
|
+
expect(vi.mocked(setDoc)).not.toHaveBeenCalled();
|
|
212
|
+
expect(result.id).toBe('post-existing');
|
|
213
|
+
expect(typeof result.updatedAt).toBe('number');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
describe('Timestamp.now mock', () => {
|
|
219
|
+
it('does not call the real Firebase SDK', () => {
|
|
220
|
+
expect(vi.mocked(Timestamp.now)).toBeDefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// -------------------------------------------------------------------------
|
|
225
|
+
describe('deleteStorageFile', () => {
|
|
226
|
+
it('calls ref() with the storage instance and the given path', async () => {
|
|
227
|
+
vi.mocked(deleteObject).mockResolvedValue(undefined);
|
|
228
|
+
|
|
229
|
+
await lastValueFrom(service.deleteStorageFile('posts/p1/cover/img.jpg'));
|
|
230
|
+
|
|
231
|
+
expect(vi.mocked(ref)).toHaveBeenCalledWith(
|
|
232
|
+
expect.anything(),
|
|
233
|
+
'posts/p1/cover/img.jpg',
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('calls deleteObject with the ref returned by ref()', async () => {
|
|
238
|
+
const fakeRef = { _path: 'posts/p1/cover/img.jpg' };
|
|
239
|
+
vi.mocked(ref).mockReturnValue(fakeRef as unknown as ReturnType<typeof ref>);
|
|
240
|
+
vi.mocked(deleteObject).mockResolvedValue(undefined);
|
|
241
|
+
|
|
242
|
+
await lastValueFrom(service.deleteStorageFile('posts/p1/cover/img.jpg'));
|
|
243
|
+
|
|
244
|
+
expect(vi.mocked(deleteObject)).toHaveBeenCalledWith(fakeRef);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('returns an Observable that resolves to void when deleteObject succeeds', async () => {
|
|
248
|
+
vi.mocked(deleteObject).mockResolvedValue(undefined);
|
|
249
|
+
|
|
250
|
+
const result = await lastValueFrom(service.deleteStorageFile('any/path'));
|
|
251
|
+
|
|
252
|
+
expect(result).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import {
|
|
3
|
+
collection,
|
|
4
|
+
doc,
|
|
5
|
+
getDoc,
|
|
6
|
+
getDocs,
|
|
7
|
+
limit,
|
|
8
|
+
orderBy,
|
|
9
|
+
query,
|
|
10
|
+
setDoc,
|
|
11
|
+
Timestamp,
|
|
12
|
+
updateDoc,
|
|
13
|
+
where,
|
|
14
|
+
} from 'firebase/firestore';
|
|
15
|
+
import { deleteObject, ref } from 'firebase/storage';
|
|
16
|
+
import { from, Observable, of } from 'rxjs';
|
|
17
|
+
import { catchError, map } from 'rxjs/operators';
|
|
18
|
+
import { FIREBASE_STORAGE, FIRESTORE } from '../firebase/firebase.config';
|
|
19
|
+
import type { BlogPost } from '../models/post.model';
|
|
20
|
+
import { normalizePost } from '../utils/normalize-post';
|
|
21
|
+
import type { IBlogPostService } from '../tokens/post-service.token';
|
|
22
|
+
|
|
23
|
+
@Injectable({ providedIn: 'root' })
|
|
24
|
+
export class PostService implements IBlogPostService {
|
|
25
|
+
// Non-null assertions are safe: PostService is only active in the browser
|
|
26
|
+
// where FIRESTORE and FIREBASE_STORAGE are always initialized.
|
|
27
|
+
// On the server, BLOG_POST_SERVICE resolves to ServerBlogPostService instead.
|
|
28
|
+
private readonly firestore = inject(FIRESTORE)!;
|
|
29
|
+
private readonly storage = inject(FIREBASE_STORAGE)!;
|
|
30
|
+
|
|
31
|
+
getPublishedPosts(): Observable<BlogPost[]> {
|
|
32
|
+
const q = query(
|
|
33
|
+
collection(this.firestore, 'posts'),
|
|
34
|
+
where('status', '==', 'published'),
|
|
35
|
+
orderBy('publishedAt', 'desc'),
|
|
36
|
+
);
|
|
37
|
+
return from(getDocs(q)).pipe(
|
|
38
|
+
map((snapshot) =>
|
|
39
|
+
snapshot.docs.map((d) => normalizePost({ id: d.id, ...d.data() })),
|
|
40
|
+
),
|
|
41
|
+
catchError((err) => {
|
|
42
|
+
console.error('[PostService.getPublishedPosts]', err);
|
|
43
|
+
return of([]);
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getPostBySlug(slug: string): Observable<BlogPost | null> {
|
|
49
|
+
const q = query(
|
|
50
|
+
collection(this.firestore, 'posts'),
|
|
51
|
+
where('status', '==', 'published'),
|
|
52
|
+
where('slug', '==', slug),
|
|
53
|
+
limit(1),
|
|
54
|
+
);
|
|
55
|
+
return from(getDocs(q)).pipe(
|
|
56
|
+
map((snapshot) => {
|
|
57
|
+
if (snapshot.empty) return null;
|
|
58
|
+
const d = snapshot.docs[0];
|
|
59
|
+
return normalizePost({ id: d.id, ...d.data() });
|
|
60
|
+
}),
|
|
61
|
+
catchError((err) => {
|
|
62
|
+
console.error('[PostService.getPostBySlug]', err);
|
|
63
|
+
return of(null);
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getPostsByTag(tag: string): Observable<BlogPost[]> {
|
|
69
|
+
const q = query(
|
|
70
|
+
collection(this.firestore, 'posts'),
|
|
71
|
+
where('status', '==', 'published'),
|
|
72
|
+
where('tags', 'array-contains', tag),
|
|
73
|
+
orderBy('publishedAt', 'desc'),
|
|
74
|
+
);
|
|
75
|
+
return from(getDocs(q)).pipe(
|
|
76
|
+
map((snapshot) =>
|
|
77
|
+
snapshot.docs.map((d) => normalizePost({ id: d.id, ...d.data() })),
|
|
78
|
+
),
|
|
79
|
+
catchError((err) => {
|
|
80
|
+
console.error('[PostService.getPostsByTag]', err);
|
|
81
|
+
return of([]);
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getAllPosts(): Observable<BlogPost[]> {
|
|
87
|
+
const q = query(
|
|
88
|
+
collection(this.firestore, 'posts'),
|
|
89
|
+
orderBy('updatedAt', 'desc'),
|
|
90
|
+
);
|
|
91
|
+
return from(getDocs(q)).pipe(
|
|
92
|
+
map((snapshot) =>
|
|
93
|
+
snapshot.docs.map((d) => normalizePost({ id: d.id, ...d.data() })),
|
|
94
|
+
),
|
|
95
|
+
catchError((err) => {
|
|
96
|
+
console.error('[PostService.getAllPosts]', err);
|
|
97
|
+
return of([]);
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
getPostById(id: string): Observable<BlogPost> {
|
|
103
|
+
return from(getDoc(doc(this.firestore, 'posts', id))).pipe(
|
|
104
|
+
map((snapshot) => {
|
|
105
|
+
if (!snapshot.exists()) throw new Error(`Post not found: ${id}`);
|
|
106
|
+
return normalizePost({ id: snapshot.id, ...snapshot.data() });
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
deleteStorageFile(storagePath: string): Observable<void> {
|
|
112
|
+
const fileRef = ref(this.storage, storagePath);
|
|
113
|
+
return from(deleteObject(fileRef));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
savePost(post: BlogPost): Observable<BlogPost> {
|
|
117
|
+
const nowMs = Date.now();
|
|
118
|
+
const nowTs = Timestamp.fromMillis(nowMs);
|
|
119
|
+
|
|
120
|
+
if (post.id === '') {
|
|
121
|
+
const newId = doc(collection(this.firestore, 'posts')).id;
|
|
122
|
+
const savedPost: BlogPost = { ...post, id: newId, createdAt: nowMs, updatedAt: nowMs };
|
|
123
|
+
// Write Timestamp objects to Firestore for proper ordering/querying
|
|
124
|
+
const firestorePayload = { ...savedPost, createdAt: nowTs, updatedAt: nowTs };
|
|
125
|
+
return from(
|
|
126
|
+
setDoc(doc(this.firestore, 'posts', newId), firestorePayload),
|
|
127
|
+
).pipe(
|
|
128
|
+
map(() => savedPost),
|
|
129
|
+
catchError((err) => {
|
|
130
|
+
console.error('[PostService.savePost/create]', err);
|
|
131
|
+
throw err;
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const savedPost: BlogPost = { ...post, updatedAt: nowMs };
|
|
137
|
+
const firestorePayload = { ...savedPost, updatedAt: nowTs };
|
|
138
|
+
return from(
|
|
139
|
+
updateDoc(doc(this.firestore, 'posts', post.id), firestorePayload),
|
|
140
|
+
).pipe(
|
|
141
|
+
map(() => savedPost),
|
|
142
|
+
catchError((err) => {
|
|
143
|
+
console.error('[PostService.savePost/update]', err);
|
|
144
|
+
throw err;
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { inject, Injectable, makeStateKey } from '@angular/core';
|
|
2
|
+
import { doc, getDoc, setDoc, Timestamp } from 'firebase/firestore';
|
|
3
|
+
import { defer, from, Observable, of } from 'rxjs';
|
|
4
|
+
import { catchError, filter, map } from 'rxjs/operators';
|
|
5
|
+
import { FIRESTORE } from '../firebase/firebase.config';
|
|
6
|
+
import type { AboutPageConfig, SiteConfig } from '../models/site-config.model';
|
|
7
|
+
import { normalizeSiteConfig } from '../utils/normalize-site-config';
|
|
8
|
+
import type { ISiteConfigService } from '../tokens/site-config-service.token';
|
|
9
|
+
|
|
10
|
+
export const ABOUT_CONFIG_TRANSFER_KEY = makeStateKey<AboutPageConfig | null>('about-config');
|
|
11
|
+
|
|
12
|
+
function stripUndefined(obj: Record<string, unknown>): Record<string, unknown> {
|
|
13
|
+
const result: Record<string, unknown> = {};
|
|
14
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
15
|
+
if (value === undefined) continue;
|
|
16
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value) && value.constructor === Object) {
|
|
17
|
+
result[key] = stripUndefined(value as Record<string, unknown>);
|
|
18
|
+
} else {
|
|
19
|
+
result[key] = value;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Injectable({ providedIn: 'root' })
|
|
26
|
+
export class SiteConfigService implements ISiteConfigService {
|
|
27
|
+
private readonly firestore = inject(FIRESTORE);
|
|
28
|
+
|
|
29
|
+
getSiteConfig(siteId: string): Observable<SiteConfig | null> {
|
|
30
|
+
if (!this.firestore) return of(null);
|
|
31
|
+
const ref = doc(this.firestore, 'site-config', siteId);
|
|
32
|
+
return from(getDoc(ref)).pipe(
|
|
33
|
+
map((snap) => {
|
|
34
|
+
if (!snap.exists()) return null;
|
|
35
|
+
return normalizeSiteConfig({ id: snap.id, ...snap.data() });
|
|
36
|
+
}),
|
|
37
|
+
catchError((err) => {
|
|
38
|
+
console.error('[SiteConfigService.getSiteConfig]', err);
|
|
39
|
+
return of(null);
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getDefaultSiteConfig(): Observable<SiteConfig | null> {
|
|
45
|
+
return this.getSiteConfig('default');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Returns the default SiteConfig, filtering out null (no-document) results. */
|
|
49
|
+
getConfig(): Observable<SiteConfig> {
|
|
50
|
+
return this.getDefaultSiteConfig().pipe(
|
|
51
|
+
filter((c): c is SiteConfig => c !== null),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Convenience method that maps the default SiteConfig to its pages.about field.
|
|
57
|
+
* Does not make a separate Firestore read.
|
|
58
|
+
* TransferState hydration is handled by the aboutPageResolver in the blog app.
|
|
59
|
+
*/
|
|
60
|
+
getAboutConfig(): Observable<AboutPageConfig | null> {
|
|
61
|
+
return this.getConfig().pipe(
|
|
62
|
+
map((c) => c.pages?.about ?? null),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
saveSiteConfig(config: SiteConfig): Observable<SiteConfig> {
|
|
67
|
+
if (!this.firestore) return of(config);
|
|
68
|
+
const siteId = config.id || 'default';
|
|
69
|
+
const nowMs = Date.now();
|
|
70
|
+
const nowTs = Timestamp.fromMillis(nowMs);
|
|
71
|
+
const saved: SiteConfig = { ...config, id: siteId, updatedAt: nowMs };
|
|
72
|
+
const firestorePayload = stripUndefined({
|
|
73
|
+
...saved,
|
|
74
|
+
updatedAt: nowTs,
|
|
75
|
+
} as unknown as Record<string, unknown>);
|
|
76
|
+
return defer(() =>
|
|
77
|
+
setDoc(doc(this.firestore!, 'site-config', siteId), firestorePayload),
|
|
78
|
+
).pipe(
|
|
79
|
+
map(() => saved),
|
|
80
|
+
catchError((err) => {
|
|
81
|
+
console.error('[SiteConfigService.saveSiteConfig]', err);
|
|
82
|
+
throw err;
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import { collection, getDocs } from 'firebase/firestore';
|
|
3
|
+
import { from, Observable, of } from 'rxjs';
|
|
4
|
+
import { catchError, map } from 'rxjs/operators';
|
|
5
|
+
import { FIRESTORE } from '../firebase/firebase.config';
|
|
6
|
+
import type { Tag } from '../models/tag.model';
|
|
7
|
+
|
|
8
|
+
@Injectable({ providedIn: 'root' })
|
|
9
|
+
export class TagService {
|
|
10
|
+
private readonly firestore = inject(FIRESTORE);
|
|
11
|
+
|
|
12
|
+
getAllTags(): Observable<Tag[]> {
|
|
13
|
+
if (!this.firestore) return of([]);
|
|
14
|
+
return from(getDocs(collection(this.firestore, 'tags'))).pipe(
|
|
15
|
+
map((snapshot) =>
|
|
16
|
+
snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }) as Tag),
|
|
17
|
+
),
|
|
18
|
+
catchError((err) => {
|
|
19
|
+
console.error('[TagService.getAllTags]', err);
|
|
20
|
+
return of([]);
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { InjectionToken, makeStateKey } from '@angular/core';
|
|
2
|
+
import type { Observable } from 'rxjs';
|
|
3
|
+
import type { BlogPost } from '../models/post.model';
|
|
4
|
+
|
|
5
|
+
export interface IBlogPostService {
|
|
6
|
+
getPublishedPosts(): Observable<BlogPost[]>;
|
|
7
|
+
getPostBySlug(slug: string): Observable<BlogPost | null>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const BLOG_POST_SERVICE = new InjectionToken<IBlogPostService>(
|
|
11
|
+
'BLOG_POST_SERVICE',
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export const POSTS_TRANSFER_KEY = makeStateKey<BlogPost[]>('blog-posts');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { InjectionToken } from '@angular/core';
|
|
2
|
+
import type { Observable } from 'rxjs';
|
|
3
|
+
import type { AboutPageConfig, SiteConfig } from '../models/site-config.model';
|
|
4
|
+
|
|
5
|
+
export interface ISiteConfigService {
|
|
6
|
+
getAboutConfig(): Observable<AboutPageConfig | null>;
|
|
7
|
+
getConfig(): Observable<SiteConfig>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SITE_CONFIG_SERVICE = new InjectionToken<ISiteConfigService>(
|
|
11
|
+
'SITE_CONFIG_SERVICE',
|
|
12
|
+
);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Author } from '../models/author.model';
|
|
2
|
+
import type { SocialLink, SocialPlatform } from '../models/site-config.model';
|
|
3
|
+
|
|
4
|
+
function normalizeTimestamp(value: unknown): number {
|
|
5
|
+
if (value == null) return 0;
|
|
6
|
+
if (typeof value === 'number') return value;
|
|
7
|
+
if (value instanceof Date) return value.getTime();
|
|
8
|
+
if (typeof value === 'object') {
|
|
9
|
+
const v = value as Record<string, unknown>;
|
|
10
|
+
if (typeof (v as { toMillis?: unknown }).toMillis === 'function') {
|
|
11
|
+
return (v as { toMillis(): number }).toMillis();
|
|
12
|
+
}
|
|
13
|
+
if (typeof (v as { toDate?: unknown }).toDate === 'function') {
|
|
14
|
+
return (v as { toDate(): Date }).toDate().getTime();
|
|
15
|
+
}
|
|
16
|
+
if (typeof v['_seconds'] === 'number') {
|
|
17
|
+
return (v['_seconds'] as number) * 1000 +
|
|
18
|
+
Math.floor(((v['_nanoseconds'] as number) ?? 0) / 1e6);
|
|
19
|
+
}
|
|
20
|
+
if (typeof v['seconds'] === 'number') {
|
|
21
|
+
return (v['seconds'] as number) * 1000 +
|
|
22
|
+
Math.floor(((v['nanoseconds'] as number) ?? 0) / 1e6);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeSocialLinks(raw: unknown): SocialLink[] | undefined {
|
|
29
|
+
if (!Array.isArray(raw)) return undefined;
|
|
30
|
+
return (raw as Record<string, unknown>[]).map((item) => ({
|
|
31
|
+
platform: item['platform'] as SocialPlatform,
|
|
32
|
+
url: (item['url'] as string) ?? '',
|
|
33
|
+
label: item['label'] as string | undefined,
|
|
34
|
+
icon: item['icon'] as string | undefined,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeAuthor(raw: Record<string, unknown>): Author {
|
|
39
|
+
return {
|
|
40
|
+
id: (raw['id'] as string) ?? '',
|
|
41
|
+
displayName: (raw['displayName'] as string) ?? '',
|
|
42
|
+
bio: raw['bio'] as string | undefined,
|
|
43
|
+
photoUrl: raw['photoUrl'] as string | undefined,
|
|
44
|
+
photoUrlDark: raw['photoUrlDark'] as string | undefined,
|
|
45
|
+
socialLinks: normalizeSocialLinks(raw['socialLinks']),
|
|
46
|
+
email: raw['email'] as string | undefined,
|
|
47
|
+
createdAt: normalizeTimestamp(raw['createdAt']),
|
|
48
|
+
updatedAt: normalizeTimestamp(raw['updatedAt']),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { BlogPost } from '../models/post.model';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts a Firestore timestamp in any of its runtime forms to milliseconds.
|
|
5
|
+
*
|
|
6
|
+
* Handles:
|
|
7
|
+
* - Native Timestamp objects from either SDK (have .toMillis())
|
|
8
|
+
* - Objects with .toDate() method (client SDK Timestamp without .toMillis())
|
|
9
|
+
* - Admin SDK serialized form { _seconds, _nanoseconds }
|
|
10
|
+
* - Plain { seconds, nanoseconds } objects
|
|
11
|
+
* - Already-numeric millisecond values
|
|
12
|
+
* - JavaScript Date objects
|
|
13
|
+
*/
|
|
14
|
+
function normalizeTimestamp(value: unknown): number {
|
|
15
|
+
if (value == null) return 0;
|
|
16
|
+
if (typeof value === 'number') return value;
|
|
17
|
+
if (value instanceof Date) return value.getTime();
|
|
18
|
+
if (typeof value === 'object') {
|
|
19
|
+
const v = value as Record<string, unknown>;
|
|
20
|
+
if (typeof (v as { toMillis?: unknown }).toMillis === 'function') {
|
|
21
|
+
return (v as { toMillis(): number }).toMillis();
|
|
22
|
+
}
|
|
23
|
+
if (typeof (v as { toDate?: unknown }).toDate === 'function') {
|
|
24
|
+
return (v as { toDate(): Date }).toDate().getTime();
|
|
25
|
+
}
|
|
26
|
+
if (typeof v['_seconds'] === 'number') {
|
|
27
|
+
return (v['_seconds'] as number) * 1000 +
|
|
28
|
+
Math.floor(((v['_nanoseconds'] as number) ?? 0) / 1e6);
|
|
29
|
+
}
|
|
30
|
+
if (typeof v['seconds'] === 'number') {
|
|
31
|
+
return (v['seconds'] as number) * 1000 +
|
|
32
|
+
Math.floor(((v['nanoseconds'] as number) ?? 0) / 1e6);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Maps raw Firestore DocumentData (merged with its doc id) into a fully typed,
|
|
40
|
+
* TransferState-serializable BlogPost. Callers must pass { id: doc.id, ...doc.data() }.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizePost(raw: Record<string, unknown>): BlogPost {
|
|
43
|
+
return {
|
|
44
|
+
id: (raw['id'] as string) ?? '',
|
|
45
|
+
slug: (raw['slug'] as string) ?? '',
|
|
46
|
+
title: (raw['title'] as string) ?? '',
|
|
47
|
+
subtitle: raw['subtitle'] as string | undefined,
|
|
48
|
+
status: raw['status'] as BlogPost['status'],
|
|
49
|
+
content: (raw['content'] as string) ?? '',
|
|
50
|
+
excerpt: raw['excerpt'] as string | undefined,
|
|
51
|
+
thumbnailUrl: raw['thumbnailUrl'] as string | undefined,
|
|
52
|
+
thumbnailAlt: raw['thumbnailAlt'] as string | undefined,
|
|
53
|
+
tags: (raw['tags'] as string[]) ?? [],
|
|
54
|
+
authorId: raw['authorId'] as string | undefined,
|
|
55
|
+
readingTimeMinutes: raw['readingTimeMinutes'] as number | undefined,
|
|
56
|
+
embeddedMedia: (raw['embeddedMedia'] as BlogPost['embeddedMedia']) ?? {},
|
|
57
|
+
seo: (raw['seo'] as BlogPost['seo']) ?? {},
|
|
58
|
+
publishedAt: normalizeTimestamp(raw['publishedAt']),
|
|
59
|
+
scheduledPublishAt:
|
|
60
|
+
raw['scheduledPublishAt'] != null
|
|
61
|
+
? normalizeTimestamp(raw['scheduledPublishAt'])
|
|
62
|
+
: undefined,
|
|
63
|
+
updatedAt: normalizeTimestamp(raw['updatedAt']),
|
|
64
|
+
createdAt: normalizeTimestamp(raw['createdAt']),
|
|
65
|
+
};
|
|
66
|
+
}
|