@foliokit/cms-ui 0.4.2 → 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 +155 -0
- package/eslint.config.mjs +48 -0
- package/ng-package.json +19 -0
- package/package.json +12 -19
- package/project.json +32 -0
- package/src/index.ts +7 -0
- package/src/lib/about-page/about-page.component.ts +219 -0
- package/src/lib/app-shell/app-shell.component.html +57 -0
- package/src/lib/app-shell/app-shell.component.scss +204 -0
- package/src/lib/app-shell/app-shell.component.ts +88 -0
- package/src/lib/app-shell/shell-nav-footer.directive.ts +4 -0
- package/src/lib/cms-ui/cms-ui.html +1 -0
- package/src/lib/cms-ui/cms-ui.scss +0 -0
- package/src/lib/cms-ui/cms-ui.spec.ts +93 -0
- package/src/lib/cms-ui/cms-ui.ts +9 -0
- package/src/lib/links-page/links-page.component.ts +256 -0
- package/src/lib/route-data.ts +27 -0
- package/src/lib/shell-config.token.ts +11 -0
- package/src/lib/theme.service.ts +44 -0
- package/src/styles/_focus.scss +17 -0
- package/src/styles/_reset.scss +65 -0
- package/src/styles/_theme.scss +76 -0
- package/src/styles/_tokens.scss +216 -0
- package/src/styles/_typography.scss +146 -0
- package/src/styles/index.scss +5 -0
- package/src/styles/tokens.css +135 -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-ui.js +0 -5
- package/esm2022/foliokit-cms-ui.js.map +0 -1
- package/esm2022/index.js +0 -4
- package/esm2022/index.js.map +0 -1
- package/esm2022/lib/app-shell/app-shell.component.js +0 -52
- package/esm2022/lib/app-shell/app-shell.component.js.map +0 -1
- package/esm2022/lib/shell-config.token.js +0 -3
- package/esm2022/lib/shell-config.token.js.map +0 -1
- package/esm2022/lib/theme.service.js +0 -45
- package/esm2022/lib/theme.service.js.map +0 -1
- package/foliokit-cms-ui.d.ts +0 -5
- package/index.d.ts +0 -3
- package/lib/app-shell/app-shell.component.d.ts +0 -17
- package/lib/shell-config.token.d.ts +0 -9
- package/lib/theme.service.d.ts +0 -11
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
height: 100%;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.folio-shell-container {
|
|
7
|
+
height: 100%;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
mat-sidenav-container {
|
|
11
|
+
height: 100%;
|
|
12
|
+
background: var(--bg);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
mat-sidenav {
|
|
16
|
+
width: 220px;
|
|
17
|
+
background: var(--surface-1);
|
|
18
|
+
border-right: 1px solid var(--border);
|
|
19
|
+
--mat-sidenav-container-shape: 0px;
|
|
20
|
+
border-radius: 0;
|
|
21
|
+
transition: width 0.18s ease;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Icon-rail (768–1023px) ───────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
mat-sidenav.icon-rail {
|
|
27
|
+
width: 52px;
|
|
28
|
+
overflow-x: hidden;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
mat-sidenav.icon-rail .nav-label {
|
|
32
|
+
display: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
mat-sidenav.icon-rail .nav-group-label {
|
|
36
|
+
display: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
::ng-deep mat-sidenav.icon-rail .nav-item {
|
|
40
|
+
padding: 0 !important;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Nav wrapper layout ───────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
.folio-nav-wrapper {
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
height: 100%;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.folio-nav-body {
|
|
53
|
+
flex: 1;
|
|
54
|
+
overflow-y: auto;
|
|
55
|
+
overflow-x: hidden;
|
|
56
|
+
padding-top: 8px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.folio-nav-footer {
|
|
60
|
+
border-top: 1px solid var(--border);
|
|
61
|
+
padding: 8px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.folio-nav-header {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 10px;
|
|
68
|
+
padding: 16px 16px 12px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Nav group labels ─────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
::ng-deep mat-sidenav .nav-group-label {
|
|
74
|
+
font-family: var(--font-mono);
|
|
75
|
+
font-size: 9px;
|
|
76
|
+
letter-spacing: 0.12em;
|
|
77
|
+
text-transform: uppercase;
|
|
78
|
+
color: var(--text-muted);
|
|
79
|
+
padding: 0 16px;
|
|
80
|
+
margin-bottom: 3px;
|
|
81
|
+
margin-top: 18px;
|
|
82
|
+
display: block;
|
|
83
|
+
|
|
84
|
+
&:first-child {
|
|
85
|
+
margin-top: 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Nav items ────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
::ng-deep mat-sidenav .nav-item {
|
|
92
|
+
height: 40px;
|
|
93
|
+
padding: 0 16px;
|
|
94
|
+
gap: 10px;
|
|
95
|
+
display: flex !important;
|
|
96
|
+
align-items: center;
|
|
97
|
+
font-size: 13px;
|
|
98
|
+
font-family: var(--font-body);
|
|
99
|
+
color: var(--text-secondary);
|
|
100
|
+
text-decoration: none;
|
|
101
|
+
border-left: 2px solid transparent;
|
|
102
|
+
transition: background 0.12s, color 0.12s;
|
|
103
|
+
|
|
104
|
+
&:hover {
|
|
105
|
+
background: var(--surface-2);
|
|
106
|
+
color: var(--text-primary);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.nav-icon {
|
|
110
|
+
font-size: 18px !important;
|
|
111
|
+
width: 18px !important;
|
|
112
|
+
height: 18px !important;
|
|
113
|
+
flex-shrink: 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
&.active-link {
|
|
117
|
+
border-left-color: var(--text-accent);
|
|
118
|
+
background: var(--surface-2);
|
|
119
|
+
color: var(--text-accent);
|
|
120
|
+
font-weight: 500;
|
|
121
|
+
padding-left: 14px;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
[data-theme="dark"] ::ng-deep mat-sidenav .nav-item.active-link {
|
|
126
|
+
background: var(--nav-active-bg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Logo mark ────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
.folio-logo-mark {
|
|
132
|
+
position: relative;
|
|
133
|
+
width: 30px;
|
|
134
|
+
height: 30px;
|
|
135
|
+
background: var(--logo-bg);
|
|
136
|
+
border-radius: var(--r-md);
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
justify-content: center;
|
|
140
|
+
transition: background 0.25s;
|
|
141
|
+
flex-shrink: 0;
|
|
142
|
+
|
|
143
|
+
span {
|
|
144
|
+
font-family: var(--font-display);
|
|
145
|
+
font-size: 17px;
|
|
146
|
+
font-weight: 900;
|
|
147
|
+
color: var(--logo-text);
|
|
148
|
+
line-height: 1;
|
|
149
|
+
transition: color 0.25s;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.folio-logo-dot {
|
|
154
|
+
position: absolute;
|
|
155
|
+
bottom: 5px;
|
|
156
|
+
right: 5px;
|
|
157
|
+
width: 4px;
|
|
158
|
+
height: 4px;
|
|
159
|
+
border-radius: 50%;
|
|
160
|
+
background: var(--logo-dot);
|
|
161
|
+
transition: background 0.25s;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.folio-app-name {
|
|
165
|
+
font-family: var(--font-display);
|
|
166
|
+
font-size: 17px;
|
|
167
|
+
font-weight: 700;
|
|
168
|
+
color: var(--text-primary);
|
|
169
|
+
letter-spacing: -0.025em;
|
|
170
|
+
|
|
171
|
+
em {
|
|
172
|
+
font-style: italic;
|
|
173
|
+
color: var(--text-muted);
|
|
174
|
+
font-weight: 400;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Toolbar ─────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
.folio-toolbar {
|
|
181
|
+
position: sticky;
|
|
182
|
+
top: 0;
|
|
183
|
+
z-index: 100;
|
|
184
|
+
height: 56px;
|
|
185
|
+
background: color-mix(in srgb, var(--surface-2) 85%, transparent);
|
|
186
|
+
backdrop-filter: blur(14px);
|
|
187
|
+
border-bottom: 1px solid var(--border);
|
|
188
|
+
gap: 4px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── Content area ─────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
.folio-content {
|
|
194
|
+
display: flex;
|
|
195
|
+
flex-direction: column;
|
|
196
|
+
background: var(--bg);
|
|
197
|
+
overflow: hidden !important;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.shell-main {
|
|
201
|
+
flex: 1;
|
|
202
|
+
min-height: 0;
|
|
203
|
+
overflow-y: auto;
|
|
204
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
ContentChild,
|
|
5
|
+
inject,
|
|
6
|
+
OnDestroy,
|
|
7
|
+
OnInit,
|
|
8
|
+
signal,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import { BreakpointObserver } from '@angular/cdk/layout';
|
|
11
|
+
import { Subscription } from 'rxjs';
|
|
12
|
+
import { filter } from 'rxjs/operators';
|
|
13
|
+
import { NavigationEnd, Router } from '@angular/router';
|
|
14
|
+
import { MatSidenavModule } from '@angular/material/sidenav';
|
|
15
|
+
import { MatToolbarModule } from '@angular/material/toolbar';
|
|
16
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
17
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
18
|
+
import { SHELL_CONFIG } from '../shell-config.token';
|
|
19
|
+
import { ThemeService } from '../theme.service';
|
|
20
|
+
import { ShellNavFooterDirective } from './shell-nav-footer.directive';
|
|
21
|
+
|
|
22
|
+
const MOBILE_BP = '(max-width: 767.98px)';
|
|
23
|
+
const ICON_RAIL_BP = '(min-width: 768px) and (max-width: 1023.98px)';
|
|
24
|
+
|
|
25
|
+
@Component({
|
|
26
|
+
selector: 'folio-app-shell',
|
|
27
|
+
templateUrl: './app-shell.component.html',
|
|
28
|
+
styleUrl: './app-shell.component.scss',
|
|
29
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
30
|
+
standalone: true,
|
|
31
|
+
imports: [
|
|
32
|
+
MatSidenavModule,
|
|
33
|
+
MatToolbarModule,
|
|
34
|
+
MatIconModule,
|
|
35
|
+
MatButtonModule,
|
|
36
|
+
],
|
|
37
|
+
})
|
|
38
|
+
export class AppShellComponent implements OnInit, OnDestroy {
|
|
39
|
+
protected readonly config = inject(SHELL_CONFIG);
|
|
40
|
+
protected readonly theme = inject(ThemeService);
|
|
41
|
+
|
|
42
|
+
@ContentChild(ShellNavFooterDirective) protected navFooter?: ShellNavFooterDirective;
|
|
43
|
+
|
|
44
|
+
protected readonly isMobile = signal(false);
|
|
45
|
+
protected readonly isIconRail = signal(false);
|
|
46
|
+
protected readonly sidenavOpen = signal(false);
|
|
47
|
+
|
|
48
|
+
private readonly breakpointObserver = inject(BreakpointObserver);
|
|
49
|
+
private readonly router = inject(Router);
|
|
50
|
+
private bpSub?: Subscription;
|
|
51
|
+
private navSub?: Subscription;
|
|
52
|
+
|
|
53
|
+
ngOnInit(): void {
|
|
54
|
+
this.bpSub = this.breakpointObserver
|
|
55
|
+
.observe([MOBILE_BP, ICON_RAIL_BP])
|
|
56
|
+
.subscribe((state) => {
|
|
57
|
+
const mobile = state.breakpoints[MOBILE_BP];
|
|
58
|
+
const iconRail = state.breakpoints[ICON_RAIL_BP];
|
|
59
|
+
this.isMobile.set(mobile);
|
|
60
|
+
this.isIconRail.set(iconRail);
|
|
61
|
+
// Mobile: close sidenav (overlay); everything else: keep open
|
|
62
|
+
this.sidenavOpen.set(!mobile);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.navSub = this.router.events
|
|
66
|
+
.pipe(filter(e => e instanceof NavigationEnd))
|
|
67
|
+
.subscribe(() => {
|
|
68
|
+
if (this.isMobile()) {
|
|
69
|
+
this.sidenavOpen.set(false);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.theme.apply();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ngOnDestroy(): void {
|
|
77
|
+
this.bpSub?.unsubscribe();
|
|
78
|
+
this.navSub?.unsubscribe();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
protected toggleSidenav(): void {
|
|
82
|
+
this.sidenavOpen.update((open) => !open);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
protected toggleTheme(): void {
|
|
86
|
+
this.theme.toggle();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<p>CmsUi works!</p>
|
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
import { provideRouter } from '@angular/router';
|
|
3
|
+
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
|
4
|
+
import { ADMIN_EMAIL, FIREBASE_AUTH, FIRESTORE, FIREBASE_STORAGE, BLOG_POST_SERVICE, SITE_CONFIG_SERVICE } from '@foliokit/cms-core';
|
|
5
|
+
import { AppShellComponent } from '../app-shell/app-shell.component';
|
|
6
|
+
import { SHELL_CONFIG, ShellConfig } from '../shell-config.token';
|
|
7
|
+
|
|
8
|
+
// Null providers for Firebase root services — prevents NullInjectorError when
|
|
9
|
+
// providedIn:'root' services (PostService, SiteConfigService) are instantiated
|
|
10
|
+
// by TestBed even though AppShellComponent doesn't depend on them directly.
|
|
11
|
+
const firebaseNullProviders = [
|
|
12
|
+
{ provide: FIRESTORE, useValue: null },
|
|
13
|
+
{ provide: FIREBASE_AUTH, useValue: null },
|
|
14
|
+
{ provide: FIREBASE_STORAGE, useValue: null },
|
|
15
|
+
{ provide: ADMIN_EMAIL, useValue: 'test@example.com' },
|
|
16
|
+
{ provide: BLOG_POST_SERVICE, useValue: null },
|
|
17
|
+
{ provide: SITE_CONFIG_SERVICE, useValue: null },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const testConfig: ShellConfig = {
|
|
21
|
+
appName: 'Test App',
|
|
22
|
+
nav: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// JSDOM doesn't implement window.matchMedia — stub it so ThemeService
|
|
26
|
+
// (providedIn:'root') can initialise without throwing.
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
29
|
+
writable: true,
|
|
30
|
+
value: vi.fn().mockReturnValue({
|
|
31
|
+
matches: false,
|
|
32
|
+
media: '',
|
|
33
|
+
addListener: vi.fn(), // deprecated but used by Angular CDK BreakpointObserver
|
|
34
|
+
removeListener: vi.fn(), // deprecated but used by Angular CDK BreakpointObserver
|
|
35
|
+
addEventListener: vi.fn(),
|
|
36
|
+
removeEventListener: vi.fn(),
|
|
37
|
+
dispatchEvent: vi.fn(),
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('cms-ui public API smoke tests', () => {
|
|
43
|
+
afterEach(() => TestBed.resetTestingModule());
|
|
44
|
+
|
|
45
|
+
it('ShellConfig appName is a required string field', () => {
|
|
46
|
+
// Documents the runtime contract; TypeScript enforces it at compile time.
|
|
47
|
+
expect(typeof testConfig.appName).toBe('string');
|
|
48
|
+
expect(testConfig.appName.length).toBeGreaterThan(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('SHELL_CONFIG token is injectable when provided', () => {
|
|
52
|
+
TestBed.configureTestingModule({
|
|
53
|
+
providers: [{ provide: SHELL_CONFIG, useValue: testConfig }],
|
|
54
|
+
});
|
|
55
|
+
const config = TestBed.inject(SHELL_CONFIG);
|
|
56
|
+
expect(config.appName).toBe('Test App');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('AppShellComponent renders when SHELL_CONFIG is provided', async () => {
|
|
60
|
+
await TestBed.configureTestingModule({
|
|
61
|
+
imports: [AppShellComponent],
|
|
62
|
+
providers: [
|
|
63
|
+
{ provide: SHELL_CONFIG, useValue: testConfig },
|
|
64
|
+
provideRouter([]),
|
|
65
|
+
provideAnimationsAsync(),
|
|
66
|
+
...firebaseNullProviders,
|
|
67
|
+
],
|
|
68
|
+
}).compileComponents();
|
|
69
|
+
|
|
70
|
+
const fixture = TestBed.createComponent(AppShellComponent);
|
|
71
|
+
await fixture.whenStable();
|
|
72
|
+
|
|
73
|
+
expect(fixture.componentInstance).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('AppShellComponent renders the appName from SHELL_CONFIG', async () => {
|
|
77
|
+
await TestBed.configureTestingModule({
|
|
78
|
+
imports: [AppShellComponent],
|
|
79
|
+
providers: [
|
|
80
|
+
{ provide: SHELL_CONFIG, useValue: { appName: 'My Blog', nav: [] } },
|
|
81
|
+
provideRouter([]),
|
|
82
|
+
provideAnimationsAsync(),
|
|
83
|
+
...firebaseNullProviders,
|
|
84
|
+
],
|
|
85
|
+
}).compileComponents();
|
|
86
|
+
|
|
87
|
+
const fixture = TestBed.createComponent(AppShellComponent);
|
|
88
|
+
fixture.detectChanges();
|
|
89
|
+
await fixture.whenStable();
|
|
90
|
+
|
|
91
|
+
expect(fixture.nativeElement.textContent).toContain('My Blog');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
effect,
|
|
6
|
+
inject,
|
|
7
|
+
PLATFORM_ID,
|
|
8
|
+
} from '@angular/core';
|
|
9
|
+
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
|
10
|
+
import { ActivatedRoute } from '@angular/router';
|
|
11
|
+
import { toSignal } from '@angular/core/rxjs-interop';
|
|
12
|
+
import { map } from 'rxjs';
|
|
13
|
+
import { Meta, Title } from '@angular/platform-browser';
|
|
14
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
15
|
+
import type { LinksPageConfig, LinksLink } from '@foliokit/cms-core';
|
|
16
|
+
import type { SocialPlatform } from '@foliokit/cms-core';
|
|
17
|
+
import { ThemeService } from '../theme.service';
|
|
18
|
+
|
|
19
|
+
const PLATFORM_ICONS: Record<SocialPlatform, string> = {
|
|
20
|
+
youtube: 'play_circle',
|
|
21
|
+
twitch: 'live_tv',
|
|
22
|
+
twitter: 'tag',
|
|
23
|
+
bluesky: 'cloud',
|
|
24
|
+
github: 'code',
|
|
25
|
+
linkedin: 'business',
|
|
26
|
+
instagram: 'photo_camera',
|
|
27
|
+
tiktok: 'music_note',
|
|
28
|
+
facebook: 'thumb_up',
|
|
29
|
+
email: 'mail',
|
|
30
|
+
website: 'language',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
@Component({
|
|
34
|
+
selector: 'cms-links-page',
|
|
35
|
+
standalone: true,
|
|
36
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
37
|
+
imports: [MatIconModule],
|
|
38
|
+
styles: [`
|
|
39
|
+
:host { display: block; }
|
|
40
|
+
|
|
41
|
+
.links-container {
|
|
42
|
+
max-width: 480px;
|
|
43
|
+
margin: 48px auto;
|
|
44
|
+
padding: 0 24px;
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
align-items: center;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.avatar--xl {
|
|
51
|
+
width: 96px;
|
|
52
|
+
height: 96px;
|
|
53
|
+
border-radius: 50%;
|
|
54
|
+
background: var(--logo-bg);
|
|
55
|
+
color: var(--logo-text);
|
|
56
|
+
font-family: var(--font-body);
|
|
57
|
+
font-weight: 600;
|
|
58
|
+
font-size: 32px;
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
flex-shrink: 0;
|
|
64
|
+
|
|
65
|
+
img {
|
|
66
|
+
width: 100%;
|
|
67
|
+
height: 100%;
|
|
68
|
+
object-fit: cover;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.links-name {
|
|
73
|
+
font-family: var(--font-display);
|
|
74
|
+
font-size: 1.5rem;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
letter-spacing: -0.015em;
|
|
77
|
+
color: var(--text-primary);
|
|
78
|
+
text-align: center;
|
|
79
|
+
margin-top: 16px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.links-bio {
|
|
83
|
+
font-size: 16px;
|
|
84
|
+
line-height: 1.75;
|
|
85
|
+
color: var(--text-secondary);
|
|
86
|
+
text-align: center;
|
|
87
|
+
max-width: 480px;
|
|
88
|
+
margin: 8px auto 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.links-nav {
|
|
92
|
+
width: 100%;
|
|
93
|
+
display: flex;
|
|
94
|
+
flex-direction: column;
|
|
95
|
+
margin-top: 24px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.link-btn {
|
|
99
|
+
width: 100%;
|
|
100
|
+
margin-top: 12px;
|
|
101
|
+
background: var(--surface-0);
|
|
102
|
+
border: 1px solid var(--border-strong);
|
|
103
|
+
border-radius: var(--r-lg);
|
|
104
|
+
padding: 13px 18px;
|
|
105
|
+
box-shadow: var(--shadow-sm);
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 12px;
|
|
109
|
+
text-decoration: none;
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
transition: box-shadow 0.12s, transform 0.12s;
|
|
112
|
+
|
|
113
|
+
&:hover {
|
|
114
|
+
box-shadow: var(--shadow-md);
|
|
115
|
+
transform: translateY(-1px);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.link-icon {
|
|
119
|
+
font-size: 18px;
|
|
120
|
+
width: 18px;
|
|
121
|
+
height: 18px;
|
|
122
|
+
color: var(--text-accent);
|
|
123
|
+
flex-shrink: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.link-label {
|
|
127
|
+
font-size: 14px;
|
|
128
|
+
font-weight: 500;
|
|
129
|
+
color: var(--text-primary);
|
|
130
|
+
flex: 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.link-chevron {
|
|
134
|
+
font-size: 16px;
|
|
135
|
+
width: 16px;
|
|
136
|
+
height: 16px;
|
|
137
|
+
color: var(--text-muted);
|
|
138
|
+
flex-shrink: 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
`],
|
|
142
|
+
template: `
|
|
143
|
+
@if (page()) {
|
|
144
|
+
<div class="links-container">
|
|
145
|
+
<div class="avatar--xl">
|
|
146
|
+
@if (avatarSrc()) {
|
|
147
|
+
<img [src]="avatarSrc()" [alt]="page()!.avatarAlt || page()!.title" />
|
|
148
|
+
} @else {
|
|
149
|
+
{{ initials() }}
|
|
150
|
+
}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
@if (page()!.headline) {
|
|
154
|
+
<h1 class="links-name">{{ page()!.headline }}</h1>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@if (page()!.bio) {
|
|
158
|
+
<p class="links-bio">{{ page()!.bio }}</p>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
<nav class="links-nav">
|
|
162
|
+
@for (link of sortedLinks(); track link.id) {
|
|
163
|
+
<a
|
|
164
|
+
class="link-btn"
|
|
165
|
+
[href]="link.url"
|
|
166
|
+
target="_blank"
|
|
167
|
+
rel="noopener noreferrer"
|
|
168
|
+
>
|
|
169
|
+
<mat-icon class="link-icon">{{ getIcon(link) }}</mat-icon>
|
|
170
|
+
<span class="link-label">{{ link.label }}</span>
|
|
171
|
+
<mat-icon class="link-chevron">chevron_right</mat-icon>
|
|
172
|
+
</a>
|
|
173
|
+
}
|
|
174
|
+
</nav>
|
|
175
|
+
</div>
|
|
176
|
+
} @else {
|
|
177
|
+
<p style="padding: 40px; text-align: center; color: var(--text-muted)">No content available.</p>
|
|
178
|
+
}
|
|
179
|
+
`,
|
|
180
|
+
})
|
|
181
|
+
export class LinksPageComponent {
|
|
182
|
+
private readonly route = inject(ActivatedRoute);
|
|
183
|
+
private readonly meta = inject(Meta);
|
|
184
|
+
private readonly title = inject(Title);
|
|
185
|
+
private readonly platformId = inject(PLATFORM_ID);
|
|
186
|
+
private readonly document = inject(DOCUMENT);
|
|
187
|
+
readonly theme = inject(ThemeService);
|
|
188
|
+
|
|
189
|
+
readonly page = toSignal(
|
|
190
|
+
this.route.data.pipe(map((data) => (data['page'] as LinksPageConfig) ?? null)),
|
|
191
|
+
{ initialValue: (this.route.snapshot.data['page'] as LinksPageConfig) ?? null },
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
readonly sortedLinks = computed<LinksLink[]>(() =>
|
|
195
|
+
[...(this.page()?.links ?? [])].sort((a, b) => a.order - b.order),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
protected readonly avatarSrc = computed(() =>
|
|
199
|
+
this.theme.isDark() && this.page()?.avatarUrlDark
|
|
200
|
+
? this.page()!.avatarUrlDark!
|
|
201
|
+
: this.page()!.avatarUrl,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
protected readonly initials = computed(() => {
|
|
205
|
+
const headline = this.page()?.headline ?? this.page()?.title ?? '';
|
|
206
|
+
return headline
|
|
207
|
+
.split(' ')
|
|
208
|
+
.slice(0, 2)
|
|
209
|
+
.map((w: string) => w[0])
|
|
210
|
+
.join('')
|
|
211
|
+
.toUpperCase();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
private upsertCanonical(href: string): void {
|
|
215
|
+
let el = this.document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
|
216
|
+
if (!el) {
|
|
217
|
+
el = this.document.createElement('link');
|
|
218
|
+
el.rel = 'canonical';
|
|
219
|
+
this.document.head.appendChild(el);
|
|
220
|
+
}
|
|
221
|
+
el.href = href;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
getIcon(link: LinksLink): string {
|
|
225
|
+
if (link.platform && PLATFORM_ICONS[link.platform]) {
|
|
226
|
+
return PLATFORM_ICONS[link.platform];
|
|
227
|
+
}
|
|
228
|
+
return 'open_in_new';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
constructor() {
|
|
232
|
+
effect(() => {
|
|
233
|
+
const p = this.page();
|
|
234
|
+
if (!p) return;
|
|
235
|
+
if (!isPlatformBrowser(this.platformId)) return;
|
|
236
|
+
const pageTitle = p.seo?.title ?? p.title ?? 'Links';
|
|
237
|
+
this.title.setTitle(pageTitle);
|
|
238
|
+
if (p.seo?.description) {
|
|
239
|
+
this.meta.updateTag({ name: 'description', content: p.seo.description });
|
|
240
|
+
}
|
|
241
|
+
this.meta.updateTag({ property: 'og:title', content: pageTitle });
|
|
242
|
+
this.meta.updateTag({ property: 'og:type', content: 'website' });
|
|
243
|
+
const canonicalUrl = `${this.document.location?.origin ?? ''}/links`;
|
|
244
|
+
this.meta.updateTag({ property: 'og:url', content: canonicalUrl });
|
|
245
|
+
this.upsertCanonical(canonicalUrl);
|
|
246
|
+
if (p.seo?.ogImage) {
|
|
247
|
+
this.meta.updateTag({ property: 'og:image', content: p.seo.ogImage });
|
|
248
|
+
}
|
|
249
|
+
if (p.seo?.noIndex) {
|
|
250
|
+
this.meta.updateTag({ name: 'robots', content: 'noindex' });
|
|
251
|
+
} else {
|
|
252
|
+
this.meta.removeTag('name="robots"');
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AboutPageConfig, LinksPageConfig } from '@foliokit/cms-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The route data key used by LinksPageComponent to read its page from Angular
|
|
5
|
+
* Router resolved data.
|
|
6
|
+
*/
|
|
7
|
+
export const CMS_ROUTE_DATA_KEY = 'page' as const;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The route data key used by AboutPageComponent to read its config from Angular
|
|
11
|
+
* Router resolved data.
|
|
12
|
+
*/
|
|
13
|
+
export const ABOUT_ROUTE_DATA_KEY = 'about' as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shape of the resolved route data expected by AboutPageComponent.
|
|
17
|
+
*/
|
|
18
|
+
export interface AboutPageRouteData {
|
|
19
|
+
[ABOUT_ROUTE_DATA_KEY]: AboutPageConfig | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shape of the resolved route data expected by LinksPageComponent.
|
|
24
|
+
*/
|
|
25
|
+
export interface LinksPageRouteData {
|
|
26
|
+
[CMS_ROUTE_DATA_KEY]: LinksPageConfig | null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { InjectionToken } from '@angular/core';
|
|
2
|
+
import type { NavEntry } from '@foliokit/cms-core';
|
|
3
|
+
|
|
4
|
+
export interface ShellConfig {
|
|
5
|
+
appName: string;
|
|
6
|
+
logoUrl?: string;
|
|
7
|
+
showAuth?: boolean;
|
|
8
|
+
nav?: NavEntry[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const SHELL_CONFIG = new InjectionToken<ShellConfig>('SHELL_CONFIG');
|