heyiam 0.3.4 → 0.3.6

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.
Files changed (45) hide show
  1. package/dist/export.js +19 -5
  2. package/dist/mount.js +9 -7
  3. package/dist/public/assets/{index-Cq04whgG.js → index-7mUuxgqY.js} +3 -3
  4. package/dist/public/index.html +1 -1
  5. package/dist/render/build-render-data.js +1 -0
  6. package/dist/render/liquid.js +12 -2
  7. package/dist/render/templates/aurora/project.liquid +1 -1
  8. package/dist/render/templates/bauhaus/project.liquid +1 -1
  9. package/dist/render/templates/blueprint/project.liquid +1 -1
  10. package/dist/render/templates/canvas/project.liquid +1 -1
  11. package/dist/render/templates/carbon/project.liquid +1 -1
  12. package/dist/render/templates/chalk/project.liquid +1 -1
  13. package/dist/render/templates/circuit/project.liquid +1 -1
  14. package/dist/render/templates/cosmos/project.liquid +1 -1
  15. package/dist/render/templates/daylight/project.liquid +1 -1
  16. package/dist/render/templates/editorial/project.liquid +1 -1
  17. package/dist/render/templates/ember/project.liquid +1 -1
  18. package/dist/render/templates/glacier/project.liquid +1 -1
  19. package/dist/render/templates/grid/project.liquid +1 -1
  20. package/dist/render/templates/kinetic/portfolio.liquid +1 -1
  21. package/dist/render/templates/kinetic/project.liquid +1 -1
  22. package/dist/render/templates/kinetic/styles.css +15 -9
  23. package/dist/render/templates/meridian/project.liquid +1 -1
  24. package/dist/render/templates/minimal/project.liquid +1 -1
  25. package/dist/render/templates/mono/project.liquid +1 -1
  26. package/dist/render/templates/neon/project.liquid +1 -1
  27. package/dist/render/templates/noir/project.liquid +1 -1
  28. package/dist/render/templates/obsidian/project.liquid +1 -1
  29. package/dist/render/templates/paper/project.liquid +1 -1
  30. package/dist/render/templates/parallax/project.liquid +1 -1
  31. package/dist/render/templates/parchment/project.liquid +1 -1
  32. package/dist/render/templates/project.liquid +1 -1
  33. package/dist/render/templates/radar/project.liquid +1 -1
  34. package/dist/render/templates/showcase/project.liquid +1 -1
  35. package/dist/render/templates/signal/project.liquid +1 -1
  36. package/dist/render/templates/strata/project.liquid +1 -1
  37. package/dist/render/templates/terminal/project.liquid +1 -1
  38. package/dist/render/templates/verdant/project.liquid +1 -1
  39. package/dist/render/templates/zen/project.liquid +1 -1
  40. package/dist/routes/portfolio-render-data.js +2 -2
  41. package/dist/routes/preview.js +18 -2
  42. package/dist/routes/project-session-upload.js +4 -3
  43. package/dist/routes/publish.js +118 -16
  44. package/dist/settings.js +14 -1
  45. package/package.json +1 -1
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { getAuthToken } from '../auth.js';
7
7
  import { API_URL, PUBLIC_URL, warnIfNonDefaultApiUrl } from '../config.js';
