Add account hierarchy framework with migrations, RLS policies, and UI components

This commit is contained in:
T. Zehetbauer
2026-03-31 22:18:04 +02:00
parent 7e7da0b465
commit 59546ad6d2
262 changed files with 11671 additions and 3927 deletions

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreatePageSchema } from '../schema/site.schema';
import { createPage } from '../server/actions/site-builder-actions';
@@ -35,7 +45,10 @@ export function CreatePageForm({ accountId, account }: Props) {
.toLowerCase()
.replace(/[^a-z0-9äöüß\s-]+/g, '')
.replace(/\s+/g, '-')
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/^-|-$/g, '');
const { execute, isPending } = useAction(createPage, {
@@ -45,50 +58,102 @@ export function CreatePageForm({ accountId, account }: Props) {
router.push(`/home/${account}/site-builder/${data.data.id}/edit`);
}
},
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler beim Erstellen'),
onError: ({ error }) =>
toast.error(error.serverError ?? 'Fehler beim Erstellen'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-xl">
<form
onSubmit={form.handleSubmit((data) =>
execute({ ...data, slug: data.slug || autoSlug }),
)}
className="max-w-xl space-y-6"
>
<Card>
<CardHeader><CardTitle>Neue Seite erstellen</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Neue Seite erstellen</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem>
<FormLabel>Seitentitel *</FormLabel>
<FormControl><Input placeholder="z.B. Startseite, Über uns, Kontakt" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem>
<FormLabel>URL-Pfad</FormLabel>
<FormControl><Input placeholder={autoSlug || 'wird-automatisch-generiert'} {...field} /></FormControl>
<p className="text-xs text-muted-foreground">Leer lassen für automatische Generierung aus dem Titel</p>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="metaDescription" render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung (SEO)</FormLabel>
<FormControl><Input placeholder="Kurze Beschreibung für Suchmaschinen" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="isHomepage" render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl>
<input type="checkbox" checked={field.value} onChange={field.onChange} className="h-4 w-4" />
</FormControl>
<FormLabel className="!mt-0">Als Startseite festlegen</FormLabel>
</FormItem>
)} />
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Seitentitel *</FormLabel>
<FormControl>
<Input
placeholder="z.B. Startseite, Über uns, Kontakt"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>URL-Pfad</FormLabel>
<FormControl>
<Input
placeholder={autoSlug || 'wird-automatisch-generiert'}
{...field}
/>
</FormControl>
<p className="text-muted-foreground text-xs">
Leer lassen für automatische Generierung aus dem Titel
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metaDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung (SEO)</FormLabel>
<FormControl>
<Input
placeholder="Kurze Beschreibung für Suchmaschinen"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isHomepage"
render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<FormLabel className="!mt-0">
Als Startseite festlegen
</FormLabel>
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Seite erstellen & Editor öffnen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Seite erstellen & Editor öffnen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { CreatePostSchema } from '../schema/site.schema';
import { createPost } from '../server/actions/site-builder-actions';
@@ -34,47 +44,127 @@ export function CreatePostForm({ accountId, account }: Props) {
// Auto-generate slug from title
const watchTitle = form.watch('title');
const autoSlug = watchTitle.toLowerCase().replace(/[^a-z0-9äöüß]+/g, '-').replace(/^-|-$/g, '').replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss');
const autoSlug = watchTitle
.toLowerCase()
.replace(/[^a-z0-9äöüß]+/g, '-')
.replace(/^-|-$/g, '')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss');
const { execute, isPending } = useAction(createPost, {
onSuccess: () => { toast.success('Beitrag erstellt'); router.push(`/home/${account}/site-builder/posts`); },
onSuccess: () => {
toast.success('Beitrag erstellt');
router.push(`/home/${account}/site-builder/posts`);
},
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute({ ...data, slug: data.slug || autoSlug }))} className="space-y-6 max-w-3xl">
<form
onSubmit={form.handleSubmit((data) =>
execute({ ...data, slug: data.slug || autoSlug }),
)}
className="max-w-3xl space-y-6"
>
<Card>
<CardHeader><CardTitle>Beitrag</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Beitrag</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="title" render={({ field }) => (
<FormItem><FormLabel>Titel *</FormLabel><FormControl><Input placeholder="Vereinsnachrichten..." {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem><FormLabel>URL-Slug</FormLabel><FormControl><Input placeholder={autoSlug || 'wird-automatisch-generiert'} {...field} /></FormControl>
<p className="text-xs text-muted-foreground">Leer lassen für automatische Generierung</p><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="excerpt" render={({ field }) => (
<FormItem><FormLabel>Kurzfassung</FormLabel><FormControl><Input placeholder="Kurze Zusammenfassung..." {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="content" render={({ field }) => (
<FormItem><FormLabel>Inhalt</FormLabel><FormControl>
<textarea {...field} rows={12} className="w-full rounded-md border px-3 py-2 text-sm font-mono" placeholder="Beitragsinhalt (HTML erlaubt)..." />
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem><FormLabel>Status</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="draft">Entwurf</option>
<option value="published">Veröffentlicht</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Titel *</FormLabel>
<FormControl>
<Input placeholder="Vereinsnachrichten..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>URL-Slug</FormLabel>
<FormControl>
<Input
placeholder={autoSlug || 'wird-automatisch-generiert'}
{...field}
/>
</FormControl>
<p className="text-muted-foreground text-xs">
Leer lassen für automatische Generierung
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="excerpt"
render={({ field }) => (
<FormItem>
<FormLabel>Kurzfassung</FormLabel>
<FormControl>
<Input placeholder="Kurze Zusammenfassung..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel>Inhalt</FormLabel>
<FormControl>
<textarea
{...field}
rows={12}
className="w-full rounded-md border px-3 py-2 font-mono text-sm"
placeholder="Beitragsinhalt (HTML erlaubt)..."
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="draft">Entwurf</option>
<option value="published">Veröffentlicht</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Beitrag erstellen'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird erstellt...' : 'Beitrag erstellen'}
</Button>
</div>
</form>
</Form>

View File

@@ -1,25 +1,47 @@
'use client';
import { useState } from 'react';
import { createClient } from '@supabase/supabase-js';
import { useRouter } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js';
import { createClient } from '@supabase/supabase-js';
import { Shield, LogIn, AlertCircle } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
import { Separator } from '@kit/ui/separator';
import { toast } from '@kit/ui/sonner';
import { Shield, LogIn, AlertCircle } from 'lucide-react';
const OAUTH_PROVIDERS: { id: Provider; label: string }[] = [
{ id: 'google', label: 'Google' },
{ id: 'apple', label: 'Apple' },
{ id: 'azure', label: 'Microsoft' },
{ id: 'github', label: 'GitHub' },
];
interface Props {
slug: string;
accountName: string;
}
function getSupabaseClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
);
}
export function PortalLoginForm({ slug, accountName }: Props) {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
const [error, setError] = useState('');
const handleLogin = async (e: React.FormEvent) => {
@@ -28,18 +50,19 @@ export function PortalLoginForm({ slug, accountName }: Props) {
setError('');
try {
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY!,
const supabase = getSupabaseClient();
const { data, error: authError } = await supabase.auth.signInWithPassword(
{
email,
password,
},
);
const { data, error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) {
setError('Ungültige Anmeldedaten. Bitte überprüfen Sie E-Mail und Passwort.');
setError(
'Ungültige Anmeldedaten. Bitte überprüfen Sie E-Mail und Passwort.',
);
setLoading(false);
return;
}
@@ -56,19 +79,42 @@ export function PortalLoginForm({ slug, accountName }: Props) {
}
};
const handleOAuthLogin = async (provider: Provider) => {
setOauthLoading(provider);
setError('');
try {
const supabase = getSupabaseClient();
const redirectTo = `${window.location.origin}/club/${slug}/portal`;
const { error: oauthError } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo },
});
if (oauthError) {
setError(`Anmeldung fehlgeschlagen: ${oauthError.message}`);
setOauthLoading(null);
}
} catch {
setError('Verbindungsfehler. Bitte versuchen Sie es erneut.');
setOauthLoading(null);
}
};
return (
<Card className="w-full max-w-md mx-auto">
<Card className="mx-auto w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Shield className="h-6 w-6 text-primary" />
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Shield className="text-primary h-6 w-6" />
</div>
<CardTitle>Mitgliederbereich</CardTitle>
<p className="text-sm text-muted-foreground">{accountName}</p>
<p className="text-muted-foreground text-sm">{accountName}</p>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-md p-3 text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
@@ -103,10 +149,39 @@ export function PortalLoginForm({ slug, accountName }: Props) {
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
Zugangsdaten erhalten Sie per Einladung von Ihrem Verein.
</p>
</form>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2">
oder
</span>
</div>
</div>
<div className="flex flex-col gap-2">
{OAUTH_PROVIDERS.map(({ id, label }) => (
<Button
key={id}
variant="outline"
className="w-full gap-2"
disabled={!!oauthLoading}
onClick={() => handleOAuthLogin(id)}
>
<OauthProviderLogoImage providerId={id} />
{oauthLoading === id
? 'Wird weitergeleitet...'
: `Mit ${label} anmelden`}
</Button>
))}
</div>
<p className="text-muted-foreground mt-4 text-center text-xs">
Zugangsdaten erhalten Sie per Einladung von Ihrem Verein.
</p>
</CardContent>
</Card>
);

View File

@@ -1,9 +1,12 @@
'use client';
import { Puck } from '@measured/puck';
import '@measured/puck/puck.css';
import { useAction } from 'next-safe-action/hooks';
import { toast } from '@kit/ui/sonner';
import { clubPuckConfig } from '../config/puck-config';
import { publishPage } from '../server/actions/site-builder-actions';

View File

@@ -1,6 +1,7 @@
'use client';
import { Render } from '@measured/puck';
import { clubPuckConfig } from '../config/puck-config';
import { SiteDataProvider, type SiteData } from '../context/site-data-context';
@@ -10,7 +11,12 @@ interface Props {
}
export function SiteRenderer({ data, siteData }: Props) {
const defaultData: SiteData = { accountId: '', events: [], courses: [], posts: [] };
const defaultData: SiteData = {
accountId: '',
events: [],
courses: [],
posts: [],
};
return (
<SiteDataProvider data={siteData ?? defaultData}>

View File

@@ -1,14 +1,24 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useAction } from 'next-safe-action/hooks';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { SiteSettingsSchema } from '../schema/site.schema';
import { updateSiteSettings } from '../server/actions/site-builder-actions';
@@ -41,79 +51,210 @@ export function SiteSettingsForm({ accountId, account, settings }: Props) {
});
const { execute, isPending } = useAction(updateSiteSettings, {
onSuccess: () => { toast.success('Einstellungen gespeichert'); router.refresh(); },
onSuccess: () => {
toast.success('Einstellungen gespeichert');
router.refresh();
},
onError: ({ error }) => toast.error(error.serverError ?? 'Fehler'),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6 max-w-3xl">
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="max-w-3xl space-y-6"
>
<Card>
<CardHeader><CardTitle>Allgemein</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Allgemein</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="siteName" render={({ field }) => (
<FormItem><FormLabel>Website-Name</FormLabel><FormControl><Input placeholder="Mein Verein" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="fontFamily" render={({ field }) => (
<FormItem><FormLabel>Schriftart</FormLabel><FormControl>
<select {...field} className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="Inter">Inter</option>
<option value="system-ui">System</option>
<option value="Georgia">Georgia</option>
<option value="Roboto">Roboto</option>
</select>
</FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="primaryColor" render={({ field }) => (
<FormItem><FormLabel>Primärfarbe</FormLabel><FormControl><Input type="color" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="secondaryColor" render={({ field }) => (
<FormItem><FormLabel>Sekundärfarbe</FormLabel><FormControl><Input type="color" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="siteName"
render={({ field }) => (
<FormItem>
<FormLabel>Website-Name</FormLabel>
<FormControl>
<Input placeholder="Mein Verein" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>Schriftart</FormLabel>
<FormControl>
<select
{...field}
className="border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm"
>
<option value="Inter">Inter</option>
<option value="system-ui">System</option>
<option value="Georgia">Georgia</option>
<option value="Roboto">Roboto</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="primaryColor"
render={({ field }) => (
<FormItem>
<FormLabel>Primärfarbe</FormLabel>
<FormControl>
<Input type="color" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secondaryColor"
render={({ field }) => (
<FormItem>
<FormLabel>Sekundärfarbe</FormLabel>
<FormControl>
<Input type="color" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Kontakt</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Kontakt</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField control={form.control} name="contactEmail" render={({ field }) => (
<FormItem><FormLabel>E-Mail</FormLabel><FormControl><Input type="email" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactPhone" render={({ field }) => (
<FormItem><FormLabel>Telefon</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="contactAddress" render={({ field }) => (
<FormItem className="col-span-full"><FormLabel>Adresse</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="contactEmail"
render={({ field }) => (
<FormItem>
<FormLabel>E-Mail</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactPhone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactAddress"
render={({ field }) => (
<FormItem className="col-span-full">
<FormLabel>Adresse</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Rechtliches</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Rechtliches</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField control={form.control} name="impressum" render={({ field }) => (
<FormItem><FormLabel>Impressum</FormLabel><FormControl><textarea {...field} rows={6} className="w-full rounded-md border px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="datenschutz" render={({ field }) => (
<FormItem><FormLabel>Datenschutzerklärung</FormLabel><FormControl><textarea {...field} rows={6} className="w-full rounded-md border px-3 py-2 text-sm" /></FormControl><FormMessage /></FormItem>
)} />
<FormField
control={form.control}
name="impressum"
render={({ field }) => (
<FormItem>
<FormLabel>Impressum</FormLabel>
<FormControl>
<textarea
{...field}
rows={6}
className="w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="datenschutz"
render={({ field }) => (
<FormItem>
<FormLabel>Datenschutzerklärung</FormLabel>
<FormControl>
<textarea
{...field}
rows={6}
className="w-full rounded-md border px-3 py-2 text-sm"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Veröffentlichung</CardTitle></CardHeader>
<CardHeader>
<CardTitle>Veröffentlichung</CardTitle>
</CardHeader>
<CardContent>
<FormField control={form.control} name="isPublic" render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl><input type="checkbox" checked={field.value} onChange={field.onChange} className="h-4 w-4" /></FormControl>
<div>
<FormLabel>Website öffentlich zugänglich</FormLabel>
<p className="text-xs text-muted-foreground">Wenn aktiviert, ist Ihre Website unter /club/{account} erreichbar.</p>
</div>
</FormItem>
)} />
<FormField
control={form.control}
name="isPublic"
render={({ field }) => (
<FormItem className="flex items-center gap-3">
<FormControl>
<input
type="checkbox"
checked={field.value}
onChange={field.onChange}
className="h-4 w-4"
/>
</FormControl>
<div>
<FormLabel>Website öffentlich zugänglich</FormLabel>
<p className="text-muted-foreground text-xs">
Wenn aktiviert, ist Ihre Website unter /club/{account}{' '}
erreichbar.
</p>
</div>
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
<Button type="submit" disabled={isPending}>{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Wird gespeichert...' : 'Einstellungen speichern'}
</Button>
</div>
</form>
</Form>

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,11 @@ import { z } from 'zod';
export const CreatePageSchema = z.object({
accountId: z.string().uuid(),
slug: z.string().min(1).max(128).regex(/^[a-z0-9-]+$/),
slug: z
.string()
.min(1)
.max(128)
.regex(/^[a-z0-9-]+$/),
title: z.string().min(1).max(256),
puckData: z.record(z.string(), z.unknown()).default({}),
isHomepage: z.boolean().default(false),
@@ -29,7 +33,9 @@ export const SiteSettingsSchema = z.object({
secondaryColor: z.string().default('#64748b'),
fontFamily: z.string().default('Inter'),
customCss: z.string().optional(),
navigation: z.array(z.object({ label: z.string(), href: z.string() })).default([]),
navigation: z
.array(z.object({ label: z.string(), href: z.string() }))
.default([]),
footerText: z.string().optional(),
contactEmail: z.string().optional(),
contactPhone: z.string().optional(),
@@ -42,7 +48,10 @@ export const SiteSettingsSchema = z.object({
export const CreatePostSchema = z.object({
accountId: z.string().uuid(),
title: z.string().min(1),
slug: z.string().min(1).regex(/^[a-z0-9-]+$/),
slug: z
.string()
.min(1)
.regex(/^[a-z0-9-]+$/),
content: z.string().optional(),
excerpt: z.string().optional(),
coverImage: z.string().optional(),

View File

@@ -1,10 +1,19 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { CreatePageSchema, UpdatePageSchema, SiteSettingsSchema, CreatePostSchema, UpdatePostSchema, NewsletterSubscribeSchema } from '../../schema/site.schema';
import {
CreatePageSchema,
UpdatePageSchema,
SiteSettingsSchema,
CreatePostSchema,
UpdatePostSchema,
NewsletterSubscribeSchema,
} from '../../schema/site.schema';
import { createSiteBuilderApi } from '../api';
export const createPage = authActionClient
@@ -21,7 +30,11 @@ export const saveDraft = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.updatePage(input.pageId, { ...input, isPublished: false }, ctx.user.id);
const data = await api.updatePage(
input.pageId,
{ ...input, isPublished: false },
ctx.user.id,
);
return { success: true, data };
});
@@ -30,7 +43,11 @@ export const publishPage = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const api = createSiteBuilderApi(client);
const data = await api.updatePage(input.pageId, { ...input, isPublished: true }, ctx.user.id);
const data = await api.updatePage(
input.pageId,
{ ...input, isPublished: true },
ctx.user.id,
);
return { success: true, data };
});

View File

@@ -1,42 +1,90 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createSiteBuilderApi(client: SupabaseClient<Database>) {
return {
// Pages
async listPages(accountId: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).order('sort_order');
const { data, error } = await client
.from('site_pages')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
},
async getPage(pageId: string) {
const { data, error } = await client.from('site_pages').select('*').eq('id', pageId).single();
const { data, error } = await client
.from('site_pages')
.select('*')
.eq('id', pageId)
.single();
if (error) throw error;
return data;
},
async getPageBySlug(accountId: string, slug: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).eq('slug', slug).single();
const { data, error } = await client
.from('site_pages')
.select('*')
.eq('account_id', accountId)
.eq('slug', slug)
.single();
if (error) throw error;
return data;
},
async getHomepage(accountId: string) {
const { data, error } = await client.from('site_pages').select('*')
.eq('account_id', accountId).eq('is_homepage', true).eq('is_published', true).maybeSingle();
const { data, error } = await client
.from('site_pages')
.select('*')
.eq('account_id', accountId)
.eq('is_homepage', true)
.eq('is_published', true)
.maybeSingle();
if (error) throw error;
return data;
},
async createPage(input: { accountId: string; slug: string; title: string; puckData?: Record<string, unknown>; isHomepage?: boolean; metaDescription?: string }, userId: string) {
const { data, error } = await client.from('site_pages').insert({
account_id: input.accountId, slug: input.slug, title: input.title,
puck_data: (input.puckData ?? {}) as any, is_homepage: input.isHomepage ?? false,
meta_description: input.metaDescription, created_by: userId, updated_by: userId,
}).select().single();
async createPage(
input: {
accountId: string;
slug: string;
title: string;
puckData?: Record<string, unknown>;
isHomepage?: boolean;
metaDescription?: string;
},
userId: string,
) {
const { data, error } = await client
.from('site_pages')
.insert({
account_id: input.accountId,
slug: input.slug,
title: input.title,
puck_data: (input.puckData ?? {}) as any,
is_homepage: input.isHomepage ?? false,
meta_description: input.metaDescription,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updatePage(pageId: string, input: { title?: string; slug?: string; puckData?: Record<string, unknown>; isPublished?: boolean; isHomepage?: boolean; metaDescription?: string; metaImage?: string }, userId: string) {
async updatePage(
pageId: string,
input: {
title?: string;
slug?: string;
puckData?: Record<string, unknown>;
isPublished?: boolean;
isHomepage?: boolean;
metaDescription?: string;
metaImage?: string;
},
userId: string,
) {
const update: Record<string, unknown> = { updated_by: userId };
if (input.title !== undefined) update.title = input.title;
if (input.slug !== undefined) update.slug = input.slug;
@@ -46,65 +94,129 @@ export function createSiteBuilderApi(client: SupabaseClient<Database>) {
if (input.isPublished) update.published_at = new Date().toISOString();
}
if (input.isHomepage !== undefined) update.is_homepage = input.isHomepage;
if (input.metaDescription !== undefined) update.meta_description = input.metaDescription;
if (input.metaDescription !== undefined)
update.meta_description = input.metaDescription;
if (input.metaImage !== undefined) update.meta_image = input.metaImage;
const { data, error } = await client.from('site_pages').update(update).eq('id', pageId).select().single();
const { data, error } = await client
.from('site_pages')
.update(update)
.eq('id', pageId)
.select()
.single();
if (error) throw error;
return data;
},
async deletePage(pageId: string) {
const { error } = await client.from('site_pages').delete().eq('id', pageId);
const { error } = await client
.from('site_pages')
.delete()
.eq('id', pageId);
if (error) throw error;
},
// Settings
async getSiteSettings(accountId: string) {
const { data, error } = await client.from('site_settings').select('*').eq('account_id', accountId).maybeSingle();
const { data, error } = await client
.from('site_settings')
.select('*')
.eq('account_id', accountId)
.maybeSingle();
if (error) throw error;
return data;
},
async upsertSiteSettings(accountId: string, input: Record<string, unknown>) {
async upsertSiteSettings(
accountId: string,
input: Record<string, unknown>,
) {
const row: Record<string, unknown> = { account_id: accountId };
if (input.siteName !== undefined) row.site_name = input.siteName;
if (input.siteLogo !== undefined) row.site_logo = input.siteLogo;
if (input.primaryColor !== undefined) row.primary_color = input.primaryColor;
if (input.secondaryColor !== undefined) row.secondary_color = input.secondaryColor;
if (input.primaryColor !== undefined)
row.primary_color = input.primaryColor;
if (input.secondaryColor !== undefined)
row.secondary_color = input.secondaryColor;
if (input.fontFamily !== undefined) row.font_family = input.fontFamily;
if (input.navigation !== undefined) row.navigation = input.navigation;
if (input.footerText !== undefined) row.footer_text = input.footerText;
if (input.contactEmail !== undefined) row.contact_email = input.contactEmail;
if (input.contactPhone !== undefined) row.contact_phone = input.contactPhone;
if (input.contactAddress !== undefined) row.contact_address = input.contactAddress;
if (input.contactEmail !== undefined)
row.contact_email = input.contactEmail;
if (input.contactPhone !== undefined)
row.contact_phone = input.contactPhone;
if (input.contactAddress !== undefined)
row.contact_address = input.contactAddress;
if (input.impressum !== undefined) row.impressum = input.impressum;
if (input.datenschutz !== undefined) row.datenschutz = input.datenschutz;
if (input.isPublic !== undefined) row.is_public = input.isPublic;
const { data, error } = await client.from('site_settings').upsert(row as any).select().single();
const { data, error } = await client
.from('site_settings')
.upsert(row as any)
.select()
.single();
if (error) throw error;
return data;
},
// Posts
async listPosts(accountId: string, status?: string) {
let query = client.from('cms_posts').select('*').eq('account_id', accountId).order('created_at', { ascending: false });
let query = client
.from('cms_posts')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (status) query = query.eq('status', status);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async getPost(postId: string) {
const { data, error } = await client.from('cms_posts').select('*').eq('id', postId).single();
const { data, error } = await client
.from('cms_posts')
.select('*')
.eq('id', postId)
.single();
if (error) throw error;
return data;
},
async createPost(input: { accountId: string; title: string; slug: string; content?: string; excerpt?: string; coverImage?: string; status?: string }, userId: string) {
const { data, error } = await client.from('cms_posts').insert({
account_id: input.accountId, title: input.title, slug: input.slug,
content: input.content, excerpt: input.excerpt, cover_image: input.coverImage,
status: input.status ?? 'draft', author_id: userId,
published_at: input.status === 'published' ? new Date().toISOString() : null,
}).select().single();
async createPost(
input: {
accountId: string;
title: string;
slug: string;
content?: string;
excerpt?: string;
coverImage?: string;
status?: string;
},
userId: string,
) {
const { data, error } = await client
.from('cms_posts')
.insert({
account_id: input.accountId,
title: input.title,
slug: input.slug,
content: input.content,
excerpt: input.excerpt,
cover_image: input.coverImage,
status: input.status ?? 'draft',
author_id: userId,
published_at:
input.status === 'published' ? new Date().toISOString() : null,
})
.select()
.single();
if (error) throw error;
return data;
},
async updatePost(postId: string, input: { title?: string; slug?: string; content?: string; excerpt?: string; coverImage?: string; status?: string }) {
async updatePost(
postId: string,
input: {
title?: string;
slug?: string;
content?: string;
excerpt?: string;
coverImage?: string;
status?: string;
},
) {
const update: Record<string, unknown> = {};
if (input.title !== undefined) update.title = input.title;
if (input.slug !== undefined) update.slug = input.slug;
@@ -113,22 +225,38 @@ export function createSiteBuilderApi(client: SupabaseClient<Database>) {
if (input.coverImage !== undefined) update.cover_image = input.coverImage;
if (input.status !== undefined) {
update.status = input.status;
if (input.status === 'published') update.published_at = new Date().toISOString();
if (input.status === 'published')
update.published_at = new Date().toISOString();
}
const { data, error } = await client.from('cms_posts').update(update).eq('id', postId).select().single();
const { data, error } = await client
.from('cms_posts')
.update(update)
.eq('id', postId)
.select()
.single();
if (error) throw error;
return data;
},
async deletePost(postId: string) {
const { error } = await client.from('cms_posts').delete().eq('id', postId);
const { error } = await client
.from('cms_posts')
.delete()
.eq('id', postId);
if (error) throw error;
},
// Newsletter
async subscribe(accountId: string, email: string, name?: string) {
const token = crypto.randomUUID();
const { error } = await client.from('newsletter_subscriptions').upsert({
account_id: accountId, email, name, confirmation_token: token, is_active: true,
}, { onConflict: 'account_id,email' });
const { error } = await client.from('newsletter_subscriptions').upsert(
{
account_id: accountId,
email,
name,
confirmation_token: token,
is_active: true,
},
{ onConflict: 'account_id,email' },
);
if (error) throw error;
return token;
},