@ansiversa/components 0.0.126 → 0.0.128
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 +3 -0
- package/package.json +1 -1
- package/src/components/Ai/AvAiAssist.astro +294 -0
- package/src/components/media/AvImageUploader.astro +341 -0
- package/src/media/types.ts +13 -0
- package/src/templates/portfolio-public/Classic.astro +7 -0
- package/src/templates/portfolio-public/Gallery.astro +7 -0
- package/src/templates/portfolio-public/Minimal.astro +7 -0
- package/src/templates/portfolio-public/Story.astro +7 -0
- package/src/templates/portfolio-public/types.ts +1 -0
package/index.ts
CHANGED
|
@@ -37,6 +37,8 @@ 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";
|
|
41
|
+
export { default as AvAiAssist } from "./src/components/Ai/AvAiAssist.astro";
|
|
40
42
|
export { default as ResumeBuilderShell } from './src/resume-templates/ResumeBuilderShell.astro';
|
|
41
43
|
export { default as ResumeTemplateClassic } from './src/resume-templates/ResumeTemplateClassic.astro';
|
|
42
44
|
export { default as ResumeTemplateModernTwoTone } from './src/resume-templates/ResumeTemplateModernTwoTone.astro';
|
|
@@ -58,6 +60,7 @@ export type {
|
|
|
58
60
|
PortfolioPublicTemplateKey,
|
|
59
61
|
PortfolioPublicSectionKey,
|
|
60
62
|
} from "./src/templates/portfolio-public";
|
|
63
|
+
export type { AvMediaUploadResponse, AvMediaUploadResult } from "./src/media/types";
|
|
61
64
|
|
|
62
65
|
export * from "./src/alpine";
|
|
63
66
|
export * from "./src/Summary/types";
|
package/package.json
CHANGED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
featureKey: string;
|
|
4
|
+
value: string;
|
|
5
|
+
minChars?: number;
|
|
6
|
+
maxChars?: number;
|
|
7
|
+
label?: string;
|
|
8
|
+
onAppendEvent?: string;
|
|
9
|
+
onReplaceEvent?: string;
|
|
10
|
+
[key: string]: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
featureKey,
|
|
15
|
+
value,
|
|
16
|
+
minChars = 30,
|
|
17
|
+
maxChars = 1500,
|
|
18
|
+
label = "AI",
|
|
19
|
+
onAppendEvent = "av:ai-append",
|
|
20
|
+
onReplaceEvent = "av:ai-replace",
|
|
21
|
+
...rest
|
|
22
|
+
} = Astro.props as Props;
|
|
23
|
+
|
|
24
|
+
const componentId = `av-ai-assist-${Math.random().toString(36).slice(2, 10)}`;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<div
|
|
28
|
+
class="av-ai-assist"
|
|
29
|
+
data-av-ai-assist={componentId}
|
|
30
|
+
data-value={value}
|
|
31
|
+
{...rest}
|
|
32
|
+
x-data={`avAiAssist(${JSON.stringify({
|
|
33
|
+
featureKey,
|
|
34
|
+
minChars,
|
|
35
|
+
maxChars,
|
|
36
|
+
label,
|
|
37
|
+
onAppendEvent,
|
|
38
|
+
onReplaceEvent,
|
|
39
|
+
})})`}
|
|
40
|
+
x-init="init()"
|
|
41
|
+
>
|
|
42
|
+
<span class="av-ai-assist-trigger" :title="disabledReason || `Get ${label} suggestions`">
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
class="av-btn-ghost av-btn-sm av-ai-assist-btn"
|
|
46
|
+
@click.prevent="openModal()"
|
|
47
|
+
:disabled="!canFetch"
|
|
48
|
+
aria-label="Get AI suggestions"
|
|
49
|
+
>
|
|
50
|
+
<span aria-hidden="true">✨</span>
|
|
51
|
+
<span x-text="label"></span>
|
|
52
|
+
</button>
|
|
53
|
+
</span>
|
|
54
|
+
|
|
55
|
+
<template x-if="open">
|
|
56
|
+
<div class="av-ai-assist-overlay" @click.self="closeModal()" @keydown.escape.window="closeModal()">
|
|
57
|
+
<div class="av-ai-assist-modal" role="dialog" aria-modal="true" aria-label="AI suggestions">
|
|
58
|
+
<div class="av-auth-stack-sm">
|
|
59
|
+
<div class="av-row-between">
|
|
60
|
+
<h3 class="av-card-heading av-m-0">AI Suggestions</h3>
|
|
61
|
+
<button type="button" class="av-btn-ghost av-btn-sm" @click.prevent="closeModal()">Close</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<template x-if="loading">
|
|
65
|
+
<p class="av-text-soft av-m-0">Generating suggestions...</p>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<template x-if="error && !loading">
|
|
69
|
+
<div class="av-alert av-alert-danger" role="status" x-text="error"></div>
|
|
70
|
+
</template>
|
|
71
|
+
|
|
72
|
+
<template x-if="!loading && suggestions.length">
|
|
73
|
+
<div class="av-auth-stack-xs">
|
|
74
|
+
<template x-for="(suggestion, index) in suggestions" :key="`assist-${index}`">
|
|
75
|
+
<div class="av-ai-assist-suggestion">
|
|
76
|
+
<p class="av-m-0" x-text="suggestion"></p>
|
|
77
|
+
<div class="av-row-wrap">
|
|
78
|
+
<button type="button" class="av-btn-ghost av-btn-sm" @click.prevent="appendSuggestion(suggestion)">Append</button>
|
|
79
|
+
<button type="button" class="av-btn-primary av-btn-sm" @click.prevent="replaceSuggestion(suggestion)">Replace</button>
|
|
80
|
+
<button type="button" class="av-btn-ghost av-btn-sm" @click.prevent="copySuggestion(suggestion, index)">
|
|
81
|
+
<span x-text="copiedIndex === index ? 'Copied' : 'Copy'"></span>
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<p class="av-form-hint av-m-0">AI suggestions are optional. Please review before saving.</p>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<script is:inline>
|
|
97
|
+
if (typeof window !== "undefined" && !window.avAiAssist) {
|
|
98
|
+
window.avAiAssist = (config) => ({
|
|
99
|
+
featureKey: config.featureKey,
|
|
100
|
+
minChars: Number(config.minChars ?? 30),
|
|
101
|
+
maxChars: Number(config.maxChars ?? 1500),
|
|
102
|
+
label: typeof config.label === "string" && config.label ? config.label : "AI",
|
|
103
|
+
onAppendEvent: typeof config.onAppendEvent === "string" && config.onAppendEvent ? config.onAppendEvent : "av:ai-append",
|
|
104
|
+
onReplaceEvent: typeof config.onReplaceEvent === "string" && config.onReplaceEvent ? config.onReplaceEvent : "av:ai-replace",
|
|
105
|
+
value: "",
|
|
106
|
+
open: false,
|
|
107
|
+
loading: false,
|
|
108
|
+
error: null,
|
|
109
|
+
suggestions: [],
|
|
110
|
+
copiedIndex: null,
|
|
111
|
+
observer: null,
|
|
112
|
+
|
|
113
|
+
init() {
|
|
114
|
+
this.syncValue();
|
|
115
|
+
|
|
116
|
+
if (typeof MutationObserver !== "undefined") {
|
|
117
|
+
this.observer = new MutationObserver(() => this.syncValue());
|
|
118
|
+
this.observer.observe(this.$el, { attributes: true, attributeFilter: ["data-value"] });
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
get textValue() {
|
|
123
|
+
const raw = typeof this.value === "string" ? this.value : "";
|
|
124
|
+
return raw.trim();
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
get canFetch() {
|
|
128
|
+
return !this.loading && !this.disabledReason;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
get disabledReason() {
|
|
132
|
+
if (this.loading) return "Generating suggestions...";
|
|
133
|
+
if (this.textValue.length > this.maxChars) return `Text exceeds ${this.maxChars} characters.`;
|
|
134
|
+
if (this.textValue.length < this.minChars) return `Add at least ${this.minChars} characters to get meaningful suggestions.`;
|
|
135
|
+
return "";
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
syncValue() {
|
|
139
|
+
const nextValue = this.$el.getAttribute("data-value") ?? "";
|
|
140
|
+
this.value = String(nextValue);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
closeModal() {
|
|
144
|
+
this.open = false;
|
|
145
|
+
this.loading = false;
|
|
146
|
+
this.error = null;
|
|
147
|
+
this.suggestions = [];
|
|
148
|
+
this.copiedIndex = null;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
mapError(status, payload) {
|
|
152
|
+
if (status === 401) return "Please sign in again.";
|
|
153
|
+
if (status === 429) return "Too many requests. Try again in a few minutes.";
|
|
154
|
+
if (status === 400) {
|
|
155
|
+
if (payload && typeof payload.error === "string" && payload.error.trim()) {
|
|
156
|
+
return payload.error.trim();
|
|
157
|
+
}
|
|
158
|
+
return "Invalid request.";
|
|
159
|
+
}
|
|
160
|
+
return "Something went wrong. Please try again.";
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async openModal() {
|
|
164
|
+
this.syncValue();
|
|
165
|
+
if (!this.canFetch) return;
|
|
166
|
+
|
|
167
|
+
this.open = true;
|
|
168
|
+
this.loading = true;
|
|
169
|
+
this.error = null;
|
|
170
|
+
this.suggestions = [];
|
|
171
|
+
this.copiedIndex = null;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const response = await fetch("/api/ai/suggest", {
|
|
175
|
+
method: "POST",
|
|
176
|
+
credentials: "include",
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
},
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
featureKey: this.featureKey,
|
|
182
|
+
userText: this.textValue,
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const text = await response.text();
|
|
187
|
+
let payload = null;
|
|
188
|
+
|
|
189
|
+
if (text) {
|
|
190
|
+
try {
|
|
191
|
+
payload = JSON.parse(text);
|
|
192
|
+
} catch {
|
|
193
|
+
payload = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
this.error = this.mapError(response.status, payload);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const list = Array.isArray(payload?.suggestions)
|
|
203
|
+
? payload.suggestions
|
|
204
|
+
.filter((item) => typeof item === "string")
|
|
205
|
+
.map((item) => item.trim())
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
: [];
|
|
208
|
+
|
|
209
|
+
this.suggestions = list.slice(0, 5);
|
|
210
|
+
if (!this.suggestions.length) {
|
|
211
|
+
this.error = "No suggestions returned.";
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
this.error = "Something went wrong. Please try again.";
|
|
215
|
+
} finally {
|
|
216
|
+
this.loading = false;
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
appendSuggestion(text) {
|
|
221
|
+
const value = String(text ?? "").trim();
|
|
222
|
+
if (!value) return;
|
|
223
|
+
window.dispatchEvent(new CustomEvent(this.onAppendEvent, { detail: { text: value } }));
|
|
224
|
+
this.closeModal();
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
replaceSuggestion(text) {
|
|
228
|
+
const value = String(text ?? "").trim();
|
|
229
|
+
if (!value) return;
|
|
230
|
+
window.dispatchEvent(new CustomEvent(this.onReplaceEvent, { detail: { text: value } }));
|
|
231
|
+
this.closeModal();
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async copySuggestion(text, index) {
|
|
235
|
+
const value = String(text ?? "").trim();
|
|
236
|
+
if (!value) return;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (!navigator?.clipboard?.writeText) {
|
|
240
|
+
throw new Error("Clipboard unavailable");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await navigator.clipboard.writeText(value);
|
|
244
|
+
this.copiedIndex = index;
|
|
245
|
+
window.setTimeout(() => {
|
|
246
|
+
if (this.copiedIndex === index) this.copiedIndex = null;
|
|
247
|
+
}, 1200);
|
|
248
|
+
} catch {
|
|
249
|
+
this.error = "Copy failed. Please copy manually.";
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
</script>
|
|
255
|
+
|
|
256
|
+
<style>
|
|
257
|
+
.av-ai-assist-trigger {
|
|
258
|
+
display: inline-flex;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.av-ai-assist-btn[disabled] {
|
|
262
|
+
cursor: not-allowed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.av-ai-assist-overlay {
|
|
266
|
+
position: fixed;
|
|
267
|
+
inset: 0;
|
|
268
|
+
z-index: 60;
|
|
269
|
+
display: flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
justify-content: center;
|
|
272
|
+
padding: 1rem;
|
|
273
|
+
background: rgba(2, 6, 23, 0.72);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.av-ai-assist-modal {
|
|
277
|
+
width: min(720px, 100%);
|
|
278
|
+
max-height: min(80vh, 720px);
|
|
279
|
+
overflow: auto;
|
|
280
|
+
border-radius: 1rem;
|
|
281
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
282
|
+
background: var(--av-surface, #070f23);
|
|
283
|
+
padding: 1rem;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.av-ai-assist-suggestion {
|
|
287
|
+
display: grid;
|
|
288
|
+
gap: 0.75rem;
|
|
289
|
+
border-radius: 0.75rem;
|
|
290
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
291
|
+
background: var(--av-surface-soft, rgba(255, 255, 255, 0.04));
|
|
292
|
+
padding: 0.75rem;
|
|
293
|
+
}
|
|
294
|
+
</style>
|
|
@@ -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>
|
|
@@ -22,6 +22,13 @@ const hasContact =
|
|
|
22
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
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
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}
|
|
25
32
|
<h1 class="text-3xl font-bold tracking-tight text-slate-950">{ownerName}</h1>
|
|
26
33
|
{data.owner.headline ? <p class="text-base text-slate-700">{data.owner.headline}</p> : null}
|
|
27
34
|
{data.owner.location ? <p class="text-sm text-slate-500">{data.owner.location}</p> : null}
|
|
@@ -14,6 +14,13 @@ const ownerName = data.owner.fullName || data.meta?.title || "Portfolio";
|
|
|
14
14
|
<main class="mx-auto w-full max-w-6xl px-4 py-10 lg:px-8">
|
|
15
15
|
<header class="mb-8 rounded-2xl border border-slate-200 bg-slate-50 p-6 sm:p-8">
|
|
16
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}
|
|
17
24
|
<h1 class="mt-3 text-3xl font-bold tracking-tight text-slate-950 sm:text-4xl">{ownerName}</h1>
|
|
18
25
|
{data.owner.headline ? <p class="mt-2 text-lg text-slate-700">{data.owner.headline}</p> : null}
|
|
19
26
|
{hasText(data.owner.summary) ? <p class="mt-4 max-w-3xl leading-7 text-slate-700">{data.owner.summary}</p> : null}
|
|
@@ -13,6 +13,13 @@ const ownerName = data.owner.fullName || data.meta?.title || "Portfolio";
|
|
|
13
13
|
<article class="min-h-screen bg-white text-slate-900">
|
|
14
14
|
<main class="mx-auto w-full max-w-3xl px-6 py-12 sm:px-8">
|
|
15
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}
|
|
16
23
|
<h1 class="text-4xl font-light tracking-tight text-slate-950 sm:text-5xl">{ownerName}</h1>
|
|
17
24
|
{data.owner.headline ? <p class="mt-2 text-lg text-slate-700">{data.owner.headline}</p> : null}
|
|
18
25
|
{data.owner.location ? <p class="mt-1 text-sm uppercase tracking-[0.16em] text-slate-500">{data.owner.location}</p> : null}
|
|
@@ -14,6 +14,13 @@ const ownerName = data.owner.fullName || data.meta?.title || "Portfolio";
|
|
|
14
14
|
<main class="mx-auto w-full max-w-5xl px-5 py-12 lg:px-10">
|
|
15
15
|
<header class="mx-auto max-w-3xl space-y-4 text-center">
|
|
16
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}
|
|
17
24
|
<h1 class="text-4xl font-bold tracking-tight text-slate-950 sm:text-5xl">{ownerName}</h1>
|
|
18
25
|
{data.owner.headline ? <p class="text-lg text-slate-700">{data.owner.headline}</p> : null}
|
|
19
26
|
{hasText(data.owner.summary) ? <p class="leading-7 text-slate-700">{data.owner.summary}</p> : null}
|