ki integration

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

View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

43
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
example
# generated native folders
/ios
/android

1
client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
client/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

3
client/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.

1
client/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

21
client/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

56
client/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
### Other setup steps
- To set up ESLint for linting, run `npx expo lint`, or follow our guide on ["Using ESLint and Prettier"](https://docs.expo.dev/guides/using-eslint/)
- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/)
- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/)
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

49
client/app.json Normal file
View File

@@ -0,0 +1,49 @@
{
"expo": {
"name": "BrandLoop",
"slug": "brandloop",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "brandloop",
"userInterfaceStyle": "dark",
"ios": {
"icon": "./assets/expo.icon"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#0a0a0a",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#0a0a0a",
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"appwrite": {
"endpoint": "https://appwrite.webklar.com/v1",
"project": "6a5cee34002bb8360c34",
"databaseId": "brandloop"
}
}
}
}

View File

@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,40 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

7949
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
client/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "client",
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@expo/ui": "~57.0.11",
"@expo/vector-icons": "^15.1.1",
"appwrite": "23.0.0",
"expo": "~57.0.13",
"expo-constants": "~57.0.11",
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
"expo-image": "~57.0.3",
"expo-image-picker": "~57.0.10",
"expo-linking": "~57.0.6",
"expo-router": "~57.0.13",
"expo-splash-screen": "~57.0.6",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
"expo-system-ui": "~57.0.2",
"expo-web-browser": "~57.0.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-appwrite": "0.25.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-url-polyfill": "^4.0.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
},
"private": true
}

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["src", "scripts"];
const exampleDir = "example";
const newAppDir = "src/app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View, StyleSheet } from "react-native";
export default function Index() {
return (
<View style={styles.container}>
<Text>Edit src/app/index.tsx to edit this screen.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /src/app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /src/app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 src/app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 src/app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
userInput === "y"
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

20
client/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./src/*"
],
"@/assets/*": [
"./assets/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}