heyiam 0.3.0 → 0.3.2

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 (94) hide show
  1. package/dist/auth.js +29 -3
  2. package/dist/db.js +1 -1
  3. package/dist/export.js +84 -2
  4. package/dist/github.js +381 -0
  5. package/dist/parsers/index.js +22 -3
  6. package/dist/public/assets/index-Coilyhtr.css +1 -0
  7. package/dist/public/assets/index-D0noVMFu.js +44 -0
  8. package/dist/public/index.html +2 -2
  9. package/dist/render/templates/aurora/portfolio.liquid +10 -22
  10. package/dist/render/templates/aurora/project.liquid +1 -1
  11. package/dist/render/templates/aurora/styles.css +6 -0
  12. package/dist/render/templates/bauhaus/portfolio.liquid +9 -19
  13. package/dist/render/templates/bauhaus/styles.css +4 -0
  14. package/dist/render/templates/blueprint/portfolio.liquid +10 -24
  15. package/dist/render/templates/blueprint/styles.css +4 -0
  16. package/dist/render/templates/canvas/portfolio.liquid +17 -29
  17. package/dist/render/templates/canvas/styles.css +4 -0
  18. package/dist/render/templates/carbon/portfolio.liquid +9 -19
  19. package/dist/render/templates/carbon/styles.css +6 -0
  20. package/dist/render/templates/chalk/portfolio.liquid +9 -19
  21. package/dist/render/templates/chalk/styles.css +4 -0
  22. package/dist/render/templates/circuit/portfolio.liquid +10 -20
  23. package/dist/render/templates/circuit/project.liquid +1 -1
  24. package/dist/render/templates/circuit/styles.css +6 -0
  25. package/dist/render/templates/cosmos/portfolio.liquid +10 -20
  26. package/dist/render/templates/cosmos/project.liquid +1 -1
  27. package/dist/render/templates/cosmos/styles.css +6 -0
  28. package/dist/render/templates/daylight/portfolio.liquid +10 -20
  29. package/dist/render/templates/daylight/project.liquid +1 -1
  30. package/dist/render/templates/daylight/styles.css +4 -0
  31. package/dist/render/templates/editorial/portfolio.liquid +11 -27
  32. package/dist/render/templates/editorial/styles.css +4 -0
  33. package/dist/render/templates/ember/portfolio.liquid +11 -23
  34. package/dist/render/templates/ember/project.liquid +1 -1
  35. package/dist/render/templates/ember/styles.css +6 -0
  36. package/dist/render/templates/glacier/portfolio.liquid +10 -20
  37. package/dist/render/templates/glacier/project.liquid +1 -1
  38. package/dist/render/templates/glacier/styles.css +4 -0
  39. package/dist/render/templates/grid/portfolio.liquid +9 -19
  40. package/dist/render/templates/grid/styles.css +4 -0
  41. package/dist/render/templates/kinetic/portfolio.liquid +10 -22
  42. package/dist/render/templates/kinetic/project.liquid +1 -1
  43. package/dist/render/templates/kinetic/styles.css +4 -0
  44. package/dist/render/templates/meridian/portfolio.liquid +11 -23
  45. package/dist/render/templates/meridian/styles.css +6 -0
  46. package/dist/render/templates/minimal/portfolio.liquid +10 -10
  47. package/dist/render/templates/minimal/styles.css +4 -0
  48. package/dist/render/templates/mono/portfolio.liquid +9 -19
  49. package/dist/render/templates/mono/styles.css +6 -0
  50. package/dist/render/templates/neon/portfolio.liquid +10 -20
  51. package/dist/render/templates/neon/project.liquid +1 -1
  52. package/dist/render/templates/neon/styles.css +6 -0
  53. package/dist/render/templates/noir/portfolio.liquid +5 -5
  54. package/dist/render/templates/noir/styles.css +6 -0
  55. package/dist/render/templates/obsidian/portfolio.liquid +9 -19
  56. package/dist/render/templates/obsidian/styles.css +6 -0
  57. package/dist/render/templates/paper/portfolio.liquid +9 -19
  58. package/dist/render/templates/paper/styles.css +4 -0
  59. package/dist/render/templates/parallax/portfolio.liquid +9 -19
  60. package/dist/render/templates/parallax/styles.css +6 -0
  61. package/dist/render/templates/parchment/portfolio.liquid +9 -19
  62. package/dist/render/templates/parchment/styles.css +4 -0
  63. package/dist/render/templates/radar/portfolio.liquid +9 -19
  64. package/dist/render/templates/radar/styles.css +6 -0
  65. package/dist/render/templates/showcase/portfolio.liquid +9 -19
  66. package/dist/render/templates/showcase/styles.css +5 -0
  67. package/dist/render/templates/signal/portfolio.liquid +9 -19
  68. package/dist/render/templates/signal/styles.css +6 -0
  69. package/dist/render/templates/strata/portfolio.liquid +10 -22
  70. package/dist/render/templates/strata/styles.css +4 -0
  71. package/dist/render/templates/terminal/portfolio.liquid +10 -26
  72. package/dist/render/templates/terminal/styles.css +5 -0
  73. package/dist/render/templates/verdant/portfolio.liquid +11 -23
  74. package/dist/render/templates/verdant/project.liquid +1 -1
  75. package/dist/render/templates/verdant/styles.css +4 -0
  76. package/dist/render/templates/zen/portfolio.liquid +10 -22
  77. package/dist/render/templates/zen/styles.css +4 -0
  78. package/dist/routes/auth.js +7 -3
  79. package/dist/routes/context.js +2 -0
  80. package/dist/routes/delete.js +195 -0
  81. package/dist/routes/enhance.js +40 -0
  82. package/dist/routes/github.js +254 -0
  83. package/dist/routes/index.js +2 -0
  84. package/dist/routes/portfolio-render-data.js +160 -0
  85. package/dist/routes/preview.js +85 -10
  86. package/dist/routes/projects.js +50 -5
  87. package/dist/routes/publish.js +306 -15
  88. package/dist/routes/settings.js +102 -2
  89. package/dist/search.js +6 -0
  90. package/dist/server.js +3 -1
  91. package/dist/settings.js +95 -0
  92. package/package.json +2 -1
  93. package/dist/public/assets/index-BZ65TU_Y.js +0 -40
  94. package/dist/public/assets/index-CqCaW2cb.css +0 -1
