chore: add techbase to apps-archived

Integrated techbase (software comparison platform) into monorepo structure:
- Created NestJS backend with votes and comments modules
- Migrated from external Supabase to own PostgreSQL
- Set up Drizzle ORM schema for votes and comments
- Created API client replacing Supabase in Astro frontend
- Added environment configuration (port 3021)

Archived immediately as it's not yet ready for active development.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-05 13:47:39 +01:00
parent 17313473aa
commit 34c879929b
161 changed files with 12613 additions and 0 deletions

View file

@ -0,0 +1,115 @@
/**
* API Client for TechBase Backend
* Replaces Supabase with direct calls to NestJS backend
*/
const API_URL = import.meta.env.PUBLIC_BACKEND_URL || 'http://localhost:3021';
interface VoteResult {
success: boolean;
newAverage: number;
voteCount: number;
}
interface CommentResult {
success: boolean;
message: string;
}
interface MetricsResult {
metrics: Record<string, { average: number; count: number }>;
}
/**
* Submit a vote for a software metric
*/
export async function submitVote(
softwareId: string,
metric: string,
rating: number
): Promise<VoteResult> {
const res = await fetch(`${API_URL}/api/votes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ softwareId, metric, rating }),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Vote failed' }));
throw new Error(error.message || 'Failed to submit vote');
}
return res.json();
}
/**
* Get metrics for a specific software
*/
export async function getMetrics(softwareId: string): Promise<MetricsResult> {
const res = await fetch(`${API_URL}/api/votes/${softwareId}/metrics`);
if (!res.ok) {
// Return default metrics on error
return {
metrics: {
easeOfUse: { average: 0, count: 0 },
featureRichness: { average: 0, count: 0 },
valueForMoney: { average: 0, count: 0 },
support: { average: 0, count: 0 },
reliability: { average: 0, count: 0 },
},
};
}
return res.json();
}
/**
* Get all metrics for multiple software items
*/
export async function getAllMetrics(): Promise<
Record<string, Record<string, { average: number; count: number }>>
> {
const res = await fetch(`${API_URL}/api/votes/metrics/all`);
if (!res.ok) {
return {};
}
return res.json();
}
/**
* Submit a comment for review
*/
export async function submitComment(
softwareId: string,
userName: string,
comment: string
): Promise<CommentResult> {
const res = await fetch(`${API_URL}/api/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ softwareId, userName, comment }),
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: 'Comment failed' }));
throw new Error(error.message || 'Failed to submit comment');
}
return res.json();
}
/**
* Get approved comments for a software
*/
export async function getComments(softwareId: string): Promise<any[]> {
const res = await fetch(`${API_URL}/api/comments/${softwareId}`);
if (!res.ok) {
return [];
}
return res.json();
}

View file

@ -0,0 +1,18 @@
import { i18nConfig } from '../content/config';
export function getLocalizedUrl(path: string, locale: string) {
return `/${locale}${path.startsWith('/') ? path : '/' + path}`;
}
export function getLocaleFromUrl(url: URL) {
const [, locale] = url.pathname.split('/');
if (i18nConfig.locales.includes(locale)) {
return locale;
}
return i18nConfig.defaultLocale;
}
export async function loadTranslations(locale: string) {
const translations = await import(`../content/translations/${locale}.json`);
return translations.default;
}

View file

@ -0,0 +1,117 @@
// Search utility functions
/**
* Filters software based on category, platform, pricing, and rating filters
* @param softwareList The list of software to filter
* @param filters The filters to apply
* @returns Filtered software list
*/
export function filterSoftware(softwareList, filters) {
return softwareList.filter(software => {
// Filter by categories
if (filters.categories.length > 0) {
if (!software.categories?.some(category => filters.categories.includes(category))) {
return false;
}
}
// Filter by platforms
if (filters.platforms.length > 0) {
if (!software.platforms?.some(platform => filters.platforms.includes(platform))) {
return false;
}
}
// Filter by price range
if (filters.priceRanges.length > 0) {
const hasFree = filters.priceRanges.includes('free') &&
software.pricing?.some(p => p.model.toLowerCase().includes('free') || p.price.includes('0') || p.price.includes('kostenlos'));
const hasPaid = filters.priceRanges.includes('paid') &&
software.pricing?.some(p => !p.model.toLowerCase().includes('free') &&
!p.model.toLowerCase().includes('subscription') &&
!p.price.includes('0') &&
!p.price.includes('kostenlos'));
const hasSubscription = filters.priceRanges.includes('subscription') &&
software.pricing?.some(p => p.model.toLowerCase().includes('subscription') ||
p.model.toLowerCase().includes('abonnement') ||
p.price.toLowerCase().includes('/month') ||
p.price.toLowerCase().includes('/monat'));
if (!(hasFree || hasPaid || hasSubscription)) {
return false;
}
}
// Filter by rating thresholds
const metrics = software.metrics || {};
for (const [metric, threshold] of Object.entries(filters.ratingThresholds)) {
if (threshold > 0) {
const rating = metrics[metric]?.average || 0;
if (rating < threshold) {
return false;
}
}
}
return true;
});
}
/**
* Sorts software by various criteria
* @param softwareList The list of software to sort
* @param sortBy The sort criterion
* @param sortOrder The sort order ('asc' or 'desc')
* @returns Sorted software list
*/
export function sortSoftware(softwareList, sortBy = 'rating', sortOrder = 'desc') {
return [...softwareList].sort((a, b) => {
let valueA, valueB;
switch (sortBy) {
case 'name':
valueA = a.name.toLowerCase();
valueB = b.name.toLowerCase();
break;
case 'rating':
// Calculate average rating
const metricsA = a.metrics || {};
const metricsB = b.metrics || {};
const ratingsA = Object.values(metricsA).map(m => m.average || 0);
const ratingsB = Object.values(metricsB).map(m => m.average || 0);
valueA = ratingsA.length > 0 ? ratingsA.reduce((sum, r) => sum + r, 0) / ratingsA.length : 0;
valueB = ratingsB.length > 0 ? ratingsB.reduce((sum, r) => sum + r, 0) / ratingsB.length : 0;
break;
case 'votes':
// Calculate total votes
const votesA = Object.values(a.metrics || {}).reduce((sum, m) => sum + (m.count || 0), 0);
const votesB = Object.values(b.metrics || {}).reduce((sum, m) => sum + (m.count || 0), 0);
valueA = votesA;
valueB = votesB;
break;
case 'date':
valueA = new Date(a.lastUpdated).getTime();
valueB = new Date(b.lastUpdated).getTime();
break;
default:
valueA = a.name.toLowerCase();
valueB = b.name.toLowerCase();
}
// Apply sort order
if (sortOrder === 'asc') {
return valueA > valueB ? 1 : valueA < valueB ? -1 : 0;
} else {
return valueA < valueB ? 1 : valueA > valueB ? -1 : 0;
}
});
}

