@ansiversa/components 0.0.124 → 0.0.126

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
@@ -45,6 +45,19 @@ export { default as ResumeTemplateExecutiveTimeline } from './src/resume-templat
45
45
  export type { ResumeData, ResumeTemplateType } from './src/resume-templates/typescript-schema';
46
46
  export { formatDateRange } from './src/resume-templates/typescript-schema';
47
47
  export { resumeData } from './src/resume-templates/resumeData';
48
+ export {
49
+ PortfolioPublicTemplateClassic,
50
+ PortfolioPublicTemplateGallery,
51
+ PortfolioPublicTemplateMinimal,
52
+ PortfolioPublicTemplateStory,
53
+ portfolioPublicTemplates,
54
+ resolvePortfolioPublicTemplate,
55
+ } from "./src/templates/portfolio-public";
56
+ export type {
57
+ PortfolioPublicData,
58
+ PortfolioPublicTemplateKey,
59
+ PortfolioPublicSectionKey,
60
+ } from "./src/templates/portfolio-public";
48
61
 
49
62
  export * from "./src/alpine";
50
63
  export * from "./src/Summary/types";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansiversa/components",
3
- "version": "0.0.124",
3
+ "version": "0.0.126",
4
4
  "description": "Shared UI components and layouts for the Ansiversa ecosystem",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 />
@@ -93,7 +93,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
93
93
  )}
94
94
 
