admin widget zu dokploy geswitched, viele übersetzungen im Frontend vervollständigt und Anlässe-Seiten mit ChatGPT ausgebaut

This commit is contained in:
Codex Agent
2025-11-19 13:12:35 +01:00
parent 125c624588
commit d8f365ddd6
30 changed files with 2820 additions and 293 deletions

View File

@@ -137,6 +137,7 @@ DOKPLOY_API_KEY=
DOKPLOY_WEB_URL=
DOKPLOY_API_TIMEOUT=10
DOKPLOY_APPLICATION_IDS={"app":"app_xxx","queue":"app_queue","scheduler":"app_scheduler","ftp":"app_ftp"}
DOKPLOY_COMPOSE_IDS={"stack":"cmp_main","ftp":"cmp_ftp"}
GUEST_ACHIEVEMENT_MILESTONES=10,25,50

Binary file not shown.

View File

@@ -34,16 +34,17 @@ class InfrastructureActionLogResource extends Resource
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('service_id')
->label('Service')
->label('Target')
->searchable()
->copyable()
->limit(30),
Tables\Columns\BadgeColumn::make('action')
->label('Action')
->colors([
'warning' => 'restart',
'info' => 'redeploy',
'gray' => 'logs',
'warning' => fn ($state) => in_array($state, ['compose.redeploy', 'redeploy'], true),
'success' => fn ($state) => in_array($state, ['compose.deploy', 'deploy'], true),
'danger' => fn ($state) => in_array($state, ['compose.stop', 'stop'], true),
'gray' => fn ($state) => $state === 'logs',
])
->sortable(),
Tables\Columns\TextColumn::make('status_code')

View File

@@ -19,7 +19,7 @@ class DokployDeployments extends Page
protected string $view = 'filament.super-admin.pages.dokploy-deployments';
public array $applications = [];
public array $composes = [];
public array $recentLogs = [];
@@ -28,43 +28,45 @@ class DokployDeployments extends Page
public function mount(DokployClient $client): void
{
$this->dokployWebUrl = config('dokploy.web_url');
$this->refreshApplications($client);
$this->refreshComposes($client);
$this->refreshLogs();
}
public function reload(string $applicationId): void
public function redeploy(string $composeId): void
{
$this->performAction($applicationId, 'reload');
$this->performAction($composeId, 'redeploy');
}
public function redeploy(string $applicationId): void
public function stop(string $composeId): void
{
$this->performAction($applicationId, 'redeploy');
$this->performAction($composeId, 'stop');
}
protected function performAction(string $applicationId, string $action): void
protected function performAction(string $composeId, string $action): void
{
$client = app(DokployClient::class);
if (! $this->isKnownApplication($applicationId)) {
if (! $this->isKnownCompose($composeId)) {
Notification::make()
->danger()
->title('Unknown service')
->body("The application ID {$applicationId} is not configured.")
->body("The compose ID {$composeId} is not configured.")
->send();
return;
}
try {
$action === 'reload'
? $client->reloadApplication($applicationId, auth()->user())
: $client->redeployApplication($applicationId, auth()->user());
match ($action) {
'redeploy' => $client->redeployCompose($composeId, auth()->user()),
'stop' => $client->stopCompose($composeId, auth()->user()),
default => throw new \RuntimeException("Unsupported action [{$action}]"),
};
Notification::make()
->success()
->title(ucfirst($action).' requested')
->body("Dokploy accepted the {$action} action for {$applicationId}.")
->body("Dokploy accepted the {$action} action for {$composeId}.")
->send();
} catch (\Throwable $exception) {
Notification::make()
@@ -74,35 +76,35 @@ class DokployDeployments extends Page
->send();
}
$this->refreshApplications($client);
$this->refreshComposes($client);
$this->refreshLogs();
}
protected function refreshApplications(DokployClient $client): void
protected function refreshComposes(DokployClient $client): void
{
$applicationMap = config('dokploy.applications', []);
$composeMap = config('dokploy.composes', []);
$results = [];
foreach ($applicationMap as $label => $id) {
foreach ($composeMap as $label => $id) {
try {
$status = $client->applicationStatus($id);
$application = Arr::get($status, 'application', []);
$status = $client->composeStatus($id);
$compose = Arr::get($status, 'compose', []);
$results[] = [
'label' => ucfirst($label),
'application_id' => $id,
'status' => Arr::get($application, 'applicationStatus', 'unknown'),
'compose_id' => $id,
'status' => Arr::get($compose, 'composeStatus', 'unknown'),
];
} catch (\Throwable $e) {
$results[] = [
'label' => ucfirst($label),
'application_id' => $id,
'compose_id' => $id,
'status' => 'error',
];
}
}
$this->applications = $results;
$this->composes = $results;
}
protected function refreshLogs(): void
@@ -122,8 +124,8 @@ class DokployDeployments extends Page
->toArray();
}
protected function isKnownApplication(string $applicationId): bool
protected function isKnownCompose(string $composeId): bool
{
return in_array($applicationId, array_values(config('dokploy.applications', [])), true);
return in_array($composeId, array_values(config('dokploy.composes', [])), true);
}
}

View File

