admin widget zu dokploy geswitched, viele übersetzungen im Frontend vervollständigt und Anlässe-Seiten mit ChatGPT ausgebaut
This commit is contained in:
@@ -137,6 +137,7 @@ DOKPLOY_API_KEY=
|
|||||||
DOKPLOY_WEB_URL=
|
DOKPLOY_WEB_URL=
|
||||||
DOKPLOY_API_TIMEOUT=10
|
DOKPLOY_API_TIMEOUT=10
|
||||||
DOKPLOY_APPLICATION_IDS={"app":"app_xxx","queue":"app_queue","scheduler":"app_scheduler","ftp":"app_ftp"}
|
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
|
GUEST_ACHIEVEMENT_MILESTONES=10,25,50
|
||||||
|
|
||||||
|
|||||||
BIN
__pycache__/translate.cpython-312.pyc
Normal file
BIN
__pycache__/translate.cpython-312.pyc
Normal file
Binary file not shown.
@@ -34,16 +34,17 @@ class InfrastructureActionLogResource extends Resource
|
|||||||
->sortable()
|
->sortable()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Columns\TextColumn::make('service_id')
|
Tables\Columns\TextColumn::make('service_id')
|
||||||
->label('Service')
|
->label('Target')
|
||||||
->searchable()
|
->searchable()
|
||||||
->copyable()
|
->copyable()
|
||||||
->limit(30),
|
->limit(30),
|
||||||
Tables\Columns\BadgeColumn::make('action')
|
Tables\Columns\BadgeColumn::make('action')
|
||||||
->label('Action')
|
->label('Action')
|
||||||
->colors([
|
->colors([
|
||||||
'warning' => 'restart',
|
'warning' => fn ($state) => in_array($state, ['compose.redeploy', 'redeploy'], true),
|
||||||
'info' => 'redeploy',
|
'success' => fn ($state) => in_array($state, ['compose.deploy', 'deploy'], true),
|
||||||
'gray' => 'logs',
|
'danger' => fn ($state) => in_array($state, ['compose.stop', 'stop'], true),
|
||||||
|
'gray' => fn ($state) => $state === 'logs',
|
||||||
])
|
])
|
||||||
->sortable(),
|
->sortable(),
|
||||||
Tables\Columns\TextColumn::make('status_code')
|
Tables\Columns\TextColumn::make('status_code')
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class DokployDeployments extends Page
|
|||||||
|
|
||||||
protected string $view = 'filament.super-admin.pages.dokploy-deployments';
|
protected string $view = 'filament.super-admin.pages.dokploy-deployments';
|
||||||
|
|
||||||
public array $applications = [];
|
public array $composes = [];
|
||||||
|
|
||||||
public array $recentLogs = [];
|
public array $recentLogs = [];
|
||||||
|
|
||||||
@@ -28,43 +28,45 @@ class DokployDeployments extends Page
|
|||||||
public function mount(DokployClient $client): void
|
public function mount(DokployClient $client): void
|
||||||
{
|
{
|
||||||
$this->dokployWebUrl = config('dokploy.web_url');
|
$this->dokployWebUrl = config('dokploy.web_url');
|
||||||
$this->refreshApplications($client);
|
$this->refreshComposes($client);
|
||||||
$this->refreshLogs();
|
$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);
|
$client = app(DokployClient::class);
|
||||||
|
|
||||||
if (! $this->isKnownApplication($applicationId)) {
|
if (! $this->isKnownCompose($composeId)) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->danger()
|
->danger()
|
||||||
->title('Unknown service')
|
->title('Unknown service')
|
||||||
->body("The application ID {$applicationId} is not configured.")
|
->body("The compose ID {$composeId} is not configured.")
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$action === 'reload'
|
match ($action) {
|
||||||
? $client->reloadApplication($applicationId, auth()->user())
|
'redeploy' => $client->redeployCompose($composeId, auth()->user()),
|
||||||
: $client->redeployApplication($applicationId, auth()->user());
|
'stop' => $client->stopCompose($composeId, auth()->user()),
|
||||||
|
default => throw new \RuntimeException("Unsupported action [{$action}]"),
|
||||||
|
};
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
->title(ucfirst($action).' requested')
|
->title(ucfirst($action).' requested')
|
||||||
->body("Dokploy accepted the {$action} action for {$applicationId}.")
|
->body("Dokploy accepted the {$action} action for {$composeId}.")
|
||||||
->send();
|
->send();
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@@ -74,35 +76,35 @@ class DokployDeployments extends Page
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->refreshApplications($client);
|
$this->refreshComposes($client);
|
||||||
$this->refreshLogs();
|
$this->refreshLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function refreshApplications(DokployClient $client): void
|
protected function refreshComposes(DokployClient $client): void
|
||||||
{
|
{
|
||||||
$applicationMap = config('dokploy.applications', []);
|
$composeMap = config('dokploy.composes', []);
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
foreach ($applicationMap as $label => $id) {
|
foreach ($composeMap as $label => $id) {
|
||||||
try {
|
try {
|
||||||
$status = $client->applicationStatus($id);
|
$status = $client->composeStatus($id);
|
||||||
$application = Arr::get($status, 'application', []);
|
$compose = Arr::get($status, 'compose', []);
|
||||||
|
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'label' => ucfirst($label),
|
'label' => ucfirst($label),
|
||||||
'application_id' => $id,
|
'compose_id' => $id,
|
||||||
'status' => Arr::get($application, 'applicationStatus', 'unknown'),
|
'status' => Arr::get($compose, 'composeStatus', 'unknown'),
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'label' => ucfirst($label),
|
'label' => ucfirst($label),
|
||||||
'application_id' => $id,
|
'compose_id' => $id,
|
||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applications = $results;
|
$this->composes = $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function refreshLogs(): void
|
protected function refreshLogs(): void
|
||||||
@@ -122,8 +124,8 @@ class DokployDeployments extends Page
|
|||||||
->toArray();
|
->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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,49 +15,38 @@ class DokployPlatformHealth extends Widget
|
|||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'applications' => $this->loadApplications(),
|
'composes' => $this->loadComposes(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function loadApplications(): array
|
protected function loadComposes(): array
|
||||||
{
|
{
|
||||||
$client = app(DokployClient::class);
|
$client = app(DokployClient::class);
|
||||||
$applicationMap = config('dokploy.applications', []);
|
$composeMap = config('dokploy.composes', []);
|
||||||
$results = [];
|
$results = [];
|
||||||
|
|
||||||
foreach ($applicationMap as $label => $applicationId) {
|
foreach ($composeMap as $label => $composeId) {
|
||||||
try {
|
try {
|
||||||
$status = $client->applicationStatus($applicationId);
|
$status = $client->composeStatus($composeId);
|
||||||
$deployments = $client->recentDeployments($applicationId, 1);
|
$deployments = $client->composeDeployments($composeId, 1);
|
||||||
$application = Arr::get($status, 'application', []);
|
$compose = Arr::get($status, 'compose', []);
|
||||||
$monitoring = Arr::get($status, 'monitoring', []);
|
$services = $this->formatServices(Arr::get($status, 'services', []));
|
||||||
|
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'label' => ucfirst($label),
|
'label' => ucfirst($label),
|
||||||
'application_id' => $applicationId,
|
'compose_id' => $composeId,
|
||||||
'app_name' => Arr::get($application, 'appName') ?? Arr::get($application, 'name'),
|
'name' => Arr::get($compose, 'name') ?? Arr::get($compose, 'appName') ?? $composeId,
|
||||||
'status' => Arr::get($application, 'applicationStatus', 'unknown'),
|
'status' => Arr::get($compose, 'composeStatus', 'unknown'),
|
||||||
'cpu' => $this->extractMetric($monitoring, [
|
'services' => $services,
|
||||||
'metrics.cpuPercent',
|
|
||||||
'metrics.cpu_percent',
|
|
||||||
'cpuPercent',
|
|
||||||
'cpu_percent',
|
|
||||||
]),
|
|
||||||
'memory' => $this->extractMetric($monitoring, [
|
|
||||||
'metrics.memoryPercent',
|
|
||||||
'metrics.memory_percent',
|
|
||||||
'memoryPercent',
|
|
||||||
'memory_percent',
|
|
||||||
]),
|
|
||||||
'last_deploy' => Arr::get($deployments, '0.createdAt')
|
'last_deploy' => Arr::get($deployments, '0.createdAt')
|
||||||
?? Arr::get($deployments, '0.created_at')
|
?? Arr::get($deployments, '0.created_at')
|
||||||
?? Arr::get($application, 'updatedAt')
|
?? Arr::get($compose, 'updatedAt')
|
||||||
?? Arr::get($application, 'lastDeploymentAt'),
|
?? Arr::get($compose, 'lastDeploymentAt'),
|
||||||
];
|
];
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'label' => ucfirst($label),
|
'label' => ucfirst($label),
|
||||||
'application_id' => $applicationId,
|
'compose_id' => $composeId,
|
||||||
'status' => 'unreachable',
|
'status' => 'unreachable',
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
];
|
];
|
||||||
@@ -68,9 +57,9 @@ class DokployPlatformHealth extends Widget
|
|||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
'label' => 'Dokploy',
|
'label' => 'Dokploy',
|
||||||
'application_id' => '-',
|
'compose_id' => '-',
|
||||||
'status' => 'unconfigured',
|
'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;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extractMetric(array $source, array $candidates): mixed
|
protected function formatServices(array $services): array
|
||||||
{
|
{
|
||||||
foreach ($candidates as $key) {
|
return collect($services)
|
||||||
if (Arr::has($source, $key)) {
|
->map(function ($service) {
|
||||||
return Arr::get($source, $key);
|
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'),
|
||||||
|
];
|
||||||
return null;
|
})
|
||||||
|
->filter(fn ($service) => filled($service['name']))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Mail\ContactConfirmation;
|
use App\Mail\ContactConfirmation;
|
||||||
use App\Models\BlogPost;
|
use App\Models\BlogPost;
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
|
use App\Models\Event;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
@@ -33,6 +33,8 @@ class MarketingController extends Controller
|
|||||||
{
|
{
|
||||||
use PresentsPackages;
|
use PresentsPackages;
|
||||||
|
|
||||||
|
private ?MarkdownConverter $markdownConverter = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutSessionService $checkoutSessions,
|
private readonly CheckoutSessionService $checkoutSessions,
|
||||||
private readonly PaddleCheckoutService $paddleCheckout,
|
private readonly PaddleCheckoutService $paddleCheckout,
|
||||||
@@ -292,11 +294,14 @@ class MarketingController extends Controller
|
|||||||
$posts = $query->orderBy('published_at', 'desc')
|
$posts = $query->orderBy('published_at', 'desc')
|
||||||
->paginate(4)
|
->paginate(4)
|
||||||
->through(function (BlogPost $post) use ($locale) {
|
->through(function (BlogPost $post) use ($locale) {
|
||||||
|
$excerpt = $post->getTranslation('excerpt', $locale) ?? $post->getTranslation('excerpt', 'de') ?? '';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'slug' => $post->slug,
|
'slug' => $post->slug,
|
||||||
'title' => $post->getTranslation('title', $locale) ?? $post->getTranslation('title', 'de') ?? '',
|
'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,
|
'featured_image' => $post->featured_image ?? $post->banner_url ?? null,
|
||||||
'published_at' => optional($post->published_at)->toDateString(),
|
'published_at' => optional($post->published_at)->toDateString(),
|
||||||
'author' => $post->author ? ['name' => $post->author->name] : null,
|
'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',
|
'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)
|
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
|
// Transform to array with translated strings for the current locale
|
||||||
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
|
$markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? '';
|
||||||
|
$excerpt = $postModel->getTranslation('excerpt', $locale) ?? $postModel->getTranslation('excerpt', 'de') ?? '';
|
||||||
$environment = new Environment;
|
$contentHtml = $this->convertMarkdownToHtml($markdown);
|
||||||
$environment->addExtension(new CommonMarkCoreExtension);
|
[$contentHtmlWithIds, $headings] = $this->decorateHeadings($contentHtml);
|
||||||
$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);
|
|
||||||
|
|
||||||
$post = [
|
$post = [
|
||||||
'id' => $postModel->id,
|
'id' => $postModel->id,
|
||||||
'title' => $postModel->getTranslation('title', $locale) ?? $postModel->getTranslation('title', 'de') ?? '',
|
'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' => $markdown,
|
||||||
'content_html' => $contentHtml,
|
'content_html' => $contentHtmlWithIds,
|
||||||
|
'headings' => $headings,
|
||||||
'featured_image' => $postModel->featured_image ?? $postModel->banner_url ?? null,
|
'featured_image' => $postModel->featured_image ?? $postModel->banner_url ?? null,
|
||||||
'published_at' => $postModel->published_at->toDateString(),
|
'published_at' => $postModel->published_at->toDateString(),
|
||||||
'slug' => $postModel->slug,
|
'slug' => $postModel->slug,
|
||||||
|
'url' => route('blog.show', ['locale' => $locale, 'slug' => $postModel->slug], absolute: true),
|
||||||
'author' => $postModel->author ? [
|
'author' => $postModel->author ? [
|
||||||
'name' => $postModel->author->name,
|
'name' => $postModel->author->name,
|
||||||
] : null,
|
] : null,
|
||||||
|
'previous_post' => $this->presentAdjacentPost($postModel, $locale, direction: 'previous'),
|
||||||
|
'next_post' => $this->presentAdjacentPost($postModel, $locale, direction: 'next'),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Inertia::render('marketing/BlogShow', compact('post'));
|
return Inertia::render('marketing/BlogShow', compact('post'));
|
||||||
@@ -481,4 +495,119 @@ class MarketingController extends Controller
|
|||||||
'requestedType' => $normalized,
|
'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),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class DokployClient
|
|||||||
throw new \RuntimeException('Dokploy application name is required to reload the service.');
|
throw new \RuntimeException('Dokploy application name is required to reload the service.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->dispatchAction(
|
$response = $this->dispatchAction(
|
||||||
$applicationId,
|
$applicationId,
|
||||||
'reload',
|
'reload',
|
||||||
[
|
[
|
||||||
@@ -74,11 +74,15 @@ class DokployClient
|
|||||||
fn (array $payload) => $this->post('/application.reload', $payload),
|
fn (array $payload) => $this->post('/application.reload', $payload),
|
||||||
$actor,
|
$actor,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->forgetApplicationCaches($applicationId);
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function redeployApplication(string $applicationId, ?Authenticatable $actor = null): array
|
public function redeployApplication(string $applicationId, ?Authenticatable $actor = null): array
|
||||||
{
|
{
|
||||||
return $this->dispatchAction(
|
$response = $this->dispatchAction(
|
||||||
$applicationId,
|
$applicationId,
|
||||||
'redeploy',
|
'redeploy',
|
||||||
[
|
[
|
||||||
@@ -87,6 +91,95 @@ class DokployClient
|
|||||||
fn (array $payload) => $this->post('/application.redeploy', $payload),
|
fn (array $payload) => $this->post('/application.redeploy', $payload),
|
||||||
$actor,
|
$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
|
protected function cached(string $key, callable $callback, int $seconds): mixed
|
||||||
@@ -167,7 +260,7 @@ class DokployClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected function dispatchAction(
|
protected function dispatchAction(
|
||||||
string $applicationId,
|
string $targetId,
|
||||||
string $action,
|
string $action,
|
||||||
array $payload,
|
array $payload,
|
||||||
callable $callback,
|
callable $callback,
|
||||||
@@ -178,22 +271,20 @@ class DokployClient
|
|||||||
$body = $response->json() ?? [];
|
$body = $response->json() ?? [];
|
||||||
$status = $response->status();
|
$status = $response->status();
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$this->logAction($applicationId, $action, $payload, [
|
$this->logAction($targetId, $action, $payload, [
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
], null, $actor);
|
], null, $actor);
|
||||||
|
|
||||||
throw $exception;
|
throw $exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->logAction($applicationId, $action, $payload, $body, $status, $actor);
|
$this->logAction($targetId, $action, $payload, $body, $status, $actor);
|
||||||
Cache::forget($this->applicationCacheKey($applicationId));
|
|
||||||
Cache::forget($this->deploymentCacheKey($applicationId));
|
|
||||||
|
|
||||||
return $body;
|
return $body;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function logAction(
|
protected function logAction(
|
||||||
string $applicationId,
|
string $targetId,
|
||||||
string $action,
|
string $action,
|
||||||
array $payload,
|
array $payload,
|
||||||
array $response,
|
array $response,
|
||||||
@@ -202,7 +293,7 @@ class DokployClient
|
|||||||
): void {
|
): void {
|
||||||
InfrastructureActionLog::create([
|
InfrastructureActionLog::create([
|
||||||
'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(),
|
'user_id' => $actor?->getAuthIdentifier() ?? auth()->id(),
|
||||||
'service_id' => $applicationId,
|
'service_id' => $targetId,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'payload' => $payload,
|
'payload' => $payload,
|
||||||
'response' => $response,
|
'response' => $response,
|
||||||
@@ -219,4 +310,26 @@ class DokployClient
|
|||||||
{
|
{
|
||||||
return "dokploy.deployments.{$applicationId}";
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ return [
|
|||||||
],
|
],
|
||||||
'web_url' => env('DOKPLOY_WEB_URL'),
|
'web_url' => env('DOKPLOY_WEB_URL'),
|
||||||
'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [],
|
'applications' => json_decode(env('DOKPLOY_APPLICATION_IDS', '{}'), true) ?? [],
|
||||||
|
'composes' => json_decode(env('DOKPLOY_COMPOSE_IDS', '{}'), true) ?? [],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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_BASE_URL=https://dokploy.example.com/api
|
||||||
DOKPLOY_API_KEY=pat_xxxxxxxxxxxxxxxxx
|
DOKPLOY_API_KEY=pat_xxxxxxxxxxxxxxxxx
|
||||||
DOKPLOY_WEB_URL=https://dokploy.example.com
|
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_API_TIMEOUT=10
|
||||||
```
|
```
|
||||||
|
|
||||||
- `DOKPLOY_APPLICATION_IDS` is a JSON object mapping human labels to Dokploy `applicationId` values. Those IDs drive the SuperAdmin widget buttons.
|
- `DOKPLOY_COMPOSE_IDS` ist eine JSON-Map Label → `composeId` (siehe Compose-Detailseite in Dokploy). Diese IDs steuern Widget & Buttons.
|
||||||
- The API key needs permission to read the project, query deployments, and trigger `application.redeploy` / `application.reload`.
|
- 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
|
## 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`.
|
- Optionally create a dedicated container for Horizon using `docs/queue-supervisor/horizon.sh`.
|
||||||
|
|
||||||
4. **vsftpd + Photobooth control**
|
4. **vsftpd + Photobooth control**
|
||||||
- Deploy the ftp image (see `docker-compose` setup) or reuse Dokploy’s Docker Compose support.
|
- Nutze deinen bestehenden Docker-Compose-Stack (z. B. `docker-compose.dokploy.yml`) oder dedizierte Compose-Applikationen.
|
||||||
- Mount `photobooth` volume read-write.
|
- Mount `photobooth` volume read-write.
|
||||||
|
|
||||||
5. **Database/Redis**
|
5. **Database/Redis**
|
||||||
@@ -89,17 +90,16 @@ Follow these steps for each component:
|
|||||||
|
|
||||||
## 5. SuperAdmin observability (Dokploy API)
|
## 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.
|
1. **Config file** – `config/dokploy.php` liest `DOKPLOY_COMPOSE_IDS`.
|
||||||
2. **Client** – `App\Services\Dokploy\DokployClient` wraps key endpoints:
|
2. **Client** – `App\Services\Dokploy\DokployClient` kapselt:
|
||||||
- `GET /application.one?applicationId=...` → status + metadata.
|
- `GET /compose.one?composeId=...` für Meta- und Statusinfos (deploying/error/done).
|
||||||
- `GET /application.readAppMonitoring?appName=...` → CPU & memory metrics.
|
- `GET /compose.loadServices?composeId=...` für die einzelnen Services innerhalb des Stacks.
|
||||||
- `GET /deployment.all?applicationId=...` → latest deployments for history.
|
- `GET /deployment.allByCompose?composeId=...` für die Deploy-Historie.
|
||||||
- `POST /application.reload` (requires `applicationId` + `appName`).
|
- `POST /compose.redeploy`, `POST /compose.deploy`, `POST /compose.stop` (Buttons im UI).
|
||||||
- `POST /application.redeploy` (redeploy latest commit).
|
3. **Widgets / Pages** – `DokployPlatformHealth` zeigt jeden Compose-Stack inkl. Services; die `DokployDeployments`-Seite bietet Redeploy/Stop + Audit-Log (`InfrastructureActionLog`).
|
||||||
3. **Widgets / pages** – `DokployPlatformHealth` widget displays the mapped applications, and the `DokployDeployments` page exposes reload/redeploy buttons plus a log table (`InfrastructureActionLog`).
|
4. **Auditing** – jede Aktion wird mit User, Payload, Response & HTTP-Code in `infrastructure_action_logs` festgehalten.
|
||||||
4. **Auditing** – all actions persist to `infrastructure_action_logs` with user, payload, response, and status code.
|
|
||||||
|
|
||||||
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.
|
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
|
## 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.
|
2. `photobooth` volume mounted for Laravel + vsftpd + control service.
|
||||||
3. Database/Redis backups scheduled (Dokploy snapshot or external tooling).
|
3. Database/Redis backups scheduled (Dokploy snapshot or external tooling).
|
||||||
4. `.env` contains the Dokploy API credentials and application ID mapping.
|
4. `.env` enthält die Dokploy-API-Credentials und `DOKPLOY_COMPOSE_IDS`.
|
||||||
5. Scheduler, workers, and Horizon logging visible in Dokploy.
|
5. Scheduler, Worker, Horizon werden im Compose-Stack überwacht.
|
||||||
6. SuperAdmin widgets show green health states and allow reload/redeploy actions.
|
6. SuperAdmin-Widget zeigt die Compose-Stacks und erlaubt Redeploy/Stop.
|
||||||
7. Webhooks/alerts configured for failed deployments or unhealthy containers.
|
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 Laravel’s scheduler and workers continue to run within the same infrastructure.
|
With this setup the Fotospiel team can manage deployments, restarts, and metrics centrally through Dokploy while Laravel’s scheduler and workers continue to run within the same infrastructure.
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
{
|
{
|
||||||
"title_suffix": " - Fotospiel Blog",
|
"title_suffix": " - Fotospiel Blog",
|
||||||
"by_author": "Von",
|
"by_author": "Von",
|
||||||
"team": "Team",
|
"team": "Fotospiel Team",
|
||||||
"published_on": "Veröffentlicht am",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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",
|
"cta": "Paket wählen",
|
||||||
"weddings": {
|
"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.",
|
"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",
|
"benefits_title": "Vorteile für Hochzeiten",
|
||||||
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
|
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
|
||||||
@@ -373,6 +373,9 @@
|
|||||||
"privacy": "Datenschutz",
|
"privacy": "Datenschutz",
|
||||||
"impressum": "Impressum",
|
"impressum": "Impressum",
|
||||||
"occasions_types": {
|
"occasions_types": {
|
||||||
|
"weddings": "Hochzeiten",
|
||||||
|
"birthdays": "Geburtstage",
|
||||||
|
"corporate": "Firmenevents",
|
||||||
"confirmation": "Konfirmation & Jugendweihe"
|
"confirmation": "Konfirmation & Jugendweihe"
|
||||||
},
|
},
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
@@ -608,7 +611,7 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"title": "So funktioniert die Fotospiel App",
|
"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.",
|
"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",
|
"secondaryCta": "Kontakt aufnehmen",
|
||||||
"stats": [
|
"stats": [
|
||||||
{
|
{
|
||||||
@@ -870,6 +873,14 @@
|
|||||||
"description": "Unser Team hilft dir bei der Einrichtung oder plant mit dir ein Pilot-Event.",
|
"description": "Unser Team hilft dir bei der Einrichtung oder plant mit dir ein Pilot-Event.",
|
||||||
"cta": "Kontakt aufnehmen"
|
"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"
|
"timeline_title": "Der Ablauf im Detail"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
{
|
{
|
||||||
"title_suffix": " - Fotospiel Blog",
|
"title_suffix": " - Fotospiel Blog",
|
||||||
"by_author": "By",
|
"by_author": "By",
|
||||||
"team": "Team",
|
"team": "Fotospiel Team",
|
||||||
"published_on": "Published on",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -359,6 +359,9 @@
|
|||||||
"privacy": "Privacy",
|
"privacy": "Privacy",
|
||||||
"impressum": "Imprint",
|
"impressum": "Imprint",
|
||||||
"occasions_types": {
|
"occasions_types": {
|
||||||
|
"weddings": "Weddings",
|
||||||
|
"birthdays": "Birthdays",
|
||||||
|
"corporate": "Corporate Events",
|
||||||
"confirmation": "Confirmations"
|
"confirmation": "Confirmations"
|
||||||
},
|
},
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@@ -602,7 +605,7 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"title": "How the Fotospiel App Works",
|
"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.",
|
"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",
|
"secondaryCta": "Talk to our team",
|
||||||
"stats": [
|
"stats": [
|
||||||
{
|
{
|
||||||
@@ -864,6 +867,14 @@
|
|||||||
"description": "Our team is happy to set up a pilot event or walk you through the dashboard.",
|
"description": "Our team is happy to set up a pilot event or walk you through the dashboard.",
|
||||||
"cta": "Contact us"
|
"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"
|
"timeline_title": "The detailed flow"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface PostSummary {
|
|||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
|
excerpt_html?: string;
|
||||||
featured_image?: string;
|
featured_image?: string;
|
||||||
published_at?: string;
|
published_at?: string;
|
||||||
author?: { name?: string } | 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 Blog: React.FC<Props> = ({ posts }) => {
|
||||||
const { localizedPath } = useLocalizedRoutes();
|
const { localizedPath } = useLocalizedRoutes();
|
||||||
const { props } = usePage<{ supportedLocales?: string[] }>();
|
const { props } = usePage<{ supportedLocales?: string[] }>();
|
||||||
@@ -118,6 +131,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
|||||||
<div className="flex flex-wrap justify-center gap-2">
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
{posts.links.map((link, index) => {
|
{posts.links.map((link, index) => {
|
||||||
const href = resolvePaginationHref(link.url);
|
const href = resolvePaginationHref(link.url);
|
||||||
|
const labelText = link.label?.trim() || '…';
|
||||||
|
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return (
|
return (
|
||||||
@@ -126,8 +140,9 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
|||||||
variant={link.active ? 'default' : 'outline'}
|
variant={link.active ? 'default' : 'outline'}
|
||||||
disabled
|
disabled
|
||||||
className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
|
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'}
|
variant={link.active ? 'default' : 'outline'}
|
||||||
className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
|
className={link.active ? 'bg-[#FFB6C1] hover:bg-[#FF69B4]' : ''}
|
||||||
>
|
>
|
||||||
<Link href={href} dangerouslySetInnerHTML={{ __html: link.label }} />
|
<Link href={href} aria-label={labelText}>
|
||||||
|
{labelText}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -182,9 +199,11 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
|||||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-gray-50">
|
||||||
{featuredPost.title || 'Untitled'}
|
{featuredPost.title || 'Untitled'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">
|
<MarkdownPreview
|
||||||
{featuredPost.excerpt || ''}
|
html={featuredPost.excerpt_html}
|
||||||
</p>
|
fallback={featuredPost.excerpt}
|
||||||
|
className="text-lg leading-relaxed text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
{renderPostMeta(featuredPost)}
|
{renderPostMeta(featuredPost)}
|
||||||
<Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white">
|
<Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white">
|
||||||
<Link href={buildArticleHref(featuredPost.slug)}>
|
<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">
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
{post.title || 'Untitled'}
|
{post.title || 'Untitled'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
|
<MarkdownPreview
|
||||||
{post.excerpt || ''}
|
html={post.excerpt_html}
|
||||||
</p>
|
fallback={post.excerpt}
|
||||||
|
className="text-sm leading-relaxed text-gray-600 dark:text-gray-300"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{renderPostMeta(post)}
|
{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">
|
<h3 className="text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||||
{post.title || 'Untitled'}
|
{post.title || 'Untitled'}
|
||||||
</h3>
|
</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)}
|
{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">
|
<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)}>
|
<Link href={buildArticleHref(post.slug)}>
|
||||||
@@ -283,18 +308,18 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
|||||||
<MarketingLayout title={t('blog.title')}>
|
<MarketingLayout title={t('blog.title')}>
|
||||||
<Head 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">
|
<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-4xl space-y-6 text-center">
|
<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">
|
<Badge variant="outline" className="border-pink-200 text-pink-600 dark:border-pink-500/50 dark:text-pink-200">
|
||||||
Fotospiel Blog
|
Fotospiel Blog
|
||||||
</Badge>
|
</Badge>
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-50 md:text-5xl">{t('blog.hero_title')}</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-50 md:text-4xl">{t('blog.hero_title')}</h1>
|
||||||
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">{t('blog.hero_description')}</p>
|
<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-3">
|
<div className="flex flex-wrap justify-center gap-2.5">
|
||||||
<Button asChild size="lg" className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white">
|
<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>
|
<Link href={localizedPath('/packages')}>{t('home.cta_explore')}</Link>
|
||||||
</Button>
|
</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>
|
<Link href="#articles">{t('blog.hero_cta')}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,112 +1,336 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link } from '@inertiajs/react';
|
||||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarketingLayout from '@/layouts/mainWebsite';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
interface Props {
|
||||||
post: {
|
post: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
|
excerpt_html?: string;
|
||||||
content: string;
|
content: string;
|
||||||
content_html: string;
|
content_html: string;
|
||||||
|
headings?: HeadingItem[];
|
||||||
featured_image?: string;
|
featured_image?: string;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
author?: { name: string };
|
author?: { name: string };
|
||||||
slug: 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 BlogShow: React.FC<Props> = ({ post }) => {
|
||||||
const { localizedPath } = useLocalizedRoutes();
|
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 (
|
return (
|
||||||
<MarketingLayout title={`${post.title} ${t('title_suffix')}`}>
|
<MarketingLayout title={`${post.title} ${t('title_suffix')}`}>
|
||||||
<Head title={`${post.title} ${t('title_suffix')}`} />
|
<Head title={`${post.title} ${t('title_suffix')}`} />
|
||||||
|
|
||||||
{/* Hero Section */}
|
<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">
|
||||||
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] py-20 px-4">
|
<div className="container mx-auto max-w-5xl space-y-5">
|
||||||
<div className="container mx-auto max-w-4xl">
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<Card className="bg-white/10 backdrop-blur-sm border-white/20 text-white shadow-xl">
|
<nav className="flex flex-wrap items-center gap-2">
|
||||||
<CardContent className="p-8 text-center">
|
<Link href={localizedPath('/')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 leading-tight">{post.title}</h1>
|
{t('breadcrumb_home')}
|
||||||
|
</Link>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8 text-lg">
|
<span>/</span>
|
||||||
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
|
<Link href={localizedPath('/blog')} className="hover:text-gray-900 dark:hover:text-gray-100">
|
||||||
{t('by_author')} {post.author?.name || t('team')}
|
{t('breadcrumb_blog')}
|
||||||
</Badge>
|
</Link>
|
||||||
<Separator orientation="vertical" className="hidden sm:block h-6 bg-white/30" />
|
<span>/</span>
|
||||||
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
|
<span className="text-gray-700 dark:text-gray-200">{post.title}</span>
|
||||||
{t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE', {
|
</nav>
|
||||||
day: 'numeric',
|
<Link href={localizedPath('/blog')} className="text-pink-600 hover:text-pink-700">
|
||||||
month: 'long',
|
{t('back_to_blog')}
|
||||||
year: 'numeric'
|
</Link>
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{post.featured_image && (
|
<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">
|
||||||
<div className="mt-8">
|
<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
|
<img
|
||||||
src={post.featured_image}
|
src={post.featured_image}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
className="mx-auto rounded-lg shadow-lg max-w-2xl w-full object-cover"
|
className="h-full w-full rounded-2xl object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Post Content */}
|
<section className="bg-white px-4 py-12 dark:bg-gray-950">
|
||||||
<section className="py-20 px-4 bg-white">
|
<div className="container mx-auto max-w-6xl gap-10 lg:grid lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<div className="container mx-auto max-w-4xl">
|
<article className="rounded-[32px] border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardContent className="p-8 md:p-12">
|
|
||||||
<div
|
<div
|
||||||
className="prose prose-lg prose-slate max-w-none
|
className="prose prose-lg prose-slate max-w-none
|
||||||
prose-headings:text-slate-900 prose-headings:font-semibold
|
prose-headings:text-slate-900 prose-headings:font-semibold
|
||||||
prose-p:text-slate-700 prose-p:leading-relaxed
|
prose-p:text-slate-700 prose-p:leading-relaxed
|
||||||
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
|
prose-a:text-pink-600 prose-a:no-underline hover:prose-a:underline
|
||||||
prose-strong:text-slate-900 prose-strong:font-semibold
|
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-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-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-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-ul:text-slate-700 prose-ol:text-slate-700
|
||||||
prose-li:text-slate-700"
|
prose-li:text-slate-700 dark:prose-invert"
|
||||||
dangerouslySetInnerHTML={{ __html: post.content_html }}
|
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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Back to Blog */}
|
{(post.previous_post || post.next_post) && (
|
||||||
<section className="py-10 px-4 bg-gray-50">
|
<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-4xl">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<Card className="shadow-sm">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<CardContent className="p-8 text-center">
|
{post.previous_post && (
|
||||||
<Separator className="mb-6" />
|
<Card className="border-none bg-gradient-to-br from-pink-500 to-pink-400 text-white shadow-lg">
|
||||||
<Button
|
<CardContent className="space-y-4 p-6">
|
||||||
asChild
|
<p className="text-sm font-semibold uppercase tracking-wide text-white/80">
|
||||||
size="lg"
|
{t('previous_post')}
|
||||||
className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-8 py-3 rounded-full font-semibold transition-colors"
|
</p>
|
||||||
>
|
<h3 className="text-2xl font-semibold">{post.previous_post.title}</h3>
|
||||||
<Link href={localizedPath('/blog')}>
|
<MarkdownPreview
|
||||||
{t('back_to_blog')}
|
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>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</MarketingLayout>
|
</MarketingLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const HowItWorks: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
|
<Button asChild size="lg" className="bg-pink-500 hover:bg-pink-600">
|
||||||
<Link href={localizedPath('/register')}>
|
<Link href={localizedPath('/packages')}>
|
||||||
{hero.primaryCta}
|
{hero.primaryCta}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -192,7 +192,7 @@ const HowItWorks: React.FC = () => {
|
|||||||
{t('how_it_works_page.timeline_title', 'Der Ablauf im Detail')}
|
{t('how_it_works_page.timeline_title', 'Der Ablauf im Detail')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className="mt-3 text-3xl font-bold text-gray-900 dark:text-gray-50">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
@@ -211,7 +211,7 @@ const HowItWorks: React.FC = () => {
|
|||||||
{item.tips?.length ? (
|
{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">
|
<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">
|
<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>
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{item.tips.map((tip) => (
|
{item.tips.map((tip) => (
|
||||||
@@ -271,7 +271,7 @@ const HowItWorks: React.FC = () => {
|
|||||||
<CardContent className="grid gap-6 md:grid-cols-2">
|
<CardContent className="grid gap-6 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
|
<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>
|
</p>
|
||||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
{tab.recommendations.map((item) => (
|
{tab.recommendations.map((item) => (
|
||||||
@@ -284,7 +284,7 @@ const HowItWorks: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-pink-600 dark:text-pink-300">
|
<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>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tab.ideas.map((idea) => (
|
{tab.ideas.map((idea) => (
|
||||||
@@ -308,7 +308,7 @@ const HowItWorks: React.FC = () => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{checklist.title}</CardTitle>
|
<CardTitle>{checklist.title}</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.",
|
"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",
|
"cta": "Paket wählen",
|
||||||
"weddings": {
|
"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.",
|
"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",
|
"benefits_title": "Vorteile für Hochzeiten",
|
||||||
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
|
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
|
||||||
@@ -221,13 +221,38 @@
|
|||||||
"title_suffix": " - Fotospiel Blog",
|
"title_suffix": " - Fotospiel Blog",
|
||||||
"by_author": "Von",
|
"by_author": "Von",
|
||||||
"published_on": "Veröffentlicht am",
|
"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": {
|
"nav": {
|
||||||
"home": "Startseite",
|
"home": "Startseite",
|
||||||
"how_it_works": "So funktioniert es",
|
"how_it_works": "So funktioniert es",
|
||||||
"features": "Features",
|
"features": "Features",
|
||||||
"occasions": "Anlässe",
|
"occasions": "Anlässe",
|
||||||
|
"occasions_types": {
|
||||||
|
"weddings": "Hochzeiten",
|
||||||
|
"birthdays": "Geburtstage",
|
||||||
|
"corporate": "Firmenevents",
|
||||||
|
"confirmation": "Konfirmation & Jugendweihe"
|
||||||
|
},
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"packages": "Pakete",
|
"packages": "Pakete",
|
||||||
"contact": "Kontakt",
|
"contact": "Kontakt",
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ return [
|
|||||||
'read_more' => 'Lesen',
|
'read_more' => 'Lesen',
|
||||||
'back' => 'Zurück zum Blog',
|
'back' => 'Zurück zum Blog',
|
||||||
'empty' => 'Noch keine Posts verfügbar. Bleib dran!',
|
'empty' => 'Noch keine Posts verfügbar. Bleib dran!',
|
||||||
|
'pagination' => [
|
||||||
|
'previous' => 'Zurück',
|
||||||
|
'next' => 'Weiter',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'occasions' => [
|
'occasions' => [
|
||||||
'title' => 'Fotospiel für :type',
|
'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.',
|
'hero_description' => 'Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für :type – einfach, mobil und datenschutzkonform.',
|
||||||
'cta' => 'Package wählen',
|
'cta' => 'Package wählen',
|
||||||
'weddings' => [
|
'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.',
|
'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',
|
'benefits_title' => 'Vorteile für Hochzeiten',
|
||||||
'benefit1' => 'QR-Code für Gäste: Einfaches Teilen ohne App-Download.',
|
'benefit1' => 'QR-Code für Gäste: Einfaches Teilen ohne App-Download.',
|
||||||
|
|||||||
18
resources/lang/en/how_it_works.json
Normal file
18
resources/lang/en/how_it_works.json
Normal 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": [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
"hero_description": "Collect unforgettable photos from your guests with QR-Codes. Perfect for :type – simple, mobile and privacy-compliant.",
|
"hero_description": "Collect unforgettable photos from your guests with QR-Codes. Perfect for :type – simple, mobile and privacy-compliant.",
|
||||||
"cta": "Choose Package",
|
"cta": "Choose Package",
|
||||||
"weddings": {
|
"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.",
|
"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",
|
"benefits_title": "Benefits for Weddings",
|
||||||
"benefit1": "QR-Code for Guests: Easy sharing without app download.",
|
"benefit1": "QR-Code for Guests: Easy sharing without app download.",
|
||||||
@@ -221,13 +221,38 @@
|
|||||||
"title_suffix": " - Fotospiel Blog",
|
"title_suffix": " - Fotospiel Blog",
|
||||||
"by_author": "By",
|
"by_author": "By",
|
||||||
"published_on": "Published on",
|
"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": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"how_it_works": "How it works",
|
"how_it_works": "How it works",
|
||||||
"features": "Features",
|
"features": "Features",
|
||||||
"occasions": "Occasions",
|
"occasions": "Occasions",
|
||||||
|
"occasions_types": {
|
||||||
|
"weddings": "Weddings",
|
||||||
|
"birthdays": "Birthdays",
|
||||||
|
"corporate": "Corporate Events",
|
||||||
|
"confirmation": "Confirmations"
|
||||||
|
},
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"packages": "Packages",
|
"packages": "Packages",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ return [
|
|||||||
'read_more' => 'Read',
|
'read_more' => 'Read',
|
||||||
'back' => 'Back to Blog',
|
'back' => 'Back to Blog',
|
||||||
'empty' => 'No posts available yet. Stay tuned!',
|
'empty' => 'No posts available yet. Stay tuned!',
|
||||||
|
'pagination' => [
|
||||||
|
'previous' => 'Previous',
|
||||||
|
'next' => 'Next',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'occasions' => [
|
'occasions' => [
|
||||||
'title' => 'Fotospiel for :type',
|
'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.',
|
'hero_description' => 'Collect unforgettable photos from your guests with QR-Codes. Perfect for :type – simple, mobile and privacy-compliant.',
|
||||||
'cta' => 'Choose Package',
|
'cta' => 'Choose Package',
|
||||||
'weddings' => [
|
'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.',
|
'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',
|
'benefits_title' => 'Benefits for Weddings',
|
||||||
'benefit1' => 'QR-Code for Guests: Easy sharing without app download.',
|
'benefit1' => 'QR-Code for Guests: Easy sharing without app download.',
|
||||||
|
|||||||
43
resources/lang/en/marketing_new.json
Normal file
43
resources/lang/en/marketing_new.json
Normal 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": "We’ll 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." }
|
||||||
|
}
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
<div class="space-y-6">
|
<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">
|
<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="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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200">{{ $application['label'] }}</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">{{ $application['application_id'] }}</p>
|
<p class="text-xs text-slate-500 dark:text-slate-400">{{ $compose['compose_id'] }}</p>
|
||||||
</div>
|
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<x-filament::button size="sm" color="warning" wire:click="reload('{{ $application['application_id'] }}')">
|
<x-filament::button size="sm" color="warning" wire:click="redeploy('{{ $compose['compose_id'] }}')">
|
||||||
Reload
|
|
||||||
</x-filament::button>
|
|
||||||
<x-filament::button size="sm" color="gray" wire:click="redeploy('{{ $application['application_id'] }}')">
|
|
||||||
Redeploy
|
Redeploy
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
|
<x-filament::button size="sm" color="danger" wire:click="stop('{{ $compose['compose_id'] }}')">
|
||||||
|
Stop
|
||||||
|
</x-filament::button>
|
||||||
@if($dokployWebUrl)
|
@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
|
Open in Dokploy
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<tr class="text-left text-xs uppercase tracking-wide text-slate-500">
|
<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">When</th>
|
||||||
<th class="px-3 py-2">User</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">Action</th>
|
||||||
<th class="px-3 py-2">Status</th>
|
<th class="px-3 py-2">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,52 +1,54 @@
|
|||||||
<x-filament-widgets::widget>
|
<x-filament-widgets::widget>
|
||||||
<x-filament::section heading="Infra Status (Dokploy)">
|
<x-filament::section heading="Infra Status (Dokploy)">
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<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="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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ $application['label'] }}</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">{{ $application['app_name'] ?? $application['application_id'] }}</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">{{ $application['application_id'] }}</p>
|
<p class="text-[11px] text-slate-400 dark:text-slate-500">{{ $compose['compose_id'] }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span @class([
|
<span @class([
|
||||||
'rounded-full px-3 py-1 text-xs font-semibold',
|
'rounded-full px-3 py-1 text-xs font-semibold',
|
||||||
'bg-emerald-100 text-emerald-800' => $application['status'] === 'running',
|
'bg-emerald-100 text-emerald-800' => $compose['status'] === 'done',
|
||||||
'bg-amber-100 text-amber-800' => in_array($application['status'], ['deploying', 'idle']),
|
'bg-amber-100 text-amber-800' => in_array($compose['status'], ['deploying', 'pending']),
|
||||||
'bg-rose-100 text-rose-800' => in_array($application['status'], ['unreachable', 'error']),
|
'bg-rose-100 text-rose-800' => in_array($compose['status'], ['unreachable', 'error', 'failed']),
|
||||||
'bg-slate-100 text-slate-600' => ! in_array($application['status'], ['running', 'deploying', 'idle', 'unreachable', 'error']),
|
'bg-slate-100 text-slate-600' => ! in_array($compose['status'], ['done', 'deploying', 'pending', 'unreachable', 'error', 'failed']),
|
||||||
])>
|
])>
|
||||||
{{ ucfirst($application['status']) }}
|
{{ ucfirst($compose['status']) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(isset($application['error']))
|
@if(isset($compose['error']))
|
||||||
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $application['error'] }}</p>
|
<p class="mt-3 text-xs text-rose-600 dark:text-rose-400">{{ $compose['error'] }}</p>
|
||||||
@else
|
@else
|
||||||
<dl class="mt-3 grid grid-cols-3 gap-2 text-xs">
|
<div class="mt-3 space-y-1">
|
||||||
<div>
|
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400">Services</p>
|
||||||
<dt class="text-slate-500 dark:text-slate-400">CPU</dt>
|
@forelse($compose['services'] as $service)
|
||||||
<dd class="font-semibold text-slate-900 dark:text-white">
|
<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">
|
||||||
{{ isset($application['cpu']) ? $application['cpu'].'%' : '—' }}
|
<span>{{ $service['name'] }}</span>
|
||||||
</dd>
|
<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>
|
</div>
|
||||||
<div>
|
@empty
|
||||||
<dt class="text-slate-500 dark:text-slate-400">Memory</dt>
|
<p class="text-xs text-slate-500 dark:text-slate-400">No services reported.</p>
|
||||||
<dd class="font-semibold text-slate-900 dark:text-white">
|
@endforelse
|
||||||
{{ isset($application['memory']) ? $application['memory'].'%' : '—' }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<dt class="text-slate-500 dark:text-slate-400">Last Deploy</dt>
|
Last deploy: {{ $compose['last_deploy'] ? \Illuminate\Support\Carbon::parse($compose['last_deploy'])->diffForHumans() : '—' }}
|
||||||
<dd class="font-semibold text-slate-900 dark:text-white">
|
</p>
|
||||||
{{ $application['last_deploy'] ? \Illuminate\Support\Carbon::parse($application['last_deploy'])->diffForHumans() : '—' }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@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
|
@endforelse
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</svg>
|
||||||
</div>
|
</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">
|
<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()]) }}#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>
|
<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">
|
<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>
|
<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">
|
<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($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('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($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('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['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>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ route('blog', ['locale' => app()->getLocale()]) }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.blog') }}</a>
|
<a href="{{ route('blog', ['locale' => app()->getLocale()]) }}" class="text-gray-600 hover:text-gray-900">{{ __('marketing.nav.blog') }}</a>
|
||||||
|
|||||||
66
tests/Feature/Marketing/BlogIndexTest.php
Normal file
66
tests/Feature/Marketing/BlogIndexTest.php
Normal 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'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
tests/Feature/Marketing/BlogShowTest.php
Normal file
101
tests/Feature/Marketing/BlogShowTest.php
Normal 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
7
translate.py
Normal 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
19
translator.py
Normal 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)}))
|
||||||
Reference in New Issue
Block a user