chore: add techbase to apps-archived

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

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

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

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

View file

@ -266,3 +266,20 @@ FINANCE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/finance
INVENTORY_BACKEND_PORT=3020
INVENTORY_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/inventory
INVENTORY_S3_PUBLIC_URL=http://localhost:9000/inventory-storage
# ============================================
# TECHBASE PROJECT
# ============================================
TECHBASE_BACKEND_PORT=3021
TECHBASE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/techbase
# ============================================
# WORLDREAM GAME
# ============================================
WORLDREAM_SUPABASE_URL=https://gbsrekoykkesullxdvbd.supabase.co
WORLDREAM_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imdic3Jla295a2tlc3VsbHhkdmJkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY1MTU3NzksImV4cCI6MjA3MjA5MTc3OX0.qQlZvHiB56oKTRD90fd8IasZeZELjXOA46f-hnOQA1g
WORLDREAM_OPENAI_API_KEY=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
WORLDREAM_GEMINI_API_KEY=AIzaSyB74aUj1KmJlcjNyT5uUiyDODQ6iYoAOjQ
WORLDREAM_REPLICATE_API_TOKEN=r8_QlvkstNhIc6NBX1ktpQ6ibvzOE2d2UQ1Emamd

View file

@ -55,6 +55,7 @@ These projects are temporarily archived and excluded from the workspace. To re-a
| **reader** | Reading app |
| **uload** | URL shortener |
| **wisekeep** | AI wisdom extraction from video |
| **techbase** | Software comparison platform |
## Development Commands

View file

@ -0,0 +1,129 @@
# TechBase Project Guide
## Project Structure
```
apps/techbase/
├── apps/
│ ├── web/ # Astro web application (@techbase/web)
│ └── backend/ # NestJS API server (@techbase/backend)
└── package.json
```
## Commands
### Root Level (from monorepo root)
```bash
pnpm techbase:dev # Run all techbase apps
pnpm dev:techbase:web # Start web app only
pnpm dev:techbase:backend # Start backend only
pnpm dev:techbase:app # Start web + backend together
pnpm techbase:db:push # Push schema to database
pnpm techbase:db:studio # Open Drizzle Studio
```
### Project Level (from apps/techbase/)
```bash
pnpm dev # Run all apps
pnpm dev:web # Start web only
pnpm dev:backend # Start backend only
pnpm build # Build all apps
```
## Technology Stack
- **Web**: Astro 5.5.5, Tailwind CSS, Alpine.js, Fuse.js
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
- **i18n**: German (default), English (via Astro Content Collections)
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/votes` | POST | Submit vote |
| `/api/votes/:softwareId` | GET | Get votes for software |
| `/api/votes/:softwareId/metrics` | GET | Get aggregated metrics |
| `/api/votes/metrics/all` | GET | Get all software metrics |
| `/api/comments` | POST | Submit comment |
| `/api/comments/:softwareId` | GET | Get approved comments |
| `/api/admin/comments` | GET | Get all comments (admin) |
| `/api/admin/comments/pending` | GET | Get pending comments |
| `/api/admin/comments/:id/approve` | PATCH | Approve comment |
| `/api/admin/comments/:id/reject` | PATCH | Reject comment |
| `/api/admin/comments/:id` | DELETE | Delete comment |
## Database Schema
### votes
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| software_id | VARCHAR(255) | Software identifier |
| metric | VARCHAR(50) | Metric name (easeOfUse, featureRichness, etc.) |
| rating | INTEGER | Rating value (1-5) |
| ip_hash | VARCHAR(255) | Hashed IP for duplicate prevention |
| created_at | TIMESTAMP | Creation timestamp |
Unique constraint on (software_id, metric, ip_hash)
### comments
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| software_id | VARCHAR(255) | Software identifier |
| user_name | VARCHAR(100) | Comment author |
| comment | TEXT | Comment content |
| ip_hash | VARCHAR(255) | Hashed IP |
| is_approved | BOOLEAN | Moderation status |
| is_spam | BOOLEAN | Spam flag |
| moderated_at | TIMESTAMP | Moderation timestamp |
| moderated_by | VARCHAR(255) | Moderator identifier |
| created_at | TIMESTAMP | Creation timestamp |
## Environment Variables
### Backend (.env)
```env
PORT=3021
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/techbase
MANA_CORE_AUTH_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:4321,http://localhost:5173
```
### Web (.env)
```env
PUBLIC_BACKEND_URL=http://localhost:3021
```
## Code Style Preferences
- Use Tailwind CSS for styling
- Use Alpine.js for client-side interactivity
- Follow Astro's component structure
- Keep logic in separate utility files
- Use TypeScript for type safety
- Backend follows NestJS module pattern
## Key Directories
### Web App
- `src/components/`: UI components (SearchBar, VotingSystem, etc.)
- `src/content/`: Content collections (software data in DE/EN)
- `src/utils/`: Utility functions (i18n, search, api client)
- `src/pages/`: Page routes and API endpoints
- `src/layouts/`: Page layouts (Base, Admin)
### Backend
- `src/db/schema/`: Drizzle ORM schemas
- `src/votes/`: Voting module (controller, service, DTOs)
- `src/comments/`: Comments module with moderation
- `src/health/`: Health check endpoint

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/techbase',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [],
"watchAssets": false
}
}

View file

@ -0,0 +1,40 @@
{
"name": "@techbase/backend",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { VotesModule } from './votes/votes.module';
import { CommentsModule } from './comments/comments.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
VotesModule,
CommentsModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,57 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Req } from '@nestjs/common';
import { Request } from 'express';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto/create-comment.dto';
@Controller('comments')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Post()
async createComment(@Body() createCommentDto: CreateCommentDto, @Req() req: Request) {
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
return this.commentsService.createComment(
createCommentDto.softwareId,
createCommentDto.userName,
createCommentDto.comment,
ipAddress
);
}
@Get(':softwareId')
async getComments(@Param('softwareId') softwareId: string) {
return this.commentsService.getApprovedComments(softwareId);
}
}
@Controller('admin/comments')
export class AdminCommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Get()
async getAllComments() {
return this.commentsService.getAllComments();
}
@Get('pending')
async getPendingComments() {
return this.commentsService.getPendingComments();
}
@Patch(':id/approve')
async approveComment(@Param('id') id: string) {
// TODO: Get actual moderator ID from auth
return this.commentsService.approveComment(id, 'admin');
}
@Patch(':id/reject')
async rejectComment(@Param('id') id: string) {
// TODO: Get actual moderator ID from auth
return this.commentsService.rejectComment(id, 'admin');
}
@Delete(':id')
async deleteComment(@Param('id') id: string) {
return this.commentsService.deleteComment(id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CommentsController, AdminCommentsController } from './comments.controller';
import { CommentsService } from './comments.service';
@Module({
controllers: [CommentsController, AdminCommentsController],
providers: [CommentsService],
exports: [CommentsService],
})
export class CommentsModule {}

View file

@ -0,0 +1,91 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { comments, type Comment, type NewComment } from '../db/schema';
import { createHash } from 'crypto';
@Injectable()
export class CommentsService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
private hashIp(ip: string): string {
return createHash('sha256').update(ip).digest('hex').substring(0, 32);
}
async createComment(
softwareId: string,
userName: string,
commentText: string,
ipAddress: string
): Promise<{ success: boolean; message: string }> {
const ipHash = this.hashIp(ipAddress);
const newComment: NewComment = {
softwareId,
userName,
comment: commentText,
ipHash,
isApproved: false,
isSpam: false,
};
await this.db.insert(comments).values(newComment);
return {
success: true,
message: 'Comment submitted successfully. It will be visible after moderation.',
};
}
async getApprovedComments(softwareId: string): Promise<Comment[]> {
return this.db
.select()
.from(comments)
.where(and(eq(comments.softwareId, softwareId), eq(comments.isApproved, true)))
.orderBy(desc(comments.createdAt));
}
async getAllComments(): Promise<Comment[]> {
return this.db.select().from(comments).orderBy(desc(comments.createdAt));
}
async getPendingComments(): Promise<Comment[]> {
return this.db
.select()
.from(comments)
.where(and(eq(comments.isApproved, false), eq(comments.isSpam, false)))
.orderBy(desc(comments.createdAt));
}
async approveComment(id: string, moderatorId: string): Promise<{ success: boolean }> {
await this.db
.update(comments)
.set({
isApproved: true,
moderatedAt: new Date(),
moderatedBy: moderatorId,
})
.where(eq(comments.id, id));
return { success: true };
}
async rejectComment(id: string, moderatorId: string): Promise<{ success: boolean }> {
await this.db
.update(comments)
.set({
isSpam: true,
moderatedAt: new Date(),
moderatedBy: moderatorId,
})
.where(eq(comments.id, id));
return { success: true };
}
async deleteComment(id: string): Promise<{ success: boolean }> {
await this.db.delete(comments).where(eq(comments.id, id));
return { success: true };
}
}

View file

@ -0,0 +1,19 @@
import { IsString, IsNotEmpty, MaxLength, MinLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
softwareId: string;
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
userName: string;
@IsString()
@IsNotEmpty()
@MinLength(10)
@MaxLength(2000)
comment: string;
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,17 @@
import { pgTable, uuid, varchar, text, boolean, timestamp } from 'drizzle-orm/pg-core';
export const comments = pgTable('comments', {
id: uuid('id').defaultRandom().primaryKey(),
softwareId: varchar('software_id', { length: 255 }).notNull(),
userName: varchar('user_name', { length: 100 }).notNull(),
comment: text('comment').notNull(),
ipHash: varchar('ip_hash', { length: 255 }).notNull(),
isApproved: boolean('is_approved').default(false),
isSpam: boolean('is_spam').default(false),
moderatedAt: timestamp('moderated_at'),
moderatedBy: varchar('moderated_by', { length: 255 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export type Comment = typeof comments.$inferSelect;
export type NewComment = typeof comments.$inferInsert;

View file

@ -0,0 +1,2 @@
export * from './votes.schema';
export * from './comments.schema';

View file

@ -0,0 +1,19 @@
import { pgTable, uuid, varchar, integer, timestamp, unique } from 'drizzle-orm/pg-core';
export const votes = pgTable(
'votes',
{
id: uuid('id').defaultRandom().primaryKey(),
softwareId: varchar('software_id', { length: 255 }).notNull(),
metric: varchar('metric', { length: 50 }).notNull(),
rating: integer('rating').notNull(),
ipHash: varchar('ip_hash', { length: 255 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
uniqueVote: unique().on(table.softwareId, table.metric, table.ipHash),
})
);
export type Vote = typeof votes.$inferSelect;
export type NewVote = typeof votes.$inferInsert;

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'techbase-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,37 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for web app
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
'http://localhost:4321',
'http://localhost:5173',
'http://localhost:3000',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3020;
await app.listen(port);
console.log(`TechBase backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,16 @@
import { IsString, IsInt, Min, Max, IsNotEmpty } from 'class-validator';
export class CreateVoteDto {
@IsString()
@IsNotEmpty()
softwareId: string;
@IsString()
@IsNotEmpty()
metric: string;
@IsInt()
@Min(1)
@Max(5)
rating: number;
}