@@ -11,56 +11,40 @@
11
11
  {% if user.photoUrl != blank %}
12
12
  <div class="term-photo">
13
13
  <div class="term-photo__border">
14
- <img src="{{ user.photoUrl }}" alt="{{ user.displayName }}" class="term-photo__img portfolio-photo" loading="lazy" />
14
+ <img src="{{ user.photoUrl }}" alt="{{ user.displayName }}" class="term-photo__img portfolio-photo" loading="lazy" / data-portfolio-field="photoBase64"{% unless user.photoUrl %} data-portfolio-empty="true"{% endunless %}>
15
15
  </div>
16
16
  </div>
17
17
  {% endif %}
18
18
  <div class="term-identity">
19
19
  {% if user.displayName != blank %}
20
- <h1 class="term-heading"># {{ user.displayName }}</h1>
20
+ <h1 class="term-heading" data-portfolio-field="displayName"># {{ user.displayName }}</h1>
21
21
  {% endif %}
22
22
  {% if user.status != blank %}
23
23
  <div class="term-status">[ {{ user.status }} ]</div>
24
24
  {% endif %}
25
25
  {% if user.bio != blank %}
26
- <blockquote class="term-blockquote">&gt; {{ user.bio }}</blockquote>
26
+ <blockquote class="term-blockquote" data-portfolio-field="bio">&gt; {{ user.bio }}</blockquote>
27
27
  {% endif %}
28
28
  </div>
29
29
  </div>
30
30
 
31
31
  {%- comment -%} Contact as key=value pairs {%- endcomment -%}
32
- {% if user.location != blank or user.email != blank or user.phone != blank or user.githubUrl != blank or user.linkedinUrl != blank or user.twitterHandle != blank or user.websiteUrl != blank or user.resumeUrl != blank %}
33
32
  <div class="term-section">
34
33
  <div class="term-comment"># contact</div>
35
34
  <table class="term-table term-table--contact">
