@ansiversa/components 0.0.125 → 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 +13 -0
- package/package.json +1 -1
- package/src/layouts/AuthLayout.astro +1 -1
- package/src/layouts/WebLayout.astro +1 -1
- package/src/styles/global.css +15 -13
- package/src/templates/portfolio-public/Classic.astro +183 -0
- package/src/templates/portfolio-public/Gallery.astro +156 -0
- package/src/templates/portfolio-public/Minimal.astro +148 -0
- package/src/templates/portfolio-public/Story.astro +158 -0
- package/src/templates/portfolio-public/index.ts +40 -0
- package/src/templates/portfolio-public/types.ts +101 -0
- package/src/templates/portfolio-public/utils.ts +18 -0
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
package/src/styles/global.css
CHANGED
|
@@ -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:
|
|
93
|
-
color:
|
|
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);
|