managarten/apps-archived/techbase/apps/web/src/components/ComparisonTable.astro
Till-JS 34c879929b 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>
2025-12-05 13:47:39 +01:00

288 lines
No EOL
12 KiB
Text

---
import { getLocaleFromUrl, loadTranslations } from '../utils/i18n';
const { softwareList } = Astro.props;
const locale = getLocaleFromUrl(Astro.url);
const t = await loadTranslations(locale);
// Get all unique platforms from the software list
const allPlatforms = new Set();
softwareList.forEach(software => {
software.platforms?.forEach(platform => allPlatforms.add(platform));
});
const platforms = Array.from(allPlatforms).sort();
// Get all unique features from the software list
const allFeatures = new Set();
softwareList.forEach(software => {
software.features?.forEach(feature => allFeatures.add(feature));
});
const features = Array.from(allFeatures).sort();
// Get all metric types
const metrics = [
{ id: 'easeOfUse', name: t.voting.usability },
{ id: 'featureRichness', name: t.voting.features },
{ id: 'valueForMoney', name: t.voting.value },
{ id: 'support', name: t.voting.support },
{ id: 'reliability', name: t.voting.reliability },
];
---
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-hidden transition-colors duration-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<!-- Header row with software names -->
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="w-1/4 px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Features
</th>
{softwareList.map(software => (
<th class="px-6 py-3 text-center">
<div class="flex flex-col items-center">
{software.logo && (
<div class="w-16 h-16 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-2 flex items-center justify-center mb-2 mx-auto">
<img src={software.logo} alt={software.name} class="max-w-full max-h-full" />
</div>
)}
<a href={`/${locale}/software/${software.id}`} class="font-bold text-primary dark:text-blue-400 hover:underline">
{software.name}
</a>
</div>
</th>
))}
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<!-- General info section -->
<tr class="bg-gray-50 dark:bg-gray-700">
<td colspan={softwareList.length + 1} class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
General Information
</td>
</tr>
<!-- Description -->
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</td>
{softwareList.map(software => (
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs">
<p class="line-clamp-3">{software.description}</p>
</td>
))}
</tr>
<!-- Website -->
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Website
</td>
{softwareList.map(software => (
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<a href={software.website} target="_blank" rel="noopener noreferrer" class="text-primary dark:text-blue-400 hover:underline">
Visit site
</a>
</td>
))}
</tr>
<!-- Categories -->
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Categories
</td>
{softwareList.map(software => (
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap gap-1 justify-center">
{software.categories?.map(category => (
<span class="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full text-xs inline-block mb-1 mr-1">
{category}
</span>
))}
</div>
</td>
))}
</tr>
<!-- Pricing section -->
<tr class="bg-gray-50 dark:bg-gray-700">
<td colspan={softwareList.length + 1} class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Pricing
</td>
</tr>
<!-- Pricing models -->
{['Free', 'Paid', 'Subscription'].map(pricingModel => (
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-700 dark:text-gray-300">
{pricingModel} Plan
</td>
{softwareList.map(software => {
const plan = software.pricing?.find(p => p.model.toLowerCase().includes(pricingModel.toLowerCase()));
return (
<td class="px-6 py-4 text-sm text-center">
{plan ? (
<div>
<div class="font-medium text-gray-900 dark:text-white">{plan.price}</div>
<ul class="mt-2 text-xs text-gray-500 dark:text-gray-400 text-left list-disc pl-4 space-y-1">
{plan.features?.slice(0, 3).map(feature => (
<li>{feature}</li>
))}
{plan.features?.length > 3 && (
<li class="text-primary dark:text-blue-400">+{plan.features.length - 3} more</li>
)}
</ul>
</div>
) : (
<span class="text-gray-400 dark:text-gray-500">—</span>
)}
</td>
);
})}
</tr>
))}
<!-- Ratings section -->
<tr class="bg-gray-50 dark:bg-gray-700">
<td colspan={softwareList.length + 1} class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Ratings
</td>
</tr>
<!-- Average rating -->
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-700 dark:text-gray-300">
Average Rating
</td>
{softwareList.map(software => {
const metrics = software.metrics || {};
const metricKeys = Object.keys(metrics);
let averageRating = 0;
let totalVotes = 0;
if (metricKeys.length > 0) {
let totalRating = 0;
metricKeys.forEach(key => {
totalRating += metrics[key].average || 0;
totalVotes += metrics[key].count || 0;
});
averageRating = totalRating / metricKeys.length;
}
return (
<td class="px-6 py-4 text-center">
<div class="flex items-center justify-center">
<span class="text-yellow-400 mr-1 text-lg">★</span>
<span class="font-bold text-lg dark:text-white">{averageRating.toFixed(1)}</span>
</div>
{totalVotes > 0 && (
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Based on {totalVotes} ratings
</div>
)}
</td>
);
})}
</tr>
<!-- Individual metrics -->
{metrics.map(metric => (
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-700 dark:text-gray-300">
{metric.name}
</td>
{softwareList.map(software => {
const metricData = software.metrics?.[metric.id] || { average: 0, count: 0 };
const rating = metricData.average || 0;
const voteCount = metricData.count || 0;
return (
<td class="px-6 py-4 text-center">
<div class="w-full max-w-xs mx-auto">
<div class="flex justify-between items-center mb-1 text-xs">
<span class="dark:text-gray-300">{rating.toFixed(1)}/5</span>
<span class="text-gray-500 dark:text-gray-400">{voteCount} votes</span>
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
class="h-full bg-primary dark:bg-blue-500"
style={`width: ${(rating / 5) * 100}%`}
></div>
</div>
</div>
</td>
);
})}
</tr>
))}
<!-- Features section -->
<tr class="bg-gray-50 dark:bg-gray-700">
<td colspan={softwareList.length + 1} class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Features
</td>
</tr>
<!-- Feature comparison -->
{features.map(feature => (
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-700 dark:text-gray-300">
{feature}
</td>
{softwareList.map(software => {
const hasFeature = software.features?.some(f => f === feature || f.includes(feature));
return (
<td class="px-6 py-4 text-center">
{hasFeature ? (
<svg class="h-5 w-5 text-green-500 mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
) : (
<svg class="h-5 w-5 text-red-500 mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
)}
</td>
);
})}
</tr>
))}
<!-- Platforms section -->
<tr class="bg-gray-50 dark:bg-gray-700">
<td colspan={softwareList.length + 1} class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Platforms
</td>
</tr>
<!-- Platform availability -->
{platforms.map(platform => (
<tr>
<td class="px-6 py-4 text-sm font-medium text-gray-700 dark:text-gray-300">
{platform}
</td>
{softwareList.map(software => {
const supportsPlatform = software.platforms?.includes(platform);
return (
<td class="px-6 py-4 text-center">
{supportsPlatform ? (
<svg class="h-5 w-5 text-green-500 mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
) : (
<svg class="h-5 w-5 text-red-500 mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>