36
- {% if user.location != blank %}
37
- <tr><td class="term-table__key">location</td><td class="term-table__eq">=</td><td class="term-table__val">{{ user.location }}</td></tr>
38
- {% endif %}
39
- {% if user.email != blank %}
40
- <tr><td class="term-table__key">email</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="mailto:{{ user.email }}">{{ user.email }}</a></td></tr>
41
- {% endif %}
42
- {% if user.phone != blank %}
43
- <tr><td class="term-table__key">phone</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="tel:{{ user.phone }}">{{ user.phone }}</a></td></tr>
44
- {% endif %}
45
- {% if user.githubUrl != blank %}
46
- <tr><td class="term-table__key">github</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{{ user.githubUrl }}" target="_blank" rel="noopener">{{ user.githubUrl | stripProtocol }}</a></td></tr>
47
- {% endif %}
48
- {% if user.linkedinUrl != blank %}
49
- <tr><td class="term-table__key">linkedin</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{{ user.linkedinUrl }}" target="_blank" rel="noopener">{{ user.linkedinUrl | stripProtocol }}</a></td></tr>
50
- {% endif %}
51
- {% if user.twitterHandle != blank %}
52
- <tr><td class="term-table__key">twitter</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="https://twitter.com/{{ user.twitterHandle }}" target="_blank" rel="noopener">@{{ user.twitterHandle }}</a></td></tr>
53
- {% endif %}
54
- {% if user.websiteUrl != blank %}
55
- <tr><td class="term-table__key">web</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{{ user.websiteUrl }}" target="_blank" rel="noopener">{{ user.websiteUrl | stripProtocol }}</a></td></tr>
56
- {% endif %}
35
+ <tr data-portfolio-field="location"{% unless user.location %} data-portfolio-empty="true"{% endunless %}><td class="term-table__key">location</td><td class="term-table__eq">=</td><td class="term-table__val">{{ user.location }}</td></tr>
36
+ <tr data-portfolio-field="email"{% unless user.email %} data-portfolio-empty="true"{% endunless %}><td class="term-table__key">email</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{% if user.email %}mailto:{{ user.email }}{% endif %}"><span data-portfolio-text>{{ user.email }}</span></a></td></tr>
37
+ <tr data-portfolio-field="phone"{% unless user.phone %} data-portfolio-empty="true"{% endunless %}><td class="term-table__key">phone</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{% if user.phone %}tel:{{ user.phone }}{% endif %}"><span data-portfolio-text>{{ user.phone }}</span></a></td></tr>
38
+ <tr data-portfolio-field="githubUrl"{% unless user.githubUrl %} data-portfolio-empty="true"{% endunless %}><td class="term-table__key">github</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{% if user.githubUrl %}{{ user.githubUrl }}{% endif %}" target="_blank" rel="noopener">{{ user.githubUrl | stripProtocol }}</a></td></tr>
39
+ <tr data-portfolio-field="linkedinUrl"{% unless user.linkedinUrl %} data-portfolio-empty="true"{% endunless %}><td class="term-table__key">linkedin</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{% if user.linkedinUrl %}{{ user.linkedinUrl }}{% endif %}" target="_blank" rel="noopener">LinkedIn</a></td></tr>
40
+ <tr data-portfolio-field="twitterHandle"{% unless user.twitterHandle %} data-portfolio-empty="true"{% endunless %}><td class="term-table__key">twitter</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{% if user.twitterHandle %}https://x.com/{{ user.twitterHandle }}{% endif %}" target="_blank" rel="noopener"><span data-portfolio-text>{% if user.twitterHandle %}@{{ user.twitterHandle }}{% endif %}</span></a></td></tr>
41
+ <tr data-portfolio-field="websiteUrl"{% unless user.websiteUrl %} data-portfolio-empty="true"{% endunless %}><td class="term-table__key">web</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{% if user.websiteUrl %}{{ user.websiteUrl }}{% endif %}" target="_blank" rel="noopener"><span data-portfolio-text>{{ user.websiteUrl | stripProtocol }}</span></a></td></tr>
57
42
  {% if user.resumeUrl != blank %}
58
43
  <tr><td class="term-table__key">resume</td><td class="term-table__eq">=</td><td class="term-table__val"><a href="{{ user.resumeUrl }}" target="_blank" rel="noopener" download>Resume (PDF)</a></td></tr>
59
44
  {% endif %}
60
45
  </table>
61
46
  </div>
62
47
  {% endif %}
63
- {% endif %}
64
48
 
65
49
  {%- comment -%} Stats as tabular output {%- endcomment -%}
66
50
  <div class="term-section">
@@ -1,3 +1,4 @@
1
+ body { background: var(--term-bg); color: var(--term-text); }
1
2
  /* ── Terminal template styles ──
2
3
  * Green-on-black terminal aesthetic.
3
4
  * Monospace font, ASCII elements, command-line feel.
@@ -490,3 +491,7 @@
490
491
  flex-wrap: wrap;
491
492
  }
492
493
  }
494
+
495
+
496
+ /* Live-edit empty field hiding */
497
+ [data-portfolio-empty="true"] { display: none; }
@@ -8,57 +8,45 @@
8
8
  {%- comment -%} Hero / Profile {%- endcomment -%}
9
9
  <section class="vd-section vd-hero" aria-label="Profile">
10
10
  {% if user.photoUrl %}
11
- <img src="{{ user.photoUrl }}" alt="{{ user.displayName }}" class="vd-hero__photo" width="120" height="120">
11
+ <img src="{{ user.photoUrl }}" alt="{{ user.displayName }}" class="vd-hero__photo" width="120" height="120" data-portfolio-field="photoBase64"{% unless user.photoUrl %} data-portfolio-empty="true"{% endunless %}>
12
12
  {% elsif user.displayName != blank %}
13
13
  <div class="vd-hero__avatar" aria-hidden="true">{{ user.displayName | slice: 0 }}</div>
14
14
  {% endif %}
15
15
  {% if user.displayName != blank %}
16
- <h1>{{ user.displayName }}</h1>
16
+ <h1 data-portfolio-field="displayName">{{ user.displayName }}</h1>
17
17
  {% endif %}
18
18
  {% if user.bio != blank %}
19
- <p class="vd-hero__bio">{{ user.bio }}</p>
19
+ <p class="vd-hero__bio" data-portfolio-field="bio">{{ user.bio }}</p>
20
20
  {% endif %}
