| 1 |
<div class="tab-docs"><a href="/docs/profile">Docs: Profile →</a></div> |
| 2 |
|
| 3 |
<div class="form-section"> |
| 4 |
<h2 class="subsection-title">Links</h2> |
| 5 |
<p class="muted mb-4">Add links to your public profile.</p> |
| 6 |
|
| 7 |
<div id="links-list"> |
| 8 |
{% for link in custom_links %} |
| 9 |
<div class="link-row" data-id="{{ link.id }}"> |
| 10 |
<div class="link-order-buttons"> |
| 11 |
<button type="button" class="order-btn" onclick="moveLink(this, -1)" title="Move up">▲</button> |
| 12 |
<button type="button" class="order-btn" onclick="moveLink(this, 1)" title="Move down">▼</button> |
| 13 |
</div> |
| 14 |
<input type="text" value="{{ link.title }}" placeholder="Title" name="title" disabled> |
| 15 |
<input type="url" value="{{ link.url }}" placeholder="https://..." name="url" disabled> |
| 16 |
<button class="btn-secondary link-edit-btn" onclick="editLink(this)">Edit</button> |
| 17 |
<button class="btn-primary link-save-btn hidden" onclick="saveLink(this)">Save</button> |
| 18 |
<button class="btn-secondary link-cancel-btn hidden" onclick="cancelLink(this)">Cancel</button> |
| 19 |
<button class="btn-danger link-remove-btn" |
| 20 |
hx-delete="/api/links/{{ link.id }}" |
| 21 |
hx-target="closest .link-row" |
| 22 |
hx-swap="outerHTML" |
| 23 |
hx-confirm="Remove this link?">Remove</button> |
| 24 |
</div> |
| 25 |
{% endfor %} |
| 26 |
</div> |
| 27 |
|
| 28 |
<script> |
| 29 |
function moveLink(btn, direction) { |
| 30 |
const row = btn.closest('.link-row'); |
| 31 |
const list = document.getElementById('links-list'); |
| 32 |
const rows = Array.from(list.querySelectorAll('.link-row')); |
| 33 |
const index = rows.indexOf(row); |
| 34 |
|
| 35 |
if (direction === -1 && index > 0) { |
| 36 |
list.insertBefore(row, rows[index - 1]); |
| 37 |
} else if (direction === 1 && index < rows.length - 1) { |
| 38 |
list.insertBefore(rows[index + 1], row); |
| 39 |
} else { |
| 40 |
return; |
| 41 |
} |
| 42 |
|
| 43 |
|
| 44 |
const linkIds = Array.from(list.querySelectorAll('.link-row')).map(r => r.dataset.id); |
| 45 |
fetch('/api/links/reorder', { |
| 46 |
method: 'PUT', |
| 47 |
headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, |
| 48 |
body: JSON.stringify({ link_ids: linkIds }) |
| 49 |
}).catch(function() {}); |
| 50 |
} |
| 51 |
|
| 52 |
function editLink(btn) { |
| 53 |
var row = btn.closest('.link-row'); |
| 54 |
row.querySelectorAll('input').forEach(function(i) { i.disabled = false; }); |
| 55 |
row.querySelector('.link-edit-btn').classList.add('hidden'); |
| 56 |
row.querySelector('.link-remove-btn').classList.add('hidden'); |
| 57 |
row.querySelector('.link-save-btn').classList.remove('hidden'); |
| 58 |
row.querySelector('.link-cancel-btn').classList.remove('hidden'); |
| 59 |
row.dataset.origTitle = row.querySelector('[name=title]').value; |
| 60 |
row.dataset.origUrl = row.querySelector('[name=url]').value; |
| 61 |
} |
| 62 |
|
| 63 |
function cancelLink(btn) { |
| 64 |
var row = btn.closest('.link-row'); |
| 65 |
row.querySelector('[name=title]').value = row.dataset.origTitle; |
| 66 |
row.querySelector('[name=url]').value = row.dataset.origUrl; |
| 67 |
row.querySelectorAll('input').forEach(function(i) { i.disabled = true; }); |
| 68 |
row.querySelector('.link-edit-btn').classList.remove('hidden'); |
| 69 |
row.querySelector('.link-remove-btn').classList.remove('hidden'); |
| 70 |
row.querySelector('.link-save-btn').classList.add('hidden'); |
| 71 |
row.querySelector('.link-cancel-btn').classList.add('hidden'); |
| 72 |
} |
| 73 |
|
| 74 |
function saveLink(btn) { |
| 75 |
var row = btn.closest('.link-row'); |
| 76 |
var id = row.dataset.id; |
| 77 |
fetch('/api/links/' + id, { |
| 78 |
method: 'PUT', |
| 79 |
headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, |
| 80 |
body: JSON.stringify({ |
| 81 |
title: row.querySelector('[name=title]').value, |
| 82 |
url: row.querySelector('[name=url]').value |
| 83 |
}) |
| 84 |
}).then(function(res) { |
| 85 |
if (!res.ok) throw new Error('Failed to save'); |
| 86 |
cancelLink(btn); |
| 87 |
}).catch(function() { |
| 88 |
var saveBtn = row.querySelector('.link-save-btn'); |
| 89 |
if (saveBtn) saveBtn.textContent = 'Save failed: retry'; |
| 90 |
}); |
| 91 |
} |
| 92 |
</script> |
| 93 |
|
| 94 |
<form hx-post="/api/links" |
| 95 |
hx-target="#links-list" |
| 96 |
hx-swap="beforeend" |
| 97 |
hx-on::after-request="if(event.detail.successful) this.reset()"> |
| 98 |
<div class="field-row"> |
| 99 |
<div class="form-group is-grow"> |
| 100 |
<label for="link-title">Title</label> |
| 101 |
<input type="text" id="link-title" name="title" placeholder="My Website" required> |
| 102 |
</div> |
| 103 |
<div class="form-group is-grow-2"> |
| 104 |
<label for="link-url">URL</label> |
| 105 |
<input type="url" id="link-url" name="url" placeholder="https://..." required> |
| 106 |
</div> |
| 107 |
<button class="btn-secondary" type="submit">Add Link</button> |
| 108 |
</div> |
| 109 |
</form> |
| 110 |
</div> |
| 111 |
|
| 112 |
<div class="form-section"> |
| 113 |
<h2 class="subsection-title">Profile</h2> |
| 114 |
<p class="muted mb-4">Your public page: <a href="/u/{{ user.username }}">makenot.work/u/{{ user.username }}</a></p> |
| 115 |
|
| 116 |
<form hx-put="/api/users/me" |
| 117 |
hx-target="#profile-save-status" |
| 118 |
hx-swap="innerHTML" |
| 119 |
hx-indicator="#profile-spinner"> |
| 120 |
<div class="form-group"> |
| 121 |
<label for="display-name">Display Name</label> |
| 122 |
<input type="text" id="display-name" name="display_name" value="{{ user.display_name_or_username() }}"> |
| 123 |
<div class="hint">Defaults to your username if left empty</div> |
| 124 |
</div> |
| 125 |
|
| 126 |
<div class="form-group"> |
| 127 |
<label for="bio">Bio</label> |
| 128 |
<textarea id="bio" name="bio">{{ user.bio.as_deref().unwrap_or("") }}</textarea> |
| 129 |
</div> |
| 130 |
|
| 131 |
<button class="btn-primary" type="submit"> |
| 132 |
Save Changes |
| 133 |
<span id="profile-spinner" class="htmx-indicator"> ...</span> |
| 134 |
</button> |
| 135 |
<span id="profile-save-status"></span> |
| 136 |
</form> |
| 137 |
</div> |
| 138 |
|
| 139 |
{% if can_create_projects %} |
| 140 |
<details class="form-section"> |
| 141 |
<summary><h2 class="subsection-title">Custom Domain</h2></summary> |
| 142 |
<p class="muted mb-4">Point your own domain at your makenot.work profile.</p> |
| 143 |
|
| 144 |
{% match custom_domain %} |
| 145 |
{% when Some with (cd) %} |
| 146 |
<div class="profile-domain-header"> |
| 147 |
<strong>{{ cd.domain }}</strong> |
| 148 |
{% if cd.verified %} |
| 149 |
<span class="profile-domain-status is-verified">Verified</span> |
| 150 |
{% else %} |
| 151 |
<span class="profile-domain-status is-pending">Pending verification</span> |
| 152 |
{% endif %} |
| 153 |
</div> |
| 154 |
|
| 155 |
{% if !cd.verified %} |
| 156 |
<div class="callout callout--warning"> |
| 157 |
<p class="m-0 mb-3"><strong>DNS setup required:</strong> add two records at your domain registrar, then click Check DNS.</p> |
| 158 |
|
| 159 |
<p class="m-0 mb-2"><strong>1. Point your domain</strong> (routes visitors to your page)</p> |
| 160 |
<div class="dns-row"> |
| 161 |
<span class="dns-row-label">CNAME:</span> |
| 162 |
<code id="dns-route-host" class="dns-row-value">{{ cd.domain }}</code> |
| 163 |
<button type="button" class="btn-secondary dns-row-copy" |
| 164 |
onclick="navigator.clipboard.writeText(document.getElementById('dns-route-host').textContent).then(() => { this.textContent='Copied'; setTimeout(() => this.textContent='Copy', 1500) })">Copy</button> |
| 165 |
</div> |
| 166 |
<div class="dns-row"> |
| 167 |
<span class="dns-row-label">Value:</span> |
| 168 |
<code id="dns-route-value" class="dns-row-value">connect.makenot.work</code> |
| 169 |
<button type="button" class="btn-secondary dns-row-copy" |
| 170 |
onclick="navigator.clipboard.writeText(document.getElementById('dns-route-value').textContent).then(() => { this.textContent='Copied'; setTimeout(() => this.textContent='Copy', 1500) })">Copy</button> |
| 171 |
</div> |
| 172 |
<p class="callout-hint mb-3">On Cloudflare, set this record to <strong>DNS only</strong> (grey cloud), not Proxied. For an apex/root domain, use CNAME flattening or ALIAS.</p> |
| 173 |
|
| 174 |
<p class="m-0 mb-2"><strong>2. Verify ownership</strong></p> |
| 175 |
<div class="dns-row"> |
| 176 |
<span class="dns-row-label">TXT:</span> |
| 177 |
<code id="dns-record" class="dns-row-value">_mnw-verify.{{ cd.domain }}</code> |
| 178 |
<button type="button" class="btn-secondary dns-row-copy" |
| 179 |
onclick="navigator.clipboard.writeText(document.getElementById('dns-record').textContent).then(() => { this.textContent='Copied'; setTimeout(() => this.textContent='Copy', 1500) })">Copy</button> |
| 180 |
</div> |
| 181 |
<div class="dns-row"> |
| 182 |
<span class="dns-row-label">Value:</span> |
| 183 |
<code id="dns-value" class="dns-row-value">{{ cd.verification_token }}</code> |
| 184 |
<button type="button" class="btn-secondary dns-row-copy" |
| 185 |
onclick="navigator.clipboard.writeText(document.getElementById('dns-value').textContent).then(() => { this.textContent='Copied'; setTimeout(() => this.textContent='Copy', 1500) })">Copy</button> |
| 186 |
</div> |
| 187 |
<p class="callout-hint">DNS changes can take up to 24 hours to propagate. <a href="/docs/guide/custom-domains">Setup guide</a></p> |
| 188 |
</div> |
| 189 |
<div class="profile-domain-actions"> |
| 190 |
<button class="btn-primary" |
| 191 |
hx-post="/api/domains/verify" |
| 192 |
hx-vals='{"domain_id": "{{ cd.id }}"}' |
| 193 |
hx-target="#domain-verify-result" |
| 194 |
hx-swap="innerHTML" |
| 195 |
hx-on::after-request="if(event.detail.successful && event.detail.xhr.responseText.indexOf('verified successfully')!==-1) { var t=document.getElementById('settings-body'); if(t) htmx.ajax('GET','/dashboard/tabs/profile',{target:t,swap:'innerHTML'}); else { var b=document.getElementById('tab-profile'); if(b) b.click(); } }">Check DNS</button> |
| 196 |
<button class="btn-danger" |
| 197 |
hx-delete="/api/domains/{{ cd.id }}" |
| 198 |
hx-confirm="Remove this domain?" |
| 199 |
hx-target="closest .form-section" |
| 200 |
hx-swap="outerHTML" |
| 201 |
hx-on::after-request="if(event.detail.successful) { var t=document.getElementById('settings-body'); if(t) htmx.ajax('GET','/dashboard/tabs/profile',{target:t,swap:'innerHTML'}); else { var b=document.getElementById('tab-profile'); if(b) b.click(); } }">Remove</button> |
| 202 |
</div> |
| 203 |
<div id="domain-verify-result" class="profile-domain-result"></div> |
| 204 |
{% else %} |
| 205 |
<p class="profile-domain-lead"> |
| 206 |
Visitors to <strong>{{ cd.domain }}</strong> will see your profile, projects, and items. |
| 207 |
</p> |
| 208 |
<button class="btn-danger" |
| 209 |
hx-delete="/api/domains/{{ cd.id }}" |
| 210 |
hx-confirm="Remove this domain? Your domain will stop serving your content." |
| 211 |
hx-target="closest .form-section" |
| 212 |
hx-swap="outerHTML" |
| 213 |
hx-on::after-request="if(event.detail.successful) { var t=document.getElementById('settings-body'); if(t) htmx.ajax('GET','/dashboard/tabs/profile',{target:t,swap:'innerHTML'}); else { var b=document.getElementById('tab-profile'); if(b) b.click(); } }">Remove Domain</button> |
| 214 |
{% endif %} |
| 215 |
{% when None %} |
| 216 |
<form hx-post="/api/domains" |
| 217 |
hx-target="#domain-add-result" |
| 218 |
hx-swap="innerHTML" |
| 219 |
hx-on::after-request="if(event.detail.successful) { var t=document.getElementById('settings-body'); if(t) htmx.ajax('GET','/dashboard/tabs/profile',{target:t,swap:'innerHTML'}); else { var b=document.getElementById('tab-profile'); if(b) b.click(); } }"> |
| 220 |
<div class="field-row"> |
| 221 |
<div class="form-group is-grow"> |
| 222 |
<label for="custom-domain">Domain</label> |
| 223 |
<input type="text" id="custom-domain" name="domain" placeholder="mysite.com" required> |
| 224 |
</div> |
| 225 |
<button class="btn-primary" type="submit">Add Domain</button> |
| 226 |
</div> |
| 227 |
</form> |
| 228 |
<div id="domain-add-result" class="profile-domain-add-result"></div> |
| 229 |
{% endmatch %} |
| 230 |
</details> |
| 231 |
{% endif %} |
| 232 |
|
| 233 |
<details class="form-section"> |
| 234 |
<summary><h2 class="subsection-title">Personal Feed</h2></summary> |
| 235 |
<p class="muted profile-feed-lead">An RSS feed of new content from creators and projects you follow. Add this URL to your RSS reader.</p> |
| 236 |
<div class="profile-feed-row" id="feed-url-row"> |
| 237 |
<input type="text" id="feed-url" value="{{ feed_url }}" readonly class="profile-feed-input"> |
| 238 |
<button class="btn-secondary nowrap" type="button" |
| 239 |
onclick="navigator.clipboard.writeText(document.getElementById('feed-url').value).then(() => { this.textContent='Copied!'; setTimeout(() => this.textContent='Copy URL', 2000) })">Copy URL</button> |
| 240 |
<button class="btn-secondary nowrap" type="button" |
| 241 |
hx-post="/dashboard/feed/regenerate" hx-target="#feed-url-row" hx-swap="outerHTML">Regenerate</button> |
| 242 |
</div> |
| 243 |
<p class="muted profile-feed-lead">Regenerating issues a new URL and immediately stops the old one from working — use it if your feed link leaks.</p> |
| 244 |
</details> |
| 245 |
|