Files
videogen/client/src/app/(tabs)/profil.tsx
2026-08-17 23:41:49 +02:00

232 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
aufDruck={() => router.push({ pathname: '/modell/[id]', params: { id: m.$id } })}
/>
))}
</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, aufDruck }: { modell: AssetMitBild; aufDruck: () => void }) {
const quelle = useBildQuelle(modell.titelbildId, modell.titelbildBucket);
return (
<Pressable
onPress={aufDruck}
accessibilityRole="button"
accessibilityLabel={`${modell.name} öffnen`}
style={({ pressed }) => [s.kachel, pressed ? s.gedrueckt : null]}>
<View>
{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>
)}
{/* Ohne freigegebene Version ist das Modell nicht einsetzbar das muss
man sehen, bevor man es in einem Post auswählen will. */}
{modell.wartet ? (
<View style={s.wartetChip}>
<Text style={s.wartetText}>wählen</Text>
</View>
) : null}
</View>
<Text style={s.kachelName} numberOfLines={1}>
{modell.name}
</Text>
</Pressable>
);
}
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 },
wartetChip: {
position: 'absolute', bottom: 5, left: 5,
backgroundColor: colors.gold, borderRadius: radius.pill,
paddingHorizontal: space.sm, paddingVertical: 1,
},
wartetText: { fontSize: 10, fontWeight: '700', color: '#000' },
fuss: { gap: space.md, marginTop: space.md },
fussText: { ...text.mono, color: colors.faint },
});