7 Commits

8 changed files with 676 additions and 12 deletions

View File

@@ -1,11 +1,7 @@
*WIP*
# Website for Open Bokeron
This repo hosts the website for Open Bokeron which is a student organization in University of Málaga whose aim is to promote the use of free(as in freedom) software.
The philosophy with this website for now is to KISS(keep it stupid simple) using only HTML5 & CSS 3.
## Developer preview
You need [Hugo](https://gohugo.io/) installed.
@@ -18,7 +14,7 @@ hugo server
## TODO
- [x] Add our logo on the website, favicon.
- [ ] Add content describing our association and other pages as needed.
- [x] Add content describing our association and other pages as needed.
- [x] Work on SEO (OpenGraph, Twitter meta tags...)
- [x] Multilingual support(English + Spanish)
- [x] Support for RSS (index.xml)

View File

@@ -1,12 +1,340 @@
/* Override colors from CSS library */
:root {
--accent: green;
--accent-hover: darkgreen;
--accent: green;
--accent-hover: darkgreen;
}
/* Custom style for the logo */
img.icon {
max-width: 128px;
max-height: 128px;
margin-top: 10px;
max-width: 128px;
max-height: 128px;
margin-top: 10px;
}
:root {
--activity-bg: transparent;
--activity-panel: rgba(255, 255, 255, 0.03);
--activity-card: rgba(255, 255, 255, 0.04);
--activity-border: rgba(255, 255, 255, 0.08);
--activity-muted: rgba(255, 255, 255, 0.58);
--activity-accent: rgba(255, 255, 255, 0.82);
--activity-accent-soft: rgba(255, 255, 255, 0.1);
--activity-warning: #b9986a;
--activity-tag: rgba(255, 255, 255, 0.06);
}
.activities-shell {
margin-top: 2.5rem;
padding: 0;
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
}
.activities-header {
display: flex;
flex-wrap: wrap;
gap: 2rem;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2.5rem;
}
.activities-kicker {
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 0.62rem;
color: var(--activity-muted);
margin-bottom: 0.6rem;
}
.activities-title {
margin-top: 0;
margin-bottom: 0.8rem;
font-size: clamp(1.7rem, 2vw + 1.2rem, 2.4rem);
}
.activities-lead {
max-width: 38rem;
color: var(--activity-muted);
}
.activities-actions {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
align-items: center;
}
.activities-actions .button {
background: transparent;
border: 1px solid var(--activity-border);
color: #e7e9ec;
padding: 0.45rem 0.9rem;
border-radius: 999px;
transition: all 0.2s ease;
}
.activities-actions .button:hover {
border-color: rgba(255, 255, 255, 0.22);
color: #f5f7fa;
}
.activities-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.25rem;
}
.activity-card {
border-radius: 12px;
padding: 1.25rem;
background: var(--activity-card);
border: 1px solid var(--activity-border);
position: relative;
overflow: hidden;
min-height: 210px;
display: flex;
flex-direction: column;
gap: 0.8rem;
transition: border-color 0.2s ease;
}
.activity-card:hover {
border-color: rgba(255, 255, 255, 0.18);
}
.activity-card h3 {
margin: 0;
font-size: 1.1rem;
}
.activity-card p {
margin: 0;
color: var(--activity-muted);
font-size: 0.92rem;
}
.activity-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.activity-status {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.7rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.8);
}
.activity-status::before {
content: "●";
font-size: 0.6rem;
}
.activity-status.status-closed {
background: rgba(185, 152, 106, 0.25);
color: var(--activity-warning);
}
.activity-status.status-cooking {
background: green;
color: #f5f7fa;
}
.activity-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--activity-muted);
}
.activity-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.activity-tags .activity-tag:nth-child(n + 3) {
display: none;
}
.activity-tag {
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: var(--activity-tag);
color: #d5d9e0;
font-size: 0.68rem;
}
.activity-link {
margin-top: auto;
color: #f5f7fa;
font-weight: 600;
text-decoration: none;
}
.activity-link:hover {
text-decoration: underline;
}
.activities-section {
margin-bottom: 2.5rem;
}
.activities-section-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 1.2rem;
}
.activities-section-head h3 {
margin: 0;
font-size: 1.05rem;
}
.activities-section-head p {
margin: 0;
color: var(--activity-muted);
font-size: 0.88rem;
}
.activities-ideas {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.activities-idea {
padding: 0.75rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--activity-border);
background: var(--activity-panel);
display: flex;
flex-wrap: wrap;
gap: 0.6rem 1rem;
justify-content: space-between;
align-items: center;
color: #e0e5ea;
font-size: 0.92rem;
}
.activities-idea a {
color: #f5f7fa;
font-weight: 600;
text-decoration: none;
}
.activities-idea a:hover {
text-decoration: underline;
}
.activities-footer {
margin-top: 2.4rem;
padding: 1.4rem 1.6rem;
border-radius: 16px;
background: var(--activity-panel);
border: 1px solid var(--activity-border);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.activities-footer p {
margin: 0;
color: var(--activity-muted);
}
.activities-footer .button {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.22);
color: #f5f7fa;
padding: 0.5rem 1.1rem;
border-radius: 999px;
}
.skeleton {
position: relative;
overflow: hidden;
}
.skeleton::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(110deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08));
animation: shimmer 1.8s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.activities-empty {
grid-column: 1 / -1;
padding: 1.5rem;
text-align: center;
background: var(--activity-panel);
border-radius: 12px;
border: 1px dashed var(--activity-border);
color: var(--activity-muted);
}
.activities-fallback {
margin-top: 1.2rem;
padding: 1rem 1.2rem;
border-radius: 12px;
border: 1px solid var(--activity-border);
color: var(--activity-muted);
font-size: 0.9rem;
}
.activities-fallback a {
color: #f5f7fa;
}
@media (max-width: 720px) {
.activities-shell {
padding: 0;
}
.activities-actions {
width: 100%;
}
.activities-actions .button {
width: 100%;
text-align: center;
}
.activities-idea {
flex-direction: column;
align-items: flex-start;
}
.activities-footer {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,42 @@
---
title: Activities in progress
---
See what we are preparing and the current status of each proposal. Everything lives in our public Gitea board.
<div class="activities-shell" data-activities-root>
<header class="activities-header">
<div class="activities-actions">
<a class="button" href="https://openbokeron.uma.es/gitea/OpenBokeron/-/projects/5" target="_blank" rel="noopener">View board</a>
<a class="button" href="https://openbokeron.uma.es/gitea/OpenBokeron/Actividades/issues" target="_blank" rel="noopener">View issues</a>
</div>
</header>
<section class="activities-section" data-activities-working>
<div class="activities-section-head">
<h3>In development</h3>
</div>
<div class="activities-grid" data-activities-grid>
<article class="activity-card skeleton">
<div class="activity-head">
<span class="activity-status">Loading</span>
</div>
<h3>Loading...</h3>
</article>
</div>
</section>
<section class="activities-section" data-activities-ideas>
<div class="activities-section-head">
<h3>Backlog</h3>
</div>
<ul class="activities-ideas" data-activities-ideas-list>
<li class="activities-idea skeleton">Loading...</li>
</ul>
</section>
<footer class="activities-footer">
<p>Looking for past activities? You will find them in the Projects tab.</p>
<p>Want to propose something new? Drop by the Contact tab, we do not bite.</p>
</footer>
</div>

View File

@@ -0,0 +1,42 @@
---
title: Actividades en marcha
---
Descubre lo que estamos preparando y en qué punto está cada propuesta. Todo esto se coordina en nuestro tablero público de Gitea.
<div class="activities-shell" data-activities-root>
<header class="activities-header">
<div class="activities-actions">
<a class="button" href="https://openbokeron.uma.es/gitea/OpenBokeron/-/projects/5" target="_blank" rel="noopener">Ver tablero</a>
<a class="button" href="https://openbokeron.uma.es/gitea/OpenBokeron/Actividades/issues" target="_blank" rel="noopener">Ver issues</a>
</div>
</header>
<section class="activities-section" data-activities-working>
<div class="activities-section-head">
<h3>En desarrollo</h3>
</div>
<div class="activities-grid" data-activities-grid>
<article class="activity-card skeleton">
<div class="activity-head">
<span class="activity-status">Cargando</span>
</div>
<h3>Cargando...</h3>
</article>
</div>
</section>
<section class="activities-section" data-activities-ideas>
<div class="activities-section-head">
<h3>En el tintero</h3>
</div>
<ul class="activities-ideas" data-activities-ideas-list>
<li class="activities-idea skeleton">Cargando...</li>
</ul>
</section>
<footer class="activities-footer">
<p> ¿Buscas las Actividades ya realizadas? Las tienes en la pestaña de Proyectos ;) </p>
<p>¿Quieres proponer algo nuevo? Pásate por la pestaña Contacto, no mordemos.</p>
</footer>
</div>

View File

@@ -14,4 +14,4 @@ Queremos invitaros al podcast [Podcasteando con amigos](https://www.podcasteando
![Cartel del episodio](/assets/podcast/cartel.jpg)
Esperamos que os haya gustado la actividad y esperamos veros en la siguiente!!
Si os interesa el tema y tenéis un rato libre, os animamos a que vengáis.

View File

@@ -34,6 +34,12 @@ buildFuture = true
url = "/en/projects"
weight = 30
[[languages.en.menu.main]]
identifier = "activities"
name = "Activities"
url = "/en/activities"
weight = 35
[[languages.en.menu.main]]
identifier = "contact"
name = "Contact"
@@ -65,6 +71,12 @@ buildFuture = true
url = "/es/projects"
weight = 30
[[languages.es.menu.main]]
identifier = "activities"
name = "Actividades"
url = "/es/activities"
weight = 35
[[languages.es.menu.main]]
identifier = "contact"
name = "Contacto"
@@ -76,3 +88,8 @@ buildFuture = true
[params]
[params.social]
twitter = 'OpenBokeron'
[markup]
[markup.goldmark]
[markup.goldmark.renderer]
unsafe = true

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="{{ or site.Language.LanguageCode site.Language.Lang }}" dir="{{ or site.Language.LanguageDirection `ltr` }}">
<html lang="{{ or site.Language.LanguageCode site.Language.Lang }}"
dir="{{ or site.Language.LanguageDirection `ltr` }}">
<head>
{{ partial "head.html" . -}}
@@ -15,6 +16,9 @@
<footer>
{{ partial "footer.html" . -}}
</footer>
{{ if eq .Section "activities" }}
<script src="/assets/js/activities.js" defer></script>
{{ end }}
</body>
</html>

View File

@@ -0,0 +1,235 @@
const ACTIVITIES_CONFIG = {
apiBase: "https://openbokeron.uma.es/gitea/api/v1",
repo: "OpenBokeron/Actividades",
state: "open",
};
const ACTIVITY_LABELS = {
cooking: "cocinando",
};
const ACTIVITY_BADGES = {
es: {
cooking: "Cocinando",
},
en: {
cooking: "Cooking",
},
};
const ACTIVITY_BADGE_STYLE = {
cooking: "status-cooking",
};
const ACTIVITY_CTA = {
es: "Ver en Gitea",
en: "View on Gitea",
};
const ACTIVITY_EMPTY = {
es: "Ahora mismo no hay actividades abiertas. Vuelve pronto o mira el tablero completo.",
en: "There are no open activities right now. Come back soon or check the full board.",
};
const FALLBACK_TEXT = {
es: "Oops, la hemos liado al querer cargar el tablero. Puedes verlo directamente en Gitea:",
en: "Oops, we messed up while trying to load the board. You can still open it in Gitea:",
};
const FALLBACK_LINK = {
es: "Abrir tablero",
en: "Open board",
};
const LANGUAGE_LABELS = {
es: {
milestone: "Hito",
comments: "comentarios",
updated: "Actualizado",
noCooking: "No hay actividades en desarrollo. Mira el tablero completo.",
noIdeas: "No hay ideas nuevas ahora mismo.",
},
en: {
milestone: "Milestone",
comments: "comments",
updated: "Updated",
noCooking: "No activities in development. Check the full board.",
noIdeas: "There are no new ideas right now.",
},
};
const escapeHtml = (value) => {
if (value === null || value === undefined) {
return "";
}
return String(value).replace(/[&<>"']/g, (char) => {
const map = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return map[char] || char;
});
};
const container = document.querySelector("[data-activities-root]");
if (container) {
const grid = container.querySelector("[data-activities-grid]");
const lang = document.documentElement.lang.startsWith("es") ? "es" : "en";
const labels = LANGUAGE_LABELS[lang];
const fetchActivities = async () => {
const url = `${ACTIVITIES_CONFIG.apiBase}/repos/${ACTIVITIES_CONFIG.repo}/issues?state=${ACTIVITIES_CONFIG.state}`;
const response = await fetch(url, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
};
const formatDate = (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(lang, {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
};
const getLabelNames = (issue) => (issue.labels || []).map((label) => label.name.toLowerCase());
const hasLabel = (issue, label) => getLabelNames(issue).includes(label);
const renderTags = (tags) => {
if (!tags || tags.length === 0) {
return "";
}
return tags
.filter((label) => label.name.toLowerCase() !== ACTIVITY_LABELS.cooking)
.slice(0, 3)
.map((label) => `<span class="activity-tag">${label.name}</span>`)
.join("");
};
const getBadge = (issue) => {
if (hasLabel(issue, ACTIVITY_LABELS.cooking)) {
return {
text: ACTIVITY_BADGES[lang].cooking,
className: ACTIVITY_BADGE_STYLE.cooking,
};
}
return null;
};
const renderActivity = (issue) => {
const tags = renderTags(issue.labels);
const milestone = issue.milestone?.title;
const updated = formatDate(issue.updated_at);
const badge = getBadge(issue);
const comments = issue.comments || 0;
return `
<article class="activity-card">
<div class="activity-head">
${badge ? `<span class="activity-status ${badge.className}">${badge.text}</span>` : ""}
</div>
<h3>${escapeHtml(issue.title)}</h3>
<p>${issue.body ? escapeHtml(issue.body.replace(/\s+/g, " ").slice(0, 140) + "...") : ""}</p>
<div class="activity-meta">
<span>${labels.updated} ${updated}</span>
<span>${comments} ${labels.comments}</span>
<span>#${issue.number}</span>
</div>
${milestone ? `<div class="activity-meta">${labels.milestone}: ${milestone}</div>` : ""}
${tags ? `<div class="activity-tags">${tags}</div>` : ""}
<a class="activity-link" href="${issue.html_url}" target="_blank" rel="noopener">${ACTIVITY_CTA[lang]}</a>
</article>
`;
};
const renderIdeas = (ideas) => {
const ideasList = container.querySelector("[data-activities-ideas-list]");
if (!ideasList) {
return;
}
if (!ideas || ideas.length === 0) {
ideasList.innerHTML = `<li class="activities-idea">${labels.noIdeas}</li>`;
return;
}
ideasList.innerHTML = ideas
.map(
(issue) => `
<li class="activities-idea">
<div>
<strong>${escapeHtml(issue.title)}</strong>
<span class="activity-meta">#${issue.number}</span>
</div>
<a href="${issue.html_url}" target="_blank" rel="noopener">${ACTIVITY_CTA[lang]}</a>
</li>
`
)
.join("");
};
const renderActivities = (issues) => {
if (!issues || issues.length === 0) {
grid.innerHTML = `<div class="activities-empty">${ACTIVITY_EMPTY[lang]}</div>`;
renderIdeas([]);
return;
}
const cooking = issues.filter((issue) => hasLabel(issue, ACTIVITY_LABELS.cooking));
const ideas = issues.filter((issue) => !hasLabel(issue, ACTIVITY_LABELS.cooking));
const cookingTitles = new Set(cooking.map((issue) => issue.title));
const filteredIdeas = ideas.filter((issue) => !cookingTitles.has(issue.title));
if (cooking.length === 0) {
grid.innerHTML = `<div class="activities-empty">${labels.noCooking}</div>`;
} else {
grid.innerHTML = cooking.map(renderActivity).join("");
}
renderIdeas(filteredIdeas);
};
const renderFallback = () => {
grid.innerHTML = `
<div class="activities-empty">
${FALLBACK_TEXT[lang]}
<a href="https://openbokeron.uma.es/gitea/OpenBokeron/-/projects/5" target="_blank" rel="noopener">${FALLBACK_LINK[lang]}</a>
</div>
`;
const ideasList = container.querySelector("[data-activities-ideas-list]");
if (ideasList) {
ideasList.innerHTML = `<li class="activities-idea">${labels.noIdeas}</li>`;
}
};
fetchActivities()
.then((issues) => {
const sorted = [...issues].sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
renderActivities(sorted);
})
.catch(() => {
renderFallback();
});
}