@leadertechie/personal-site-kit 0.1.0-alpha.6 → 0.1.0-alpha.8
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/dist/api/handlers/about-me.d.ts.map +1 -1
- package/dist/api/handlers/auth-handler.d.ts.map +1 -1
- package/dist/api/website-api.d.ts.map +1 -1
- package/dist/api.js +2 -2
- package/dist/chunks/index-CGvOrVf8.js +213 -0
- package/dist/chunks/{index-C3wLSCKU.js → index-CYd_Pe2U.js} +1353 -482
- package/dist/chunks/{template-MawmknFQ.js → template-D1uGvdWZ.js} +10 -8
- package/dist/chunks/{website-api-DI3muo2s.js → website-api-FLejlWxJ.js} +78 -41
- package/dist/index.js +19 -9
- package/dist/prerender/data-fetcher.d.ts +19 -0
- package/dist/prerender/data-fetcher.d.ts.map +1 -0
- package/dist/prerender/page-content.d.ts.map +1 -1
- package/dist/prerender/page-generators/about.d.ts +16 -0
- package/dist/prerender/page-generators/about.d.ts.map +1 -0
- package/dist/prerender/page-generators/base.d.ts +26 -0
- package/dist/prerender/page-generators/base.d.ts.map +1 -0
- package/dist/prerender/page-generators/blog-detail.d.ts +15 -0
- package/dist/prerender/page-generators/blog-detail.d.ts.map +1 -0
- package/dist/prerender/page-generators/blogs-list.d.ts +17 -0
- package/dist/prerender/page-generators/blogs-list.d.ts.map +1 -0
- package/dist/prerender/page-generators/home.d.ts +19 -0
- package/dist/prerender/page-generators/home.d.ts.map +1 -0
- package/dist/prerender/page-generators/index.d.ts +9 -0
- package/dist/prerender/page-generators/index.d.ts.map +1 -0
- package/dist/prerender/page-generators/not-found.d.ts +14 -0
- package/dist/prerender/page-generators/not-found.d.ts.map +1 -0
- package/dist/prerender/page-generators/stories-list.d.ts +17 -0
- package/dist/prerender/page-generators/stories-list.d.ts.map +1 -0
- package/dist/prerender/page-generators/story-detail.d.ts +15 -0
- package/dist/prerender/page-generators/story-detail.d.ts.map +1 -0
- package/dist/prerender.js +109 -102
- package/dist/shared/config/index.d.ts.map +1 -1
- package/dist/shared.js +1 -1
- package/dist/ui/about-me/index.d.ts +2 -10
- package/dist/ui/about-me/index.d.ts.map +1 -1
- package/dist/ui/admin/api.d.ts +16 -0
- package/dist/ui/admin/api.d.ts.map +1 -0
- package/dist/ui/admin/components/AboutMeSection.d.ts +7 -0
- package/dist/ui/admin/components/AboutMeSection.d.ts.map +1 -0
- package/dist/ui/admin/components/AdminSection.d.ts +13 -0
- package/dist/ui/admin/components/AdminSection.d.ts.map +1 -0
- package/dist/ui/admin/components/BlogsSection.d.ts +7 -0
- package/dist/ui/admin/components/BlogsSection.d.ts.map +1 -0
- package/dist/ui/admin/components/HomeSection.d.ts +7 -0
- package/dist/ui/admin/components/HomeSection.d.ts.map +1 -0
- package/dist/ui/admin/components/ImagesSection.d.ts +7 -0
- package/dist/ui/admin/components/ImagesSection.d.ts.map +1 -0
- package/dist/ui/admin/components/LoginForm.d.ts +9 -0
- package/dist/ui/admin/components/LoginForm.d.ts.map +1 -0
- package/dist/ui/admin/components/LogoSection.d.ts +7 -0
- package/dist/ui/admin/components/LogoSection.d.ts.map +1 -0
- package/dist/ui/admin/components/ProfileSection.d.ts +7 -0
- package/dist/ui/admin/components/ProfileSection.d.ts.map +1 -0
- package/dist/ui/admin/components/StaticSection.d.ts +13 -0
- package/dist/ui/admin/components/StaticSection.d.ts.map +1 -0
- package/dist/ui/admin/components/StoriesSection.d.ts +7 -0
- package/dist/ui/admin/components/StoriesSection.d.ts.map +1 -0
- package/dist/ui/admin/components/index.d.ts +11 -0
- package/dist/ui/admin/components/index.d.ts.map +1 -0
- package/dist/ui/admin/index.d.ts +10 -26
- package/dist/ui/admin/index.d.ts.map +1 -1
- package/dist/ui/admin/types.d.ts +24 -0
- package/dist/ui/admin/types.d.ts.map +1 -0
- package/dist/ui/blog-viewer/index.d.ts.map +1 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/story-viewer/index.d.ts.map +1 -1
- package/dist/ui.js +14 -4
- package/package.json +1 -1
- package/src/api/handlers/about-me.ts +19 -9
- package/src/api/handlers/auth-handler.ts +41 -18
- package/src/api/handlers/content.ts +1 -1
- package/src/api/handlers/home.ts +2 -2
- package/src/api/website-api.ts +25 -13
- package/src/prerender/__tests__/page-content.test.ts +1 -11
- package/src/prerender/data-fetcher.ts +93 -0
- package/src/prerender/page-content.ts +109 -106
- package/src/prerender/page-generators/about.ts +38 -0
- package/src/prerender/page-generators/base.ts +77 -0
- package/src/prerender/page-generators/blog-detail.ts +35 -0
- package/src/prerender/page-generators/blogs-list.ts +43 -0
- package/src/prerender/page-generators/home.ts +54 -0
- package/src/prerender/page-generators/index.ts +8 -0
- package/src/prerender/page-generators/not-found.ts +36 -0
- package/src/prerender/page-generators/stories-list.ts +43 -0
- package/src/prerender/page-generators/story-detail.ts +35 -0
- package/src/shared/config/index.ts +4 -2
- package/src/shared/page-content.ts +1 -1
- package/src/shared/router.ts +5 -5
- package/src/ui/about-me/index.ts +23 -57
- package/src/ui/admin/api.ts +93 -0
- package/src/ui/admin/components/AboutMeSection.ts +47 -0
- package/src/ui/admin/components/AdminSection.ts +134 -0
- package/src/ui/admin/components/BlogsSection.ts +62 -0
- package/src/ui/admin/components/HomeSection.ts +47 -0
- package/src/ui/admin/components/ImagesSection.ts +54 -0
- package/src/ui/admin/components/LoginForm.ts +116 -0
- package/src/ui/admin/components/LogoSection.ts +51 -0
- package/src/ui/admin/components/ProfileSection.ts +47 -0
- package/src/ui/admin/components/StaticSection.ts +67 -0
- package/src/ui/admin/components/StoriesSection.ts +62 -0
- package/src/ui/admin/components/index.ts +10 -0
- package/src/ui/admin/index.ts +192 -434
- package/src/ui/admin/types.ts +26 -0
- package/src/ui/blog-viewer/index.ts +4 -1
- package/src/ui/index.ts +7 -0
- package/src/ui/story-viewer/index.ts +4 -1
- package/dist/ui/about-me/renderer.d.ts +0 -5
- package/dist/ui/about-me/renderer.d.ts.map +0 -1
- package/src/ui/about-me/renderer.ts +0 -7
package/src/shared/router.ts
CHANGED
|
@@ -35,10 +35,10 @@ export class Router {
|
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
this.footerLinks = [
|
|
38
|
-
{ text: 'LinkedIn', link: normalizeUrl(config.linkedin) },
|
|
39
|
-
{ text: 'GitHub', link: normalizeUrl(config.github) },
|
|
40
|
-
{ text: 'Email', link: config.email ? `mailto:${config.email}` : '' },
|
|
41
|
-
]
|
|
38
|
+
{ text: 'LinkedIn', link: normalizeUrl(config.linkedin) || 'https://linkedin.com' },
|
|
39
|
+
{ text: 'GitHub', link: normalizeUrl(config.github) || 'https://github.com' },
|
|
40
|
+
{ text: 'Email', link: config.email ? `mailto:${config.email}` : 'mailto:hello@example.com' },
|
|
41
|
+
];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
public init(appElementId: string = 'app') {
|
|
@@ -243,7 +243,7 @@ export class Router {
|
|
|
243
243
|
<main class="container container-medium">
|
|
244
244
|
<admin-portal></admin-portal>
|
|
245
245
|
</main>
|
|
246
|
-
<my-footer copyright="${this.copyright}"
|
|
246
|
+
<my-footer copyright="${this.copyright}" footerLinks='[]'></my-footer>
|
|
247
247
|
`;
|
|
248
248
|
}
|
|
249
249
|
}
|
package/src/ui/about-me/index.ts
CHANGED
|
@@ -1,19 +1,10 @@
|
|
|
1
1
|
import { LitElement, html, css } from 'lit';
|
|
2
2
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
3
3
|
|
|
4
|
-
import { ContentNode } from '@leadertechie/md2html';
|
|
5
4
|
import { Profile } from './api';
|
|
6
|
-
|
|
7
5
|
import { aboutmeStyles } from './styles';
|
|
8
|
-
|
|
9
|
-
import { AboutMeRenderer } from './renderer';
|
|
10
6
|
import { fetchAboutMe } from './api';
|
|
11
7
|
|
|
12
|
-
interface AboutMeData {
|
|
13
|
-
profile: Profile;
|
|
14
|
-
contentNodes: ContentNode[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
8
|
@customElement('my-aboutme')
|
|
18
9
|
export class MyAboutme extends LitElement {
|
|
19
10
|
static styles = aboutmeStyles;
|
|
@@ -25,22 +16,15 @@ export class MyAboutme extends LitElement {
|
|
|
25
16
|
accessor profile: Profile | null = null;
|
|
26
17
|
|
|
27
18
|
@state()
|
|
28
|
-
accessor
|
|
19
|
+
accessor htmlContent: string = '';
|
|
29
20
|
|
|
30
21
|
@state()
|
|
31
22
|
accessor loading = true;
|
|
32
23
|
|
|
33
|
-
private renderer: AboutMeRenderer;
|
|
34
|
-
/**
|
|
35
|
-
* Injectable fetcher function used to retrieve About Me data.
|
|
36
|
-
* Tests can override this (e.g. componentInstance.fetcher = myMock)
|
|
37
|
-
* to avoid network I/O. By default it uses the stable `fetchAboutMe` helper.
|
|
38
|
-
*/
|
|
39
24
|
public fetcher = fetchAboutMe;
|
|
40
25
|
|
|
41
|
-
constructor(
|
|
26
|
+
constructor() {
|
|
42
27
|
super();
|
|
43
|
-
this.renderer = renderer || new AboutMeRenderer();
|
|
44
28
|
}
|
|
45
29
|
|
|
46
30
|
private get apiBaseUrl(): string {
|
|
@@ -50,70 +34,59 @@ export class MyAboutme extends LitElement {
|
|
|
50
34
|
async connectedCallback() {
|
|
51
35
|
super.connectedCallback();
|
|
52
36
|
|
|
53
|
-
// If we have initial data from SSR, use it immediately.
|
|
54
37
|
if (typeof window !== 'undefined' && (window as any).__HYDRATION_DATA__) {
|
|
55
38
|
const hydrationData = (window as any).__HYDRATION_DATA__;
|
|
56
39
|
this.profile = hydrationData.profile;
|
|
57
|
-
this.
|
|
40
|
+
this.htmlContent = hydrationData.processedMarkdown;
|
|
58
41
|
this.loading = false;
|
|
59
42
|
return;
|
|
60
43
|
}
|
|
61
44
|
|
|
62
|
-
|
|
63
|
-
const url = this.apiBaseUrl;
|
|
64
|
-
if (url) {
|
|
65
|
-
this.loadContent();
|
|
66
|
-
}
|
|
45
|
+
this.loadContent();
|
|
67
46
|
}
|
|
68
47
|
|
|
69
48
|
updated(changedProperties: Map<string, any>) {
|
|
70
49
|
super.updated(changedProperties);
|
|
71
50
|
|
|
72
|
-
|
|
73
|
-
if (this.loading === false && this.profile && this.contentNodes.length > 0) {
|
|
51
|
+
if (this.loading === false && this.profile && this.htmlContent) {
|
|
74
52
|
return;
|
|
75
53
|
}
|
|
76
54
|
|
|
77
|
-
// If baseUrl changed and we have a valid baseUrl, load content
|
|
78
55
|
if (changedProperties.has('baseUrl') || changedProperties.has('base-url')) {
|
|
79
|
-
|
|
80
|
-
if (url) {
|
|
81
|
-
this.loadContent();
|
|
82
|
-
}
|
|
56
|
+
this.loadContent();
|
|
83
57
|
}
|
|
84
58
|
}
|
|
85
59
|
|
|
86
60
|
private async loadContent() {
|
|
61
|
+
const url = this.apiBaseUrl;
|
|
62
|
+
if (!url) {
|
|
63
|
+
this.loading = false;
|
|
64
|
+
this.setFallbackContent();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
87
68
|
try {
|
|
88
69
|
this.loading = true;
|
|
89
|
-
|
|
90
|
-
// Fetch content from API using the injectable fetcher.
|
|
91
|
-
// Use the apiBaseUrl getter to get either property or attribute
|
|
92
|
-
const url = this.apiBaseUrl;
|
|
93
70
|
const data = await this.fetcher(url);
|
|
94
71
|
|
|
95
72
|
this.profile = data.profile;
|
|
96
|
-
this.
|
|
73
|
+
this.htmlContent = (data as any).processedMarkdown;
|
|
97
74
|
this.loading = false;
|
|
98
|
-
// Wait for the render to complete
|
|
99
75
|
await this.updateComplete;
|
|
100
76
|
} catch (error) {
|
|
101
77
|
this.loading = false;
|
|
102
|
-
// Set fallback content
|
|
103
78
|
this.setFallbackContent();
|
|
104
|
-
// Wait for the render to complete
|
|
105
79
|
await this.updateComplete;
|
|
106
80
|
}
|
|
107
81
|
}
|
|
108
82
|
|
|
109
83
|
private setFallbackContent() {
|
|
110
|
-
this.profile = null;
|
|
111
|
-
this.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
];
|
|
84
|
+
this.profile = null;
|
|
85
|
+
this.htmlContent = `
|
|
86
|
+
<h2>Profile Not Found</h2>
|
|
87
|
+
<p>Your about-me profile has not been initialized yet. Please run the seed command to get started:</p>
|
|
88
|
+
<pre>npm run seed -- <username> '<password>'</pre>
|
|
89
|
+
`;
|
|
117
90
|
}
|
|
118
91
|
|
|
119
92
|
render() {
|
|
@@ -121,15 +94,10 @@ export class MyAboutme extends LitElement {
|
|
|
121
94
|
return html`<div class="aboutme"><div class="loading">Loading...</div></div>`;
|
|
122
95
|
}
|
|
123
96
|
|
|
124
|
-
// If profile is not available, check if fallback content is present.
|
|
125
97
|
if (!this.profile) {
|
|
126
|
-
|
|
127
|
-
return html`<div class="aboutme">${this.renderer.renderContent(this.contentNodes)}</div>`;
|
|
128
|
-
}
|
|
129
|
-
return html`<div class="aboutme"><div class="loading">Failed to load content</div></div>`;
|
|
98
|
+
return html`<div class="aboutme"><div class="content-section" .innerHTML="${this.htmlContent}"></div></div>`;
|
|
130
99
|
}
|
|
131
100
|
|
|
132
|
-
// Render profile header + content
|
|
133
101
|
const profileImageUrl = this.profile.profileImageUrl
|
|
134
102
|
? (this.profile.profileImageUrl.startsWith('http') || this.profile.profileImageUrl.startsWith('/')
|
|
135
103
|
? this.profile.profileImageUrl
|
|
@@ -146,10 +114,8 @@ export class MyAboutme extends LitElement {
|
|
|
146
114
|
<h1>${this.profile.name}</h1>
|
|
147
115
|
<p class="profile-title">${this.profile.title} • ${this.profile.experience}</p>
|
|
148
116
|
</div>
|
|
149
|
-
<div class="content-section">
|
|
150
|
-
${this.renderer.renderContent(this.contentNodes)}
|
|
151
|
-
</div>
|
|
117
|
+
<div class="content-section" .innerHTML="${this.htmlContent}"></div>
|
|
152
118
|
</div>
|
|
153
119
|
`;
|
|
154
120
|
}
|
|
155
|
-
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ContentItem, AuthStatus, StaticDetails } from './types';
|
|
2
|
+
|
|
3
|
+
export class AdminApiService {
|
|
4
|
+
constructor(private apiUrl: string) {}
|
|
5
|
+
|
|
6
|
+
async checkAuthStatus(): Promise<AuthStatus> {
|
|
7
|
+
const res = await fetch(`${this.apiUrl}/api/auth/status`);
|
|
8
|
+
if (!res.ok) throw new Error('Auth status check failed');
|
|
9
|
+
return res.json();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async tryAutoLogin(): Promise<ContentItem[]> {
|
|
13
|
+
const res = await fetch(`${this.apiUrl}/api/content`, {
|
|
14
|
+
credentials: 'include'
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) throw new Error('Auto login failed');
|
|
17
|
+
return res.json();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async login(username: string, password: string): Promise<void> {
|
|
21
|
+
const res = await fetch(`${this.apiUrl}/api/auth/login`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
credentials: 'include',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ username, password })
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
throw new Error(data.error || 'Login failed');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async setup(username: string, password: string): Promise<void> {
|
|
34
|
+
const res = await fetch(`${this.apiUrl}/api/auth/setup`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
credentials: 'include',
|
|
37
|
+
headers: { 'Content-Type': 'application/json' },
|
|
38
|
+
body: JSON.stringify({ username, password })
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
throw new Error(data.error || 'Setup failed');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async logout(): Promise<void> {
|
|
47
|
+
await fetch(`${this.apiUrl}/api/auth/logout`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
credentials: 'include'
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async fetchContent(): Promise<ContentItem[]> {
|
|
54
|
+
const res = await fetch(`${this.apiUrl}/api/content`, {
|
|
55
|
+
credentials: 'include'
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) throw new Error('Failed to fetch content');
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async fetchStaticDetails(): Promise<StaticDetails> {
|
|
62
|
+
const res = await fetch(`${this.apiUrl}/api/static`, {
|
|
63
|
+
credentials: 'include'
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) throw new Error('Failed to fetch static details');
|
|
66
|
+
return res.json();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async uploadContent(key: string, file: File): Promise<void> {
|
|
70
|
+
const res = await fetch(`${this.apiUrl}/api/content/${key}`, {
|
|
71
|
+
method: 'PUT',
|
|
72
|
+
credentials: 'include',
|
|
73
|
+
body: file
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) throw new Error('Upload failed');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async deleteContent(key: string): Promise<void> {
|
|
79
|
+
const res = await fetch(`${this.apiUrl}/api/content/${key}`, {
|
|
80
|
+
method: 'DELETE',
|
|
81
|
+
credentials: 'include'
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) throw new Error('Delete failed');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async clearCache(): Promise<void> {
|
|
87
|
+
const res = await fetch(`${this.apiUrl}/api/cache-clear`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
credentials: 'include'
|
|
90
|
+
});
|
|
91
|
+
if (!res.ok) throw new Error('Failed to clear cache');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { html } from 'lit';
|
|
2
|
+
import { customElement } from 'lit/decorators.js';
|
|
3
|
+
import { AdminSection } from './AdminSection';
|
|
4
|
+
|
|
5
|
+
@customElement('admin-about-me-section')
|
|
6
|
+
export class AdminAboutMeSection extends AdminSection {
|
|
7
|
+
private async handleUpload() {
|
|
8
|
+
const input = this.shadowRoot?.querySelector('#aboutFile') as HTMLInputElement;
|
|
9
|
+
if (input.files?.[0]) {
|
|
10
|
+
try {
|
|
11
|
+
await this.onUpload('about-me.md', input.files[0]);
|
|
12
|
+
this.onStatusMessage('Upload successful!');
|
|
13
|
+
} catch (e) {
|
|
14
|
+
this.onStatusMessage('Upload failed.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private async handleDelete() {
|
|
20
|
+
if (!confirm('Delete about-me.md?')) return;
|
|
21
|
+
try {
|
|
22
|
+
await this.onDelete('about-me.md');
|
|
23
|
+
} catch (e) {
|
|
24
|
+
this.onStatusMessage('Delete failed.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
const aboutMe = this.getContent('about-me.md');
|
|
30
|
+
return html`
|
|
31
|
+
<div class="section">
|
|
32
|
+
<h3>About Me Page <span class="required-badge">Required</span></h3>
|
|
33
|
+
<p class="help-text">Content for your About Me page. Supports Markdown with frontmatter.</p>
|
|
34
|
+
|
|
35
|
+
${aboutMe ? html`
|
|
36
|
+
<div class="current-file">
|
|
37
|
+
<strong>Current:</strong> about-me.md (${aboutMe.size} bytes)
|
|
38
|
+
<button class="btn-danger" @click=${this.handleDelete}>Delete</button>
|
|
39
|
+
</div>
|
|
40
|
+
` : ''}
|
|
41
|
+
|
|
42
|
+
<input type="file" id="aboutFile" accept=".md" />
|
|
43
|
+
<button class="btn-primary" @click=${this.handleUpload}>Upload about-me.md</button>
|
|
44
|
+
</div>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { LitElement, css } from 'lit';
|
|
2
|
+
import { property } from 'lit/decorators.js';
|
|
3
|
+
import type { ContentItem, StaticDetails, AdminSectionProps } from '../types';
|
|
4
|
+
|
|
5
|
+
export abstract class AdminSection extends LitElement implements AdminSectionProps {
|
|
6
|
+
|
|
7
|
+
static styles = css`
|
|
8
|
+
:host {
|
|
9
|
+
display: block;
|
|
10
|
+
width: 100%;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.section {
|
|
14
|
+
width: 100%;
|
|
15
|
+
border: none;
|
|
16
|
+
padding: 24px;
|
|
17
|
+
border-radius: 16px;
|
|
18
|
+
background: var(--card-bg, #fff);
|
|
19
|
+
box-shadow: 0 4px 6px rgba(0,0,0,0.07), 0 10px 20px rgba(0,0,0,0.05);
|
|
20
|
+
margin-bottom: 20px;
|
|
21
|
+
min-height: 450px;
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.section h3 {
|
|
26
|
+
margin: 0 0 0.5rem 0;
|
|
27
|
+
font-size: 1.25rem;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.help-text {
|
|
31
|
+
color: var(--secondary-text, #666);
|
|
32
|
+
margin-bottom: 1rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.current-file {
|
|
36
|
+
margin-bottom: 1rem;
|
|
37
|
+
padding: 1rem;
|
|
38
|
+
background: var(--nav-link-hover-bg, #f5f5f5);
|
|
39
|
+
border-radius: 8px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
input[type="text"],
|
|
43
|
+
input[type="file"] {
|
|
44
|
+
width: 100%;
|
|
45
|
+
padding: 10px;
|
|
46
|
+
margin-bottom: 1rem;
|
|
47
|
+
border: 1px solid var(--border-color);
|
|
48
|
+
border-radius: 4px;
|
|
49
|
+
background: var(--background-color);
|
|
50
|
+
color: var(--text-color);
|
|
51
|
+
box-sizing: border-box;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
input[type="file"] {
|
|
55
|
+
margin-bottom: 1rem;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
button {
|
|
59
|
+
padding: 10px 20px;
|
|
60
|
+
border: none;
|
|
61
|
+
border-radius: 4px;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
font-size: 1rem;
|
|
64
|
+
transition: background-color 0.3s ease;
|
|
65
|
+
margin-right: 10px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.btn-primary {
|
|
69
|
+
background: var(--link-color, #646cff);
|
|
70
|
+
color: white;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.btn-primary:hover {
|
|
74
|
+
background: var(--link-hover-color, #535bf2);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.btn-secondary {
|
|
78
|
+
background: var(--nav-link-hover-bg, #f0f0f0);
|
|
79
|
+
color: var(--text-color, #213547);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.btn-secondary:hover {
|
|
83
|
+
background: var(--border-color, #e0e0e0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.btn-danger {
|
|
87
|
+
background: #dc3545;
|
|
88
|
+
color: white;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.btn-danger:hover {
|
|
92
|
+
background: #c82333;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.file-list {
|
|
96
|
+
margin-top: 1rem;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.file-item {
|
|
100
|
+
display: flex;
|
|
101
|
+
justify-content: space-between;
|
|
102
|
+
align-items: center;
|
|
103
|
+
padding: 0.5rem;
|
|
104
|
+
border-bottom: 1px solid var(--border-color, #eee);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.file-item:last-child {
|
|
108
|
+
border-bottom: none;
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
@property({ type: Array })
|
|
113
|
+
accessor contentList: ContentItem[] = [];
|
|
114
|
+
|
|
115
|
+
@property({ type: Object })
|
|
116
|
+
accessor staticDetails: StaticDetails = {};
|
|
117
|
+
|
|
118
|
+
@property({ type: Function })
|
|
119
|
+
accessor onUpload: (key: string, file: File) => Promise<void> = async () => {};
|
|
120
|
+
|
|
121
|
+
@property({ type: Function })
|
|
122
|
+
accessor onDelete: (key: string) => Promise<void> = async () => {};
|
|
123
|
+
|
|
124
|
+
@property({ type: Function })
|
|
125
|
+
accessor onStatusMessage: (message: string) => void = () => {};
|
|
126
|
+
|
|
127
|
+
protected getContent(key: string): ContentItem | undefined {
|
|
128
|
+
return this.contentList.find(c => c.key === key);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected getSectionFiles(prefix: string): ContentItem[] {
|
|
132
|
+
return this.contentList.filter(c => c.key.startsWith(prefix));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { html } from 'lit';
|
|
2
|
+
import { customElement } from 'lit/decorators.js';
|
|
3
|
+
import { AdminSection } from './AdminSection';
|
|
4
|
+
|
|
5
|
+
@customElement('admin-blogs-section')
|
|
6
|
+
export class AdminBlogsSection extends AdminSection {
|
|
7
|
+
private async handleUpload() {
|
|
8
|
+
const metaInput = this.shadowRoot?.querySelector('#blogMetaFile') as HTMLInputElement;
|
|
9
|
+
const contentInput = this.shadowRoot?.querySelector('#blogContentFile') as HTMLInputElement;
|
|
10
|
+
const slugInput = this.shadowRoot?.querySelector('#blogSlug') as HTMLInputElement;
|
|
11
|
+
|
|
12
|
+
if (metaInput.files?.[0] && contentInput.files?.[0] && slugInput.value) {
|
|
13
|
+
try {
|
|
14
|
+
await this.onUpload(`blogs/${slugInput.value}.json`, metaInput.files[0]);
|
|
15
|
+
await this.onUpload(`blogs/${slugInput.value}.md`, contentInput.files[0]);
|
|
16
|
+
this.onStatusMessage('Upload successful!');
|
|
17
|
+
} catch (e) {
|
|
18
|
+
this.onStatusMessage('Upload failed.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async handleDelete(slug: string) {
|
|
24
|
+
if (!confirm(`Delete blog ${slug}?`)) return;
|
|
25
|
+
try {
|
|
26
|
+
await this.onDelete(`blogs/${slug}.json`);
|
|
27
|
+
await this.onDelete(`blogs/${slug}.md`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
this.onStatusMessage('Delete failed.');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
render() {
|
|
34
|
+
const blogs = this.getSectionFiles('blogs/').filter(b => b.key.endsWith('.json'));
|
|
35
|
+
return html`
|
|
36
|
+
<div class="section">
|
|
37
|
+
<h3>Blog Posts</h3>
|
|
38
|
+
<p class="help-text">Each blog needs 2 files: a JSON (metadata) and MD (content) file.</p>
|
|
39
|
+
|
|
40
|
+
<h4>Upload New Blog</h4>
|
|
41
|
+
<input type="file" id="blogMetaFile" accept=".json" />
|
|
42
|
+
<input type="file" id="blogContentFile" accept=".md" />
|
|
43
|
+
<input type="text" id="blogSlug" placeholder="Slug (e.g., my-new-post)" class="mt-1" />
|
|
44
|
+
<button class="btn-primary" @click=${this.handleUpload}>Upload Blog (JSON + MD)</button>
|
|
45
|
+
|
|
46
|
+
<div class="file-list">
|
|
47
|
+
<h4>Current Blogs (${blogs.length})</h4>
|
|
48
|
+
${blogs.map(b => {
|
|
49
|
+
const slug = b.key.replace('blogs/', '').replace('.json', '');
|
|
50
|
+
return html`
|
|
51
|
+
<div class="file-item">
|
|
52
|
+
<span>${b.key.replace('.json', '')}</span>
|
|
53
|
+
<button class="btn-danger" @click=${() => this.handleDelete(slug)}>Delete</button>
|
|
54
|
+
</div>
|
|
55
|
+
`;
|
|
56
|
+
})}
|
|
57
|
+
${blogs.length === 0 ? html`<p>No blogs yet.</p>` : ''}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { html } from 'lit';
|
|
2
|
+
import { customElement } from 'lit/decorators.js';
|
|
3
|
+
import { AdminSection } from './AdminSection';
|
|
4
|
+
|
|
5
|
+
@customElement('admin-home-section')
|
|
6
|
+
export class AdminHomeSection extends AdminSection {
|
|
7
|
+
private async handleUpload() {
|
|
8
|
+
const input = this.shadowRoot?.querySelector('#homeFile') as HTMLInputElement;
|
|
9
|
+
if (input.files?.[0]) {
|
|
10
|
+
try {
|
|
11
|
+
await this.onUpload('home.md', input.files[0]);
|
|
12
|
+
this.onStatusMessage('Upload successful!');
|
|
13
|
+
} catch (e) {
|
|
14
|
+
this.onStatusMessage('Upload failed.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private async handleDelete() {
|
|
20
|
+
if (!confirm('Delete home.md?')) return;
|
|
21
|
+
try {
|
|
22
|
+
await this.onDelete('home.md');
|
|
23
|
+
} catch (e) {
|
|
24
|
+
this.onStatusMessage('Delete failed.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
render() {
|
|
29
|
+
const home = this.getContent('home.md');
|
|
30
|
+
return html`
|
|
31
|
+
<div class="section">
|
|
32
|
+
<h3>Home Page</h3>
|
|
33
|
+
<p class="help-text">Content for your home page. Upload home.md with your main content.</p>
|
|
34
|
+
|
|
35
|
+
${home ? html`
|
|
36
|
+
<div class="current-file">
|
|
37
|
+
<strong>Current:</strong> home.md (${home.size} bytes)
|
|
38
|
+
<button class="btn-danger" @click=${this.handleDelete}>Delete</button>
|
|
39
|
+
</div>
|
|
40
|
+
` : ''}
|
|
41
|
+
|
|
42
|
+
<input type="file" id="homeFile" accept=".md" />
|
|
43
|
+
<button class="btn-primary" @click=${this.handleUpload}>Upload home.md</button>
|
|
44
|
+
</div>
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { html } from 'lit';
|
|
2
|
+
import { customElement } from 'lit/decorators.js';
|
|
3
|
+
import { AdminSection } from './AdminSection';
|
|
4
|
+
|
|
5
|
+
@customElement('admin-images-section')
|
|
6
|
+
export class AdminImagesSection extends AdminSection {
|
|
7
|
+
private async handleUpload() {
|
|
8
|
+
const fileInput = this.shadowRoot?.querySelector('#imageFile') as HTMLInputElement;
|
|
9
|
+
const pathInput = this.shadowRoot?.querySelector('#imagePath') as HTMLInputElement;
|
|
10
|
+
|
|
11
|
+
if (fileInput.files?.[0] && pathInput.value) {
|
|
12
|
+
try {
|
|
13
|
+
await this.onUpload(`images/${pathInput.value}`, fileInput.files[0]);
|
|
14
|
+
this.onStatusMessage('Upload successful!');
|
|
15
|
+
} catch (e) {
|
|
16
|
+
this.onStatusMessage('Upload failed.');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private async handleDelete(key: string) {
|
|
22
|
+
if (!confirm(`Delete ${key}?`)) return;
|
|
23
|
+
try {
|
|
24
|
+
await this.onDelete(key);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
this.onStatusMessage('Delete failed.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
render() {
|
|
31
|
+
const images = this.getSectionFiles('images/');
|
|
32
|
+
return html`
|
|
33
|
+
<div class="section">
|
|
34
|
+
<h3>Images</h3>
|
|
35
|
+
<p class="help-text">Upload images for use in your content. In markdown, reference images by filename only (e.g., use <code>my-photo.jpg</code> not <code>/images/my-photo.jpg</code>). The renderer automatically prepends <code>images/</code>.</p>
|
|
36
|
+
|
|
37
|
+
<input type="file" id="imageFile" accept="image/*" />
|
|
38
|
+
<input type="text" id="imagePath" placeholder="Image name (e.g., profile-photo.jpg)" class="mt-1" />
|
|
39
|
+
<button class="btn-primary" @click=${this.handleUpload}>Upload to images/</button>
|
|
40
|
+
|
|
41
|
+
<div class="file-list">
|
|
42
|
+
<h4>Current Images (${images.length})</h4>
|
|
43
|
+
${images.map(img => html`
|
|
44
|
+
<div class="file-item">
|
|
45
|
+
<span>${img.key} (${img.size} bytes)</span>
|
|
46
|
+
<button class="btn-danger" @click=${() => this.handleDelete(img.key)}>Delete</button>
|
|
47
|
+
</div>
|
|
48
|
+
`)}
|
|
49
|
+
${images.length === 0 ? html`<p>No images yet.</p>` : ''}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
}
|