ui: refresh communities page

This commit is contained in:
Marco Allegretti 2026-01-29 12:11:17 +01:00
parent f5b53ec092
commit 11621339d9
2 changed files with 425 additions and 120 deletions

View file

@ -330,6 +330,150 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
:where(button):active { :where(button):active {
transform: translateY(1px); transform: translateY(1px);
} }
:root {
--ui-container-max: 1200px;
--ui-page-pad-y: 2rem;
--ui-glass-bg: rgba(24, 24, 27, 0.65);
--ui-glass-border: rgba(255, 255, 255, 0.08);
--ui-soft-highlight: rgba(129, 140, 248, 0.16);
}
.ui-container {
max-width: var(--ui-container-max);
margin: 0 auto;
padding: 0 2rem;
}
.ui-page {
padding: var(--ui-page-pad-y) 0;
}
.ui-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.ui-page-title h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.ui-subtitle {
color: var(--color-text-muted);
font-size: 0.9375rem;
margin-top: 0.25rem;
}
.ui-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.ui-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.ui-card-glass {
background: var(--ui-glass-bg);
border-color: var(--ui-glass-border);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.ui-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.875rem;
line-height: 1;
white-space: nowrap;
}
.ui-btn-primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
.ui-btn-primary:hover {
background: var(--color-primary-hover);
color: var(--color-on-primary);
transform: translateY(-1px);
box-shadow: 0 10px 30px rgba(99, 102, 241, 0.24);
}
.ui-btn-secondary {
background: transparent;
border-color: var(--color-border);
color: var(--color-text);
}
.ui-btn-secondary:hover {
border-color: var(--color-border-hover);
background: rgba(255, 255, 255, 0.06);
color: var(--color-text);
}
:where(input.ui-input, select.ui-select) {
width: 100%;
border-radius: var(--radius-md);
}
.ui-kpis {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.ui-kpi {
padding: 0.75rem 0.875rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: rgba(0, 0, 0, 0.18);
min-width: 140px;
}
.ui-kpi-value {
font-size: 1.25rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.ui-kpi-label {
margin-top: 0.125rem;
color: var(--color-text-muted);
font-size: 0.8125rem;
}
@media (max-width: 768px) {
.ui-container {
padding: 0 1rem;
}
.ui-page-header {
flex-direction: column;
align-items: stretch;
}
.ui-actions {
justify-content: flex-start;
}
}
</style> </style>
<style> <style>

View file

@ -5,67 +5,179 @@ import { API_BASE as apiBase } from '../lib/api';
--- ---
<Layout title="Communities"> <Layout title="Communities">
<section class="communities"> <section class="ui-page">
<div class="header-row"> <div class="ui-container">
<div> <div class="ui-page-header">
<h1>Communities</h1> <div class="ui-page-title">
<p class="subtitle">Browse organizations and communities</p> <h1>Communities</h1>
<p class="ui-subtitle">Browse organizations and communities</p>
</div>
<div class="ui-actions">
<a href="/communities/new" class="ui-btn ui-btn-primary" id="create-btn" style="display: none;">Create Community</a>
</div>
</div> </div>
<a href="/communities/new" class="btn-create" id="create-btn" style="display: none;">+ Create Community</a>
</div>
<div class="search-bar"> <div class="communities-toolbar ui-card ui-card-glass">
<input type="text" id="search-input" placeholder="Search communities..." /> <div class="toolbar-row">
</div> <div class="toolbar-search">
<input type="text" id="search-input" class="ui-input" placeholder="Search communities…" autocomplete="off" />
</div>
<div class="toolbar-sort">
<select id="sort-select" class="ui-select" aria-label="Sort communities">
<option value="name_asc">Name (AZ)</option>
<option value="name_desc">Name (ZA)</option>
<option value="slug_asc">Slug (AZ)</option>
</select>
</div>
</div>
<div class="ui-kpis" aria-label="Communities summary">
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-total">—</div>
<div class="ui-kpi-label">Total</div>
</div>
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-showing">—</div>
<div class="ui-kpi-label">Showing</div>
</div>
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-query">All</div>
<div class="ui-kpi-label">Filter</div>
</div>
</div>
</div>
<div id="communities-list" class="list"> <div id="communities-list" class="communities-grid" aria-live="polite">
<p class="loading">Loading communities...</p> <div class="state-card ui-card"><p class="loading">Loading communities…</p></div>
</div> </div>
<script is:inline define:vars={{ apiBase }}> <script is:inline define:vars={{ apiBase }}>
(function() { (function() {
var API_BASE = apiBase; var API_BASE = apiBase;
var allCommunities = []; var allCommunities = [];
var currentQuery = '';
var currentSort = 'name_asc';
function escapeHtml(value) {
return String(value || '').replace(/[&<>\"']/g, function(ch) {
switch (ch) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case "'": return '&#39;';
default: return ch;
}
});
}
function updateKpis(total, showing, query) {
var totalEl = document.getElementById('kpi-total');
var showingEl = document.getElementById('kpi-showing');
var queryEl = document.getElementById('kpi-query');
if (totalEl) totalEl.textContent = String(total);
if (showingEl) showingEl.textContent = String(showing);
if (queryEl) queryEl.textContent = query ? '“' + query + '”' : 'All';
}
function renderSkeleton(count) {
var container = document.getElementById('communities-list');
if (!container) return;
var html = '';
for (var i = 0; i < count; i++) {
html += '<div class="community-tile ui-card is-skeleton" aria-hidden="true">' +
'<div class="tile-top">' +
'<div class="skel skel-title"></div>' +
'<div class="skel skel-slug"></div>' +
'</div>' +
'<div class="skel skel-desc"></div>' +
'<div class="skel skel-desc short"></div>' +
'</div>';
}
container.innerHTML = html;
}
function sortCommunities(list) {
var sorted = list.slice();
sorted.sort(function(a, b) {
var an = (a && a.name ? String(a.name) : '').toLowerCase();
var bn = (b && b.name ? String(b.name) : '').toLowerCase();
var as = (a && a.slug ? String(a.slug) : '').toLowerCase();
var bs = (b && b.slug ? String(b.slug) : '').toLowerCase();
if (currentSort === 'name_desc') {
if (an < bn) return 1;
if (an > bn) return -1;
return 0;
}
if (currentSort === 'slug_asc') {
if (as < bs) return -1;
if (as > bs) return 1;
return 0;
}
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
});
return sorted;
}
function renderCommunities(communities) { function renderCommunities(communities) {
var container = document.getElementById('communities-list'); var container = document.getElementById('communities-list');
if (!container) return; if (!container) return;
if (!communities || communities.length === 0) { if (!communities || communities.length === 0) {
container.innerHTML = '<div class="empty"><p>No communities found.</p></div>'; container.innerHTML = '<div class="state-card ui-card"><p>No communities found.</p></div>';
return; return;
} }
var html = ''; var html = '';
for (var i = 0; i < communities.length; i++) { for (var i = 0; i < communities.length; i++) {
var c = communities[i]; var c = communities[i];
html += '<a href="/communities/' + c.slug + '" class="community-card">' +
'<h3>' + c.name + '</h3>' + var name = escapeHtml(c && c.name ? c.name : 'Community');
'<p class="slug">/' + c.slug + '</p>' + var slug = escapeHtml(c && c.slug ? c.slug : '');
'<p class="description">' + (c.description || 'No description') + '</p>' + var desc = escapeHtml(c && c.description ? c.description : 'No description');
html += '<a href="/communities/' + slug + '" class="community-tile ui-card">' +
'<div class="tile-top">' +
'<h3 class="tile-title">' + name + '</h3>' +
'<div class="tile-slug">/' + slug + '</div>' +
'</div>' +
'<p class="tile-desc">' + desc + '</p>' +
'<div class="tile-meta">' +
'<span class="meta-pill">Open community</span>' +
'</div>' +
'</a>'; '</a>';
} }
container.innerHTML = html; container.innerHTML = html;
} }
function filterCommunities(query) { function applyFilterSortAndRender() {
var q = query.toLowerCase().trim(); var q = String(currentQuery || '').toLowerCase().trim();
if (!q) { var filtered = allCommunities;
renderCommunities(allCommunities); if (q) {
return; filtered = allCommunities.filter(function(c) {
var name = (c && c.name ? String(c.name) : '').toLowerCase();
var slug = (c && c.slug ? String(c.slug) : '').toLowerCase();
var desc = (c && c.description ? String(c.description) : '').toLowerCase();
return name.indexOf(q) >= 0 || slug.indexOf(q) >= 0 || desc.indexOf(q) >= 0;
});
} }
var filtered = allCommunities.filter(function(c) {
return c.name.toLowerCase().indexOf(q) >= 0 || var sorted = sortCommunities(filtered);
c.slug.toLowerCase().indexOf(q) >= 0 || updateKpis(allCommunities.length, sorted.length, currentQuery);
(c.description && c.description.toLowerCase().indexOf(q) >= 0); renderCommunities(sorted);
});
renderCommunities(filtered);
} }
function loadCommunities() { function loadCommunities() {
var container = document.getElementById('communities-list'); var container = document.getElementById('communities-list');
if (!container) return; if (!container) return;
renderSkeleton(8);
updateKpis('—', '—', '');
fetch(API_BASE + '/api/communities') fetch(API_BASE + '/api/communities')
.then(function(res) { .then(function(res) {
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
@ -73,11 +185,11 @@ import { API_BASE as apiBase } from '../lib/api';
}) })
.then(function(data) { .then(function(data) {
allCommunities = Array.isArray(data) ? data : (data.value || data.communities || []); allCommunities = Array.isArray(data) ? data : (data.value || data.communities || []);
renderCommunities(allCommunities); applyFilterSortAndRender();
}) })
.catch(function(error) { .catch(function(error) {
console.error('Failed to load communities:', error); console.error('Failed to load communities:', error);
container.innerHTML = '<div class="error"><p>Failed to load communities.</p><p class="hint">Make sure the backend is running on localhost:3000</p></div>'; container.innerHTML = '<div class="state-card ui-card"><p class="error">Failed to load communities.</p><p class="hint">Please try again later.</p></div>';
}); });
} }
@ -88,7 +200,16 @@ import { API_BASE as apiBase } from '../lib/api';
var searchInput = document.getElementById('search-input'); var searchInput = document.getElementById('search-input');
if (searchInput) { if (searchInput) {
searchInput.addEventListener('input', function(e) { searchInput.addEventListener('input', function(e) {
filterCommunities(e.target.value); currentQuery = e.target.value;
applyFilterSortAndRender();
});
}
var sortSelect = document.getElementById('sort-select');
if (sortSelect) {
sortSelect.addEventListener('change', function(e) {
currentSort = e.target.value || 'name_asc';
applyFilterSortAndRender();
}); });
} }
@ -100,127 +221,167 @@ import { API_BASE as apiBase } from '../lib/api';
} }
})(); })();
</script> </script>
</div>
</section> </section>
</Layout> </Layout>
<style> <style>
.communities { .communities-toolbar {
padding: 1.5rem 0; padding: 1rem;
margin-bottom: 1.25rem;
} }
.header-row { .toolbar-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 1rem;
}
.btn-create {
background: var(--color-primary);
color: var(--color-text-inverse);
padding: 0.625rem 1.25rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.875rem;
white-space: nowrap;
transition: all var(--motion-fast) var(--easing-standard);
}
.btn-create:hover {
background: var(--color-primary-hover);
color: var(--color-text-inverse);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(129, 140, 248, 0.25);
}
.btn-create:active {
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
.search-bar {
margin-bottom: 1.5rem;
}
.search-bar input {
width: 100%;
max-width: 400px;
border-radius: var(--radius-md);
}
.search-bar input::placeholder {
color: var(--color-text-muted);
}
h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 0.25rem;
}
.subtitle {
color: var(--color-text-muted);
font-size: 0.9375rem;
}
.list {
display: grid; display: grid;
grid-template-columns: 1fr 220px;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.75rem;
}
.communities-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem; gap: 1rem;
} }
.community-card { @media (max-width: 1024px) {
display: block; .communities-grid {
background: var(--color-surface); grid-template-columns: repeat(2, 1fr);
border: 1px solid var(--color-border); }
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
color: var(--color-text);
transition: all var(--motion-normal) var(--easing-standard);
} }
.community-card:hover { @media (max-width: 640px) {
.toolbar-row {
grid-template-columns: 1fr;
}
.communities-grid {
grid-template-columns: 1fr;
}
}
.community-tile {
display: flex;
flex-direction: column;
padding: 1.25rem 1.25rem 1rem;
color: var(--color-text);
position: relative;
overflow: hidden;
transition: transform var(--motion-normal) var(--easing-standard), box-shadow var(--motion-normal) var(--easing-standard), border-color var(--motion-fast) var(--easing-standard);
}
.community-tile::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(700px 220px at 15% 0%, rgba(129, 140, 248, 0.12), transparent 60%);
opacity: 0;
transition: opacity var(--motion-normal) var(--easing-standard);
pointer-events: none;
}
.community-tile:hover {
border-color: var(--color-border-hover); border-color: var(--color-border-hover);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.community-card h3 { .community-tile:hover::before {
font-size: 1.125rem; opacity: 1;
font-weight: 600;
margin-bottom: 0.25rem;
} }
.slug { .tile-top {
color: var(--color-primary); display: grid;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; gap: 0.25rem;
margin-bottom: 0.75rem;
}
.tile-title {
font-size: 1.05rem;
font-weight: 650;
letter-spacing: -0.01em;
margin: 0;
}
.tile-slug {
font-family: 'SF Mono', Monaco, 'Cascadia Code', ui-monospace, monospace;
font-size: 0.8125rem; font-size: 0.8125rem;
margin-bottom: 0.5rem; color: var(--color-primary);
opacity: 0.9; opacity: 0.9;
} }
.description { .tile-desc {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.9375rem; font-size: 0.9375rem;
line-height: 1.5; line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0;
min-height: calc(0.9375rem * 1.55 * 2);
} }
.loading, .empty, .error { .tile-meta {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.meta-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.78);
}
.state-card {
padding: 2.5rem 1.5rem;
text-align: center; text-align: center;
padding: 4rem 2rem; }
.loading {
color: var(--color-text-muted); color: var(--color-text-muted);
background: var(--color-surface); margin: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
} }
.hint { .hint {
font-size: 0.875rem; font-size: 0.875rem;
margin-top: 0.5rem; margin: 0.5rem 0 0;
color: var(--color-text-muted);
} }
.error { .error {
color: var(--color-error); color: var(--color-error);
margin: 0;
}
.is-skeleton {
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.skel {
border-radius: 10px;
background: linear-gradient(90deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.09) 35%, rgba(255,255,255,0.05) 70%);
background-size: 200% 100%;
animation: shimmer 1.1s ease-in-out infinite;
}
.skel-title { height: 18px; width: 68%; }
.skel-slug { height: 12px; width: 36%; }
.skel-desc { height: 12px; width: 100%; margin-top: 0.75rem; }
.skel-desc.short { width: 78%; margin-top: 0.5rem; }
@keyframes shimmer {
0% { background-position: 0% 0%; }
100% { background-position: -200% 0%; }
} }
</style> </style>