@@ -15,49 +15,38 @@ class DokployPlatformHealth extends Widget
protected function getViewData(): array
{
return [
'applications' => $this->loadApplications(),
'composes' => $this->loadComposes(),
];
}
protected function loadApplications(): array
protected function loadComposes(): array
{
$client = app(DokployClient::class);
$applicationMap = config('dokploy.applications', []);
$composeMap = config('dokploy.composes', []);
$results = [];
foreach ($applicationMap as $label => $applicationId) {
foreach ($composeMap as $label => $composeId) {
try {
$status = $client->applicationStatus($applicationId);
$deployments = $client->recentDeployments($applicationId, 1);
$application = Arr::get($status, 'application', []);
$monitoring = Arr::get($status, 'monitoring', []);
$status = $client->composeStatus($composeId);
$deployments = $client->composeDeployments($composeId, 1);
$compose = Arr::get($status, 'compose', []);
$services = $this->formatServices(Arr::get($status, 'services', []));
$results[] = [
'label' => ucfirst($label),
'application_id' => $applicationId,
'app_name' => Arr::get($application, 'appName') ?? Arr::get($application, 'name'),
'status' => Arr::get($application, 'applicationStatus', 'unknown'),
'cpu' => $this->extractMetric($monitoring, [
'metrics.cpuPercent',
'metrics.cpu_percent',
'cpuPercent',
'cpu_percent',
]),
'memory' => $this->extractMetric($monitoring, [
'metrics.memoryPercent',
'metrics.memory_percent',
'memoryPercent',
'memory_percent',
]),
'compose_id' => $composeId,
'name' => Arr::get($compose, 'name') ?? Arr::get($compose, 'appName') ?? $composeId,
'status' => Arr::get($compose, 'composeStatus', 'unknown'),
'services' => $services,
'last_deploy' => Arr::get($deployments, '0.createdAt')
?? Arr::get($deployments, '0.created_at')
?? Arr::get($application, 'updatedAt')
?? Arr::get($application, 'lastDeploymentAt'),
?? Arr::get($compose, 'updatedAt')
?? Arr::get($compose, 'lastDeploymentAt'),
];
} catch (\Throwable $exception) {
$results[] = [
'label' => ucfirst($label),
'application_id' => $applicationId,
'compose_id' => $composeId,
'status' => 'unreachable',
'error' => $exception->getMessage(),
];
@@ -68,9 +57,9 @@ class DokployPlatformHealth extends Widget
return [
[
'label' => 'Dokploy',
'application_id' => '-',
'compose_id' => '-',
'status' => 'unconfigured',
'error' => 'Set DOKPLOY_APPLICATION_IDS in .env to enable monitoring.',
'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
],
];
}
@@ -78,14 +67,17 @@ class DokployPlatformHealth extends Widget
return $results;
}
protected function extractMetric(array $source, array $candidates): mixed
protected function formatServices(array $services): array
{
foreach ($candidates as $key) {
if (Arr::has($source, $key)) {
return Arr::get($source, $key);
}
}
return null;
return collect($services)
->map(function ($service) {
return [
'name' => Arr::get($service, 'serviceName') ?? Arr::get($service, 'name') ?? Arr::get($service, 'containerName'),
'status' => Arr::get($service, 'status') ?? Arr::get($service, 'state') ?? Arr::get($service, 'composeStatus', 'unknown'),
];
})
->filter(fn ($service) => filled($service['name']))
->values()
->all();
}
}

View File

@@ -4,8 +4,8 @@ namespace App\Http\Controllers;
use App\Mail\ContactConfirmation;
use App\Models\BlogPost;
use App\Models\Event;
use App\Models\CheckoutSession;
use App\Models\Event;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
@@ -33,6 +33,8 @@ class MarketingController extends Controller
{
use PresentsPackages;
private ?MarkdownConverter $markdownConverter = null;
public function __construct(
private readonly CheckoutSessionService $checkoutSessions,
private readonly PaddleCheckoutService $paddleCheckout,
@@ -292,11 +294,14 @@ class MarketingController extends Controller
$posts = $query->orderBy('published_at', 'desc')
->paginate(4)
->through(function (BlogPost $post) use ($locale) {
$excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '';
return [
'id' => $post->id,
'slug' => $post->slug,
'title' => $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '',
'excerpt' => $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '',
'excerpt' => $excerpt,
'excerpt_html' => $this->convertMarkdownToHtml($excerpt),
'featured_image' => $post->featured_image ?? $post->banner_url ?? null,
'published_at' => optional($post->published_at)->toDateString(),
'author' => $post->author ? ['name' => $post->author->name] : null,
@@ -310,7 +315,18 @@ class MarketingController extends Controller
'first_post_title' => $posts->count() > 0 ? ($posts->first()['title'] ?? 'No title') : 'No posts',
]);
return Inertia::render('marketing/Blog', compact('posts'));
$postsArray = $posts->toArray();
$postsArray['links'] = array_map(function (array $link) use ($locale) {
return [
'url' => $link['url'],
'label' => $this->localizePaginationLabel($link['label'] ?? '', $locale),
'active' => (bool) ($link['active'] ?? false),
];
}, $postsArray['links'] ?? []);
return Inertia::render('marketing/Blog', [
'posts' => $postsArray,
]);
}
public function blogShow(string $locale, string $slug)
@@ -330,29 +346,27 @@ class MarketingController extends Controller
// Transform to array with translated strings for the current locale
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension);
$environment->addExtension(new AutolinkExtension);
$environment->addExtension(new StrikethroughExtension);
$environment->addExtension(new TaskListExtension);
$converter = new MarkdownConverter($environment);
$contentHtml = (string) $converter->convert($markdown);
$excerpt = $postModel->getTranslation('excerpt', $locale) ?? $postModel->getTranslation('excerpt', 'de') ?? '';
$contentHtml = $this->convertMarkdownToHtml($markdown);
[$contentHtmlWithIds, $headings] = $this->decorateHeadings($contentHtml);
$post = [
'id' => $postModel->id,
'title' => $postModel->getTranslation('title', $locale) ?? $postModel->getTranslation('title', 'de') ?? '',
'excerpt' => $postModel->getTranslation('excerpt', $locale) ?? $postModel->getTranslation('excerpt', 'de') ?? '',
'excerpt' => $excerpt,
'excerpt_html' => $this->convertMarkdownToHtml($excerpt),
'content' => $markdown,
'content_html' => $contentHtml,
'content_html' => $contentHtmlWithIds,
'headings' => $headings,
'featured_image' => $postModel->featured_image ?? $postModel->banner_url ?? null,
'published_at' => $postModel->published_at->toDateString(),
'slug' => $postModel->slug,
'url' => route('blog.show', ['locale' => $locale, 'slug' => $postModel->slug], absolute: true),
'author' => $postModel->author ? [
'name' => $postModel->author->name,
] : null,
'previous_post' => $this->presentAdjacentPost($postModel, $locale, direction: 'previous'),
'next_post' => $this->presentAdjacentPost($postModel, $locale, direction: 'next'),
];
return Inertia::render('marketing/BlogShow', compact('post'));
@@ -481,4 +495,119 @@ class MarketingController extends Controller
'requestedType' => $normalized,
]);
}
private function localizePaginationLabel(?string $label, string $locale): string
{
$decoded = trim(html_entity_decode(strip_tags($label ?? '')));
if ($decoded === '') {
return '';
}
if ($decoded === '...') {
return '…';
}
$normalized = Str::lower($decoded);
if (Str::contains($normalized, ['previous', 'vorherige', 'zurück'])) {
return __('marketing.blog.pagination.previous', [], $locale);
}
if (Str::contains($normalized, ['next', 'weiter', 'nächste'])) {
return __('marketing.blog.pagination.next', [], $locale);
}
return $decoded;
}
private function markdownConverter(): MarkdownConverter
{
if (! $this->markdownConverter instanceof MarkdownConverter) {
$environment = new Environment;
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension);
$environment->addExtension(new AutolinkExtension);
$environment->addExtension(new StrikethroughExtension);
$environment->addExtension(new TaskListExtension);
$this->markdownConverter = new MarkdownConverter($environment);
}
return $this->markdownConverter;
}
private function convertMarkdownToHtml(?string $markdown): string
{
if ($markdown === null || trim((string) $markdown) === '') {
return '';
}
return (string) $this->markdownConverter()->convert($markdown);
}
private function decorateHeadings(string $html): array
{
$headings = [];
$usedSlugs = [];
$updatedHtml = preg_replace_callback('/<h([2-3])>(.*?)<\/h\1>/', function ($matches) use (&$headings, &$usedSlugs) {
$level = (int) $matches[1];
$text = trim(strip_tags($matches[2]));
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5);
if ($text === '') {
return $matches[0];
}
$baseSlug = Str::slug($text) ?: 'section';
$slug = $baseSlug;
$iterator = 1;
while (in_array($slug, $usedSlugs, true)) {
$slug = $baseSlug.'-'.(++$iterator);
}
$usedSlugs[] = $slug;
$headings[] = [
'text' => $text,
'slug' => $slug,
'level' => $level,
];
return sprintf('<h%s id="%s">%s</h%s>', $level, $slug, $matches[2], $level);
}, $html) ?: $html;
return [$updatedHtml, $headings];
}
private function presentAdjacentPost(BlogPost $current, string $locale, string $direction): ?array
{
$operator = $direction === 'previous' ? '<' : '>';
$orderDirection = $direction === 'previous' ? 'desc' : 'asc';
$neighbor = BlogPost::query()
->with('author')
->whereHas('category', function ($query) {
$query->where('slug', 'blog');
})
->where('is_published', true)
->whereNotNull('published_at')
->where('published_at', $operator, $current->published_at)
->orderBy('published_at', $orderDirection)
->first();
if (! $neighbor) {
return null;
}
$excerpt = $neighbor->getTranslation('excerpt', $locale) ?? $neighbor->getTranslation('excerpt', 'de') ?? '';
return [
'slug' => $neighbor->slug,
'title' => $neighbor->getTranslation('title', $locale) ?? $neighbor->getTranslation('title', 'de') ?? '',
'excerpt' => $excerpt,
'excerpt_html' => $this->convertMarkdownToHtml($excerpt),
];
}
}

View File

