ki integration
This commit is contained in:
20
client/src/app/(auth)/_layout.tsx
Normal file
20
client/src/app/(auth)/_layout.tsx
Normal 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 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
79
client/src/app/(auth)/anmelden.tsx
Normal file
79
client/src/app/(auth)/anmelden.tsx
Normal 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 },
|
||||
});
|
||||
87
client/src/app/(auth)/registrieren.tsx
Normal file
87
client/src/app/(auth)/registrieren.tsx
Normal 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 },
|
||||
});
|
||||
36
client/src/app/(auth)/willkommen.tsx
Normal file
36
client/src/app/(auth)/willkommen.tsx
Normal 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 },
|
||||
});
|
||||
65
client/src/app/(tabs)/_layout.tsx
Normal file
65
client/src/app/(tabs)/_layout.tsx
Normal 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' },
|
||||
});
|
||||
91
client/src/app/(tabs)/erstellen.tsx
Normal file
91
client/src/app/(tabs)/erstellen.tsx
Normal 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 },
|
||||
});
|
||||
10
client/src/app/(tabs)/index.tsx
Normal file
10
client/src/app/(tabs)/index.tsx
Normal 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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
208
client/src/app/(tabs)/profil.tsx
Normal file
208
client/src/app/(tabs)/profil.tsx
Normal 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 },
|
||||
});
|
||||
47
client/src/app/_layout.tsx
Normal file
47
client/src/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
client/src/app/erstellen/post.tsx
Normal file
214
client/src/app/erstellen/post.tsx
Normal 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 },
|
||||
});
|
||||
160
client/src/app/modell/neu.tsx
Normal file
160
client/src/app/modell/neu.tsx
Normal 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 },
|
||||
});
|
||||
171
client/src/app/ordner/[id].tsx
Normal file
171
client/src/app/ordner/[id].tsx
Normal 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 },
|
||||
});
|
||||
88
client/src/app/ordner/neu.tsx
Normal file
88
client/src/app/ordner/neu.tsx
Normal 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 },
|
||||
});
|
||||
106
client/src/components/modellwahl.tsx
Normal file
106
client/src/components/modellwahl.tsx
Normal 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 },
|
||||
});
|
||||
47
client/src/components/screen.tsx
Normal file
47
client/src/components/screen.tsx
Normal 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 },
|
||||
});
|
||||
114
client/src/components/ui.tsx
Normal file
114
client/src/components/ui.tsx
Normal 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 },
|
||||
});
|
||||
75
client/src/components/wahl.tsx
Normal file
75
client/src/components/wahl.tsx
Normal 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
15
client/src/global.css
Normal 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);
|
||||
}
|
||||
32
client/src/lib/appwrite.ts
Normal file
32
client/src/lib/appwrite.ts
Normal 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;
|
||||
22
client/src/lib/appwrite.web.ts
Normal file
22
client/src/lib/appwrite.web.ts
Normal 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
154
client/src/lib/assets.ts
Normal 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“),
|
||||
* 4–6 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
88
client/src/lib/auth.ts
Normal 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.';
|
||||
}
|
||||
16
client/src/lib/bildquelle.ts
Normal file
16
client/src/lib/bildquelle.ts
Normal 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;
|
||||
}
|
||||
51
client/src/lib/bildquelle.web.ts
Normal file
51
client/src/lib/bildquelle.web.ts
Normal 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
35
client/src/lib/config.ts
Normal 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
18
client/src/lib/dateien.ts
Normal 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
167
client/src/lib/folders.ts
Normal 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`;
|
||||
}
|
||||
13
client/src/lib/permissions.ts
Normal file
13
client/src/lib/permissions.ts
Normal 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
166
client/src/lib/posts.ts
Normal 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
101
client/src/lib/session.tsx
Normal 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
29
client/src/lib/upload.ts
Normal 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;
|
||||
}
|
||||
34
client/src/lib/upload.web.ts
Normal file
34
client/src/lib/upload.web.ts
Normal 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;
|
||||
}
|
||||
54
client/src/theme/tokens.ts
Normal file
54
client/src/theme/tokens.ts
Normal 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 12–26px; 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;
|
||||
Reference in New Issue
Block a user