8
- import { loadEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
8
+ import { loadEnhancedData, saveUploadedState, getDefaultTemplate, getPortfolioProfile, hashPortfolioProfile, updatePortfolioPublishTarget, getPortfolioPublishState, listUploadedProjects, DEFAULT_PORTFOLIO_TARGET, } from '../settings.js';
9
9
  import { generatePortfolioHtmlFragment, generateProjectHtmlFragment, generatePortfolioSite, createZipBuffer } from '../export.js';
10
10
  import { buildPortfolioRenderData } from './portfolio-render-data.js';
11
11
  import { buildProjectDetail } from './context.js';
@@ -19,6 +19,37 @@ import { displayNameFromDir } from '../sync.js';
19
19
  import { toSlug } from '../format-utils.js';
20
20
  import { getProjectUuid } from '../db.js';
21
21
  const IMAGE_KEY_PREFIX = 'images/';
22
+ /**
23
+ * Upload the user's base64-encoded profile photo to S3 via the Phoenix
24
+ * presign endpoints, then save the resulting key on the user record.
25
+ * Returns the public `/_img/:uuid` URL suitable for og:image / <img src>,
26
+ * or `null` if the upload failed (publish still proceeds without a photo).
27
+ */
28
+ /**
29
+ * POSTs the user's base64 profile photo to Phoenix, which resizes it into
30
+ * a full (max 1200px) and small (max 600px) variant, uploads both to R2,
31
+ * and persists the two keys on the user record. Returns the full-size
32
+ * URL (for inline <img src>) or `null` on any failure.
33
+ */
34
+ async function uploadProfilePhoto(photoBase64, auth) {
35
+ try {
36
+ const res = await fetch(`${API_URL}/api/portfolio/profile-photo`, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ Authorization: `Bearer ${auth.token}`,
41
+ },
42
+ body: JSON.stringify({ photo: photoBase64 }),
43
+ });
44
+ if (!res.ok)
45
+ return null;
46
+ const { full_url } = await res.json();
47
+ return full_url ?? null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
22
53
  export function createPublishRouter(ctx) {
23
54
  const router = Router();
24
55
  // Render project preview HTML
@@ -351,16 +382,35 @@ export function createPublishRouter(ctx) {
351
382
  });
352
383
  return;
353
384
  }