@@ -64,7 +64,7 @@ class DokployClient
throw new \RuntimeException('Dokploy application name is required to reload the service.');
}
return $this->dispatchAction(
$response = $this->dispatchAction(
$applicationId,
'reload',
[
@@ -74,11 +74,15 @@ class DokployClient
fn (array $payload) => $this->post('/application.reload', $payload),
$actor,
);
$this->forgetApplicationCaches($applicationId);
return $response;
}
public function redeployApplication(string $applicationId, ?Authenticatable $actor = null): array
{
return $this->dispatchAction(
$response = $this->dispatchAction(
$applicationId,
'redeploy',
[
@@ -87,6 +91,95 @@ class DokployClient
fn (array $payload) => $this->post('/application.redeploy', $payload),
$actor,
);
$this->forgetApplicationCaches($applicationId);
return $response;
}
public function composeStatus(string $composeId): array
{
return $this->cached($this->composeCacheKey($composeId), function () use ($composeId) {
$compose = $this->get('/compose.one', [
'composeId' => $composeId,
]);
$services = $this->optionalGet('/compose.loadServices', [
'composeId' => $composeId,
'type' => 'cache',
]);
return [
'compose' => $compose,
'services' => $services,
];
}, 30);
}
public function composeDeployments(string $composeId, int $limit = 5): array
{
return $this->cached($this->composeDeploymentsCacheKey($composeId), function () use ($composeId, $limit) {
$deployments = $this->get('/deployment.allByCompose', [
'composeId' => $composeId,
]);
if (! is_array($deployments)) {
return [];
}
return array_slice($deployments, 0, $limit);
}, 60);
}
public function redeployCompose(string $composeId, ?Authenticatable $actor = null): array
{
$response = $this->dispatchAction(
$composeId,
'compose.redeploy',
[
'composeId' => $composeId,
],
fn (array $payload) => $this->post('/compose.redeploy', $payload),
$actor,
);
$this->forgetComposeCaches($composeId);
return $response;
}
public function deployCompose(string $composeId, ?Authenticatable $actor = null): array
{
$response = $this->dispatchAction(
$composeId,
'compose.deploy',
[
'composeId' => $composeId,
],
fn (array $payload) => $this->post('/compose.deploy', $payload),
$actor,
);
$this->forgetComposeCaches($composeId);
return $response;
}
public function stopCompose(string $composeId, ?Authenticatable $actor = null): array
{
$response = $this->dispatchAction(
$composeId,
'compose.stop',
[
'composeId' => $composeId,
],
fn (array $payload) => $this->post('/compose.stop', $payload),
$actor,
);
$this->forgetComposeCaches($composeId);
return $response;
}
protected function cached(string $key, callable $callback, int $seconds): mixed
@@ -167,7 +260,7 @@ class DokployClient
}
protected function dispatchAction(
string $applicationId,
string $targetId,
string $action,
array $payload,
callable $callback,
@@ -178,22 +271,20 @@ class DokployClient
$body = $response->json() ?? [];
$status = $response->status();
} catch (\Throwable $exception) {
$this->logAction($applicationId, $action, $payload, [
$this->logAction($targetId, $action, $payload, [
'error' => $exception->getMessage(),
], null, $actor);
throw $exception;
}
$this->logAction($applicationId, $action, $payload, $body, $status, $actor);
Cache::forget($this->applicationCacheKey($applicationId));
Cache::forget($this->deploymentCacheKey($applicationId));
$this->logAction($targetId, $action, $payload, $body, $status, $actor);
return $body;
}
protected function logAction(
string $applicationId,
string $targetId,
string $action,
array $payload,
array $response,
@@ -202,7 +293,7 @@ class DokployClient
): void {
InfrastructureActionLog::create([
'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(),
'service_id' => $applicationId,
'service_id' => $targetId,
'action' => $action,
'payload' => $payload,
'response' => $response,
@@ -219,4 +310,26 @@ class DokployClient
{
return "dokploy.deployments.{$applicationId}";
}
protected function composeCacheKey(string $composeId): string
{
return "dokploy.compose.{$composeId}";
}
protected function composeDeploymentsCacheKey(string $composeId): string
{
return "dokploy.compose.deployments.{$composeId}";
}
protected function forgetApplicationCaches(string $applicationId): void
{
Cache::forget($this->applicationCacheKey($applicationId));
Cache::forget($this->deploymentCacheKey($applicationId));
}
protected function forgetComposeCaches(string $composeId): void
{
Cache::forget($this->composeCacheKey($composeId));
Cache::forget($this->composeDeploymentsCacheKey($composeId));
}
}

View File

@@ -8,4 +8,5 @@ return [
],
'web_url' => env('DOKPLOY_WEB_URL'),
'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [],
'composes' => json_decode(env('DOKPLOY_COMPOSE_IDS', '{}'), true) ?? [],
];

View File

@@ -38,12 +38,13 @@ Add the infrastructure observability variables to the Laravel app environment:
DOKPLOY_API_BASE_URL=https://dokploy.example.com/api
DOKPLOY_API_KEY=pat_xxxxxxxxxxxxxxxxx
DOKPLOY_WEB_URL=https://dokploy.example.com
DOKPLOY_APPLICATION_IDS={"app":"app_123","queue":"app_456","scheduler":"app_789","ftp":"app_abc"}
DOKPLOY_COMPOSE_IDS={"stack":"cmp_main","ftp":"cmp_ftp"}
DOKPLOY_API_TIMEOUT=10
```
- `DOKPLOY_APPLICATION_IDS` is a JSON object mapping human labels to Dokploy `applicationId` values. Those IDs drive the SuperAdmin widget buttons.
- The API key needs permission to read the project, query deployments, and trigger `application.redeploy` / `application.reload`.
- `DOKPLOY_COMPOSE_IDS` ist eine JSON-Map Label → `composeId` (siehe Compose-Detailseite in Dokploy). Diese IDs steuern Widget & Buttons.
- Optional kannst du weiterhin `DOKPLOY_APPLICATION_IDS` pflegen, falls du später einzelne Apps statt Compose-Stacks integrieren möchtest.
- Die API benötigt Rechte für `compose.one`, `compose.loadServices`, `compose.redeploy`, `compose.stop` etc.
## 3. Project & server setup
@@ -77,7 +78,7 @@ Follow these steps for each component:
- Optionally create a dedicated container for Horizon using `docs/queue-supervisor/horizon.sh`.
4. **vsftpd + Photobooth control**
- Deploy the ftp image (see `docker-compose` setup) or reuse Dokploys Docker Compose support.
- Nutze deinen bestehenden Docker-Compose-Stack (z.B. `docker-compose.dokploy.yml`) oder dedizierte Compose-Applikationen.
- Mount `photobooth` volume read-write.
5. **Database/Redis**
@@ -89,17 +90,16 @@ Follow these steps for each component:
## 5. SuperAdmin observability (Dokploy API)
The SuperAdmin dashboard now uses the Dokploy API to fetch health data and trigger actions:
Das SuperAdmin-Dashboard nutzt jetzt ausschließlich Compose-Endpunkte:
1. **Config file** `config/dokploy.php` reads the environment variables above.
2. **Client** `App\Services\Dokploy\DokployClient` wraps key endpoints:
- `GET /application.one?applicationId=...` → status + metadata.
- `GET /application.readAppMonitoring?appName=...` → CPU & memory metrics.
- `GET /deployment.all?applicationId=...` → latest deployments for history.
- `POST /application.reload` (requires `applicationId` + `appName`).
- `POST /application.redeploy` (redeploy latest commit).
3. **Widgets / pages** `DokployPlatformHealth` widget displays the mapped applications, and the `DokployDeployments` page exposes reload/redeploy buttons plus a log table (`InfrastructureActionLog`).
4. **Auditing** all actions persist to `infrastructure_action_logs` with user, payload, response, and status code.
1. **Config file** `config/dokploy.php` liest `DOKPLOY_COMPOSE_IDS`.
2. **Client** `App\Services\Dokploy\DokployClient` kapselt:
- `GET /compose.one?composeId=...` für Meta- und Statusinfos (deploying/error/done).
- `GET /compose.loadServices?composeId=...` für die einzelnen Services innerhalb des Stacks.
- `GET /deployment.allByCompose?composeId=...` für die Deploy-Historie.
- `POST /compose.redeploy`, `POST /compose.deploy`, `POST /compose.stop` (Buttons im UI).
3. **Widgets / Pages** `DokployPlatformHealth` zeigt jeden Compose-Stack inkl. Services; die `DokployDeployments`-Seite bietet Redeploy/Stop + Audit-Log (`InfrastructureActionLog`).
4. **Auditing** jede Aktion wird mit User, Payload, Response & HTTP-Code in `infrastructure_action_logs` festgehalten.
Only SuperAdmins should have access to these widgets. If you rotate the API key, update the `.env` and deploy the app to refresh the cache.
@@ -111,12 +111,12 @@ Only SuperAdmins should have access to these widgets. If you rotate the API key,
## 7. Production readiness checklist
1. All applications deployed in Dokploy with health checks and attached volumes.
1. Alle Compose-Stacks in Dokploy laufen mit Health Checks & Volumes.
2. `photobooth` volume mounted for Laravel + vsftpd + control service.
3. Database/Redis backups scheduled (Dokploy snapshot or external tooling).
4. `.env` contains the Dokploy API credentials and application ID mapping.
5. Scheduler, workers, and Horizon logging visible in Dokploy.
6. SuperAdmin widgets show green health states and allow reload/redeploy actions.
4. `.env` enthält die Dokploy-API-Credentials und `DOKPLOY_COMPOSE_IDS`.
5. Scheduler, Worker, Horizon werden im Compose-Stack überwacht.
6. SuperAdmin-Widget zeigt die Compose-Stacks und erlaubt Redeploy/Stop.
7. Webhooks/alerts configured for failed deployments or unhealthy containers.
With this setup the Fotospiel team can manage deployments, restarts, and metrics centrally through Dokploy while Laravels scheduler and workers continue to run within the same infrastructure.

View File

@@ -1,7 +1,25 @@
{
"title_suffix": " - Fotospiel Blog",
"by_author": "Von",
"team": "Team",
"team": "Fotospiel Team",
"published_on": "Veröffentlicht am",
"back_to_blog": "Zurück zum Blog"
"back_to_blog": "Zurück zum Blog",
"breadcrumb_home": "Start",
"breadcrumb_blog": "Blog",
"summary_title": "Wichtigste Erkenntnisse",
"toc_title": "In diesem Artikel",
"toc_empty": "Scrolle weiter, um die komplette Story zu lesen.",
"sidebar_author_title": "Über den Autor",
"sidebar_author_description": "Kuratiert vom Fotospiel Team.",
"share_title": "Story teilen",
"share_hint": "Mit einem Tipp verbreiten.",
"share_copy": "Link kopieren",
"share_copied": "Link kopiert!",
"share_native": "Gerät zum Teilen nutzen",
"share_whatsapp": "WhatsApp",
"share_linkedin": "LinkedIn",
"share_email": "E-Mail",
"previous_post": "Vorherige Story",
"next_post": "Nächste Story",
"read_story": "Story lesen"
}

View File

@@ -255,7 +255,7 @@
"hero_description": "Sammeln Sie unvergessliche Fotos von Ihren Gästen mit QR-Codes. Perfekt für :type einfach, mobil und datenschutzkonform.",
"cta": "Paket wählen",
"weddings": {
"title": "Hochzeiten mit Fotospiel",
"title": "Hochzeiten mit der Fotospiel App",
"description": "Fangen Sie romantische Momente ein: Gäste teilen Fotos via QR, wählen Emotionen wie 'Romantisch' oder 'Freudig'. Besser als traditionelle Fotoboxen.",
"benefits_title": "Vorteile für Hochzeiten",
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
@@ -373,6 +373,9 @@
"privacy": "Datenschutz",
"impressum": "Impressum",
"occasions_types": {
"weddings": "Hochzeiten",
"birthdays": "Geburtstage",
"corporate": "Firmenevents",
"confirmation": "Konfirmation & Jugendweihe"
},
"language": "Sprache",
@@ -608,7 +611,7 @@
"hero": {
"title": "So funktioniert die Fotospiel App",
"subtitle": "Teile deinen QR-Code, sammle Fotos in Echtzeit und behalte die Moderation. Alles läuft im Browser ganz ohne App.",
"primaryCta": "Event starten",
"primaryCta": "Pakete entdecken",
"secondaryCta": "Kontakt aufnehmen",
"stats": [
{
@@ -870,6 +873,14 @@
"description": "Unser Team hilft dir bei der Einrichtung oder plant mit dir ein Pilot-Event.",
"cta": "Kontakt aufnehmen"
},
"labels": {
"timeline_heading": "Ein klarer Fahrplan für euer Event",
"recommendations": "Empfehlungen",
"challenge_ideas": "Ideen für Challenges",
"prep_hint": "Alles, was ihr vor dem Event abhaken solltet.",
"good_to_know": "Gut zu wissen",
"tips": "Tipps"
},
"timeline_title": "Der Ablauf im Detail"
},
"labels": {

View File

@@ -1,7 +1,25 @@
{
"title_suffix": " - Fotospiel Blog",
"by_author": "By",
"team": "Team",
"team": "Fotospiel Team",
"published_on": "Published on",
"back_to_blog": "Back to Blog"
"back_to_blog": "Back to Blog",
"breadcrumb_home": "Home",
"breadcrumb_blog": "Blog",
"summary_title": "Key takeaways",
"toc_title": "In this article",
"toc_empty": "Scroll to explore the full story.",
"sidebar_author_title": "About the author",
"sidebar_author_description": "Stories curated by the Fotospiel team.",
"share_title": "Share this story",
"share_hint": "Spread the word with one tap.",
"share_copy": "Copy link",
"share_copied": "Link copied!",
"share_native": "Share via device",
"share_whatsapp": "WhatsApp",
"share_linkedin": "LinkedIn",
"share_email": "Email",
"previous_post": "Previous story",
"next_post": "Next story",
"read_story": "Read story"
}

View File

@@ -359,6 +359,9 @@
"privacy": "Privacy",
"impressum": "Imprint",
"occasions_types": {
"weddings": "Weddings",
"birthdays": "Birthdays",
"corporate": "Corporate Events",
"confirmation": "Confirmations"
},
"language": "Language",
@@ -602,7 +605,7 @@
"hero": {
"title": "How the Fotospiel App Works",
"subtitle": "Share your QR code, collect guest photos in real time, and stay in full control all inside the browser.",
"primaryCta": "Create an event",
"primaryCta": "Discover our packages",
"secondaryCta": "Talk to our team",
"stats": [
{
@@ -864,6 +867,14 @@
"description": "Our team is happy to set up a pilot event or walk you through the dashboard.",
"cta": "Contact us"
},
"labels": {
"timeline_heading": "A clear roadmap for your event",
"recommendations": "Recommendations",
"challenge_ideas": "Challenge ideas",
"prep_hint": "Everything you should tick off before the event.",
"good_to_know": "Good to know",
"tips": "Tips"
},
"timeline_title": "The detailed flow"
},
"labels": {

View File

@@ -14,6 +14,7 @@ interface PostSummary {
slug: string;
title: string;
excerpt?: string;
excerpt_html?: string;
featured_image?: string;
published_at?: string;
author?: { name?: string } | string;
@@ -34,6 +35,18 @@ interface Props {
};
}
const MarkdownPreview: React.FC<{ html?: string; fallback?: string; className?: string }> = ({ html, fallback, className }) => {
if (html && html.trim().length > 0) {
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}
if (!fallback) {
return null;
}
return <p className={className}>{fallback}</p>;
};
const Blog: React.FC<Props> = ({ posts }) => {
const { localizedPath } = useLocalizedRoutes();
const { props } = usePage<{ supportedLocales?: string[] }>();
@@ -118,6 +131,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
<div className="flex flex-wrap justify-center gap-2">
{posts.links.map((link, index) => {
const href = resolvePaginationHref(link.url);
const labelText = link.label?.trim() || '…';
if (!href) {
return (
@@ -126,8 +140,9 @@ const Blog: React.FC<Props> = ({ posts }) => {
variant={link.active ? 'default' : 'outline'}
disabled
className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
>
{labelText}
</Button>
);
}
@@ -138,7 +153,9 @@ const Blog: React.FC<Props> = ({ posts }) => {
variant={link.active ? 'default' : 'outline'}
className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
>
<Link href={href} dangerouslySetInnerHTML={{ __html: link.label }} />
<Link href={href} aria-label={labelText}>
{labelText}
</Link>
</Button>
);
})}
@@ -182,9 +199,11 @@ const Blog: React.FC<Props> = ({ posts }) => {
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">
{featuredPost.title || 'Untitled'}
</h2>
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">
{featuredPost.excerpt || ''}
</p>
<MarkdownPreview
html={featuredPost.excerpt_html}
fallback={featuredPost.excerpt}
className="text-lg leading-relaxed text-gray-600 dark:text-gray-300"
/>
{renderPostMeta(featuredPost)}
<Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white">
<Link href={buildArticleHref(featuredPost.slug)}>
@@ -224,9 +243,11 @@ const Blog: React.FC<Props> = ({ posts }) => {
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50">
{post.title || 'Untitled'}
</h3>
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{post.excerpt || ''}
</p>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-sm leading-relaxed text-gray-600 dark:text-gray-300"
/>
</div>
<div className="flex-1" />
{renderPostMeta(post)}
@@ -261,7 +282,11 @@ const Blog: React.FC<Props> = ({ posts }) => {
<h3 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
{post.title || 'Untitled'}
</h3>
<p className="text-gray-600 dark:text-gray-300">{post.excerpt || ''}</p>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-gray-600 dark:text-gray-300"
/>
{renderPostMeta(post)}
<Button asChild variant="outline" className="w-fit border-pink-200 text-pink-600 hover:bg-pink-50 dark:border-pink-500/40 dark:text-pink-200">
<Link href={buildArticleHref(post.slug)}>
@@ -283,18 +308,18 @@ const Blog: React.FC<Props> = ({ posts }) => {
<MarketingLayout title={t('blog.title')}>
<Head title={t('blog.title')} />
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-14 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
<div className="container mx-auto max-w-4xl space-y-6 text-center">
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-10 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-12">
<div className="container mx-auto max-w-3xl space-y-5 text-center">
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
Fotospiel Blog
</Badge>
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-50 md:text-5xl">{t('blog.hero_title')}</h1>
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">{t('blog.hero_description')}</p>
<div className="flex flex-wrap justify-center gap-3">
<Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-50 md:text-4xl">{t('blog.hero_title')}</h1>
<p className="text-base leading-relaxed text-gray-600 dark:text-gray-300 md:text-lg">{t('blog.hero_description')}</p>
<div className="flex flex-wrap justify-center gap-2.5">
<Button asChild className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-6 py-2.5 text-base">
<Link href={localizedPath('/packages')}>{t('home.cta_explore')}</Link>
</Button>
<Button asChild size="lg" variant="outline" className="border-gray-300 text-gray-800 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800">
<Button asChild variant="outline" className="border-gray-300 text-gray-800 hover:bg-gray-100 px-6 py-2.5 text-base dark:border-gray-700 dark:text-gray-50 dark:hover:bg-gray-800">
<Link href="#articles">{t('blog.hero_cta')}</Link>
</Button>
</div>

View File

@@ -1,112 +1,336 @@
import React from 'react';
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/mainWebsite';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
interface AdjacentPost {
slug: string;
title: string;
excerpt?: string;
excerpt_html?: string;
}
interface HeadingItem {
text: string;
slug: string;
level: number;
}
interface Props {
post: {
id: number;
title: string;
excerpt?: string;
excerpt_html?: string;
content: string;
content_html: string;
headings?: HeadingItem[];
featured_image?: string;
published_at: string;
author?: { name: string };
slug: string;
url?: string;
previous_post?: AdjacentPost | null;
next_post?: AdjacentPost | null;
};
}
const MarkdownPreview: React.FC<{ html?: string; fallback?: string; className?: string }> = ({ html, fallback, className }) => {
if (html && html.trim().length > 0) {
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
}
if (!fallback) {
return null;
}
return <p className={className}>{fallback}</p>;
};
const BlogShow: React.FC<Props> = ({ post }) => {
const { localizedPath } = useLocalizedRoutes();
const { t } = useTranslation('blog_show');
const { t, i18n } = useTranslation('blog_show');
const [copied, setCopied] = React.useState(false);
const locale = i18n.language || 'de';
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE';
const formattedDate = React.useMemo(() => {
try {
return new Date(post.published_at).toLocaleDateString(dateLocale, {
day: 'numeric',
month: 'long',
year: 'numeric'
});
} catch (error) {
console.warn('[Marketing BlogShow] Failed to format date', error);
return post.published_at;
}
}, [post.published_at, dateLocale]);
const handleCopyLink = React.useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.clipboard) {
return;
}
navigator.clipboard.writeText(post.url || window.location.href).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch((error) => {
console.warn('[Marketing BlogShow] Failed to copy link', error);
});
}, [post.url]);
const shareUrl = post.url || (typeof window !== 'undefined' ? window.location.href : '');
const encodedShareUrl = encodeURIComponent(shareUrl);
const encodedTitle = encodeURIComponent(post.title);
const shareLinks = [
{
key: 'whatsapp',
label: t('share_whatsapp'),
href: `https://wa.me/?text=${encodedTitle}%20${encodedShareUrl}`
},
{
key: 'linkedin',
label: t('share_linkedin'),
href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedShareUrl}`
},
{
key: 'email',
label: t('share_email'),
href: `mailto:?subject=${encodedTitle}&body=${encodedShareUrl}`
}
];
const canUseNativeShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function';
const buildArticleHref = React.useCallback((slug?: string | null) => {
if (!slug) {
return '#';
}
return localizedPath(`/blog/${encodeURIComponent(slug)}`);
}, [localizedPath]);
return (
<MarketingLayout title={`${post.title} ${t('title_suffix')}`}>
<Head title={`${post.title} ${t('title_suffix')}`} />
{/* Hero Section */}
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] py-20 px-4">
<div className="container mx-auto max-w-4xl">
<Card className="bg-white/10 backdrop-blur-sm border-white/20 text-white shadow-xl">
<CardContent className="p-8 text-center">
<h1 className="text-4xl md:text-5xl font-bold mb-6 leading-tight">{post.title}</h1>
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-8 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950 md:py-10">
<div className="container mx-auto max-w-5xl space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
<nav className="flex flex-wrap items-center gap-2">
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('breadcrumb_home')}
</Link>
<span>/</span>
<Link href={localizedPath('/blog')} className="hover:text-gray-900 dark:hover:text-gray-100">
{t('breadcrumb_blog')}
</Link>
<span>/</span>
<span className="text-gray-700 dark:text-gray-200">{post.title}</span>
</nav>
<Link href={localizedPath('/blog')} className="text-pink-600 hover:text-pink-700">
{t('back_to_blog')}
</Link>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8 text-lg">
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
{t('by_author')} {post.author?.name || t('team')}
</Badge>
<Separator orientation="vertical" className="hidden sm:block h-6 bg-white/30" />
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
{t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</Badge>
</div>
{post.featured_image && (
<div className="mt-8">
<img
src={post.featured_image}
alt={post.title}
className="mx-auto rounded-lg shadow-lg max-w-2xl w-full object-cover"
<Card className="rounded-[32px] border border-white/60 bg-white/80 text-gray-900 shadow-xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/80 dark:text-gray-50">
<CardContent className="space-y-6 p-6 md:p-8">
<div className="flex flex-col gap-6 md:grid md:grid-cols-[minmax(0,1fr)_240px] md:items-center md:gap-8">
<div className="space-y-4 text-left">
<Badge variant="outline" className="border-pink-200 bg-white/70 text-pink-600 dark:border-pink-500/60 dark:bg-gray-900/80 dark:text-pink-200">
Fotospiel Stories
</Badge>
<h1 className="text-3xl font-bold leading-tight text-gray-900 dark:text-gray-50 md:text-4xl">{post.title}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-600 dark:text-gray-300">
<span>{t('by_author')} {post.author?.name || t('team')}</span>
<span className="hidden text-gray-400 sm:inline"></span>
<span>{t('published_on')} {formattedDate}</span>
</div>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-base leading-relaxed text-gray-700 dark:text-gray-200"
/>
</div>
<div className="relative">
{post.featured_image ? (
<div className="rounded-3xl border border-white/80 bg-white/60 p-2 shadow-2xl dark:border-gray-800/80 dark:bg-gray-900/80">
<img
src={post.featured_image}
alt={post.title}
className="h-full w-full rounded-2xl object-cover"
/>
</div>
) : (
<div className="rounded-3xl border border-dashed border-pink-200 bg-gradient-to-br from-pink-100 via-white to-amber-100 p-8 text-center text-lg font-semibold text-pink-700 shadow-lg dark:border-pink-500/40 dark:from-pink-900/30 dark:via-gray-900 dark:to-gray-900">
Fotospiel Stories
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</section>
<section className="bg-white px-4 py-12 dark:bg-gray-950">
<div className="container mx-auto max-w-6xl gap-10 lg:grid lg:grid-cols-[minmax(0,1fr)_320px]">
<article className="rounded-[32px] border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div
className="prose prose-lg prose-slate max-w-none
prose-headings:text-slate-900 prose-headings:font-semibold
prose-p:text-slate-700 prose-p:leading-relaxed
prose-a:text-pink-600 prose-a:no-underline hover:prose-a:underline
prose-strong:text-slate-900 prose-strong:font-semibold
prose-code:text-slate-900 prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-slate-900 prose-pre:text-slate-100
prose-blockquote:border-l-4 prose-blockquote:border-pink-500 prose-blockquote:pl-6 prose-blockquote:italic
prose-ul:text-slate-700 prose-ol:text-slate-700
prose-li:text-slate-700 dark:prose-invert"
dangerouslySetInnerHTML={{ __html: post.content_html }}
/>
</article>
<aside className="mt-10 space-y-6 lg:mt-0 lg:sticky lg:top-24">
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('summary_title')}
</p>
<MarkdownPreview
html={post.excerpt_html}
fallback={post.excerpt}
className="text-base leading-relaxed text-gray-700 dark:text-gray-200"
/>
</CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-4 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('toc_title')}
</p>
{post.headings && post.headings.length > 0 ? (
<ul className="space-y-3 text-sm text-gray-700 dark:text-gray-200">
{post.headings.map((heading) => (
<li key={heading.slug} className="border-l-2 border-pink-100 pl-3 dark:border-pink-500/40">
<a href={`#${heading.slug}`} className="hover:text-pink-600">
{heading.text}
</a>
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">{t('toc_empty')}</p>
)}
</CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-3 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('sidebar_author_title')}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-gray-50">{post.author?.name || t('team')}</p>
<p className="text-sm text-gray-600 dark:text-gray-300">{t('sidebar_author_description')}</p>
</CardContent>
</Card>
<Card className="border-gray-100 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<CardContent className="space-y-4 p-6">
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t('share_title')}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('share_hint')}</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" size="sm" onClick={handleCopyLink} className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
{copied ? t('share_copied') : t('share_copy')}
</Button>
{canUseNativeShare && (
<Button
variant="ghost"
size="sm"
className="text-gray-700 hover:text-pink-600 dark:text-gray-200"
onClick={() => navigator.share?.({ title: post.title, url: shareUrl })}
>
{t('share_native')}
</Button>
)}
</div>
<Separator className="my-2" />
<div className="flex flex-col gap-2">
{shareLinks.map((link) => (
<Button key={link.key} variant="outline" size="sm" asChild className="justify-start border-gray-200 text-gray-700 hover:border-pink-200 hover:text-pink-600 dark:border-gray-700 dark:text-gray-50">
<a href={link.href} target="_blank" rel="noreferrer">
{link.label}
</a>
</Button>
))}
</div>
</CardContent>
</Card>
</aside>
</div>
</section>
{(post.previous_post || post.next_post) && (
<section className="bg-gradient-to-br from-pink-50 via-white to-amber-50 px-4 py-12 dark:from-gray-950 dark:via-gray-900 dark:to-gray-950">
<div className="container mx-auto max-w-6xl">
<div className="grid gap-6 md:grid-cols-2">
{post.previous_post && (
<Card className="border-none bg-gradient-to-br from-pink-500 to-pink-400 text-white shadow-lg">
<CardContent className="space-y-4 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-white/80">
{t('previous_post')}
</p>
<h3 className="text-2xl font-semibold">{post.previous_post.title}</h3>
<MarkdownPreview
html={post.previous_post.excerpt_html}
fallback={post.previous_post.excerpt}
className="text-white/90"
/>
<Button asChild variant="secondary" className="bg-white/20 text-white hover:bg-white/30">
<Link href={buildArticleHref(post.previous_post.slug)}>
{t('read_story')}
</Link>
</Button>
</CardContent>
</Card>
)}
</CardContent>
</Card>
</div>
</section>
{/* Post Content */}
<section className="py-20 px-4 bg-white">
<div className="container mx-auto max-w-4xl">
<Card className="shadow-sm">
<CardContent className="p-8 md:p-12">
<div
className="prose prose-lg prose-slate max-w-none
prose-headings:text-slate-900 prose-headings:font-semibold
prose-p:text-slate-700 prose-p:leading-relaxed
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
prose-strong:text-slate-900 prose-strong:font-semibold
prose-code:text-slate-900 prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-slate-900 prose-pre:text-slate-100
prose-blockquote:border-l-4 prose-blockquote:border-blue-500 prose-blockquote:pl-6 prose-blockquote:italic
prose-ul:text-slate-700 prose-ol:text-slate-700
prose-li:text-slate-700"
dangerouslySetInnerHTML={{ __html: post.content_html }}
/>
</CardContent>
</Card>
</div>
</section>
{/* Back to Blog */}
<section className="py-10 px-4 bg-gray-50">
<div className="container mx-auto max-w-4xl">
<Card className="shadow-sm">
<CardContent className="p-8 text-center">
<Separator className="mb-6" />
<Button
asChild
size="lg"
className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-8 py-3 rounded-full font-semibold transition-colors"
>
<Link href={localizedPath('/blog')}>
{t('back_to_blog')}
</Link>
</Button>
</CardContent>
</Card>
</div>
</section>
{post.next_post && (
<Card className="border-none bg-gradient-to-br from-amber-400 to-pink-400 text-gray-900 shadow-lg">
<CardContent className="space-y-4 p-6">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-800/80">
{t('next_post')}
</p>
<h3 className="text-2xl font-semibold">{post.next_post.title}</h3>
<MarkdownPreview
html={post.next_post.excerpt_html}
fallback={post.next_post.excerpt}
className="text-gray-900/80"
/>
<Button asChild className="bg-gray-900 text-white hover:bg-gray-800">
<Link href={buildArticleHref(post.next_post.slug)}>
{t('read_story')}
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</div>
</section>
)}
</MarketingLayout>
);
};

