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,12 @@
---
import { loadTranslations } from '../../utils/i18n';
import CommentSystem from '../CommentSystem.astro';
const { softwareId, locale } = Astro.props;
const t = await loadTranslations(locale);
---
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[350px] overflow-y-auto">
<h2 class="text-2xl font-bold mb-4">{t.software.comments || 'Comments'}</h2>
<CommentSystem softwareId={softwareId} />
</div>

View file

@ -0,0 +1,775 @@
---
import { loadTranslations } from '../../utils/i18n';
import SoftwareRatings from './SoftwareRatings.astro';
import CommentSystem from '../CommentSystem.astro';
const { locale } = Astro.props;
const t = await loadTranslations(locale);
---
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden"
>
<!-- Header mit Software-Info und Steuerungselementen -->
<div class="flex flex-col md:flex-row md:items-center mb-8 p-4 border-b border-gray-200 dark:border-gray-700">
<!-- Minimieren-Button -->
<button
id="minimizeCompareBtn"
class="mr-4 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Minimize View"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
<!-- Software-Info -->
<div class="flex items-center flex-1">
<div class="w-24 h-24 bg-white p-2 rounded-lg shadow-sm flex items-center justify-center mr-6 mb-4 md:mb-0">
<img id="compare-software-logo" src="/logos/sample-logo.svg" alt="Software logo" class="max-w-full max-h-full" />
</div>
<div>
<h2 id="compare-software-name" class="text-3xl font-bold">Select Software</h2>
<div id="compare-software-categories" class="flex flex-wrap text-sm text-gray-500 mt-2"></div>
</div>
</div>
<!-- Aktionsbuttons -->
<div class="md:ml-auto mt-4 md:mt-0 flex space-x-2">
<button
id="viewDemoBtn"
class="btn btn-primary"
>
Visit Website
</button>
<button
id="backToSoftwareListBtn"
class="btn btn-secondary whitespace-nowrap"
>
Select Another
</button>
</div>
</div>
<!-- Software-Liste (angezeigt, wenn noch keine Software ausgewählt ist) -->
<div
id="software-list-view"
class="p-0 overflow-y-auto"
style="max-height: calc(100vh - 200px);">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold">Select Another Software</h3>
<div class="mt-4">
<input
type="text"
id="software-search-input"
placeholder="Search"
class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-primary focus:border-primary dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div class="p-0 overflow-y-auto" id="software-list-container">
<div id="loading-indicator" class="py-8 text-center text-gray-500 dark:text-gray-400" style="display: none;">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading...
</div>
<div id="error-message" class="py-8 text-center text-red-500" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p id="error-text">An error occurred</p>
</div>
<div id="empty-message" class="py-8 text-center text-gray-500" style="display: none;">
No similar software available
</div>
<div id="software-items">
<!-- Software-Einträge werden hier per JavaScript eingefügt -->
</div>
</div>
</div>
<!-- Detaillierte Software-Vergleichsansicht (wenn Software ausgewählt wurde) -->
<div class="hidden" id="software-detail-view">
<div id="detail-loading" class="text-center text-gray-500 dark:text-gray-400 p-6">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading software details...
</div>
<div id="detail-content" style="display: none;" class="software-content px-4">
<!-- Hauptinhaltsbereich mit einspaltigem Layout ohne Grid -->
<div class="grid grid-cols-1 gap-8">
<!-- Software-Übersichtskomponente -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[500px] overflow-y-auto">
<h2 class="text-2xl font-bold mb-4">Overview</h2>
<p id="software-description" class="text-gray-700 dark:text-gray-300 mb-6"></p>
<!-- Screenshots-Container (wird dynamisch befüllt) -->
<div id="compare-screenshots" class="mb-6" style="display: none;">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- Hier werden Screenshots per JS eingefügt -->
</div>
</div>
<!-- Features -->
<h3 class="text-xl font-bold mb-4">Features</h3>
<ul id="software-features" class="list-disc list-inside mb-6 text-gray-700 dark:text-gray-300"></ul>
<!-- Plattformen -->
<div id="platforms-section">
<h3 class="text-xl font-bold mb-4">Platforms</h3>
<div id="software-platforms" class="flex flex-wrap gap-2 mb-6"></div>
</div>
<!-- Last Updated -->
<div id="last-updated" class="text-sm text-gray-500 dark:text-gray-400">
Last Updated: <span id="update-date"></span>
</div>
</div>
<!-- Preisgestaltungsbereich (jetzt an zweiter Stelle wie auf der linken Seite) -->
<div id="pricing-section" class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[400px] overflow-y-auto">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">Pricing</h2>
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-full p-1">
<button
id="compare-monthly-btn"
class="px-3 py-1 rounded-full bg-primary text-white text-sm font-medium"
>
Monthly
</button>
<button
id="compare-yearly-btn"
class="px-3 py-1 rounded-full text-sm font-medium"
>
Yearly
</button>
</div>
</div>
<div id="software-pricing" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Pricing Info wird hier per JavaScript eingefügt -->
</div>
</div>
<!-- Kommentar-Bereich (jetzt an dritter Stelle wie auf der linken Seite) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[350px] overflow-y-auto">
<h2 class="text-2xl font-bold mb-4">Comments</h2>
<div id="compare-comments">
<p class="text-gray-500">Comments will be loaded when comparing specific software.</p>
</div>
</div>
<!-- Software-Bewertungskomponente (jetzt an vierter Stelle wie auf der linken Seite) -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[300px] overflow-y-auto">
<h3 class="text-xl font-bold mb-4">Ratings</h3>
<div id="compare-ratings" class="space-y-4">
<!-- Ease of Use -->
<div>
<div class="flex justify-between items-center mb-1">
<span class="font-medium">Ease of Use</span>
<span id="compare-ease-value" class="font-bold">0.0 / 5</span>
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div id="compare-ease-bar" class="h-full bg-primary" style="width: 0%"></div>
</div>
<div id="compare-ease-count" class="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">0 votes</div>
</div>
<!-- Features -->
<div>
<div class="flex justify-between items-center mb-1">
<span class="font-medium">Features</span>
<span id="compare-features-value" class="font-bold">0.0 / 5</span>
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div id="compare-features-bar" class="h-full bg-primary" style="width: 0%"></div>
</div>
<div id="compare-features-count" class="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">0 votes</div>
</div>
<!-- Value -->
<div>
<div class="flex justify-between items-center mb-1">
<span class="font-medium">Value for Money</span>
<span id="compare-value-value" class="font-bold">0.0 / 5</span>
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div id="compare-value-bar" class="h-full bg-primary" style="width: 0%"></div>
</div>
<div id="compare-value-count" class="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">0 votes</div>
</div>
<!-- Support -->
<div>
<div class="flex justify-between items-center mb-1">
<span class="font-medium">Support</span>
<span id="compare-support-value" class="font-bold">0.0 / 5</span>
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div id="compare-support-bar" class="h-full bg-primary" style="width: 0%"></div>
</div>
<div id="compare-support-count" class="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">0 votes</div>
</div>
<!-- Reliability -->
<div>
<div class="flex justify-between items-center mb-1">
<span class="font-medium">Reliability</span>
<span id="compare-reliability-value" class="font-bold">0.0 / 5</span>
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div id="compare-reliability-bar" class="h-full bg-primary" style="width: 0%"></div>
</div>
<div id="compare-reliability-count" class="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">0 votes</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Minimierte Ansicht -->
<div id="minimized-view" class="p-4 flex flex-col items-center justify-center border-l border-gray-200 dark:border-gray-700 h-full hidden">
<div class="w-16 h-16 bg-white p-2 rounded-lg shadow-sm flex items-center justify-center mb-2">
<img id="minimized-logo" src="/logos/sample-logo.svg" alt="Software logo" class="max-w-full max-h-full" />
</div>
<h2 id="minimized-name" class="text-center text-lg font-bold mb-2">Software Name</h2>
<p id="minimized-description" class="text-center text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-3">Description will appear here</p>
</div>
</div>
<script>
// Client-side Funktionalität für die Vergleichsansicht
document.addEventListener('DOMContentLoaded', () => {
// DOM-Elemente
const softwareListView = document.getElementById('software-list-view');
const softwareDetailView = document.getElementById('software-detail-view');
const softwareItems = document.getElementById('software-items');
const searchInput = document.getElementById('software-search-input');
const loadingIndicator = document.getElementById('loading-indicator');
const errorMessage = document.getElementById('error-message');
const errorText = document.getElementById('error-text');
const emptyMessage = document.getElementById('empty-message');
const backButton = document.getElementById('backToSoftwareListBtn');
const minimizeButton = document.getElementById('minimizeCompareBtn');
const minimizedView = document.getElementById('minimized-view');
// Detail-Elemente
const softwareLogo = document.getElementById('compare-software-logo');
const softwareName = document.getElementById('compare-software-name');
const softwareCategories = document.getElementById('compare-software-categories');
const softwareDescription = document.getElementById('software-description');
const softwareFeatures = document.getElementById('software-features');
const softwarePlatforms = document.getElementById('software-platforms');
const softwarePricing = document.getElementById('software-pricing');
const platformsSection = document.getElementById('platforms-section');
const pricingSection = document.getElementById('pricing-section');
const detailLoading = document.getElementById('detail-loading');
const detailContent = document.getElementById('detail-content');
// Bewertungselemente
const compareEaseValue = document.getElementById('compare-ease-value');
const compareFeaturesValue = document.getElementById('compare-features-value');
const compareValueValue = document.getElementById('compare-value-value');
const compareSupportValue = document.getElementById('compare-support-value');
const compareReliabilityValue = document.getElementById('compare-reliability-value');
// Minimized-Elemente
const minimizedLogo = document.getElementById('minimized-logo');
const minimizedName = document.getElementById('minimized-name');
const minimizedDescription = document.getElementById('minimized-description');
let isMinimized = false;
let similarSoftware = [];
let filteredSoftware = [];
let currentSoftware = null;
let currentLocale = 'en';
// Initialisiere mit dem Data-Attribut des Containers
const initializeFromContainer = () => {
// Container-Element für die Daten
const softwareDetail = document.getElementById('softwareDetail');
if (softwareDetail) {
try {
console.log('Container data:', softwareDetail.dataset);
let similarData = [];
if (softwareDetail.dataset.similarSoftware) {
similarData = JSON.parse(softwareDetail.dataset.similarSoftware);
console.log('Parsed similar software data:', similarData);
} else {
console.warn('No similar software data found in container');
}
// Ähnliche Software speichern
similarSoftware = similarData;
filteredSoftware = similarData;
// Locale erhalten
currentLocale = document.documentElement.lang || 'en';
// Initialisiere die Software-Liste
renderSoftwareList(similarSoftware);
// Zeige die Anzahl der geladenen Einträge
console.log(`Loaded ${similarSoftware.length} similar software entries`);
} catch (e) {
console.error('Error initializing compare panel:', e);
console.error(e);
}
} else {
console.error('Software detail container not found');
}
};
// Rendere die Software-Liste
const renderSoftwareList = (softwareList) => {
// Liste leeren
softwareItems.innerHTML = '';
if (softwareList.length === 0) {
emptyMessage.style.display = 'block';
return;
}
emptyMessage.style.display = 'none';
// Für jede Software einen Eintrag erstellen
softwareList.forEach(software => {
const item = document.createElement('div');
item.className = 'p-4 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer';
item.innerHTML = `
<div class="flex items-center">
<div class="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
${software.logo
? `<img src="${software.logo}" alt="${software.name}" class="max-w-full max-h-full p-1" />`
: `<span class="text-lg">${software.name.charAt(0)}</span>`
}
</div>
<div>
<div class="font-medium dark:text-white">${software.name}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">${software.categories.join(', ')}</div>
</div>
</div>
`;
// Click-Event zum Auswählen der Software
item.addEventListener('click', () => selectComparisonSoftware(software.id));
softwareItems.appendChild(item);
});
};
// Software zum Vergleich auswählen
const selectComparisonSoftware = async (id) => {
try {
// Lade-Zustand anzeigen
softwareListView.style.display = 'none';
softwareDetailView.style.display = 'block';
detailLoading.style.display = 'block';
detailContent.style.display = 'none';
// URL-Parameter aktualisieren
const url = new URL(window.location.href);
url.searchParams.set('compare', id);
window.history.replaceState({}, '', url.toString());
// Software-Daten laden
const softwareData = await loadSoftwareData(id, currentLocale);
if (!softwareData) {
throw new Error('Failed to load software data');
}
// Aktuelle Software speichern
currentSoftware = softwareData;
// UI aktualisieren
updateSoftwareUI(softwareData);
// Informiere die SoftwareDetail-Komponente über die ausgewählte Software
const detailPanel = document.querySelector('.left-panel');
if (detailPanel) {
// Für die Einzelansicht in mobilen Geräten
detailPanel.classList.add('lg:w-1/2');
detailPanel.classList.remove('w-full');
}
} catch (e) {
console.error('Error selecting software for comparison:', e);
detailLoading.style.display = 'none';
errorText.textContent = e.message || 'Failed to load software data';
errorMessage.style.display = 'block';
}
};
// Software-Daten über API laden
const loadSoftwareData = async (id, locale) => {
try {
const response = await fetch(`/api/software/${id}.json?lang=${locale}`);
if (!response.ok) {
throw new Error(`Error status: ${response.status}`);
}
return await response.json();
} catch (e) {
console.error('Error loading software data:', e);
return null;
}
};
// UI mit Software-Daten aktualisieren
const updateSoftwareUI = (software) => {
// Header-Informationen
softwareLogo.src = software.logo || '/logos/sample-logo.svg';
softwareLogo.alt = software.name;
softwareName.textContent = software.name;
// Kategorie-Tags erstellen
softwareCategories.innerHTML = '';
if (software.categories && software.categories.length > 0) {
software.categories.forEach((category, index) => {
const a = document.createElement('a');
a.href = `/${currentLocale}/category/${category.toLowerCase().replace(' ', '-')}`;
a.className = 'text-primary hover:underline';
a.textContent = category;
softwareCategories.appendChild(a);
if (index < software.categories.length - 1) {
const separator = document.createElement('span');
separator.className = 'mx-2 text-gray-400';
separator.textContent = '•';
softwareCategories.appendChild(separator);
}
});
}
// Website-Button aktivieren, wenn URL vorhanden
const viewDemoBtn = document.getElementById('viewDemoBtn');
if (viewDemoBtn) {
if (software.website) {
viewDemoBtn.disabled = false;
} else {
viewDemoBtn.disabled = true;
}
}
// Beschreibung
softwareDescription.textContent = software.description || 'No description available';
// Screenshots, falls vorhanden
const screenshotsContainer = document.getElementById('compare-screenshots');
const screenshotsGrid = screenshotsContainer.querySelector('.grid');
if (software.screenshots && software.screenshots.length > 0) {
screenshotsGrid.innerHTML = '';
screenshotsContainer.style.display = 'block';
software.screenshots.forEach(screenshot => {
const screenshotDiv = document.createElement('div');
screenshotDiv.className = 'aspect-video bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden';
const img = document.createElement('img');
img.src = screenshot;
img.alt = `${software.name} screenshot`;
img.className = 'w-full h-full object-cover';
screenshotDiv.appendChild(img);
screenshotsGrid.appendChild(screenshotDiv);
});
} else {
screenshotsContainer.style.display = 'none';
}
// Features
softwareFeatures.innerHTML = '';
if (software.features && software.features.length > 0) {
software.features.forEach(feature => {
const li = document.createElement('li');
li.className = 'mb-2 text-gray-700 dark:text-gray-300';
li.textContent = feature;
softwareFeatures.appendChild(li);
});
} else {
const li = document.createElement('li');
li.className = 'mb-2 text-gray-700 dark:text-gray-300';
li.textContent = 'No features listed';
softwareFeatures.appendChild(li);
}
// Plattformen
softwarePlatforms.innerHTML = '';
if (software.platforms && software.platforms.length > 0) {
platformsSection.style.display = 'block';
software.platforms.forEach(platform => {
const span = document.createElement('span');
span.className = 'px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-sm';
span.textContent = platform;
softwarePlatforms.appendChild(span);
});
} else {
platformsSection.style.display = 'none';
}
// Last Updated Datum
const updateDate = document.getElementById('update-date');
if (updateDate && software.lastUpdated) {
updateDate.textContent = new Date(software.lastUpdated).toLocaleDateString();
} else if (updateDate) {
updateDate.textContent = 'N/A';
}
// Preisgestaltung
softwarePricing.innerHTML = '';
if (software.pricing && software.pricing.length > 0) {
pricingSection.style.display = 'block';
// Preis-Schalter einrichten
const monthlyBtn = document.getElementById('compare-monthly-btn');
const yearlyBtn = document.getElementById('compare-yearly-btn');
if (monthlyBtn && yearlyBtn) {
// Standard ist monatlich
let showMonthly = true;
// Preise generieren
const generatePricing = () => {
softwarePricing.innerHTML = '';
software.pricing.forEach(plan => {
const planDiv = document.createElement('div');
planDiv.className = 'border border-gray-200 dark:border-gray-700 rounded-lg p-6';
const planTitle = document.createElement('h3');
planTitle.className = 'text-xl font-bold mb-2';
planTitle.textContent = plan.model;
// Preisanzeige-Container für monatlich/jährlich
const priceContainer = document.createElement('div');
priceContainer.className = 'mb-4';
const planPrice = document.createElement('p');
planPrice.className = 'text-2xl font-bold text-primary dark:text-blue-400';
planPrice.textContent = showMonthly ? plan.price : (plan.yearly_price || 'N/A');
priceContainer.appendChild(planPrice);
// Features
const featuresList = document.createElement('ul');
featuresList.className = 'space-y-2';
plan.features.forEach(feature => {
const li = document.createElement('li');
li.className = 'flex items-start';
li.innerHTML = `
<svg class="h-5 w-5 text-green-500 mr-2 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="dark:text-gray-300">${feature}</span>
`;
featuresList.appendChild(li);
});
planDiv.appendChild(planTitle);
planDiv.appendChild(priceContainer);
planDiv.appendChild(featuresList);
softwarePricing.appendChild(planDiv);
});
};
// Initial erzeugen
generatePricing();
// Event-Listener für Schalter
monthlyBtn.addEventListener('click', () => {
monthlyBtn.classList.add('bg-primary', 'text-white');
yearlyBtn.classList.remove('bg-primary', 'text-white');
showMonthly = true;
generatePricing();
});
yearlyBtn.addEventListener('click', () => {
yearlyBtn.classList.add('bg-primary', 'text-white');
monthlyBtn.classList.remove('bg-primary', 'text-white');
showMonthly = false;
generatePricing();
});
}
} else {
pricingSection.style.display = 'none';
}
// Bewertungen aktualisieren
if (software.metrics) {
updateRatings(software.metrics);
} else {
// Leere Bewertungen anzeigen, wenn keine vorhanden sind
updateRatings({
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 }
});
}
// Minimierte Ansicht aktualisieren
minimizedLogo.src = software.logo || '/logos/sample-logo.svg';
minimizedLogo.alt = software.name;
minimizedName.textContent = software.name;
minimizedDescription.textContent = software.description || 'No description available';
// Lade-Zustand ausblenden, Inhalte einblenden
detailLoading.style.display = 'none';
detailContent.style.display = 'block';
};
// Bewertungen aktualisieren
const updateRatings = (metrics) => {
// Bewertungen abrufen
const easeOfUse = metrics.easeOfUse || { average: 0, count: 0 };
const featureRichness = metrics.featureRichness || { average: 0, count: 0 };
const valueForMoney = metrics.valueForMoney || { average: 0, count: 0 };
const support = metrics.support || { average: 0, count: 0 };
const reliability = metrics.reliability || { average: 0, count: 0 };
// Werte aktualisieren für Ease of Use
if (compareEaseValue) compareEaseValue.textContent = `${easeOfUse.average.toFixed(1)} / 5`;
const easeBar = document.getElementById('compare-ease-bar');
if (easeBar) easeBar.style.width = `${(easeOfUse.average / 5) * 100}%`;
const easeCount = document.getElementById('compare-ease-count');
if (easeCount) easeCount.textContent = `${easeOfUse.count} votes`;
// Werte aktualisieren für Features
if (compareFeaturesValue) compareFeaturesValue.textContent = `${featureRichness.average.toFixed(1)} / 5`;
const featuresBar = document.getElementById('compare-features-bar');
if (featuresBar) featuresBar.style.width = `${(featureRichness.average / 5) * 100}%`;
const featuresCount = document.getElementById('compare-features-count');
if (featuresCount) featuresCount.textContent = `${featureRichness.count} votes`;
// Werte aktualisieren für Value
if (compareValueValue) compareValueValue.textContent = `${valueForMoney.average.toFixed(1)} / 5`;
const valueBar = document.getElementById('compare-value-bar');
if (valueBar) valueBar.style.width = `${(valueForMoney.average / 5) * 100}%`;
const valueCount = document.getElementById('compare-value-count');
if (valueCount) valueCount.textContent = `${valueForMoney.count} votes`;
// Werte aktualisieren für Support
if (compareSupportValue) compareSupportValue.textContent = `${support.average.toFixed(1)} / 5`;
const supportBar = document.getElementById('compare-support-bar');
if (supportBar) supportBar.style.width = `${(support.average / 5) * 100}%`;
const supportCount = document.getElementById('compare-support-count');
if (supportCount) supportCount.textContent = `${support.count} votes`;
// Werte aktualisieren für Reliability
if (compareReliabilityValue) compareReliabilityValue.textContent = `${reliability.average.toFixed(1)} / 5`;
const reliabilityBar = document.getElementById('compare-reliability-bar');
if (reliabilityBar) reliabilityBar.style.width = `${(reliability.average / 5) * 100}%`;
const reliabilityCount = document.getElementById('compare-reliability-count');
if (reliabilityCount) reliabilityCount.textContent = `${reliability.count} votes`;
};
// Filter-Funktion für die Suche
const filterSoftware = (searchTerm) => {
if (!searchTerm || searchTerm.length < 2) {
filteredSoftware = similarSoftware;
renderSoftwareList(filteredSoftware);
return;
}
const term = searchTerm.toLowerCase();
filteredSoftware = similarSoftware.filter(software => {
return software.name.toLowerCase().includes(term) ||
software.description.toLowerCase().includes(term) ||
(software.categories && software.categories.some(cat => cat.toLowerCase().includes(term)));
});
renderSoftwareList(filteredSoftware);
};
// Minimierte Ansicht umschalten
const toggleMinimize = () => {
isMinimized = !isMinimized;
if (isMinimized) {
softwareDetailView.style.display = 'none';
minimizedView.style.display = 'flex';
} else {
softwareDetailView.style.display = 'block';
minimizedView.style.display = 'none';
}
};
// Zurück zur Software-Liste
const backToList = () => {
softwareDetailView.style.display = 'none';
minimizedView.style.display = 'none';
softwareListView.style.display = 'block';
isMinimized = false;
};
// Event-Listener einrichten
if (searchInput) {
searchInput.addEventListener('input', () => filterSoftware(searchInput.value));
}
if (backButton) {
backButton.addEventListener('click', backToList);
}
if (minimizeButton) {
minimizeButton.addEventListener('click', toggleMinimize);
}
// URL-Parameter überprüfen
const checkURLParams = () => {
const urlParams = new URLSearchParams(window.location.search);
const compareId = urlParams.get('compare');
if (compareId) {
selectComparisonSoftware(compareId);
}
};
// Website-Button-Handler
const viewDemoBtn = document.getElementById('viewDemoBtn');
if (viewDemoBtn) {
viewDemoBtn.addEventListener('click', () => {
if (currentSoftware && currentSoftware.website) {
window.open(currentSoftware.website, '_blank');
}
});
// Initial deaktivieren, bis Software ausgewählt ist
viewDemoBtn.disabled = true;
}
// Initialisierung starten
initializeFromContainer();
checkURLParams();
// Prüfen, ob Daten vorhanden sind
if (similarSoftware.length === 0) {
// Verzögerung, um sicherzustellen, dass Daten verfügbar sind
setTimeout(() => {
initializeFromContainer();
if (similarSoftware.length === 0) {
emptyMessage.style.display = 'block';
}
}, 500);
}
});
</script>