95
95
  {projects.length > 0 && (
96
- <div class="mt-10">
96
+ <div class="mt-8">
97
97
  <h2 class="text-sm font-bold tracking-widest text-slate-900">PROJECTS</h2>
98
98
  <div class="mt-5 space-y-4">
99
99
  {projects.map((project) => (
@@ -14,6 +14,8 @@ const locationLabel =
14
14
  [basics.location?.city, basics.location?.country].filter(Boolean).join(", ");
15
15
 
16
16
  const contactLinks = buildContactEntries(basics, locationLabel);
17
+ const primaryContacts = contactLinks.filter((item) => item.kind === "email" || item.kind === "phone");
18
+ const secondaryContacts = contactLinks.filter((item) => item.kind !== "email" && item.kind !== "phone");
17
19
 
18
20
  const headlineLine = [basics.headline, locationLabel].filter(Boolean).join(" · ");
19
21
  const experienceItems = data.experience ?? [];
@@ -28,24 +30,37 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
28
30
  <div class="resume-template av-print-white bg-slate-100 text-slate-900 print:bg-white">
29
31
  <main class="mx-auto max-w-4xl p-4 sm:p-6">
30
32
  <section class="rounded-2xl bg-white p-8 shadow-sm ring-1 ring-slate-200 sm:p-12 print:shadow-none print:ring-0">
31
- <header class="space-y-4">
32
- <div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
33
+ <header class="space-y-3">
34
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
33
35
  <div>
34
- <h1 class="text-4xl font-semibold tracking-tight">{basics.fullName}</h1>
36
+ <h1 class="text-4xl font-bold tracking-tight text-slate-950">{basics.fullName}</h1>
35
37
  {headlineLine && <p class="mt-2 text-sm text-slate-600">{headlineLine}</p>}
36
38
  </div>
37
39
  {contactLinks.length > 0 && (
38
40
  <div class="text-sm text-slate-700 sm:max-w-[18rem] sm:border-l sm:border-slate-200 sm:pl-4 sm:text-right">
39
- <div class="flex flex-col gap-1">
40
- {contactLinks.map((item) => (
41
+ <div class="flex flex-col gap-1.5">
42
+ {primaryContacts.map((item) => (
41
43
  item.href ? (
42
- <a class="underline underline-offset-4 hover:text-slate-900 print:no-underline" href={item.href}>
44
+ <a class="font-semibold text-slate-900 underline underline-offset-4 hover:text-slate-900 print:no-underline" href={item.href}>
43
45
  {item.label}
44
46
  </a>
45
47
  ) : (
46
- <div>{item.label}</div>
48
+ <div class="font-semibold text-slate-900">{item.label}</div>
47
49
  )
48
50
  ))}
51
+ {secondaryContacts.length > 0 && (
52
+ <div class="pt-1 text-xs text-slate-600">
53
+ {secondaryContacts.map((item) => (
54
+ item.href ? (
55
+ <a class="underline underline-offset-4 hover:text-slate-800 print:no-underline" href={item.href}>
56
+ {item.label}
57
+ </a>
58
+ ) : (
59
+ <div>{item.label}</div>
60
+ )
61
+ ))}
62
+ </div>
63
+ )}
49
64
  </div>
50
65
  </div>
51
66
  )}
@@ -56,10 +71,10 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
56
71
  )}
57
72
  </header>
58
73
 
59
- <div class="my-10 h-px bg-slate-200"></div>
74
+ <div class="my-8 h-px bg-slate-200"></div>
60
75
 
61
- <div class="grid gap-10 lg:grid-cols-12">
62
- <div class="space-y-10 lg:col-span-8">
76
+ <div class="grid gap-8 lg:grid-cols-12">
77
+ <div class="space-y-8 lg:col-span-8">
63
78
  {experienceItems.length > 0 && (
64
79
  <section>
65
80
  <h2 class="text-xs font-semibold tracking-[0.25em] text-slate-500">EXPERIENCE</h2>
@@ -94,9 +109,9 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
94
109
  {projects.length > 0 && (
95
110
  <section>
96
111
  <h2 class="text-xs font-semibold tracking-[0.25em] text-slate-500">PROJECTS</h2>
97
- <div class="mt-6 space-y-5">
112
+ <div class="mt-5 space-y-4">
98
113
  {projects.map((project) => (
99
- <article class="rounded-xl border border-slate-200 p-5">
114
+ <article class="rounded-xl border border-slate-200 p-4">
100
115
  <div class="flex items-baseline justify-between gap-3">
101
116
  {project.link ? (
102
117
  <a class="text-base font-semibold underline underline-offset-4" href={project.link}>
@@ -132,18 +147,20 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
132
147
  )}
133
148
  </div>
134
149
 
135
- <aside class="space-y-10 lg:col-span-4">
150
+ <aside class="space-y-8 lg:col-span-4">
136
151
  {skills.length > 0 && (
137
152
  <section>
138
153
  <h2 class="text-xs font-semibold tracking-[0.25em] text-slate-500">SKILLS</h2>
139
154
  {showSkillsAs === "chips" ? (
140
- <div class="mt-5 flex flex-wrap gap-2 text-sm text-slate-700">
141
- {skills.map((skill) => (
142
- <span class="rounded-full bg-slate-100 px-3 py-1 text-xs">{skill.name}</span>
155
+ <div class="mt-4 text-sm text-slate-700 leading-6">
156
+ {skills.map((skill, index) => (
157
+ <span>
158
+ {skill.name}{index < skills.length - 1 ? " · " : ""}
159
+ </span>
143
160
  ))}
144
161
  </div>
145
162
  ) : (
146
- <div class="mt-5 space-y-3 text-sm text-slate-700">
163
+ <div class="mt-4 space-y-2 text-sm text-slate-700">
147
164
  {skills.map((skill) => (
148
165
  <div class="flex items-center justify-between">
149
166
  <span>{skill.name}</span>
@@ -194,8 +211,8 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
194
211
 
195
212
  {hasDeclaration && (
196
213
  <>
197
- <div class="my-6 h-px bg-slate-200"></div>
198
- <section class="av-print-avoid-break rounded-xl border border-slate-200 p-4 space-y-3">
214
+ <div class="my-8 h-px bg-slate-200"></div>
215
+ <section class="av-print-avoid-break space-y-3">
199
216
  <h2 class="text-xs font-semibold tracking-[0.25em] text-slate-500">DECLARATION</h2>
200
217
  {declaration.text && (
201
218
  <p class="text-sm leading-7 text-slate-700">{declaration.text}</p>
@@ -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,183 @@
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
+ <h1 class="text-3xl font-bold tracking-tight text-slate-950">{ownerName}</h1>
26
+ {data.owner.headline ? <p class="text-base text-slate-700">{data.owner.headline}</p> : null}
27
+ {data.owner.location ? <p class="text-sm text-slate-500">{data.owner.location}</p> : null}
28
+ </header>
29
+
30
+ {isSectionVisible(data, "skills") && data.sections.skills.length > 0 ? (
31
+ <section aria-labelledby="classic-skills" class="space-y-4">
32
+ <h2 id="classic-skills" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Skills</h2>
33
+ <div class="space-y-3">
34
+ {data.sections.skills.map((group) => (
35
+ <div>
36
+ <h3 class="text-sm font-semibold text-slate-800">{group.name || "Skills"}</h3>
37
+ {group.items.length > 0 ? (
38
+ <p class="mt-1 text-sm leading-6 text-slate-600">{group.items.join(", ")}</p>
39
+ ) : null}
40
+ </div>
41
+ ))}
42
+ </div>
43
+ </section>
44
+ ) : null}
45
+
46
+ {isSectionVisible(data, "contact") && hasContact ? (
47
+ <section aria-labelledby="classic-contact" class="space-y-3">
48
+ <h2 id="classic-contact" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Contact</h2>
49
+ <div class="grid gap-2 text-sm text-slate-700">
50
+ {data.contact.email ? <a class="underline decoration-slate-300 underline-offset-2" href={`mailto:${data.contact.email}`}>{data.contact.email}</a> : null}
51
+ {data.contact.phone ? <a class="underline decoration-slate-300 underline-offset-2" href={`tel:${data.contact.phone}`}>{data.contact.phone}</a> : null}
52
+ {data.contact.website ? (
53
+ <a class="underline decoration-slate-300 underline-offset-2" href={normalizeHttpHref(data.contact.website)}>
54
+ {data.contact.website}
55
+ </a>
56
+ ) : null}
57
+ {data.contact.github ? (
58
+ <a class="underline decoration-slate-300 underline-offset-2" href={normalizeHttpHref(data.contact.github)}>
59
+ {data.contact.github}
60
+ </a>
61
+ ) : null}
62
+ {data.contact.linkedin ? (
63
+ <a class="underline decoration-slate-300 underline-offset-2" href={normalizeHttpHref(data.contact.linkedin)}>
64
+ {data.contact.linkedin}
65
+ </a>
66
+ ) : null}
67
+ {data.contact.links.map((link) => (
68
+ <a class="underline decoration-slate-300 underline-offset-2" href={link.href}>{link.label || link.href}</a>
69
+ ))}
70
+ </div>
71
+ </section>
72
+ ) : null}
73
+ </aside>
74
+
75
+ <div class="space-y-10">
76
+ {isSectionVisible(data, "about") && hasText(data.sections.about) ? (
77
+ <section aria-labelledby="classic-about" class="space-y-3">
78
+ <h2 id="classic-about" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">About</h2>
79
+ <p class="max-w-3xl leading-7 text-slate-700">{data.sections.about}</p>
80
+ </section>
81
+ ) : null}
82
+
83
+ {isSectionVisible(data, "featuredProjects") && data.sections.featuredProjects.length > 0 ? (
84
+ <section aria-labelledby="classic-projects" class="space-y-5">
85
+ <h2 id="classic-projects" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Featured Projects</h2>
86
+ <div class="space-y-5">
87
+ {data.sections.featuredProjects.map((project) => (
88
+ <article class="rounded-xl border border-slate-200 p-5">
89
+ <div class="flex flex-wrap items-center justify-between gap-3">
90
+ <h3 class="text-lg font-semibold text-slate-900">{project.name || "Project"}</h3>
91
+ {project.link ? <a class="text-sm font-medium text-slate-700 underline" href={normalizeHttpHref(project.link)}>View</a> : null}
92
+ </div>
93
+ {project.description ? <p class="mt-2 leading-7 text-slate-700">{project.description}</p> : null}
94
+ {project.bullets.length > 0 ? (
95
+ <ul class="mt-3 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
96
+ {project.bullets.map((bullet) => (
97
+ <li>{bullet}</li>
98
+ ))}
99
+ </ul>
100
+ ) : null}
101
+ {project.tags.length > 0 ? (
102
+ <p class="mt-3 text-xs uppercase tracking-[0.12em] text-slate-500">{project.tags.join(" • ")}</p>
103
+ ) : null}
104
+ </article>
105
+ ))}
106
+ </div>
107
+ </section>
108
+ ) : null}
109
+
110
+ {isSectionVisible(data, "experience") && data.sections.experience.length > 0 ? (
111
+ <section aria-labelledby="classic-experience" class="space-y-4">
112
+ <h2 id="classic-experience" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Experience</h2>
113
+ <div class="space-y-5">
114
+ {data.sections.experience.map((item) => (
115
+ <article class="space-y-2">
116
+ <div class="flex flex-wrap items-baseline justify-between gap-2">
117
+ <h3 class="text-lg font-semibold text-slate-900">{item.role || "Role"}</h3>
118
+ {listHasContent([item.start, item.end]) ? <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p> : null}
119
+ </div>
120
+ <p class="text-sm text-slate-700">{[item.company, item.location].filter(Boolean).join(" • ")}</p>
121
+ {item.bullets.length > 0 ? (
122
+ <ul class="list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
123
+ {item.bullets.map((bullet) => (
124
+ <li>{bullet}</li>
125
+ ))}
126
+ </ul>
127
+ ) : null}
128
+ </article>
129
+ ))}
130
+ </div>
131
+ </section>
132
+ ) : null}
133
+
134
+ {isSectionVisible(data, "education") && data.sections.education.length > 0 ? (
135
+ <section aria-labelledby="classic-education" class="space-y-4">
136
+ <h2 id="classic-education" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Education</h2>
137
+ <div class="space-y-4">
138
+ {data.sections.education.map((item) => (
139
+ <article>
140
+ <h3 class="text-base font-semibold text-slate-900">{item.degree || "Degree"}</h3>
141
+ <p class="text-sm text-slate-700">{[item.field, item.institution].filter(Boolean).join(" • ")}</p>
142
+ <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
143
+ {item.grade ? <p class="text-sm text-slate-600">{item.grade}</p> : null}
144
+ </article>
145
+ ))}
146
+ </div>
147
+ </section>
148
+ ) : null}
149
+
150
+ {isSectionVisible(data, "certifications") && data.sections.certifications.length > 0 ? (
151
+ <section aria-labelledby="classic-certifications" class="space-y-3">
152
+ <h2 id="classic-certifications" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Certifications</h2>
153
+ <ul class="space-y-2 text-sm text-slate-700">
154
+ {data.sections.certifications.map((item) => (
155
+ <li>
156
+ <span class="font-semibold text-slate-900">{item.title || "Certification"}</span>
157
+ {listHasContent([item.issuer, item.year, item.note])
158
+ ? ` · ${[item.issuer, item.year, item.note].filter(Boolean).join(" · ")}`
159
+ : ""}
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ </section>
164
+ ) : null}
165
+
166
+ {isSectionVisible(data, "achievements") && data.sections.achievements.length > 0 ? (
167
+ <section aria-labelledby="classic-achievements" class="space-y-3">
168
+ <h2 id="classic-achievements" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Achievements</h2>
169
+ <ul class="space-y-2 text-sm text-slate-700">
170
+ {data.sections.achievements.map((item) => (
171
+ <li>
172
+ <span class="font-semibold text-slate-900">{item.title || "Achievement"}</span>
173
+ {listHasContent([item.issuer, item.year, item.note])
174
+ ? ` · ${[item.issuer, item.year, item.note].filter(Boolean).join(" · ")}`
175
+ : ""}
176
+ </li>
177
+ ))}
178
+ </ul>
179
+ </section>
180
+ ) : null}
181
+ </div>
182
+ </main>
183
+ </article>
@@ -0,0 +1,156 @@
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
+ <h1 class="mt-3 text-3xl font-bold tracking-tight text-slate-950 sm:text-4xl">{ownerName}</h1>
18
+ {data.owner.headline ? <p class="mt-2 text-lg text-slate-700">{data.owner.headline}</p> : null}
19
+ {hasText(data.owner.summary) ? <p class="mt-4 max-w-3xl leading-7 text-slate-700">{data.owner.summary}</p> : null}
20
+ {data.owner.location ? <p class="mt-3 text-sm text-slate-500">{data.owner.location}</p> : null}
21
+ </header>
22
+
23
+ {isSectionVisible(data, "featuredProjects") && data.sections.featuredProjects.length > 0 ? (
24
+ <section aria-labelledby="gallery-projects" class="space-y-5">
25
+ <h2 id="gallery-projects" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Featured Projects</h2>
26
+ <div class="grid gap-4 sm:grid-cols-2">
27
+ {data.sections.featuredProjects.map((project) => (
28
+ <article class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
29
+ <div class="flex items-baseline justify-between gap-3">
30
+ <h3 class="text-xl font-semibold text-slate-900">{project.name || "Project"}</h3>
31
+ {project.link ? <a class="text-sm font-medium text-slate-700 underline" href={normalizeHttpHref(project.link)}>Open</a> : null}
32
+ </div>
33
+ {project.description ? <p class="mt-3 text-sm leading-6 text-slate-700">{project.description}</p> : null}
34
+ {project.tags.length > 0 ? (
35
+ <div class="mt-3 flex flex-wrap gap-2">
36
+ {project.tags.map((tag) => (
37
+ <span class="rounded-full border border-slate-200 bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700">{tag}</span>
38
+ ))}
39
+ </div>
40
+ ) : null}
41
+ {project.bullets.length > 0 ? (
42
+ <ul class="mt-4 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
43
+ {project.bullets.map((bullet) => (
44
+ <li>{bullet}</li>
45
+ ))}
46
+ </ul>
47
+ ) : null}
48
+ </article>
49
+ ))}
50
+ </div>
51
+ </section>
52
+ ) : null}
53
+
54
+ <div class="mt-10 grid gap-8 lg:grid-cols-3">
55
+ <div class="space-y-8 lg:col-span-2">
56
+ {isSectionVisible(data, "experience") && data.sections.experience.length > 0 ? (
57
+ <section aria-labelledby="gallery-experience" class="space-y-4">
58
+ <h2 id="gallery-experience" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Experience</h2>
59
+ <div class="space-y-4">
60
+ {data.sections.experience.map((item) => (
61
+ <article class="rounded-xl border border-slate-200 p-4">
62
+ <h3 class="text-base font-semibold text-slate-900">{item.role || "Role"}</h3>
63
+ <p class="text-sm text-slate-600">{[item.company, item.location].filter(Boolean).join(" • ")}</p>
64
+ <p class="text-xs uppercase tracking-[0.14em] text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
65
+ {item.bullets.length > 0 ? (
66
+ <ul class="mt-3 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
67
+ {item.bullets.map((bullet) => (
68
+ <li>{bullet}</li>
69
+ ))}
70
+ </ul>
71
+ ) : null}
72
+ </article>
73
+ ))}
74
+ </div>
75
+ </section>
76
+ ) : null}
77
+
78
+ {isSectionVisible(data, "about") && hasText(data.sections.about) ? (
79
+ <section aria-labelledby="gallery-about" class="rounded-xl border border-slate-200 p-5">
80
+ <h2 id="gallery-about" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">About</h2>
81
+ <p class="mt-2 leading-7 text-slate-700">{data.sections.about}</p>
82
+ </section>
83
+ ) : null}
84
+ </div>
85
+
86
+ <aside class="space-y-8">
87
+ {isSectionVisible(data, "skills") && data.sections.skills.length > 0 ? (
88
+ <section aria-labelledby="gallery-skills" class="rounded-xl border border-slate-200 p-5">
89
+ <h2 id="gallery-skills" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Skills</h2>
90
+ <div class="mt-3 space-y-3">
91
+ {data.sections.skills.map((group) => (
92
+ <div>
93
+ <h3 class="text-sm font-semibold text-slate-800">{group.name || "Skills"}</h3>
94
+ <p class="mt-1 text-sm text-slate-600">{group.items.join(", ")}</p>
95
+ </div>
96
+ ))}
97
+ </div>
98
+ </section>
99
+ ) : null}
100
+
101
+ {isSectionVisible(data, "education") && data.sections.education.length > 0 ? (
102
+ <section aria-labelledby="gallery-education" class="rounded-xl border border-slate-200 p-5">
103
+ <h2 id="gallery-education" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Education</h2>
104
+ <div class="mt-3 space-y-3 text-sm text-slate-700">
105
+ {data.sections.education.map((item) => (
106
+ <article>
107
+ <h3 class="font-semibold text-slate-900">{item.degree || "Degree"}</h3>
108
+ <p>{[item.field, item.institution].filter(Boolean).join(" • ")}</p>
109
+ <p class="text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
110
+ </article>
111
+ ))}
112
+ </div>
113
+ </section>
114
+ ) : null}
115
+
116
+ {isSectionVisible(data, "contact") ? (
117
+ <section aria-labelledby="gallery-contact" class="rounded-xl border border-slate-200 p-5">
118
+ <h2 id="gallery-contact" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Contact</h2>
119
+ {data.contact.callToActionTitle ? <p class="mt-3 font-semibold text-slate-900">{data.contact.callToActionTitle}</p> : null}
120
+ {data.contact.callToActionText ? <p class="mt-1 text-sm text-slate-700">{data.contact.callToActionText}</p> : null}
121
+ <div class="mt-3 grid gap-1 text-sm text-slate-700">
122
+ {data.contact.email ? <a class="underline" href={`mailto:${data.contact.email}`}>{data.contact.email}</a> : null}
123
+ {data.contact.phone ? <a class="underline" href={`tel:${data.contact.phone}`}>{data.contact.phone}</a> : null}
124
+ {data.contact.website ? <a class="underline" href={normalizeHttpHref(data.contact.website)}>{data.contact.website}</a> : null}
125
+ {data.contact.links.map((link) => (
126
+ <a class="underline" href={link.href}>{link.label || link.href}</a>
127
+ ))}
128
+ </div>
129
+ </section>
130
+ ) : null}
131
+
132
+ {isSectionVisible(data, "certifications") && data.sections.certifications.length > 0 ? (
133
+ <section aria-labelledby="gallery-certifications" class="rounded-xl border border-slate-200 p-5">
134
+ <h2 id="gallery-certifications" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Certifications</h2>
135
+ <ul class="mt-3 space-y-2 text-sm text-slate-700">
136
+ {data.sections.certifications.map((item) => (
137
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
138
+ ))}
139
+ </ul>
140
+ </section>
141
+ ) : null}
142
+
143
+ {isSectionVisible(data, "achievements") && data.sections.achievements.length > 0 ? (
144
+ <section aria-labelledby="gallery-achievements" class="rounded-xl border border-slate-200 p-5">
145
+ <h2 id="gallery-achievements" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Achievements</h2>
146
+ <ul class="mt-3 space-y-2 text-sm text-slate-700">
147
+ {data.sections.achievements.map((item) => (
148
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
149
+ ))}
150
+ </ul>
151
+ </section>
152
+ ) : null}
153
+ </aside>
154
+ </div>
155
+ </main>
156
+ </article>
@@ -0,0 +1,148 @@
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
+ <h1 class="text-4xl font-light tracking-tight text-slate-950 sm:text-5xl">{ownerName}</h1>
17
+ {data.owner.headline ? <p class="mt-2 text-lg text-slate-700">{data.owner.headline}</p> : null}
18
+ {data.owner.location ? <p class="mt-1 text-sm uppercase tracking-[0.16em] text-slate-500">{data.owner.location}</p> : null}
19
+ {hasText(data.owner.summary) ? <p class="mt-5 max-w-2xl leading-7 text-slate-700">{data.owner.summary}</p> : null}
20
+ </header>
21
+
22
+ <div class="space-y-10 pt-8">
23
+ {isSectionVisible(data, "about") && hasText(data.sections.about) ? (
24
+ <section aria-labelledby="minimal-about" class="space-y-3">
25
+ <h2 id="minimal-about" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">About</h2>
26
+ <p class="leading-7 text-slate-700">{data.sections.about}</p>
27
+ </section>
28
+ ) : null}
29
+
30
+ {isSectionVisible(data, "featuredProjects") && data.sections.featuredProjects.length > 0 ? (
31
+ <section aria-labelledby="minimal-projects" class="space-y-5">
32
+ <h2 id="minimal-projects" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Projects</h2>
33
+ <div class="space-y-6">
34
+ {data.sections.featuredProjects.map((project) => (
35
+ <article class="space-y-2">
36
+ <div class="flex flex-wrap items-baseline justify-between gap-2">
37
+ <h3 class="text-xl font-medium text-slate-900">{project.name || "Project"}</h3>
38
+ {project.link ? <a class="text-sm text-slate-600 underline" href={normalizeHttpHref(project.link)}>Visit</a> : null}
39
+ </div>
40
+ {project.description ? <p class="leading-7 text-slate-700">{project.description}</p> : null}
41
+ {project.tags.length > 0 ? <p class="text-xs uppercase tracking-[0.16em] text-slate-500">{project.tags.join(" / ")}</p> : null}
42
+ {project.bullets.length > 0 ? (
43
+ <ul class="list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
44
+ {project.bullets.map((bullet) => (
45
+ <li>{bullet}</li>
46
+ ))}
47
+ </ul>
48
+ ) : null}
49
+ </article>
50
+ ))}
51
+ </div>
52
+ </section>
53
+ ) : null}
54
+
55
+ {isSectionVisible(data, "experience") && data.sections.experience.length > 0 ? (
56
+ <section aria-labelledby="minimal-experience" class="space-y-5">
57
+ <h2 id="minimal-experience" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Experience</h2>
58
+ <div class="space-y-6">
59
+ {data.sections.experience.map((item) => (
60
+ <article class="space-y-2">
61
+ <h3 class="text-lg font-medium text-slate-900">{item.role || "Role"}</h3>
62
+ <p class="text-sm text-slate-600">{[item.company, item.location].filter(Boolean).join(" • ")}</p>
63
+ <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
64
+ {item.bullets.length > 0 ? (
65
+ <ul class="list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
66
+ {item.bullets.map((bullet) => (
67
+ <li>{bullet}</li>
68
+ ))}
69
+ </ul>
70
+ ) : null}
71
+ </article>
72
+ ))}
73
+ </div>
74
+ </section>
75
+ ) : null}
76
+
77
+ {isSectionVisible(data, "skills") && data.sections.skills.length > 0 ? (
78
+ <section aria-labelledby="minimal-skills" class="space-y-4">
79
+ <h2 id="minimal-skills" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Skills</h2>
80
+ <div class="space-y-4">
81
+ {data.sections.skills.map((group) => (
82
+ <div>
83
+ <h3 class="text-sm font-semibold text-slate-800">{group.name || "Skills"}</h3>
84
+ <p class="mt-1 text-sm text-slate-600">{group.items.join(", ")}</p>
85
+ </div>
86
+ ))}
87
+ </div>
88
+ </section>
89
+ ) : null}
90
+
91
+ {isSectionVisible(data, "education") && data.sections.education.length > 0 ? (
92
+ <section aria-labelledby="minimal-education" class="space-y-4">
93
+ <h2 id="minimal-education" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Education</h2>
94
+ <div class="space-y-4">
95
+ {data.sections.education.map((item) => (
96
+ <article>
97
+ <h3 class="text-base font-medium 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
+
107
+ {isSectionVisible(data, "certifications") && data.sections.certifications.length > 0 ? (
108
+ <section aria-labelledby="minimal-certifications" class="space-y-3">
109
+ <h2 id="minimal-certifications" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Certifications</h2>
110
+ <ul class="space-y-1 text-sm text-slate-700">
111
+ {data.sections.certifications.map((item) => (
112
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
113
+ ))}
114
+ </ul>
115
+ </section>
116
+ ) : null}
117
+
118
+ {isSectionVisible(data, "achievements") && data.sections.achievements.length > 0 ? (
119
+ <section aria-labelledby="minimal-achievements" class="space-y-3">
120
+ <h2 id="minimal-achievements" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Achievements</h2>
121
+ <ul class="space-y-1 text-sm text-slate-700">
122
+ {data.sections.achievements.map((item) => (
123
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
124
+ ))}
125
+ </ul>
126
+ </section>
127
+ ) : null}
128
+
129
+ {isSectionVisible(data, "contact") ? (
130
+ <section aria-labelledby="minimal-contact" class="border-t border-slate-200 pt-8">
131
+ <h2 id="minimal-contact" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Contact</h2>
132
+ {data.contact.callToActionTitle ? <p class="mt-3 text-base font-medium text-slate-900">{data.contact.callToActionTitle}</p> : null}
133
+ {data.contact.callToActionText ? <p class="mt-2 text-sm text-slate-700">{data.contact.callToActionText}</p> : null}
134
+ <div class="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm text-slate-700">
135
+ {data.contact.email ? <a class="underline" href={`mailto:${data.contact.email}`}>{data.contact.email}</a> : null}
136
+ {data.contact.phone ? <a class="underline" href={`tel:${data.contact.phone}`}>{data.contact.phone}</a> : null}
137
+ {data.contact.website ? <a class="underline" href={normalizeHttpHref(data.contact.website)}>{data.contact.website}</a> : null}
138
+ {data.contact.github ? <a class="underline" href={normalizeHttpHref(data.contact.github)}>GitHub</a> : null}
139
+ {data.contact.linkedin ? <a class="underline" href={normalizeHttpHref(data.contact.linkedin)}>LinkedIn</a> : null}
140
+ {data.contact.links.map((link) => (
141
+ <a class="underline" href={link.href}>{link.label || link.href}</a>
142
+ ))}
143
+ </div>
144
+ </section>
145
+ ) : null}
146
+ </div>
147
+ </main>
148
+ </article>
@@ -0,0 +1,158 @@
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
+ <h1 class="text-4xl font-bold tracking-tight text-slate-950 sm:text-5xl">{ownerName}</h1>
18
+ {data.owner.headline ? <p class="text-lg text-slate-700">{data.owner.headline}</p> : null}
19
+ {hasText(data.owner.summary) ? <p class="leading-7 text-slate-700">{data.owner.summary}</p> : null}
20
+ {data.owner.location ? <p class="text-sm text-slate-500">{data.owner.location}</p> : null}
21
+ </header>
22
+
23
+ <div class="mt-12 grid gap-10 lg:grid-cols-[1fr_270px]">
24
+ <div class="relative border-l border-slate-200 pl-6 sm:pl-8">
25
+ {isSectionVisible(data, "about") && hasText(data.sections.about) ? (
26
+ <section aria-labelledby="story-about" class="relative mb-10">
27
+ <span class="absolute -left-[2.05rem] top-1 h-3 w-3 rounded-full bg-slate-400"></span>
28
+ <h2 id="story-about" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">About</h2>
29
+ <p class="mt-3 leading-7 text-slate-700">{data.sections.about}</p>
30
+ </section>
31
+ ) : null}
32
+
33
+ {isSectionVisible(data, "experience") && data.sections.experience.length > 0 ? (
34
+ <section aria-labelledby="story-experience" class="relative mb-10">
35
+ <span class="absolute -left-[2.05rem] top-1 h-3 w-3 rounded-full bg-slate-400"></span>
36
+ <h2 id="story-experience" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Experience Timeline</h2>
37
+ <div class="mt-4 space-y-6">
38
+ {data.sections.experience.map((item) => (
39
+ <article class="rounded-xl border border-slate-200 p-4">
40
+ <p class="text-xs uppercase tracking-[0.14em] text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
41
+ <h3 class="mt-1 text-lg font-semibold text-slate-900">{item.role || "Role"}</h3>
42
+ <p class="text-sm text-slate-700">{[item.company, item.location].filter(Boolean).join(" • ")}</p>
43
+ {item.bullets.length > 0 ? (
44
+ <ul class="mt-3 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
45
+ {item.bullets.map((bullet) => (
46
+ <li>{bullet}</li>
47
+ ))}
48
+ </ul>
49
+ ) : null}
50
+ </article>
51
+ ))}
52
+ </div>
53
+ </section>
54
+ ) : null}
55
+
56
+ {isSectionVisible(data, "featuredProjects") && data.sections.featuredProjects.length > 0 ? (
57
+ <section aria-labelledby="story-projects" class="relative mb-10">
58
+ <span class="absolute -left-[2.05rem] top-1 h-3 w-3 rounded-full bg-slate-400"></span>
59
+ <h2 id="story-projects" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Project Chapters</h2>
60
+ <div class="mt-4 space-y-6">
61
+ {data.sections.featuredProjects.map((project, index) => (
62
+ <article>
63
+ <p class="text-xs uppercase tracking-[0.14em] text-slate-500">Chapter {index + 1}</p>
64
+ <div class="mt-1 flex flex-wrap items-baseline justify-between gap-2">
65
+ <h3 class="text-xl font-semibold text-slate-900">{project.name || "Project"}</h3>
66
+ {project.link ? <a class="text-sm text-slate-700 underline" href={normalizeHttpHref(project.link)}>Read</a> : null}
67
+ </div>
68
+ {project.description ? <p class="mt-2 leading-7 text-slate-700">{project.description}</p> : null}
69
+ {project.bullets.length > 0 ? (
70
+ <ul class="mt-2 list-disc space-y-1 pl-5 text-sm leading-6 text-slate-700">
71
+ {project.bullets.map((bullet) => (
72
+ <li>{bullet}</li>
73
+ ))}
74
+ </ul>
75
+ ) : null}
76
+ {project.tags.length > 0 ? <p class="mt-2 text-xs text-slate-500">{project.tags.join(" • ")}</p> : null}
77
+ </article>
78
+ ))}
79
+ </div>
80
+ </section>
81
+ ) : null}
82
+
83
+ {isSectionVisible(data, "education") && data.sections.education.length > 0 ? (
84
+ <section aria-labelledby="story-education" class="relative mb-10">
85
+ <span class="absolute -left-[2.05rem] top-1 h-3 w-3 rounded-full bg-slate-400"></span>
86
+ <h2 id="story-education" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Education</h2>
87
+ <div class="mt-4 space-y-3">
88
+ {data.sections.education.map((item) => (
89
+ <article>
90
+ <h3 class="font-semibold text-slate-900">{item.degree || "Degree"}</h3>
91
+ <p class="text-sm text-slate-700">{[item.field, item.institution].filter(Boolean).join(" • ")}</p>
92
+ <p class="text-sm text-slate-500">{[item.start, item.end].filter(Boolean).join(" - ")}</p>
93
+ {item.grade ? <p class="text-sm text-slate-600">{item.grade}</p> : null}
94
+ </article>
95
+ ))}
96
+ </div>
97
+ </section>
98
+ ) : null}
99
+ </div>
100
+
101
+ <aside class="space-y-8">
102
+ {isSectionVisible(data, "skills") && data.sections.skills.length > 0 ? (
103
+ <section aria-labelledby="story-skills" class="rounded-xl bg-slate-50 p-5">
104
+ <h2 id="story-skills" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Skills</h2>
105
+ <div class="mt-3 space-y-3">
106
+ {data.sections.skills.map((group) => (
107
+ <div>
108
+ <h3 class="text-sm font-semibold text-slate-900">{group.name || "Skills"}</h3>
109
+ <p class="text-sm text-slate-600">{group.items.join(", ")}</p>
110
+ </div>
111
+ ))}
112
+ </div>
113
+ </section>
114
+ ) : null}
115
+
116
+ {isSectionVisible(data, "certifications") && data.sections.certifications.length > 0 ? (
117
+ <section aria-labelledby="story-certifications" class="rounded-xl bg-slate-50 p-5">
118
+ <h2 id="story-certifications" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Certifications</h2>
119
+ <ul class="mt-3 space-y-2 text-sm text-slate-700">
120
+ {data.sections.certifications.map((item) => (
121
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
122
+ ))}
123
+ </ul>
124
+ </section>
125
+ ) : null}
126
+
127
+ {isSectionVisible(data, "achievements") && data.sections.achievements.length > 0 ? (
128
+ <section aria-labelledby="story-achievements" class="rounded-xl bg-slate-50 p-5">
129
+ <h2 id="story-achievements" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Achievements</h2>
130
+ <ul class="mt-3 space-y-2 text-sm text-slate-700">
131
+ {data.sections.achievements.map((item) => (
132
+ <li>{[item.title, item.issuer, item.year].filter(Boolean).join(" • ")}</li>
133
+ ))}
134
+ </ul>
135
+ </section>
136
+ ) : null}
137
+
138
+ {isSectionVisible(data, "contact") ? (
139
+ <section aria-labelledby="story-contact" class="rounded-xl border border-slate-200 p-5">
140
+ <h2 id="story-contact" class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">Contact</h2>
141
+ {data.contact.callToActionTitle ? <p class="mt-3 font-semibold text-slate-900">{data.contact.callToActionTitle}</p> : null}
142
+ {data.contact.callToActionText ? <p class="mt-1 text-sm text-slate-700">{data.contact.callToActionText}</p> : null}
143
+ <div class="mt-3 grid gap-1 text-sm text-slate-700">
144
+ {data.contact.email ? <a class="underline" href={`mailto:${data.contact.email}`}>{data.contact.email}</a> : null}
145
+ {data.contact.phone ? <a class="underline" href={`tel:${data.contact.phone}`}>{data.contact.phone}</a> : null}
146
+ {data.contact.website ? <a class="underline" href={normalizeHttpHref(data.contact.website)}>{data.contact.website}</a> : null}
147
+ {data.contact.github ? <a class="underline" href={normalizeHttpHref(data.contact.github)}>GitHub</a> : null}
148
+ {data.contact.linkedin ? <a class="underline" href={normalizeHttpHref(data.contact.linkedin)}>LinkedIn</a> : null}
149
+ {data.contact.links.map((link) => (
150
+ <a class="underline" href={link.href}>{link.label || link.href}</a>
151
+ ))}
152
+ </div>
153
+ </section>
154
+ ) : null}
155
+ </aside>
156
+ </div>
157
+ </main>
158
+ </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,101 @@
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
+ };
24
+
25
+ export type PortfolioPublicContact = {
26
+ email: string;
27
+ phone: string;
28
+ website: string;
29
+ github: string;
30
+ linkedin: string;
31
+ callToActionTitle: string;
32
+ callToActionText: string;
33
+ links: PortfolioPublicLink[];
34
+ };
35
+
36
+ export type PortfolioPublicProject = {
37
+ name: string;
38
+ description: string;
39
+ link: string;
40
+ bullets: string[];
41
+ tags: string[];
42
+ };
43
+
44
+ export type PortfolioPublicExperience = {
45
+ role: string;
46
+ company: string;
47
+ location: string;
48
+ start: string;
49
+ end: string;
50
+ bullets: string[];
51
+ };
52
+
53
+ export type PortfolioPublicEducation = {
54
+ degree: string;
55
+ field: string;
56
+ institution: string;
57
+ start: string;
58
+ end: string;
59
+ grade: string;
60
+ };
61
+
62
+ export type PortfolioPublicCredential = {
63
+ title: string;
64
+ issuer: string;
65
+ year: string;
66
+ note: string;
67
+ };
68
+
69
+ export type PortfolioPublicSkillGroup = {
70
+ name: string;
71
+ items: string[];
72
+ };
73
+
74
+ export type PortfolioPublicSections = {
75
+ about: string;
76
+ featuredProjects: PortfolioPublicProject[];
77
+ experience: PortfolioPublicExperience[];
78
+ skills: PortfolioPublicSkillGroup[];
79
+ education: PortfolioPublicEducation[];
80
+ certifications: PortfolioPublicCredential[];
81
+ achievements: PortfolioPublicCredential[];
82
+ };
83
+
84
+ export type PortfolioPublicMeta = {
85
+ title?: string;
86
+ slug?: string;
87
+ visibility?: "public" | "unlisted" | "private";
88
+ publishedAt?: string | null;
89
+ updatedAt?: string | null;
90
+ lastUpdatedLabel?: string;
91
+ };
92
+
93
+ export type PortfolioPublicData = {
94
+ version: "1.0";
95
+ templateKey: PortfolioPublicTemplateKey;
96
+ owner: PortfolioPublicOwner;
97
+ contact: PortfolioPublicContact;
98
+ sections: PortfolioPublicSections;
99
+ visibleSections: PortfolioPublicSectionKey[];
100
+ meta?: PortfolioPublicMeta;
101
+ };
@@ -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);