View file

@ -0,0 +1,30 @@
import { Controller, Post, Get, Body, Param, Req } from '@nestjs/common';
import { Request } from 'express';
import { VotesService } from './votes.service';
import { CreateVoteDto } from './dto/create-vote.dto';
@Controller('votes')
export class VotesController {
constructor(private readonly votesService: VotesService) {}
@Post()
async createVote(@Body() createVoteDto: CreateVoteDto, @Req() req: Request) {
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
return this.votesService.createVote(
createVoteDto.softwareId,
createVoteDto.metric,
createVoteDto.rating,
ipAddress
);
}
@Get(':softwareId/metrics')
async getMetrics(@Param('softwareId') softwareId: string) {
return this.votesService.getAllMetrics(softwareId);
}
@Get(':softwareId/metrics/:metric')
async getMetricByName(@Param('softwareId') softwareId: string, @Param('metric') metric: string) {
return this.votesService.getMetrics(softwareId, metric);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { VotesController } from './votes.controller';
import { VotesService } from './votes.service';
@Module({
controllers: [VotesController],
providers: [VotesService],
exports: [VotesService],
})
export class VotesModule {}

View file

@ -0,0 +1,98 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, sql, avg, count } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { votes, type NewVote } from '../db/schema';
import { createHash } from 'crypto';
@Injectable()
export class VotesService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
private hashIp(ip: string): string {
return createHash('sha256').update(ip).digest('hex').substring(0, 32);
}
async createVote(
softwareId: string,
metric: string,
rating: number,
ipAddress: string
): Promise<{ success: boolean; newAverage: number; voteCount: number }> {
const ipHash = this.hashIp(ipAddress);
// Check if user already voted for this metric
const existingVote = await this.db
.select()
.from(votes)
.where(and(eq(votes.softwareId, softwareId), eq(votes.metric, metric), eq(votes.ipHash, ipHash)))
.limit(1);
if (existingVote.length > 0) {
// Update existing vote
await this.db
.update(votes)
.set({ rating, createdAt: new Date() })
.where(eq(votes.id, existingVote[0].id));
} else {
// Create new vote
const newVote: NewVote = {
softwareId,
metric,
rating,
ipHash,
};
await this.db.insert(votes).values(newVote);
}
// Get updated metrics
const metrics = await this.getMetrics(softwareId, metric);
return {
success: true,
newAverage: metrics.averageRating,
voteCount: metrics.voteCount,
};
}
async getMetrics(
softwareId: string,
metric?: string
): Promise<{ averageRating: number; voteCount: number }> {
const conditions = metric
? and(eq(votes.softwareId, softwareId), eq(votes.metric, metric))
: eq(votes.softwareId, softwareId);
const result = await this.db
.select({
averageRating: avg(votes.rating),
voteCount: count(votes.id),
})
.from(votes)
.where(conditions);
return {
averageRating: parseFloat(result[0]?.averageRating || '0') || 0,
voteCount: result[0]?.voteCount || 0,
};
}
async getAllMetrics(
softwareId: string
): Promise<{ metric: string; averageRating: number; voteCount: number }[]> {
const result = await this.db
.select({
metric: votes.metric,
averageRating: avg(votes.rating),
voteCount: count(votes.id),
})
.from(votes)
.where(eq(votes.softwareId, softwareId))
.groupBy(votes.metric);
return result.map((r) => ({
metric: r.metric,
averageRating: parseFloat(r.averageRating || '0') || 0,
voteCount: r.voteCount || 0,
}));
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,17 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import alpinejs from '@astrojs/alpinejs';
// https://astro.build/config
export default defineConfig({
integrations: [
tailwind(),
alpinejs(),
],
// Für mehrsprachige Unterstützung
i18n: {
defaultLocale: 'de',
locales: ['de', 'en'],
}
});

View file

@ -0,0 +1,22 @@
{
"name": "@techbase/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/alpinejs": "^0.4.4",
"@astrojs/tailwind": "^6.0.2",
"alpinejs": "^3.14.9",
"astro": "^5.5.5",
"fuse.js": "^7.1.0",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,33 @@
// Debug-Hilfsfunktionen
console.log('Debug-Tools geladen');
// Listener für fetch-Anfragen
const originalFetch = window.fetch;
window.fetch = function(...args) {
console.log('Fetch Request:', args[0], args[1]);
return originalFetch.apply(this, args)
.then(response => {
if (!response.ok) {
console.error('Fetch error:', response.status, response.statusText, response.url);
}
return response.clone()
.text()
.then(text => {
try {
const data = JSON.parse(text);
console.log('Fetch Response:', data);
} catch (e) {
console.log('Fetch Response (nicht-JSON):', text.substring(0, 500));
}
return response;
})
.catch(err => {
console.error('Error parsing response:', err);
return response;
});
})
.catch(error => {
console.error('Fetch failed:', error);
throw error;
});
};

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#f0f0f0" rx="10" />
<text x="50" y="50" font-family="Arial" font-size="14" text-anchor="middle" dominant-baseline="middle" fill="#333">
Sample Logo
</text>
<circle cx="50" cy="50" r="30" fill="none" stroke="#3b82f6" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View file

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

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="115" height="48"><path fill="#17191E" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="url(#a)" d="M7.77 36.35C6.4 35.11 6 32.51 6.57 30.62c.99 1.2 2.35 1.57 3.75 1.78 2.18.33 4.31.2 6.33-.78.23-.12.44-.27.7-.42.18.55.23 1.1.17 1.67a4.56 4.56 0 0 1-1.94 3.23c-.43.32-.9.61-1.34.91-1.38.94-1.76 2.03-1.24 3.62l.05.17a3.63 3.63 0 0 1-1.6-1.38 3.87 3.87 0 0 1-.63-2.1c0-.37 0-.74-.05-1.1-.13-.9-.55-1.3-1.33-1.32a1.56 1.56 0 0 0-1.63 1.26c0 .06-.03.12-.05.2Z"/><path fill="#17191E" d="M.02 30.31s4.02-1.95 8.05-1.95l3.04-9.4c.11-.45.44-.76.82-.76.37 0 .7.31.82.76l3.04 9.4c4.77 0 8.05 1.95 8.05 1.95L17 11.71c-.2-.56-.53-.91-.98-.91H7.83c-.44 0-.76.35-.97.9L.02 30.31Zm42.37-5.97c0 1.64-2.05 2.62-4.88 2.62-1.85 0-2.5-.45-2.5-1.41 0-1 .8-1.49 2.65-1.49 1.67 0 3.09.03 4.73.23v.05Zm.03-2.04a21.37 21.37 0 0 0-4.37-.36c-5.32 0-7.82 1.25-7.82 4.18 0 3.04 1.71 4.2 5.68 4.2 3.35 0 5.63-.84 6.46-2.92h.14c-.03.5-.05 1-.05 1.4 0 1.07.18 1.16 1.06 1.16h4.15a16.9 16.9 0 0 1-.36-4c0-1.67.06-2.93.06-4.62 0-3.45-2.07-5.64-8.56-5.64-2.8 0-5.9.48-8.26 1.19.22.93.54 2.83.7 4.06 2.04-.96 4.95-1.37 7.2-1.37 3.11 0 3.97.71 3.97 2.15v.57Zm11.37 3c-.56.07-1.33.07-2.12.07-.83 0-1.6-.03-2.12-.1l-.02.58c0 2.85 1.87 4.52 8.45 4.52 6.2 0 8.2-1.64 8.2-4.55 0-2.74-1.33-4.09-7.2-4.39-4.58-.2-4.99-.7-4.99-1.28 0-.66.59-1 3.65-1 3.18 0 4.03.43 4.03 1.35v.2a46.13 46.13 0 0 1 4.24.03l.02-.55c0-3.36-2.8-4.46-8.2-4.46-6.08 0-8.13 1.49-8.13 4.39 0 2.6 1.64 4.23 7.48 4.48 4.3.14 4.77.62 4.77 1.28 0 .7-.7 1.03-3.71 1.03-3.47 0-4.35-.48-4.35-1.47v-.13Zm19.82-12.05a17.5 17.5 0 0 1-6.24 3.48c.03.84.03 2.4.03 3.24l1.5.02c-.02 1.63-.04 3.6-.04 4.9 0 3.04 1.6 5.32 6.58 5.32 2.1 0 3.5-.23 5.23-.6a43.77 43.77 0 0 1-.46-4.13c-1.03.34-2.34.53-3.78.53-2 0-2.82-.55-2.82-2.13 0-1.37 0-2.65.03-3.84 2.57.02 5.13.07 6.64.11-.02-1.18.03-2.9.1-4.04-2.2.04-4.65.07-6.68.07l.07-2.93h-.16Zm13.46 6.04a767.33 767.33 0 0 1 .07-3.18H82.6c.07 1.96.07 3.98.07 6.92 0 2.95-.03 4.99-.07 6.93h5.18c-.09-1.37-.11-3.68-.11-5.65 0-3.1 1.26-4 4.12-4 1.33 0 2.28.16 3.1.46.03-1.16.26-3.43.4-4.43-.86-.25-1.81-.41-2.96-.41-2.46-.03-4.26.98-5.1 3.38l-.17-.02Zm22.55 3.65c0 2.5-1.8 3.66-4.64 3.66-2.81 0-4.61-1.1-4.61-3.66s1.82-3.52 4.61-3.52c2.82 0 4.64 1.03 4.64 3.52Zm4.71-.11c0-4.96-3.87-7.18-9.35-7.18-5.5 0-9.23 2.22-9.23 7.18 0 4.94 3.49 7.59 9.21 7.59 5.77 0 9.37-2.65 9.37-7.6Z"/><defs><linearGradient id="a" x1="6.33" x2="19.43" y1="40.8" y2="34.6" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1024" fill="none"><path fill="url(#a)" fill-rule="evenodd" d="M-217.58 475.75c91.82-72.02 225.52-29.38 341.2-44.74C240 415.56 372.33 315.14 466.77 384.9c102.9 76.02 44.74 246.76 90.31 366.31 29.83 78.24 90.48 136.14 129.48 210.23 57.92 109.99 169.67 208.23 155.9 331.77-13.52 121.26-103.42 264.33-224.23 281.37-141.96 20.03-232.72-220.96-374.06-196.99-151.7 25.73-172.68 330.24-325.85 315.72-128.6-12.2-110.9-230.73-128.15-358.76-12.16-90.14 65.87-176.25 44.1-264.57-26.42-107.2-167.12-163.46-176.72-273.45-10.15-116.29 33.01-248.75 124.87-320.79Z" clip-rule="evenodd" style="opacity:.154"/><path fill="url(#b)" fill-rule="evenodd" d="M1103.43 115.43c146.42-19.45 275.33-155.84 413.5-103.59 188.09 71.13 409 212.64 407.06 413.88-1.94 201.25-259.28 278.6-414.96 405.96-130 106.35-240.24 294.39-405.6 265.3-163.7-28.8-161.93-274.12-284.34-386.66-134.95-124.06-436-101.46-445.82-284.6-9.68-180.38 247.41-246.3 413.54-316.9 101.01-42.93 207.83 21.06 316.62 6.61Z" clip-rule="evenodd" style="opacity:.154"/><defs><linearGradient id="b" x1="373" x2="1995.44" y1="1100" y2="118.03" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient><linearGradient id="a" x1="107.37" x2="1130.66" y1="1993.35" y2="1026.31" gradientUnits="userSpaceOnUse"><stop stop-color="#3245FF"/><stop offset="1" stop-color="#BC52EE"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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">
&copy; {year} TechBase. Alle Rechte vorbehalten.
</div>
</div>
</footer>

View 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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,5 @@
---
name: Webbrowser
description: Software-Anwendungen für den Zugriff auf Websites und Webanwendungen.
icon: 🌐
---

View file

@ -0,0 +1,5 @@
---
name: Cloud-Speicher
description: Online-Speicherlösungen, die Dateifreigabe, Backup und Synchronisierung zwischen Geräten ermöglichen.
icon: ☁️
---

View file

@ -0,0 +1,5 @@
---
name: Design & Grafik
description: Software-Tools zum Erstellen, Bearbeiten und Manipulieren von visuellen Inhalten, einschließlich Fotos, Illustrationen und digitaler Kunst.
icon: 🎨
---

View file

@ -0,0 +1,5 @@
---
name: Dokumentenverwaltung
description: Anwendungen zum Erstellen, Anzeigen, Bearbeiten und Verwalten von PDF und anderen Dokumentformaten.
icon: 📄
---

View file

@ -0,0 +1,5 @@
---
name: E-Mail
description: E-Mail-Clients und -Dienste für persönliche und professionelle Kommunikation.
icon: 📧
---

View file

@ -0,0 +1,5 @@
---
name: Spiele
description: Software für Unterhaltung, Brettspiele, Lernspiele und Spiel-Plattformen
icon: gamepad
---

View file

@ -0,0 +1,5 @@
---
name: Kategorie 1
description: Dies ist eine Beispiel-Kategorie für Software-Produkte.
icon: 📊
---

View file

@ -0,0 +1,5 @@
---
name: Navigation
description: Tools für Routenplanung, Schritt-für-Schritt-Anweisungen und Reiseführung.
icon: 🧭
---

View file

@ -0,0 +1,5 @@
---
name: Office-Suiten
description: Softwaresammlungen von Produktivitätsanwendungen für die Büroarbeit.
icon: 📝
---

View file

@ -0,0 +1,5 @@
---
name: Betriebssysteme
description: Software, die die Computerhardware verwaltet und gemeinsame Dienste für Computerprogramme bereitstellt.
icon: 💻
---

View file

@ -0,0 +1,5 @@
---
name: Produktivität
description: Anwendungen und Tools zur Steigerung von Effizienz, Organisation und Arbeitsabläufen.
icon: ⚡
---

View file

@ -0,0 +1,5 @@
---
name: Reisen
description: Anwendungen für Reiseplanung, Buchung und Erkundung von Reisezielen.
icon: ✈️
---

View file

@ -0,0 +1,5 @@
---
name: Web Browsers
description: Software applications for accessing websites and web applications.
icon: 🌐
---

View file

@ -0,0 +1,5 @@
---
name: Category 1
description: This is a sample category for software products.
icon: 📊
---

View file

@ -0,0 +1,5 @@
---
name: Cloud Storage
description: Online storage solutions that enable file sharing, backup, and synchronization across devices.
icon: ☁️
---

View file

@ -0,0 +1,5 @@
---
name: Design & Graphics
description: Software tools for creating, editing, and manipulating visual content, including photos, illustrations, and digital art.
icon: 🎨
---

View file

@ -0,0 +1,5 @@
---
name: Document Management
description: Applications for creating, viewing, editing, and managing PDF and other document formats.
icon: 📄
---

View file

@ -0,0 +1,5 @@
---
name: Email
description: Email clients and services for personal and professional communication.
icon: 📧
---

View file

@ -0,0 +1,5 @@
---
name: Games
description: Software for entertainment, board games, educational games and gaming platforms
icon: gamepad
---

View file

@ -0,0 +1,5 @@
---
name: Navigation
description: Tools for route planning, turn-by-turn directions, and travel guidance.
icon: 🧭
---

View file

@ -0,0 +1,5 @@
---
name: Office Suites
description: Software collections of productivity applications designed for office work.
icon: 📝
---

View file

@ -0,0 +1,5 @@
---
name: Operating Systems
description: Software that manages computer hardware and provides common services for computer programs.
icon: 💻
---

View file

@ -0,0 +1,5 @@
---
name: Productivity
description: Applications and tools designed to enhance efficiency, organization, and workflow.
icon: ⚡
---

View file

@ -0,0 +1,5 @@
---
name: Travel
description: Applications for travel planning, booking, and exploration of destinations.
icon: ✈️
---

View file

@ -0,0 +1,78 @@
import { defineCollection, z } from 'astro:content';
// Schema für Developer-Einträge
export const developersCollection = defineCollection({
type: 'content',
schema: z.object({
name: z.string(),
description: z.string(),
logo: z.string().optional(),
website: z.string().url(),
foundedYear: z.number().optional(),
headquarters: z.string().optional(),
country: z.string().optional(),
employees: z.string().optional(),
revenue: z.string().optional(),
industry: z.string().optional(),
keyProducts: z.array(z.string()).optional(),
socialMedia: z.object({
twitter: z.string().optional(),
linkedin: z.string().optional(),
github: z.string().optional(),
facebook: z.string().optional()
}).optional()
})
});
// Schema für Software-Einträge
export const softwareCollection = defineCollection({
type: 'content',
schema: z.object({
name: z.string(),
description: z.string(),
logo: z.string().optional(),
website: z.string().url(),
screenshots: z.array(z.string()).optional(),
pricing: z.array(z.object({
model: z.string(),
price: z.string(),
yearly_price: z.string().optional(),
features: z.array(z.string())
})),
features: z.array(z.string()),
categories: z.array(z.string()),
platforms: z.array(z.string()),
supportedPlatforms: z.array(z.string()).optional(),
developer: z.string().optional(),
lastUpdated: z.coerce.date()
})
});
// Schema für Kategorien
export const categoriesCollection = defineCollection({
type: 'content',
schema: z.object({
name: z.string(),
description: z.string(),
icon: z.string().optional()
})
});
// Schema für Übersetzungen
export const translationsCollection = defineCollection({
type: 'data',
});
// i18n-Konfiguration
export const i18nConfig = {
defaultLocale: 'de',
locales: ['de', 'en']
};
// Collections registrieren
export const collections = {
'software': softwareCollection,
'categories': categoriesCollection,
'translations': translationsCollection,
'developers': developersCollection
};

View file

@ -0,0 +1,36 @@
---
name: Adobe Inc.
description: Amerikanisches multinationales Softwareunternehmen mit Fokus auf Kreativsoftwareprodukten.
logo: /logos/sample-logo.svg
website: https://www.adobe.com
foundedYear: 1982
headquarters: San Jose, Kalifornien
country: USA
employees: "26.000+"
revenue: "17,6 Milliarden $ (2022)"
industry: "Software, Creative Cloud, Dokumentenmanagement"
keyProducts:
- "Photoshop"
- "Illustrator"
- "Acrobat"
- "Premiere Pro"
- "After Effects"
- "InDesign"
- "Creative Cloud"
- "Experience Cloud"
socialMedia:
twitter: https://twitter.com/Adobe
linkedin: https://www.linkedin.com/company/adobe/
github: https://github.com/adobe
facebook: https://www.facebook.com/Adobe
---
Adobe Inc. (früher Adobe Systems Incorporated) wurde im Dezember 1982 von John Warnock und Charles Geschke gegründet, die zuvor bei Xerox PARC gearbeitet hatten. Das Unternehmen war Pionier bei der Entwicklung von PostScript, einer Seitenbeschreibungssprache, die das Desktop-Publishing revolutionierte.
Adobes Transformation begann mit der Einführung von Photoshop im Jahr 1990, das schnell zum Industriestandard für die Bildbearbeitung wurde. Im Laufe der Jahre erweiterte Adobe sein Produktportfolio um eine umfassende Suite von kreativen Werkzeugen, darunter Illustrator für Vektorgrafiken, InDesign für Seitenlayout und Premiere Pro für Videobearbeitung.
Im Jahr 2013 vollzog Adobe einen bedeutenden strategischen Wandel, indem es vom traditionellen Modell der Dauerlizenzierung zu einem abonnementbasierten Dienst mit der Einführung von Creative Cloud überging. Diese Transformation ermöglichte es dem Unternehmen, häufigere Updates, Cloud-Speicher und einen berechenbareren Einnahmestrom bereitzustellen.
Adobe hat sein Angebot über kreative Werkzeuge hinaus auf digitales Marketing, Analytik und Dokumentenmanagement-Lösungen durch seine Experience Cloud- und Document Cloud-Dienste erweitert. Das von Adobe mit Adobe Acrobat eingeführte PDF-Format ist zum globalen Standard für den sicheren Dokumentenaustausch geworden.
Heute wird Adobe als führend in den Bereichen Kreativsoftware, digitales Marketing und Dokumentenmanagement-Lösungen anerkannt und innoviert kontinuierlich, um den sich entwickelnden Bedürfnissen von Kreativprofis, Vermarktern und Unternehmen weltweit gerecht zu werden.

View file

@ -0,0 +1,32 @@
---
name: Apple Inc.
description: Multinationales Technologieunternehmen, das Unterhaltungselektronik, Computersoftware und Online-Dienste entwickelt und verkauft, bekannt für Produkte wie iPhone, iPad, Mac-Computer und Dienste wie App Store und Apple Music.
logo: /logos/sample-logo.svg
website: https://www.apple.com
foundedYear: 1976
headquarters: Cupertino, Kalifornien
country: USA
employees: "164.000+"
revenue: "394,3 Milliarden $ (2022)"
industry: "Technologie, Unterhaltungselektronik, Software, Online-Dienste"
keyProducts:
- "iPhone"
- "iPad"
- "Mac"
- "Apple Watch"
- "AirPods"
- "macOS"
- "iOS"
- "Safari"
socialMedia:
twitter: https://twitter.com/Apple
linkedin: https://www.linkedin.com/company/apple/
github: https://github.com/apple
facebook: https://www.facebook.com/apple
---
Apple Inc. ist eines der größten Technologieunternehmen der Welt, gegründet von Steve Jobs, Steve Wozniak und Ronald Wayne im Jahr 1976. Das Unternehmen begann als Hersteller von Personal Computern, hat sich aber seitdem auf verschiedene Technologiemärkte ausgeweitet und ist bekannt für seine Innovation, Qualität und starke Markentreue.
Apple revolutionierte die Smartphone-Branche mit der Einführung des iPhones im Jahr 2007, den Tablet-Markt mit dem iPad im Jahr 2010 und führt weiterhin im Bereich der Premium-Unterhaltungselektronik. Das Ökosystem aus Hardware, Software und Diensten ist eng integriert und schafft ein zusammenhängendes Benutzererlebnis über alle Apple-Produkte hinweg.
Das Unternehmen legt großen Wert auf Design, Benutzerdatenschutz und ökologische Nachhaltigkeit. Apples Einzelhandelsgeschäfte sind bekannt für ihre markante Architektur und ihren Kundenservice, und das Unternehmen betreibt weltweit über 500 Geschäfte. Apples Dienstleistungsbereich, einschließlich App Store, Apple Music, Apple TV+, iCloud und Apple Pay, ist zu einem zunehmend wichtigen Teil seines Geschäftsmodells geworden.

View file

@ -0,0 +1,29 @@
---
name: Google
description: Globales Technologieunternehmen, das sich auf Internet-bezogene Dienste und Produkte spezialisiert hat, darunter Online-Werbetechnologien, Suchmaschinen, Cloud Computing, Software und Hardware.
logo: /logos/sample-logo.svg
website: https://www.google.com
foundedYear: 1998
headquarters: Mountain View, Kalifornien
country: USA
employees: "156.500+"
revenue: "283 Milliarden $ (2022)"
industry: "Technologie, Internet, Cloud Computing, Werbung"
keyProducts:
- "Google Suche"
- "Android"
- "YouTube"
- "Google Cloud"
- "Google Workspace"
socialMedia:
twitter: https://twitter.com/Google
linkedin: https://www.linkedin.com/company/google/
github: https://github.com/google
facebook: https://www.facebook.com/Google/
---
Google LLC ist ein multinationales Technologieunternehmen, das sich auf Suchmaschinen-Technologie, Online-Werbung, Cloud Computing, Computersoftware, Quantencomputing, E-Commerce, künstliche Intelligenz und Unterhaltungselektronik konzentriert.
Das Unternehmen hat seinen ursprünglichen Fokus auf Suchmaschinen-Technologie weit überschritten und bietet heute mehr als 100 Produkte und Dienste an, darunter das Android-Betriebssystem, den Chrome-Webbrowser, den E-Mail-Dienst Gmail, Google Maps, Google Cloud Platform und YouTube. Googles Muttergesellschaft, Alphabet Inc., wurde 2015 durch eine Umstrukturierung gegründet und gehört heute zu den wertvollsten Unternehmen der Welt.
Googles Mission ist es, "die Informationen der Welt zu organisieren und allgemein zugänglich und nützlich zu machen." Das Unternehmen ist bekannt für seine innovative Kultur, Mitarbeitervorteile und erhebliche Investitionen in Forschung und Entwicklung.

View file

@ -0,0 +1,34 @@
---
name: Microsoft Corporation
description: Multinationales Technologieunternehmen, das Computersoftware, Unterhaltungselektronik, Personal Computer und zugehörige Dienstleistungen produziert.
logo: /logos/sample-logo.svg
website: https://www.microsoft.com
foundedYear: 1975
headquarters: Redmond, Washington
country: USA
employees: "221.000+"
revenue: "198,3 Milliarden $ (2022)"
industry: "Technologie, Software, Cloud Computing, Gaming, Hardware"
keyProducts:
- "Windows"
- "Office 365"
- "Azure"
- "Xbox"
- "Surface"
- "GitHub"
- "LinkedIn"
- "Teams"
socialMedia:
twitter: https://twitter.com/Microsoft
linkedin: https://www.linkedin.com/company/microsoft/
github: https://github.com/microsoft
facebook: https://www.facebook.com/Microsoft
---
Die Microsoft Corporation ist ein multinationales Technologieunternehmen, das am 4. April 1975 von Bill Gates und Paul Allen gegründet wurde. Ursprünglich konzentrierte sich das Unternehmen auf die Entwicklung von Software für den Altair 8800 Mikrocomputer und stieg in den 1980er Jahren mit MS-DOS und später mit Microsoft Windows zum dominierenden Anbieter von Betriebssystemen für Personal Computer auf.
Unter der Führung von Satya Nadella seit 2014 hat Microsoft seinen Fokus auf Cloud-Computing-Dienste verlagert, wobei Azure zu einem wichtigen Akteur auf dem Markt für Cloud-Infrastruktur geworden ist. Das Unternehmen hat auch Open-Source-Entwicklungspraktiken übernommen und sein Hardware-Angebot mit der Surface-Produktlinie erweitert.
Das Geschäftsmodell von Microsoft umfasst ein breites Spektrum an Technologieprodukten und -dienstleistungen, darunter Betriebssysteme, Produktivitätssoftware, Serveranwendungen, Cloud-Dienste, Spielekonsolen und Unternehmenslösungen. Dem Unternehmen ist es gelungen, sich von einem primären Softwareanbieter zu einem diversifizierten Technologieführer mit bedeutender Präsenz in Cloud-Diensten, künstlicher Intelligenz und Mixed Reality zu wandeln.
Im Laufe seiner Geschichte hat Microsoft eine bemerkenswerte Widerstandsfähigkeit und Anpassungsfähigkeit bewiesen, indem es wichtige Branchenveränderungen navigierte und sein Geschäftsmodell weiterentwickelte, um an der Spitze der technologischen Innovation zu bleiben. Das Unternehmen investiert weiterhin stark in neue Technologien wie künstliche Intelligenz, Quantencomputing und Mixed Reality und positioniert sich damit für weiteres Wachstum in der sich schnell verändernden Technologielandschaft.

View file

@ -0,0 +1,32 @@
---
name: Mozilla Foundation
description: Gemeinnützige Organisation, die das Open-Source-Mozilla-Projekt unterstützt und leitet, mit dem Fokus darauf, das Internet als globale öffentliche Ressource offen und für alle zugänglich zu halten.
logo: /logos/sample-logo.svg
website: https://www.mozilla.org
foundedYear: 2003
headquarters: Mountain View, Kalifornien
country: USA
employees: "750+"
revenue: "ungefähr 500 Millionen $ (2021)"
industry: "Gemeinnützigkeit, Open Source, Internet-Datenschutz, Web-Technologien"
keyProducts:
- "Firefox"
- "Firefox Mobile"
- "Mozilla VPN"
- "Pocket"
- "MDN Web Docs"
- "Thunderbird"
socialMedia:
twitter: https://twitter.com/mozilla
linkedin: https://www.linkedin.com/company/mozilla-corporation/
github: https://github.com/mozilla
facebook: https://www.facebook.com/mozilla
---
Die Mozilla Foundation ist eine gemeinnützige Organisation, die als Heimat des Mozilla-Projekts dient, einer Open-Source-Software-Gemeinschaft, die sich auf die Schaffung eines besseren Internets konzentriert. Die Stiftung wurde im Juli 2003 gegründet, wobei der Firefox-Webbrowser ihr Flaggschiffprodukt ist.
Die Struktur von Mozilla umfasst die Mozilla Foundation und ihre Tochtergesellschaft, die Mozilla Corporation, die für die Entwicklung und Verteilung von Firefox und anderen Produkten zuständig ist. Die Organisation orientiert sich am Mozilla-Manifest, das Prinzipien zur Förderung eines Internets als globale öffentliche Ressource, offen und für alle zugänglich, darlegt.
Über Firefox hinaus hat Mozilla an verschiedenen offenen Internet-Initiativen mitgewirkt, darunter die Entwicklung von Webstandards, Datenschutzfürsprache und Programme zur digitalen Bildung. Die Organisation setzt sich für die Prinzipien des Online-Datenschutzes, Open Source und Dezentralisierung ein und positioniert sich oft als Alternative zu den kommerziellen Interessen größerer Technologieunternehmen.
Mozillas Einnahmen stammen hauptsächlich aus Partnerschaften mit Suchmaschinen, wobei Google ein wichtiger Beitragender ist. Die Stiftung sucht kontinuierlich nach Möglichkeiten, ihre Finanzierung zu diversifizieren und gleichzeitig ihr Engagement für den Datenschutz der Nutzer und die Werte des offenen Internets aufrechtzuerhalten.

View file

@ -0,0 +1,36 @@
---
name: Adobe Inc.
description: American multinational computer software company focused on creativity software products.
logo: /logos/sample-logo.svg
website: https://www.adobe.com
foundedYear: 1982
headquarters: San Jose, California
country: USA
employees: "26,000+"
revenue: "$17.6 billion (2022)"
industry: "Software, Creative Cloud, Document Management"
keyProducts:
- "Photoshop"
- "Illustrator"
- "Acrobat"
- "Premiere Pro"
- "After Effects"
- "InDesign"
- "Creative Cloud"
- "Experience Cloud"
socialMedia:
twitter: https://twitter.com/Adobe
linkedin: https://www.linkedin.com/company/adobe/
github: https://github.com/adobe
facebook: https://www.facebook.com/Adobe
---
Adobe Inc. (formerly Adobe Systems Incorporated) was founded in December 1982 by John Warnock and Charles Geschke, who had previously worked at Xerox PARC. The company pioneered the development of PostScript, a page description language that revolutionized desktop publishing.
Adobe's transformation began with the introduction of Photoshop in 1990, which quickly became the industry standard for image editing. Over the years, Adobe expanded its product portfolio to include a comprehensive suite of creative tools, including Illustrator for vector graphics, InDesign for page layout, and Premiere Pro for video editing.
In 2013, Adobe made a significant strategic shift by moving from a traditional perpetual licensing model to a subscription-based service with the introduction of Creative Cloud. This transformation enabled the company to provide more frequent updates, cloud storage, and a more predictable revenue stream.
Adobe has further expanded its offerings beyond creative tools to include digital marketing, analytics, and document management solutions through its Experience Cloud and Document Cloud services. The company's PDF format, introduced with Adobe Acrobat, has become the global standard for secure document exchange.
Today, Adobe is recognized as a leader in creative software, digital marketing, and document management solutions, continuously innovating to meet the evolving needs of creative professionals, marketers, and businesses worldwide.

View file

@ -0,0 +1,32 @@
---
name: Apple Inc.
description: Multinational technology company that designs, develops, and sells consumer electronics, computer software, and online services, known for products like the iPhone, iPad, Mac computers, and services like the App Store and Apple Music.
logo: /logos/sample-logo.svg
website: https://www.apple.com
foundedYear: 1976
headquarters: Cupertino, California
country: USA
employees: "164,000+"
revenue: "$394.3 billion (2022)"
industry: "Technology, Consumer Electronics, Software, Online Services"
keyProducts:
- "iPhone"
- "iPad"
- "Mac"
- "Apple Watch"
- "AirPods"
- "macOS"
- "iOS"
- "Safari"
socialMedia:
twitter: https://twitter.com/Apple
linkedin: https://www.linkedin.com/company/apple/
github: https://github.com/apple
facebook: https://www.facebook.com/apple
---
Apple Inc. is one of the world's largest technology companies, founded by Steve Jobs, Steve Wozniak, and Ronald Wayne in 1976. The company began as a personal computer manufacturer but has since expanded into various technology markets, becoming known for its innovation, quality, and strong brand loyalty.
Apple revolutionized the smartphone industry with the introduction of the iPhone in 2007, the tablet market with the iPad in 2010, and continues to lead in premium consumer electronics. Its ecosystem of hardware, software, and services is tightly integrated, creating a cohesive user experience across all Apple products.
The company places a strong emphasis on design, user privacy, and environmental sustainability. Apple's retail stores are known for their distinctive architecture and customer service, with the company operating over 500 stores worldwide. Apple's services division, including the App Store, Apple Music, Apple TV+, iCloud, and Apple Pay, has become an increasingly important part of its business model.

View file

@ -0,0 +1,29 @@
---
name: Google
description: Global technology company that specializes in Internet-related services and products including online advertising technologies, search engines, cloud computing, software, and hardware.
logo: /logos/sample-logo.svg
website: https://www.google.com
foundedYear: 1998
headquarters: Mountain View, California
country: USA
employees: "156,500+"
revenue: "$283 billion (2022)"
industry: "Technology, Internet, Cloud Computing, Advertising"
keyProducts:
- "Google Search"
- "Android"
- "YouTube"
- "Google Cloud"
- "Google Workspace"
socialMedia:
twitter: https://twitter.com/Google
linkedin: https://www.linkedin.com/company/google/
github: https://github.com/google
facebook: https://www.facebook.com/Google/
---
Google LLC is a multinational technology company that focuses on search engine technology, online advertising, cloud computing, computer software, quantum computing, e-commerce, artificial intelligence, and consumer electronics.
The company has expanded far beyond its original focus on search engine technology and now offers over 100 products and services, including the Android operating system, Chrome web browser, Gmail email service, Google Maps, Google Cloud Platform, and YouTube. Google's parent company, Alphabet Inc., was created through a corporate restructuring in 2015 and is now one of the world's most valuable companies.
Google's mission is to "organize the world's information and make it universally accessible and useful." The company is known for its innovative culture, employee perks, and significant investments in research and development.

View file

@ -0,0 +1,34 @@
---
name: Microsoft Corporation
description: Multinational technology company that produces computer software, consumer electronics, personal computers, and related services.
logo: /logos/sample-logo.svg
website: https://www.microsoft.com
foundedYear: 1975
headquarters: Redmond, Washington
country: USA
employees: "221,000+"
revenue: "$198.3 billion (2022)"
industry: "Technology, Software, Cloud Computing, Gaming, Hardware"
keyProducts:
- "Windows"
- "Office 365"
- "Azure"
- "Xbox"
- "Surface"
- "GitHub"
- "LinkedIn"
- "Teams"
socialMedia:
twitter: https://twitter.com/Microsoft
linkedin: https://www.linkedin.com/company/microsoft/
github: https://github.com/microsoft
facebook: https://www.facebook.com/Microsoft
---
Microsoft Corporation is a multinational technology company founded by Bill Gates and Paul Allen on April 4, 1975. Originally focused on developing software for the Altair 8800 microcomputer, the company rose to dominate the personal computer operating system market with MS-DOS in the mid-1980s, followed by Microsoft Windows.
Under the leadership of Satya Nadella since 2014, Microsoft has shifted its focus toward cloud computing services, with Azure becoming a major player in the cloud infrastructure market. The company has also embraced open source development practices and expanded its hardware offerings with the Surface line of devices.
Microsoft's business model encompasses a wide range of technology products and services, including operating systems, productivity software, server applications, cloud services, gaming consoles, and enterprise solutions. The company has successfully transformed itself from primarily a software vendor to a diversified technology leader with significant presence in cloud services, artificial intelligence, and mixed reality.
Throughout its history, Microsoft has displayed remarkable resilience and adaptability, navigating major industry shifts and evolving its business model to remain at the forefront of technological innovation. The company continues to invest heavily in emerging technologies such as artificial intelligence, quantum computing, and mixed reality, positioning itself for continued growth in the rapidly changing technology landscape.

View file

@ -0,0 +1,32 @@
---
name: Mozilla Foundation
description: Non-profit organization that exists to support and collectively lead the open source Mozilla project, focusing on keeping the internet a global public resource, open and accessible to all.
logo: /logos/sample-logo.svg
website: https://www.mozilla.org
foundedYear: 2003
headquarters: Mountain View, California
country: USA
employees: "750+"
revenue: "approximately $500 million (2021)"
industry: "Non-profit, Open Source, Internet Privacy, Web Technologies"
keyProducts:
- "Firefox"
- "Firefox Mobile"
- "Mozilla VPN"
- "Pocket"
- "MDN Web Docs"
- "Thunderbird"
socialMedia:
twitter: https://twitter.com/mozilla
linkedin: https://www.linkedin.com/company/mozilla-corporation/
github: https://github.com/mozilla
facebook: https://www.facebook.com/mozilla
---
The Mozilla Foundation is a non-profit organization that serves as the home for the Mozilla project, an open-source software community focused on creating a better internet. The foundation was established in July 2003, with the Firefox web browser as its flagship product.
Mozilla's structure includes the Mozilla Foundation and its subsidiary, Mozilla Corporation, which handles the development and distribution of Firefox and other products. The organization is guided by the Mozilla Manifesto, which outlines principles promoting an internet that is a global public resource, open and accessible to all.
Beyond Firefox, Mozilla has been involved in various open internet initiatives, including web standards development, privacy advocacy, and digital literacy programs. The organization champions the principles of online privacy, open source, and decentralization, often positioning itself as an alternative to the commercial interests of larger tech companies.
Mozilla's revenue primarily comes from search engine partnerships, with Google being a significant contributor, and the foundation continually seeks ways to diversify its funding while maintaining its commitment to user privacy and open internet values.

View file

@ -0,0 +1,54 @@
---
name: Adobe Acrobat Reader
description: Der globale Standard für das zuverlässige Anzeigen, Drucken und Kommentieren von PDF-Dokumenten.
logo: /logos/sample-logo.svg
website: https://www.adobe.com/de/acrobat/pdf-reader.html
developer: adobe
pricing:
- model: Kostenlos
price: €0
yearly_price: €0
features:
- PDFs anzeigen und drucken
- PDFs kommentieren
- Formulare ausfüllen und unterschreiben
- Dateien online speichern und teilen
- model: Acrobat Standard DC
price: €14,99
yearly_price: €179,88
features:
- PDFs erstellen
- Text und Bilder bearbeiten
- PDFs in Office-Formate konvertieren
- Mehrere Dateien zu einer PDF zusammenführen
- model: Acrobat Pro DC
price: €24,99
yearly_price: €299,88
features:
- Alle Standard-Funktionen
- Zwei Versionen einer PDF vergleichen
- Auf iPad und mobilen Geräten bearbeiten
- Erweiterte PDF-Bearbeitung und -Schutz
features:
- "PDF-Anzeige: PDF-Inhalte mit hoher Genauigkeit auf allen Geräten anzeigen"
- "Kommentare & Anmerkungen: PDFs mit Hervorhebungen und Kommentaren versehen"
- "Formularausfüllung: Ausfüllbare Formulare vervollständigen und Unterschriften hinzufügen"
- "Teilen & Überprüfen: Kommentare von mehreren Prüfern sammeln"
- "Mobiler Zugriff: PDFs auf mobilen Geräten anzeigen und kommentieren"
- "Cloud-Speicher: Auf Ihre PDFs von überall aus über Adobe Document Cloud zugreifen"
categories:
- document
- productivity
platforms:
- Windows
- macOS
- Android
- iOS
supportedPlatforms:
- Windows
- macOS
- Android
- iOS
- Web
lastUpdated: 2025-03-29
---

View file

@ -0,0 +1,40 @@
---
name: Android OS
description: Das Open-Source-Mobilbetriebssystem von Google, das Smartphones, Tablets und andere Geräte antreibt.
logo: /logos/sample-logo.svg
website: https://www.android.com/intl/de_de/
developer: google
pricing:
- model: Kostenlos
price: €0
yearly_price: €0
features:
- Kostenloses Open-Source-Betriebssystem
- Zugang zum Google Play Store
- Regelmäßige Sicherheitsupdates
- Integration mit Google-Diensten
features:
- "Offenes Ökosystem: Open-Source-Basis mit Anpassungsoptionen"
- "Google-Dienste: Tiefe Integration mit Google-Apps und -Diensten"
- "App Store: Zugang zu Millionen von Apps über den Google Play Store"
- "Anpassung: Umfangreiche Personalisierungsoptionen für Benutzer und Hersteller"
- "Assistent: Google Assistant für Sprachbefehle und KI-Unterstützung"
- "Sicherheit: Regelmäßige Sicherheitsupdates und integrierter Malware-Schutz"
- "Multi-Gerät: Unterstützt Telefone, Tablets, Fernseher, Uhren und Autos"
- "Entwickler-Tools: Umfassende SDK und Entwickler-Ressourcen"
categories:
- operating-system
platforms:
- Smartphones
- Tablets
- Smart TVs
- Wearables
- Automotive
supportedPlatforms:
- Smartphones
- Tablets
- Smart TVs
- Wearables
- Automotive
lastUpdated: 2025-03-29
---

View file

@ -0,0 +1,55 @@
---
name: Chess.com
description: Die weltweit führende Online-Schachplattform mit Millionen von Spielern, Tutorials, Turnieren und Analysetools.
logo: /logos/sample-logo.svg
website: https://www.chess.com
screenshots:
- /screenshots/chess1.PNG
- /screenshots/chess2.PNG
- /screenshots/chess3.PNG
- /screenshots/chess4.PNG
- /screenshots/chess5.PNG
pricing:
- model: Free
price: €0
yearly_price: €0
features:
- Unbegrenzte Schachspiele
- Tägliche Schachaufgaben
- Videoanleitungen
- Community-Forum
- model: Gold
price: €4.99/Monat
yearly_price: €49.99/Jahr
features:
- Alle Free-Funktionen
- Unbegrenzte Taktik-Aufgaben
- 25 Analysen pro Tag
- Keine Werbung
- Erweiterte Lernfunktionen
- model: Platinum
price: €9.99/Monat
yearly_price: €99.99/Jahr
features:
- Alle Gold-Funktionen
- Unbegrenzte Analysen
- Premium-Videos und Kurse
- Fortgeschrittene KI-Gegner
- Personalisiertes Training
features:
- "Spielen: Schach gegen KI oder Spieler weltweit in verschiedenen Zeitkontrollen"
- "Lernen: Interaktive Lektionen, Eröffnungs- und Taktik-Training"
- "Analysen: Tiefgehende Spielanalysen mit Engine-Unterstützung"
- "Turniere: Tägliche und wöchentliche Online-Schachturniere"
- "Community: Foren, Clubs und Freundschaftssystem"
categories:
- games
- kategorie2
platforms:
- Windows
- macOS
- iOS
- Android
- Web
lastUpdated: 2025-03-28
---

View file

@ -0,0 +1,37 @@
---
name: Google Chrome
description: Schneller, sicherer Browser von Google mit integrierter Google-Dienst-Anbindung.
logo: /logos/sample-logo.svg
website: https://www.google.com/chrome/
developer: google
pricing:
- model: Kostenlos
price: €0
yearly_price: €0
features:
- Schnelles Surferlebnis
- Integrierter Phishing- und Malware-Schutz
- Synchronisation über Geräte hinweg
- Entwicklertools
features:
- "Geschwindigkeit: Chrome ist für Leistung mit einer schnellen JavaScript-Engine optimiert"
- "Sicherheit: Automatische Updates und isolierte Tabs für erhöhte Sicherheit"
- "Synchronisation: Synchronisiere Lesezeichen, Verlauf, Passwörter und Einstellungen über Geräte hinweg"
- "Erweiterungen: Zugriff auf tausende Erweiterungen über den Chrome Web Store"
- "Entwicklertools: Umfassende integrierte Werkzeuge für Webentwickler"
categories:
- browser
platforms:
- Windows
- macOS
- Linux
- Android
- iOS
supportedPlatforms:
- Windows
- macOS
- Linux
- Android
- iOS
lastUpdated: 2025-03-28
---

View file

@ -0,0 +1,57 @@
---
name: Microsoft Excel
description: Leistungsstarke Tabellenkalkulationssoftware mit Berechnungs- und Grafiktools sowie Pivot-Tabellen für Datenanalysen.
logo: /logos/sample-logo.svg
website: https://www.microsoft.com/de-de/microsoft-365/excel
developer: microsoft
pricing:
- model: Microsoft 365 Personal
price: €7,00
yearly_price: €69,00
features:
- Premium Office-Apps einschließlich Excel
- 1 TB Cloud-Speicher
- Werbefreie E-Mail und Premium-Sicherheit
- Zugriff auf mehreren Geräten
- model: Microsoft 365 Family
price: €10,00
yearly_price: €99,00
features:
- Für bis zu 6 Personen
- Premium Office-Apps einschließlich Excel
- 1 TB Cloud-Speicher pro Person
- Werbefreie E-Mail und Premium-Sicherheit
- model: Office Home & Student 2021
price: €149,00
yearly_price: €149,00
features:
- Einmaliger Kauf für 1 PC oder Mac
- Enthält Excel, Word und PowerPoint
- Microsoft Support für 60 Tage
- Keine Feature-Updates
features:
- "Tabellenkalkulations-Funktionen: Über 400 integrierte Funktionen für komplexe Berechnungen"
- "Datenanalyse: Leistungsstarke Tools wie Power Pivot und Power Query"
- "Datenvisualisierung: Erstellen von Diagrammen, Grafiken und bedingter Formatierung"
- "PivotTables: Schnelles Zusammenfassen und Analysieren großer Datensätze"
- "Was-wäre-wenn-Analyse: Tools für Sensitivitätsanalyse und Prognosen"
- "Echtzeit-Zusammenarbeit: Mehrere Benutzer können Tabellenkalkulationen gleichzeitig bearbeiten"
- "Excel im Web: Auf Tabellenkalkulationen von jedem Browser aus zugreifen und bearbeiten"
- "Integration: Verbindung zu mehreren Datenquellen und Microsoft 365-Apps"
categories:
- office-suite
- productivity
platforms:
- Windows
- macOS
- Android
- iOS
- Web
supportedPlatforms:
- Windows
- macOS
- Android
- iOS
- Web
lastUpdated: 2025-03-29
---

View file

@ -0,0 +1,37 @@
---
name: Mozilla Firefox
description: Open-Source-Browser mit Fokus auf Datenschutz, Anpassbarkeit und Web-Standards-Konformität.
logo: /logos/sample-logo.svg
website: https://www.mozilla.org/de/firefox/
developer: mozilla
pricing:
- model: Kostenlos
price: €0
yearly_price: €0
features:
- Erweiterter Datenschutz
- Open-Source-Entwicklung
- Plattformübergreifende Synchronisation
- Umfangreiche Anpassungsmöglichkeiten
features:
- "Datenschutz: Verbesserter Tracking-Schutz blockiert standardmäßig Tracking-Cookies von Drittanbietern"
- "Open Source: Transparenter Entwicklungsprozess mit öffentlich einsehbarem Code"
- "Anpassbarkeit: Hochgradig anpassbare Benutzeroberfläche mit Themes und Add-ons"
- "Container-Tabs: Separate Browsing-Kontexte zur Verhinderung von websiteübergreifendem Tracking"
- "Lesemodus: Ablenkungsfreies Leseerlebnis für Artikel"
categories:
- browser
platforms:
- Windows
- macOS
- Linux
- Android
- iOS
supportedPlatforms:
- Windows
- macOS
- Linux
- Android
- iOS
lastUpdated: 2025-03-28
---

Some files were not shown because too many files have changed in this diff Show more