@ansiversa/components 0.0.125 → 0.0.127

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/index.ts CHANGED
@@ -37,6 +37,7 @@ export { default as QuizSummary } from './src/Summary/QuizSummary.astro';
37
37
  export { default as FlashNoteSummary } from './src/Summary/FlashNoteSummary.astro';
38
38
  export { default as ResumeBuilderSummary } from './src/Summary/ResumeBuilderSummary.astro';
39
39
  export { default as PortfolioCreatorSummary } from './src/Summary/PortfolioCreatorSummary.astro';
40
+ export { default as AvImageUploader } from "./src/components/media/AvImageUploader.astro";
40
41
  export { default as ResumeBuilderShell } from './src/resume-templates/ResumeBuilderShell.astro';
41
42
  export { default as ResumeTemplateClassic } from './src/resume-templates/ResumeTemplateClassic.astro';
42
43
  export { default as ResumeTemplateModernTwoTone } from './src/resume-templates/ResumeTemplateModernTwoTone.astro';
@@ -45,6 +46,20 @@ export { default as ResumeTemplateExecutiveTimeline } from './src/resume-templat
45
46
  export type { ResumeData, ResumeTemplateType } from './src/resume-templates/typescript-schema';
46
47
  export { formatDateRange } from './src/resume-templates/typescript-schema';
47
48
  export { resumeData } from './src/resume-templates/resumeData';
49
+ export {
50
+ PortfolioPublicTemplateClassic,
51
+ PortfolioPublicTemplateGallery,
52
+ PortfolioPublicTemplateMinimal,
53
+ PortfolioPublicTemplateStory,
54
+ portfolioPublicTemplates,
55
+ resolvePortfolioPublicTemplate,
56
+ } from "./src/templates/portfolio-public";
57
+ export type {
58
+ PortfolioPublicData,
59
+ PortfolioPublicTemplateKey,
60
+ PortfolioPublicSectionKey,
61
+ } from "./src/templates/portfolio-public";
62
+ export type { AvMediaUploadResponse, AvMediaUploadResult } from "./src/media/types";
48
63
 
49
64
  export * from "./src/alpine";
50
65
  export * from "./src/Summary/types";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansiversa/components",
3
- "version": "0.0.125",
3
+ "version": "0.0.127",
4
4
  "description": "Shared UI components and layouts for the Ansiversa ecosystem",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,341 @@