21
21
  {% if user.location != blank %}
22
22
  <p class="vd-hero__location">
23
23
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
24
- {{ user.location }}
24
+ <span data-portfolio-field="location">{{ user.location }}</span>
25
25
  </p>
26
26
  {% endif %}
27
27
  {% if user.email or user.linkedinUrl or user.githubUrl or user.twitterHandle or user.websiteUrl or user.resumeUrl %}
28
28
  <div class="vd-hero__links">
29
- {% if user.email %}
30
- <a href="mailto:{{ user.email }}">
29
+ <a href="{% if user.email %}mailto:{{ user.email }}{% endif %}" data-portfolio-field="email"{% unless user.email %} data-portfolio-empty="true"{% endunless %}>
31
30
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 4 10 8 10-8"/></svg>
32
31
  {{ user.email }}
33
32
  </a>
34
- {% endif %}
35
- {% if user.phone %}
36
- <a href="tel:{{ user.phone }}">
33
+ <a href="{% if user.phone %}tel:{{ user.phone }}{% endif %}" data-portfolio-field="phone"{% unless user.phone %} data-portfolio-empty="true"{% endunless %}>
37
34
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"/></svg>
38
35
  {{ user.phone }}
39
36
  </a>
40
- {% endif %}
41
- {% if user.linkedinUrl %}
42
- <a href="{{ user.linkedinUrl }}">
37
+ <a href="{% if user.linkedinUrl %}{{ user.linkedinUrl }}{% endif %}" data-portfolio-field="linkedinUrl"{% unless user.linkedinUrl %} data-portfolio-empty="true"{% endunless %}>
43
38
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"/></svg>
44
39
  LinkedIn
45
40
  </a>
46
- {% endif %}
47
- {% if user.githubUrl %}
48
- <a href="{{ user.githubUrl }}">
41
+ <a href="{% if user.githubUrl %}{{ user.githubUrl }}{% endif %}" data-portfolio-field="githubUrl"{% unless user.githubUrl %} data-portfolio-empty="true"{% endunless %}>
49
42
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg>
50
43
  GitHub
51
44
  </a>
52
- {% endif %}
53
- {% if user.twitterHandle %}
54
- <a href="https://x.com/{{ user.twitterHandle }}">
45
+ <a href="{% if user.twitterHandle %}https://x.com/{{ user.twitterHandle }}{% endif %}" data-portfolio-field="twitterHandle"{% unless user.twitterHandle %} data-portfolio-empty="true"{% endunless %}>
55
46
  <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
56
47
  @{{ user.twitterHandle }}
57
48
  </a>
58
- {% endif %}
59
- {% if user.websiteUrl %}
60
- <a href="{{ user.websiteUrl }}">{{ user.websiteUrl | stripProtocol }}</a>
61
- {% endif %}
49
+ <a href="{% if user.websiteUrl %}{{ user.websiteUrl }}{% endif %}" data-portfolio-field="websiteUrl"{% unless user.websiteUrl %} data-portfolio-empty="true"{% endunless %}>{{ user.websiteUrl | stripProtocol }}</a>
62
50
  {% if user.resumeUrl %}
63
51
  <a href="{{ user.resumeUrl }}">
64
52
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><polyline points="9 15 12 18 15 15"/></svg>
@@ -94,7 +82,7 @@
94
82
  <div class="vd-stat-card">
95
83
  {% assign totalCombined = totalDurationMinutes | plus: totalAgentDurationMinutes %}
96
84
  {% if totalCombined > 0 %}
97
- {% assign humanPct = totalDurationMinutes | times: 100 | divided_by: totalCombined %}
85
+ {% assign humanPct = totalDurationMinutes | times: 100 | divided_by: totalCombined | round %}
98
86
  {% assign agentPct = 100 | minus: humanPct %}
99
87
  <div style="display: flex; align-items: center; gap: 4px; margin-bottom: 6px;">
100
88
  <div style="flex: {{ humanPct }}; height: 6px; border-radius: 3px; background: rgba(0,0,0,0.12);"></div>
@@ -96,7 +96,7 @@
96
96
  <div class="vd-stat-card">
97
97
  {% assign totalCombined = project.totalDurationMinutes | plus: project.totalAgentDurationMinutes %}
98
98
  {% if totalCombined > 0 %}
99
- {% assign humanPct = project.totalDurationMinutes | times: 100 | divided_by: totalCombined %}
99
+ {% assign humanPct = project.totalDurationMinutes | times: 100 | divided_by: totalCombined | round %}
100
100
  {% assign agentPct = 100 | minus: humanPct %}
101
101
  <div style="display: flex; align-items: center; gap: 4px; margin-bottom: 6px;">
102
102
  <div style="flex: {{ humanPct }}; height: 6px; border-radius: 3px; background: rgba(0,0,0,0.12);"></div>
@@ -1255,3 +1255,7 @@
1255
1255
  .verdant .vd-nav__links { gap: 1rem; }
