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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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