View file

@ -0,0 +1,66 @@
// Zentraler Daten-Store für Software-Informationen
document.addEventListener('alpine:init', () => {
Alpine.store('software', {
// Aktuelle Software, die im Detail angezeigt wird (aus MD-Datei geladen)
current: null,
// Software zum Vergleich (über API geladen)
comparison: null,
// Ähnliche Software-Optionen
similar: [],
// Status-Flags
loading: false,
error: null,
// Modus-Flags
compareMode: false,
// Lädt Vergleichssoftware über API
async loadComparisonSoftware(id, locale) {
if (!id) return;
this.loading = true;
this.error = null;
try {
const response = await fetch(`/api/software/${id}.json?lang=${locale}`);
if (!response.ok) {
throw new Error(`Error loading software: ${response.status}`);
}
const data = await response.json();
this.comparison = data;
return data;
} catch (error) {
console.error('Error loading comparison software:', error);
this.error = 'Failed to load software data. Please try again.';
} finally {
this.loading = false;
}
},
// Setzt den aktuellen Vergleichsmodus
setCompareMode(mode) {
this.compareMode = mode;
if (!mode) {
// Bei Beenden des Vergleichsmodus Daten zurücksetzen
this.comparison = null;
}
},
// Initialisiert ähnliche Software-Optionen
initSimilarSoftware(similarOptions) {
this.similar = similarOptions || [];
},
// Initialisiert die aktuelle Software
initCurrentSoftware(software) {
this.current = software;
}
});
});

View file

@ -0,0 +1,99 @@
import { getCollection } from 'astro:content';
import { getMetrics } from './api';
interface SoftwareData {
id: string;
name: string;
description: string;
logo?: string;
screenshots?: string[];
features: string[];
platforms: string[];
categories: string[];
pricing: {
model: string;
price: string;
yearly_price?: string;
features: string[];
}[];
website: string;
lastUpdated: string;
metrics?: Record<string, { average: number; count: number }>;
similarSoftware?: any[];
}
export async function loadSoftwareData(entry: any, locale: string): Promise<SoftwareData> {
// Extract the software ID
const id = entry.id.split('/')[1]; // The format is 'locale/id'
// Get software data from the entry
const software: SoftwareData = {
id,
...entry.data,
};
// Load metrics from backend
await loadMetricsFromBackend(software.id, software);
// Load similar software
await loadSimilarSoftware(software, locale);
return software;
}
async function loadMetricsFromBackend(softwareId: string, software: SoftwareData): Promise<void> {
// Default metrics
software.metrics = {
easeOfUse: { average: 4.2, count: 15 },
featureRichness: { average: 3.8, count: 12 },
valueForMoney: { average: 4.5, count: 18 },
support: { average: 3.5, count: 10 },
reliability: { average: 4.0, count: 14 },
};
try {
const result = await getMetrics(softwareId);
if (result && result.metrics) {
software.metrics = result.metrics;
}
} catch (error) {
console.error('Error fetching metrics from backend:', error);
// Continue with default metrics
}
}
async function loadSimilarSoftware(software: SoftwareData, locale: string): Promise<void> {
try {
// Get all software entries
const allSoftware = await getCollection('software');
// Filter similar software based on categories
const similarSoftwareEntries = allSoftware.filter(item => {
// Skip the current software
if (item.id === `${locale}/${software.id}`) return false;
// Extract the software's locale and categories
const [itemLocale, itemId] = item.id.split('/');
const itemCategories = item.data.categories || [];
// Only include software with the same locale and at least one shared category
return itemLocale === locale &&
itemCategories.some(cat => software.categories.includes(cat));
});
// Convert entries to a more usable format for the frontend
software.similarSoftware = similarSoftwareEntries.map(item => {
const [itemLocale, itemId] = item.id.split('/');
return {
id: itemId,
name: item.data.name,
description: item.data.description,
categories: item.data.categories || [],
logo: item.data.logo || '/logos/sample-logo.svg'
};
});
} catch (error) {
console.error('Error loading similar software:', error);
software.similarSoftware = [];
}
}