View File

@@ -103,7 +103,7 @@ const HowItWorks: React.FC = () => {
</p>
<div className="flex flex-wrap items-center gap-3">
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
<Link href={localizedPath('/register')}>
<Link href={localizedPath('/packages')}>
{hero.primaryCta}
</Link>
</Button>
@@ -192,7 +192,7 @@ const HowItWorks: React.FC = () => {
{t('how_it_works_page.timeline_title', 'Der Ablauf im Detail')}
</Badge>
<h2 className="mt-3 text-3xl font-bold text-gray-900 dark:text-gray-50">
Ein klarer Fahrplan für dein Event
{t('how_it_works_page.labels.timeline_heading', 'Ein klarer Fahrplan für dein Event')}
</h2>
</div>
<Accordion type="single" collapsible className="w-full">
@@ -211,7 +211,7 @@ const HowItWorks: React.FC = () => {
{item.tips?.length ? (
<div className="mt-4 rounded-lg border border-pink-100 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-pink-900/40 dark:bg-gray-900 dark:text-gray-300">
<p className="mb-2 font-semibold text-pink-600 dark:text-pink-300">
{t('marketing.actions.tips', 'Tipps')}
{t('how_it_works_page.labels.tips', 'Tipps')}
</p>
<ul className="space-y-1">
{item.tips.map((tip) => (
@@ -271,7 +271,7 @@ const HowItWorks: React.FC = () => {
<CardContent className="grid gap-6 md:grid-cols-2">
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('marketing.labels.recommendations', 'Empfehlungen')}
{t('how_it_works_page.labels.recommendations', 'Empfehlungen')}
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
{tab.recommendations.map((item) => (
@@ -284,7 +284,7 @@ const HowItWorks: React.FC = () => {
</div>
<div>
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
{t('marketing.labels.challengeIdeas', 'Ideen für Challenges')}
{t('how_it_works_page.labels.challenge_ideas', 'Ideen für Challenges')}
</p>
<div className="flex flex-wrap gap-2">
{tab.ideas.map((idea) => (
@@ -308,7 +308,7 @@ const HowItWorks: React.FC = () => {
<CardHeader>
<CardTitle>{checklist.title}</CardTitle>
<CardDescription>
{t('marketing.labels.prepHint', 'Alles, was du vor dem Event abhaken solltest.')}
{t('how_it_works_page.labels.prep_hint', 'Alles, was du vor dem Event abhaken solltest.')}
</CardDescription>
</CardHeader>
<CardContent>

File diff suppressed because it is too large Load Diff

View File

@@ -141,7 +141,7 @@
"hero_description": "Sammeln Sie unvergessliche Fotos von Ihren Gästen mit QR-Codes. Perfekt für :type einfach, mobil und datenschutzkonform.",
"cta": "Paket wählen",
"weddings": {
"title": "Hochzeiten mit Fotospiel",
"title": "Hochzeiten mit der Fotospiel App",
"description": "Fangen Sie romantische Momente ein: Gäste teilen Fotos via QR, wählen Emotionen wie 'Romantisch' oder 'Freudig'. Besser als traditionelle Fotoboxen.",
"benefits_title": "Vorteile für Hochzeiten",
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
@@ -221,13 +221,38 @@
"title_suffix": " - Fotospiel Blog",
"by_author": "Von",
"published_on": "Veröffentlicht am",
"back_to_blog": "Zurück zum Blog"
"back_to_blog": "Zurück zum Blog",
"breadcrumb_home": "Start",
"breadcrumb_blog": "Blog",
"team": "Fotospiel Team",
"summary_title": "Wichtigste Erkenntnisse",
"toc_title": "In diesem Artikel",
"toc_empty": "Scrolle weiter, um die komplette Story zu lesen.",
"sidebar_author_title": "Über den Autor",
"sidebar_author_description": "Kuratiert vom Fotospiel Team.",
"share_title": "Story teilen",
"share_hint": "Mit einem Tipp verbreiten.",
"share_copy": "Link kopieren",
"share_copied": "Link kopiert!",
"share_native": "Gerät zum Teilen nutzen",
"share_whatsapp": "WhatsApp",
"share_linkedin": "LinkedIn",
"share_email": "E-Mail",
"previous_post": "Vorherige Story",
"next_post": "Nächste Story",
"read_story": "Story lesen"
},
"nav": {
"home": "Startseite",
"how_it_works": "So funktioniert es",
"features": "Features",
"occasions": "Anlässe",
"occasions_types": {
"weddings": "Hochzeiten",
"birthdays": "Geburtstage",
"corporate": "Firmenevents",
"confirmation": "Konfirmation & Jugendweihe"
},
"blog": "Blog",
"packages": "Pakete",
"contact": "Kontakt",

View File

@@ -117,6 +117,10 @@ return [
'read_more' => 'Lesen',
'back' => 'Zurück zum Blog',
'empty' => 'Noch keine Posts verfügbar. Bleib dran!',
'pagination' => [
'previous' => 'Zurück',
'next' => 'Weiter',
],
],
'occasions' => [
'title' => 'Fotospiel für :type',
@@ -124,7 +128,7 @@ return [
'hero_description' => 'Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für :type einfach, mobil und datenschutzkonform.',
'cta' => 'Package wählen',
'weddings' => [
'title' => 'Hochzeiten mit Fotospiel',
'title' => 'Hochzeiten mit der Fotospiel App',
'description' => 'Erfange romantische Momente: Gäste teilen Fotos via QR, wähle Emotions wie \'Romantisch\' oder \'Fröhlich\'. Besser als traditionelle Fotoboxen.',
'benefits_title' => 'Vorteile für Hochzeiten',
'benefit1' => 'QR-Code für Gäste: Einfaches Teilen ohne App-Download.',

View File

@@ -0,0 +1,18 @@
{
"timeline_title": "The detailed flow",
"experience": {
"host": {
"label": "Hosts",
"intro": "Plan, moderate, and export your event memories from a single dashboard.",
"callouts_heading": "Good to know"
},
"guest": {
"label": "Guests",
"intro": "Your guests simply scan, shoot, and share. No login, no download, no friction.",
"callouts_heading": "Good to know"
}
},
"timeline": [
{ "title": "Prepare your event", "body": "...", "tips": [] }
]
}

View File

@@ -141,7 +141,7 @@
"hero_description": "Collect unforgettable photos from your guests with QR-Codes. Perfect for :type simple, mobile and privacy-compliant.",
"cta": "Choose Package",
"weddings": {
"title": "Weddings with Fotospiel",
"title": "Weddings with the Fotospiel App",
"description": "Capture romantic moments: Guests share photos via QR, choose emotions like 'Romantic' or 'Joyful'. Better than traditional photo booths.",
"benefits_title": "Benefits for Weddings",
"benefit1": "QR-Code for Guests: Easy sharing without app download.",
@@ -221,13 +221,38 @@
"title_suffix": " - Fotospiel Blog",
"by_author": "By",
"published_on": "Published on",
"back_to_blog": "Back to Blog"
"back_to_blog": "Back to Blog",
"breadcrumb_home": "Home",
"breadcrumb_blog": "Blog",
"team": "Fotospiel Team",
"summary_title": "Key takeaways",
"toc_title": "In this article",
"toc_empty": "Scroll to explore the full story.",
"sidebar_author_title": "About the author",
"sidebar_author_description": "Stories curated by the Fotospiel team.",
"share_title": "Share this story",
"share_hint": "Spread the word with one tap.",
"share_copy": "Copy link",
"share_copied": "Link copied!",
"share_native": "Share via device",
"share_whatsapp": "WhatsApp",
"share_linkedin": "LinkedIn",
"share_email": "Email",
"previous_post": "Previous story",
"next_post": "Next story",
"read_story": "Read story"
},
"nav": {
"home": "Home",
"how_it_works": "How it works",
"features": "Features",
"occasions": "Occasions",
"occasions_types": {
"weddings": "Weddings",
"birthdays": "Birthdays",
"corporate": "Corporate Events",
"confirmation": "Confirmations"
},
"blog": "Blog",
"packages": "Packages",
"contact": "Contact",

View File

@@ -117,6 +117,10 @@ return [
'read_more' => 'Read',
'back' => 'Back to Blog',
'empty' => 'No posts available yet. Stay tuned!',
'pagination' => [
'previous' => 'Previous',
'next' => 'Next',
],
],
'occasions' => [
'title' => 'Fotospiel for :type',
@@ -124,7 +128,7 @@ return [
'hero_description' => 'Collect unforgettable photos from your guests with QR-Codes. Perfect for :type simple, mobile and privacy-compliant.',
'cta' => 'Choose Package',
'weddings' => [
'title' => 'Weddings with Fotospiel',
'title' => 'Weddings with the Fotospiel App',
'description' => 'Capture romantic moments: Guests share photos via QR, choose emotions like \'Romantic\' or \'Joyful\'. Better than traditional photo booths.',
'benefits_title' => 'Benefits for Weddings',
'benefit1' => 'QR-Code for Guests: Easy sharing without app download.',

View File

@@ -0,0 +1,43 @@
{
"home": {
"title": "Home - Fotospiel",
"hero_title": "Your event. Their photos.",
"hero_description": "The Fotospiel App combines QR access, live galleries, and moderation in one platform—perfect for weddings, corporate events, and every celebration that deserves a highlight reel.",
"cta_explore": "Discover Packages",
"cta_explore_highlight": "Start your Fotospiel trial",
"hero_image_alt": "Guests sharing photos via QR code",
"how_title": "How the Fotospiel App works",
"step1_title": "Create event & pick a package",
"step1_desc": "Set limits for photos, guests, and branding in just a few clicks.",
"step2_title": "Share QR link & access code",
"step2_desc": "Guests scan the QR code or type your access code to start uploading instantly—no app store needed.",
"step3_title": "Moderate live & spotlight favorites",
"step3_desc": "Approve posts, trigger slideshows, and export highlight galleries on demand.",
"features_title": "Why the Fotospiel App?",
"feature1_title": "Secure & Privacy Compliant",
"feature1_desc": "GDPR compliant, no PII storage.",
"feature2_title": "Mobile & PWA",
"feature2_desc": "Works offline, installable like an app.",
"feature3_title": "Easy to Use",
"feature3_desc": "Intuitive UI for guests and organizers.",
"packages_title": "Packages & pricing",
"view_details": "View Details",
"all_packages": "View All Packages",
"contact_title": "Let's plan your event",
"contact_lead": "Well guide you through moderation, QR touchpoints, and the perfect Fotospiel App setup.",
"name_label": "Name",
"email_label": "Email",
"message_label": "Message",
"sending": "Sending...",
"send": "Send",
"testimonials_title": "Voices from the community",
"testimonials_subtitle": "Over 1,200 events have already run on the Fotospiel App.",
"testimonial1": "Our guests documented the day for us—and everything landed in one secure archive.",
"testimonial2": "Branding, moderation, analytics—all right where I need them during an event.",
"testimonial3": "Confirmation without messaging chaos. QR out, emojis in, photos for everyone.",
"faq_title": "Still curious?",
"faq1_q": "Can I try the Fotospiel App first?",
"faq1_a": "Absolutely! Use our demo event or pick the Free package to explore all core features.",
"faq2_q": "Do guests need an account?",
"faq2_a": "No. A personal access code is enough, and you can add an optional PIN for extra gallery protection." }
}

View File

@@ -1,28 +1,28 @@
<x-filament-panels::page>
<div class="space-y-6">
<x-filament::section heading="Application Controls">
<x-filament::section heading="Compose Controls">
<div class="grid gap-4 md:grid-cols-2">
@foreach($applications as $application)
@foreach($composes as $compose)
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $application['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $application['application_id'] }}</p>
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $compose['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $compose['compose_id'] }}</p>
</div>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700 dark:bg-slate-800 dark:text-slate-100">
{{ ucfirst($application['status'] ?? 'unknown') }}
{{ ucfirst($compose['status'] ?? 'unknown') }}
</span>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<x-filament::button size="sm" color="warning" wire:click="reload('{{ $application['application_id'] }}')">
Reload
</x-filament::button>
<x-filament::button size="sm" color="gray" wire:click="redeploy('{{ $application['application_id'] }}')">
<x-filament::button size="sm" color="warning" wire:click="redeploy('{{ $compose['compose_id'] }}')">
Redeploy
</x-filament::button>
<x-filament::button size="sm" color="danger" wire:click="stop('{{ $compose['compose_id'] }}')">
Stop
</x-filament::button>
@if($dokployWebUrl)
<x-filament::button tag="a" size="sm" color="gray" href="{{ rtrim($dokployWebUrl, '/') }}/applications/{{ $application['application_id'] }}" target="_blank">
<x-filament::button tag="a" size="sm" color="gray" href="{{ rtrim($dokployWebUrl, '/') }}" target="_blank">
Open in Dokploy
</x-filament::button>
@endif
@@ -39,7 +39,7 @@
<tr class="text-left text-xs uppercase tracking-wide text-slate-500">
<th class="px-3 py-2">When</th>
<th class="px-3 py-2">User</th>
<th class="px-3 py-2">Application</th>
<th class="px-3 py-2">Target</th>
<th class="px-3 py-2">Action</th>
<th class="px-3 py-2">Status</th>
</tr>

View File

@@ -1,52 +1,54 @@
<x-filament-widgets::widget>
<x-filament::section heading="Infra Status (Dokploy)">
<div class="grid gap-4 md:grid-cols-2">
@forelse($applications as $application)
@forelse($composes as $compose)
<div class="rounded-2xl border border-slate-200/70 bg-white/80 p-4 shadow-sm dark:border-white/10 dark:bg-slate-900/60">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $application['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $application['app_name'] ?? $application['application_id'] }}</p>
<p class="text-[11px] text-slate-400 dark:text-slate-500">{{ $application['application_id'] }}</p>
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $compose['label'] }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $compose['name'] }}</p>
<p class="text-[11px] text-slate-400 dark:text-slate-500">{{ $compose['compose_id'] }}</p>
</div>
<span @class([
'rounded-full px-3 py-1 text-xs font-semibold',
'bg-emerald-100 text-emerald-800' => $application['status'] === 'running',
'bg-amber-100 text-amber-800' => in_array($application['status'], ['deploying', 'idle']),
'bg-rose-100 text-rose-800' => in_array($application['status'], ['unreachable', 'error']),
'bg-slate-100 text-slate-600' => ! in_array($application['status'], ['running', 'deploying', 'idle', 'unreachable', 'error']),
'bg-emerald-100 text-emerald-800' => $compose['status'] === 'done',
'bg-amber-100 text-amber-800' => in_array($compose['status'], ['deploying', 'pending']),
'bg-rose-100 text-rose-800' => in_array($compose['status'], ['unreachable', 'error', 'failed']),
'bg-slate-100 text-slate-600' => ! in_array($compose['status'], ['done', 'deploying', 'pending', 'unreachable', 'error', 'failed']),
])>
{{ ucfirst($application['status']) }}
{{ ucfirst($compose['status']) }}
</span>
</div>
@if(isset($application['error']))
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $application['error'] }}</p>
@if(isset($compose['error']))
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $compose['error'] }}</p>
@else
<dl class="mt-3 grid grid-cols-3 gap-2 text-xs">
<div>
<dt class="text-slate-500 dark:text-slate-400">CPU</dt>
<dd class="font-semibold text-slate-900 dark:text-white">
{{ isset($application['cpu']) ? $application['cpu'].'%' : '—' }}
</dd>
</div>
<div>
<dt class="text-slate-500 dark:text-slate-400">Memory</dt>
<dd class="font-semibold text-slate-900 dark:text-white">
{{ isset($application['memory']) ? $application['memory'].'%' : '—' }}
</dd>
</div>
<div>
<dt class="text-slate-500 dark:text-slate-400">Last Deploy</dt>
<dd class="font-semibold text-slate-900 dark:text-white">
{{ $application['last_deploy'] ? \Illuminate\Support\Carbon::parse($application['last_deploy'])->diffForHumans() : '—' }}
</dd>
</div>
</dl>
<div class="mt-3 space-y-1">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400">Services</p>
@forelse($compose['services'] as $service)
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-1 text-[11px] font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200">
<span>{{ $service['name'] }}</span>
<span @class([
'rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide',
'bg-emerald-200/70 text-emerald-900' => in_array($service['status'], ['running', 'done']),
'bg-amber-200/70 text-amber-900' => in_array($service['status'], ['starting', 'deploying']),
'bg-rose-200/70 text-rose-900' => in_array($service['status'], ['error', 'failed', 'unhealthy']),
'bg-slate-200/70 text-slate-900' => ! in_array($service['status'], ['running', 'done', 'starting', 'deploying', 'error', 'failed', 'unhealthy']),
])>
{{ strtoupper($service['status'] ?? 'N/A') }}
</span>
</div>
@empty
<p class="text-xs text-slate-500 dark:text-slate-400">No services reported.</p>
@endforelse
</div>
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
Last deploy: {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }}
</p>
@endif
</div>
@empty
<p class="text-sm text-slate-500 dark:text-slate-300">No Dokploy applications configured.</p>
<p class="text-sm text-slate-500 dark:text-slate-300">No Dokploy compose stacks configured.</p>
@endforelse
</div>
</x-filament::section>

View File

@@ -7,15 +7,26 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
@php
$currentLocale = app()->getLocale();
$occasionRouteName = $currentLocale === 'en' ? 'occasions.type' : 'anlaesse.type';
$occasionSlugs = [
'hochzeit' => $currentLocale === 'en' ? 'wedding' : 'hochzeit',
'geburtstag' => $currentLocale === 'en' ? 'birthday' : 'geburtstag',
'firmenevent' => $currentLocale === 'en' ? 'corporate-event' : 'firmenevent',
'konfirmation' => $currentLocale === 'en' ? 'confirmation' : 'konfirmation',
];
@endphp
<nav class="hidden md:flex space-x-6 items-center">
<a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}#how-it-works" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.how_it_works') }}</a>
<a href="{{ route('marketing.home', ['locale' => app()->getLocale()]) }}#features" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.features') }}</a>
<div x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false" @click.away="open = false" class="relative">
<button class="text-gray-600 hover:text-gray-900" @click.stop="open = !open">{{ __('marketing.nav.occasions') }}</button>
<div x-show="open" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg z-10">
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'hochzeit']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.weddings') }}</a>
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'geburtstag']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.birthdays') }}</a>
<a href="{{ route('anlaesse.type', ['locale' => app()->getLocale(), 'type' => 'firmenevent']) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.corporate') }}</a>
<a href="{{ route($occasionRouteName, ['locale' => $currentLocale, 'type' => $occasionSlugs['hochzeit']]) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.weddings') }}</a>
<a href="{{ route($occasionRouteName, ['locale' => $currentLocale, 'type' => $occasionSlugs['geburtstag']]) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.birthdays') }}</a>
<a href="{{ route($occasionRouteName, ['locale' => $currentLocale, 'type' => $occasionSlugs['firmenevent']]) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.corporate') }}</a>
<a href="{{ route($occasionRouteName, ['locale' => $currentLocale, 'type' => $occasionSlugs['konfirmation']]) }}" class="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition">{{ __('marketing.nav.occasions_types.confirmation') }}</a>
</div>
</div>
<a href="{{ route('blog', ['locale' => app()->getLocale()]) }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.blog') }}</a>

View File

@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature\Marketing;
use App\Models\BlogCategory;
use App\Models\BlogPost;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Testing\Fluent\AssertableJson;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class BlogIndexTest extends TestCase
{
use RefreshDatabase;
public function test_blog_index_returns_html_excerpts(): void
{
$category = BlogCategory::query()->create([
'slug' => 'blog',
'name' => [
'de' => 'Blog',
'en' => 'Blog',
],
'description' => [
'de' => 'Hochzeitsblog',
'en' => 'Wedding blog',
],
'is_visible' => true,
]);
BlogPost::query()->create([
'blog_category_id' => $category->id,
'slug' => 'markdown-vorschau',
'title' => [
'de' => 'Markdown Vorschau',
'en' => 'Markdown Preview',
],
'excerpt' => [
'de' => '**Fette** Vorschau für Gäste',
'en' => '**Bold** preview for guests',
],
'content' => [
'de' => '## Inhalt',
'en' => '## Content',
],
'is_published' => true,
'published_at' => Carbon::now()->subDay(),
]);
$response = $this->get('/de/blog');
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('marketing/Blog')
->has('posts.data', 1, fn (AssertableJson $post) => $post
->where('excerpt', '**Fette** Vorschau für Gäste')
->where('excerpt_html', fn ($value) => is_string($value) && str_contains($value, '<strong>Fette</strong>'))
->etc()
)
->where('posts.links.0.label', __('marketing.blog.pagination.previous', [], 'de'))
->where('posts.links.2.label', __('marketing.blog.pagination.next', [], 'de'))
);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Tests\Feature\Marketing;
use App\Models\BlogCategory;
use App\Models\BlogPost;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class BlogShowTest extends TestCase
{
use RefreshDatabase;
public function test_blog_show_returns_headings_and_adjacent_posts(): void
{
$category = BlogCategory::query()->create([
'slug' => 'blog',
'name' => [
'de' => 'Blog',
'en' => 'Blog',
],
'description' => [
'de' => 'Hochzeitsblog',
'en' => 'Wedding blog',
],
'is_visible' => true,
]);
$older = BlogPost::query()->create([
'blog_category_id' => $category->id,
'slug' => 'older-story',
'title' => [
'de' => 'Ältere Story',
'en' => 'Older Story',
],
'excerpt' => [
'de' => '**Alt**',
'en' => '**Old**',
],
'content' => [
'de' => '## Start\nÄlterer Inhalt',
'en' => '## Start\nOlder content',
],
'is_published' => true,
'published_at' => Carbon::parse('2024-01-01'),
]);
$current = BlogPost::query()->create([
'blog_category_id' => $category->id,
'slug' => 'spotlight-story',
'title' => [
'de' => 'Spotlight Story',
'en' => 'Spotlight Story',
],
'excerpt' => [
'de' => 'Zusammenfassung',
'en' => 'Summary',
],
'content' => [
'de' => "## Einleitung\nWillkommen\n### Schritte\nLos geht's",
'en' => "## Intro\nWelcome\n### Steps\nLet's go",
],
'is_published' => true,
'published_at' => Carbon::parse('2024-02-01'),
]);
$newer = BlogPost::query()->create([
'blog_category_id' => $category->id,
'slug' => 'new-story',
'title' => [
'de' => 'Neue Story',
'en' => 'New Story',
],
'excerpt' => [
'de' => 'Neu',
'en' => 'New',
],
'content' => [
'de' => '## Vorschau\nNeuer Inhalt',
'en' => '## Preview\nNew content',
],
'is_published' => true,
'published_at' => Carbon::parse('2024-03-01'),
]);
$response = $this->get('/de/blog/'.$current->slug);
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('marketing/BlogShow')
->where('post.previous_post.slug', $older->slug)
->where('post.next_post.slug', $newer->slug)
->where('post.headings.0.text', 'Einleitung')
->where('post.headings.0.slug', fn ($slug) => is_string($slug) && str_contains($slug, 'einleitung'))
->where('post.content_html', fn ($html) => is_string($html) && str_contains($html, 'id="einleitung"'))
);
}
}

7
translate.py Normal file
View File

@@ -0,0 +1,7 @@
import sys
from translate import Translator
language = sys.argv[1]
text = sys.stdin.read().strip()
translator = Translator(to_lang=language)
print(translator.translate(text))

19
translator.py Normal file
View File

@@ -0,0 +1,19 @@
import sys
import json
# Usage: python translator.py <target_lang>
# Expects JSON on stdin: {"text": "..."}
# For now, simple dictionary-based approximation / placeholder logic.
lang = sys.argv[1]
params = json.loads(sys.stdin.read())
text = params.get('text', '')
# TODO: Replace with proper translation logic or API call.
def fake_translate(text, lang):
if lang.lower().startswith('en'):
return text
# For example, a trivial & inaccurate mock transformation for demonstration.
return text[::-1]
print(json.dumps({"translation": fake_translate(text, lang)}))