← back to Stars of Design
views/public/feed.ejs
444 lines
<%- include('../partials/head', { title: title, meta_desc_override: typeof meta_desc_override !== 'undefined' ? meta_desc_override : undefined }) %>
<%- include('../partials/header') %>
<style>
.feed-shell {
max-width: 520px;
margin: 0 auto;
padding: 16px 12px 96px;
background: var(--bg, #0e0e0e);
}
.feed-head {
text-align: center;
padding: 18px 0 8px;
border-bottom: 1px solid var(--line, #2a2620);
margin-bottom: 16px;
}
.feed-head h1 {
font-family: 'Cormorant Garamond', Georgia, serif;
font-weight: 400;
font-size: 32px;
margin: 0 0 4px;
color: var(--gold, #c9a14b);
letter-spacing: -0.01em;
}
.feed-head p {
margin: 0;
color: var(--ink-faint, #7a706a);
font-size: 12px;
letter-spacing: 0.04em;
}
.post {
background: var(--card, #1a1714);
border: 1px solid var(--line, #2a2620);
border-radius: 12px;
margin: 0 0 14px;
overflow: hidden;
}
.post-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
}
.post-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: #14110e no-repeat center/cover;
border: 1px solid var(--line, #2a2620);
flex-shrink: 0;
}
.post-meta {
flex: 1;
min-width: 0;
}
.post-name {
color: var(--ink, #f0ebe3);
font-weight: 500;
font-size: 15px;
margin: 0;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.post-name:hover { color: var(--gold, #c9a14b); }
.post-sub {
color: var(--ink-faint, #7a706a);
font-size: 12px;
margin: 1px 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-body {
padding: 0 12px 12px;
}
.post-areas {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.post-areas li {
padding: 3px 9px;
background: rgba(201, 161, 75, 0.08);
border: 1px solid var(--line, #2a2620);
border-radius: 999px;
color: var(--ink-soft, #c8beb2);
font-size: 11px;
letter-spacing: 0.04em;
}
.post-links {
margin: 10px 0 0;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.post-links a {
color: var(--ink-faint, #7a706a);
font-size: 12px;
text-decoration: none;
}
.post-links a:hover { color: var(--gold, #c9a14b); }
.post-actions {
display: flex;
align-items: center;
border-top: 1px solid var(--line, #2a2620);
padding: 4px 4px;
}
.post-actions button {
background: transparent;
border: 0;
color: var(--ink-soft, #c8beb2);
font: inherit;
font-size: 13px;
padding: 10px 14px;
cursor: pointer;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 6px;
}
.post-actions button:hover { background: rgba(255, 255, 255, 0.04); }
.post-actions button[aria-pressed="true"] { color: var(--gold, #c9a14b); font-weight: 500; }
.post-actions .ico { font-size: 16px; line-height: 1; }
.like-count { font-variant-numeric: tabular-nums; }
.comments {
display: none;
padding: 6px 12px 12px;
border-top: 1px solid var(--line, #2a2620);
}
.comments.open { display: block; }
.comment {
padding: 8px 0;
border-bottom: 1px dotted var(--line, #2a2620);
}
.comment:last-child { border-bottom: 0; }
.comment-author {
color: var(--gold, #c9a14b);
font-size: 12px;
font-weight: 500;
margin: 0 0 2px;
}
.comment-body {
color: var(--ink-soft, #c8beb2);
font-size: 13.5px;
margin: 0;
line-height: 1.5;
word-wrap: break-word;
}
.comment-time {
color: var(--ink-faint, #7a706a);
font-size: 11px;
margin-left: 8px;
}
.comment-form {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.comment-form input, .comment-form textarea {
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--line, #2a2620);
color: var(--ink, #f0ebe3);
border-radius: 6px;
padding: 7px 10px;
font: inherit;
font-size: 13px;
width: 100%;
box-sizing: border-box;
resize: vertical;
font-family: inherit;
}
.comment-form textarea { min-height: 60px; }
.comment-form .row {
display: flex;
gap: 6px;
align-items: center;
}
.comment-form .row input { flex: 1; }
.comment-form button {
background: var(--gold, #c9a14b);
color: #1a1410;
border: 0;
padding: 6px 14px;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.comment-form button:disabled { opacity: 0.5; cursor: not-allowed; }
.comment-form .err {
color: #e07a7a;
font-size: 12px;
margin: 0;
}
.pager {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
.pager a {
color: var(--ink-soft, #c8beb2);
text-decoration: none;
padding: 8px 18px;
border: 1px solid var(--line, #2a2620);
border-radius: 6px;
font-size: 12px;
letter-spacing: 0.08em;
}
.pager a:hover { color: var(--gold, #c9a14b); border-color: var(--gold, #c9a14b); }
.pager .disabled { opacity: 0.35; pointer-events: none; }
@media (max-width: 520px) {
.feed-shell { padding: 8px 8px 80px; }
.post { border-radius: 0; border-left: 0; border-right: 0; margin-left: -8px; margin-right: -8px; }
}
</style>
<main class="feed-shell">
<div class="feed-head">
<h1>DW Feed</h1>
<p>Like, comment, follow your favorite design houses.</p>
</div>
<% items.forEach(function(it) { %>
<article class="post" data-id="<%= it.id %>">
<div class="post-head">
<div class="post-avatar" style="background-image:url('<%= it.headshot %>')"></div>
<div class="post-meta">
<a class="post-name" href="/clients/<%= it.slug %>"><%= it.full_name %></a>
<p class="post-sub">
<% if (it.firm_name) { %><%= it.firm_name %><% if (it.role) { %> · <%= it.role %><% } %><% } else if (it.role) { %><%= it.role %><% } %>
<% if ((it.firm_name || it.role) && (it.city || it.state)) { %> · <% } %>
<% if (it.city || it.state) { %><%= [it.city, it.state].filter(Boolean).join(', ') %><% } %>
</p>
</div>
</div>
<% if (it.areas && it.areas.length || it.links && (it.links['firm-site'] || it.links.linkedin || it.links.instagram)) { %>
<div class="post-body">
<% if (it.areas && it.areas.length) { %>
<ul class="post-areas">
<% it.areas.slice(0,5).forEach(function(a) { %><li><%= a %></li><% }); %>
</ul>
<% } %>
<% var siteHref = (it.links['firm-site']||[])[0]; var liHref = (it.links.linkedin||[])[0]; var igHref = (it.links.instagram||[])[0]; %>
<% if (siteHref || liHref || igHref) { %>
<div class="post-links">
<% if (siteHref) { %><a href="<%= siteHref %>" target="_blank" rel="nofollow noopener noreferrer">↗ Site</a><% } %>
<% if (liHref) { %><a href="<%= liHref %>" target="_blank" rel="nofollow noopener noreferrer">↗ LinkedIn</a><% } %>
<% if (igHref) { %><a href="<%= igHref %>" target="_blank" rel="nofollow noopener noreferrer">↗ Instagram</a><% } %>
</div>
<% } %>
</div>
<% } %>
<div class="post-actions">
<button class="js-like" type="button" aria-pressed="<%= it.i_liked %>">
<span class="ico">♥</span> <span class="like-count"><%= it.like_count %></span> <span>Like</span>
</button>
<button class="js-comment-toggle" type="button" aria-expanded="false">
<span class="ico">💬</span> <span class="comment-count"><%= it.comment_count %></span> <span>Comment</span>
</button>
<button class="js-share" type="button" data-share-url="/clients/<%= it.slug %>">
<span class="ico">↗</span> <span>Share</span>
</button>
</div>
<section class="comments">
<div class="comments-list"><!-- lazy-loaded --></div>
<form class="comment-form js-comment-form">
<div class="row">
<input name="author_name" placeholder="Your name *" maxlength="80" required>
<input name="author_email" placeholder="Email (optional)" maxlength="160" type="email">
</div>
<textarea name="body" placeholder="Add a comment…" maxlength="1500" required></textarea>
<p class="err" hidden></p>
<div style="display:flex;justify-content:flex-end">
<button type="submit">Post</button>
</div>
</form>
</section>
</article>
<% }); %>
<div class="pager">
<a class="<%= page <= 1 ? 'disabled' : '' %>" href="?page=<%= Math.max(1, page-1) %>">← Newer</a>
<a class="<%= items.length < 15 ? 'disabled' : '' %>" href="?page=<%= page+1 %>">Older →</a>
</div>
</main>
<script>
(function(){
// Like toggle
document.querySelectorAll('.js-like').forEach(function(btn){
btn.addEventListener('click', async function(){
var post = btn.closest('.post');
var id = post.dataset.id;
btn.disabled = true;
try {
var r = await fetch('/api/clients/' + id + '/like', { method: 'POST' });
if (!r.ok) throw new Error('like-failed');
var j = await r.json();
btn.querySelector('.like-count').textContent = j.like_count;
btn.setAttribute('aria-pressed', j.liked ? 'true' : 'false');
} catch(e) {
// silent fail
} finally {
btn.disabled = false;
}
});
});
// Comment toggle + lazy load
document.querySelectorAll('.js-comment-toggle').forEach(function(btn){
btn.addEventListener('click', async function(){
var post = btn.closest('.post');
var sect = post.querySelector('.comments');
var open = !sect.classList.contains('open');
sect.classList.toggle('open', open);
btn.setAttribute('aria-expanded', String(open));
if (open && !sect.dataset.loaded) {
var list = sect.querySelector('.comments-list');
list.textContent = 'Loading…';
try {
var r = await fetch('/api/clients/' + post.dataset.id + '/comments');
if (!r.ok) throw new Error('load');
var j = await r.json();
list.innerHTML = '';
(j.comments || []).forEach(function(c){
var el = document.createElement('div');
el.className = 'comment';
var au = document.createElement('p');
au.className = 'comment-author';
au.textContent = c.author_name;
var t = document.createElement('span');
t.className = 'comment-time';
t.textContent = new Date(c.created_at).toLocaleString();
au.appendChild(t);
var bo = document.createElement('p');
bo.className = 'comment-body';
bo.textContent = c.body;
el.appendChild(au); el.appendChild(bo);
list.appendChild(el);
});
if (!j.comments || !j.comments.length) {
list.innerHTML = '<p style="color:var(--ink-faint,#7a706a);font-size:12.5px;margin:8px 0 0">Be the first to comment.</p>';
}
sect.dataset.loaded = '1';
} catch (e) {
list.textContent = 'Couldn\'t load comments.';
}
}
});
});
// Comment submit
document.querySelectorAll('.js-comment-form').forEach(function(form){
form.addEventListener('submit', async function(ev){
ev.preventDefault();
var post = form.closest('.post');
var btn = form.querySelector('button[type=submit]');
var err = form.querySelector('.err');
err.hidden = true;
btn.disabled = true;
try {
var body = {
author_name: form.author_name.value.trim(),
author_email: form.author_email.value.trim(),
body: form.body.value.trim()
};
var r = await fetch('/api/clients/' + post.dataset.id + '/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!r.ok) {
var j = await r.json().catch(function(){ return {}; });
throw new Error(j.error || 'failed');
}
// Insert into list
var list = post.querySelector('.comments-list');
var el = document.createElement('div');
el.className = 'comment';
var au = document.createElement('p');
au.className = 'comment-author';
au.textContent = body.author_name;
var t = document.createElement('span');
t.className = 'comment-time';
t.textContent = new Date().toLocaleString();
au.appendChild(t);
var bo = document.createElement('p');
bo.className = 'comment-body';
bo.textContent = body.body;
el.appendChild(au); el.appendChild(bo);
list.appendChild(el);
form.body.value = '';
// Bump comment count
var cc = post.querySelector('.comment-count');
cc.textContent = String(parseInt(cc.textContent || '0', 10) + 1);
} catch (e) {
err.hidden = false;
err.textContent =
e.message === 'name-required' ? 'Name is required.' :
e.message === 'spam-or-empty' ? 'Comment looks empty or spammy.' :
e.message === 'rate-limit' ? 'Too many comments. Slow down.' :
'Could not post. Try again.';
} finally {
btn.disabled = false;
}
});
});
// Share — Web Share API where available, fallback to copy
document.querySelectorAll('.js-share').forEach(function(btn){
btn.addEventListener('click', async function(){
var url = location.origin + btn.dataset.shareUrl;
try {
if (navigator.share) {
await navigator.share({ url, title: document.title });
} else {
await navigator.clipboard.writeText(url);
btn.querySelector('span:last-child').textContent = 'Copied!';
setTimeout(function(){ btn.querySelector('span:last-child').textContent = 'Share'; }, 1500);
}
} catch (e) { /* user-canceled */ }
});
});
})();
</script>
<%- include('../partials/footer') %>