ki integration

This commit is contained in:
2026-08-15 13:43:52 +02:00
parent 82d98bd8cf
commit 9ecb292c26
78 changed files with 12055 additions and 55 deletions

View File

@@ -0,0 +1,20 @@
import { Redirect, Stack } from 'expo-router';
import { useSession } from '@/lib/session';
import { colors } from '@/theme/tokens';
export default function AuthLayout() {
const { laedt, konto } = useSession();
if (laedt) return null; // Splash bleibt stehen, bis die Session geklärt ist
if (konto) return <Redirect href="/" />;
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bg },
}}
/>
);
}

View File

@@ -0,0 +1,79 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, ErrorNote, Field } from '@/components/ui';
import { anmelden, fehlertext } from '@/lib/auth';
import { useSession } from '@/lib/session';
import { colors, space, text } from '@/theme/tokens';
export default function AnmeldenScreen() {
const router = useRouter();
const { neuLaden } = useSession();
const [email, setEmail] = useState('');
const [passwort, setPasswort] = useState('');
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function absenden() {
setFehler(null);
setBusy(true);
try {
await anmelden(email.trim(), passwort);
await neuLaden();
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
return (
<SafeAreaView style={s.safe}>
<KeyboardAvoidingView
style={s.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Anmelden</Text>
{fehler ? <ErrorNote message={fehler} /> : null}
<Field
label="E-Mail"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
placeholder="du@marke.de"
/>
<Field
label="Passwort"
value={passwort}
onChangeText={setPasswort}
secureTextEntry
autoComplete="current-password"
/>
<View style={s.actions}>
<Button
title="Anmelden"
onPress={absenden}
busy={busy}
disabled={!email.includes('@') || passwort.length < 8}
/>
<Button title="Zurück" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
flex: { flex: 1 },
body: { padding: space.xl, gap: space.lg },
titel: { ...text.title, color: colors.txt },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,87 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, ErrorNote, Field } from '@/components/ui';
import { fehlertext, registrieren } from '@/lib/auth';
import { useSession } from '@/lib/session';
import { colors, space, text } from '@/theme/tokens';
export default function RegistrierenScreen() {
const router = useRouter();
const { neuLaden } = useSession();
const [label, setLabel] = useState('');
const [email, setEmail] = useState('');
const [passwort, setPasswort] = useState('');
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const bereit = label.trim().length > 1 && email.includes('@') && passwort.length >= 8;
async function absenden() {
setFehler(null);
setBusy(true);
try {
await registrieren(email.trim(), passwort, label.trim());
await neuLaden(); // Session-Zustand aktualisieren der Gate leitet dann selbst weiter
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
return (
<SafeAreaView style={s.safe}>
<KeyboardAvoidingView
style={s.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Konto erstellen</Text>
<Text style={s.unter}>Ein Konto ist eine Marke. Der Name lässt sich später ändern.</Text>
{fehler ? <ErrorNote message={fehler} /> : null}
<Field
label="Name der Marke"
value={label}
onChangeText={setLabel}
autoCapitalize="words"
placeholder="z. B. Modaily"
/>
<Field
label="E-Mail"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
placeholder="du@marke.de"
/>
<Field
label="Passwort"
value={passwort}
onChangeText={setPasswort}
secureTextEntry
autoComplete="new-password"
placeholder="mindestens 8 Zeichen"
/>
<View style={s.actions}>
<Button title="Konto erstellen" onPress={absenden} busy={busy} disabled={!bereit} />
<Button title="Zurück" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
flex: { flex: 1 },
body: { padding: space.xl, gap: space.lg },
titel: { ...text.title, color: colors.txt },
unter: { ...text.body, color: colors.mut, marginTop: -space.sm },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,36 @@
import { useRouter } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui';
import { colors, space, text } from '@/theme/tokens';
export default function WillkommenScreen() {
const router = useRouter();
return (
<SafeAreaView style={s.safe}>
<View style={s.body}>
<View style={s.top}>
<Text style={s.marke}>BrandLoop</Text>
<Text style={s.claim}>
Deine Ads lernen aus jedem ausgegebenen Euro. Generische Tools erstellen Bilder hier
entsteht ein Gedächtnis für deine Marke.
</Text>
</View>
<View style={s.actions}>
<Button title="Konto erstellen" onPress={() => router.push('/registrieren')} />
<Button title="Anmelden" variant="ghost" onPress={() => router.push('/anmelden')} />
</View>
</View>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { flex: 1, justifyContent: 'space-between', padding: space.xl, paddingBottom: space.xxl },
top: { flex: 1, justifyContent: 'center', gap: space.lg },
marke: { fontSize: 40, fontWeight: '700', color: colors.txt, letterSpacing: -1 },
claim: { ...text.body, color: colors.mut, fontSize: 17, lineHeight: 25 },
actions: { gap: space.md },
});

View File

@@ -0,0 +1,65 @@
import { Ionicons } from '@expo/vector-icons';
import { Redirect, Tabs } from 'expo-router';
import { StyleSheet } from 'react-native';
import { useSession } from '@/lib/session';
import { colors, space } from '@/theme/tokens';
/**
* Drei Tabs: Feed · · Profil (app-aufbau.md §1 und §3).
*
* Das ist dort als **Popover** beschrieben und nicht als Screen es gibt
* drei verschiedene Erstellen-Abläufe, von denen keiner der Standard sein soll.
* Bis der Popover in E6 steht, ist es hier ein normaler Tab.
*/
export default function TabsLayout() {
const { laedt, konto } = useSession();
if (laedt) return null;
// Der Zugang wird hier nur *bequem* gesperrt. Dicht ist er durch die
// Zeilenrechte in Appwrite ohne Session liefert die API schlicht nichts.
if (!konto) return <Redirect href="/willkommen" />;
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.faint,
tabBarStyle: styles.bar,
tabBarLabelStyle: styles.label,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Feed',
tabBarIcon: ({ color, size }) => <Ionicons name="albums-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="erstellen"
options={{
title: 'Erstellen',
tabBarIcon: ({ color, size }) => <Ionicons name="add-circle-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="profil"
options={{
title: 'Profil',
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" size={size} color={color} />,
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
bar: {
backgroundColor: colors.sheet,
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
paddingTop: space.xs,
},
label: { fontSize: 11, fontWeight: '500' },
});

View File

@@ -0,0 +1,91 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Screen } from '@/components/screen';
import { useSession } from '@/lib/session';
import { colors, radius, space, text } from '@/theme/tokens';
/**
* Wird in E6 zum Popover über der Tab-Leiste. Bis dahin dieselbe Auswahl als
* Screen die drei Abläufe sind unterschiedlich lang, keiner darf der
* Standard werden (app-aufbau.md §3).
*/
export default function ErstellenScreen() {
const router = useRouter();
const { aktiverOrdner } = useSession();
return (
<Screen title="Erstellen">
{aktiverOrdner ? (
<Text style={s.scope}>
Aktiver Ordner: <Text style={s.scopeName}>{aktiverOrdner.name}</Text>
</Text>
) : (
<Text style={s.scope}>Noch kein Ordner gewählt im Profil einen anlegen.</Text>
)}
<Eintrag
icon="images-outline"
titel="Post"
text="Kulisse, Produkt und Person wählen, Bilderkette erzeugen."
aufDruck={() => router.push('/erstellen/post')}
/>
<Eintrag icon="film-outline" titel="Video" text="Kommt mit E10." gesperrt />
<Eintrag
icon="cube-outline"
titel="Modell"
text="Person, Produkt oder Kulisse anlegen."
aufDruck={() => router.push('/modell/neu')}
/>
<Eintrag icon="document-outline" titel="Entwürfe" text="Kommt mit E6." gesperrt />
</Screen>
);
}
function Eintrag({
icon, titel, text: beschreibung, aufDruck, gesperrt,
}: {
icon: keyof typeof Ionicons.glyphMap;
titel: string;
text: string;
aufDruck?: () => void;
gesperrt?: boolean;
}) {
return (
<Pressable
onPress={aufDruck}
disabled={gesperrt}
accessibilityRole="button"
style={({ pressed }) => [s.zeile, gesperrt ? s.aus : null, pressed && !gesperrt ? s.gedrueckt : null]}>
<View style={s.icon}>
<Ionicons name={icon} size={20} color={gesperrt ? colors.faint : colors.accent} />
</View>
<View style={s.zeileText}>
<Text style={s.zeileTitel}>{titel}</Text>
<Text style={s.zeileUnter}>{beschreibung}</Text>
</View>
{!gesperrt ? <Ionicons name="chevron-forward" size={18} color={colors.faint} /> : null}
</Pressable>
);
}
const s = StyleSheet.create({
scope: { ...text.label, color: colors.mut },
scopeName: { color: colors.txt, fontWeight: '700' },
zeile: {
flexDirection: 'row', alignItems: 'center', gap: space.lg,
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md, padding: space.lg,
},
aus: { opacity: 0.45 },
gedrueckt: { opacity: 0.8 },
icon: {
width: 38, height: 38, borderRadius: radius.sm,
backgroundColor: colors.surface2, alignItems: 'center', justifyContent: 'center',
},
zeileText: { flex: 1, gap: 2 },
zeileTitel: { ...text.body, fontWeight: '600', color: colors.txt },
zeileUnter: { ...text.label, color: colors.mut, lineHeight: 18 },
});

View File

@@ -0,0 +1,10 @@
import { Screen } from '@/components/screen';
export default function FeedScreen() {
return (
<Screen
title="Feed"
hint="E7 · Feed-Deck mit den Reitern Videos · Folge ich · Posts. Öffentliche Posts nach Nische gefiltert, nach feed_score sortiert „Folge ich“ dagegen chronologisch."
/>
);
}

View File

@@ -0,0 +1,208 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui';
import { TYPEN, assetsMitBild, type AssetMitBild, type AssetTyp } from '@/lib/assets';
import { useBildQuelle } from '@/lib/bildquelle';
import { reifegrad, type Ordner } from '@/lib/folders';
import { useSession } from '@/lib/session';
import { colors, radius, space, text } from '@/theme/tokens';
export default function ProfilScreen() {
const router = useRouter();
const { brand, ordner, aktiverOrdner, setzeAktivenOrdner, ordnerNeuLaden, ausloggen } = useSession();
const [modelle, setModelle] = useState<AssetMitBild[]>([]);
const [laedt, setLaedt] = useState(false);
const laden = useCallback(async () => {
if (!brand) return;
setLaedt(true);
try {
const [m] = await Promise.all([assetsMitBild(brand.$id), ordnerNeuLaden()]);
setModelle(m);
} finally {
setLaedt(false);
}
}, [brand, ordnerNeuLaden]);
// Nach dem Anlegen kommt man per router.back() zurück ohne das hier stünde
// die Liste noch auf dem alten Stand.
useFocusEffect(
useCallback(() => {
void laden();
}, [laden]),
);
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView
contentContainerStyle={s.body}
refreshControl={<RefreshControl refreshing={laedt} onRefresh={laden} tintColor={colors.mut} />}>
<Text style={s.marke}>{brand?.label_name ?? 'Profil'}</Text>
{/* Ordner stehen oben, nicht unter den Metriken sie sind der Zugang
zum Wissens-Scope (app-aufbau.md §4.5). */}
<Abschnitt
titel="Ordner"
aktion="Neu"
aufAktion={() => router.push('/ordner/neu')}
leer={ordner.length === 0 ? 'Noch kein Ordner. Der erste bestimmt, woraus generiert wird.' : undefined}>
{ordner.map((o) => (
<OrdnerZeile
key={o.$id}
ordner={o}
aktiv={o.$id === aktiverOrdner?.$id}
aufWahl={() => setzeAktivenOrdner(o)}
aufOeffnen={() => router.push(`/ordner/${o.$id}`)}
/>
))}
</Abschnitt>
<Abschnitt
titel="Modelle"
aktion="Neu"
aufAktion={() => router.push('/modell/neu')}
leer={modelle.length === 0 ? 'Noch kein Modell. Personen, Produkte und Kulissen kommen hierher.' : undefined}>
{TYPEN.map((t) => {
const davon = modelle.filter((m) => m.typ === t.wert);
if (!davon.length) return null;
return (
<View key={t.wert} style={s.gruppe}>
<Text style={s.gruppeTitel}>{t.titel}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.reihe}>
{davon.map((m) => (
<ModellKachel key={m.$id} modell={m} />
))}
</ScrollView>
</View>
);
})}
</Abschnitt>
<View style={s.fuss}>
<Text style={s.fussText}>{brand?.plan ?? 'trial'} · {brand?.$id}</Text>
<Button title="Abmelden" variant="ghost" onPress={() => void ausloggen()} />
</View>
</ScrollView>
</SafeAreaView>
);
}
function Abschnitt({
titel, aktion, aufAktion, leer, children,
}: {
titel: string; aktion: string; aufAktion: () => void; leer?: string; children?: React.ReactNode;
}) {
return (
<View style={s.abschnitt}>
<View style={s.abschnittKopf}>
<Text style={s.abschnittTitel}>{titel}</Text>
<Pressable onPress={aufAktion} accessibilityRole="button" style={s.aktion}>
<Ionicons name="add" size={16} color={colors.accent} />
<Text style={s.aktionText}>{aktion}</Text>
</Pressable>
</View>
{leer ? <Text style={s.leer}>{leer}</Text> : children}
</View>
);
}
function OrdnerZeile({
ordner, aktiv, aufWahl, aufOeffnen,
}: {
ordner: Ordner; aktiv: boolean; aufWahl: () => void; aufOeffnen: () => void;
}) {
return (
<View style={[s.zeile, aktiv ? s.zeileAktiv : null]}>
{/* Antippen wählt, der Pfeil öffnet zwei verschiedene Absichten, die
man sonst nicht auseinanderhalten kann. */}
<Pressable
onPress={aufWahl}
accessibilityRole="radio"
accessibilityState={{ selected: aktiv }}
accessibilityLabel={`${ordner.name} als aktiven Ordner wählen`}
style={({ pressed }) => [s.zeileText, pressed ? s.gedrueckt : null]}>
<Text style={s.zeileTitel}>{ordner.name}</Text>
<Text style={s.zeileUnter}>
{ordner.zweck === 'sammlung' ? 'Sammlung' : 'Wissens-Scope'} · {reifegrad(ordner)}
</Text>
</Pressable>
{aktiv ? (
<View style={s.aktivChip}>
<Text style={s.aktivChipText}>aktiv</Text>
</View>
) : null}
<Pressable onPress={aufOeffnen} accessibilityRole="button" accessibilityLabel={`${ordner.name} öffnen`} hitSlop={8}>
<Ionicons name="chevron-forward" size={18} color={colors.faint} />
</Pressable>
</View>
);
}
function ModellKachel({ modell }: { modell: AssetMitBild }) {
const quelle = useBildQuelle(modell.titelbildId);
return (
<View style={s.kachel}>
{quelle ? (
<Image source={quelle} style={s.kachelBild} contentFit="cover" transition={150} />
) : (
<View style={[s.kachelBild, s.kachelLeer]}>
<Ionicons name={iconFuer(modell.typ)} size={22} color={colors.faint} />
</View>
)}
<Text style={s.kachelName} numberOfLines={1}>
{modell.name}
</Text>
</View>
);
}
function iconFuer(typ: AssetTyp): keyof typeof Ionicons.glyphMap {
return (TYPEN.find((t) => t.wert === typ)?.icon ?? 'cube-outline') as keyof typeof Ionicons.glyphMap;
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { padding: space.xl, gap: space.xl, paddingBottom: space.xxl },
marke: { ...text.title, color: colors.txt },
abschnitt: { gap: space.md },
abschnittKopf: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
abschnittTitel: { ...text.heading, color: colors.txt },
aktion: { flexDirection: 'row', alignItems: 'center', gap: 2, paddingVertical: space.xs, paddingHorizontal: space.sm },
aktionText: { ...text.label, color: colors.accent, fontWeight: '600' },
leer: { ...text.body, color: colors.faint, lineHeight: 21 },
zeile: {
flexDirection: 'row', alignItems: 'center', gap: space.md,
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md, padding: space.lg,
},
zeileAktiv: { borderColor: colors.accent, backgroundColor: 'rgba(255,92,57,0.08)' },
gedrueckt: { opacity: 0.8 },
zeileText: { flex: 1, gap: 2 },
zeileTitel: { ...text.body, fontWeight: '600', color: colors.txt },
zeileUnter: { ...text.label, color: colors.mut },
aktivChip: { backgroundColor: colors.accent, borderRadius: radius.pill, paddingHorizontal: space.md, paddingVertical: 3 },
aktivChipText: { fontSize: 11, fontWeight: '700', color: '#fff' },
gruppe: { gap: space.sm },
gruppeTitel: { ...text.label, color: colors.faint, textTransform: 'uppercase', letterSpacing: 0.5 },
reihe: { gap: space.md, paddingRight: space.xl },
kachel: { width: 104, gap: space.sm },
kachelBild: {
width: 104, height: 104, borderRadius: radius.md,
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
kachelLeer: { alignItems: 'center', justifyContent: 'center' },
kachelName: { ...text.label, color: colors.mut },
fuss: { gap: space.md, marginTop: space.md },
fussText: { ...text.mono, color: colors.faint },
});

View File

@@ -0,0 +1,47 @@
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SessionProvider } from '@/lib/session';
import { colors } from '@/theme/tokens';
import '../global.css';
/**
* Wurzel-Navigation. Bewusst ein Stack und nicht direkt die Tabs: laut
* app-aufbau.md §3 liegen Willkommen/Anmelden **vor** den Tabs und das
* Onboarding als Modal **über** ihnen. Beide brauchen eine Ebene, auf der die
* Tab-Leiste nicht existiert die gibt es nur, wenn die Tabs eine Gruppe
* innerhalb eines Stacks sind.
*/
const navTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: colors.bg,
card: colors.sheet,
text: colors.txt,
border: colors.border,
primary: colors.accent,
},
};
export default function RootLayout() {
return (
<SafeAreaProvider>
<SessionProvider>
<ThemeProvider value={navTheme}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bg },
}}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
{/* E3: onboarding als presentation: 'modal' */}
</Stack>
</ThemeProvider>
</SessionProvider>
</SafeAreaProvider>
);
}

View File

@@ -0,0 +1,214 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ModellWahl } from '@/components/modellwahl';
import { Button, ErrorNote, Field } from '@/components/ui';
import { Wahl } from '@/components/wahl';
import { assetsMitBild, type AssetMitBild } from '@/lib/assets';
import { fehlertext } from '@/lib/auth';
import { reifegrad } from '@/lib/folders';
import { FORMATE, postAnlegen, type Format, type Slots } from '@/lib/posts';
import { useSession } from '@/lib/session';
import { colors, radius, space, text } from '@/theme/tokens';
export default function PostErstellenScreen() {
const router = useRouter();
const { brand, ordner, aktiverOrdner, setzeAktivenOrdner } = useSession();
const [modelle, setModelle] = useState<AssetMitBild[]>([]);
const [titel, setTitel] = useState('');
const [prompt, setPrompt] = useState('');
const [format, setFormat] = useState<Format>('4:5');
const [kette, setKette] = useState(3);
const [slots, setSlots] = useState<Slots>({});
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const laden = useCallback(async () => {
if (!brand) return;
setModelle(await assetsMitBild(brand.$id));
}, [brand]);
useEffect(() => {
void laden();
}, [laden]);
const setzeSlot = (k: keyof Slots) => (v: string | undefined) =>
setSlots((s) => ({ ...s, [k]: v }));
const bereit = !!aktiverOrdner && titel.trim().length > 1 && prompt.trim().length > 5;
async function anlegen() {
if (!brand?.team_id || !aktiverOrdner) return;
setFehler(null);
setBusy(true);
try {
await postAnlegen(brand.$id, brand.team_id, {
folderId: aktiverOrdner.$id,
titel: titel.trim(),
prompt: prompt.trim(),
format,
bildCount: kette,
slots,
nische: brand.nische,
});
router.replace(`/ordner/${aktiverOrdner.$id}`);
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Post erstellen</Text>
{/* Der aktive Ordner gehört sichtbar in die Kopfzeile, nicht in ein
Untermenü sonst wird im falschen Scope generiert (app-aufbau.md §2.2). */}
<View style={s.ordnerBox}>
<Text style={s.ordnerLabel}>Speichern in</Text>
{ordner.length === 0 ? (
<Text style={s.leer}>Noch kein Ordner. Erst im Profil einen anlegen.</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.chips}>
{ordner.map((o) => {
const aktiv = o.$id === aktiverOrdner?.$id;
return (
<Pressable
key={o.$id}
onPress={() => setzeAktivenOrdner(o)}
accessibilityRole="radio"
accessibilityState={{ selected: aktiv }}
style={[s.chip, aktiv ? s.chipAktiv : null]}>
<Text style={[s.chipText, aktiv ? s.chipTextAktiv : null]}>{o.name}</Text>
</Pressable>
);
})}
</ScrollView>
)}
{aktiverOrdner ? (
<Text style={s.ordnerHinweis}>
<Ionicons name="information-circle-outline" size={12} color={colors.faint} />{' '}
Zieht sein Wissen aus {aktiverOrdner.name}" · {reifegrad(aktiverOrdner)}
</Text>
) : null}
</View>
{fehler ? <ErrorNote message={fehler} /> : null}
<Field label="Titel" value={titel} onChangeText={setTitel} placeholder="z. B. Serum auf Waschtisch" />
<ModellWahl
label="Kulisse"
typ="kulisse"
modelle={modelle}
gewaehlt={slots.kulisse_asset_id}
aufWahl={setzeSlot('kulisse_asset_id')}
hinweis="Wo das Bild spielt."
/>
<ModellWahl
label="Produkt"
typ="produkt"
modelle={modelle}
gewaehlt={slots.produkt_asset_id}
aufWahl={setzeSlot('produkt_asset_id')}
hinweis="Muss genau so aussehen wie im Regal."
/>
<ModellWahl
label="Person"
typ="gesicht"
modelle={modelle}
gewaehlt={slots.person_asset_id}
aufWahl={setzeSlot('person_asset_id')}
hinweis="Optional. Nur KI-generierte Gesichter echte brauchen eine dokumentierte Einwilligung."
/>
<Wahl
label="Format"
optionen={FORMATE.map((f) => ({ wert: f.wert, titel: f.titel, erklaerung: f.erklaerung }))}
wert={format}
aufWahl={setFormat}
/>
<View style={s.block}>
<Text style={s.label}>Bilder in der Kette</Text>
<Text style={s.hinweis}>
Über die Kette variieren nur Position und Winkel. Modelle, Kulisse, Licht und Farbe
bleiben gleich.
</Text>
<View style={s.zahlen}>
{[1, 2, 3, 4, 5].map((n) => (
<Pressable
key={n}
onPress={() => setKette(n)}
accessibilityRole="radio"
accessibilityState={{ selected: n === kette }}
style={[s.zahl, n === kette ? s.zahlAktiv : null]}>
<Text style={[s.zahlText, n === kette ? s.zahlTextAktiv : null]}>{n}</Text>
</Pressable>
))}
</View>
</View>
<Field
label="Was soll zu sehen sein?"
value={prompt}
onChangeText={setPrompt}
placeholder="Ein Satz genügt die Marke steuert den Rest bei."
multiline
numberOfLines={4}
style={s.mehrzeilig}
/>
<View style={s.actions}>
<Button title={`Rezept speichern und ${kette} Bilder einreihen`} onPress={anlegen} busy={busy} disabled={!bereit} />
<Button title="Abbrechen" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { padding: space.xl, gap: space.lg, paddingBottom: space.xxl },
titel: { ...text.title, color: colors.txt },
ordnerBox: {
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md, padding: space.lg, gap: space.sm,
},
ordnerLabel: { ...text.label, color: colors.mut },
ordnerHinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
chips: { gap: space.sm, paddingRight: space.lg },
chip: {
borderRadius: radius.pill, paddingHorizontal: space.lg, paddingVertical: space.sm,
backgroundColor: colors.surface2, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
chipAktiv: { backgroundColor: colors.accent, borderColor: colors.accent },
chipText: { ...text.label, color: colors.mut },
chipTextAktiv: { color: '#fff', fontWeight: '700' },
block: { gap: space.sm },
label: { ...text.label, color: colors.mut },
hinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
leer: { ...text.label, color: colors.faint },
mehrzeilig: { minHeight: 96, textAlignVertical: 'top' },
zahlen: { flexDirection: 'row', gap: space.sm, marginTop: space.xs },
zahl: {
width: 46, height: 46, borderRadius: radius.sm,
alignItems: 'center', justifyContent: 'center',
backgroundColor: colors.surface, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
zahlAktiv: { borderColor: colors.accent, backgroundColor: 'rgba(255,92,57,0.12)' },
zahlText: { ...text.body, color: colors.mut, fontWeight: '600' },
zahlTextAktiv: { color: colors.txt },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,160 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, ErrorNote, Field } from '@/components/ui';
import { Wahl } from '@/components/wahl';
import { BILDER_EMPFOHLEN, BILDER_MAX, TYPEN, assetAnlegen, type AssetTyp } from '@/lib/assets';
import { fehlertext } from '@/lib/auth';
import { useSession } from '@/lib/session';
import type { Auswahl } from '@/lib/upload';
import { colors, radius, space, text } from '@/theme/tokens';
export default function ModellNeuScreen() {
const router = useRouter();
const { brand } = useSession();
const [typ, setTyp] = useState<AssetTyp>('gesicht');
const [name, setName] = useState('');
const [beschreibung, setBeschreibung] = useState('');
const [bilder, setBilder] = useState<Auswahl[]>([]);
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function bilderWaehlen() {
const res = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsMultipleSelection: true,
selectionLimit: BILDER_MAX - bilder.length,
quality: 0.9,
});
if (res.canceled) return;
const neu: Auswahl[] = res.assets.map((a, i) => ({
uri: a.uri,
name: a.fileName ?? `referenz-${Date.now()}-${i}.jpg`,
mimeType: a.mimeType ?? 'image/jpeg',
size: a.fileSize ?? 0,
}));
setBilder((b) => [...b, ...neu].slice(0, BILDER_MAX));
}
async function anlegen() {
if (!brand?.team_id) {
setFehler('Zur Marke ist kein Team hinterlegt bitte neu anmelden.');
return;
}
setFehler(null);
setBusy(true);
try {
await assetAnlegen(brand.$id, brand.team_id, {
typ,
name: name.trim(),
beschreibung: beschreibung.trim(),
bilder,
});
router.back();
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
const zuViele = bilder.length > 7;
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Modell anlegen</Text>
<Text style={s.unter}>
Ein Modell ist das, was im Bild zu sehen ist eine Person, ein Produkt oder eine Kulisse.
Es wird wiederverwendet, damit es überall gleich aussieht.
</Text>
{fehler ? <ErrorNote message={fehler} /> : null}
<Wahl label="Was ist es?" optionen={TYPEN} wert={typ} aufWahl={setTyp} />
<Field label="Name" value={name} onChangeText={setName} placeholder="z. B. Serum 30 ml" />
<Field
label="Beschreibung"
value={beschreibung}
onChangeText={setBeschreibung}
placeholder="Merkmale, die immer stimmen müssen wörtlich, nicht blumig."
multiline
numberOfLines={4}
style={s.mehrzeilig}
/>
<View style={s.block}>
<Text style={s.label}>Referenzbilder</Text>
<Text style={s.hinweis}>
{BILDER_EMPFOHLEN} bis {BILDER_MAX} sind das Optimum. Mehr als sieben mitteln die
Merkmale weg, statt sie zu schärfen.
</Text>
<View style={s.gitter}>
{bilder.map((b, i) => (
<View key={`${b.uri}-${i}`} style={s.kachel}>
<Image source={{ uri: b.uri }} style={s.vorschau} contentFit="cover" />
<Pressable
onPress={() => setBilder((alt) => alt.filter((_, j) => j !== i))}
style={s.weg}
accessibilityLabel="Bild entfernen">
<Ionicons name="close" size={14} color="#fff" />
</Pressable>
</View>
))}
{bilder.length < BILDER_MAX ? (
<Pressable onPress={bilderWaehlen} style={[s.kachel, s.plus]} accessibilityLabel="Bilder auswählen">
<Ionicons name="add" size={26} color={colors.mut} />
</Pressable>
) : null}
</View>
{zuViele ? <Text style={s.warnung}>Über sieben Bilder verschlechtern die Konsistenz.</Text> : null}
</View>
<View style={s.actions}>
<Button
title="Modell anlegen"
onPress={anlegen}
busy={busy}
disabled={name.trim().length < 2 || bilder.length === 0}
/>
<Button title="Abbrechen" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { padding: space.xl, gap: space.lg, paddingBottom: space.xxl },
titel: { ...text.title, color: colors.txt },
unter: { ...text.body, color: colors.mut, lineHeight: 21, marginTop: -space.sm },
mehrzeilig: { minHeight: 100, textAlignVertical: 'top' },
block: { gap: space.sm },
label: { ...text.label, color: colors.mut },
hinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
gitter: { flexDirection: 'row', flexWrap: 'wrap', gap: space.md, marginTop: space.sm },
kachel: {
width: 88, height: 88, borderRadius: radius.sm, overflow: 'hidden',
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
vorschau: { width: '100%', height: '100%' },
weg: {
position: 'absolute', top: 4, right: 4,
width: 22, height: 22, borderRadius: 11,
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'center', justifyContent: 'center',
},
plus: { alignItems: 'center', justifyContent: 'center', borderStyle: 'dashed' },
warnung: { ...text.label, color: colors.gold },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,171 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui';
import { assetsMitBild, type AssetMitBild } from '@/lib/assets';
import { useBildQuelle } from '@/lib/bildquelle';
import { BUCKET_BILDER } from '@/lib/dateien';
import { ordnerLesen, reifegrad, type Ordner } from '@/lib/folders';
import { bilderZuPosts, postsImOrdner, slotsLesen, type Post, type PostBild } from '@/lib/posts';
import { useSession } from '@/lib/session';
import { colors, radius, space, text } from '@/theme/tokens';
export default function OrdnerDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { brand, aktiverOrdner, setzeAktivenOrdner } = useSession();
const [ordner, setOrdner] = useState<Ordner | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [modelle, setModelle] = useState<AssetMitBild[]>([]);
const [bilder, setBilder] = useState<Map<string, PostBild[]>>(new Map());
const laden = useCallback(async () => {
if (!id || !brand) return;
const [o, p, m] = await Promise.all([ordnerLesen(id), postsImOrdner(id), assetsMitBild(brand.$id)]);
setOrdner(o);
setPosts(p);
setModelle(m);
setBilder(await bilderZuPosts(p.map((x) => x.$id)));
}, [id, brand]);
useFocusEffect(
useCallback(() => {
void laden();
}, [laden]),
);
const istAktiv = ordner?.$id === aktiverOrdner?.$id;
const nameVon = (assetId?: string) => modelle.find((m) => m.$id === assetId)?.name;
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView contentContainerStyle={s.body}>
<Text style={s.titel}>{ordner?.name ?? 'Ordner'}</Text>
<Text style={s.unter}>
{ordner?.zweck === 'sammlung' ? 'Sammlung' : 'Wissens-Scope'} ·{' '}
{ordner?.startwert_modus === 'erben' ? 'geerbte Startwerte' : 'startet aus den Posts'} ·{' '}
{ordner ? reifegrad(ordner) : ''}
</Text>
{!istAktiv && ordner ? (
<Button title="Als aktiven Ordner setzen" variant="ghost" onPress={() => setzeAktivenOrdner(ordner)} />
) : (
<View style={s.aktivHinweis}>
<Ionicons name="checkmark-circle" size={15} color={colors.accent} />
<Text style={s.aktivText}>Aktiver Ordner neue Posts landen hier.</Text>
</View>
)}
<View style={s.abschnitt}>
<Text style={s.abschnittTitel}>Posts</Text>
{posts.length === 0 ? (
<Text style={s.leer}>Noch nichts drin. Ein Post entsteht über Erstellen.</Text>
) : (
posts.map((p) => {
const sl = slotsLesen(p);
const teile = [nameVon(sl.kulisse_asset_id), nameVon(sl.produkt_asset_id), nameVon(sl.person_asset_id)]
.filter(Boolean)
.join(' · ');
return (
<View key={p.$id} style={s.karte}>
<View style={s.karteKopf}>
<Text style={s.karteTitel} numberOfLines={1}>
{p.titel || 'Ohne Titel'}
</Text>
<View style={[s.status, p.status === 'generiert' ? s.statusOk : null]}>
<Text style={s.statusText}>{p.status === 'generiert' ? 'fertig' : 'wartet'}</Text>
</View>
</View>
<Text style={s.karteZeile}>
{p.format} · {p.bild_count} {p.bild_count === 1 ? 'Bild' : 'Bilder'} ·{' '}
{p.sichtbarkeit === 'oeffentlich' ? 'öffentlich' : 'privat'}
</Text>
{teile ? <Text style={s.karteSlots}>{teile}</Text> : null}
{p.user_prompt ? (
<Text style={s.kartePrompt} numberOfLines={2}>
{p.user_prompt}
</Text>
) : null}
{(bilder.get(p.$id) ?? []).length > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.kette}>
{(bilder.get(p.$id) ?? []).map((b) => (
<KettenBild key={b.$id} bild={b} />
))}
</ScrollView>
) : null}
</View>
);
})
)}
</View>
<Button title="Zurück" variant="ghost" onPress={() => router.back()} />
</ScrollView>
</SafeAreaView>
);
}
function KettenBild({ bild }: { bild: PostBild }) {
const quelle = useBildQuelle(bild.storage_file_id, BUCKET_BILDER);
return (
<View style={s.kettenRahmen}>
{quelle ? (
<Image source={quelle} style={s.kettenBild} contentFit="cover" transition={150} />
) : (
<View style={[s.kettenBild, s.kettenLeer]}>
<Ionicons name="hourglass-outline" size={16} color={colors.faint} />
</View>
)}
<View style={s.kettenNr}>
<Text style={s.kettenNrText}>{bild.position}</Text>
</View>
</View>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
kette: { gap: space.sm, paddingTop: space.sm, paddingRight: space.lg },
kettenRahmen: {
width: 76, height: 95, borderRadius: radius.sm, overflow: 'hidden',
backgroundColor: colors.surface2,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
kettenBild: { width: '100%', height: '100%' },
kettenLeer: { alignItems: 'center', justifyContent: 'center' },
kettenNr: {
position: 'absolute', bottom: 4, left: 4,
minWidth: 16, height: 16, borderRadius: 8, paddingHorizontal: 4,
backgroundColor: 'rgba(0,0,0,0.65)', alignItems: 'center', justifyContent: 'center',
},
kettenNrText: { fontSize: 10, color: '#fff', fontWeight: '700' },
body: { padding: space.xl, gap: space.lg, paddingBottom: space.xxl },
titel: { ...text.title, color: colors.txt },
unter: { ...text.label, color: colors.mut, marginTop: -space.sm },
aktivHinweis: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
aktivText: { ...text.label, color: colors.mut },
abschnitt: { gap: space.md },
abschnittTitel: { ...text.heading, color: colors.txt },
leer: { ...text.body, color: colors.faint },
karte: {
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md, padding: space.lg, gap: space.xs,
},
karteKopf: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.md },
karteTitel: { ...text.body, fontWeight: '600', color: colors.txt, flexShrink: 1 },
status: { backgroundColor: colors.surface2, borderRadius: radius.pill, paddingHorizontal: space.md, paddingVertical: 2 },
statusOk: { backgroundColor: 'rgba(52,211,153,0.18)' },
statusText: { fontSize: 11, color: colors.mut, fontWeight: '600' },
karteZeile: { ...text.label, color: colors.mut },
karteSlots: { ...text.label, color: colors.faint },
kartePrompt: { ...text.label, color: colors.faint, lineHeight: 18, marginTop: space.xs },
});

View File

@@ -0,0 +1,88 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, ErrorNote, Field } from '@/components/ui';
import { Wahl } from '@/components/wahl';
import { fehlertext } from '@/lib/auth';
import { ordnerAnlegen, STARTWERTE, ZWECKE, type Startwert, type Zweck } from '@/lib/folders';
import { useSession } from '@/lib/session';
import { colors, space, text } from '@/theme/tokens';
export default function OrdnerNeuScreen() {
const router = useRouter();
const { brand, ordnerNeuLaden, setzeAktivenOrdner } = useSession();
const [name, setName] = useState('');
const [thema, setThema] = useState('');
const [zweck, setZweck] = useState<Zweck>('wissens_scope');
const [startwert, setStartwert] = useState<Startwert>('erben');
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function anlegen() {
if (!brand?.team_id) {
setFehler('Zur Marke ist kein Team hinterlegt bitte neu anmelden.');
return;
}
setFehler(null);
setBusy(true);
try {
const o = await ordnerAnlegen(brand.$id, brand.team_id, {
name: name.trim(),
zweck,
startwert_modus: startwert,
theme_md: thema.trim(),
});
await ordnerNeuLaden();
setzeAktivenOrdner(o);
router.back();
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Ordner anlegen</Text>
<Text style={s.unter}>
Ein Ordner ist ein eigener Wissensstand, keine Ablage. Was hier hineinkommt, prägt die
Bilder, die daraus entstehen und nur die.
</Text>
{fehler ? <ErrorNote message={fehler} /> : null}
<Field label="Name" value={name} onChangeText={setName} placeholder="z. B. Sommerkampagne" />
<Field
label="Worum geht es hier? (optional)"
value={thema}
onChangeText={setThema}
placeholder="Hilft später beim Einsortieren"
multiline
numberOfLines={3}
style={s.mehrzeilig}
/>
<Wahl label="Zweck" optionen={ZWECKE} wert={zweck} aufWahl={setZweck} />
<Wahl label="Startwerte" optionen={STARTWERTE} wert={startwert} aufWahl={setStartwert} />
<View style={s.actions}>
<Button title="Ordner anlegen" onPress={anlegen} busy={busy} disabled={name.trim().length < 2} />
<Button title="Abbrechen" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { padding: space.xl, gap: space.lg, paddingBottom: space.xxl },
titel: { ...text.title, color: colors.txt },
unter: { ...text.body, color: colors.mut, lineHeight: 21, marginTop: -space.sm },
mehrzeilig: { minHeight: 84, textAlignVertical: 'top' },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,106 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { TYPEN, type AssetMitBild, type AssetTyp } from '@/lib/assets';
import { useBildQuelle } from '@/lib/bildquelle';
import { colors, radius, space, text } from '@/theme/tokens';
type Props = {
label: string;
typ: AssetTyp;
modelle: AssetMitBild[];
gewaehlt?: string;
aufWahl: (id: string | undefined) => void;
hinweis?: string;
};
/**
* Auswahl eines eigenen Modells für einen Slot als Bildkachel, nicht als
* Dropdown. Bei Kulissen und Produkten ist das Aussehen die Information; ein
* Name wie „Halle, Metallwand" sagt nichts darüber, ob es passt.
*/
export function ModellWahl({ label, typ, modelle, gewaehlt, aufWahl, hinweis }: Props) {
const passende = modelle.filter((m) => m.typ === typ);
const titel = TYPEN.find((t) => t.wert === typ)?.titel ?? label;
return (
<View style={s.block}>
<View style={s.kopf}>
<Text style={s.label}>{label}</Text>
{gewaehlt ? (
<Pressable onPress={() => aufWahl(undefined)} accessibilityRole="button">
<Text style={s.loeschen}>entfernen</Text>
</Pressable>
) : null}
</View>
{hinweis ? <Text style={s.hinweis}>{hinweis}</Text> : null}
{passende.length === 0 ? (
<Text style={s.leer}>Noch kein Modell vom Typ {titel}". Erst im Profil anlegen.</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.reihe}>
{passende.map((m) => (
<Kachel key={m.$id} modell={m} aktiv={m.$id === gewaehlt} aufWahl={() => aufWahl(m.$id)} />
))}
</ScrollView>
)}
</View>
);
}
function Kachel({ modell, aktiv, aufWahl }: { modell: AssetMitBild; aktiv: boolean; aufWahl: () => void }) {
const quelle = useBildQuelle(modell.titelbildId);
return (
<Pressable
onPress={aufWahl}
accessibilityRole="radio"
accessibilityState={{ selected: aktiv }}
accessibilityLabel={modell.name}
style={({ pressed }) => [s.kachel, pressed ? s.gedrueckt : null]}>
<View style={[s.rahmen, aktiv ? s.rahmenAktiv : null]}>
{quelle ? (
<Image source={quelle} style={s.bild} contentFit="cover" transition={150} />
) : (
<View style={[s.bild, s.bildLeer]}>
<Ionicons name="image-outline" size={20} color={colors.faint} />
</View>
)}
{aktiv ? (
<View style={s.haken}>
<Ionicons name="checkmark" size={13} color="#fff" />
</View>
) : null}
</View>
<Text style={[s.name, aktiv ? s.nameAktiv : null]} numberOfLines={1}>
{modell.name}
</Text>
</Pressable>
);
}
const s = StyleSheet.create({
block: { gap: space.sm },
kopf: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
label: { ...text.label, color: colors.mut },
loeschen: { ...text.label, color: colors.faint },
hinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
leer: { ...text.label, color: colors.faint, lineHeight: 18, paddingVertical: space.sm },
reihe: { gap: space.md, paddingRight: space.xl, paddingTop: space.xs },
kachel: { width: 92, gap: space.xs },
gedrueckt: { opacity: 0.8 },
rahmen: {
width: 92, height: 92, borderRadius: radius.md, overflow: 'hidden',
borderColor: colors.border, borderWidth: 2, backgroundColor: colors.surface,
},
rahmenAktiv: { borderColor: colors.accent },
bild: { width: '100%', height: '100%' },
bildLeer: { alignItems: 'center', justifyContent: 'center' },
haken: {
position: 'absolute', top: 5, right: 5,
width: 20, height: 20, borderRadius: 10, backgroundColor: colors.accent,
alignItems: 'center', justifyContent: 'center',
},
name: { ...text.label, color: colors.faint },
nameAktiv: { color: colors.txt },
});

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, radius, space, text } from '@/theme/tokens';
type ScreenProps = {
title: string;
/** Kurzer Hinweis, was hier später entsteht nur solange der Screen leer ist. */
hint?: string;
children?: ReactNode;
};
/**
* Gemeinsames Grundgerüst aller Screens: Hintergrund, sichere Ränder, Titel.
* In E1 tragen die drei Tabs damit nur ihren Namen ab E3 kommt Inhalt in
* `children`, der Rahmen bleibt.
*/
export function Screen({ title, hint, children }: ScreenProps) {
return (
<SafeAreaView style={styles.safe} edges={['top', 'left', 'right']}>
<View style={styles.body}>
<Text style={styles.title}>{title}</Text>
{hint ? (
<View style={styles.hintBox}>
<Text style={styles.hint}>{hint}</Text>
</View>
) : null}
{children}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { flex: 1, paddingHorizontal: space.xl, paddingTop: space.xl, gap: space.lg },
title: { ...text.title, color: colors.txt },
hintBox: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
padding: space.lg,
},
hint: { ...text.body, color: colors.mut, lineHeight: 21 },
});

View File

@@ -0,0 +1,114 @@
import { forwardRef } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
TextInput,
View,
type TextInputProps,
} from 'react-native';
import { colors, radius, space, text } from '@/theme/tokens';
type FieldProps = TextInputProps & { label: string; error?: string };
export const Field = forwardRef<TextInput, FieldProps>(function Field(
{ label, error, style, ...props },
ref,
) {
return (
<View style={s.field}>
<Text style={s.label}>{label}</Text>
<TextInput
ref={ref}
placeholderTextColor={colors.faint}
style={[s.input, error ? s.inputError : null, style]}
{...props}
/>
{error ? <Text style={s.error}>{error}</Text> : null}
</View>
);
});
type ButtonProps = {
title: string;
onPress: () => void;
variant?: 'primary' | 'ghost';
busy?: boolean;
disabled?: boolean;
};
export function Button({ title, onPress, variant = 'primary', busy, disabled }: ButtonProps) {
const off = disabled || busy;
return (
<Pressable
onPress={onPress}
disabled={off}
accessibilityRole="button"
style={({ pressed }) => [
s.btn,
variant === 'primary' ? s.btnPrimary : s.btnGhost,
pressed && !off ? s.btnPressed : null,
off ? s.btnOff : null,
]}>
{busy ? (
<ActivityIndicator color={variant === 'primary' ? '#fff' : colors.txt} />
) : (
<Text style={[s.btnText, variant === 'ghost' ? s.btnTextGhost : null]}>{title}</Text>
)}
</Pressable>
);
}
/** Fehlermeldung aus einem fehlgeschlagenen Aufruf nie stumm scheitern lassen. */
export function ErrorNote({ message }: { message: string }) {
return (
<View style={s.errBox}>
<Text style={s.errText}>{message}</Text>
</View>
);
}
const s = StyleSheet.create({
field: { gap: space.sm },
label: { ...text.label, color: colors.mut },
input: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.sm,
paddingHorizontal: space.lg,
paddingVertical: space.md,
color: colors.txt,
fontSize: 16,
},
inputError: { borderColor: colors.accent },
error: { ...text.label, color: colors.accent },
btn: {
borderRadius: radius.pill,
paddingVertical: space.lg,
paddingHorizontal: space.xl,
alignItems: 'center',
justifyContent: 'center',
minHeight: 52,
},
btnPrimary: { backgroundColor: colors.accent },
btnGhost: {
backgroundColor: 'transparent',
borderColor: colors.borderStrong,
borderWidth: StyleSheet.hairlineWidth,
},
btnPressed: { opacity: 0.75 },
btnOff: { opacity: 0.45 },
btnText: { ...text.body, fontWeight: '600', color: '#fff', fontSize: 16 },
btnTextGhost: { color: colors.txt },
errBox: {
backgroundColor: 'rgba(255,92,57,0.12)',
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.sm,
padding: space.lg,
},
errText: { ...text.body, color: colors.txt },
});

View File

@@ -0,0 +1,75 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors, radius, space, text } from '@/theme/tokens';
type Option<T extends string> = { wert: T; titel: string; erklaerung: string };
type Props<T extends string> = {
label: string;
optionen: Option<T>[];
wert: T;
aufWahl: (w: T) => void;
};
/**
* Auswahl mit Erklärung je Option statt eines nackten Schalters.
*
* Bewusst so: „Zweck" und „Startwerte" beim Ordner entscheiden darüber, woraus
* später generiert wird wer sie nicht versteht, baut sich einen Ordner, der
* nicht das tut, was er erwartet (konzept-bilder-feed.md §9).
*/
export function Wahl<T extends string>({ label, optionen, wert, aufWahl }: Props<T>) {
return (
<View style={s.block}>
<Text style={s.label}>{label}</Text>
<View style={s.optionen}>
{optionen.map((o) => {
const aktiv = o.wert === wert;
return (
<Pressable
key={o.wert}
onPress={() => aufWahl(o.wert)}
accessibilityRole="radio"
accessibilityState={{ selected: aktiv }}
style={({ pressed }) => [s.opt, aktiv ? s.optAktiv : null, pressed ? s.gedrueckt : null]}>
<View style={s.kopf}>
<View style={[s.punkt, aktiv ? s.punktAktiv : null]}>
{aktiv ? <View style={s.punktKern} /> : null}
</View>
<Text style={[s.titel, aktiv ? s.titelAktiv : null]}>{o.titel}</Text>
</View>
<Text style={s.erklaerung}>{o.erklaerung}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const s = StyleSheet.create({
block: { gap: space.sm },
label: { ...text.label, color: colors.mut },
optionen: { gap: space.sm },
opt: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
padding: space.lg,
gap: space.sm,
},
optAktiv: { borderColor: colors.accent, backgroundColor: 'rgba(255,92,57,0.08)' },
gedrueckt: { opacity: 0.8 },
kopf: { flexDirection: 'row', alignItems: 'center', gap: space.md },
punkt: {
width: 18, height: 18, borderRadius: 9,
borderColor: colors.borderStrong, borderWidth: 1.5,
alignItems: 'center', justifyContent: 'center',
},
punktAktiv: { borderColor: colors.accent },
punktKern: { width: 9, height: 9, borderRadius: 5, backgroundColor: colors.accent },
titel: { ...text.body, fontWeight: '600', color: colors.txt },
titelAktiv: { color: colors.txt },
erklaerung: { ...text.label, color: colors.mut, lineHeight: 18 },
});

15
client/src/global.css Normal file
View File

@@ -0,0 +1,15 @@
/* Nur für den Web-Build. Font-Stack und Grundfarbe wie in prototyp-app.html,
damit die Seite beim Laden nicht kurz weiß aufblitzt. */
:root {
--font-display:
-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Inter, Roboto, system-ui, sans-serif;
--font-mono: ui-monospace, Menlo, SFMono-Regular, Consolas, monospace;
}
html,
body,
#root {
background-color: #0a0a0a;
color: #ffffff;
font-family: var(--font-display);
}

View File

@@ -0,0 +1,32 @@
/**
* Appwrite-Client **native** (iOS/Android).
*
* Es gibt zwei Fassungen dieser Datei: diese und `appwrite.web.ts`. Metro löst
* die Endung nach Plattform auf, der Rest der App importiert immer nur
* `@/lib/appwrite` und merkt vom Unterschied nichts.
*
* Warum überhaupt zwei: `react-native-appwrite` bringt native Abhängigkeiten
* mit (URL-Polyfill, Dateizugriff) und läuft nicht im Browser; das Web-SDK
* `appwrite` kennt umgekehrt kein React Native. Beide exportieren dieselben
* Klassen, deshalb ist die Trennung hier billig und wäre später teuer.
*/
import 'react-native-url-polyfill/auto';
import { Account, Client, Storage, TablesDB, Teams } from 'react-native-appwrite';
import { appwriteConfig } from './config';
export const client = new Client()
.setEndpoint(appwriteConfig.endpoint)
.setProject(appwriteConfig.project);
// TODO (E2): .setPlatform('<bundle-id>') sobald die native App-Kennung feststeht
// und in der Appwrite-Konsole als Platform registriert ist. Ohne das lehnt
// Appwrite Anfragen aus dem nativen Build ab.
export const account = new Account(client);
export const tables = new TablesDB(client);
export const storage = new Storage(client);
export const teams = new Teams(client);
export { ID, Permission, Query, Role } from 'react-native-appwrite';
export const databaseId = appwriteConfig.databaseId;

View File

@@ -0,0 +1,22 @@
/**
* Appwrite-Client **Web**. Gegenstück zu `appwrite.ts`, siehe dortiger Kopf.
*
* Die Web-App ist nicht nur ein Nebenprodukt: die Zahlung soll laut
* projekt-uebersicht.md §9 bewusst hier laufen, um die App-Store-Abgabe zu
* umgehen. Dieser Pfad muss also genauso funktionieren wie der native.
*/
import { Account, Client, Storage, TablesDB, Teams } from 'appwrite';
import { appwriteConfig } from './config';
export const client = new Client()
.setEndpoint(appwriteConfig.endpoint)
.setProject(appwriteConfig.project);
export const account = new Account(client);
export const tables = new TablesDB(client);
export const storage = new Storage(client);
export const teams = new Teams(client);
export { ID, Permission, Query, Role } from 'appwrite';
export const databaseId = appwriteConfig.databaseId;

154
client/src/lib/assets.ts Normal file
View File

@@ -0,0 +1,154 @@
import { ID, Query, databaseId, tables } from './appwrite';
import { BUCKET_REFERENZEN as BUCKET } from './dateien';
import { teamRechte } from './permissions';
import { hochladen, type Auswahl } from './upload';
/**
* „Modell“ heißt in diesem Projekt **immer** das Asset Person, Produkt,
* Kulisse oder Logo. Das generierende KI-Modell heißt ausgeschrieben
* (projekt-uebersicht.md, Sprachregelung).
*/
export type AssetTyp = 'gesicht' | 'produkt' | 'kulisse' | 'logo' | 'sonstiges';
export type Asset = {
$id: string;
brand_id: string;
typ: AssetTyp;
name: string;
released_version_id?: string;
ist_teilbar?: boolean;
};
export type AssetVersion = {
$id: string;
asset_id: string;
version_no: number;
status: 'entwurf' | 'freigegeben' | 'archiviert';
beschreibung_md?: string;
merkmale?: string[];
reference_file_ids?: string[];
};
export const TYPEN: { wert: AssetTyp; titel: string; erklaerung: string; icon: string }[] = [
{ wert: 'gesicht', titel: 'Person', erklaerung: 'Ein Gesicht, das in Bildern wiederkehrt.', icon: 'person-outline' },
{ wert: 'produkt', titel: 'Produkt', erklaerung: 'Ein Objekt, das genau so aussehen muss wie im Regal.', icon: 'cube-outline' },
{ wert: 'kulisse', titel: 'Kulisse', erklaerung: 'Ein Ort oder Hintergrund, vor dem gearbeitet wird.', icon: 'image-outline' },
{ wert: 'logo', titel: 'Logo', erklaerung: 'Markenzeichen, das nie verfremdet werden darf.', icon: 'ribbon-outline' },
];
export { BUCKET_REFERENZEN, dateiUrl } from './dateien';
export async function assetListe(brandId: string, typ?: AssetTyp): Promise<Asset[]> {
const queries = [Query.equal('brand_id', brandId), Query.limit(100)];
if (typ) queries.push(Query.equal('typ', typ));
const res = await tables.listRows({ databaseId, tableId: 'assets', queries });
return res.rows as unknown as Asset[];
}
export async function versionLesen(id: string): Promise<AssetVersion | null> {
try {
const row = await tables.getRow({ databaseId, tableId: 'asset_versions', rowId: id });
return row as unknown as AssetVersion;
} catch {
return null;
}
}
export async function versionenZuAsset(assetId: string): Promise<AssetVersion[]> {
const res = await tables.listRows({
databaseId,
tableId: 'asset_versions',
queries: [Query.equal('asset_id', assetId), Query.limit(50)],
});
return res.rows as unknown as AssetVersion[];
}
/**
* Legt ein Modell samt erster Version an und gibt sie sofort frei.
*
* Release-Prinzip (projekt-uebersicht.md §4): Bilder verwenden immer eine
* *freigegebene* Version, damit nichts unbemerkt wegdriftet. Version 1 wird
* hier direkt freigegeben, weil es sonst nichts gäbe, womit man arbeiten kann.
*/
export async function assetAnlegen(
brandId: string,
teamId: string,
daten: { typ: AssetTyp; name: string; beschreibung: string; bilder: Auswahl[] },
): Promise<Asset> {
const rechte = teamRechte(teamId);
const fileIds: string[] = [];
for (const bild of daten.bilder) {
fileIds.push(await hochladen(BUCKET, bild, rechte));
}
const asset = (await tables.createRow({
databaseId,
tableId: 'assets',
rowId: ID.unique(),
data: { brand_id: brandId, typ: daten.typ, name: daten.name, ist_teilbar: false },
permissions: rechte,
})) as unknown as Asset;
const version = (await tables.createRow({
databaseId,
tableId: 'asset_versions',
rowId: ID.unique(),
data: {
asset_id: asset.$id,
version_no: 1,
status: 'freigegeben',
beschreibung_md: daten.beschreibung,
reference_file_ids: fileIds,
},
permissions: rechte,
})) as unknown as AssetVersion;
await tables.updateRow({
databaseId,
tableId: 'assets',
rowId: asset.$id,
data: { released_version_id: version.$id },
});
return { ...asset, released_version_id: version.$id };
}
export async function assetLoeschen(id: string): Promise<void> {
await tables.deleteRow({ databaseId, tableId: 'assets', rowId: id });
}
export type AssetMitBild = Asset & { titelbildId?: string };
/**
* Modelle samt Titelbild das erste Referenzbild der **freigegebenen** Version.
*
* Holt alle Versionen in einer einzigen Abfrage statt einer je Modell:
* `Query.equal` nimmt auch eine Liste. Bei 30 Kulissen wären das sonst 30
* Rundreisen, nur um Vorschaubilder zu zeigen.
*/
export async function assetsMitBild(brandId: string, typ?: AssetTyp): Promise<AssetMitBild[]> {
const liste = await assetListe(brandId, typ);
const versionIds = liste.map((a) => a.released_version_id).filter((v): v is string => !!v);
if (!versionIds.length) return liste;
const res = await tables.listRows({
databaseId,
tableId: 'asset_versions',
queries: [Query.equal('$id', versionIds), Query.limit(100)],
});
const nachId = new Map(
(res.rows as unknown as AssetVersion[]).map((v) => [v.$id, v.reference_file_ids?.[0]]),
);
return liste.map((a) => ({
...a,
titelbildId: a.released_version_id ? nachId.get(a.released_version_id) : undefined,
}));
}
/**
* Mehr als 7 Referenzbilder mitteln die Merkmale weg („feature averaging“),
* 46 sind das Optimum (projekt-uebersicht.md §8).
*/
export const BILDER_MAX = 6;
export const BILDER_EMPFOHLEN = 4;

88
client/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,88 @@
import { ID, Permission, Query, Role, account, databaseId, tables, teams } from './appwrite';
/** Eine Zeile aus `brands`, so weit die App sie braucht. */
export type Brand = {
$id: string;
team_id?: string;
label_name: string;
anzeigename?: string;
nische?: string;
plan?: string;
status?: string;
};
export type Konto = { $id: string; email: string; name: string };
export async function aktuellesKonto(): Promise<Konto | null> {
try {
const u = await account.get();
return { $id: u.$id, email: u.email, name: u.name };
} catch {
return null; // keine Session kein Fehlerfall, sondern der Normalzustand vor dem Login
}
}
/**
* Die Brand des angemeldeten Kontos.
*
* Es braucht hier bewusst **keinen** Filter auf die eigene ID: `brands` hat
* `rowSecurity`, und die Zeile trägt nur die Team-Permission ihrer Brand. Die
* Abfrage liefert deshalb von sich aus ausschließlich die eigene Zeile. Genau
* das ist die Mandantentrennung sie steckt in den Rechten, nicht im Query.
*/
export async function eigeneBrand(): Promise<Brand | null> {
const res = await tables.listRows({
databaseId,
tableId: 'brands',
queries: [Query.limit(1)],
});
return (res.rows[0] as unknown as Brand) ?? null;
}
/**
* Legt Team + `brands`-Zeile an, falls beides noch fehlt.
*
* Eigene Funktion, weil die Registrierung aus vier Schritten besteht und
* zwischen Schritt 2 und 4 abbrechen kann (Netz weg, App geschlossen). Dann
* existiert ein Konto ohne Brand. Statt diesen Zustand als Fehler zu behandeln,
* wird er beim nächsten Start einfach nachgeholt.
*/
export async function brandSicherstellen(labelName: string): Promise<Brand> {
const vorhanden = await eigeneBrand();
if (vorhanden) return vorhanden;
const meine = await teams.list({ queries: [Query.limit(1)] });
const team = meine.teams[0] ?? (await teams.create({ teamId: ID.unique(), name: labelName }));
const rolle = Role.team(team.$id);
const row = await tables.createRow({
databaseId,
tableId: 'brands',
rowId: ID.unique(),
data: { team_id: team.$id, label_name: labelName, status: 'trial', plan: 'trial' },
// Kein create() die Zeile existiert ja bereits. Lesen/Ändern/Löschen
// ausschließlich für das Team dieser Brand.
permissions: [Permission.read(rolle), Permission.update(rolle), Permission.delete(rolle)],
});
return row as unknown as Brand;
}
export async function registrieren(email: string, password: string, labelName: string): Promise<Brand> {
await account.create({ userId: ID.unique(), email, password, name: labelName });
await account.createEmailPasswordSession({ email, password });
return brandSicherstellen(labelName);
}
export async function anmelden(email: string, password: string): Promise<void> {
await account.createEmailPasswordSession({ email, password });
}
export async function abmelden(): Promise<void> {
await account.deleteSession({ sessionId: 'current' });
}
/** Appwrite-Fehler tragen die Meldung in `message`; alles andere abfangen. */
export function fehlertext(e: unknown): string {
if (e && typeof e === 'object' && 'message' in e) return String((e as { message: unknown }).message);
return 'Unbekannter Fehler.';
}

View File

@@ -0,0 +1,16 @@
import { BUCKET_REFERENZEN, dateiUrl } from './dateien';
/**
* Bildquelle für ein geschütztes Appwrite-File **native**.
* Gegenstück: `bildquelle.web.ts`.
*
* Nativ teilen sich `<Image>` und die SDK-Aufrufe den HTTP-Stack der
* Plattform samt Cookie-Speicher, und die Same-Site-Regeln des Browsers gelten
* nicht. Die schlichte URL genügt hier also.
*/
export function useBildQuelle(
fileId?: string,
bucketId: string = BUCKET_REFERENZEN,
): { uri: string } | undefined {
return fileId ? { uri: dateiUrl(fileId, bucketId) } : undefined;
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { BUCKET_REFERENZEN, dateiUrl } from './dateien';
/**
* Bildquelle für ein geschütztes Appwrite-File **Web**.
* Gegenstück: `bildquelle.ts`.
*
* Warum nicht einfach die URL ins `<img>`: Ein `<img>` schickt bei einer
* site-fremden Anfrage die Appwrite-Session nicht mit, das Bild bleibt leer.
* Im Betrieb liegen App und Appwrite unter derselben Domain (`webklar.com`),
* dort wäre das kein Thema in der Entwicklung läuft die App aber auf
* `localhost`, und dann ist jede Anfrage site-fremd.
*
* Deshalb wird die Datei einmal per `fetch` mit `credentials: 'include'`
* geholt und als Object-URL gerendert. Das funktioniert in beiden Fällen und
* spart die Sonderbehandlung „nur lokal kaputt".
*/
export function useBildQuelle(
fileId?: string,
bucketId: string = BUCKET_REFERENZEN,
): { uri: string } | undefined {
const [uri, setUri] = useState<string>();
useEffect(() => {
if (!fileId) {
setUri(undefined);
return;
}
let abgebrochen = false;
let objectUrl: string | undefined;
fetch(dateiUrl(fileId, bucketId), { credentials: 'include' })
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((blob) => {
if (abgebrochen) return;
objectUrl = URL.createObjectURL(blob);
setUri(objectUrl);
})
.catch(() => {
if (!abgebrochen) setUri(undefined);
});
return () => {
abgebrochen = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [fileId, bucketId]);
return uri ? { uri } : undefined;
}

35
client/src/lib/config.ts Normal file
View File

@@ -0,0 +1,35 @@
import Constants from 'expo-constants';
/**
* Client-Konfiguration aus `app.json` → `expo.extra.appwrite`.
*
* ⚠️ Hier gehört ausschließlich hinein, was ohnehin im Bundle landet und
* öffentlich sein darf: Endpoint, Projekt-ID, Datenbank-ID. Der Appwrite-
* **Server-API-Key** darf niemals in den Client er steht in der .env im
* Repo-Root und wird nur von den Skripten in `scripts/` und später von
* Appwrite-Functions benutzt.
*/
type AppwriteConfig = {
endpoint: string;
project: string;
databaseId: string;
};
const extra = Constants.expoConfig?.extra as { appwrite?: Partial<AppwriteConfig> } | undefined;
const cfg = extra?.appwrite;
function required(key: keyof AppwriteConfig): string {
const value = cfg?.[key];
if (!value) {
throw new Error(
`Appwrite-Konfiguration unvollständig: "${key}" fehlt in app.json unter expo.extra.appwrite.`,
);
}
return value;
}
export const appwriteConfig: AppwriteConfig = {
endpoint: required('endpoint'),
project: required('project'),
databaseId: required('databaseId'),
};

18
client/src/lib/dateien.ts Normal file
View File

@@ -0,0 +1,18 @@
import { appwriteConfig } from './config';
export const BUCKET_REFERENZEN = 'asset-references';
export const BUCKET_UPLOADS = 'uploads';
export const BUCKET_BILDER = 'generated-images';
/**
* Roh-URL einer Datei in Appwrite.
*
* Die Buckets haben `fileSecurity`, die Datei trägt nur die Team-Permission
* der Abruf braucht also die Session. Ob die mitgeht, hängt von der Plattform
* ab; deshalb geht die Anzeige nicht über diese URL, sondern über
* `useBildQuelle` (siehe `bildquelle.web.ts`).
*/
export function dateiUrl(fileId: string, bucketId: string = BUCKET_REFERENZEN): string {
const { endpoint, project } = appwriteConfig;
return `${endpoint}/storage/buckets/${bucketId}/files/${fileId}/view?project=${project}`;
}

167
client/src/lib/folders.ts Normal file
View File

@@ -0,0 +1,167 @@
import { ID, Query, databaseId, tables } from './appwrite';
import { teamRechte } from './permissions';
/**
* Ein Ordner ist ein **privater Wissens-Scope**, kein Sortier-Ordner
* (konzept-bilder-feed.md §9). Beim Generieren zieht P4 nur die Attribute des
* gewählten Ordners er überschreibt die brand-weite Ebene vollständig,
* gemischt wird nicht.
*/
export type Ordner = {
$id: string;
brand_id: string;
name: string;
theme_md?: string;
zweck?: string;
startwert_modus?: Startwert;
ist_default?: boolean;
post_count?: number;
signal_count?: number;
};
/** `sammlung` = nur einsortieren · `wissens_scope` = daraus erstellen. */
export type Zweck = 'sammlung' | 'wissens_scope';
/** Woher die Attribut-Scores beim Anlegen kommen. */
export type Startwert = 'erben' | 'aus_posts' | 'neutral';
export const ZWECKE: { wert: Zweck; titel: string; erklaerung: string }[] = [
{
wert: 'wissens_scope',
titel: 'Daraus erstellen',
erklaerung: 'Generierungen ziehen ihr Wissen aus diesem Ordner. Das ist der Normalfall.',
},
{
wert: 'sammlung',
titel: 'Nur sammeln',
erklaerung: 'Reine Ablage zum Sortieren. Beeinflusst keine Generierung.',
},
];
export const STARTWERTE: { wert: Startwert; titel: string; erklaerung: string }[] = [
{
wert: 'erben',
titel: 'Vom Konto erben',
erklaerung: 'Startet mit einer Kopie der bisherigen Scores. Gut, wenn der Ordner die Marke fortsetzt.',
},
{
wert: 'aus_posts',
titel: 'Nur aus den Posts',
erklaerung:
'Startet leer und lernt ausschließlich aus dem, was hier landet. Gut, wenn der Ordner bewusst anders aussehen soll als der Rest.',
},
];
export async function ordnerListe(brandId: string): Promise<Ordner[]> {
const res = await tables.listRows({
databaseId,
tableId: 'folders',
queries: [Query.equal('brand_id', brandId), Query.limit(100)],
});
return res.rows as unknown as Ordner[];
}
export async function ordnerLesen(id: string): Promise<Ordner> {
const row = await tables.getRow({ databaseId, tableId: 'folders', rowId: id });
return row as unknown as Ordner;
}
export async function ordnerAnlegen(
brandId: string,
teamId: string,
daten: { name: string; zweck: Zweck; startwert_modus: Startwert; theme_md?: string },
): Promise<Ordner> {
const row = await tables.createRow({
databaseId,
tableId: 'folders',
rowId: ID.unique(),
data: {
brand_id: brandId,
name: daten.name,
zweck: daten.zweck,
startwert_modus: daten.startwert_modus,
theme_md: daten.theme_md ?? '',
ist_default: false,
post_count: 0,
signal_count: 0,
},
permissions: teamRechte(teamId),
});
const ordner = row as unknown as Ordner;
await ordnerInitialisieren(brandId, teamId, ordner.$id, daten.startwert_modus);
return ordner;
}
/**
* Füllt `attribute_scores` für einen frischen Ordner.
*
* `erben` kopiert die brand-weite Ebene (`folder_id = null`) der Ordner setzt
* die Marke fort. `aus_posts` und `neutral` starten leer: dort soll das Wissen
* ausschließlich aus dem entstehen, was später hineinkommt. Genau das ist der
* Zweck der Ordner Wissen **segmentieren statt mitteln**
* (konzept-bilder-feed.md §9).
*
* Läuft vorerst im Client statt in der Function `ordner-initialisieren`. Das
* ist vertretbar, weil nur eigene Zeilen kopiert werden und die Zeilenrechte
* das ohnehin begrenzen beim Umzug in eine Function ändert sich nur der Ort.
*/
export async function ordnerInitialisieren(
brandId: string,
teamId: string,
folderId: string,
modus: Startwert,
): Promise<number> {
if (modus !== 'erben') return 0;
const quelle = await tables.listRows({
databaseId,
tableId: 'attribute_scores',
queries: [Query.equal('brand_id', brandId), Query.isNull('folder_id'), Query.limit(200)],
});
const rechte = teamRechte(teamId);
let kopiert = 0;
for (const z of quelle.rows as unknown as BrandScore[]) {
await tables.createRow({
databaseId,
tableId: 'attribute_scores',
rowId: ID.unique(),
data: {
brand_id: brandId,
attribute_id: z.attribute_id,
category_id: z.category_id,
folder_id: folderId,
score: z.score ?? 5000,
start_value: z.score ?? 5000,
// Der Ordner erbt den Wert, aber nicht die Sicherheit: k_factor zurück
// auf 32, weil im neuen Scope noch nichts belegt ist.
k_factor: 32,
start_quelle: 'geerbt',
used_count: 0, wins: 0, losses: 0,
},
permissions: rechte,
});
kopiert++;
}
return kopiert;
}
type BrandScore = {
attribute_id: string;
category_id: string;
score?: number;
};
export async function ordnerLoeschen(id: string): Promise<void> {
await tables.deleteRow({ databaseId, tableId: 'folders', rowId: id });
}
/** „4 Posts lernt noch“: ohne Reifegrad ist für den Nutzer nicht erklärbar,
* warum zwei gleiche Prompts verschiedene Bilder ergeben (app-aufbau.md §5.3). */
export function reifegrad(o: Ordner): string {
const posts = o.post_count ?? 0;
const signale = o.signal_count ?? 0;
if (posts === 0) return 'leer';
if (signale < 5) return `${posts} Posts lernt noch`;
return `${posts} Posts eingespielt`;
}

View File

@@ -0,0 +1,13 @@
import { Permission, Role } from './appwrite';
/**
* Die Rechte, die jede Zeile und jede Datei einer Brand bekommt.
*
* Kein `create` das regelt das Tabellen-Recht, und die Zeile existiert beim
* Setzen ja bereits. Kein `read("any")`: öffentlich wird ausschließlich ein
* veröffentlichter Post, und zwar gezielt beim Veröffentlichen (E7).
*/
export function teamRechte(teamId: string): string[] {
const r = Role.team(teamId);
return [Permission.read(r), Permission.update(r), Permission.delete(r)];
}

166
client/src/lib/posts.ts Normal file
View File

@@ -0,0 +1,166 @@
import { ID, Query, databaseId, tables } from './appwrite';
import { teamRechte } from './permissions';
/**
* Ein Post ist ein **Slot-Rezept**, kein Freitext-Prompt nur deshalb ist er
* überhaupt kopierbar (konzept-bilder-feed.md §3). Die Slots liegen strukturiert
* in `slots`, damit P19 daraus später Chips ableiten kann, ohne den Prompt im
* Wortlaut preiszugeben.
*/
export type Slots = {
kulisse_asset_id?: string;
person_asset_id?: string;
produkt_asset_id?: string;
licht?: string;
kamera?: string;
farbe?: string;
werbetext?: string;
};
export type Post = {
$id: string;
brand_id: string;
folder_id?: string;
titel?: string;
user_prompt?: string;
slots?: string;
format?: string;
bild_count?: number;
status?: 'entwurf' | 'generiert' | 'fehler';
sichtbarkeit?: 'privat' | 'oeffentlich';
nische?: string;
};
export const FORMATE = [
{ wert: '1:1', titel: 'Quadrat', erklaerung: 'Feed-Beiträge, Produktkacheln.' },
{ wert: '4:5', titel: 'Hochformat', erklaerung: 'Nimmt im Feed mehr Fläche ein.' },
{ wert: '9:16', titel: 'Story', erklaerung: 'Bildschirmfüllend, für Stories und Reels.' },
] as const;
export type Format = (typeof FORMATE)[number]['wert'];
export async function postsImOrdner(folderId: string): Promise<Post[]> {
const res = await tables.listRows({
databaseId,
tableId: 'posts',
queries: [Query.equal('folder_id', folderId), Query.orderDesc('$createdAt'), Query.limit(100)],
});
return res.rows as unknown as Post[];
}
export async function postsDerMarke(brandId: string): Promise<Post[]> {
const res = await tables.listRows({
databaseId,
tableId: 'posts',
queries: [Query.equal('brand_id', brandId), Query.orderDesc('$createdAt'), Query.limit(100)],
});
return res.rows as unknown as Post[];
}
/**
* Legt das Rezept an und reiht die Generierungen ein.
*
* Der Post entsteht **sofort** mit `status: 'entwurf'`, bevor irgendetwas
* generiert wird. Das ist Absicht: Generierung ist asynchron, und wer während
* des Wartens weg navigiert, muss das Ergebnis wiederfinden (app-aufbau.md
* §2.1). Ohne die Zeile gäbe es nichts, wohin man zurückkehren könnte.
*
* Je Bild der Kette eine `jobs`-Zeile. Abgearbeitet werden sie vom
* Job-Dispatcher mit Server-Key **nicht** vom Client, denn dafür müsste der
* Anbieter-Schlüssel ins Bundle.
*/
export async function postAnlegen(
brandId: string,
teamId: string,
daten: {
folderId: string;
titel: string;
prompt: string;
format: Format;
bildCount: number;
slots: Slots;
nische?: string;
},
): Promise<Post> {
const rechte = teamRechte(teamId);
const post = (await tables.createRow({
databaseId,
tableId: 'posts',
rowId: ID.unique(),
data: {
brand_id: brandId,
folder_id: daten.folderId,
titel: daten.titel,
user_prompt: daten.prompt,
slots: JSON.stringify(daten.slots),
format: daten.format,
bild_count: daten.bildCount,
status: 'entwurf',
sichtbarkeit: 'privat', // Veröffentlichen ist ein aktiver, eigener Schritt
kopien_count: 0,
feed_score: 0,
...(daten.nische ? { nische: daten.nische } : {}),
},
permissions: rechte,
})) as unknown as Post;
for (let i = 0; i < daten.bildCount; i++) {
await tables.createRow({
databaseId,
tableId: 'jobs',
rowId: ID.unique(),
data: {
brand_id: brandId,
typ: 'bild_gen',
prompt_template_key: 'P7',
status: 'wartend',
refs: JSON.stringify({ post_id: post.$id, position: i + 1 }),
},
permissions: rechte,
});
}
return post;
}
export type PostBild = {
$id: string;
post_id: string;
position: number;
typ: 'motiv' | 'text_overlay';
storage_file_id?: string;
};
/**
* Die Bilderketten mehrerer Posts in einer Abfrage nicht eine je Post.
* Ein Ordner mit 20 Posts wären sonst 20 Rundreisen für eine Listenansicht.
*/
export async function bilderZuPosts(postIds: string[]): Promise<Map<string, PostBild[]>> {
const nach = new Map<string, PostBild[]>();
if (!postIds.length) return nach;
const res = await tables.listRows({
databaseId,
tableId: 'post_images',
queries: [Query.equal('post_id', postIds), Query.orderAsc('position'), Query.limit(200)],
});
for (const b of res.rows as unknown as PostBild[]) {
const liste = nach.get(b.post_id) ?? [];
liste.push(b);
nach.set(b.post_id, liste);
}
return nach;
}
export async function postLoeschen(id: string): Promise<void> {
await tables.deleteRow({ databaseId, tableId: 'posts', rowId: id });
}
export function slotsLesen(post: Post): Slots {
try {
return post.slots ? (JSON.parse(post.slots) as Slots) : {};
} catch {
return {};
}
}

101
client/src/lib/session.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import {
abmelden as abmeldenApi,
aktuellesKonto,
brandSicherstellen,
eigeneBrand,
type Brand,
type Konto,
} from './auth';
import { ordnerListe, type Ordner } from './folders';
type Session = {
/** true, solange noch nicht feststeht, ob jemand angemeldet ist. */
laedt: boolean;
konto: Konto | null;
brand: Brand | null;
/**
* Der aktive Ordner bestimmt, welches Wissen in den Prompt wandert. Er ist
* deshalb App-Zustand und kein Bildschirm-lokaler Wert (app-aufbau.md §2.2)
* sonst generiert der Nutzer im falschen Scope und versteht das Ergebnis nicht.
*/
aktiverOrdner: Ordner | null;
ordner: Ordner[];
setzeAktivenOrdner: (o: Ordner | null) => void;
ordnerNeuLaden: () => Promise<void>;
neuLaden: () => Promise<void>;
ausloggen: () => Promise<void>;
};
const Ctx = createContext<Session | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [laedt, setLaedt] = useState(true);
const [konto, setKonto] = useState<Konto | null>(null);
const [brand, setBrand] = useState<Brand | null>(null);
const [ordner, setOrdner] = useState<Ordner[]>([]);
const [aktiverOrdner, setzeAktivenOrdner] = useState<Ordner | null>(null);
const ordnerFuer = useCallback(async (b: Brand | null) => {
if (!b) {
setOrdner([]);
setzeAktivenOrdner(null);
return;
}
const liste = await ordnerListe(b.$id);
setOrdner(liste);
setzeAktivenOrdner((bisher) => {
if (bisher) return liste.find((o) => o.$id === bisher.$id) ?? liste[0] ?? null;
return liste.find((o) => o.ist_default) ?? liste[0] ?? null;
});
}, []);
const neuLaden = useCallback(async () => {
const k = await aktuellesKonto();
setKonto(k);
if (!k) {
setBrand(null);
await ordnerFuer(null);
setLaedt(false);
return;
}
// Konto ohne Brand kann entstehen, wenn die Registrierung mittendrin
// abgebrochen ist hier wird das stillschweigend nachgeholt.
let b = await eigeneBrand();
if (!b) b = await brandSicherstellen(k.name || k.email);
setBrand(b);
await ordnerFuer(b);
setLaedt(false);
}, [ordnerFuer]);
const ordnerNeuLaden = useCallback(() => ordnerFuer(brand), [brand, ordnerFuer]);
const ausloggen = useCallback(async () => {
await abmeldenApi();
setKonto(null);
setBrand(null);
setOrdner([]);
setzeAktivenOrdner(null);
}, []);
useEffect(() => {
void neuLaden();
}, [neuLaden]);
return (
<Ctx.Provider
value={{
laedt, konto, brand, ordner, aktiverOrdner,
setzeAktivenOrdner, ordnerNeuLaden, neuLaden, ausloggen,
}}>
{children}
</Ctx.Provider>
);
}
export function useSession(): Session {
const v = useContext(Ctx);
if (!v) throw new Error('useSession muss innerhalb von <SessionProvider> stehen.');
return v;
}

29
client/src/lib/upload.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Datei-Upload **native**. Gegenstück: `upload.web.ts`.
*
* Das native SDK will die Datei als `{name, type, size, uri}` und liest sie
* selbst vom Dateisystem; das Web-SDK will ein `File`-Objekt. Deshalb liegt der
* Upload genauso plattform-getrennt wie der Client selbst.
*/
import { ID, storage } from './appwrite';
export type Auswahl = {
uri: string;
name: string;
mimeType: string;
size: number;
};
export async function hochladen(
bucketId: string,
datei: Auswahl,
permissions: string[],
): Promise<string> {
const f = await storage.createFile({
bucketId,
fileId: ID.unique(),
file: { name: datei.name, type: datei.mimeType, size: datei.size, uri: datei.uri },
permissions,
});
return f.$id;
}

View File

@@ -0,0 +1,34 @@
/**
* Datei-Upload **Web**. Gegenstück: `upload.ts`, siehe dortiger Kopf.
*
* Der Picker liefert im Browser eine blob:- oder data:-URI. Das Web-SDK will
* ein echtes `File`, also wird die URI einmal gelesen und umgepackt.
*/
// Bewusst `./appwrite.web` und nicht `./appwrite`: TypeScript löst die
// Plattform-Endung nicht auf und würde sonst die native Signatur prüfen
// (`{name,type,size,uri}` statt `File`). Metro lädt auf Web ohnehin dieselbe
// Datei, der explizite Pfad ändert am Ergebnis nichts nur an der Prüfung.
import { ID, storage } from './appwrite.web';
export type Auswahl = {
uri: string;
name: string;
mimeType: string;
size: number;
};
export async function hochladen(
bucketId: string,
datei: Auswahl,
permissions: string[],
): Promise<string> {
const blob = await (await fetch(datei.uri)).blob();
const file = new File([blob], datei.name, { type: datei.mimeType || blob.type });
const f = await storage.createFile({
bucketId,
fileId: ID.unique(),
file,
permissions,
});
return f.$id;
}

View File

@@ -0,0 +1,54 @@
/**
* Design-Tokens, übernommen aus `prototyp-app.html` (CSS-Custom-Properties).
*
* Der Prototyp ist ein reines Dark Design es gibt dort kein Light-Theme und
* deshalb hier auch keins. `userInterfaceStyle` steht in app.json bewusst auf
* "dark", damit die App auf hellen Systemen nicht halb umkippt.
*
* Nicht übernommen (siehe programmier-plan.md E1): die Fake-Tastatur und die
* Shader-Spielerei per setInterval. Beides ist Design-Requisit, kein App-Code.
*/
export const colors = {
bg: '#0a0a0a',
card: '#161616',
sheet: '#131316',
txt: '#ffffff',
mut: 'rgba(255,255,255,0.6)',
faint: 'rgba(255,255,255,0.4)',
border: 'rgba(255,255,255,0.09)',
borderStrong: 'rgba(255,255,255,0.18)',
surface: 'rgba(255,255,255,0.05)',
surface2: 'rgba(255,255,255,0.10)',
accent: '#ff5c39',
gold: '#fbbf24',
ok: '#34d399',
} as const;
/** Der Prototyp nutzt 1226px; hier auf eine Leiter reduziert. 100 = Pille. */
export const radius = {
sm: 12,
md: 16,
lg: 20,
xl: 24,
pill: 100,
} as const;
export const space = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
xxl: 32,
} as const;
export const text = {
title: { fontSize: 28, fontWeight: '700' },
heading: { fontSize: 20, fontWeight: '600' },
body: { fontSize: 15, fontWeight: '400' },
label: { fontSize: 13, fontWeight: '500' },
mono: { fontSize: 12, fontFamily: 'monospace' },
} as const;
export type Colors = typeof colors;