feat(templates): add projects page

This commit is contained in:
h
2025-11-25 20:09:45 +01:00
commit 9477b8137a
6 changed files with 524 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
repomix-output.xml
conf/

1
README.md Normal file
View File

@@ -0,0 +1 @@
# Gitea themes + main page for git.kotikot.com

View File

@@ -0,0 +1,3 @@
{{if .IsSigned}}
<a class="item" href="/projects">projects</a>
{{end}}

View 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
View File

@@ -0,0 +1,3 @@
{{template "base/head" .}}
{{template "custom/portfolio" .}}
{{template "base/footer" .}}

View File

@@ -0,0 +1,3 @@
{{template "base/head" .}}
{{template "custom/portfolio" .}}
{{template "base/footer" .}}