3.1.0 - Added Gitea integration for Projects page
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m25s

This commit is contained in:
2025-10-08 16:12:26 -06:00
parent 50e1627ea3
commit 9b98476df6
8 changed files with 612 additions and 347 deletions

View File

@@ -40,6 +40,12 @@ export default defineConfig({
"download", "download",
"web", "web",
"arrow-left", "arrow-left",
"source-commit",
"code-tags",
"tag-multiple",
"clock-outline",
"apple",
"google-play",
], ],
"simple-icons": [ "simple-icons": [
"gitea", "gitea",

View File

@@ -1,7 +1,7 @@
{ {
"name": "atridotdad", "name": "atridotdad",
"type": "module", "type": "module",
"version": "3.0.0", "version": "3.1.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
@@ -15,21 +15,21 @@
"@astrojs/preact": "^4.1.1", "@astrojs/preact": "^4.1.1",
"@astrojs/rss": "^4.0.12", "@astrojs/rss": "^4.0.12",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@preact/signals": "^2.3.1", "@preact/signals": "^2.3.2",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.14",
"astro": "^5.14.1", "astro": "^5.14.1",
"astro-icon": "^1.1.5", "astro-icon": "^1.1.5",
"lucide-preact": "^0.544.0", "lucide-preact": "^0.545.0",
"playwright": "^1.55.1", "playwright": "^1.56.0",
"preact": "^10.27.2", "preact": "^10.27.2",
"sharp": "^0.34.4", "sharp": "^0.34.4",
"tailwindcss": "^4.1.13" "tailwindcss": "^4.1.14"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.2.3",
"@iconify-json/simple-icons": "^1.2.53", "@iconify-json/simple-icons": "^1.2.54",
"daisyui": "^5.1.26" "daisyui": "^5.1.29"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [

594
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
--- ---
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
import type { Project } from "../types"; import type { Project } from "../types";
import { formatRelativeTime } from "../utils/gitea";
interface Props { interface Props {
project: Project; project: Project;
@@ -10,9 +11,9 @@ const { project } = Astro.props;
--- ---
<div <div
class="card bg-accent text-accent-content w-full max-w-sm shrink shadow-md" class="card bg-accent text-accent-content w-full max-w-sm shrink shadow-lg hover:shadow-xl transition-shadow"
> >
<div class="card-body break-words"> <div class="card-body">
<h2 <h2
class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words" class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words"
> >
@@ -24,28 +25,117 @@ const { project } = Astro.props;
</p> </p>
{ {
project.tags && project.tags.length > 0 && ( project.giteaInfo &&
<div class="flex gap-2 flex-wrap mb-4 justify-center"> (project.giteaInfo.commits > 0 ||
{project.tags.map((tag: string) => ( project.giteaInfo.releases > 0 ||
<div class="badge badge-primary font-bold"> project.giteaInfo.language) && (
<Icon name="mdi:tag" class="text-lg" /> <>
{tag} <div class="divider my-2 before:bg-accent-content/30 after:bg-accent-content/30" />
<div class="flex flex-wrap gap-3 justify-center text-sm">
<div class="flex items-center gap-1.5">
<Icon
name="mdi:source-commit"
class="w-4 h-4"
/>
<span class="font-semibold">
{project.giteaInfo.commits}
</span>
<span class="opacity-70">commits</span>
</div>
<div class="flex items-center gap-1.5">
<Icon name="mdi:tag-multiple" class="w-4 h-4" />
<span class="font-semibold">
{project.giteaInfo.releases}
</span>
<span class="opacity-70">releases</span>
</div>
</div> </div>
))} <div class="flex flex-wrap gap-2 justify-center mt-2">
</div> {project.giteaInfo.language && (
) <div class="badge badge-sm gap-1 bg-accent-content/20 border-accent-content/30">
<Icon
name="mdi:code-tags"
class="w-3 h-3"
/>
{project.giteaInfo.language}
</div>
)}
{project.giteaInfo.updatedAt && (
<div class="badge badge-sm gap-1 bg-accent-content/20 border-accent-content/30">
<Icon
name="mdi:clock-outline"
class="w-3 h-3"
/>
{formatRelativeTime(
project.giteaInfo.updatedAt,
)}
</div>
)}
</div>
</>
)
} }
<div class="card-actions justify-end"> {
<a project.giteaInfo?.topics &&
href={project.link} project.giteaInfo.topics.length > 0 && (
target="_blank" <>
rel="noopener noreferrer" <div class="divider my-2 before:bg-accent-content/30 after:bg-accent-content/30" />
class="btn btn-circle" <div class="flex gap-2 flex-wrap justify-center">
aria-label={`Visit ${project.name}`} {project.giteaInfo.topics.map((tag: string) => (
> <div class="badge badge-sm badge-outline gap-1">
<Icon name="mdi:link" class="text-lg" /> <Icon name="mdi:tag" class="w-3 h-3" />
</a> {tag}
</div>
))}
</div>
</>
)
}
<div class="card-actions justify-center gap-2 mt-4">
{
project.link && (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm gap-2 bg-accent-content text-accent hover:bg-accent-content/90"
aria-label={`Visit ${project.name}`}
>
<Icon name="mdi:link" class="w-4 h-4" />
Source
</a>
)
}
{
project.iosLink && (
<a
href={project.iosLink}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm gap-2 bg-accent-content text-accent hover:bg-accent-content/90"
aria-label={`Download ${project.name} on iOS App Store`}
>
<Icon name="mdi:apple" class="w-4 h-4" />
iOS
</a>
)
}
{
project.androidLink && (
<a
href={project.androidLink}
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm gap-2 bg-accent-content text-accent hover:bg-accent-content/90"
aria-label={`Download ${project.name} on Google Play Store`}
>
<Icon name="mdi:google-play" class="w-4 h-4" />
Android
</a>
)
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -142,6 +142,7 @@ export const config: Config = {
url: "https://atri.dad", url: "https://atri.dad",
author: "Atridad Lahiji", author: "Atridad Lahiji",
}, },
giteaDomains: ["https://git.atri.dad"],
}, },
talks: [ talks: [
@@ -160,35 +161,25 @@ export const config: Config = {
name: "OpenClimb", name: "OpenClimb",
description: "FOSS Rock Climbing Tracker for iOS and Android", description: "FOSS Rock Climbing Tracker for iOS and Android",
link: "https://git.atri.dad/atridad/OpenClimb", link: "https://git.atri.dad/atridad/OpenClimb",
tags: [ iosLink: "https://apps.apple.com/app/openclimb/id123456789",
"kotlin",
"jetpack compose",
"swift",
"swiftui",
"mobile",
"monorepo",
],
}, },
{ {
id: "muse", id: "muse",
name: "muse", name: "muse",
description: "Go-based music generation using TOML song definitions", description: "Go-based music generation using TOML song definitions",
link: "https://git.atri.dad/atridad/muse", link: "https://git.atri.dad/atridad/muse",
tags: ["golang", "cli"],
}, },
{ {
id: "magiccounter", id: "magiccounter",
name: "MagicCounter", name: "MagicCounter",
description: "Jeckpack Compose based Magic the Gathering Health Tracker", description: "Jeckpack Compose based Magic the Gathering Health Tracker",
link: "https://git.atri.dad/atridad/MagicCounter", link: "https://git.atri.dad/atridad/MagicCounter",
tags: ["kotlin", "mobile"],
}, },
{ {
id: "mealient", id: "mealient",
name: "Mealient (Fork of project by Kirill Kamakin)", name: "Mealient (Fork of project by Kirill Kamakin)",
description: "An Android client for a self-hosted recipe manager Mealie.", description: "An Android client for a self-hosted recipe manager Mealie.",
link: "https://git.atri.dad/atridad/Mealient", link: "https://git.atri.dad/atridad/Mealient",
tags: ["kotlin", "mobile", "fork"],
}, },
{ {
id: "goth-stack", id: "goth-stack",
@@ -196,7 +187,6 @@ export const config: Config = {
description: description:
"🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀", "🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
link: "https://git.atri.dad/atridad/goth.stack", link: "https://git.atri.dad/atridad/goth.stack",
tags: ["golang", "web"],
}, },
{ {
id: "himbot", id: "himbot",
@@ -204,7 +194,6 @@ export const config: Config = {
description: description:
"A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).", "A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
link: "https://git.atri.dad/atridad/himbot", link: "https://git.atri.dad/atridad/himbot",
tags: ["golang", "webserver"],
}, },
{ {
id: "loadr", id: "loadr",
@@ -212,7 +201,6 @@ export const config: Config = {
description: description:
"A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.", "A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.",
link: "https://git.atri.dad/atridad/loadr", link: "https://git.atri.dad/atridad/loadr",
tags: ["golang", "cli"],
}, },
], ],

View File

@@ -2,6 +2,42 @@
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import ProjectCard from "../components/ProjectCard.astro"; import ProjectCard from "../components/ProjectCard.astro";
import { config } from "../config"; import { config } from "../config";
import { fetchGiteaInfoFromUrl } from "../utils/gitea";
import type { Project } from "../types";
function isGiteaDomain(url: string): boolean {
if (!config.siteConfig.giteaDomains) return true;
try {
const urlObj = new URL(url);
return config.siteConfig.giteaDomains.some(
(domain) => urlObj.origin === new URL(domain).origin,
);
} catch {
return false;
}
}
const projectsWithGiteaInfo = await Promise.all(
config.projects.map(async (project) => {
if (project.link && !project.giteaInfo && isGiteaDomain(project.link)) {
const giteaInfo = await fetchGiteaInfoFromUrl(project.link);
if (giteaInfo) {
return { ...project, giteaInfo } as Project;
}
}
return project;
}),
);
const sortedProjects = projectsWithGiteaInfo.sort((a, b) => {
const aTime = a.giteaInfo?.updatedAt
? new Date(a.giteaInfo.updatedAt).getTime()
: 0;
const bTime = b.giteaInfo?.updatedAt
? new Date(b.giteaInfo.updatedAt).getTime()
: 0;
return bTime - aTime;
});
--- ---
<Layout> <Layout>
@@ -14,15 +50,11 @@ import { config } from "../config";
<div <div
class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto" class="flex flex-row flex-wrap justify-center gap-4 sm:gap-6 max-w-6xl mx-auto"
> >
{ {sortedProjects.map((project) => <ProjectCard project={project} />)}
config.projects.map((project) => (
<ProjectCard project={project} />
))
}
</div> </div>
{ {
config.projects.length === 0 && ( sortedProjects.length === 0 && (
<p class="text-center text-gray-500 mt-12"> <p class="text-center text-gray-500 mt-12">
No projects available yet. Check back soon! No projects available yet. Check back soon!
</p> </p>

View File

@@ -1,5 +1,6 @@
import type { ImageMetadata } from "astro"; import type { ImageMetadata } from "astro";
import type { ComponentType } from "preact"; import type { ComponentType } from "preact";
import type { GiteaRepoInfo } from "./utils/gitea";
// Icon Types // Icon Types
export type LucideIcon = ComponentType<{ size?: number; class?: string }>; export type LucideIcon = ComponentType<{ size?: number; class?: string }>;
@@ -20,8 +21,10 @@ export interface Project {
name: string; name: string;
description: string; description: string;
link: string; link: string;
tags?: string[];
status?: string; status?: string;
iosLink?: string;
androidLink?: string;
giteaInfo?: GiteaRepoInfo;
} }
export interface SocialLink { export interface SocialLink {
@@ -124,6 +127,7 @@ export interface SiteConfig {
url: string; url: string;
author: string; author: string;
}; };
giteaDomains?: string[];
} }
export interface Config { export interface Config {

145
src/utils/gitea.ts Normal file
View File

@@ -0,0 +1,145 @@
export interface GiteaRepoInfo {
commits: number;
releases: number;
language: string;
updatedAt: string;
size: number;
defaultBranch: string;
topics: string[];
}
export interface GiteaConfig {
domain: string;
owner: string;
repo: string;
}
export function parseGiteaUrl(url: string): GiteaConfig | null {
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split("/").filter((p) => p);
if (pathParts.length >= 2) {
return {
domain: urlObj.origin,
owner: pathParts[0],
repo: pathParts[1],
};
}
} catch (e) {
// Invalid URL
}
return null;
}
export async function fetchGiteaRepoInfo(
config: GiteaConfig,
): Promise<GiteaRepoInfo | null> {
try {
const apiUrl = `${config.domain}/api/v1/repos/${config.owner}/${config.repo}`;
const response = await fetch(apiUrl, {
headers: {
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return null;
}
const data = await response.json();
let commitsCount = 0;
try {
const commitsUrl = `${config.domain}/api/v1/repos/${config.owner}/${config.repo}/commits?limit=1&stat=false`;
const commitsResponse = await fetch(commitsUrl, {
headers: {
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});
if (commitsResponse.ok) {
const totalCount = commitsResponse.headers.get("X-Total-Count");
commitsCount = totalCount ? parseInt(totalCount, 10) : 0;
}
} catch (error) {
// Ignore
}
let releasesCount = 0;
try {
const releasesUrl = `${config.domain}/api/v1/repos/${config.owner}/${config.repo}/releases`;
const releasesResponse = await fetch(releasesUrl, {
headers: {
Accept: "application/json",
},
signal: AbortSignal.timeout(5000),
});
if (releasesResponse.ok) {
const releases = await releasesResponse.json();
releasesCount = Array.isArray(releases) ? releases.length : 0;
}
} catch (error) {
// Ignore
}
return {
commits: commitsCount,
releases: releasesCount,
language: data.language || "Unknown",
updatedAt: data.updated_at || data.pushed_at || "",
size: data.size || 0,
defaultBranch: data.default_branch || "main",
topics: Array.isArray(data.topics) ? data.topics : [],
};
} catch (error) {
return null;
}
}
export async function fetchGiteaInfoFromUrl(
url: string,
): Promise<GiteaRepoInfo | null> {
const config = parseGiteaUrl(url);
if (!config) {
return null;
}
return fetchGiteaRepoInfo(config);
}
export function formatRelativeTime(dateString: string): string {
if (!dateString) return "Unknown";
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes < 60) {
return `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
} else if (diffDays < 30) {
return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
} else if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `${months} month${months !== 1 ? "s" : ""} ago`;
} else {
const years = Math.floor(diffDays / 365);
return `${years} year${years !== 1 ? "s" : ""} ago`;
}
}
export function formatRepoSize(sizeKb: number): string {
if (sizeKb < 1024) {
return `${sizeKb} KB`;
} else if (sizeKb < 1024 * 1024) {
return `${(sizeKb / 1024).toFixed(1)} MB`;
} else {
return `${(sizeKb / (1024 * 1024)).toFixed(1)} GB`;
}
}