1
+ ---
2
+ interface Props {
3
+ uploadUrl: string;
4
+ scope: "account" | "portfolio-profile";
5
+ purpose?: "avatar";
6
+ portfolioId?: string;
7
+ label?: string;
8
+ helpText?: string;
9
+ initialUrl?: string | null;
10
+ maxBytes?: number;
11
+ onUploadedEventName?: string;
12
+ }
13
+
14
+ const {
15
+ uploadUrl,
16
+ scope,
17
+ purpose = "avatar",
18
+ portfolioId = "",
19
+ label = "Profile photo",
20
+ helpText = "JPG, PNG, or WebP up to 2 MB.",
21
+ initialUrl = null,
22
+ maxBytes = 2 * 1024 * 1024,
23
+ onUploadedEventName = "av:uploaded",
24
+ } = Astro.props as Props;
25
+
26
+ if (!uploadUrl) {
27
+ throw new Error("AvImageUploader requires an `uploadUrl` prop.");
28
+ }
29
+
30
+ const uploaderId = `av-image-uploader-${Math.random().toString(36).slice(2, 10)}`;
31
+ ---
32
+
33
+ <div class="av-image-uploader" id={uploaderId}>
34
+ <p class="av-image-uploader__label">{label}</p>
35
+ {helpText ? <p class="av-image-uploader__help">{helpText}</p> : null}
36
+
37
+ <div class="av-image-uploader__row">
38
+ <div class="av-image-uploader__preview-wrap">
39
+ <img
40
+ class={`av-image-uploader__preview ${initialUrl ? "is-visible" : ""}`}
41
+ src={initialUrl ?? ""}
42
+ alt="Uploaded image preview"
43
+ data-av-preview
44
+ />
45
+ <div
46
+ class={`av-image-uploader__placeholder ${initialUrl ? "is-hidden" : ""}`}
47
+ data-av-placeholder
48
+ >
49
+ No photo
50
+ </div>
51
+ </div>
52
+
53
+ <div class="av-image-uploader__controls">
54
+ <label class="av-image-uploader__button" data-av-trigger for={`${uploaderId}-input`}>
55
+ Choose image
56
+ </label>
57
+ <input
58
+ id={`${uploaderId}-input`}
59
+ class="av-image-uploader__input"
60
+ type="file"
61
+ accept="image/jpeg,image/png,image/webp"
62
+ data-av-input
63
+ />
64
+ <p class="av-image-uploader__status" data-av-status></p>
65
+ <p class="av-image-uploader__error" data-av-error role="status"></p>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <script
71
+ define:vars={{
72
+ uploaderId,
73
+ uploadUrl,
74
+ scope,
75
+ purpose,
76
+ portfolioId,
77
+ maxBytes,
78
+ onUploadedEventName,
79
+ }}
80
+ >
81
+ (() => {
82
+ const root = document.getElementById(uploaderId);
83
+ if (!root) return;
84
+
85
+ const input = root.querySelector("[data-av-input]");
86
+ const trigger = root.querySelector("[data-av-trigger]");
87
+ const preview = root.querySelector("[data-av-preview]");
88
+ const placeholder = root.querySelector("[data-av-placeholder]");
89
+ const status = root.querySelector("[data-av-status]");
90
+ const error = root.querySelector("[data-av-error]");
91
+
92
+ const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
93
+
94
+ const formatBytes = (bytes) => {
95
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
96
+ if (bytes < 1024) return `${bytes} B`;
97
+ const kb = bytes / 1024;
98
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
99
+ return `${(kb / 1024).toFixed(1)} MB`;
100
+ };
101
+
102
+ const setUploading = (isUploading) => {
103
+ if (trigger) {
104
+ trigger.textContent = isUploading ? "Uploading..." : "Choose image";
105
+ trigger.classList.toggle("is-disabled", isUploading);
106
+ }
107
+ if (input) {
108
+ input.disabled = isUploading;
109
+ }
110
+ };
111
+
112
+ const setError = (message) => {
113
+ if (error) {
114
+ error.textContent = message ?? "";
115
+ }
116
+ if (status && message) {
117
+ status.textContent = "";
118
+ }
119
+ };
120
+
121
+ const setStatus = (message) => {
122
+ if (status) {
123
+ status.textContent = message ?? "";
124
+ }
125
+ };
126
+
127
+ const setPreview = (url) => {
128
+ if (!preview || !placeholder) return;
129
+
130
+ if (url) {
131
+ preview.src = url;
132
+ preview.classList.add("is-visible");
133
+ placeholder.classList.add("is-hidden");
134
+ } else {
135
+ preview.src = "";
136
+ preview.classList.remove("is-visible");
137
+ placeholder.classList.remove("is-hidden");
138
+ }
139
+ };
140
+
141
+ const parseError = async (res) => {
142
+ try {
143
+ const body = await res.json();
144
+ return body?.error || body?.message || "Upload failed.";
145
+ } catch {
146
+ return "Upload failed.";
147
+ }
148
+ };
149
+
150
+ input?.addEventListener("change", async (event) => {
151
+ const target = event.currentTarget;
152
+ const file = target?.files?.[0];
153
+ if (!file) return;
154
+
155
+ setError("");
156
+ setStatus("");
157
+
158
+ if (!allowedTypes.has(file.type)) {
159
+ setError("Please upload a JPG, PNG, or WebP image.");
160
+ target.value = "";
161
+ return;
162
+ }
163
+
164
+ if (file.size > maxBytes) {
165
+ setError(`Image must be ${formatBytes(maxBytes)} or smaller.`);
166
+ target.value = "";
167
+ return;
168
+ }
169
+
170
+ setUploading(true);
171
+
172
+ try {
173
+ const form = new FormData();
174
+ form.set("purpose", purpose);
175
+ form.set("scope", scope);
176
+ if (scope === "portfolio-profile" && portfolioId) {
177
+ form.set("portfolioId", portfolioId);
178
+ }
179
+ form.set("file", file);
180
+
181
+ const res = await fetch(uploadUrl, {
182
+ method: "POST",
183
+ body: form,
184
+ credentials: "include",
185
+ });
186
+
187
+ if (!res.ok) {
188
+ throw new Error(await parseError(res));
189
+ }
190
+
191
+ const payload = await res.json();
192
+ if (!payload?.ok || !payload?.media?.url) {
193
+ throw new Error("Upload response is invalid.");
194
+ }
195
+
196
+ const media = payload.media;
197
+ setPreview(media.url);
198
+ setStatus(`Uploaded ${formatBytes(media.size)} (${media.width}x${media.height})`);
199
+ setError("");
200
+
201
+ root.dispatchEvent(
202
+ new CustomEvent(onUploadedEventName, {
203
+ bubbles: true,
204
+ detail: media,
205
+ }),
206
+ );
207
+ } catch (uploadError) {
208
+ setError(uploadError?.message || "Upload failed.");
209
+ } finally {
210
+ setUploading(false);
211
+ target.value = "";
212
+ }
213
+ });
214
+ })();
215
+ </script>
216
+
217
+ <style>
218
+ .av-image-uploader {
219
+ display: grid;
220
+ gap: 0.6rem;
221
+ }
222
+
223
+ .av-image-uploader__label {
224
+ margin: 0;
225
+ font-size: 0.9rem;
226
+ font-weight: 600;
227
+ color: #e2e8f0;
228
+ }
229
+
230
+ .av-image-uploader__help {
231
+ margin: 0;
232
+ font-size: 0.8rem;
233
+ color: #94a3b8;
234
+ }
235
+
236
+ .av-image-uploader__row {
237
+ display: flex;
238
+ flex-wrap: wrap;
239
+ align-items: center;
240
+ gap: 1rem;
241
+ }
242
+
243
+ .av-image-uploader__preview-wrap {
244
+ width: 86px;
245
+ height: 86px;
246
+ position: relative;
247
+ flex: 0 0 auto;
248
+ }
249
+
250
+ .av-image-uploader__preview,
251
+ .av-image-uploader__placeholder {
252
+ width: 86px;
253
+ height: 86px;
254
+ border-radius: 999px;
255
+ }
256
+
257
+ .av-image-uploader__preview {
258
+ display: none;
259
+ object-fit: cover;
260
+ border: 1px solid rgba(148, 163, 184, 0.35);
261
+ background: rgba(15, 23, 42, 0.6);
262
+ }
263
+
264
+ .av-image-uploader__preview.is-visible {
265
+ display: block;
266
+ }
267
+
268
+ .av-image-uploader__placeholder {
269
+ display: inline-flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ border: 1px dashed rgba(148, 163, 184, 0.45);
273
+ background: rgba(15, 23, 42, 0.45);
274
+ color: #94a3b8;
275
+ font-size: 0.72rem;
276
+ text-transform: uppercase;
277
+ letter-spacing: 0.06em;
278
+ }
279
+
280
+ .av-image-uploader__placeholder.is-hidden {
281
+ display: none;
282
+ }
283
+
284
+ .av-image-uploader__controls {
285
+ display: grid;
286
+ gap: 0.35rem;
287
+ min-width: 220px;
288
+ }
289
+
290
+ .av-image-uploader__button {
291
+ display: inline-flex;
292
+ align-items: center;
293
+ justify-content: center;
294
+ width: fit-content;
295
+ min-width: 130px;
296
+ padding: 0.52rem 0.9rem;
297
+ border-radius: 0.65rem;
298
+ border: 1px solid rgba(100, 116, 139, 0.4);
299
+ background: rgba(15, 23, 42, 0.75);
300
+ color: #e2e8f0;
301
+ font-size: 0.8rem;
302
+ font-weight: 600;
303
+ cursor: pointer;
304
+ transition: border-color 120ms ease, background 120ms ease;
305
+ }
306
+
307
+ .av-image-uploader__button:hover {
308
+ border-color: rgba(148, 163, 184, 0.65);
309
+ background: rgba(30, 41, 59, 0.85);
310
+ }
311
+
312
+ .av-image-uploader__button.is-disabled {
313
+ opacity: 0.7;
314
+ cursor: progress;
315
+ pointer-events: none;
316
+ }
317
+
318
+ .av-image-uploader__input {
319
+ position: absolute;
320
+ width: 1px;
321
+ height: 1px;
322
+ padding: 0;
323
+ margin: -1px;
324
+ overflow: hidden;
325
+ clip: rect(0, 0, 0, 0);
326
+ border: 0;
327
+ }
328
+
329
+ .av-image-uploader__status {
330
+ margin: 0;
331
+ color: #93c5fd;
332
+ font-size: 0.78rem;
333
+ }
334
+
335
+ .av-image-uploader__error {
336
+ margin: 0;
337
+ min-height: 1em;
338
+ color: #fca5a5;
339
+ font-size: 0.78rem;
340
+ }
341
+ </style>
@@ -60,7 +60,7 @@ const ROOT_URL = rawDomain.match(/^https?:\/\//i)
60
60
  <slot name="head" />
61
61
  </head>
62
62
 
63
- <body class="av-page">
63
+ <body class="av-page av-theme-app">
64
64
  <main class="av-main">
65
65
  <slot />
66
66
  </main>
@@ -80,7 +80,7 @@ const ROOT_URL = rawDomain.match(/^https?:\/\//i)
80
80
  <slot name="head" />
81
81
  </head>
82
82
 
83
- <body class="av-page">
83
+ <body class="av-page av-theme-app">
84
84
  <!-- Top Navigation -->
85
85
  <AvNavbar>
86
86
  <AvBrand />
@@ -0,0 +1,13 @@
1
+ export type AvMediaUploadResult = {
2
+ key: string;
3
+ url: string;
4
+ size: number;
5
+ contentType: string;
6
+ width: number;
7
+ height: number;
8
+ };
9
+
10
+ export type AvMediaUploadResponse = {
11
+ ok: true;
12
+ media: AvMediaUploadResult;
13
+ };
@@ -6,8 +6,6 @@
6
6
  ========================================= */
7
7
 
8
8
  :root {
9
- color-scheme: dark;
10
-
11
9
  /* Brand Colors */
12
10
  --av-bg: #020617;
13
11
  --av-bg-elevated: #020617;
@@ -89,21 +87,12 @@
89
87
  body {
90
88
  min-height: 100vh;
91
89
  overflow-x: hidden;
92
- background-color: var(--av-bg);
93
- color: var(--av-text);
90
+ background-color: #ffffff;
91
+ color: #0f172a;
94
92
  font-family: var(--av-font-sans);
95
93
  text-rendering: geometricPrecision;
96
94
  -webkit-font-smoothing: antialiased;
97
95
  -moz-osx-font-smoothing: grayscale;
98
-
99
- background-image:
100
- radial-gradient(circle at top left, rgba(0, 234, 255, 0.12), transparent 55%),
101
- radial-gradient(circle at bottom right, rgba(122, 0, 255, 0.16), transparent 60%),
102
- radial-gradient(circle at top right, rgba(15, 23, 42, 0.9), rgba(2, 6, 23, 1));
103
- background-attachment: fixed;
104
-
105
- scrollbar-width: thin;
106
- scrollbar-color: rgba(148, 163, 184, 0.6) transparent;
107
96
  }
108
97
 
109
98
  @media print {
@@ -132,6 +121,19 @@
132
121
  );
133
122
  }
134
123
 
124
+ .av-theme-app {
125
+ color-scheme: dark;
126
+ background-color: var(--av-bg);
127
+ color: var(--av-text);
128
+ background-image:
129
+ radial-gradient(circle at top left, rgba(0, 234, 255, 0.12), transparent 55%),
130
+ radial-gradient(circle at bottom right, rgba(122, 0, 255, 0.16), transparent 60%),
131
+ radial-gradient(circle at top right, rgba(15, 23, 42, 0.9), rgba(2, 6, 23, 1));
132
+ background-attachment: fixed;
133
+ scrollbar-width: thin;
134
+ scrollbar-color: rgba(148, 163, 184, 0.6) transparent;
135
+ }
136
+
135
137
  h1,
136
138
  h2,
137
139
  h3,
@@ -0,0 +1,190 @@
1
+ ---
2
+ import type { PortfolioPublicData } from "./types";
3
+ import { hasText, isSectionVisible, listHasContent, normalizeHttpHref } from "./utils";
4
+
5
+ interface Props {
6
+ data: PortfolioPublicData;
7
+ }
8
+
9
+ const { data } = Astro.props as Props;
10
+
11
+ const ownerName = data.owner.fullName || data.meta?.title || "Portfolio";
12
+ const hasContact =
13
+ hasText(data.contact.email) ||
14
+ hasText(data.contact.phone) ||
15
+ hasText(data.contact.website) ||
16
+ hasText(data.contact.github) ||
17
+ hasText(data.contact.linkedin) ||
18
+ data.contact.links.length > 0;
19
+ ---
20
+
21
+ <article class="min-h-screen bg-white text-slate-900">
22
+ <main class="mx-auto grid w-full max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[280px_1fr] lg:px-8">
23
+ <aside class="space-y-8 border-b border-slate-200 pb-8 lg:border-b-0 lg:border-r lg:pb-0 lg:pr-8">
24
+ <header class="space-y-2">
25
+ {data.owner.profilePhotoUrl ? (
26
+ <img
27
+ src={data.owner.profilePhotoUrl}
28
+ alt={`${ownerName} profile photo`}
29
+ class="h-16 w-16 rounded-full border border-slate-200 object-cover"
30
+ />
31
+ ) : null}
32
+ <h1 class="text-3xl font-bold tracking-tight text-slate-950">{ownerName}</h1>
33
+ {data.owner.headline ? <p class="text-base text-slate-700">{data.owner.headline}</p> : null}
34
+ {data.owner.location ? <p class="text-sm text-slate-500">{data.owner.location}</p> : null}
35
+ </header>
36
+
37
+ {isSectionVisible(data, "skills") && data.sections.skills.length > 0 ? (
38
+ <section aria-labelledby="classic-skills" class="space-y-4">
39
+ <h2 id="classic-skills" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Skills</h2>
40
+ <div class="space-y-3">
41
+ {data.sections.skills.map((group) => (
42
+ <div>
43
+ <h3 class="text-sm font-semibold text-slate-800">{group.name || "Skills"}</h3>
44
+ {group.items.length > 0 ? (
45
+ <p class="mt-1 text-sm leading-6 text-slate-600">{group.items.join(", ")}</p>
46
+ ) : null}
47
+ </div>
48
+ ))}
49
+ </div>
50
+ </section>
51
+ ) : null}
52
+
53
+ {isSectionVisible(data, "contact") && hasContact ? (
54
+ <section aria-labelledby="classic-contact" class="space-y-3">
55
+ <h2 id="classic-contact" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Contact</h2>
56
+ <div class="grid gap-2 text-sm text-slate-700">
57
+ {data.contact.email ? <a class="underline decoration-slate-300 underline-offset-2" href={`mailto:${data.contact.email}`}>{data.contact.email}</a> : null}
58
+ {data.contact.phone ? <a class="underline decoration-slate-300 underline-offset-2" href={`tel:${data.contact.phone}`}>{data.contact.phone}</a> : null}
59
+ {data.contact.website ? (
60
+ <a class="underline decoration-slate-300 underline-offset-2" href={normalizeHttpHref(data.contact.website)}>
61
+ {data.contact.website}
62
+ </a>
63
+ ) : null}
64
+ {data.contact.github ? (
65
+ <a class="underline decoration-slate-300 underline-offset-2" href={normalizeHttpHref(data.contact.github)}>
66
+ {data.contact.github}
67
+ </a>
68
+ ) : null}
69
+ {data.contact.linkedin ? (
70
+ <a class="underline decoration-slate-300 underline-offset-2" href={normalizeHttpHref(data.contact.linkedin)}>
71
+ {data.contact.linkedin}
72
+ </a>
73
+ ) : null}
74
+ {data.contact.links.map((link) => (
75
+ <a class="underline decoration-slate-300 underline-offset-2" href={link.href}>{link.label || link.href}</a>
76
+ ))}
77
+ </div>
78
+ </section>
79
+ ) : null}
80
+ </aside>
81
+
82
+ <div class="space-y-10">
83
+ {isSectionVisible(data, "about") && hasText(data.sections.about) ? (
84
+ <section aria-labelledby="classic-about" class="space-y-3">
85
+ <h2 id="classic-about" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">About</h2>
86
+ <p class="max-w-3xl leading-7 text-slate-700">{data.sections.about}</p>
87
+ </section>
88
+ ) : null}
89
+
90
+ {isSectionVisible(data, "featuredProjects") && data.sections.featuredProjects.length > 0 ? (
91
+ <section aria-labelledby="classic-projects" class="space-y-5">
92
+ <h2 id="classic-projects" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Featured Projects</h2>
93
+ <div class="space-y-5">
94
+ {data.sections.featuredProjects.map((project) => (
95
+ <article class="rounded-xl border border-slate-200 p-5">
96
+ <div class="flex flex-wrap items-center justify-between gap-3">
97
+ <h3 class="text-lg font-semibold text-slate-900">{project.name || "Project"}</h3>
98
+ {project.link ? <a class="text-sm font-medium text-slate-700 underline" href={normalizeHttpHref(project.link)}>View</a> : null}
99
+ </div>
100
+ {project.description ? <p class="mt-2 leading-7 text-slate-700">{project.description}</p> : null}
101
+ {project.bullets.length > 0 ? (
102
+ <ul class="mt-3 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
103
+ {project.bullets.map((bullet) => (
104
+ <li>{bullet}</li>
105
+ ))}
106
+ </ul>
107
+ ) : null}
108
+ {project.tags.length > 0 ? (
109
+ <p class="mt-3 text-xs uppercase tracking-[0.12em] text-slate-500">{project.tags.join(" • ")}</p>
110
+ ) : null}
111
+ </article>
112
+ ))}
113
+ </div>
114
+ </section>
115
+ ) : null}
116
+
117
+ {isSectionVisible(data, "experience") && data.sections.experience.length > 0 ? (
118
+ <section aria-labelledby="classic-experience" class="space-y-4">
119
+ <h2 id="classic-experience" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Experience</h2>
120
+ <div class="space-y-5">
121
+ {data.sections.experience.map((item) => (
122
+ <article class="space-y-2">
123
+ <div class="flex flex-wrap items-baseline justify-between gap-2">
124
+ <h3 class="text-lg font-semibold text-slate-900">{item.role || "Role"}</h3>
125
+ {listHasContent([item.start, item.end]) ? <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p> : null}
126
+ </div>
127
+ <p class="text-sm text-slate-700">{[item.company, item.location].filter(Boolean).join(" • ")}</p>
128
+ {item.bullets.length > 0 ? (
129
+ <ul class="list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
130
+ {item.bullets.map((bullet) => (
131
+ <li>{bullet}</li>
132
+ ))}
133
+ </ul>
134
+ ) : null}
135
+ </article>
136
+ ))}
137
+ </div>
138
+ </section>
139
+ ) : null}
140
+
141
+ {isSectionVisible(data, "education") && data.sections.education.length > 0 ? (
142
+ <section aria-labelledby="classic-education" class="space-y-4">
143
+ <h2 id="classic-education" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Education</h2>
144
+ <div class="space-y-4">
145
+ {data.sections.education.map((item) => (
146
+ <article>
147
+ <h3 class="text-base font-semibold text-slate-900">{item.degree || "Degree"}</h3>
148
+ <p class="text-sm text-slate-700">{[item.field, item.institution].filter(Boolean).join(" • ")}</p>
149
+ <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
150
+ {item.grade ? <p class="text-sm text-slate-600">{item.grade}</p> : null}
151
+ </article>
152
+ ))}
153
+ </div>
154
+ </section>
155
+ ) : null}
156
+
157
+ {isSectionVisible(data, "certifications") && data.sections.certifications.length > 0 ? (
158
+ <section aria-labelledby="classic-certifications" class="space-y-3">
159
+ <h2 id="classic-certifications" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Certifications</h2>
160
+ <ul class="space-y-2 text-sm text-slate-700">
161
+ {data.sections.certifications.map((item) => (
162
+ <li>
163
+ <span class="font-semibold text-slate-900">{item.title || "Certification"}</span>
164
+ {listHasContent([item.issuer, item.year, item.note])
165
+ ? ` · ${[item.issuer, item.year, item.note].filter(Boolean).join(" · ")}`
166
+ : ""}
167
+ </li>
168
+ ))}
169
+ </ul>
170
+ </section>
171
+ ) : null}
172
+
173
+ {isSectionVisible(data, "achievements") && data.sections.achievements.length > 0 ? (
174
+ <section aria-labelledby="classic-achievements" class="space-y-3">
175
+ <h2 id="classic-achievements" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Achievements</h2>
176
+ <ul class="space-y-2 text-sm text-slate-700">
177
+ {data.sections.achievements.map((item) => (
178
+ <li>
179
+ <span class="font-semibold text-slate-900">{item.title || "Achievement"}</span>
180
+ {listHasContent([item.issuer, item.year, item.note])
181
+ ? ` · ${[item.issuer, item.year, item.note].filter(Boolean).join(" · ")}`
182
+ : ""}
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ </section>
187
+ ) : null}
188
+ </div>
189
+ </main>
190
+ </article>
@@ -0,0 +1,163 @@
1
+ ---
2
+ import type { PortfolioPublicData } from "./types";
3
+ import { hasText, isSectionVisible, normalizeHttpHref } from "./utils";
4
+
5
+ interface Props {
6
+ data: PortfolioPublicData;
7
+ }
8
+
9
+ const { data } = Astro.props as Props;
10
+ const ownerName = data.owner.fullName || data.meta?.title || "Portfolio";
11
+ ---
12
+
13
+ <article class="min-h-screen bg-white text-slate-900">
14
+ <main class="mx-auto w-full max-w-6xl px-4 py-10 lg:px-8">
15
+ <header class="mb-8 rounded-2xl border border-slate-200 bg-slate-50 p-6 sm:p-8">
16
+ <p class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Portfolio</p>
17
+ {data.owner.profilePhotoUrl ? (
18
+ <img
19
+ src={data.owner.profilePhotoUrl}
20
+ alt={`${ownerName} profile photo`}
21
+ class="mt-4 h-16 w-16 rounded-full border border-slate-200 object-cover"
22
+ />
23
+ ) : null}
24
+ <h1 class="mt-3 text-3xl font-bold tracking-tight text-slate-950 sm:text-4xl">{ownerName}</h1>
25
+ {data.owner.headline ? <p class="mt-2 text-lg text-slate-700">{data.owner.headline}</p> : null}
26
+ {hasText(data.owner.summary) ? <p class="mt-4 max-w-3xl leading-7 text-slate-700">{data.owner.summary}</p> : null}
27
+ {data.owner.location ? <p class="mt-3 text-sm text-slate-500">{data.owner.location}</p> : null}
28
+ </header>
29
+
30
+ {isSectionVisible(data, "featuredProjects") && data.sections.featuredProjects.length > 0 ? (
31
+ <section aria-labelledby="gallery-projects" class="space-y-5">
32
+ <h2 id="gallery-projects" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Featured Projects</h2>
33
+ <div class="grid gap-4 sm:grid-cols-2">
34
+ {data.sections.featuredProjects.map((project) => (
35
+ <article class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
36
+ <div class="flex items-baseline justify-between gap-3">
37
+ <h3 class="text-xl font-semibold text-slate-900">{project.name || "Project"}</h3>
38
+ {project.link ? <a class="text-sm font-medium text-slate-700 underline" href={normalizeHttpHref(project.link)}>Open</a> : null}
39
+ </div>
40
+ {project.description ? <p class="mt-3 text-sm leading-6 text-slate-700">{project.description}</p> : null}
41
+ {project.tags.length > 0 ? (
42
+ <div class="mt-3 flex flex-wrap gap-2">
43
+ {project.tags.map((tag) => (
44
+ <span class="rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{tag}</span>
45
+ ))}
46
+ </div>
47
+ ) : null}
48
+ {project.bullets.length > 0 ? (
49
+ <ul class="mt-4 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
50
+ {project.bullets.map((bullet) => (
51
+ <li>{bullet}</li>
52
+ ))}
53
+ </ul>
54
+ ) : null}
55
+ </article>
56
+ ))}
57
+ </div>
58
+ </section>
59
+ ) : null}
60
+
61
+ <div class="mt-10 grid gap-8 lg:grid-cols-3">
62
+ <div class="space-y-8 lg:col-span-2">
63
+ {isSectionVisible(data, "experience") && data.sections.experience.length > 0 ? (
64
+ <section aria-labelledby="gallery-experience" class="space-y-4">
65
+ <h2 id="gallery-experience" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Experience</h2>
66
+ <div class="space-y-4">
67
+ {data.sections.experience.map((item) => (
68
+ <article class="rounded-xl border border-slate-200 p-4">
69
+ <h3 class="text-base font-semibold text-slate-900">{item.role || "Role"}</h3>
70
+ <p class="text-sm text-slate-600">{[item.company, item.location].filter(Boolean).join(" • ")}</p>
71
+ <p class="text-xs uppercase tracking-[0.14em] text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
72
+ {item.bullets.length > 0 ? (
73
+ <ul class="mt-3 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
74
+ {item.bullets.map((bullet) => (
75
+ <li>{bullet}</li>
76
+ ))}
77
+ </ul>
78
+ ) : null}
79
+ </article>
80
+ ))}
81
+ </div>
82
+ </section>
83
+ ) : null}
84
+
85
+ {isSectionVisible(data, "about") && hasText(data.sections.about) ? (
86
+ <section aria-labelledby="gallery-about" class="rounded-xl border border-slate-200 p-5">
87
+ <h2 id="gallery-about" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">About</h2>
88
+ <p class="mt-2 leading-7 text-slate-700">{data.sections.about}</p>
89
+ </section>
90
+ ) : null}
91
+ </div>
92
+
93
+ <aside class="space-y-8">
94
+ {isSectionVisible(data, "skills") && data.sections.skills.length > 0 ? (
95
+ <section aria-labelledby="gallery-skills" class="rounded-xl border border-slate-200 p-5">
96
+ <h2 id="gallery-skills" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Skills</h2>
97
+ <div class="mt-3 space-y-3">
98
+ {data.sections.skills.map((group) => (
99
+ <div>
100
+ <h3 class="text-sm font-semibold text-slate-800">{group.name || "Skills"}</h3>
101
+ <p class="mt-1 text-sm text-slate-600">{group.items.join(", ")}</p>
102
+ </div>
103
+ ))}
104
+ </div>
105
+ </section>
106
+ ) : null}
107
+
108
+ {isSectionVisible(data, "education") && data.sections.education.length > 0 ? (
109
+ <section aria-labelledby="gallery-education" class="rounded-xl border border-slate-200 p-5">
110
+ <h2 id="gallery-education" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Education</h2>
111
+ <div class="mt-3 space-y-3 text-sm text-slate-700">
112
+ {data.sections.education.map((item) => (
113
+ <article>
114
+ <h3 class="font-semibold text-slate-900">{item.degree || "Degree"}</h3>
115
+ <p>{[item.field, item.institution].filter(Boolean).join(" • ")}</p>
116
+ <p class="text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
117
+ </article>
118
+ ))}
119
+ </div>
120
+ </section>
121
+ ) : null}
122
+
123
+ {isSectionVisible(data, "contact") ? (
124
+ <section aria-labelledby="gallery-contact" class="rounded-xl border border-slate-200 p-5">
125
+ <h2 id="gallery-contact" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Contact</h2>
126
+ {data.contact.callToActionTitle ? <p class="mt-3 font-semibold text-slate-900">{data.contact.callToActionTitle}</p> : null}
127
+ {data.contact.callToActionText ? <p class="mt-1 text-sm text-slate-700">{data.contact.callToActionText}</p> : null}
128
+ <div class="mt-3 grid gap-1 text-sm text-slate-700">
129
+ {data.contact.email ? <a class="underline" href={`mailto:${data.contact.email}`}>{data.contact.email}</a> : null}
130
+ {data.contact.phone ? <a class="underline" href={`tel:${data.contact.phone}`}>{data.contact.phone}</a> : null}
131
+ {data.contact.website ? <a class="underline" href={normalizeHttpHref(data.contact.website)}>{data.contact.website}</a> : null}
132
+ {data.contact.links.map((link) => (
133
+ <a class="underline" href={link.href}>{link.label || link.href}</a>
134
+ ))}
135
+ </div>
136
+ </section>
137
+ ) : null}
138
+
139
+ {isSectionVisible(data, "certifications") && data.sections.certifications.length > 0 ? (
140
+ <section aria-labelledby="gallery-certifications" class="rounded-xl border border-slate-200 p-5">
141
+ <h2 id="gallery-certifications" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Certifications</h2>
142
+ <ul class="mt-3 space-y-2 text-sm text-slate-700">
143
+ {data.sections.certifications.map((item) => (
144
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
145
+ ))}
146
+ </ul>
147
+ </section>
148
+ ) : null}
149
+
150
+ {isSectionVisible(data, "achievements") && data.sections.achievements.length > 0 ? (
151
+ <section aria-labelledby="gallery-achievements" class="rounded-xl border border-slate-200 p-5">
152
+ <h2 id="gallery-achievements" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Achievements</h2>
153
+ <ul class="mt-3 space-y-2 text-sm text-slate-700">
154
+ {data.sections.achievements.map((item) => (
155
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
156
+ ))}
157
+ </ul>
158
+ </section>
159
+ ) : null}
160
+ </aside>
161
+ </div>
162
+ </main>
163
+ </article>
@@ -0,0 +1,155 @@
1
+ ---
2
+ import type { PortfolioPublicData } from "./types";
3
+ import { hasText, isSectionVisible, normalizeHttpHref } from "./utils";
4
+
5
+ interface Props {
6
+ data: PortfolioPublicData;
7
+ }
8
+
9
+ const { data } = Astro.props as Props;
10
+ const ownerName = data.owner.fullName || data.meta?.title || "Portfolio";
11
+ ---
12
+
13
+ <article class="min-h-screen bg-white text-slate-900">
14
+ <main class="mx-auto w-full max-w-3xl px-6 py-12 sm:px-8">
15
+ <header class="border-b border-slate-200 pb-8">
16
+ {data.owner.profilePhotoUrl ? (
17
+ <img
18
+ src={data.owner.profilePhotoUrl}
19
+ alt={`${ownerName} profile photo`}
20
+ class="mb-4 h-16 w-16 rounded-full border border-slate-200 object-cover"
21
+ />
22
+ ) : null}
23
+ <h1 class="text-4xl font-light tracking-tight text-slate-950 sm:text-5xl">{ownerName}</h1>
24
+ {data.owner.headline ? <p class="mt-2 text-lg text-slate-700">{data.owner.headline}</p> : null}
25
+ {data.owner.location ? <p class="mt-1 text-sm uppercase tracking-[0.16em] text-slate-500">{data.owner.location}</p> : null}
26
+ {hasText(data.owner.summary) ? <p class="mt-5 max-w-2xl leading-7 text-slate-700">{data.owner.summary}</p> : null}
27
+ </header>
28
+
29
+ <div class="space-y-10 pt-8">
30
+ {isSectionVisible(data, "about") && hasText(data.sections.about) ? (
31
+ <section aria-labelledby="minimal-about" class="space-y-3">
32
+ <h2 id="minimal-about" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">About</h2>
33
+ <p class="leading-7 text-slate-700">{data.sections.about}</p>
34
+ </section>
35
+ ) : null}
36
+
37
+ {isSectionVisible(data, "featuredProjects") && data.sections.featuredProjects.length > 0 ? (
38
+ <section aria-labelledby="minimal-projects" class="space-y-5">
39
+ <h2 id="minimal-projects" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Projects</h2>
40
+ <div class="space-y-6">
41
+ {data.sections.featuredProjects.map((project) => (
42
+ <article class="space-y-2">
43
+ <div class="flex flex-wrap items-baseline justify-between gap-2">
44
+ <h3 class="text-xl font-medium text-slate-900">{project.name || "Project"}</h3>
45
+ {project.link ? <a class="text-sm text-slate-600 underline" href={normalizeHttpHref(project.link)}>Visit</a> : null}
46
+ </div>
47
+ {project.description ? <p class="leading-7 text-slate-700">{project.description}</p> : null}
48
+ {project.tags.length > 0 ? <p class="text-xs uppercase tracking-[0.16em] text-slate-500">{project.tags.join(" / ")}</p> : null}
49
+ {project.bullets.length > 0 ? (
50
+ <ul class="list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
51
+ {project.bullets.map((bullet) => (
52
+ <li>{bullet}</li>
53
+ ))}
54
+ </ul>
55
+ ) : null}
56
+ </article>
57
+ ))}
58
+ </div>
59
+ </section>
60
+ ) : null}
61
+
62
+ {isSectionVisible(data, "experience") && data.sections.experience.length > 0 ? (
63
+ <section aria-labelledby="minimal-experience" class="space-y-5">
64
+ <h2 id="minimal-experience" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Experience</h2>
65
+ <div class="space-y-6">
66
+ {data.sections.experience.map((item) => (
67
+ <article class="space-y-2">
68
+ <h3 class="text-lg font-medium text-slate-900">{item.role || "Role"}</h3>
69
+ <p class="text-sm text-slate-600">{[item.company, item.location].filter(Boolean).join(" • ")}</p>
70
+ <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
71
+ {item.bullets.length > 0 ? (
72
+ <ul class="list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
73
+ {item.bullets.map((bullet) => (
74
+ <li>{bullet}</li>
75
+ ))}
76
+ </ul>
77
+ ) : null}
78
+ </article>
79
+ ))}
80
+ </div>
81
+ </section>
82
+ ) : null}
83
+
84
+ {isSectionVisible(data, "skills") && data.sections.skills.length > 0 ? (
85
+ <section aria-labelledby="minimal-skills" class="space-y-4">
86
+ <h2 id="minimal-skills" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Skills</h2>
87
+ <div class="space-y-4">
88
+ {data.sections.skills.map((group) => (
89
+ <div>
90
+ <h3 class="text-sm font-semibold text-slate-800">{group.name || "Skills"}</h3>
91
+ <p class="mt-1 text-sm text-slate-600">{group.items.join(", ")}</p>
92
+ </div>
93
+ ))}
94
+ </div>
95
+ </section>
96
+ ) : null}
97
+
98
+ {isSectionVisible(data, "education") && data.sections.education.length > 0 ? (
99
+ <section aria-labelledby="minimal-education" class="space-y-4">
100
+ <h2 id="minimal-education" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Education</h2>
101
+ <div class="space-y-4">
102
+ {data.sections.education.map((item) => (
103
+ <article>
104
+ <h3 class="text-base font-medium text-slate-900">{item.degree || "Degree"}</h3>
105
+ <p class="text-sm text-slate-700">{[item.field, item.institution].filter(Boolean).join(" • ")}</p>
106
+ <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
107
+ {item.grade ? <p class="text-sm text-slate-600">{item.grade}</p> : null}
108
+ </article>
109
+ ))}
110
+ </div>
111
+ </section>
112
+ ) : null}
113
+
114
+ {isSectionVisible(data, "certifications") && data.sections.certifications.length > 0 ? (
115
+ <section aria-labelledby="minimal-certifications" class="space-y-3">
116
+ <h2 id="minimal-certifications" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Certifications</h2>
117
+ <ul class="space-y-1 text-sm text-slate-700">
118
+ {data.sections.certifications.map((item) => (
119
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
120
+ ))}
121
+ </ul>
122
+ </section>
123
+ ) : null}
124
+
125
+ {isSectionVisible(data, "achievements") && data.sections.achievements.length > 0 ? (
126
+ <section aria-labelledby="minimal-achievements" class="space-y-3">
127
+ <h2 id="minimal-achievements" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Achievements</h2>
128
+ <ul class="space-y-1 text-sm text-slate-700">
129
+ {data.sections.achievements.map((item) => (
130
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
131
+ ))}
132
+ </ul>
133
+ </section>
134
+ ) : null}
135
+
136
+ {isSectionVisible(data, "contact") ? (
137
+ <section aria-labelledby="minimal-contact" class="border-t border-slate-200 pt-8">
138
+ <h2 id="minimal-contact" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Contact</h2>
139
+ {data.contact.callToActionTitle ? <p class="mt-3 text-base font-medium text-slate-900">{data.contact.callToActionTitle}</p> : null}
140
+ {data.contact.callToActionText ? <p class="mt-2 text-sm text-slate-700">{data.contact.callToActionText}</p> : null}
141
+ <div class="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm text-slate-700">
142
+ {data.contact.email ? <a class="underline" href={`mailto:${data.contact.email}`}>{data.contact.email}</a> : null}
143
+ {data.contact.phone ? <a class="underline" href={`tel:${data.contact.phone}`}>{data.contact.phone}</a> : null}
144
+ {data.contact.website ? <a class="underline" href={normalizeHttpHref(data.contact.website)}>{data.contact.website}</a> : null}
145
+ {data.contact.github ? <a class="underline" href={normalizeHttpHref(data.contact.github)}>GitHub</a> : null}
146
+ {data.contact.linkedin ? <a class="underline" href={normalizeHttpHref(data.contact.linkedin)}>LinkedIn</a> : null}
147
+ {data.contact.links.map((link) => (
148
+ <a class="underline" href={link.href}>{link.label || link.href}</a>
149
+ ))}
150
+ </div>
151
+ </section>
152
+ ) : null}
153
+ </div>
154
+ </main>
155
+ </article>
@@ -0,0 +1,165 @@
1
+ ---
2
+ import type { PortfolioPublicData } from "./types";
3
+ import { hasText, isSectionVisible, normalizeHttpHref } from "./utils";
4
+
5
+ interface Props {
6
+ data: PortfolioPublicData;
7
+ }
8
+
9
+ const { data } = Astro.props as Props;
10
+ const ownerName = data.owner.fullName || data.meta?.title || "Portfolio";
11
+ ---
12
+
13
+ <article class="min-h-screen bg-white text-slate-900">
14
+ <main class="mx-auto w-full max-w-5xl px-5 py-12 lg:px-10">
15
+ <header class="mx-auto max-w-3xl space-y-4 text-center">
16
+ <p class="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">Story Portfolio</p>
17
+ {data.owner.profilePhotoUrl ? (
18
+ <img
19
+ src={data.owner.profilePhotoUrl}
20
+ alt={`${ownerName} profile photo`}
21
+ class="mx-auto h-20 w-20 rounded-full border border-slate-200 object-cover"
22
+ />
23
+ ) : null}
24
+ <h1 class="text-4xl font-bold tracking-tight text-slate-950 sm:text-5xl">{ownerName}</h1>
25
+ {data.owner.headline ? <p class="text-lg text-slate-700">{data.owner.headline}</p> : null}
26
+ {hasText(data.owner.summary) ? <p class="leading-7 text-slate-700">{data.owner.summary}</p> : null}
27
+ {data.owner.location ? <p class="text-sm text-slate-500">{data.owner.location}</p> : null}
28
+ </header>
29
+
30
+ <div class="mt-12 grid gap-10 lg:grid-cols-[1fr_270px]">
31
+ <div class="relative border-l border-slate-200 pl-6 sm:pl-8">
32
+ {isSectionVisible(data, "about") && hasText(data.sections.about) ? (
33
+ <section aria-labelledby="story-about" class="relative mb-10">
34
+ <span class="absolute -left-[2.05rem] top-1 h-3 w-3 rounded-full bg-slate-400"></span>
35
+ <h2 id="story-about" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">About</h2>
36
+ <p class="mt-3 leading-7 text-slate-700">{data.sections.about}</p>
37
+ </section>
38
+ ) : null}
39
+
40
+ {isSectionVisible(data, "experience") && data.sections.experience.length > 0 ? (
41
+ <section aria-labelledby="story-experience" class="relative mb-10">
42
+ <span class="absolute -left-[2.05rem] top-1 h-3 w-3 rounded-full bg-slate-400"></span>
43
+ <h2 id="story-experience" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Experience Timeline</h2>
44
+ <div class="mt-4 space-y-6">
45
+ {data.sections.experience.map((item) => (
46
+ <article class="rounded-xl border border-slate-200 p-4">
47
+ <p class="text-xs uppercase tracking-[0.14em] text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
48
+ <h3 class="mt-1 text-lg font-semibold text-slate-900">{item.role || "Role"}</h3>
49
+ <p class="text-sm text-slate-700">{[item.company, item.location].filter(Boolean).join(" • ")}</p>
50
+ {item.bullets.length > 0 ? (
51
+ <ul class="mt-3 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
52
+ {item.bullets.map((bullet) => (
53
+ <li>{bullet}</li>
54
+ ))}
55
+ </ul>
56
+ ) : null}
57
+ </article>
58
+ ))}
59
+ </div>
60
+ </section>
61
+ ) : null}
62
+
63
+ {isSectionVisible(data, "featuredProjects") && data.sections.featuredProjects.length > 0 ? (
64
+ <section aria-labelledby="story-projects" class="relative mb-10">
65
+ <span class="absolute -left-[2.05rem] top-1 h-3 w-3 rounded-full bg-slate-400"></span>
66
+ <h2 id="story-projects" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Project Chapters</h2>
67
+ <div class="mt-4 space-y-6">
68
+ {data.sections.featuredProjects.map((project, index) => (
69
+ <article>
70
+ <p class="text-xs uppercase tracking-[0.14em] text-slate-500">Chapter {index + 1}</p>
71
+ <div class="mt-1 flex flex-wrap items-baseline justify-between gap-2">
72
+ <h3 class="text-xl font-semibold text-slate-900">{project.name || "Project"}</h3>
73
+ {project.link ? <a class="text-sm text-slate-700 underline" href={normalizeHttpHref(project.link)}>Read</a> : null}
74
+ </div>
75
+ {project.description ? <p class="mt-2 leading-7 text-slate-700">{project.description}</p> : null}
76
+ {project.bullets.length > 0 ? (
77
+ <ul class="mt-2 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
78
+ {project.bullets.map((bullet) => (
79
+ <li>{bullet}</li>
80
+ ))}
81
+ </ul>
82
+ ) : null}
83
+ {project.tags.length > 0 ? <p class="mt-2 text-xs text-slate-500">{project.tags.join(" • ")}</p> : null}
84
+ </article>
85
+ ))}
86
+ </div>
87
+ </section>
88
+ ) : null}
89
+
90
+ {isSectionVisible(data, "education") && data.sections.education.length > 0 ? (
91
+ <section aria-labelledby="story-education" class="relative mb-10">
92
+ <span class="absolute -left-[2.05rem] top-1 h-3 w-3 rounded-full bg-slate-400"></span>
93
+ <h2 id="story-education" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Education</h2>
94
+ <div class="mt-4 space-y-3">
95
+ {data.sections.education.map((item) => (
96
+ <article>
97
+ <h3 class="font-semibold text-slate-900">{item.degree || "Degree"}</h3>
98
+ <p class="text-sm text-slate-700">{[item.field, item.institution].filter(Boolean).join(" • ")}</p>
99
+ <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
100
+ {item.grade ? <p class="text-sm text-slate-600">{item.grade}</p> : null}
101
+ </article>
102
+ ))}
103
+ </div>
104
+ </section>
105
+ ) : null}
106
+ </div>
107
+
108
+ <aside class="space-y-8">
109
+ {isSectionVisible(data, "skills") && data.sections.skills.length > 0 ? (
110
+ <section aria-labelledby="story-skills" class="rounded-xl bg-slate-50 p-5">
111
+ <h2 id="story-skills" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Skills</h2>
112
+ <div class="mt-3 space-y-3">
113
+ {data.sections.skills.map((group) => (
114
+ <div>
115
+ <h3 class="text-sm font-semibold text-slate-900">{group.name || "Skills"}</h3>
116
+ <p class="text-sm text-slate-600">{group.items.join(", ")}</p>
117
+ </div>
118
+ ))}
119
+ </div>
120
+ </section>
121
+ ) : null}
122
+
123
+ {isSectionVisible(data, "certifications") && data.sections.certifications.length > 0 ? (
124
+ <section aria-labelledby="story-certifications" class="rounded-xl bg-slate-50 p-5">
125
+ <h2 id="story-certifications" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Certifications</h2>
126
+ <ul class="mt-3 space-y-2 text-sm text-slate-700">
127
+ {data.sections.certifications.map((item) => (
128
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
129
+ ))}
130
+ </ul>
131
+ </section>
132
+ ) : null}
133
+
134
+ {isSectionVisible(data, "achievements") && data.sections.achievements.length > 0 ? (
135
+ <section aria-labelledby="story-achievements" class="rounded-xl bg-slate-50 p-5">
136
+ <h2 id="story-achievements" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Achievements</h2>
137
+ <ul class="mt-3 space-y-2 text-sm text-slate-700">
138
+ {data.sections.achievements.map((item) => (
139
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
140
+ ))}
141
+ </ul>
142
+ </section>
143
+ ) : null}
144
+
145
+ {isSectionVisible(data, "contact") ? (
146
+ <section aria-labelledby="story-contact" class="rounded-xl border border-slate-200 p-5">
147
+ <h2 id="story-contact" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Contact</h2>
148
+ {data.contact.callToActionTitle ? <p class="mt-3 font-semibold text-slate-900">{data.contact.callToActionTitle}</p> : null}
149
+ {data.contact.callToActionText ? <p class="mt-1 text-sm text-slate-700">{data.contact.callToActionText}</p> : null}
150
+ <div class="mt-3 grid gap-1 text-sm text-slate-700">
151
+ {data.contact.email ? <a class="underline" href={`mailto:${data.contact.email}`}>{data.contact.email}</a> : null}
152
+ {data.contact.phone ? <a class="underline" href={`tel:${data.contact.phone}`}>{data.contact.phone}</a> : null}
153
+ {data.contact.website ? <a class="underline" href={normalizeHttpHref(data.contact.website)}>{data.contact.website}</a> : null}
154
+ {data.contact.github ? <a class="underline" href={normalizeHttpHref(data.contact.github)}>GitHub</a> : null}
155
+ {data.contact.linkedin ? <a class="underline" href={normalizeHttpHref(data.contact.linkedin)}>LinkedIn</a> : null}
156
+ {data.contact.links.map((link) => (
157
+ <a class="underline" href={link.href}>{link.label || link.href}</a>
158
+ ))}
159
+ </div>
160
+ </section>
161
+ ) : null}
162
+ </aside>
163
+ </div>
164
+ </main>
165
+ </article>
@@ -0,0 +1,40 @@
1
+ import PortfolioPublicTemplateClassic from "./Classic.astro";
2
+ import PortfolioPublicTemplateGallery from "./Gallery.astro";
3
+ import PortfolioPublicTemplateMinimal from "./Minimal.astro";
4
+ import PortfolioPublicTemplateStory from "./Story.astro";
5
+
6
+ import type { PortfolioPublicTemplateKey } from "./types";
7
+
8
+ export type {
9
+ PortfolioPublicContact,
10
+ PortfolioPublicCredential,
11
+ PortfolioPublicData,
12
+ PortfolioPublicEducation,
13
+ PortfolioPublicExperience,
14
+ PortfolioPublicLink,
15
+ PortfolioPublicMeta,
16
+ PortfolioPublicOwner,
17
+ PortfolioPublicProject,
18
+ PortfolioPublicSectionKey,
19
+ PortfolioPublicSections,
20
+ PortfolioPublicSkillGroup,
21
+ PortfolioPublicTemplateKey,
22
+ } from "./types";
23
+
24
+ export {
25
+ PortfolioPublicTemplateClassic,
26
+ PortfolioPublicTemplateGallery,
27
+ PortfolioPublicTemplateMinimal,
28
+ PortfolioPublicTemplateStory,
29
+ };
30
+
31
+ export const portfolioPublicTemplates = {
32
+ classic: PortfolioPublicTemplateClassic,
33
+ gallery: PortfolioPublicTemplateGallery,
34
+ minimal: PortfolioPublicTemplateMinimal,
35
+ story: PortfolioPublicTemplateStory,
36
+ } satisfies Record<PortfolioPublicTemplateKey, typeof PortfolioPublicTemplateClassic>;
37
+
38
+ export const resolvePortfolioPublicTemplate = (templateKey?: string) =>
39
+ portfolioPublicTemplates[(templateKey as PortfolioPublicTemplateKey) || "classic"] ??
40
+ PortfolioPublicTemplateClassic;
@@ -0,0 +1,102 @@
1
+ export type PortfolioPublicTemplateKey = "classic" | "gallery" | "minimal" | "story";
2
+
3
+ export type PortfolioPublicSectionKey =
4
+ | "about"
5
+ | "featuredProjects"
6
+ | "experience"
7
+ | "skills"
8
+ | "education"
9
+ | "certifications"
10
+ | "achievements"
11
+ | "contact";
12
+
13
+ export type PortfolioPublicLink = {
14
+ label: string;
15
+ href: string;
16
+ };
17
+
18
+ export type PortfolioPublicOwner = {
19
+ fullName: string;
20
+ headline: string;
21
+ summary: string;
22
+ location: string;
23
+ profilePhotoUrl: string;
24
+ };
25
+
26
+ export type PortfolioPublicContact = {
27
+ email: string;
28
+ phone: string;
29
+ website: string;
30
+ github: string;
31
+ linkedin: string;
32
+ callToActionTitle: string;
33
+ callToActionText: string;
34
+ links: PortfolioPublicLink[];
35
+ };
36
+
37
+ export type PortfolioPublicProject = {
38
+ name: string;
39
+ description: string;
40
+ link: string;
41
+ bullets: string[];
42
+ tags: string[];
43
+ };
44
+
45
+ export type PortfolioPublicExperience = {
46
+ role: string;
47
+ company: string;
48
+ location: string;
49
+ start: string;
50
+ end: string;
51
+ bullets: string[];
52
+ };
53
+
54
+ export type PortfolioPublicEducation = {
55
+ degree: string;
56
+ field: string;
57
+ institution: string;
58
+ start: string;
59
+ end: string;
60
+ grade: string;
61
+ };
62
+
63
+ export type PortfolioPublicCredential = {
64
+ title: string;
65
+ issuer: string;
66
+ year: string;
67
+ note: string;
68
+ };
69
+
70
+ export type PortfolioPublicSkillGroup = {
71
+ name: string;
72
+ items: string[];
73
+ };
74
+
75
+ export type PortfolioPublicSections = {
76
+ about: string;
77
+ featuredProjects: PortfolioPublicProject[];
78
+ experience: PortfolioPublicExperience[];
79
+ skills: PortfolioPublicSkillGroup[];
80
+ education: PortfolioPublicEducation[];
81
+ certifications: PortfolioPublicCredential[];
82
+ achievements: PortfolioPublicCredential[];
83
+ };
84
+
85
+ export type PortfolioPublicMeta = {
86
+ title?: string;
87
+ slug?: string;
88
+ visibility?: "public" | "unlisted" | "private";
89
+ publishedAt?: string | null;
90
+ updatedAt?: string | null;
91
+ lastUpdatedLabel?: string;
92
+ };
93
+
94
+ export type PortfolioPublicData = {
95
+ version: "1.0";
96
+ templateKey: PortfolioPublicTemplateKey;
97
+ owner: PortfolioPublicOwner;
98
+ contact: PortfolioPublicContact;
99
+ sections: PortfolioPublicSections;
100
+ visibleSections: PortfolioPublicSectionKey[];
101
+ meta?: PortfolioPublicMeta;
102
+ };
@@ -0,0 +1,18 @@
1
+ import type { PortfolioPublicData, PortfolioPublicSectionKey } from "./types";
2
+
3
+ const clean = (value?: string | null) => (value ?? "").trim();
4
+
5
+ export const hasText = (value?: string | null) => clean(value).length > 0;
6
+
7
+ export const listHasContent = (values: Array<string | null | undefined>) =>
8
+ values.some((value) => hasText(value));
9
+
10
+ export const normalizeHttpHref = (value?: string | null) => {
11
+ const raw = clean(value);
12
+ if (!raw) return "";
13
+ if (/^(mailto:|tel:|https?:\/\/)/i.test(raw)) return raw;
14
+ return `https://${raw.replace(/^\/+/, "")}`;
15
+ };
16
+
17
+ export const isSectionVisible = (data: PortfolioPublicData, key: PortfolioPublicSectionKey) =>
18
+ data.visibleSections.includes(key);