1256
1256
  .verdant .vd-footer { flex-direction: column; gap: 0.5rem; text-align: center; }
1257
1257
  }
1258
+
1259
+
1260
+ /* Live-edit empty field hiding */
1261
+ [data-portfolio-empty="true"] { display: none; }
@@ -7,38 +7,26 @@
7
7
  <section class="zen-section zen-fade" aria-label="Profile">
8
8
  <div class="zen-header-profile">
9
9
  {% if user.photoUrl %}
10
- <img src="{{ user.photoUrl }}" alt="{{ user.displayName }}" class="zen-header-photo">
10
+ <img src="{{ user.photoUrl }}" alt="{{ user.displayName }}" class="zen-header-photo" data-portfolio-field="photoBase64"{% unless user.photoUrl %} data-portfolio-empty="true"{% endunless %}>
11
11
  {% endif %}
12
12
  <div class="zen-header-info">
13
13
  {% if user.displayName != blank %}
14
- <h1 class="zen-display zen-header-name">{{ user.displayName }}</h1>
14
+ <h1 class="zen-display zen-header-name" data-portfolio-field="displayName">{{ user.displayName }}</h1>
15
15
  {% endif %}
16
16
  {% if user.bio != blank %}
17
- <p class="zen-header-bio">{{ user.bio }}</p>
17
+ <p class="zen-header-bio" data-portfolio-field="bio">{{ user.bio }}</p>
18
18
  {% endif %}
19
19
  {% if user.location != blank %}
20
- <p class="zen-header-location">{{ user.location }}</p>
20
+ <p class="zen-header-location" data-portfolio-field="location">{{ user.location }}</p>
21
21
  {% endif %}
22
22
  {% if user.email != blank or user.linkedinUrl != blank or user.githubUrl != blank or user.twitterHandle != blank or user.websiteUrl != blank %}
23
23
  <ul class="zen-header-contact" aria-label="Contact and social links">
24
- {% if user.email != blank %}
25
- <li><a href="mailto:{{ user.email }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 4 10 8 10-8"/></svg>{{ user.email }}</a></li>
26
- {% endif %}
27
- {% if user.phone != blank %}
28
- <li><a href="tel:{{ user.phone }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>{{ user.phone }}</a></li>
29
- {% endif %}
30
- {% if user.linkedinUrl != blank %}
31
- <li><a href="{{ user.linkedinUrl }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"/></svg>LinkedIn</a></li>
32
- {% endif %}
33
- {% if user.githubUrl != blank %}
34
- <li><a href="{{ user.githubUrl }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg>GitHub</a></li>
35
- {% endif %}
36
- {% if user.twitterHandle != blank %}
37
- <li><a href="https://x.com/{{ user.twitterHandle }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>@{{ user.twitterHandle }}</a></li>
38
- {% endif %}
39
- {% if user.websiteUrl != blank %}
40
- <li><a href="{{ user.websiteUrl }}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>{{ user.websiteUrl | stripProtocol }}</a></li>
41
- {% endif %}
24
+ <li data-portfolio-field="email"{% unless user.email %} data-portfolio-empty="true"{% endunless %}><a href="{% if user.email %}mailto:{{ user.email }}{% endif %}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 4 10 8 10-8"/></svg>{{ user.email }}</a></li>
25
+ <li data-portfolio-field="phone"{% unless user.phone %} data-portfolio-empty="true"{% endunless %}><a href="{% if user.phone %}tel:{{ user.phone }}{% endif %}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>{{ user.phone }}</a></li>
26
+ <li data-portfolio-field="linkedinUrl"{% unless user.linkedinUrl %} data-portfolio-empty="true"{% endunless %}><a href="{% if user.linkedinUrl %}{{ user.linkedinUrl }}{% endif %}"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"/></svg>LinkedIn</a></li>
27
+ <li data-portfolio-field="githubUrl"{% unless user.githubUrl %} data-portfolio-empty="true"{% endunless %}><a href="{% if user.githubUrl %}{{ user.githubUrl }}{% endif %}"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/></svg>GitHub</a></li>
28
+ <li data-portfolio-field="twitterHandle"{% unless user.twitterHandle %} data-portfolio-empty="true"{% endunless %}><a href="{% if user.twitterHandle %}https://x.com/{{ user.twitterHandle }}{% endif %}"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>@{{ user.twitterHandle }}</a></li>
29
+ <li data-portfolio-field="websiteUrl"{% unless user.websiteUrl %} data-portfolio-empty="true"{% endunless %}><a href="{% if user.websiteUrl %}{{ user.websiteUrl }}{% endif %}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>{{ user.websiteUrl | stripProtocol }}</a></li>
42
30
  </ul>
43
31
  {% endif %}
44
32
  {% if user.resumeUrl != blank %}
@@ -1205,3 +1205,7 @@ a:active {
1205
1205
  border-block-start-color: #ccc;
1206
1206
  }
