mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 19:49:40 +02:00
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>
288 lines
No EOL
12 KiB
Text
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> |