mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 17:06:42 +02:00
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:
parent
17313473aa
commit
34c879929b
161 changed files with 12613 additions and 0 deletions
|
|
@ -0,0 +1,162 @@
|
|||
---
|
||||
import { getLocaleFromUrl, loadTranslations } from '../utils/i18n';
|
||||
import { supabase } from '../utils/supabase';
|
||||
|
||||
const { softwareId } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = await loadTranslations(locale);
|
||||
|
||||
// Versuche, Kommentare aus Supabase zu laden, mit Fallback für den Fall,
|
||||
// dass Supabase nicht verfügbar ist
|
||||
let comments = [];
|
||||
try {
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase
|
||||
.from('comments')
|
||||
.select('*')
|
||||
.eq('software_id', softwareId)
|
||||
.eq('is_approved', true)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error && data) {
|
||||
comments = data;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching comments:', error);
|
||||
// Weitermachen mit leerem Array
|
||||
}
|
||||
---
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 transition-colors duration-200"
|
||||
data-software-id={softwareId}
|
||||
x-data="{
|
||||
softwareId: '',
|
||||
userName: '',
|
||||
comment: '',
|
||||
loading: false,
|
||||
message: '',
|
||||
showMessage: false,
|
||||
init() {
|
||||
// Software-ID aus Datenattribut lesen
|
||||
this.softwareId = this.$el.getAttribute('data-software-id');
|
||||
console.log('Initialized CommentSystem with software ID:', this.softwareId);
|
||||
},
|
||||
|
||||
async submitComment() {
|
||||
if (!this.userName.trim() || !this.comment.trim()) {
|
||||
this.message = 'Please fill in all fields';
|
||||
this.showMessage = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
console.log('Submitting comment for', this.softwareId);
|
||||
const response = await fetch('/api/comment', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
softwareId: this.softwareId,
|
||||
userName: this.userName,
|
||||
comment: this.comment
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Comment submission failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
this.message = t.comments.moderation;
|
||||
this.showMessage = true;
|
||||
this.userName = '';
|
||||
this.comment = '';
|
||||
} catch (error) {
|
||||
console.error('Error submitting comment:', error);
|
||||
this.message = 'Error submitting comment';
|
||||
this.showMessage = true;
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
|
||||
// Hide message after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.showMessage = false;
|
||||
}, 5000);
|
||||
}
|
||||
}">
|
||||
<h3 class="text-xl font-bold mb-6 dark:text-white">{t.comments.title}</h3>
|
||||
|
||||
{comments.length > 0 ? (
|
||||
<div class="space-y-4 mb-8">
|
||||
{comments.map(comment => (
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="font-medium dark:text-white">{comment.user_name}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{new Date(comment.created_at).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-300">{comment.comment}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400 mb-8">
|
||||
{t.comments.noComments}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h4 class="font-medium mb-4 dark:text-white">{t.comments.writeComment}</h4>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="userName" class="block mb-1 text-sm font-medium dark:text-gray-300">{t.comments.yourName}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="userName"
|
||||
x-model="userName"
|
||||
class="w-full p-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="comment" class="block mb-1 text-sm font-medium dark:text-gray-300">{t.comments.yourComment}</label>
|
||||
<textarea
|
||||
id="comment"
|
||||
x-model="comment"
|
||||
class="w-full p-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded h-32 transition-colors duration-200"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="submitComment"
|
||||
class="btn btn-primary"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span x-show="!loading">{t.comments.submit}</span>
|
||||
<span x-show="loading">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline-block" 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...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="showMessage"
|
||||
class="mt-4 p-3 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-100 rounded transition-colors duration-200"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
>
|
||||
<p x-text="message"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
const { developer } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-300 overflow-hidden h-full flex flex-col">
|
||||
<div class="p-6 bg-gradient-to-br from-blue-100 to-indigo-100 dark:from-blue-900 dark:to-indigo-900">
|
||||
<div class="flex items-center">
|
||||
{developer.logo ? (
|
||||
<img
|
||||
src={developer.logo}
|
||||
alt={developer.name}
|
||||
class="w-16 h-16 rounded-lg bg-white/80 dark:bg-white/10 p-2 object-contain mr-4"
|
||||
/>
|
||||
) : (
|
||||
<div class="w-16 h-16 rounded-lg bg-white/80 dark:bg-white/10 flex items-center justify-center text-3xl mr-4">
|
||||
{developer.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-primary dark:group-hover:text-blue-400 transition-colors">
|
||||
{developer.name}
|
||||
</h2>
|
||||
|
||||
{developer.softwareCount > 0 && (
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{developer.softwareCount} {developer.softwareCount === 1 ? 'Software' : 'Software'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{developer.country && (
|
||||
<div class="mt-3 flex items-center">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
|
||||
</svg>
|
||||
{developer.country}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="p-6 flex-grow">
|
||||
<p class="text-gray-700 dark:text-gray-300 line-clamp-3">
|
||||
{developer.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-3 border-t border-gray-100 dark:border-gray-600">
|
||||
<div class="flex justify-end">
|
||||
<span class="text-primary dark:text-blue-400 group-hover:underline">
|
||||
Explore →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
---
|
||||
import { getLocaleFromUrl } from '../utils/i18n';
|
||||
|
||||
const { categories = [], platforms = [] } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
|
||||
// Define price ranges
|
||||
const priceRanges = [
|
||||
{ id: 'free', label: 'Free' },
|
||||
{ id: 'paid', label: 'Paid' },
|
||||
{ id: 'subscription', label: 'Subscription' }
|
||||
];
|
||||
|
||||
// Define rating filters
|
||||
const ratingFilters = [
|
||||
{ metric: 'easeOfUse', label: 'Ease of Use' },
|
||||
{ metric: 'featureRichness', label: 'Feature Richness' },
|
||||
{ metric: 'valueForMoney', label: 'Value for Money' },
|
||||
{ metric: 'support', label: 'Support' },
|
||||
{ metric: 'reliability', label: 'Reliability' }
|
||||
];
|
||||
---
|
||||
|
||||
<div class="p-4 transition-colors duration-200" x-data="{
|
||||
showMobileFilters: false,
|
||||
activeCategories: [],
|
||||
activePlatforms: [],
|
||||
activePriceRanges: [],
|
||||
ratingThresholds: {
|
||||
easeOfUse: 0,
|
||||
featureRichness: 0,
|
||||
valueForMoney: 0,
|
||||
support: 0,
|
||||
reliability: 0
|
||||
},
|
||||
|
||||
toggleCategory(category) {
|
||||
if (this.activeCategories.includes(category)) {
|
||||
this.activeCategories = this.activeCategories.filter(c => c !== category);
|
||||
} else {
|
||||
this.activeCategories.push(category);
|
||||
}
|
||||
this.updateFilters();
|
||||
},
|
||||
|
||||
togglePlatform(platform) {
|
||||
if (this.activePlatforms.includes(platform)) {
|
||||
this.activePlatforms = this.activePlatforms.filter(p => p !== platform);
|
||||
} else {
|
||||
this.activePlatforms.push(platform);
|
||||
}
|
||||
this.updateFilters();
|
||||
},
|
||||
|
||||
togglePriceRange(range) {
|
||||
if (this.activePriceRanges.includes(range)) {
|
||||
this.activePriceRanges = this.activePriceRanges.filter(r => r !== range);
|
||||
} else {
|
||||
this.activePriceRanges.push(range);
|
||||
}
|
||||
this.updateFilters();
|
||||
},
|
||||
|
||||
updateRatingThreshold(metric, value) {
|
||||
this.ratingThresholds[metric] = value;
|
||||
this.updateFilters();
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
this.activeCategories = [];
|
||||
this.activePlatforms = [];
|
||||
this.activePriceRanges = [];
|
||||
this.ratingThresholds = {
|
||||
easeOfUse: 0,
|
||||
featureRichness: 0,
|
||||
valueForMoney: 0,
|
||||
support: 0,
|
||||
reliability: 0
|
||||
};
|
||||
this.updateFilters();
|
||||
},
|
||||
|
||||
updateFilters() {
|
||||
// Emit custom event for parent components to listen to
|
||||
const filters = {
|
||||
categories: this.activeCategories,
|
||||
platforms: this.activePlatforms,
|
||||
priceRanges: this.activePriceRanges,
|
||||
ratingThresholds: this.ratingThresholds
|
||||
};
|
||||
|
||||
this.$dispatch('filters-updated', {
|
||||
detail: filters
|
||||
});
|
||||
}
|
||||
}">
|
||||
<!-- Mobile filter toggle -->
|
||||
<div class="lg:hidden mb-4">
|
||||
<button
|
||||
@click="showMobileFilters = !showMobileFilters"
|
||||
class="w-full flex items-center justify-between bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 p-3 rounded-md transition-colors duration-200"
|
||||
>
|
||||
<span class="font-medium">Filters</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
:class="{'rotate-180': showMobileFilters}"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter content -->
|
||||
<div
|
||||
class="space-y-6 lg:block"
|
||||
:class="{'hidden': !showMobileFilters && window.innerWidth < 1024}"
|
||||
x-transition
|
||||
>
|
||||
<!-- Categories -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg mb-3 dark:text-white">Categories</h3>
|
||||
<div class="space-y-2">
|
||||
{categories.map(category => (
|
||||
<label class="flex items-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 p-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={category.id}
|
||||
@click="toggleCategory(`${category.id}`)"
|
||||
:checked="activeCategories.includes(`${category.id}`)"
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="ml-2 dark:text-gray-300">{category.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{categories.length === 0 && (
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No additional categories available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Platforms -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg mb-3 dark:text-white">Platforms</h3>
|
||||
<div class="space-y-2">
|
||||
{platforms.map(platform => (
|
||||
<label class="flex items-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 p-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={platform}
|
||||
@click="togglePlatform(`${platform}`)"
|
||||
:checked="activePlatforms.includes(`${platform}`)"
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="ml-2 dark:text-gray-300">{platform}</span>
|
||||
</label>
|
||||
))}
|
||||
{platforms.length === 0 && (
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No platform filters available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price Range -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg mb-3 dark:text-white">Price</h3>
|
||||
<div class="space-y-2">
|
||||
{priceRanges.map(range => (
|
||||
<label class="flex items-center cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 p-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={range.id}
|
||||
@click="togglePriceRange(`${range.id}`)"
|
||||
:checked="activePriceRanges.includes(`${range.id}`)"
|
||||
class="rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="ml-2 dark:text-gray-300">{range.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ratings -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg mb-3 dark:text-white">Minimum Rating</h3>
|
||||
<div class="space-y-4">
|
||||
{ratingFilters.map(filter => (
|
||||
<div>
|
||||
<div class="flex justify-between mb-1">
|
||||
<label for={`rating-${filter.metric}`} class="text-sm font-medium dark:text-gray-300">{filter.label}</label>
|
||||
<span class="text-sm font-bold dark:text-white" x-text="ratingThresholds.${filter.metric}"></span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
id={`rating-${filter.metric}`}
|
||||
min="0"
|
||||
max="5"
|
||||
step="0.5"
|
||||
x-model="ratingThresholds.${filter.metric}"
|
||||
@input="updateRatingThreshold('${filter.metric}', $event.target.value)"
|
||||
class="w-full h-2 bg-gray-200 dark:bg-gray-600 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span>Any</span>
|
||||
<span>5★</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
<div class="pt-2">
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="text-sm text-primary dark:text-blue-400 font-medium hover:underline flex items-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
38
apps-archived/techbase/apps/web/src/components/Footer.astro
Normal file
38
apps-archived/techbase/apps/web/src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
import { getLocaleFromUrl } from '../utils/i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="bg-gray-800 text-white py-8 mt-auto transition-colors duration-200">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-4">TechBase</h3>
|
||||
<p class="text-gray-300">Die zentrale Plattform für Software-Vergleiche und Bewertungen</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-4">Links</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href={`/${locale}`} class="text-gray-300 hover:text-white">Home</a></li>
|
||||
<li><a href={`/${locale}/about`} class="text-gray-300 hover:text-white">Über uns</a></li>
|
||||
<li><a href={`/${locale}/contact`} class="text-gray-300 hover:text-white">Kontakt</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-bold mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href={`/${locale}/privacy`} class="text-gray-300 hover:text-white">Datenschutz</a></li>
|
||||
<li><a href={`/${locale}/imprint`} class="text-gray-300 hover:text-white">Impressum</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 mt-8 pt-4 text-center text-gray-400">
|
||||
© {year} TechBase. Alle Rechte vorbehalten.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
91
apps-archived/techbase/apps/web/src/components/Header.astro
Normal file
91
apps-archived/techbase/apps/web/src/components/Header.astro
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
import LanguageSwitcher from './LanguageSwitcher.astro';
|
||||
import ThemeToggle from './ThemeToggle.astro';
|
||||
import { getLocaleFromUrl } from '../utils/i18n';
|
||||
import { loadTranslations } from '../utils/i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = await loadTranslations(locale);
|
||||
|
||||
// Get current path for active link highlighting
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<header class="bg-white dark:bg-gray-800 shadow-md transition-colors duration-200 w-full sticky top-0 z-50">
|
||||
<div class="container mx-auto px-4 py-3 flex justify-between items-center">
|
||||
<a href={`/${locale}`} class="text-2xl font-bold text-primary dark:text-blue-400">TechBase</a>
|
||||
|
||||
<nav class="hidden md:flex space-x-6">
|
||||
<a
|
||||
href={`/${locale}/software`}
|
||||
class={`text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-blue-400 pb-1 border-b-2 transition-colors ${currentPath.includes(`/${locale}/software`) && !currentPath.includes(`/${locale}/software/`) ? 'border-primary dark:border-blue-400' : 'border-transparent'}`}
|
||||
>
|
||||
{t.common.software}
|
||||
</a>
|
||||
<a
|
||||
href={`/${locale}/categories`}
|
||||
class={`text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-blue-400 pb-1 border-b-2 transition-colors ${currentPath.includes(`/${locale}/categories`) ? 'border-primary dark:border-blue-400' : 'border-transparent'}`}
|
||||
>
|
||||
{t.common.categories}
|
||||
</a>
|
||||
<a
|
||||
href={`/${locale}/developers`}
|
||||
class={`text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-blue-400 pb-1 border-b-2 transition-colors ${currentPath.includes(`/${locale}/developers`) ? 'border-primary dark:border-blue-400' : 'border-transparent'}`}
|
||||
>
|
||||
{t.common.developers}
|
||||
</a>
|
||||
<a
|
||||
href={`/${locale}/compare`}
|
||||
class={`text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-blue-400 pb-1 border-b-2 transition-colors ${currentPath.includes(`/${locale}/compare`) ? 'border-primary dark:border-blue-400' : 'border-transparent'}`}
|
||||
>
|
||||
{t.common.compare}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile hamburger menu button -->
|
||||
<button class="md:hidden flex items-center p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary dark:focus:ring-blue-400" aria-expanded="false">
|
||||
<svg class="h-6 w-6 dark:text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<ThemeToggle />
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu, hidden by default -->
|
||||
<div class="md:hidden hidden bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="container mx-auto px-4 py-2 space-y-2">
|
||||
<a href={`/${locale}/software`} class="block py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-blue-400">
|
||||
{t.common.software}
|
||||
</a>
|
||||
<a href={`/${locale}/categories`} class="block py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-blue-400">
|
||||
{t.common.categories}
|
||||
</a>
|
||||
<a href={`/${locale}/developers`} class="block py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-blue-400">
|
||||
{t.common.developers}
|
||||
</a>
|
||||
<a href={`/${locale}/compare`} class="block py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-blue-400">
|
||||
{t.common.compare}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mobileMenuButton = document.querySelector('header button');
|
||||
const mobileMenu = document.querySelector('header > div:nth-child(2)');
|
||||
|
||||
// Toggle mobile menu
|
||||
if (mobileMenuButton && mobileMenu) {
|
||||
mobileMenuButton.addEventListener('click', () => {
|
||||
const expanded = mobileMenuButton.getAttribute('aria-expanded') === 'true';
|
||||
mobileMenuButton.setAttribute('aria-expanded', !expanded);
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
import { getLocaleFromUrl, getLocalizedUrl } from '../utils/i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const pathname = Astro.url.pathname;
|
||||
const currentPath = pathname.replace(new RegExp(`^/${locale}`), '') || '/';
|
||||
|
||||
const languages = [
|
||||
{ code: 'de', name: 'Deutsch' },
|
||||
{ code: 'en', name: 'English' }
|
||||
];
|
||||
---
|
||||
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<button
|
||||
@click="open = !open"
|
||||
class="flex items-center space-x-1 p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors duration-200"
|
||||
>
|
||||
<span>{locale === 'de' ? 'DE' : 'EN'}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
@click.away="open = false"
|
||||
class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md overflow-hidden z-10 border border-gray-200 dark:border-gray-700 transition-colors duration-200"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="py-1">
|
||||
{languages.map(lang => (
|
||||
<a
|
||||
href={getLocalizedUrl(currentPath, lang.code)}
|
||||
class={`block px-4 py-2 text-sm transition-colors duration-200 ${
|
||||
locale === lang.code
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-primary dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{lang.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
150
apps-archived/techbase/apps/web/src/components/SearchBar.astro
Normal file
150
apps-archived/techbase/apps/web/src/components/SearchBar.astro
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
---
|
||||
import { getLocaleFromUrl } from '../utils/i18n';
|
||||
|
||||
const { placeholder = 'Search...', showButton = true } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
---
|
||||
|
||||
<div class="relative"
|
||||
data-locale={locale}
|
||||
x-data="{
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
isLoading: false,
|
||||
showResults: false,
|
||||
currentLocale: '',
|
||||
init() {
|
||||
// Get locale from the data attribute
|
||||
this.currentLocale = this.$el.dataset.locale;
|
||||
|
||||
this.$watch('searchQuery', value => {
|
||||
if (value.length >= 2) {
|
||||
this.performSearch();
|
||||
} else {
|
||||
this.searchResults = [];
|
||||
this.showResults = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
async performSearch() {
|
||||
if (this.searchQuery.length < 2) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Fetch all software data if we haven't already
|
||||
if (!window.softwareData) {
|
||||
const response = await fetch('/api/software.json');
|
||||
window.softwareData = await response.json();
|
||||
}
|
||||
|
||||
// Filter software by the current locale
|
||||
const localeSoftware = window.softwareData.filter(item => item.locale === this.currentLocale);
|
||||
|
||||
// Perform search using Fuse.js
|
||||
const fuse = new Fuse(localeSoftware, {
|
||||
keys: ['name', 'description', 'features', 'categories'],
|
||||
threshold: 0.4,
|
||||
ignoreLocation: true
|
||||
});
|
||||
|
||||
this.searchResults = fuse.search(this.searchQuery).map(result => result.item);
|
||||
this.showResults = this.searchResults.length > 0;
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
navigateToResult(url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
}">
|
||||
<div class="relative flex w-full">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
placeholder={placeholder}
|
||||
class="w-full py-2 px-4 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l focus:outline-none focus:ring-2 focus:ring-primary dark:focus:ring-blue-500 focus:border-transparent transition-colors duration-200"
|
||||
@focus="showResults = searchResults.length > 0"
|
||||
@blur="setTimeout(() => showResults = false, 200)"
|
||||
/>
|
||||
|
||||
{showButton && (
|
||||
<button
|
||||
class="bg-primary dark:bg-blue-600 text-white px-4 py-2 rounded-r hover:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition-colors duration-200"
|
||||
@click="performSearch()"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
x-show="isLoading"
|
||||
class="absolute right-4 top-1/2 transform -translate-y-1/2"
|
||||
>
|
||||
<svg class="animate-spin h-5 w-5 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="showResults"
|
||||
@click.away="showResults = false"
|
||||
class="absolute z-10 mt-2 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-96 overflow-y-auto transition-colors duration-200"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
style="display: none;"
|
||||
>
|
||||
<template x-if="searchResults.length === 0 && searchQuery.length >= 2">
|
||||
<div class="p-4 text-center text-gray-500 dark:text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-for="result in searchResults" :key="result.id">
|
||||
<a
|
||||
:href="result.url"
|
||||
class="block p-4 hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors duration-200"
|
||||
@mousedown.prevent
|
||||
@click.prevent="navigateToResult(result.url)"
|
||||
>
|
||||
<div class="flex items-start">
|
||||
<template x-if="result.logo">
|
||||
<div class="w-10 h-10 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded mr-3 flex items-center justify-center">
|
||||
<img :src="result.logo" :alt="result.name" class="max-w-full max-h-full p-1">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900 dark:text-white" x-text="result.name"></div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-2" x-text="result.description"></div>
|
||||
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
<template x-for="category in result.categories.slice(0, 3)" :key="category">
|
||||
<span class="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full" x-text="category"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Fuse.js -->
|
||||
<script is:inline src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2"></script>
|
||||
|
||||
<script>
|
||||
// Initialize global variable for software data
|
||||
if (typeof window !== 'undefined' && !window.softwareData) {
|
||||
window.softwareData = null;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
---
|
||||
import { getLocaleFromUrl } from '../utils/i18n';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const { software } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
|
||||
// Get developer info if available
|
||||
let developer = null;
|
||||
if (software.developer) {
|
||||
const developers = await getCollection('developers');
|
||||
developer = developers.find(dev => dev.id === `${locale}/${software.developer}`);
|
||||
|
||||
// Fallback to other locale if not found
|
||||
if (!developer) {
|
||||
const otherLocale = locale === 'en' ? 'de' : 'en';
|
||||
developer = developers.find(dev => dev.id === `${otherLocale}/${software.developer}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average rating across all metrics
|
||||
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;
|
||||
}
|
||||
|
||||
// Format average rating to one decimal place
|
||||
const formattedRating = averageRating.toFixed(1);
|
||||
---
|
||||
|
||||
<div class="relative">
|
||||
<a href={`/${locale}/software/${software.id}`} class="block group">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-300 h-full flex flex-col overflow-hidden">
|
||||
<div class="p-6 flex-grow">
|
||||
<div class="flex items-center mb-4">
|
||||
{software.logo && (
|
||||
<div class="w-12 h-12 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-1 flex items-center justify-center mr-4 overflow-hidden">
|
||||
<img src={software.logo} alt={software.name} class="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-bold group-hover:text-primary dark:group-hover:text-blue-400 dark:text-white transition-colors duration-200">{software.name}</h3>
|
||||
|
||||
{developer && (
|
||||
<a
|
||||
href={`/${locale}/developers/${software.developer.replace('.md', '')}`}
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-blue-400 hover:underline inline-block"
|
||||
onclick="event.stopPropagation(); return true;"
|
||||
>
|
||||
by {developer.data.name}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4 line-clamp-3">
|
||||
{software.description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{software.categories?.slice(0, 3).map(category => (
|
||||
<span class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-xs text-gray-700 dark:text-gray-300">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
{software.categories?.length > 3 && (
|
||||
<span class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-full text-xs text-gray-700 dark:text-gray-300">
|
||||
+{software.categories.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{software.supportedPlatforms ? (
|
||||
<>
|
||||
{software.supportedPlatforms?.slice(0, 4).map(platform => (
|
||||
<span class="px-2 py-1 bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full text-xs">
|
||||
{platform === 'Windows' && '🪟 '}
|
||||
{platform === 'macOS' && '🍎 '}
|
||||
{platform === 'Linux' && '🐧 '}
|
||||
{platform === 'Android' && '🤖 '}
|
||||
{platform === 'iOS' && '📱 '}
|
||||
{platform === 'Web' && '🌐 '}
|
||||
{platform}
|
||||
</span>
|
||||
))}
|
||||
{software.supportedPlatforms?.length > 4 && (
|
||||
<span class="px-2 py-1 bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full text-xs">
|
||||
+{software.supportedPlatforms.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{software.platforms?.slice(0, 4).map(platform => (
|
||||
<span class="px-2 py-1 bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full text-xs">
|
||||
{platform === 'Windows' && '🪟 '}
|
||||
{platform === 'macOS' && '🍎 '}
|
||||
{platform === 'Linux' && '🐧 '}
|
||||
{platform === 'Android' && '🤖 '}
|
||||
{platform === 'iOS' && '📱 '}
|
||||
{platform === 'Web' && '🌐 '}
|
||||
{platform}
|
||||
</span>
|
||||
))}
|
||||
{software.platforms?.length > 4 && (
|
||||
<span class="px-2 py-1 bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full text-xs">
|
||||
+{software.platforms.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 px-6 py-3 border-t border-gray-100 dark:border-gray-600">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<span class="text-yellow-400 mr-1">★</span>
|
||||
<span class="font-bold dark:text-white">{formattedRating}</span>
|
||||
{totalVotes > 0 && (
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-1">({totalVotes})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span class="text-primary dark:text-blue-400 group-hover:underline">
|
||||
Details →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
---
|
||||
// ThemeToggle component for switching between light and dark modes
|
||||
---
|
||||
|
||||
<button id="themeToggle" class="theme-toggle flex items-center justify-center w-8 h-8 rounded-full" aria-label="Toggle Dark Mode">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 dark:hidden">
|
||||
<!-- Sun icon -->
|
||||
<path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5 hidden dark:block">
|
||||
<!-- Moon icon -->
|
||||
<path fill-rule="evenodd" d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
// JavaScript to handle theme toggling
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
|
||||
// Check for saved theme preference or use OS preference
|
||||
const getInitialTheme = () => {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
}
|
||||
|
||||
// Default to dark theme
|
||||
return 'dark';
|
||||
};
|
||||
|
||||
// Apply the initial theme
|
||||
const setTheme = (theme) => {
|
||||
if (theme === 'dark') {
|
||||
html.classList.add('dark');
|
||||
document.body.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
};
|
||||
|
||||
// Set initial theme
|
||||
setTheme(getInitialTheme());
|
||||
|
||||
// Handle toggle click
|
||||
themeToggle?.addEventListener('click', () => {
|
||||
const isDark = html.classList.contains('dark');
|
||||
setTheme(isDark ? 'light' : 'dark');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
---
|
||||
import { getLocaleFromUrl, loadTranslations } from '../utils/i18n';
|
||||
|
||||
const { softwareId } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = await loadTranslations(locale);
|
||||
|
||||
const metrics = [
|
||||
{ id: 'usability', name: t.voting.usability },
|
||||
{ id: 'features', name: t.voting.features },
|
||||
{ id: 'performance', name: t.voting.performance },
|
||||
{ id: 'support', name: t.voting.support },
|
||||
{ id: 'value', name: t.voting.value }
|
||||
];
|
||||
---
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 mb-8 transition-colors duration-200"
|
||||
data-software-id={softwareId}
|
||||
data-metric-usability={t.voting.usability}
|
||||
data-metric-features={t.voting.features}
|
||||
data-metric-performance={t.voting.performance}
|
||||
data-metric-support={t.voting.support}
|
||||
data-metric-value={t.voting.value}
|
||||
x-data="{
|
||||
softwareId: '',
|
||||
metrics: [],
|
||||
ratings: {},
|
||||
message: '',
|
||||
showMessage: false,
|
||||
loading: false,
|
||||
init() {
|
||||
// Software-ID aus Datenattribut lesen
|
||||
this.softwareId = this.$el.getAttribute('data-software-id');
|
||||
console.log('Initialized VotingSystem with software ID:', this.softwareId);
|
||||
|
||||
// Metriken aus Datenattributen lesen
|
||||
this.metrics = [
|
||||
{ id: 'usability', name: this.$el.getAttribute('data-metric-usability') },
|
||||
{ id: 'features', name: this.$el.getAttribute('data-metric-features') },
|
||||
{ id: 'performance', name: this.$el.getAttribute('data-metric-performance') },
|
||||
{ id: 'support', name: this.$el.getAttribute('data-metric-support') },
|
||||
{ id: 'value', name: this.$el.getAttribute('data-metric-value') }
|
||||
];
|
||||
|
||||
// Ratings initialisieren
|
||||
this.metrics.forEach(metric => {
|
||||
this.ratings[metric.id] = 0;
|
||||
});
|
||||
},
|
||||
setRating(metricId, rating) {
|
||||
this.ratings[metricId] = rating;
|
||||
},
|
||||
async submitRatings() {
|
||||
this.loading = true;
|
||||
|
||||
for (const metricId in this.ratings) {
|
||||
if (this.ratings[metricId] > 0) {
|
||||
try {
|
||||
const response = await fetch('/api/vote', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
softwareId: this.softwareId,
|
||||
metric: metricId,
|
||||
rating: this.ratings[metricId]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Voting failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting vote:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
this.message = t.voting.thankYou;
|
||||
this.showMessage = true;
|
||||
|
||||
// Reset ratings
|
||||
Object.keys(this.ratings).forEach(key => {
|
||||
this.ratings[key] = 0;
|
||||
});
|
||||
|
||||
// Hide message after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.showMessage = false;
|
||||
}, 3000);
|
||||
}
|
||||
}">
|
||||
<h3 class="text-xl font-bold mb-4 dark:text-white">{t.software.vote}</h3>
|
||||
|
||||
<div class="space-y-4 mb-6">
|
||||
<template x-for="metric in metrics" :key="metric.id">
|
||||
<div>
|
||||
<label class="block mb-2 font-medium dark:text-gray-200" x-text="metric.name"></label>
|
||||
<div class="flex space-x-2">
|
||||
<template x-for="star in [1, 2, 3, 4, 5]">
|
||||
<button
|
||||
@click="setRating(metric.id, star)"
|
||||
class="text-2xl"
|
||||
:class="ratings[metric.id] >= star ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-600'"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="submitRatings"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="loading || Object.values(ratings).every(v => v === 0)"
|
||||
>
|
||||
<span x-show="!loading">{t.voting.submit}</span>
|
||||
<span x-show="loading">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline-block" 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...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="showMessage"
|
||||
class="mt-4 p-3 bg-green-100 dark:bg-green-800 text-green-800 dark:text-green-100 rounded transition-colors duration-200"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
>
|
||||
<p x-text="message"></p>
|
||||
</div>
|
||||
</div>
|
||||
210
apps-archived/techbase/apps/web/src/components/Welcome.astro
Normal file
210
apps-archived/techbase/apps/web/src/components/Welcome.astro
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
---
|
||||
import astroLogo from '../assets/astro.svg';
|
||||
import background from '../assets/background.svg';
|
||||
---
|
||||
|
||||
<div id="container">
|
||||
<img id="background" src={background.src} alt="" fetchpriority="high" />
|
||||
<main>
|
||||
<section id="hero">
|
||||
<a href="https://astro.build"
|
||||
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
|
||||
>
|
||||
<h1>
|
||||
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
|
||||
</h1>
|
||||
<section id="links">
|
||||
<a class="button" href="https://docs.astro.build">Read our docs</a>
|
||||
<a href="https://astro.build/chat"
|
||||
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
|
||||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
|
||||
fill="#111827"></path></svg
|
||||
>
|
||||
<h2>What's New in Astro 5.0?</h2>
|
||||
<p>
|
||||
From content layers to server islands, click to learn more about the new features and
|
||||
improvements in Astro 5.0
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
#container {
|
||||
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
#links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
color: #111827;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#links a:hover {
|
||||
color: rgb(78, 80, 86);
|
||||
}
|
||||
|
||||
#links a svg {
|
||||
height: 1em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
color: white;
|
||||
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#links a.button:hover {
|
||||
color: rgb(230, 230, 230);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
|
||||
monospace;
|
||||
font-weight: normal;
|
||||
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1em;
|
||||
font-weight: normal;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #4b5563;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.006em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
background:
|
||||
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
|
||||
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border-radius: 16px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
#news {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
max-width: 300px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
backdrop-filter: blur(50px);
|
||||
}
|
||||
|
||||
#news:hover {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 368px) {
|
||||
#news {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
#container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: block;
|
||||
padding-top: 10%;
|
||||
}
|
||||
|
||||
#links {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
#news {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
bottom: 2.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue