feat(templates): add projects page
This commit is contained in:
3
templates/custom/extra_links.tmpl
Normal file
3
templates/custom/extra_links.tmpl
Normal file
@@ -0,0 +1,3 @@
|
||||
{{if .IsSigned}}
|
||||
<a class="item" href="/projects">projects</a>
|
||||
{{end}}
|
||||
512
templates/custom/portfolio.tmpl
Normal file
512
templates/custom/portfolio.tmpl
Normal file
@@ -0,0 +1,512 @@
|
||||
<div class="page-content home">
|
||||
<div class="ui fluid container">
|
||||
<div class="hero">
|
||||
<h2 class="ui header title">
|
||||
{{AppName}}
|
||||
</h2>
|
||||
<p class="ui large text">
|
||||
hi there. explore our projects below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="portfolio-controls">
|
||||
<div class="control-group">
|
||||
<div class="ui buttons">
|
||||
<button class="ui button active" data-filter="all">all</button>
|
||||
<button class="ui button" data-filter="project">projects</button>
|
||||
<button class="ui button" data-filter="repo">repos</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<select class="ui dropdown" id="org-select">
|
||||
<option value="all">all types</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group search-group">
|
||||
<div class="ui icon input fluid">
|
||||
<input type="text" id="search-input" placeholder="search...">
|
||||
<i class="search icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<select class="ui dropdown" id="sort-select">
|
||||
<option value="updated">last updated</option>
|
||||
<option value="alpha">alphabetical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading-indicator" class="ui active centered inline loader text">loading portfolio...</div>
|
||||
|
||||
<div id="error-message" class="ui negative message hidden">
|
||||
<div class="header">error loading data</div>
|
||||
<p>could not fetch organizations or repositories. please try again later.</p>
|
||||
</div>
|
||||
|
||||
<div id="portfolio-grid">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Libre+Barcode+39+Text&display=swap');
|
||||
|
||||
.page-content.home {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-content.home .hero {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
#portfolio-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
background: var(--color-box-header);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
flex-grow: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-group .ui.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
background: var(--color-input-background, #2b2c2d) !important;
|
||||
color: var(--color-text, #e0e0e0) !important;
|
||||
border: 1px solid var(--color-secondary) !important;
|
||||
}
|
||||
|
||||
#search-input:focus {
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
#portfolio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#portfolio-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#portfolio-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.portfolio-card {
|
||||
background-color: var(--color-box-body);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 380px;
|
||||
position: relative;
|
||||
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.portfolio-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.barcode-container {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.barcode-title {
|
||||
font-family: 'Libre Barcode 39 Text', cursive;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: var(--color-text-light);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.topic-tag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.links-container {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.portfolio-link {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.portfolio-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const API_BASE = '{{AppSubUrl}}/api/v1';
|
||||
const grid = document.getElementById('portfolio-grid');
|
||||
const loading = document.getElementById('loading-indicator');
|
||||
const errorMsg = document.getElementById('error-message');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const sortSelect = document.getElementById('sort-select');
|
||||
const orgSelect = document.getElementById('org-select');
|
||||
const filterButtons = document.querySelectorAll('#portfolio-controls .ui.buttons .button');
|
||||
|
||||
let allItems = [];
|
||||
let filteredItems = [];
|
||||
let currentMainFilter = 'all';
|
||||
let currentOrgFilter = 'all';
|
||||
|
||||
const langColors = {
|
||||
'Python': '#3572A5',
|
||||
'JavaScript': '#f1e05a',
|
||||
'TypeScript': '#2b7489',
|
||||
'HTML': '#e34c26',
|
||||
'CSS': '#563d7c',
|
||||
'Go': '#00ADD8',
|
||||
'Java': '#b07219',
|
||||
'C++': '#f34b7d',
|
||||
'C': '#555555',
|
||||
'Shell': '#89e051',
|
||||
'Rust': '#dea584',
|
||||
'Vue': '#41b883',
|
||||
'Swift': '#ffac45',
|
||||
'Kotlin': '#A97BFF',
|
||||
'PHP': '#4F5D95'
|
||||
};
|
||||
const defaultColor = '#8e6adb';
|
||||
|
||||
function getLangColor(lang) {
|
||||
return langColors[lang] || defaultColor;
|
||||
}
|
||||
|
||||
function fitText(el) {
|
||||
const text = el.innerText;
|
||||
const containerWidth = el.parentElement.clientWidth;
|
||||
let fontSize = 60;
|
||||
el.style.fontSize = fontSize + 'px';
|
||||
|
||||
while (el.scrollWidth > containerWidth && fontSize > 20) {
|
||||
fontSize -= 2;
|
||||
el.style.fontSize = fontSize + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPortfolio() {
|
||||
try {
|
||||
loading.classList.remove('hidden');
|
||||
grid.innerHTML = '';
|
||||
errorMsg.classList.add('hidden');
|
||||
|
||||
const orgsRes = await fetch(`${API_BASE}/orgs`);
|
||||
if (!orgsRes.ok) throw new Error('failed to fetch orgs');
|
||||
const orgs = await orgsRes.json();
|
||||
|
||||
const items = [];
|
||||
const orgNames = new Set();
|
||||
|
||||
await Promise.all(orgs.map(async (org) => {
|
||||
const isProject = org.location && org.location.toLowerCase().includes('project');
|
||||
const isFeaturedOrg = org.location && org.location.toLowerCase().includes('featured');
|
||||
|
||||
const orgName = org.full_name || org.username;
|
||||
|
||||
if (!isProject) {
|
||||
orgNames.add(orgName);
|
||||
}
|
||||
|
||||
if (isProject) {
|
||||
let updatedAt = new Date(0);
|
||||
let mainLang = null;
|
||||
|
||||
try {
|
||||
const reposRes = await fetch(`${API_BASE}/orgs/${org.username}/repos`);
|
||||
if (reposRes.ok) {
|
||||
const repos = await reposRes.json();
|
||||
const langCounts = {};
|
||||
repos.forEach(r => {
|
||||
const d = new Date(r.updated_at);
|
||||
if (d > updatedAt) updatedAt = d;
|
||||
if (r.language) {
|
||||
langCounts[r.language] = (langCounts[r.language] || 0) + 1;
|
||||
}
|
||||
});
|
||||
mainLang = Object.keys(langCounts).reduce((a, b) => langCounts[a] > langCounts[b] ? a : b, null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`failed to fetch repos for project org ${org.username}`, e);
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'project',
|
||||
id: org.id,
|
||||
name: orgName,
|
||||
username: org.username,
|
||||
description: org.description,
|
||||
website: org.website,
|
||||
updated_at: updatedAt,
|
||||
isFeatured: isFeaturedOrg,
|
||||
topics: ['Project'],
|
||||
language: mainLang,
|
||||
orgName: 'Projects',
|
||||
url: `{{AppSubUrl}}/orgs/${org.username}/dashboard`
|
||||
});
|
||||
|
||||
} else {
|
||||
try {
|
||||
const reposRes = await fetch(`${API_BASE}/orgs/${org.username}/repos`);
|
||||
if (reposRes.ok) {
|
||||
const repos = await reposRes.json();
|
||||
repos.forEach(repo => {
|
||||
const isFeaturedRepo = repo.topics && repo.topics.includes('featured');
|
||||
|
||||
const displayTopics = [orgName];
|
||||
if (repo.topics) {
|
||||
repo.topics.forEach(t => {
|
||||
if (t !== 'featured') displayTopics.push(t);
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'repo',
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
description: repo.description,
|
||||
website: repo.website,
|
||||
updated_at: new Date(repo.updated_at),
|
||||
isFeatured: isFeaturedRepo,
|
||||
topics: displayTopics,
|
||||
language: repo.language,
|
||||
orgName: orgName,
|
||||
url: repo.html_url
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`failed to fetch repos for category org ${org.username}`, e);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
allItems = items;
|
||||
populateOrgFilter(orgNames);
|
||||
applySortAndFilter();
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
errorMsg.classList.remove('hidden');
|
||||
} finally {
|
||||
loading.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function populateOrgFilter(names) {
|
||||
orgSelect.innerHTML = '<option value="all">all types</option>';
|
||||
Array.from(names).sort().forEach(n => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n;
|
||||
opt.textContent = n;
|
||||
orgSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function applySortAndFilter() {
|
||||
const query = searchInput.value.toLowerCase();
|
||||
const sortMode = sortSelect.value;
|
||||
const orgFilter = orgSelect.value;
|
||||
|
||||
filteredItems = allItems.filter(item => {
|
||||
if (currentMainFilter !== 'all' && item.type !== currentMainFilter) return false;
|
||||
|
||||
if (orgFilter !== 'all') {
|
||||
if (item.orgName !== orgFilter) return false;
|
||||
}
|
||||
|
||||
const matchName = item.name.toLowerCase().includes(query);
|
||||
const matchDesc = (item.description || '').toLowerCase().includes(query);
|
||||
return matchName || matchDesc;
|
||||
});
|
||||
|
||||
if (sortMode === 'alpha') {
|
||||
filteredItems.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else {
|
||||
filteredItems.sort((a, b) => {
|
||||
if (a.isFeatured && !b.isFeatured) return -1;
|
||||
if (!a.isFeatured && b.isFeatured) return 1;
|
||||
return b.updated_at - a.updated_at;
|
||||
});
|
||||
}
|
||||
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
grid.innerHTML = '<div class="ui center aligned container" style="width:100%; padding: 2rem; color: var(--color-text-light);">no items found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
filteredItems.forEach(item => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'portfolio-card';
|
||||
|
||||
const langColor = getLangColor(item.language);
|
||||
|
||||
card.style.setProperty('--hover-color', langColor);
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.borderColor = langColor;
|
||||
});
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.borderColor = 'var(--color-secondary)';
|
||||
});
|
||||
|
||||
let linksHtml = '';
|
||||
linksHtml += `<a href="${item.url}" class="portfolio-link" style="color: ${langColor}" target="_blank">git</a>`;
|
||||
if (item.website) {
|
||||
linksHtml += `<a href="${item.website}" class="portfolio-link" style="color: ${langColor}" target="_blank">website</a>`;
|
||||
}
|
||||
|
||||
let tagsHtml = '';
|
||||
if (item.topics && item.topics.length > 0) {
|
||||
item.topics.slice(0, 4).forEach(t => {
|
||||
tagsHtml += `<span class="topic-tag">${t}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-content">
|
||||
<div class="barcode-container">
|
||||
<div class="barcode-title" title="${item.name}">${item.name}</div>
|
||||
</div>
|
||||
<div class="card-description">
|
||||
${item.description || '^-^'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="tags-container">
|
||||
${tagsHtml}
|
||||
</div>
|
||||
<div class="links-container">
|
||||
${linksHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.onclick = (e) => {
|
||||
if (e.target.tagName !== 'A') {
|
||||
window.location.href = item.url;
|
||||
}
|
||||
};
|
||||
|
||||
grid.appendChild(card);
|
||||
|
||||
const titleEl = card.querySelector('.barcode-title');
|
||||
fitText(titleEl);
|
||||
});
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', applySortAndFilter);
|
||||
sortSelect.addEventListener('change', applySortAndFilter);
|
||||
orgSelect.addEventListener('change', applySortAndFilter);
|
||||
|
||||
filterButtons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
filterButtons.forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
currentMainFilter = e.target.dataset.filter;
|
||||
applySortAndFilter();
|
||||
});
|
||||
});
|
||||
|
||||
fetchPortfolio();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
document.querySelectorAll('.barcode-title').forEach(fitText);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
3
templates/home.tmpl
Normal file
3
templates/home.tmpl
Normal file
@@ -0,0 +1,3 @@
|
||||
{{template "base/head" .}}
|
||||
{{template "custom/portfolio" .}}
|
||||
{{template "base/footer" .}}
|
||||
3
templates/status/404.tmpl
Normal file
3
templates/status/404.tmpl
Normal file
@@ -0,0 +1,3 @@
|
||||
{{template "base/head" .}}
|
||||
{{template "custom/portfolio" .}}
|
||||
{{template "base/footer" .}}
|
||||
Reference in New Issue
Block a user