385
+ const send = startSSE(res);
354
386
  try {
387
+ send({ type: 'progress', message: 'Preparing portfolio…' });
355
388
  const profile = getPortfolioProfile();
356
389
  const templateName = getDefaultTemplate() || 'editorial';
357
- const { renderData, filteredProjects } = await buildPortfolioRenderData(ctx, auth);
390
+ // If the user has a profile photo, upload it to Phoenix first so the
391
+ // rendered HTML can reference the hosted URL instead of inlining 1.5MB
392
+ // of base64. Phoenix resizes into a full + small variant and stores
393
+ // both in R2. Removals are handled by the upload endpoint below based
394
+ // on the `has_photo` signal — no explicit DELETE needed.
395
+ let photoUrlOverride;
396
+ if (profile.photoBase64) {
397
+ send({ type: 'progress', message: 'Uploading profile photo…' });
398
+ const uploadedUrl = await uploadProfilePhoto(profile.photoBase64, auth);
399
+ if (uploadedUrl)
400
+ photoUrlOverride = uploadedUrl;
401
+ }
402
+ const { renderData, filteredProjects } = await buildPortfolioRenderData(ctx, auth, { photoUrlOverride });
403
+ send({ type: 'progress', message: 'Rendering portfolio HTML…' });
358
404
  const renderedHtml = generatePortfolioHtmlFragment(renderData, templateName);
359
405
  // Upload individual project pages for every project included in the
360
406
  // portfolio. This ensures project detail pages exist on heyi.am even
361
407
  // if the user never published them individually.
362
408
  const MAX_SLUG_RETRIES = 10;
409
+ const slugMap = new Map();
410
+ send({ type: 'progress', message: `Publishing ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}…` });
411
+ let projectIndex = 0;
363
412
  for (const rawProj of filteredProjects) {
413
+ projectIndex++;
364
414
  try {
365
415
  const allProjectsList = await ctx.getProjects();
366
416
  const projInfo = allProjectsList.find((p) => p.dirName === rawProj.dirName);
@@ -384,6 +434,7 @@ export function createPublishRouter(ctx) {
384
434
  || projRecord.name || displayNameFromDir(rawProj.dirName);
385
435
  const baseSlug = toSlug(title);
386
436
  const clientProjectId = getProjectUuid(ctx.db, rawProj.dirName);
437
+ send({ type: 'project', project: title, index: projectIndex, total: filteredProjects.length, status: 'creating' });
387
438
  const projectHtmlPreview = generateProjectHtmlFragment(rawProj.dirName, cache, detail.sessions, auth.username, {
388
439
  totalFilesChanged: projRecord.totalFiles,
389
440
  totalAgentDurationMinutes: projRecord.totalAgentDuration,
@@ -431,7 +482,13 @@ export function createPublishRouter(ctx) {
431
482
  }
432
483
  break;
433
484
  }
434
- if (!projectRes?.ok) {
485
+ if (projectRes?.ok) {
486
+ const data = await projectRes.json().catch(() => null);
487
+ if (data?.slug && data.slug !== baseSlug) {
488
+ slugMap.set(baseSlug, data.slug);
489
+ }
490
+ }
491
+ else {
435
492
  const errText = await projectRes?.text().catch(() => '');
436
493
  console.warn(`[portfolio-upload] project ${rawProj.dirName} create failed:`, projectRes?.status, errText);
437
494
  }
@@ -463,11 +520,27 @@ export function createPublishRouter(ctx) {
463
520
  continue;
464
521
  }
465
522
  const projectData = await projectRes.json();
523
+ if (projectData.slug !== baseSlug) {
524
+ slugMap.set(baseSlug, projectData.slug);
525
+ }
526
+ send({ type: 'project', project: title, index: projectIndex, total: filteredProjects.length, status: 'created' });
527
+ send({ type: 'progress', message: `Uploading ${selectedSessionIds.length} session${selectedSessionIds.length === 1 ? '' : 's'} for ${title}…` });
466
528
  const { uploadedSessionCards } = await uploadSelectedSessions(ctx, auth, {
467
529
  proj: projInfo,
468
530
  projectData,
469
531
  selectedSessionIds,
532
+ sessionStatus: 'listed',
533
+ send: (evt) => send({ ...evt, project: title }),
470
534
  });
535
+ // Ensure all existing sessions for this project are listed
536
+ await fetch(`${API_URL}/api/sessions/bulk-status`, {
537
+ method: 'PATCH',
538
+ headers: {
539
+ 'Content-Type': 'application/json',
540
+ Authorization: `Bearer ${auth.token}`,
541
+ },
542
+ body: JSON.stringify({ project_id: projectData.project_id, status: 'listed' }),
543
+ }).catch((e) => console.warn(`[portfolio-upload] bulk-status failed for ${title}:`, e.message));
471
544
  if (uploadedSessionCards.length === 0) {
472
545
  continue;
473
546
  }
@@ -515,9 +588,39 @@ export function createPublishRouter(ctx) {
515
588
  console.warn(`[portfolio-upload] skipping project ${rawProj.dirName}:`, projErr.message);
516
589
  }
517
590
  }
518
- // POST portfolio landing page to Phoenix.
519
- // Phoenix sanitizes the HTML, persists to users.rendered_portfolio_html,
520
- // and applies the optional profile snapshot via Accounts.update_user_profile.
591
+ // Demote sessions on projects that were previously published but are
592
+ // no longer included in the portfolio.
593
+ const includedDirNames = new Set(filteredProjects.map((p) => p.dirName));
594
+ const previouslyUploaded = listUploadedProjects();
595
+ for (const { dirName, state } of previouslyUploaded) {
596
+ if (includedDirNames.has(dirName))
597
+ continue;
598
+ try {
599
+ send({ type: 'progress', message: `Demoting sessions for removed project "${dirName}"…` });
600
+ await fetch(`${API_URL}/api/sessions/bulk-status`, {
601
+ method: 'PATCH',
602
+ headers: {
603
+ 'Content-Type': 'application/json',
604
+ Authorization: `Bearer ${auth.token}`,
605
+ },
606
+ body: JSON.stringify({ project_id: state.projectId, status: 'unlisted' }),
607
+ });
608
+ }
609
+ catch (demoteErr) {
610
+ console.warn(`[portfolio-upload] failed to demote sessions for ${dirName}:`, demoteErr.message);
611
+ }
612
+ }
613
+ // Rewrite project links in the portfolio HTML to match the actual
614
+ // Phoenix-assigned slugs (which may differ due to conflict retries).
615
+ let finalHtml = renderedHtml;
616
+ for (const [originalSlug, actualSlug] of slugMap) {
617
+ const pattern = `/${auth.username}/${originalSlug}"`;
618
+ const replacement = `/${auth.username}/${actualSlug}"`;
619
+ while (finalHtml.includes(pattern)) {
620
+ finalHtml = finalHtml.replace(pattern, replacement);
621
+ }
622
+ }
623
+ send({ type: 'progress', message: 'Publishing landing page…' });
521
624
  const phoenixRes = await fetch(`${API_URL}/api/portfolio/upload`, {
522
625
  method: 'POST',
523
626
  headers: {
@@ -525,8 +628,9 @@ export function createPublishRouter(ctx) {
525
628
  Authorization: `Bearer ${auth.token}`,
526
629
  },
527
630
  body: JSON.stringify({
528
- html: renderedHtml,
631
+ html: finalHtml,
529
632
  profile,
633
+ has_photo: Boolean(profile.photoBase64),
530
634
  }),
531
635
  });
532
636
  if (!phoenixRes.ok) {
@@ -543,9 +647,8 @@ export function createPublishRouter(ctx) {
543
647
  lastError: errMsg,
544
648
  lastErrorAt: new Date().toISOString(),
545
649
  });
546
- res.status(phoenixRes.status >= 500 ? 502 : phoenixRes.status).json({
547
- error: { code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg },
548
- });
650
+ send({ type: 'error', code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg });
651
+ res.end();
549
652
  return;
550
653
  }
551
654
  const okBody = await phoenixRes.json().catch(() => ({}));
@@ -561,15 +664,15 @@ export function createPublishRouter(ctx) {
561
664
  lastError: undefined,
562
665
  lastErrorAt: undefined,
563
666
  });
564
- // Published version of the portfolio just changed — drop any cached
565
- // /preview/portfolio HTML so the next preview reflects reality.
566
667
  invalidatePortfolioPreviewCache();
567
- res.json({
668
+ send({
669
+ type: 'done',
568
670
  ok: true,
569
671
  url: publishedUrl ?? `${PUBLIC_URL}/${auth.username}`,
570
672
  publishedAt,
571
673
  hash,
572
674
  });
675
+ res.end();
573
676
  }
574
677
  catch (err) {
575
678
  const errMsg = err.message;
@@ -581,9 +684,8 @@ export function createPublishRouter(ctx) {
581
684
  });
582
685
  }
583
686
  catch { /* don't mask the original error */ }
584
- res.status(500).json({
585
- error: { code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg },
586
- });
687
+ send({ type: 'error', code: 'PORTFOLIO_UPLOAD_FAILED', message: errMsg });
688
+ res.end();
587
689
  }
588
690
  });
589
691
  // Export the portfolio as a downloadable .zip file.
package/dist/settings.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
@@ -235,6 +235,19 @@ export function clearUploadedState(projectDirName, configDir) {
235
235
  if (existsSync(path))
236
236
  unlinkSync(path);
237
237
  }
238
+ export function listUploadedProjects(configDir) {
239
+ const dir = uploadedDir(configDir);
240
+ if (!existsSync(dir))
241
+ return [];
242
+ return readdirSync(dir)
243
+ .filter((f) => f.endsWith('.json'))
244
+ .map((f) => {
245
+ const dirName = f.replace(/\.json$/, '');
246
+ const state = getUploadedState(dirName, configDir);
247
+ return state ? { dirName, state } : null;
248
+ })
249
+ .filter((x) => x !== null);
250
+ }
238
251
  const PORTFOLIO_PUBLISH_FILE = 'portfolio-publish.json';
239
252
  const DEFAULT_PORTFOLIO_TARGET = 'heyi.am';
240
253
  function portfolioPublishPath(configDir = getDataDir()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyiam",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Turn AI coding sessions into portfolio case studies",
5
5
  "type": "module",
6
6
  "license": "MIT",