1207
1207
  }
1208
+
1209
+
1210
+ /* Live-edit empty field hiding */
1211
+ [data-portfolio-empty="true"] { display: none; }
@@ -1,5 +1,5 @@
1
1
  import { Router } from 'express';
2
- import { checkAuthStatus, saveAuthToken, deleteAuthToken } from '../auth.js';
2
+ import { checkAuthStatus, saveAuthToken, deleteAuthToken, normalizeUsername } from '../auth.js';
3
3
  import { API_URL } from '../config.js';
4
4
  export function createAuthRouter(_ctx) {
5
5
  const router = Router();
@@ -100,8 +100,12 @@ export function createAuthRouter(_ctx) {
100
100
  });
101
101
  const data = await response.json();
102
102
  if (response.ok && data.access_token) {
103
- saveAuthToken(data.access_token, data.username);
104
- res.json({ authenticated: true, username: data.username });
103
+ // Always persist and echo the lowercase form so downstream URL
104
+ // construction and UI display stay consistent with Phoenix's
105
+ // lowercase-only DB constraint.
106
+ const username = normalizeUsername(String(data.username ?? ''));
107
+ saveAuthToken(data.access_token, username);
108
+ res.json({ authenticated: true, username });
105
109
  }
106
110
  else {
107
111
  res.status(response.status).json(data);
@@ -427,6 +427,7 @@ export function createRouteContext(sessionsBasePath, dbPath) {
427
427
  uploadedSessionCount: published?.uploadedSessions?.length ?? 0,
428
428
  uploadedSessions: published?.uploadedSessions ?? [],
429
429
  enhancedAt: enhanceCache?.enhancedAt ?? null,
430
+ enhancedSessionCount: enhanceCache?.selectedSessionIds?.length ?? 0,
430
431
  totalAgentDuration: agentRow.total,
431
432
  totalInputTokens: dbStats.totalInputTokens,
432
433
  totalOutputTokens: dbStats.totalOutputTokens,
@@ -469,6 +470,7 @@ export function createRouteContext(sessionsBasePath, dbPath) {
469
470
  uploadedSessionCount: published?.uploadedSessions?.length ?? 0,
470
471
  uploadedSessions: published?.uploadedSessions ?? [],
471
472
  enhancedAt: enhanceCache?.enhancedAt ?? null,
473
+ enhancedSessionCount: enhanceCache?.selectedSessionIds?.length ?? 0,
472
474
  totalAgentDuration,
473
475
  totalInputTokens: 0,
474
476
  totalOutputTokens: 0,
@@ -0,0 +1,195 @@
1
+ import { Router } from 'express';
2
+ import { getAuthToken } from '../auth.js';
3
+ import { API_URL, warnIfNonDefaultApiUrl } from '../config.js';
4
+ import { getUploadedState, clearUploadedState, saveUploadedState, loadEnhancedData, saveEnhancedData, } from '../settings.js';
5
+ function sendError(res, status, error) {
6
+ res.status(status).json({ error });
7
+ }
8
+ function validatePathParam(value, field) {
9
+ if (typeof value !== 'string') {
10
+ return { ok: false, error: { code: 'INVALID_PARAM', message: `${field} is required` } };
11
+ }
12
+ const trimmed = value.trim();
13
+ if (trimmed.length === 0) {
14
+ return { ok: false, error: { code: 'INVALID_PARAM', message: `${field} is required` } };
15
+ }
16
+ if (trimmed.length > 200) {
17
+ return { ok: false, error: { code: 'INVALID_PARAM', message: `${field} exceeds 200 characters` } };
18
+ }
19
+ return { ok: true, value };
20
+ }
21
+ export function createDeleteRouter(_ctx) {
22
+ const router = Router();
23
+ /**
24
+ * DELETE /api/projects/:project/remote
25
+ *
26
+ * Removes the project (and all its sessions, per Phoenix contract) from
27
+ * heyi.am. Does NOT touch local archived session data — the user may be
28
+ * mid-edit. Clears the local uploaded-state record so the UI re-reflects
29
+ * "Local only" after the round-trip.
30
+ *
31
+ * :project is the CLI-side directory name, NOT the published slug. We
32
+ * resolve the published slug from local uploaded state — if the user
33
+ * never published from this machine we have no slug to delete against.
34
+ */
35
+ router.delete('/api/projects/:project/remote', async (req, res) => {
36
+ const projectResult = validatePathParam(req.params.project, 'project');
37
+ if (!projectResult.ok) {
38
+ sendError(res, 400, projectResult.error);
39
+ return;
40
+ }
41
+ const project = projectResult.value;
42
+ const auth = getAuthToken();
43
+ warnIfNonDefaultApiUrl();
44
+ if (!auth) {
45
+ sendError(res, 401, { code: 'UNAUTHENTICATED', message: 'Authentication required' });
46
+ return;
47
+ }
48
+ const uploaded = getUploadedState(project);
49
+ if (!uploaded?.slug) {
50
+ sendError(res, 404, {
51
+ code: 'NOT_PUBLISHED',
52
+ message: 'This project has no remote copy to delete',
53
+ });
54
+ return;
55
+ }
56
+ try {
57
+ const phoenixRes = await fetch(`${API_URL}/api/projects/${encodeURIComponent(uploaded.slug)}`, {
58
+ method: 'DELETE',
59
+ headers: { Authorization: `Bearer ${auth.token}` },
60
+ });
61
+ if (phoenixRes.status === 204) {
62
+ // Strip local uploaded state + session 'uploaded' flags so UI
63
+ // shows the correct status. Failure to clear local flags is
64
+ // non-fatal (the remote copy is already gone).
65
+ try {
66
+ clearUploadedState(project);
67
+ for (const sessionId of uploaded.uploadedSessions ?? []) {
68
+ const enhanced = loadEnhancedData(sessionId);
69
+ if (enhanced?.uploaded) {
70
+ saveEnhancedData(sessionId, { ...enhanced, uploaded: false });
71
+ }
72
+ }
73
+ }
74
+ catch (cleanupErr) {
75
+ console.warn('[delete-project] local cleanup failed:', cleanupErr.message);
76
+ }
77
+ res.json({ ok: true });
78
+ return;
79
+ }
80
+ if (phoenixRes.status === 404) {
81
+ // Remote already gone — still clear local state so UI
82
+ // re-renders as "Local only". Surface 404 so UI can inform
83
+ // the user the remote copy was already missing.
84
+ try {
85
+ clearUploadedState(project);
86
+ }
87
+ catch { /* best effort */ }
88
+ sendError(res, 404, {
89
+ code: 'NOT_FOUND',
90
+ message: 'Project not found on heyi.am (already deleted?)',
91
+ });
92
+ return;
93
+ }
94
+ if (phoenixRes.status === 401 || phoenixRes.status === 403) {
95
+ sendError(res, phoenixRes.status, {
96
+ code: 'UNAUTHORIZED',
97
+ message: 'Not authorized to delete this project',
98
+ });
99
+ return;
100
+ }
101
+ const status = phoenixRes.status >= 500 ? 502 : phoenixRes.status;
102
+ sendError(res, status, {
103
+ code: 'DELETE_FAILED',
104
+ message: `Remote delete failed (HTTP ${phoenixRes.status})`,
105
+ });
106
+ }
107
+ catch (err) {
108
+ const message = err.message;
109
+ console.error('[delete-project] Error:', message);
110
+ sendError(res, 502, { code: 'DELETE_FAILED', message });
111
+ }
112
+ });
113
+ /**
114
+ * DELETE /api/projects/:project/sessions/:sessionId/remote
115
+ *
116
+ * Removes a single session from heyi.am. Local archive is untouched.
117
+ * Updates the local uploaded-state record so the session no longer
118
+ * appears in the "uploaded" set. Leaves the project uploaded-state
119
+ * shell in place when this was the last session — per spec, the user
120
+ * may be mid-edit.
121
+ */
122
+ router.delete('/api/projects/:project/sessions/:sessionId/remote', async (req, res) => {
123
+ const projectResult = validatePathParam(req.params.project, 'project');
124
+ if (!projectResult.ok) {
125
+ sendError(res, 400, projectResult.error);
126
+ return;
127
+ }
128
+ const sessionResult = validatePathParam(req.params.sessionId, 'sessionId');
129
+ if (!sessionResult.ok) {
130
+ sendError(res, 400, sessionResult.error);
131
+ return;
132
+ }
133
+ const project = projectResult.value;
134
+ const sessionId = sessionResult.value;
135
+ const auth = getAuthToken();
136
+ warnIfNonDefaultApiUrl();
137
+ if (!auth) {
138
+ sendError(res, 401, { code: 'UNAUTHENTICATED', message: 'Authentication required' });
139
+ return;
140
+ }
141
+ try {
142
+ const phoenixRes = await fetch(`${API_URL}/api/sessions/${encodeURIComponent(sessionId)}`, {
143
+ method: 'DELETE',
144
+ headers: { Authorization: `Bearer ${auth.token}` },
145
+ });
146
+ if (phoenixRes.status === 204) {
147
+ try {
148
+ const uploaded = getUploadedState(project);
149
+ if (uploaded) {
150
+ const remaining = (uploaded.uploadedSessions ?? []).filter((id) => id !== sessionId);
151
+ saveUploadedState(project, {
152
+ slug: uploaded.slug,
153
+ projectId: uploaded.projectId,
154
+ uploadedSessions: remaining,
155
+ });
156
+ }
157
+ const enhanced = loadEnhancedData(sessionId);
158
+ if (enhanced?.uploaded) {
159
+ saveEnhancedData(sessionId, { ...enhanced, uploaded: false });
160
+ }
161
+ }
162
+ catch (cleanupErr) {
163
+ console.warn('[delete-session] local cleanup failed:', cleanupErr.message);
164
+ }
165
+ res.json({ ok: true });
166
+ return;
167
+ }
168
+ if (phoenixRes.status === 404) {
169
+ sendError(res, 404, {
170
+ code: 'NOT_FOUND',
171
+ message: 'Session not found on heyi.am (already deleted?)',
172
+ });
173
+ return;
174
+ }
175
+ if (phoenixRes.status === 401 || phoenixRes.status === 403) {
176
+ sendError(res, phoenixRes.status, {
177
+ code: 'UNAUTHORIZED',
178
+ message: 'Not authorized to delete this session',
179
+ });
180
+ return;
181
+ }
182
+ const status = phoenixRes.status >= 500 ? 502 : phoenixRes.status;
183
+ sendError(res, status, {
184
+ code: 'DELETE_FAILED',
185
+ message: `Remote delete failed (HTTP ${phoenixRes.status})`,
186
+ });
187
+ }
188
+ catch (err) {
189
+ const message = err.message;
190
+ console.error('[delete-session] Error:', message);
191
+ sendError(res, 502, { code: 'DELETE_FAILED', message });
192
+ }
193
+ });
194
+ return router;
195
+ }
@@ -5,6 +5,7 @@ import { enhanceProject, refineNarrative } from '../llm/project-enhance.js';
5
5
  import { getAnthropicApiKey, saveEnhancedData, loadEnhancedData, deleteEnhancedData, loadFreshProjectEnhanceResult, saveProjectEnhanceResult, loadProjectEnhanceResult, buildProjectFingerprint, getUploadedState, } from '../settings.js';
6
6
  import { requireProject } from './context.js';
7
7
  import { startSSE } from './sse.js';
8
+ import { invalidatePortfolioPreviewCache } from './preview.js';
8
9
  export function createEnhanceRouter(ctx) {
9
10
  const router = Router();
10
11
  // Triage endpoint -- AI selects which sessions are worth showcasing (SSE stream)
@@ -81,6 +82,43 @@ export function createEnhanceRouter(ctx) {
81
82
  });
82
83
  }
83
84
  });