View file

@ -0,0 +1,535 @@
---
import { loadTranslations } from '../../utils/i18n';
const { currentSoftware, similarSoftware, locale } = Astro.props;
const t = await loadTranslations(locale);
---
<div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 h-[200px] overflow-y-auto">
<h3 class="text-xl font-bold mb-4">{t.common.compare || 'Compare'}</h3>
<p class="text-gray-700 dark:text-gray-300 mb-4">{t.software.compareDescription || 'Compare this software with others to find the best solution for your needs.'}</p>
<a
href={`/${locale}/compare?software=${currentSoftware.id}`}
class="btn btn-secondary w-full"
>
{t.software.compare || 'Compare'}
</a>
</div>
<!-- Similar Software List -->
<div
x-show="isCompareMode && !selectedSoftwareId"
class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-sm h-full overflow-hidden"
>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-bold" id="software-selection-title">{t.software.selectAnother}</h3>
<div class="mt-4">
<input
type="text"
placeholder={t.common.search}
class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-primary focus:border-primary dark:bg-gray-700 dark:text-white"
id="software-search-input"
/>
</div>
</div>
<div class="p-0 overflow-y-auto" style="max-height: calc(100vh - 200px);" id="software-list-container">
<!-- This will be populated via JavaScript -->
<div id="similar-software-list" style="display:none">{JSON.stringify(similarSoftware)}</div>
</div>
</div>
<!-- Selected Software for Comparison -->
<div
x-show="isCompareMode && selectedSoftwareId"
x-transition
class="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden"
id="comparison-container"
>
<div class="flex items-center p-4 border-b border-gray-200 dark:border-gray-700">
<button
@click="toggleMinimize('right')"
class="mr-4 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
:aria-label="isRightMinimized ? '${t.software.expandView}' : '${t.software.minimizeView}'"
>
<svg x-show="!isRightMinimized" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
<svg x-show="isRightMinimized" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<div class="flex items-center flex-1">
<div class="w-12 h-12 bg-white p-1 rounded-lg shadow-sm flex items-center justify-center mr-4">
<img id="compare-logo" alt="Software logo" class="max-w-full max-h-full" />
</div>
<div>
<h2 id="compare-name" class="text-xl font-bold"></h2>
<div id="compare-categories" class="flex flex-wrap text-sm text-gray-500"></div>
</div>
</div>
<button
@click="selectedSoftwareId = null"
class="ml-auto btn btn-sm btn-secondary whitespace-nowrap"
>
{t.software.selectAnother}
</button>
</div>
<div x-show="!isRightMinimized" class="p-6" id="compare-content">
<div class="text-center text-gray-500 dark:text-gray-400">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading software details...
</div>
</div>
<template x-if="isRightMinimized">
<div class="p-4 flex flex-col items-center justify-center border-l border-gray-200 dark:border-gray-700 h-full">
<div class="w-16 h-16 bg-white p-2 rounded-lg shadow-sm flex items-center justify-center mb-2">
<img id="compare-mini-logo" alt="Software logo" class="max-w-full max-h-full" />
</div>
<h2 id="compare-mini-name" class="text-center text-lg font-bold mb-2"></h2>
<p id="compare-mini-description" class="text-center text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-3"></p>
</div>
</template>
</div>
<!-- Mobile view switcher (only shown in compare mode on small screens) -->
<div
x-show="isCompareMode"
class="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3 flex justify-center space-x-4 lg:hidden z-10"
>
<button
@click="switchViewSide('left')"
class="px-4 py-2 rounded-md transition-colors"
:class="{'bg-primary text-white': currentViewSide === 'left', 'bg-gray-100 dark:bg-gray-700': currentViewSide !== 'left'}"
>
{currentSoftware.name}
</button>
<button
x-show="selectedSoftwareId"
@click="switchViewSide('right')"
class="px-4 py-2 rounded-md transition-colors"
:class="{'bg-primary text-white': currentViewSide === 'right', 'bg-gray-100 dark:bg-gray-700': currentViewSide !== 'right'}"
id="compare-tab-button"
>
Compare
</button>
</div>
</div>
<!-- Hidden data for JS -->
<div id="current-software-categories" style="display:none">{JSON.stringify(currentSoftware.categories)}</div>
<div id="current-software-name" style="display:none">{currentSoftware.name}</div>
<script>
// Function to populate software items
window.populateSoftwareItems = function() {
const listContainer = document.getElementById('software-list-container');
const selectionTitle = document.getElementById('software-selection-title');
if (!listContainer) return;
// Create a backup of the software list for the "back" button to use
if (!document.getElementById('software-list-backup')) {
const backupDiv = document.createElement('div');
backupDiv.id = 'software-list-backup';
backupDiv.style.display = 'none';
document.body.appendChild(backupDiv);
}
listContainer.innerHTML = '<div class="p-4 text-center">Loading...</div>';
// Get the current software's categories and similar software from hidden elements
const currentSoftwareCategories = JSON.parse(document.getElementById('current-software-categories').textContent);
const currentSoftwareName = document.getElementById('current-software-name').textContent;
const similarSoftwareList = JSON.parse(document.getElementById('similar-software-list').textContent);
// Update the selection title with the primary category
if (selectionTitle && currentSoftwareCategories.length > 0) {
const primaryCategory = currentSoftwareCategories[0];
selectionTitle.textContent = `Other ${primaryCategory} software`;
}
setTimeout(() => {
listContainer.innerHTML = '';
// Use pre-filtered data from content collections on the server side
const filteredSoftware = similarSoftwareList;
if (filteredSoftware.length === 0) {
const emptyEl = document.createElement('div');
emptyEl.classList.add('py-8', 'text-center', 'text-gray-500');
emptyEl.textContent = 'No similar software available for comparison';
listContainer.appendChild(emptyEl);
return;
}
filteredSoftware.forEach(software => {
console.log("Rendering software item in ComparisonView:", software);
const item = document.createElement('div');
item.className = 'p-4 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer';
// Ensure categories is an array
const categories = Array.isArray(software.categories)
? software.categories.join(', ')
: '';
// Use logo if available, otherwise show first letter of name
const logoHtml = software.logo
? `<img src="${software.logo}" alt="${software.name}" class="max-w-full max-h-full p-1" />`
: `<span class="text-lg">${software.name.charAt(0)}</span>`;
item.innerHTML = `
<div class="flex items-center">
<div class="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center mr-3">
${logoHtml}
</div>
<div>
<div class="font-medium dark:text-white">${software.name}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">${categories}</div>
</div>
</div>
`;
item.addEventListener('click', async () => {
console.log(`Selected software for comparison in ComparisonView:`, software);
// Get the alpine data context
const alpine = document.querySelector('[x-data]')?.__x;
if (alpine) {
alpine.$data.selectedSoftwareId = software.id;
}
// Update URL params
const url = new URL(window.location.href);
url.searchParams.set('compare', software.id);
window.history.replaceState({}, '', url.toString());
// Load software data
try {
const compareContainer = document.getElementById('comparison-container');
if (!compareContainer) {
console.error("Comparison container not found");
return;
}
// Update basic info immediately for responsive UI
const nameEl = document.getElementById('compare-name');
const logoEl = document.getElementById('compare-logo');
if (nameEl) nameEl.textContent = software.name;
if (logoEl) logoEl.src = software.logo || '/logos/sample-logo.svg';
// Fill in software features from local data before API call
const contentEl = document.getElementById('compare-content');
if (contentEl) {
contentEl.innerHTML = `
<div class="mb-8">
<h2 class="text-xl font-bold mb-4">Overview</h2>
<p class="text-gray-700 dark:text-gray-300">${software.description || "No description available"}</p>
</div>
<div class="mb-8">
<h2 class="text-xl font-bold mb-4">Features</h2>
<ul class="list-disc list-inside space-y-2 text-gray-700 dark:text-gray-300">
${Array.isArray(software.features) && software.features.length > 0
? software.features.map(f => `<li>${f}</li>`).join('')
: '<li>No features listed</li>'}
</ul>
</div>
${Array.isArray(software.platforms) && software.platforms.length > 0
? `<div class="mb-8">
<h3 class="text-xl font-bold mb-4">Platforms</h3>
<div class="flex flex-wrap gap-2 mb-6">
${software.platforms.map(p => `<span class="px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-sm">${p}</span>`).join('')}
</div>
</div>`
: ''}
`;
}
// Get software data with API call if needed
await updateComparisonSoftware(software);
} catch (error) {
console.error('Error selecting software for comparison:', error);
}
});
listContainer.appendChild(item);
});
// Save a backup of the complete software list panel
const comparePanel = document.querySelector('#comparison-container');
const backupDiv = document.getElementById('software-list-backup');
if (comparePanel && backupDiv) {
backupDiv.innerHTML = comparePanel.innerHTML;
}
}, 100);
};
// Function to update comparison software with full data
async function updateComparisonSoftware(software) {
// Get software data from API
const urlPathParts = window.location.pathname.split('/');
const locale = urlPathParts[1] || 'en'; // The first segment after the leading slash is the locale
try {
// Show loading state in the comparison panel
const contentEl = document.getElementById('compare-content');
if (contentEl) {
contentEl.innerHTML = `
<div class="text-center text-gray-500 dark:text-gray-400">
<svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Loading software details...
</div>
`;
}
// Use direct data if we already have it, otherwise load from API
let softwareData;
if (typeof software === 'object' && software.id && software.name) {
console.log("Using provided software data directly:", software.id);
// We already have software data, use it directly
softwareData = {
...software,
// Add mock metrics for consistency if not present
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 }
},
// Ensure we have features array
features: software.features || []
};
} else {
// Load the full software data from API
console.log("Loading software data from API for:", software.id);
softwareData = await loadSoftwareData(software.id, locale);
}
if (!softwareData) {
throw new Error('Failed to load software data');
}
// Update the UI elements
updateComparisonUI(softwareData);
} catch (error) {
console.error('Error updating comparison software:', error);
showComparisonError();
}
}
// Function to update the UI with the loaded software data
function updateComparisonUI(software) {
if (!software) {
console.error("Cannot update UI: No software data provided");
showComparisonError();
return;
}
// Ensure software has all required properties with fallbacks
const safeSoftware = {
name: software.name || "Unknown Software",
logo: software.logo || '/logos/sample-logo.svg',
description: software.description || "No description available",
categories: Array.isArray(software.categories) ? software.categories : [],
features: Array.isArray(software.features) ? software.features : ["No features listed"],
platforms: Array.isArray(software.platforms) ? software.platforms : []
};
// Update header elements
const logoEl = document.getElementById('compare-logo');
const nameEl = document.getElementById('compare-name');
const categoriesEl = document.getElementById('compare-categories');
const miniLogoEl = document.getElementById('compare-mini-logo');
const miniNameEl = document.getElementById('compare-mini-name');
const miniDescEl = document.getElementById('compare-mini-description');
const tabButtonEl = document.getElementById('compare-tab-button');
if (logoEl) logoEl.src = safeSoftware.logo;
if (miniLogoEl) miniLogoEl.src = safeSoftware.logo;
if (nameEl) nameEl.textContent = safeSoftware.name;
if (miniNameEl) miniNameEl.textContent = safeSoftware.name;
if (tabButtonEl) tabButtonEl.textContent = safeSoftware.name;
if (categoriesEl) {
categoriesEl.innerHTML = '';
safeSoftware.categories.forEach((cat, i) => {
const span = document.createElement('span');
span.textContent = cat + (i < safeSoftware.categories.length - 1 ? ' • ' : '');
categoriesEl.appendChild(span);
});
}
if (miniDescEl) miniDescEl.textContent = safeSoftware.description;
// Update content area
const contentEl = document.getElementById('compare-content');
if (contentEl) {
contentEl.innerHTML = '';
// Add description
const descDiv = document.createElement('div');
descDiv.className = 'mb-8';
const descTitle = document.createElement('h2');
descTitle.className = 'text-xl font-bold mb-4';
descTitle.textContent = 'Overview';
const descText = document.createElement('p');
descText.className = 'text-gray-700 dark:text-gray-300';
descText.textContent = safeSoftware.description;
descDiv.appendChild(descTitle);
descDiv.appendChild(descText);
contentEl.appendChild(descDiv);
// Add features
const featuresDiv = document.createElement('div');
featuresDiv.className = 'mb-8';
const featuresTitle = document.createElement('h2');
featuresTitle.className = 'text-xl font-bold mb-4';
featuresTitle.textContent = 'Features';
const featuresList = document.createElement('ul');
featuresList.className = 'list-disc list-inside space-y-2 text-gray-700 dark:text-gray-300';
safeSoftware.features.forEach(feature => {
const li = document.createElement('li');
li.textContent = feature;
featuresList.appendChild(li);
});
featuresDiv.appendChild(featuresTitle);
featuresDiv.appendChild(featuresList);
contentEl.appendChild(featuresDiv);
// Add platforms
if (safeSoftware.platforms.length > 0) {
const platformsDiv = document.createElement('div');
platformsDiv.className = 'mb-8';
const platformsTitle = document.createElement('h3');
platformsTitle.className = 'text-xl font-bold mb-4';
platformsTitle.textContent = 'Platforms';
const platformsContainer = document.createElement('div');
platformsContainer.className = 'flex flex-wrap gap-2 mb-6';
safeSoftware.platforms.forEach(platform => {
const span = document.createElement('span');
span.className = 'px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-sm';
span.textContent = platform;
platformsContainer.appendChild(span);
});
platformsDiv.appendChild(platformsTitle);
platformsDiv.appendChild(platformsContainer);
contentEl.appendChild(platformsDiv);
}
}
}
// Function to show error in the comparison panel
function showComparisonError() {
const contentEl = document.getElementById('compare-content');
if (contentEl) {
contentEl.innerHTML = `
<div class="text-center py-8 text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p class="text-lg font-medium">Failed to load software data</p>
<p class="mt-2">Please try again later</p>
</div>
`;
}
// Also update the software name so it's not "Select Software" anymore
const nameEl = document.getElementById('compare-name');
if (nameEl) {
nameEl.textContent = "Error loading software";
}
}
// Function to load software data from API
async function loadSoftwareData(softwareId, locale) {
try {
// First try to fetch from the API
const response = await fetch(`/api/software/${softwareId}.json?lang=${locale}`);
if (!response.ok) {
console.warn(`API request failed: ${response.status}. Trying fallback method.`);
throw new Error(`API request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Error loading software data:", error);
// Fallback: try to find the software in the similarity data
try {
const similarSoftwareList = JSON.parse(document.getElementById('similar-software-list').textContent);
const matchingSoftware = similarSoftwareList.find(s => s.id === softwareId);
if (matchingSoftware) {
console.log("Using fallback data from similar-software-list");
// Add mock metrics for consistency
return {
...matchingSoftware,
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 }
}
};
}
} catch (fallbackError) {
console.error("Fallback method also failed:", fallbackError);
}
return null;
}
}
// Initialize - check if we're in compare mode
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
const urlParams = new URLSearchParams(window.location.search);
const compareWith = urlParams.get('compare');
// Check if we're in compare mode through Alpine.js
const alpine = document.querySelector('[x-data]')?.__x;
if (alpine && alpine.$data.isCompareMode) {
console.log("We're in compare mode, loading items immediately");
populateSoftwareItems();
} else if (compareWith) {
console.log("Compare parameter found in URL, loading items");
populateSoftwareItems();
}
// Also add a direct event listener to the compare button to populate items
document.querySelectorAll('[x-text="isCompareMode ? \'${t.software.expandView}\' : \'${t.software.inlineCompare}\'"').forEach(btn => {
btn.addEventListener('click', () => {
// Short delay to ensure Alpine has updated its state
setTimeout(() => populateSoftwareItems(), 100);
});
});
}, 500);
});
</script>

View file

@ -0,0 +1,86 @@
---
import { loadTranslations } from '../../utils/i18n';
const { pricing, locale } = Astro.props;
const t = await loadTranslations(locale);
---
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[400px] overflow-y-auto">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">{t.software.pricing || 'Pricing'}</h2>
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-full p-1">
<button
id="monthlyPricingBtn"
class="px-3 py-1 rounded-full bg-primary text-white text-sm font-medium"
>
{t.software.monthlyPrice || 'Monthly'}
</button>
<button
id="yearlyPricingBtn"
class="px-3 py-1 rounded-full text-sm font-medium"
>
{t.software.yearlyPrice || 'Yearly'}
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{pricing.map(plan => (
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 class="text-xl font-bold mb-2">{plan.model}</h3>
<div class="pricing-container">
<div class="monthly-price block">
<p class="text-2xl font-bold text-primary dark:text-blue-400">{plan.price}</p>
</div>
<div class="yearly-price hidden">
<p class="text-2xl font-bold text-primary dark:text-blue-400">{plan.yearly_price}</p>
</div>
</div>
<ul class="space-y-2">
{plan.features.map(feature => (
<li class="flex items-start">
<svg class="h-5 w-5 text-green-500 mr-2 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="dark:text-gray-300">{feature}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<script>
// Pricing toggle functionality
document.addEventListener('DOMContentLoaded', () => {
const monthlyBtn = document.getElementById('monthlyPricingBtn');
const yearlyBtn = document.getElementById('yearlyPricingBtn');
const monthlyPrices = document.querySelectorAll('.monthly-price');
const yearlyPrices = document.querySelectorAll('.yearly-price');
if (monthlyBtn && yearlyBtn) {
monthlyBtn.addEventListener('click', () => {
monthlyBtn.classList.add('bg-primary', 'text-white');
yearlyBtn.classList.remove('bg-primary', 'text-white');
monthlyPrices.forEach(el => el.classList.remove('hidden'));
monthlyPrices.forEach(el => el.classList.add('block'));
yearlyPrices.forEach(el => el.classList.add('hidden'));
yearlyPrices.forEach(el => el.classList.remove('block'));
});
yearlyBtn.addEventListener('click', () => {
yearlyBtn.classList.add('bg-primary', 'text-white');
monthlyBtn.classList.remove('bg-primary', 'text-white');
yearlyPrices.forEach(el => el.classList.remove('hidden'));
yearlyPrices.forEach(el => el.classList.add('block'));
monthlyPrices.forEach(el => el.classList.add('hidden'));
monthlyPrices.forEach(el => el.classList.remove('block'));
});
}
});
</script>

View file

@ -0,0 +1,96 @@
---
const { screenshots, softwareName } = Astro.props;
---
<div class="mb-8 relative">
<h3 class="text-xl font-bold mb-4">Screenshots</h3>
<div class="relative">
{/* Navigation buttons - outside the scrolling area */}
<button
id="prev-btn"
class="absolute left-2 top-1/2 transform -translate-y-1/2 bg-black bg-opacity-50 hover:bg-opacity-70 text-white p-2 rounded-full z-10"
aria-label="Previous screenshot"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
id="next-btn"
class="absolute right-2 top-1/2 transform -translate-y-1/2 bg-black bg-opacity-50 hover:bg-opacity-70 text-white p-2 rounded-full z-10"
aria-label="Next screenshot"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Scrollable gallery area */}
<div id="screenshot-gallery" class="rounded-lg overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-200 dark:scrollbar-thumb-gray-600 dark:scrollbar-track-gray-800">
<div class="flex space-x-4 pb-4 pl-[calc(50%-150px)]" id="screenshot-container">
{screenshots.map((screenshot, index) => (
<div class={`screenshot-slide inline-block min-w-min flex-shrink-0 ${index === 0 ? 'active' : ''}`} data-index={index}>
<img
src={screenshot}
alt={`${softwareName} screenshot ${index + 1}`}
class="h-auto max-h-[80vh] object-contain rounded-lg"
style="min-width: 300px;"
/>
</div>
))}
{/* Empty div at the end for equal spacing */}
<div class="pr-[calc(50%-150px)]"></div>
</div>
</div>
</div>
</div>
<script>
// Screenshot gallery functionality
document.addEventListener('DOMContentLoaded', () => {
const gallery = document.getElementById('screenshot-gallery');
if (gallery) {
const slides = document.querySelectorAll('.screenshot-slide');
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
let currentIndex = 0;
const totalSlides = slides.length;
function scrollToActiveSlide(index, behavior = 'smooth') {
if (index < 0) index = totalSlides - 1;
if (index >= totalSlides) index = 0;
currentIndex = index;
slides.forEach(slide => slide.classList.remove('active'));
slides[currentIndex].classList.add('active');
const activeSlide = slides[currentIndex];
const galleryRect = gallery.getBoundingClientRect();
const slideRect = activeSlide.getBoundingClientRect();
const scrollOffset = (slideRect.left + slideRect.width / 2) - (galleryRect.left + galleryRect.width / 2);
gallery.scrollBy({
left: scrollOffset,
behavior: behavior
});
}
prevBtn?.addEventListener('click', () => scrollToActiveSlide(currentIndex - 1));
nextBtn?.addEventListener('click', () => scrollToActiveSlide(currentIndex + 1));
slides.forEach((slide, index) => {
slide.addEventListener('click', () => {
scrollToActiveSlide(index);
});
});
window.addEventListener('load', () => {
setTimeout(() => {
scrollToActiveSlide(0, 'auto');
}, 100);
});
}
});
</script>

View file

@ -0,0 +1,375 @@
---
import { loadTranslations } from '../../utils/i18n';
import SoftwareOverview from './SoftwareOverview.astro';
import SoftwareRatings from './SoftwareRatings.astro';
import ComparisonView from './ComparisonView.astro';
import ComparePanel from './ComparePanel.astro';
import CommentSystem from '../CommentSystem.astro';
const { software, similarSoftware, metrics, locale } = Astro.props;
const t = await loadTranslations(locale);
// Daten für den Client vorbereiten
const softwareJson = JSON.stringify(software);
const similarSoftwareJson = JSON.stringify(similarSoftware);
---
<div
id="softwareDetail"
data-software-id={software.id}
data-software={softwareJson}
data-similar-software={similarSoftwareJson}
class="container mx-auto px-4 py-8"
>
<!-- Flexibles Layout für die Software-Ansicht -->
<div
class="flex flex-col lg:flex-row transition-all duration-300 software-container"
>
<!-- Linke Spalte - Aktuelle Software -->
<div
class="transition-all duration-300 overflow-hidden w-full left-panel"
>
<!-- Software-Header mit Name, Kategorien, Logo und Aktionsschaltflächen -->
<div class="flex flex-col md:flex-row md:items-center mb-8">
<!-- Minimieren-Button (wird via JS hinzugefügt) -->
{software.logo && (
<div class="w-24 h-24 bg-white p-2 rounded-lg shadow-sm flex items-center justify-center mr-6 mb-4 md:mb-0">
<img src={software.logo} alt={software.name} class="max-w-full max-h-full" />
</div>
)}
<div>
<h1 class="text-3xl font-bold">{software.name}</h1>
<div class="flex flex-wrap items-center mt-2">
{software.categories.map((category, index) => (
<>
<a
href={`/${locale}/category/${category.toLowerCase().replace(' ', '-')}`}
class="text-primary hover:underline"
>
{category}
</a>
{index < software.categories.length - 1 && (
<span class="mx-2 text-gray-400">•</span>
)}
</>
))}
</div>
</div>
<div class="md:ml-auto mt-4 md:mt-0 flex space-x-2">
<a
href={software.website}
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary"
>
{t.common.visit || 'Visit Website'}
</a>
<!-- Vergleichsschalter -->
<button
id="compareToggleBtn"
class="btn btn-secondary"
>Compare</button>
</div>
</div>
<!-- Software-Inhalt -->
<div class="software-content">
<div class="block">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Hauptinhaltsbereich - Übersicht, Preisgestaltung, Kommentare -->
<div class="lg:col-span-2">
<!-- Software-Übersichtskomponente -->
<SoftwareOverview software={software} locale={locale} />
<!-- Preisgestaltungsbereich -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[400px] overflow-y-auto">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">{t.software.pricing || 'Pricing'}</h2>
<div class="flex items-center bg-gray-100 dark:bg-gray-700 rounded-full p-1">
<button
id="monthlyPricingBtn"
class="px-3 py-1 rounded-full bg-primary text-white text-sm font-medium"
>
{t.software.monthlyPrice || 'Monthly'}
</button>
<button
id="yearlyPricingBtn"
class="px-3 py-1 rounded-full text-sm font-medium"
>
{t.software.yearlyPrice || 'Yearly'}
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{software.pricing.map(plan => (
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 class="text-xl font-bold mb-2">{plan.model}</h3>
<div class="pricing-container">
<div class="monthly-price block">
<p class="text-2xl font-bold text-primary dark:text-blue-400">{plan.price}</p>
</div>
<div class="yearly-price hidden">
<p class="text-2xl font-bold text-primary dark:text-blue-400">{plan.yearly_price}</p>
</div>
</div>
<ul class="space-y-2">
{plan.features.map(feature => (
<li class="flex items-start">
<svg class="h-5 w-5 text-green-500 mr-2 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="dark:text-gray-300">{feature}</span>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<!-- Kommentar-Bereich -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[350px] overflow-y-auto">
<h2 class="text-2xl font-bold mb-4">Kommentare</h2>
<CommentSystem softwareId={software.id} />
</div>
</div>
<!-- Seitenleiste - Bewertungen und Vergleichsfunktionen -->
<div>
<!-- Software-Bewertungskomponente -->
<SoftwareRatings softwareId={software.id} metrics={metrics} locale={locale} />
<!-- Vergleichs-Ansicht-Komponente -->
<ComparisonView
currentSoftware={software}
similarSoftware={similarSoftware}
locale={locale}
/>
</div>
</div>
</div>
</div>
<!-- Minimierte Ansicht wird per JS hinzugefügt -->
</div>
<!-- Rechte Spalte - Vergleichs-Software (wird per JS angezeigt) -->
<div class="compare-panel-container hidden lg:w-1/2 mt-8 lg:mt-0 lg:ml-8 transition-all duration-300">
<!-- Vergleichs-Panel-Komponente -->
<ComparePanel locale={locale} />
</div>
</div>
<!-- Mobile-Ansicht-Umschalter (via JS eingeblendet) -->
<div class="mobile-switcher hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3 flex justify-center space-x-4 lg:hidden z-10">
<button class="left-view-btn px-4 py-2 rounded-md transition-colors bg-primary text-white">
{software.name}
</button>
<button class="right-view-btn px-4 py-2 rounded-md transition-colors bg-gray-100 dark:bg-gray-700">
Compare
</button>
</div>
</div>
<script>
// Seiteninitialisierung
document.addEventListener('DOMContentLoaded', () => {
// Vergleichsmodus-Steuerung
const softwareDetail = document.getElementById('softwareDetail');
const compareToggleBtn = document.getElementById('compareToggleBtn');
const comparePanelContainer = document.querySelector('.compare-panel-container');
const mobileSwitcher = document.querySelector('.mobile-switcher');
const softwareListView = document.getElementById('software-list-view');
// Preisgestaltungs-Umschalter
const monthlyBtn = document.getElementById('monthlyPricingBtn');
const yearlyBtn = document.getElementById('yearlyPricingBtn');
const monthlyPrices = document.querySelectorAll('.monthly-price');
const yearlyPrices = document.querySelectorAll('.yearly-price');
// Initialisiere Alpine.js Store mit Daten aus data-Attributen
let compareMode = false;
try {
// Daten aus data-Attributen laden
const softwareId = softwareDetail.dataset.softwareId;
const softwareData = JSON.parse(softwareDetail.dataset.software);
const similarSoftware = JSON.parse(softwareDetail.dataset.similarSoftware);
// URL-Parameter überprüfen
const urlParams = new URLSearchParams(window.location.search);
const compareWith = urlParams.get('compare');
if (compareWith) {
compareMode = true;
toggleCompareMode(true);
// Buttontext aktualisieren
if (compareToggleBtn) {
compareToggleBtn.textContent = 'Exit Compare';
}
// TODO: Lade Software-Daten zum Vergleich
}
// Compare-Button-Handler
if (compareToggleBtn) {
compareToggleBtn.addEventListener('click', () => {
compareMode = !compareMode;
toggleCompareMode(compareMode);
// Buttontext ändern
if (compareMode) {
compareToggleBtn.textContent = 'Exit Compare';
} else {
compareToggleBtn.textContent = 'Compare';
}
});
}
// Setze Mobile Switcher
const leftViewBtn = document.querySelector('.left-view-btn');
const rightViewBtn = document.querySelector('.right-view-btn');
if (leftViewBtn && rightViewBtn) {
leftViewBtn.addEventListener('click', () => switchView('left'));
rightViewBtn.addEventListener('click', () => switchView('right'));
}
// Toggle Vergleichsmodus-Funktion
function toggleCompareMode(isActive) {
console.log("Toggling compare mode:", isActive);
if (isActive) {
// UI für Vergleichsmodus anpassen
comparePanelContainer.classList.remove('hidden');
mobileSwitcher.classList.remove('hidden');
// Software-Liste anzeigen im ComparePanel
if (softwareListView) {
softwareListView.style.display = 'block';
}
// Passe Layout an - Linke Panele auf Halbbreite
const leftPanel = document.querySelector('.left-panel');
if (leftPanel) {
leftPanel.classList.add('lg:w-1/2');
leftPanel.classList.remove('w-full');
// Zusätzlich die Grid-Struktur anpassen - auf vollen Platz in der linken Spalte
const contentGrid = leftPanel.querySelector('.grid');
if (contentGrid) {
// Hier einfach das gesamte Grid auf eine Spalte umstellen
contentGrid.classList.add('lg:grid-cols-1');
contentGrid.classList.remove('lg:grid-cols-3');
// Hauptinhaltsbereich auf volle Breite
const mainContent = contentGrid.querySelector('.lg\\:col-span-2');
if (mainContent) {
mainContent.classList.remove('lg:col-span-2');
mainContent.classList.add('lg:col-span-1');
}
}
}
// URL-Parameter aktualisieren
// Wird später implementiert basierend auf ausgewählter Software
} else {
// UI für Standardmodus anpassen
comparePanelContainer.classList.add('hidden');
mobileSwitcher.classList.add('hidden');
// Passe Layout an - Linke Panele auf volle Breite
const leftPanel = document.querySelector('.left-panel');
if (leftPanel) {
leftPanel.classList.remove('lg:w-1/2');
leftPanel.classList.add('w-full');
// Grid-Struktur wiederherstellen
const contentGrid = leftPanel.querySelector('.grid');
if (contentGrid) {
contentGrid.classList.remove('lg:grid-cols-1');
contentGrid.classList.add('lg:grid-cols-3');
// Hauptinhaltsbereich auf 2/3 Breite
const mainContent = contentGrid.querySelector('.lg\\:col-span-1');
if (mainContent) {
mainContent.classList.add('lg:col-span-2');
mainContent.classList.remove('lg:col-span-1');
}
}
}
// URL-Parameter entfernen
const url = new URL(window.location.href);
url.searchParams.delete('compare');
window.history.pushState({}, '', url.toString());
}
}
// Mobile-Anzeige umschalten
function switchView(side) {
if (side === 'left') {
leftViewBtn.classList.add('bg-primary', 'text-white');
leftViewBtn.classList.remove('bg-gray-100', 'dark:bg-gray-700');
rightViewBtn.classList.remove('bg-primary', 'text-white');
rightViewBtn.classList.add('bg-gray-100', 'dark:bg-gray-700');
// Für mobile Geräte
const mediaQuery = window.matchMedia('(max-width: 1024px)');
if (mediaQuery.matches) {
// Panel anzeigen/verstecken
document.querySelector('.left-panel').style.display = 'block';
comparePanelContainer.style.display = 'none';
}
} else {
rightViewBtn.classList.add('bg-primary', 'text-white');
rightViewBtn.classList.remove('bg-gray-100', 'dark:bg-gray-700');
leftViewBtn.classList.remove('bg-primary', 'text-white');
leftViewBtn.classList.add('bg-gray-100', 'dark:bg-gray-700');
// Für mobile Geräte
const mediaQuery = window.matchMedia('(max-width: 1024px)');
if (mediaQuery.matches) {
// Panel anzeigen/verstecken
document.querySelector('.left-panel').style.display = 'none';
comparePanelContainer.style.display = 'block';
}
}
}
} catch (e) {
console.error("Error initializing software detail:", e);
}
// Preisgestaltungs-Umschalter
if (monthlyBtn && yearlyBtn) {
monthlyBtn.addEventListener('click', () => {
monthlyBtn.classList.add('bg-primary', 'text-white');
yearlyBtn.classList.remove('bg-primary', 'text-white');
monthlyPrices.forEach(el => el.classList.remove('hidden'));
monthlyPrices.forEach(el => el.classList.add('block'));
yearlyPrices.forEach(el => el.classList.add('hidden'));
yearlyPrices.forEach(el => el.classList.remove('block'));
});
yearlyBtn.addEventListener('click', () => {
yearlyBtn.classList.add('bg-primary', 'text-white');
monthlyBtn.classList.remove('bg-primary', 'text-white');
yearlyPrices.forEach(el => el.classList.remove('hidden'));
yearlyPrices.forEach(el => el.classList.add('block'));
monthlyPrices.forEach(el => el.classList.add('hidden'));
monthlyPrices.forEach(el => el.classList.remove('block'));
});
}
});
</script>

View file

@ -0,0 +1,80 @@
---
import { loadTranslations } from '../../utils/i18n';
const { software, locale, isCompareMode = false } = Astro.props;
const t = await loadTranslations(locale);
---
<div class="flex flex-col md:flex-row md:items-center mb-8">
<!-- Toggle minimize button for comparison mode -->
{isCompareMode && (
<button
@click="toggleMinimize('left')"
class="mr-4 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
:aria-label="isLeftMinimized ? '${t.software.expandView}' : '${t.software.minimizeView}'"
>
<svg x-show="!isLeftMinimized" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
<svg x-show="isLeftMinimized" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
)}
{software.logo && (
<div class="w-24 h-24 bg-white p-2 rounded-lg shadow-sm flex items-center justify-center mr-6 mb-4 md:mb-0">
<img src={software.logo} alt={software.name} class="max-w-full max-h-full" />
</div>
)}
<div>
<h1 class="text-3xl font-bold">{software.name}</h1>
<div class="flex flex-wrap items-center mt-2">
{software.categories.map((category, index) => (
<>
<a
href={`/${locale}/category/${category.toLowerCase().replace(' ', '-')}`}
class="text-primary hover:underline"
>
{category}
</a>
{index < software.categories.length - 1 && (
<span class="mx-2 text-gray-400">•</span>
)}
</>
))}
</div>
</div>
<div class="md:ml-auto mt-4 md:mt-0 flex space-x-2">
<a
href={software.website}
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary"
>
{t.common.visit || 'Visit Website'}
</a>
<!-- Compare button -->
<button
@click="toggleCompareMode"
class="btn btn-secondary"
x-text="isCompareMode ? '${t.software.expandView}' : '${t.software.inlineCompare}'"
></button>
</div>
</div>
<!-- Minimized view for left panel when in comparison mode -->
<template x-if="isLeftMinimized && isCompareMode">
<div class="p-4 flex flex-col items-center justify-center border-r border-gray-200 dark:border-gray-700 h-full">
{software.logo && (
<div class="w-16 h-16 bg-white p-2 rounded-lg shadow-sm flex items-center justify-center mb-2">
<img src={software.logo} alt={software.name} class="max-w-full max-h-full" />
</div>
)}
<h2 class="text-center text-lg font-bold mb-2">{software.name}</h2>
<p class="text-center text-sm text-gray-500 dark:text-gray-400 mb-4 line-clamp-3">{software.description}</p>
</div>
</template>

View file

@ -0,0 +1,36 @@
---
import { loadTranslations } from '../../utils/i18n';
import ScreenshotGallery from './ScreenshotGallery.astro';
const { software, locale } = Astro.props;
const t = await loadTranslations(locale);
---
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[500px] overflow-y-auto">
<h2 class="text-2xl font-bold mb-4">{t.common.overview || 'Overview'}</h2>
<p class="text-gray-700 dark:text-gray-300 mb-6">{software.description}</p>
{software.screenshots && software.screenshots.length > 0 && (
<ScreenshotGallery screenshots={software.screenshots} softwareName={software.name} />
)}
<h3 class="text-xl font-bold mb-4">{t.software.features || 'Features'}</h3>
<ul class="list-disc list-inside mb-6">
{software.features.map(feature => (
<li class="mb-2 text-gray-700 dark:text-gray-300">{feature}</li>
))}
</ul>
<h3 class="text-xl font-bold mb-4">{t.software.platforms || 'Platforms'}</h3>
<div class="flex flex-wrap gap-2 mb-6">
{software.platforms.map(platform => (
<span class="px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-sm">
{platform}
</span>
))}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{t.software.lastUpdated || 'Last Updated'}: {new Date(software.lastUpdated).toLocaleDateString()}
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
---
import { loadTranslations } from '../../utils/i18n';
import VotingSystem from '../VotingSystem.astro';
const { softwareId, metrics, locale } = Astro.props;
const t = await loadTranslations(locale);
---
<div class="space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[300px] overflow-y-auto">
<h3 class="text-xl font-bold mb-4">{t.common.ratings || 'Ratings'}</h3>
<div class="space-y-4">
{Object.entries(metrics).map(([key, value]) => {
const metricName = t.voting[key] || key;
return (
<div>
<div class="flex justify-between items-center mb-1">
<span class="font-medium">{metricName}</span>
<span class="font-bold">{value.average.toFixed(1)} / 5</span>
</div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-primary"
style={`width: ${(value.average / 5) * 100}%`}
></div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 text-right mt-1">
{value.count} {t.common.votes || 'votes'}
</div>
</div>
);
})}
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 h-[300px] overflow-y-auto">
<h3 class="text-xl font-bold mb-4">Bewerten</h3>
<VotingSystem softwareId={softwareId} />
</div>
</div>