@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,103 @@
|
|
|
1
|
+
import type { SeoMeta } from './post.model';
|
|
2
|
+
import type { LinksLink } from './page.model';
|
|
3
|
+
|
|
4
|
+
export type { SeoMeta };
|
|
5
|
+
|
|
6
|
+
export interface NavItem {
|
|
7
|
+
label: string;
|
|
8
|
+
url: string;
|
|
9
|
+
order?: number;
|
|
10
|
+
external?: boolean;
|
|
11
|
+
icon?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NavGroup {
|
|
15
|
+
group: string;
|
|
16
|
+
items: NavItem[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type NavEntry = NavItem | NavGroup;
|
|
20
|
+
|
|
21
|
+
export function isNavGroup(entry: NavEntry): entry is NavGroup {
|
|
22
|
+
return 'group' in entry && 'items' in entry;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SocialPlatform =
|
|
26
|
+
| 'twitter'
|
|
27
|
+
| 'instagram'
|
|
28
|
+
| 'github'
|
|
29
|
+
| 'linkedin'
|
|
30
|
+
| 'youtube'
|
|
31
|
+
| 'twitch'
|
|
32
|
+
| 'bluesky'
|
|
33
|
+
| 'tiktok'
|
|
34
|
+
| 'facebook'
|
|
35
|
+
| 'email'
|
|
36
|
+
| 'website';
|
|
37
|
+
|
|
38
|
+
export interface SocialLink {
|
|
39
|
+
platform: SocialPlatform;
|
|
40
|
+
url: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
icon?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AboutPageConfig {
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
headline: string;
|
|
48
|
+
subheadline?: string;
|
|
49
|
+
/** Markdown — rendered via MarkdownComponent */
|
|
50
|
+
bio: string;
|
|
51
|
+
/** Firebase Storage URL */
|
|
52
|
+
photoUrl?: string;
|
|
53
|
+
/** Firebase Storage URL — shown in dark mode when set */
|
|
54
|
+
photoUrlDark?: string;
|
|
55
|
+
photoAlt?: string;
|
|
56
|
+
socialLinks?: SocialLink[];
|
|
57
|
+
seo?: SeoMeta;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LinksPageConfig {
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
links?: LinksLink[];
|
|
63
|
+
title?: string;
|
|
64
|
+
avatarUrl?: string;
|
|
65
|
+
avatarUrlDark?: string;
|
|
66
|
+
avatarAlt?: string;
|
|
67
|
+
headline?: string;
|
|
68
|
+
bio?: string;
|
|
69
|
+
seo?: SeoMeta;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface HomePageConfig {
|
|
73
|
+
enabled: boolean;
|
|
74
|
+
heroHeadline: string;
|
|
75
|
+
heroSubheadline?: string;
|
|
76
|
+
ctaLabel?: string;
|
|
77
|
+
ctaUrl?: string;
|
|
78
|
+
showRecentPosts?: boolean;
|
|
79
|
+
seo?: SeoMeta;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SiteConfig {
|
|
83
|
+
id: string;
|
|
84
|
+
siteName: string;
|
|
85
|
+
siteUrl: string;
|
|
86
|
+
description?: string;
|
|
87
|
+
logo?: string;
|
|
88
|
+
favicon?: string;
|
|
89
|
+
nav: NavItem[];
|
|
90
|
+
defaultAuthorId?: string;
|
|
91
|
+
defaultSeo?: SeoMeta;
|
|
92
|
+
pages?: {
|
|
93
|
+
home?: HomePageConfig;
|
|
94
|
+
about?: AboutPageConfig;
|
|
95
|
+
links?: LinksPageConfig;
|
|
96
|
+
};
|
|
97
|
+
/** Set to true once the admin has completed the setup wizard. Never reset. */
|
|
98
|
+
setupComplete?: boolean;
|
|
99
|
+
/** Step IDs the admin has explicitly saved in the setup wizard (tracks optional step acknowledgment). */
|
|
100
|
+
setupAcknowledgedSteps?: string[];
|
|
101
|
+
/** Unix milliseconds. */
|
|
102
|
+
updatedAt: number;
|
|
103
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Pipe, PipeTransform } from '@angular/core';
|
|
2
|
+
import type { Tag } from '../models/tag.model';
|
|
3
|
+
|
|
4
|
+
function humanizeSlug(slug: string): string {
|
|
5
|
+
return slug
|
|
6
|
+
.replace(/^tag-/, '')
|
|
7
|
+
.replace(/-/g, ' ')
|
|
8
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@Pipe({ name: 'tagLabel', standalone: true, pure: true })
|
|
12
|
+
export class TagLabelPipe implements PipeTransform {
|
|
13
|
+
transform(tagId: string, lookup?: Map<string, Tag>): string {
|
|
14
|
+
return lookup?.get(tagId)?.label ?? humanizeSlug(tagId);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { inject, makeStateKey, PLATFORM_ID, TransferState } from '@angular/core';
|
|
2
|
+
import { isPlatformServer } from '@angular/common';
|
|
3
|
+
import { ResolveFn, Router } from '@angular/router';
|
|
4
|
+
import { map, tap, take } from 'rxjs/operators';
|
|
5
|
+
import { SITE_CONFIG_SERVICE } from '../tokens/site-config-service.token';
|
|
6
|
+
import type { AboutPageConfig } from '../models/site-config.model';
|
|
7
|
+
|
|
8
|
+
const ABOUT_PAGE_KEY = makeStateKey<AboutPageConfig | null>('about-page');
|
|
9
|
+
|
|
10
|
+
/** Options for {@link createAboutPageResolver}. */
|
|
11
|
+
export interface AboutPageResolverOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Route to redirect to when about page config is absent or has no `bio`.
|
|
14
|
+
* @default '/not-found'
|
|
15
|
+
*/
|
|
16
|
+
notFoundRoute?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Factory that creates a resolver for the About page.
|
|
21
|
+
*
|
|
22
|
+
* Reads `AboutPageConfig` from `SITE_CONFIG_SERVICE.getAboutConfig()`, using
|
|
23
|
+
* Angular's `TransferState` to avoid a duplicate Firestore read on browser
|
|
24
|
+
* hydration after SSR. Redirects to `notFoundRoute` (default: `/not-found`)
|
|
25
|
+
* when the config is absent or has no `bio` field.
|
|
26
|
+
*
|
|
27
|
+
* `SITE_CONFIG_SERVICE` must be provided in your app — either the default
|
|
28
|
+
* `SiteConfigService` (client SDK) or a server-side override (Admin SDK) via
|
|
29
|
+
* `app.config.server.ts`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* // app.routes.ts
|
|
34
|
+
* {
|
|
35
|
+
* path: 'about',
|
|
36
|
+
* resolve: { about: createAboutPageResolver() },
|
|
37
|
+
* loadComponent: () => import('./about/about.component').then(m => m.AboutComponent),
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function createAboutPageResolver(
|
|
42
|
+
options?: AboutPageResolverOptions,
|
|
43
|
+
): ResolveFn<AboutPageConfig> {
|
|
44
|
+
const notFoundRoute = options?.notFoundRoute ?? '/not-found';
|
|
45
|
+
|
|
46
|
+
return () => {
|
|
47
|
+
const transferState = inject(TransferState);
|
|
48
|
+
const service = inject(SITE_CONFIG_SERVICE);
|
|
49
|
+
const platformId = inject(PLATFORM_ID);
|
|
50
|
+
const router = inject(Router);
|
|
51
|
+
|
|
52
|
+
if (transferState.hasKey(ABOUT_PAGE_KEY)) {
|
|
53
|
+
const about = transferState.get(ABOUT_PAGE_KEY, null);
|
|
54
|
+
transferState.remove(ABOUT_PAGE_KEY);
|
|
55
|
+
if (!about || !about.bio) {
|
|
56
|
+
return router.createUrlTree([notFoundRoute]) as never;
|
|
57
|
+
}
|
|
58
|
+
return about;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return service.getAboutConfig().pipe(
|
|
62
|
+
take(1),
|
|
63
|
+
tap((about) => {
|
|
64
|
+
if (isPlatformServer(platformId)) {
|
|
65
|
+
transferState.set(ABOUT_PAGE_KEY, about);
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
map((about) => {
|
|
69
|
+
if (!about || !about.bio) {
|
|
70
|
+
return router.createUrlTree([notFoundRoute]) as never;
|
|
71
|
+
}
|
|
72
|
+
return about;
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { inject, makeStateKey, PLATFORM_ID, TransferState } from '@angular/core';
|
|
2
|
+
import { isPlatformServer } from '@angular/common';
|
|
3
|
+
import { ResolveFn, Router } from '@angular/router';
|
|
4
|
+
import { map, tap, take } from 'rxjs/operators';
|
|
5
|
+
import { SITE_CONFIG_SERVICE } from '../tokens/site-config-service.token';
|
|
6
|
+
import type { LinksPageConfig } from '../models/site-config.model';
|
|
7
|
+
|
|
8
|
+
const LINKS_PAGE_KEY = makeStateKey<LinksPageConfig | null>('links-page');
|
|
9
|
+
|
|
10
|
+
/** Options for {@link createLinksPageResolver}. */
|
|
11
|
+
export interface LinksPageResolverOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Route to redirect to when links page config is absent or has no links.
|
|
14
|
+
* @default '/not-found'
|
|
15
|
+
*/
|
|
16
|
+
notFoundRoute?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Factory that creates a resolver for the Links page.
|
|
21
|
+
*
|
|
22
|
+
* Reads `LinksPageConfig` from `SITE_CONFIG_SERVICE.getConfig().pages.links`,
|
|
23
|
+
* using Angular's `TransferState` to avoid a duplicate Firestore read on
|
|
24
|
+
* browser hydration after SSR. Redirects to `notFoundRoute` (default:
|
|
25
|
+
* `/not-found`) when the config is absent or the `links` array is empty.
|
|
26
|
+
*
|
|
27
|
+
* `SITE_CONFIG_SERVICE` must be provided in your app — either the default
|
|
28
|
+
* `SiteConfigService` (client SDK) or a server-side override (Admin SDK) via
|
|
29
|
+
* `app.config.server.ts`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* // app.routes.ts
|
|
34
|
+
* {
|
|
35
|
+
* path: 'links',
|
|
36
|
+
* resolve: { page: createLinksPageResolver() },
|
|
37
|
+
* loadComponent: () => import('@foliokit/cms-ui').then(m => m.LinksPageComponent),
|
|
38
|
+
* }
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function createLinksPageResolver(
|
|
42
|
+
options?: LinksPageResolverOptions,
|
|
43
|
+
): ResolveFn<LinksPageConfig> {
|
|
44
|
+
const notFoundRoute = options?.notFoundRoute ?? '/not-found';
|
|
45
|
+
|
|
46
|
+
return () => {
|
|
47
|
+
const transferState = inject(TransferState);
|
|
48
|
+
const service = inject(SITE_CONFIG_SERVICE);
|
|
49
|
+
const platformId = inject(PLATFORM_ID);
|
|
50
|
+
const router = inject(Router);
|
|
51
|
+
|
|
52
|
+
if (transferState.hasKey(LINKS_PAGE_KEY)) {
|
|
53
|
+
const links = transferState.get(LINKS_PAGE_KEY, null);
|
|
54
|
+
transferState.remove(LINKS_PAGE_KEY);
|
|
55
|
+
if (!links || !links.links?.length) {
|
|
56
|
+
return router.createUrlTree([notFoundRoute]) as never;
|
|
57
|
+
}
|
|
58
|
+
return links;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return service.getConfig().pipe(
|
|
62
|
+
take(1),
|
|
63
|
+
tap((config) => {
|
|
64
|
+
if (isPlatformServer(platformId)) {
|
|
65
|
+
transferState.set(LINKS_PAGE_KEY, config.pages?.links ?? null);
|
|
66
|
+
}
|
|
67
|
+
}),
|
|
68
|
+
map((config) => {
|
|
69
|
+
const links = config.pages?.links;
|
|
70
|
+
if (!links || !links.links?.length) {
|
|
71
|
+
return router.createUrlTree([notFoundRoute]) as never;
|
|
72
|
+
}
|
|
73
|
+
return links;
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { inject, PLATFORM_ID, TransferState } from '@angular/core';
|
|
2
|
+
import { isPlatformServer } from '@angular/common';
|
|
3
|
+
import { ResolveFn } from '@angular/router';
|
|
4
|
+
import { take, tap } from 'rxjs/operators';
|
|
5
|
+
import { BLOG_POST_SERVICE, POSTS_TRANSFER_KEY } from '../tokens/post-service.token';
|
|
6
|
+
import type { BlogPost } from '../models/post.model';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Factory that creates a resolver for the published posts list.
|
|
10
|
+
*
|
|
11
|
+
* Reads all published posts from `BLOG_POST_SERVICE.getPublishedPosts()`,
|
|
12
|
+
* using Angular's `TransferState` (`POSTS_TRANSFER_KEY`) to avoid a duplicate
|
|
13
|
+
* Firestore read on browser hydration after SSR. Always resolves to an array
|
|
14
|
+
* (empty on error or when no posts are published).
|
|
15
|
+
*
|
|
16
|
+
* `BLOG_POST_SERVICE` must be provided in your app — either the default
|
|
17
|
+
* `PostService` (client SDK) or a server-side override (Admin SDK) via
|
|
18
|
+
* `app.config.server.ts`.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* // app.routes.ts
|
|
23
|
+
* {
|
|
24
|
+
* path: 'posts',
|
|
25
|
+
* resolve: { posts: createPostsResolver() },
|
|
26
|
+
* loadComponent: () => import('./post-list/post-list.component').then(m => m.PostListComponent),
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function createPostsResolver(): ResolveFn<BlogPost[]> {
|
|
31
|
+
return () => {
|
|
32
|
+
const transferState = inject(TransferState);
|
|
33
|
+
const service = inject(BLOG_POST_SERVICE);
|
|
34
|
+
const platformId = inject(PLATFORM_ID);
|
|
35
|
+
|
|
36
|
+
if (transferState.hasKey(POSTS_TRANSFER_KEY)) {
|
|
37
|
+
const posts = transferState.get(POSTS_TRANSFER_KEY, []);
|
|
38
|
+
transferState.remove(POSTS_TRANSFER_KEY);
|
|
39
|
+
return posts;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return service.getPublishedPosts().pipe(
|
|
43
|
+
take(1),
|
|
44
|
+
tap((posts) => {
|
|
45
|
+
if (isPlatformServer(platformId)) {
|
|
46
|
+
transferState.set(POSTS_TRANSFER_KEY, posts);
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { computed, inject, Injectable } from '@angular/core';
|
|
2
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
import {
|
|
5
|
+
GoogleAuthProvider,
|
|
6
|
+
onAuthStateChanged,
|
|
7
|
+
signInWithPopup,
|
|
8
|
+
signOut,
|
|
9
|
+
User,
|
|
10
|
+
} from 'firebase/auth';
|
|
11
|
+
import { ADMIN_EMAIL, FIREBASE_AUTH } from '../firebase/firebase.config';
|
|
12
|
+
|
|
13
|
+
@Injectable({ providedIn: 'root' })
|
|
14
|
+
export class AuthService {
|
|
15
|
+
private readonly auth = inject(FIREBASE_AUTH);
|
|
16
|
+
private readonly adminEmail = inject(ADMIN_EMAIL, { optional: true });
|
|
17
|
+
|
|
18
|
+
readonly user = toSignal(
|
|
19
|
+
new Observable<User | null>((subscriber) => {
|
|
20
|
+
if (!this.auth) {
|
|
21
|
+
subscriber.next(null);
|
|
22
|
+
subscriber.complete();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
return onAuthStateChanged(this.auth, (u) => subscriber.next(u));
|
|
26
|
+
}),
|
|
27
|
+
{ requireSync: false, initialValue: undefined },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
readonly isAuthenticated = computed(() => this.user() != null);
|
|
31
|
+
|
|
32
|
+
readonly isAdmin = computed(() => {
|
|
33
|
+
const email = this.user()?.email;
|
|
34
|
+
if (!email) return false;
|
|
35
|
+
return this.adminEmail ? email === this.adminEmail : false;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
async signInWithGoogle(): Promise<void> {
|
|
39
|
+
if (!this.auth) return;
|
|
40
|
+
const provider = new GoogleAuthProvider();
|
|
41
|
+
provider.setCustomParameters({ prompt: 'login' });
|
|
42
|
+
await signInWithPopup(this.auth, provider);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async signOut(): Promise<void> {
|
|
46
|
+
if (!this.auth) return;
|
|
47
|
+
await signOut(this.auth);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { inject, Injectable } from '@angular/core';
|
|
2
|
+
import {
|
|
3
|
+
collection,
|
|
4
|
+
deleteDoc,
|
|
5
|
+
doc,
|
|
6
|
+
getDoc,
|
|
7
|
+
getDocs,
|
|
8
|
+
orderBy,
|
|
9
|
+
query,
|
|
10
|
+
setDoc,
|
|
11
|
+
Timestamp,
|
|
12
|
+
updateDoc,
|
|
13
|
+
} from 'firebase/firestore';
|
|
14
|
+
import { from, Observable, of } from 'rxjs';
|
|
15
|
+
import { catchError, map } from 'rxjs/operators';
|
|
16
|
+
import { FIRESTORE } from '../firebase/firebase.config';
|
|
17
|
+
import type { Author } from '../models/author.model';
|
|
18
|
+
import { normalizeAuthor } from '../utils/normalize-author';
|
|
19
|
+
|
|
20
|
+
@Injectable({ providedIn: 'root' })
|
|
21
|
+
export class AuthorService {
|
|
22
|
+
private readonly firestore = inject(FIRESTORE)!;
|
|
23
|
+
|
|
24
|
+
getAll(): Observable<Author[]> {
|
|
25
|
+
const q = query(
|
|
26
|
+
collection(this.firestore, 'authors'),
|
|
27
|
+
orderBy('displayName', 'asc'),
|
|
28
|
+
);
|
|
29
|
+
return from(getDocs(q)).pipe(
|
|
30
|
+
map((snapshot) =>
|
|
31
|
+
snapshot.docs.map((d) => normalizeAuthor({ id: d.id, ...d.data() })),
|
|
32
|
+
),
|
|
33
|
+
catchError((err) => {
|
|
34
|
+
console.error('[AuthorService.getAll]', err);
|
|
35
|
+
return of([]);
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getById(id: string): Observable<Author | null> {
|
|
41
|
+
return from(getDoc(doc(this.firestore, 'authors', id))).pipe(
|
|
42
|
+
map((snap) => {
|
|
43
|
+
if (!snap.exists()) return null;
|
|
44
|
+
return normalizeAuthor({ id: snap.id, ...snap.data() });
|
|
45
|
+
}),
|
|
46
|
+
catchError((err) => {
|
|
47
|
+
console.error('[AuthorService.getById]', err);
|
|
48
|
+
return of(null);
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
create(data: Omit<Author, 'id' | 'createdAt' | 'updatedAt'>): Observable<Author> {
|
|
54
|
+
const nowMs = Date.now();
|
|
55
|
+
const nowTs = Timestamp.fromMillis(nowMs);
|
|
56
|
+
const newId = doc(collection(this.firestore, 'authors')).id;
|
|
57
|
+
const author: Author = { ...data, id: newId, createdAt: nowMs, updatedAt: nowMs };
|
|
58
|
+
const firestorePayload = { ...author, createdAt: nowTs, updatedAt: nowTs };
|
|
59
|
+
return from(setDoc(doc(this.firestore, 'authors', newId), firestorePayload)).pipe(
|
|
60
|
+
map(() => author),
|
|
61
|
+
catchError((err) => {
|
|
62
|
+
console.error('[AuthorService.create]', err);
|
|
63
|
+
throw err;
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
update(id: string, data: Partial<Omit<Author, 'id' | 'createdAt'>>): Observable<void> {
|
|
69
|
+
const nowTs = Timestamp.fromMillis(Date.now());
|
|
70
|
+
return from(
|
|
71
|
+
updateDoc(doc(this.firestore, 'authors', id), { ...data, updatedAt: nowTs }),
|
|
72
|
+
).pipe(
|
|
73
|
+
catchError((err) => {
|
|
74
|
+
console.error('[AuthorService.update]', err);
|
|
75
|
+
throw err;
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
delete(id: string): Observable<void> {
|
|
81
|
+
return from(deleteDoc(doc(this.firestore, 'authors', id))).pipe(
|
|
82
|
+
catchError((err) => {
|
|
83
|
+
console.error('[AuthorService.delete]', err);
|
|
84
|
+
throw err;
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|