85
+ // Update locally-saved enhanced data (partial merge)
86
+ router.patch('/api/sessions/:id/enhanced', (req, res) => {
87
+ const { id } = req.params;
88
+ const existing = loadEnhancedData(id);
89
+ if (!existing) {
90
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'No enhanced data for this session' } });
91
+ return;
92
+ }
93
+ const { title, developerTake, skills, qaPairs, executionSteps } = req.body;
94
+ if (title !== undefined && (typeof title !== 'string' || title.length === 0 || title.length > 200)) {
95
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'title must be 1-200 characters' } });
96
+ return;
97
+ }
98
+ if (developerTake !== undefined && (typeof developerTake !== 'string' || developerTake.length > 2000)) {
99
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'developerTake must be under 2000 characters' } });
100
+ return;
101
+ }
102
+ if (skills !== undefined && (!Array.isArray(skills) || !skills.every((s) => typeof s === 'string'))) {
103
+ res.status(400).json({ error: { code: 'INVALID_INPUT', message: 'skills must be an array of strings' } });
104
+ return;
105
+ }
106
+ const merged = {
107
+ ...existing,
108
+ ...(title !== undefined ? { title } : {}),
109
+ ...(developerTake !== undefined ? { developerTake } : {}),
110
+ ...(skills !== undefined ? { skills } : {}),
111
+ ...(qaPairs !== undefined ? { qaPairs } : {}),
112
+ ...(executionSteps !== undefined ? { executionSteps } : {}),
113
+ };
114
+ // Strip runtime-only fields before saving — saveEnhancedData re-adds enhancedAt
115
+ const { enhancedAt: _ea, quickEnhanced: qe, ...rest } = merged;
116
+ saveEnhancedData(id, { ...rest, quickEnhanced: qe });
117
+ invalidatePortfolioPreviewCache();
118
+ console.log(`[enhance] Updated enhanced data for ${id}`);
119
+ const updated = loadEnhancedData(id);
120
+ res.json({ ok: true, enhancedAt: updated?.enhancedAt });
121
+ });
84
122
  // Delete locally-saved enhanced data
85
123
  router.delete('/api/sessions/:id/enhanced', (_req, res) => {
86
124
  const { id } = _req.params;
@@ -235,6 +273,8 @@ export function createEnhanceRouter(ctx) {
235
273
  if (!proj)
236
274
  return;
237
275
  saveProjectEnhanceResult(proj.dirName, selectedSessionIds, result, undefined, { title, repoUrl, projectUrl, screenshotBase64 });
276
+ // Project title/narrative/skills appear in portfolio listing — bust cache.
277
+ invalidatePortfolioPreviewCache();
238
278
  res.json({ saved: true, enhancedAt: new Date().toISOString() });
239
279
  }
240
280
  catch (err) {