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

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "brandloop-web",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "client", "run", "web"],
"port": 8081
}
]
}

View File

@@ -6,9 +6,63 @@ KI-Video-Generierung mit lernender Brand-Knowledge-Base (Elo-Scoring über Attri
- **Desktop-Ansicht:** Design-Handoff für die Desktop-Adaption in [design/desktop-ansicht/](design/desktop-ansicht/) (Spezifikation: [README.md](design/desktop-ansicht/README.md))
- **Backend:** selbst gehostetes Appwrite (`https://appwrite.webklar.com/v1`), Projekt **BrandLoop** `6a5cee34002bb8360c34`, Datenbank `brandloop`
## App (`client/`)
Expo / React Native, eine Codebase für Web und nativ. Stand: Gerüst aus E1
drei Tabs, sonst leer.
```bash
npm --prefix client run web
```
## Bildgenerierung
Die Warteschlange aus `jobs` arbeitet der Dispatcher ab **serverseitig**, weil
der Anbieter-Schlüssel nicht ins Client-Bundle darf:
```bash
BILD_ANBIETER=stub node scripts/job-dispatcher.mjs
```
`stub` erzeugt Platzhalterbilder und macht die ganze Kette prüfbar, ohne Kosten.
`ark` und `openrouter` sprechen die echten Modelle an beide setzen voraus,
dass das jeweilige Konto freigeschaltet bzw. aufgeladen ist:
- **BytePlus Ark:** Modell in der Konsole unter Model Service aktivieren
- **OpenRouter:** Guthaben aufladen
Danach reicht `BILD_ANBIETER=ark node scripts/job-dispatcher.mjs` am Code
ändert sich nichts.
## Demo-Daten
Demo-Daten zum Anschauen (Marke, zwei Ordner, vier Modelle mit echten Bildern
aus `uploads/`) legt alles als **Client** an, belegt damit nebenbei die
Rechte, und ist idempotent:
```bash
node scripts/seed-demo.mjs
```
Die Mandantentrennung lässt sich jederzeit nachprüfen legt zwei echte Konten an,
prüft über die API und räumt sich selbst auf:
```bash
node scripts/test-mandanten.mjs
```
Die Appwrite-SDKs sind **exakt** gepinnt (`appwrite@23.0.0`,
`react-native-appwrite@0.25.0`): der Server läuft 1.8.1, neuere SDKs sprechen
Response-Format 1.9.x. Beim Server-Upgrade beide zusammen anheben.
Konfiguration (Endpoint, Projekt-ID, DB-ID) steht in `client/app.json` unter
`expo.extra.appwrite`. Der Appwrite-**Server-Key** gehört dort **nicht** hinein
er landet sonst im Client-Bundle; er steht in der `.env` im Repo-Root und wird
nur von `scripts/` und später von Appwrite-Functions benutzt.
## Datenbank-Setup
Das komplette Schema (15 Tabellen, Indizes, 4 Storage-Buckets, internes Dev-Team) legt
Das komplette Schema (22 Tabellen, Indizes, 5 Storage-Buckets, internes Dev-Team) legt
[scripts/setup-appwrite.mjs](scripts/setup-appwrite.mjs) an idempotent, kann nach
Plan-Erweiterungen jederzeit erneut laufen:
@@ -39,8 +93,8 @@ node scripts/seed-prompts.mjs
- **Referenzen als indizierte String-Spalten (size 64) statt Relationship-Spalten.**
Appwrite-Relationships sind nicht filter-/indizierbar der Top-N-Index auf
`attributes` (`brand_id, category_id, status, score DESC`) und alle Listen-Queries
brauchen aber genau das. Many-to-many (`videos.attribute_ids`) ist ein String-Array
`attribute_scores` (`brand_id, folder_id, category_id, score DESC`) und alle
Listen-Queries brauchen aber genau das. Many-to-many (`videos.attribute_ids`) ist ein String-Array
(`Query.contains`). IDs mit 64 Zeichen, weil uuid4 = 36 Zeichen.
- **`created_at`-Spalten entfallen** Appwrite pflegt `$createdAt` automatisch
(Indizes nutzen `$createdAt` direkt, z. B. `score_events`, `jobs`).

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"
]
}

View File

@@ -10,13 +10,13 @@
| | Stand |
|---|---|
| **Appwrite-Schema** | 15 Tabellen, Indizes, 4 Buckets, Team `internal` angelegt durch `scripts/setup-appwrite.mjs`, idempotent |
| **Appwrite-Schema** | **22 Tabellen, Indizes, 5 Buckets, Team `internal`** live und vollständig, `scripts/setup-appwrite.mjs` reproduziert diesen Stand idempotent (Stand 14.08.2026) |
| **Prompts** | P1P18 als 22 Zeilen geseedet (P8 = Kern + 4 Adapter) via `scripts/seed-prompts.mjs`, versioniert |
| **Regeln** | 4 globale Brand-Regeln in `rules` |
| **Prototyp** | `prototyp-app.html`, 21 Screens, Handy-Ansicht, vollständig ohne Backend |
| **App-Code** | **existiert noch nicht** es gibt einen Expo-QR-Code, aber keine Anwendung |
**Der entscheidende Punkt:** Das Schema im Repo bildet den Stand **vor** dem Bild-/Feed-Konzept ab. Es fehlen alle Tabellen des neuen Produktteils. Wer jetzt anfängt, UI zu bauen, baut gegen Tabellen, die es nicht gibt.
> **Nachtrag 14.08.2026 E0 ist erledigt.** Der Abschnitt unten beschreibt den Stand vom 28.07. und ist ab hier **Historie, keine Aufgabenliste mehr**. Die Live-Datenbank war bereits vollständig migriert (alle 7 neuen Tabellen, alle 4 Enum-Erweiterungen, `attributes` aufgetrennt, 5. Bucket) nur `setup-appwrite.mjs` im Repo hing auf dem alten Stand hinterher und hätte beim Ausführen die Score-Spalten in `attributes` wieder angelegt. Das Skript ist inzwischen auf den Live-Stand gezogen und gegen ihn geprüft: **0 angelegt, 251 existierten schon, 0 Warnungen.** Auch die beiden Enum-Entscheidungen aus §7.1 (Nischen-Liste, `typ_tags`) sind getroffen und stehen als `NISCHEN` und `TYP_TAGS` im Skript.
---
@@ -77,20 +77,39 @@ Dazu die aus dem alten Plan noch offenen: `elo-update`, `favorit-berechnen`, `ad
Jede Etappe hat ein Abnahmekriterium etwas, das man vorführen kann. Ohne das ist nicht entscheidbar, ob sie fertig ist.
### E0 · Schema nachziehen
**Inhalt:** `setup-appwrite.mjs` um die 7 neuen Tabellen, die geänderten Spalten, die Enum-Erweiterungen und den Bucket ergänzen. `attributes` auftrennen. `P_KEYS` auf 22. Skript einmal komplett gegen eine **leere** Datenbank laufen lassen.
**Abnahme:** Skript läuft zweimal hintereinander fehlerfrei durch (idempotent), 22 Tabellen und 5 Buckets stehen in der Appwrite-Konsole.
**Aufwand:** klein. Reine Skriptarbeit, kein UI.
### E0 · Schema nachziehen ✅ **erledigt (14.08.2026)**
**Inhalt:** `setup-appwrite.mjs` um die 7 neuen Tabellen, die geänderten Spalten, die Enum-Erweiterungen und den Bucket ergänzen. `attributes` auftrennen. `P_KEYS` auf 22.
**Abnahme erfüllt:** 22 Tabellen und 5 Buckets stehen live; das Skript läuft ohne eine einzige Änderung durch (`0 angelegt, 251 existierten schon, 0 Warnungen`).
**Datenbestand:** nur Stammdaten 4 Zeilen `rules`, 22 Zeilen `prompt_templates` (P1P18, P8 als Familie). Keine Brand-Daten. **Enum-Änderungen sind ab jetzt trotzdem nicht mehr gefahrlos**, weil `prompt_templates.key` gefüllt ist.
### E1 · Projektgerüst Expo
**Inhalt:** Expo-Projekt anlegen, TypeScript, Navigation (Tabs + Stacks), Appwrite-SDK verdrahten, Theme aus dem Prototyp übernehmen (Farben, Typo, Glas-Effekte **ohne** die Fake-Tastatur und ohne die Shader-Spielerei per `setInterval`).
**Abnahme:** App startet auf Handy und im Browser, drei Tabs sind klickbar, jeder Tab zeigt einen leeren Screen mit seinem Namen.
**Aufwand:** mittel. Hier entscheidet sich die Ordnerstruktur des Codes sorgfältig sein.
> **Lehre aus dieser Etappe:** Die Datenbank war der Quelle voraus, nicht umgekehrt. Wer `setup-appwrite.mjs` in diesem Zustand ausgeführt hätte, hätte die sieben Score-Spalten in `attributes` neu angelegt und die Auftrennung halb zurückgedreht ein idempotentes Skript schützt nur vor doppeltem Anlegen, nicht vor einer veralteten Definition. Deshalb: **nach jeder Schema-Änderung an der Konsole das Skript nachziehen und mit `scripts/check-appwrite.mjs` gegenprüfen.**
### E2 · Auth und Mandant
**Inhalt:** Registrieren, Anmelden, Abmelden. Bei der Registrierung: Appwrite-Team anlegen, `brands`-Zeile anlegen, Zeilenrechte setzen. Session-Persistenz.
**Abnahme:** Zwei Konten anlegen; Konto B sieht **keine** Daten von Konto A geprüft über die API, nicht über das UI.
**Aufwand:** mittel. Die Rechte-Prüfung ist der eigentliche Inhalt, nicht die Formulare.
### E1 · Projektgerüst Expo ✅ **erledigt im Browser (14.08.2026), nativ ungeprüft**
**Inhalt:** Expo-Projekt in `client/`, TypeScript, Expo Router (Stack + Tabs), Appwrite-SDK verdrahtet, Theme aus dem Prototyp. Fake-Tastatur und `setInterval`-Shader sind bewusst nicht übernommen.
**Abnahme:** `npx tsc --noEmit` fehlerfrei · `npx expo export --platform web` erzeugt alle drei Routen · im Browser rendern `/`, `/erstellen` und `/profil` mit Tab-Leiste, keine Konsolen-Fehler. **Offen: der Start auf einem echten Gerät** dafür fehlt ein Testgerät bzw. ein Dev-Build.
**Struktur (`client/src/`):**
| Pfad | Inhalt |
|---|---|
| `app/_layout.tsx` | Wurzel-**Stack**, nicht direkt die Tabs Willkommen/Anmelden liegen laut app-aufbau.md §3 vor den Tabs, das Onboarding als Modal darüber. Beide brauchen eine Ebene ohne Tab-Leiste. |
| `app/(tabs)/` | Feed · Erstellen · Profil |
| `lib/appwrite.ts` + `.web.ts` | plattform-getrennter Client. `react-native-appwrite` läuft nicht im Browser, das Web-SDK `appwrite` kennt kein React Native beide exportieren dieselben Klassen, der Rest der App importiert nur `@/lib/appwrite`. |
| `lib/config.ts` | Endpoint, Projekt-ID, DB-ID aus `app.json``expo.extra.appwrite`. **Kein Server-Key** der landet sonst im Bundle. |
| `theme/tokens.ts` | Farben, Radien, Abstände aus `prototyp-app.html`. Dark-only, weil der Prototyp kein Light-Theme hat. |
**Zwei bewusste Abweichungen:** Das ist vorerst ein normaler Tab statt des Popovers aus §3 der kommt mit den drei Erstellen-Abläufen in E6. Und der Profil-Screen zeigt provisorisch Endpoint und Projekt-ID, damit belegt ist, dass der Client wirklich lädt; das fliegt in E2 raus.
### E2 · Auth und Mandant ✅ **erledigt (14.08.2026)**
**Inhalt:** Willkommen/Registrieren/Anmelden/Abmelden, Session-Persistenz, bei der Registrierung Team + `brands`-Zeile mit Zeilenrechten.
**Abnahme erfüllt:** `scripts/test-mandanten.mjs` legt zwei echte Konten an und prüft **über die API**: B listet nur die eigene Zeile · Direktzugriff B→A `404 row_not_found` · Schreibzugriff B→A `401 user_unauthorized` · Gegenprobe, dass der Server-Key beide Zeilen sieht. Räumt sich selbst auf.
**Der eigentliche Inhalt war das Rechte-Modell, nicht die Formulare.** Zwei Dinge, die vorher niemandem aufgefallen waren:
1. **Kein Client konnte irgendetwas anlegen.** Bei `rowSecurity: true` regeln Zeilenrechte lesen/ändern/löschen aber eine Zeile, die es noch nicht gibt, hat keine Rechte. Ohne Tabellen-Recht zum Anlegen war jede Client-Schreiboperation blockiert. 16 Tabellen haben jetzt `create("users")`; `score_events`, `video_metrics`, `post_metrics` und `usage_records` bewusst **nicht** sonst könnte ein Nutzer seine eigenen Elo-Werte und Feed-Signale fälschen (§11, „Manipulation des Feed-Scores").
2. **`setup-appwrite.mjs` hat Rechte nie abgeglichen.** Bei bestehenden Tabellen lieferte `POST` nur ein 409, die Rechte blieben unangetastet. Das Skript vergleicht jetzt und zieht per `PUT` nach sonst driftet das Rechte-Modell genauso wie zuvor das Schema.
**Nebenfund: SDK-Version passte nicht zum Server.** Appwrite läuft in **1.8.1**, `create-expo-app` hatte SDKs mit Response-Format 1.9.5 gezogen. Gepinnt auf `appwrite@23.0.0` und `react-native-appwrite@0.25.0` (beide Response-Format 1.8.0), **exakt statt Caret** der Sinn der Pinnung ist ja gerade der Gleichstand mit dem Server.
> Das ist die Etappe, bei der man am ehesten schludert und es am teuersten wird. Die Mandantentrennung ist das Fundament des Rechte-Modells aus `datenbank-aufbau.md` §5.
@@ -99,20 +118,51 @@ Jede Etappe hat ein Abnahmekriterium etwas, das man vorführen kann. Ohne da
**Abnahme:** Nach dem Onboarding stehen in `brands` sechs ausgefüllte Felder und in `attribute_scores` die ersten Attribute auf der brand-weiten Ebene (`folder_id = null`).
**Aufwand:** mittel.
### E4 · Modelle
### E4 · Modelle 🟡 **Kern steht (14.08.2026), Verbessern-Strecke offen**
**Inhalt:** Modell anlegen (Person, Produkt, **Kulisse**), echte Referenzbild-Uploads, Versionierung mit Release-Prinzip, Modelle-Übersicht im Profil.
**Abnahme:** Ein Produkt-Modell mit 4 Referenzbildern anlegen, freigeben, in der Übersicht sehen. `assets.released_version_id` zeigt auf die richtige Version.
**Aufwand:** mittel. Ohne Modelle kann nichts generiert werden deshalb vor der Generierung.
**Erledigt:** Anlegen mit Typwahl und Mehrfach-Bildauswahl (`modell/neu`), Upload nach `asset-references` mit Team-Rechten, Version 1 wird angelegt und sofort freigegeben, `assets.released_version_id` zeigt darauf. Übersicht im Profil nach Typ gruppiert, mit Titelbild aus der freigegebenen Version.
**Belegt:** Vier Modelle (2 Produkte, 2 Kulissen) über `scripts/seed-demo.mjs` als **Client** angelegt das beweist nebenbei, dass die Tabellen- und Bucket-Rechte aus E2 ausreichen. Alle vier Bilder laden in der App in Originalgröße.
**Offen:** weitere Versionen anlegen und freigeben (Verbessern-Strecke), `merkmale` als Token-Lock, Einwilligungs-Upload für Personen.
### E5 · Ordner
### E5 · Ordner ✅ **erledigt (14.08.2026)**
**Inhalt:** Ordner-Liste, Anlegen mit den zwei Schaltern, Ordner-Detail, aktiver Ordner als App-Zustand. Function `ordner-initialisieren`.
**Abnahme:** Zwei Ordner anlegen einen mit `erben`, einen mit `aus_posts`. In `attribute_scores` stehen danach unterschiedliche Startwerte mit korrekt gesetzter `start_quelle`.
**Aufwand:** mittel. Fachlich der anspruchsvollste Teil bis hierher.
**Abnahme erfüllt** nach dem Anlegen über die Oberfläche steht in `attribute_scores`:
### E6 · Post erstellen (erste echte Generierung)
| Ordner | Modus | Scores | `start_quelle` |
|---|---|---|---|
| (brand-weit, `folder_id = null`) | | 12 | `neutral` |
| Sommerkampagne | `erben` | 12 | `geerbt` |
| Cleane Studioshots | `aus_posts` | 0 | |
| Herbstlinie (über die App angelegt) | `erben` | 12 | `geerbt` |
Zweck und Startwerte sind als Auswahl **mit Erklärung** umgesetzt, nicht als nackter Schalter: wer sie missversteht, baut sich einen Ordner, der nicht tut, was er erwartet.
**Beim Erben wird der Wert kopiert, aber nicht die Sicherheit** `k_factor` geht zurück auf 32, weil im neuen Scope noch nichts belegt ist.
**Abweichung:** `ordner-initialisieren` läuft vorerst im Client statt als Appwrite-Function. Vertretbar, weil nur eigene Zeilen kopiert werden und die Zeilenrechte das ohnehin begrenzen; beim Umzug in eine Function ändert sich nur der Ort.
### E6 · Post erstellen 🟡 **Rezept-Hälfte steht (14.08.2026), Generierung extern blockiert**
**Inhalt:** Create-Screen mit Ordner, Format, Kette, Slots. `jobs` + `job-dispatcher` + Realtime-Wartezustand. P7 auf das Slot-System umbauen. P22 als Bild-Tagger. Post-Ergebnis mit privat/veröffentlichen.
**Abnahme:** Ein Prompt erzeugt eine dreiteilige Bilderkette, die im Entwürfe-Screen auftaucht, auch wenn man die App zwischendurch schließt.
**Aufwand:** groß. Hier hängt die Auswahl des Bildmodells dran **offener Punkt 1**.
**Erledigt:** `erstellen/post` mit Ordnerwahl in der Kopfzeile, Format, Kettenlänge und Slot-Auswahl für Kulisse, Produkt und Person als Bildkacheln, weil bei Kulissen und Produkten das Aussehen die Information ist und ein Name wie „Halle, Metallwand" nichts darüber sagt, ob es passt. Der Post entsteht **sofort** mit `status: entwurf`, bevor irgendetwas generiert wird; ohne diese Zeile gäbe es keinen Ort, an den man nach dem Warten zurückkehrt. Je Kettenbild eine `jobs`-Zeile. Ordner-Detail zeigt die Posts mit aufgelösten Slot-Namen.
**Nachweis über die Oberfläche:** Post „Serum auf Waschtisch" → Ordner Herbstlinie, `4:5`, 3 Bilder, `privat`, Slots Halle-Metallwand + Refine-&-Renew-Serum, dazu **3 Jobs `bild_gen/wartend` mit `prompt_template_key = P7`**.
**Job-Dispatcher steht** `scripts/job-dispatcher.mjs`, serverseitig, weil der Anbieter-Schlüssel nicht ins Client-Bundle darf. Er baut aus Slot-Rezept, den Beschreibungen der freigegebenen Asset-Versionen, den Top-Attributen **des gewählten Ordners** und den globalen Regeln einen Prompt, erzeugt das Bild, legt es in `generated-images` mit Team-Rechten ab, schreibt `post_images` und setzt den Post auf `generiert`, sobald die Kette vollständig ist.
Drei Anbieter über `BILD_ANBIETER`:
| Wert | Ergebnis (geprüft 14.08.2026) |
|---|---|
| `stub` | ✅ erzeugt Platzhalterbilder **die ganze Kette ist damit prüfbar, ohne einen Cent auszugeben** |
| `ark` | ✗ `ModelNotOpen: account 3003959567 has not activated seedream-5-0` |
| `openrouter` | ✗ `Insufficient credits. This account never purchased credits` |
**Durchgespielt mit `stub`:** Post „Serum auf Waschtisch" → 3 Jobs → 3 Bilder erzeugt, hochgeladen, Post auf `generiert`, Kette im Ordner-Detail sichtbar (3/3 geladen). Bei den echten Anbietern greift der Fehlerpfad: Job auf `fehler` mit der Anbieter-Meldung, Post auf `fehler`.
**Damit fehlt nur noch die Freischaltung.** Sobald eines der beiden Konten offen ist, liefert `BILD_ANBIETER=ark node scripts/job-dispatcher.mjs` echte Bilder ohne Codeänderung.
**Offen:** Umzug des Dispatchers in eine Appwrite-Function (läuft jetzt lokal/per Cron), P7 als versionierter Prompt statt der Vorstufe im Skript, P22 als Tagger, Wartezustand mit Realtime, Ergebnis-Screen mit Veröffentlichen.
### E7 · Feed
**Inhalt:** Veröffentlichen mit Zeilen-Permission, Feed-Deck mit den drei Reitern, Post-Detail, `post_metrics` schreiben, Function `feed-score-berechnen` mit Decay und Explorations-Slot.
@@ -187,6 +237,6 @@ E0 Schema
## 7. Was vor E0 zu entscheiden ist
1. **Nischen-Liste** und **`typ_tags`-Liste** beide sind Enums und wandern in E0 ins Schema. Später zu ändern heißt: dieselbe Migrationsfalle wie oben.
2. **Bildmodell** blockiert nicht E0, aber E6. Der Test dafür sollte parallel zu E1E5 laufen.
1. ~~**Nischen-Liste** und **`typ_tags`-Liste**~~ ✅ entschieden und live. 20 Nischen, 17 Motiv-Tags, stehen als `NISCHEN` und `TYP_TAGS` in `scripts/setup-appwrite.mjs`.
2. **Bildmodell** blockiert nicht E0, aber E6. Der **Anbieter steht** (OpenRouter, siehe `projekt-uebersicht.md` §9); offen ist nur, welches der dortigen Bildmodelle die Multi-Referenz-Komposition trifft. Der Wegwerf-Test dafür sollte parallel zu E1E5 laufen.
3. **Mindestalter und Moderationsweg** blockiert nicht den Code, aber E7 im Livebetrieb.

View File

@@ -166,11 +166,26 @@ Homescreen → **Feed** · **Erstellen** (Szene / Post / Modell) · **Ordner**
| Hosting | Hetzner Cloud (MVP CX32 ~€7 → CX42 ~€16 ab ~25 Brands) |
| Video-Speicher | Hetzner Object Storage (S3-kompatibel) |
| Backend/DB | **Appwrite self-hosted** (Auth+Teams, TablesDB, Functions, Realtime, Storage mit S3-Adapter, Messaging) €0 Lizenz |
| KI-Gateway | **OpenRouter** (eine API für LLM *und* Video) |
| LLM | Claude (Scripts, Tagging, Vergleiche) · Gemini für P9 |
| Videomodelle | Seedance 2.0 + Veo 3.1 + Sora 2 / Wan |
| KI-Gateway | **Zwei Anbieter, klar getrennt:** **OpenRouter** für LLM *und* Bild · **BytePlus ModelArk** für Video |
| LLM | Claude (Scripts, Vergleiche) · Gemini für P9 **beides über OpenRouter**, Gemini dort mit nativem Video-Input |
| Bildmodelle | **noch offen** Kandidaten für den Test vor E6: `google/gemini-3-pro-image`, `openai/gpt-5-image` (OpenRouter) und `seedream-5-0-260128` (Ark **eu-west**, DSGVO-nah) |
| Videomodelle | **Seedance-Familie über BytePlus ModelArk** `dreamina-seedance-2-5` / `-2-0` / `-2-0-fast` / `-2-0-mini` · `seedance-1-5-pro` · `seedance-1-0-pro` / `-pro-fast` |
| Payment | Stripe (Abo + metered) |
> **Warum zwei Anbieter statt einem Gateway:** OpenRouter hat **kein** Modell mit Video-*Output* (geprüft am 14.08.2026 über `/api/v1/models`, 411 Modelle, keines mit `output_modalities: video`). Video-*Input* und Bild-*Output* kann OpenRouter dagegen sehr wohl deshalb läuft dort alles außer der Videogenerierung.
>
> **Ark-Regionen Keys sind regionsgebunden, jeder Key funktioniert nur in seiner eigenen Region** (geprüft am 14.08.2026):
>
> | Region | Endpoint | Modelle | Video? |
> |---|---|---|---|
> | **ap-southeast** (Singapur) | `https://ark.ap-southeast.bytepluses.com/api/v3` | 42 aktiv | **ja einzige Region mit Seedance** |
> | **eu-west** | `https://ark.eu-west.bytepluses.com/api/v3` | 5 aktiv (1× Bild `seedream-5-0`, 4× VLM) | **nein** |
> | cn-beijing | `ark.cn-beijing.volces.com` | | weist BytePlus-Keys mit `AuthenticationError` ab |
>
> **Entscheidung:** Videogenerierung läuft über **ap-southeast**, weil die EU-Region keine Videomodelle anbietet. Der EU-Key bleibt für den Bildmodell-Test und als spätere Migrationsoption bestehen falls BytePlus Seedance in eu-west ausrollt, ist der Wechsel eine Endpoint- und Key-Änderung, sonst nichts. **Deshalb gehören Endpoint und Key in die Konfiguration, nicht in den Code.**
>
> **Folge für den Vote-Loop:** Die 36 konkurrierenden Videos kommen jetzt aus **einer Modellfamilie in verschiedenen Generationen**, nicht mehr von verschiedenen Anbietern. Für die Elo-Attribution (§5) ist das eher ein Vorteil bei konstanter Modellfamilie stammt der Unterschied zwischen zwei Videos aus den Attributen, nicht aus dem Modell. Verloren geht die Absicherung, dass ein anderer Anbieter bestimmte Motive besser trifft. Die Adapter für Veo/Sora/Wan bleiben deshalb in `prompts/` liegen (siehe §7) sie sind ungenutzt, aber nicht gelöscht.
**Kostenrechnung 25 Brands (~1.000 Videos/Monat):** ~€1.300 gesamt, davon **>90 % Videogenerierung** (~€1.200). ≈ €52/Brand/Monat → Abo ab €150300/Brand lässt gesunde Marge. Genau deshalb funktioniert „Preis pro Video".
### Datenbank-Tabellen (Appwrite)
@@ -185,6 +200,7 @@ Homescreen → **Feed** · **Erstellen** (Szene / Post / Modell) · **Ordner**
## 10. Rechtliches
- **AVV** mit Hetzner (Standard, kostenlos)
- **Drittlandtransfer BytePlus (Singapur)** die Videogenerierung läuft über `ap-southeast`, weil die EU-Region keine Videomodelle hat (§9). Damit verlassen Prompt, Referenzbilder und generiertes Video die EU. Zu klären: AVV mit BytePlus inklusive **Standardvertragsklauseln**, Nennung in der Datenschutzerklärung, Aufnahme ins Verarbeitungsverzeichnis. **Entschärfend:** nach der Regel unten gehen ohnehin nur KI-generierte Gesichter raus überwiegend Geschäfts-, keine biometrischen Personendaten. Alles außerhalb der Videogenerierung bleibt in der EU bzw. bei OpenRouter.
- **EU AI Act:** KI-generierte Werbevideos müssen gekennzeichnet werden
- **Gesichter echter Personen** nur mit dokumentierter Einwilligung (auch beim Brand Owner) → **rein KI-generierte Gesichter sind der sichere Standard**
- Referenzen fremder Inhalte: Merkmale beschreiben und lernen, **nie 1:1 kopieren**
@@ -256,7 +272,7 @@ Es gibt jetzt **zwei Stränge**, die parallel laufen können der Bild-Strang
4. **Appwrite aufsetzen** und das Schema aus `datenbank-aufbau.md` anlegen **inklusive `attribute_scores`-Auftrennung von Anfang an** (siehe Abhängigkeit in §12).
5. **Elo-Function implementieren** (deterministisch, gut testbar guter erster Code), scope-bewusst.
6. **Nischen- und Typ-Tag-Enums festlegen** blockiert P19, P22 und die Kopier-Kompatibilität.
7. **Bildmodell auswählen** Kriterium ist Multi-Referenz-Komposition (Person + Produkt + Kulisse konsistent), nicht reine Bildqualität.
7. **Bildmodell auswählen** Kriterium ist Multi-Referenz-Komposition (Person + Produkt + Kulisse konsistent), nicht reine Bildqualität. Vier Kandidaten stehen bereit: `google/gemini-3-pro-image`, `google/gemini-3.1-flash-image`, `openai/gpt-5-image` (OpenRouter) und `seedream-5-0-260128` (Ark eu-west). **Seedream hat den Standortvorteil** bei gleichwertigem Ergebnis bliebe der gesamte Bild-Strang in der EU und §10 wäre nur für Video zu klären. Der Test entscheidet, nicht die Herkunft.
8. **Prototyp erweitern:** Feed, Post-Detail, Kopier-Screen mit Slot-Chips, Ordner-Ansicht, Ordner anlegen (zwei Schalter).
9. Entscheidungen nachziehen: Name, Preisstufen **für beide Zielgruppen**, Ads- **und Social-**API-Anträge starten.

266
scripts/job-dispatcher.mjs Normal file
View File

@@ -0,0 +1,266 @@
#!/usr/bin/env node
/**
* BrandLoop Job-Dispatcher für `bild_gen`.
*
* Arbeitet die Warteschlange aus `jobs` ab: baut aus dem Slot-Rezept und dem
* Wissen des gewählten Ordners einen Prompt, lässt ein Bild erzeugen, legt es
* im Bucket `generated-images` ab und schreibt eine `post_images`-Zeile.
*
* **Warum serverseitig:** Der Anbieter-Schlüssel darf nicht ins Client-Bundle
* aus einer Web-App ließe er sich sonst auslesen. Später wird daraus eine
* Appwrite-Function; bis dahin läuft dasselbe Skript lokal oder per Cron.
*
* Aufruf:
* node scripts/job-dispatcher.mjs # Anbieter aus BILD_ANBIETER
* BILD_ANBIETER=stub node scripts/job-dispatcher.mjs
*
* Anbieter:
* ark Seedream über BytePlus ModelArk
* openrouter Bildmodelle über OpenRouter
* stub erzeugt ein Platzhalterbild zum Prüfen der Kette ohne Kosten
*/
import { pngErzeugen } from './lib/png.mjs';
const ENDPOINT = process.env.APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1';
const PROJECT = process.env.APPWRITE_PROJECT || '6a5cee34002bb8360c34';
const DB = 'brandloop';
const BUCKET = 'generated-images';
const ANBIETER = process.env.BILD_ANBIETER || 'stub';
const MAX_JOBS = Number(process.env.MAX_JOBS || 20);
const KEY = process.env.APPWRITE_API_KEY;
if (!KEY) { console.error('APPWRITE_API_KEY fehlt.'); process.exit(1); }
// ---- Appwrite -------------------------------------------------------------
const q = (o) => `queries[]=${encodeURIComponent(JSON.stringify(o))}`;
const limit = (n) => q({ method: 'limit', values: [n] });
async function api(method, path, body, form) {
const headers = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': KEY };
if (!form) headers['Content-Type'] = 'application/json';
const res = await fetch(`${ENDPOINT}${path}`, {
method, headers, body: form ?? (body ? JSON.stringify(body) : undefined),
});
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = { message: text }; }
if (res.status >= 300) throw new Error(`${method} ${path}${res.status} ${json.message}`);
return json;
}
const zeilen = (tabelle, ...queries) =>
api('GET', `/tablesdb/${DB}/tables/${tabelle}/rows?${queries.join('&')}`).then((r) => r.rows);
const zeile = (tabelle, id) => api('GET', `/tablesdb/${DB}/tables/${tabelle}/rows/${id}`);
const anlegen = (tabelle, data, permissions) =>
api('POST', `/tablesdb/${DB}/tables/${tabelle}/rows`, { rowId: 'unique()', data, permissions });
const aendern = (tabelle, id, data) =>
api('PATCH', `/tablesdb/${DB}/tables/${tabelle}/rows/${id}`, { data });
// ---- Prompt-Bau (Vorstufe von P7) ----------------------------------------
const FORMAT_MASSE = { '1:1': [1024, 1024], '4:5': [896, 1120], '9:16': [768, 1365] };
/**
* Setzt den Prompt aus vier Quellen zusammen in der Reihenfolge, in der die
* Prompt-Architektur sie vorsieht: Regeln, Assets, Attribute, User-Input.
*
* Das ist bewusst noch **nicht** P7: der echte Prompt ist versioniert und liegt
* in `prompt_templates`. Hier steht die Verdrahtung, damit die Kette prüfbar
* ist; der Text wird ersetzt, sobald P7 auf das Slot-System umgebaut ist.
*/
function promptBauen({ post, slots, assets, attribute, regeln, position }) {
const teile = [];
const kulisse = assets[slots.kulisse_asset_id];
const produkt = assets[slots.produkt_asset_id];
const person = assets[slots.person_asset_id];
teile.push(post.user_prompt?.trim() || post.titel || 'Werbebild');
if (kulisse) teile.push(`Ort: ${kulisse.name}. ${kulisse.beschreibung ?? ''}`.trim());
if (produkt) teile.push(`Produkt, exakt wie beschrieben: ${produkt.name}. ${produkt.beschreibung ?? ''}`.trim());
if (person) teile.push(`Person: ${person.name}. ${person.beschreibung ?? ''}`.trim());
if (attribute.length) {
teile.push(`Bewährt für diese Marke: ${attribute.map((a) => a.name).join(', ')}.`);
}
// Nur Position und Winkel variieren über die Kette alles andere bleibt
// konstant, sonst ist es keine Kette, sondern sind es Einzelbilder (§15).
const winkel = ['frontal auf Augenhöhe', 'leicht seitlich von links', 'leichte Aufsicht',
'Detailaufnahme näher am Motiv', 'weiter gefasst, mehr Umgebung'];
teile.push(`Bild ${position} der Serie: ${winkel[(position - 1) % winkel.length]}.`);
if (regeln.length) teile.push(regeln.map((r) => r.prompt_text).filter(Boolean).join(' '));
teile.push('Kein Text, kein Logo, kein Wasserzeichen im Bild.');
return teile.filter(Boolean).join('\n');
}
// ---- Anbieter -------------------------------------------------------------
async function bildErzeugen(prompt, [breite, hoehe]) {
if (ANBIETER === 'stub') {
// Ruhiger Verlauf mit Rasterlinien erkennbar als Platzhalter, aber ein
// echtes Bild, damit Upload, Anzeige und Kettenlogik geprüft werden können.
const saat = [...prompt].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
const h = saat % 360;
// Viertelgröße: der Platzhalter soll die Kette prüfen, nicht Rechenzeit
// verbrauchen. Gemeldet werden die **tatsächlichen** Maße sonst stünde in
// `post_images` eine Zahl, die nicht zur Datei passt.
const [bw, bh] = [breite >> 2, hoehe >> 2];
return {
bytes: pngErzeugen(bw, bh, (x, y) => {
const t = y / bh;
const raster = x % 32 === 0 || y % 32 === 0 ? 18 : 0;
return hsl(h, 0.22, 0.14 + t * 0.2 + raster / 255);
}),
typ: 'image/png',
masse: [bw, bh],
kosten: 0,
};
}
if (ANBIETER === 'ark') {
const r = await fetch(`${process.env.ARK_APAC_BASE_URL}/images/generations`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.ARK_APAC_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: process.env.ARK_IMAGE_MODEL || 'seedream-5-0-260128',
prompt, size: `${breite}x${hoehe}`, response_format: 'url', watermark: false,
}),
});
const j = await r.json();
if (j.error) throw new Error(`${j.error.code}: ${j.error.message}`);
const url = j.data?.[0]?.url;
if (!url) throw new Error('Ark lieferte keine Bilddaten');
const bild = await fetch(url);
return { bytes: Buffer.from(await bild.arrayBuffer()), typ: 'image/jpeg', kosten: 0 };
}
if (ANBIETER === 'openrouter') {
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: process.env.OPENROUTER_IMAGE_MODEL || 'google/gemini-3-pro-image',
modalities: ['image', 'text'],
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
}),
});
const j = await r.json();
if (j.error) throw new Error(j.error.message ?? JSON.stringify(j.error));
const daten = j.choices?.[0]?.message?.images?.[0]?.image_url?.url;
if (!daten) throw new Error('OpenRouter lieferte keine Bilddaten');
const bytes = daten.startsWith('data:')
? Buffer.from(daten.split(',')[1], 'base64')
: Buffer.from(await (await fetch(daten)).arrayBuffer());
return { bytes, typ: 'image/png', kosten: j.usage?.cost ?? 0 };
}
throw new Error(`Unbekannter Anbieter "${ANBIETER}"`);
}
function hsl(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
: h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
// ---- Ablauf ---------------------------------------------------------------
async function main() {
console.log(`Anbieter: ${ANBIETER}\n`);
const jobs = (await zeilen('jobs', limit(MAX_JOBS), q({ method: 'orderAsc', attribute: '$createdAt' })))
.filter((j) => j.typ === 'bild_gen' && j.status === 'wartend');
if (!jobs.length) { console.log('Keine wartenden bild_gen-Jobs.'); return; }
console.log(`${jobs.length} wartende(r) Job(s).\n`);
const postCache = new Map();
let fertig = 0, fehler = 0;
for (const job of jobs) {
let refs = {};
try { refs = JSON.parse(job.refs || '{}'); } catch { /* leer lassen */ }
const kennung = `${refs.post_id ?? '?'}#${refs.position ?? '?'}`;
try {
await aendern('jobs', job.$id, { status: 'laeuft' });
const post = await zeile('posts', refs.post_id);
const brand = await zeile('brands', post.brand_id);
const rolle = `team:${brand.team_id}`;
const rechte = [`read("${rolle}")`, `update("${rolle}")`, `delete("${rolle}")`];
let kontext = postCache.get(post.$id);
if (!kontext) {
const slots = JSON.parse(post.slots || '{}');
const assets = {};
for (const id of [slots.kulisse_asset_id, slots.produkt_asset_id, slots.person_asset_id].filter(Boolean)) {
const a = await zeile('assets', id);
let beschreibung = '';
if (a.released_version_id) {
try { beschreibung = (await zeile('asset_versions', a.released_version_id)).beschreibung_md ?? ''; } catch { /* egal */ }
}
assets[id] = { name: a.name, typ: a.typ, beschreibung };
}
// Die besten Attribute **des gewählten Ordners** nicht die der Marke.
// Der Ordner überschreibt die brand-weite Ebene vollständig (§9).
const scores = post.folder_id
? await zeilen('attribute_scores', limit(8),
q({ method: 'equal', attribute: 'folder_id', values: [post.folder_id] }),
q({ method: 'orderDesc', attribute: 'score' }))
: [];
const attribute = [];
for (const s of scores.slice(0, 6)) {
try { attribute.push(await zeile('attributes', s.attribute_id)); } catch { /* egal */ }
}
const regeln = await zeilen('rules', limit(10));
kontext = { slots, assets, attribute, regeln };
postCache.set(post.$id, kontext);
}
const masse = FORMAT_MASSE[post.format] ?? FORMAT_MASSE['4:5'];
const prompt = promptBauen({ post, ...kontext, position: refs.position ?? 1 });
const { bytes, typ, kosten, masse: echteMasse } = await bildErzeugen(prompt, masse);
const [bw, bh] = echteMasse ?? masse;
const fd = new FormData();
fd.append('fileId', 'unique()');
fd.append('file', new Blob([bytes], { type: typ }), `post-${post.$id}-${refs.position}.png`);
for (const p of rechte) fd.append('permissions[]', p);
const datei = await api('POST', `/storage/buckets/${BUCKET}/files`, null, fd);
await anlegen('post_images', {
post_id: post.$id,
brand_id: post.brand_id,
position: refs.position ?? 1,
typ: 'motiv',
storage_file_id: datei.$id,
prompt_sent: prompt, // 🔒 nur eigenes Team das ist das Betriebsgeheimnis
breite: bw, hoehe: bh,
}, rechte);
await aendern('jobs', job.$id, { status: 'fertig', cost_usd: kosten });
const bisher = await zeilen('post_images', limit(50),
q({ method: 'equal', attribute: 'post_id', values: [post.$id] }));
if (bisher.length >= (post.bild_count ?? 1)) {
await aendern('posts', post.$id, { status: 'generiert' });
}
fertig++;
console.log(`${kennung} ${(bytes.length / 1024).toFixed(0)} KB, ${bw}×${bh}`);
} catch (e) {
fehler++;
const meldung = String(e.message).slice(0, 500);
console.log(`${kennung} ${meldung}`);
await aendern('jobs', job.$id, { status: 'fehler', error: meldung }).catch(() => {});
if (refs.post_id) await aendern('posts', refs.post_id, { status: 'fehler' }).catch(() => {});
}
}
console.log(`\n${fertig} erledigt, ${fehler} fehlgeschlagen.`);
if (fehler) process.exit(1);
}
main().catch((e) => { console.error(`\nAbbruch: ${e.message}`); process.exit(1); });

63
scripts/lib/png.mjs Normal file
View File

@@ -0,0 +1,63 @@
/**
* Minimaler PNG-Encoder nur für den Platzhalter-Anbieter des Job-Dispatchers.
*
* Bewusst ohne Fremdbibliothek: das Repo soll für einen Testbildgenerator keine
* Abhängigkeit mitschleppen. `zlib` bringt Node mit, mehr braucht ein PNG nicht.
*/
import { deflateSync } from 'node:zlib';
const CRC = (() => {
const t = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c;
}
return t;
})();
function crc32(buf) {
let c = -1;
for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ -1) >>> 0;
}
function chunk(typ, daten) {
const len = Buffer.alloc(4);
len.writeUInt32BE(daten.length);
const körper = Buffer.concat([Buffer.from(typ, 'ascii'), daten]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(körper));
return Buffer.concat([len, körper, crc]);
}
/**
* @param {number} breite
* @param {number} hoehe
* @param {(x:number,y:number)=>[number,number,number]} farbe RGB je Pixel
*/
export function pngErzeugen(breite, hoehe, farbe) {
const roh = Buffer.alloc(hoehe * (breite * 3 + 1));
let p = 0;
for (let y = 0; y < hoehe; y++) {
roh[p++] = 0; // Filter: none
for (let x = 0; x < breite; x++) {
const [r, g, b] = farbe(x, y);
roh[p++] = r; roh[p++] = g; roh[p++] = b;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(breite, 0);
ihdr.writeUInt32BE(hoehe, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type: truecolour
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(roh, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}

188
scripts/seed-demo.mjs Normal file
View File

@@ -0,0 +1,188 @@
/**
* Legt ein Demo-Konto mit Ordner und drei Modellen (inkl. echter Bild-Uploads)
* an damit sich die Anzeige in der App gegen echte Daten prüfen lässt.
*
* Läuft als Client (Cookie-Session), nicht mit dem Server-Key: so wird
* nebenbei belegt, dass die Tabellen-Rechte aus E2 wirklich ausreichen.
*/
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
const ENDPOINT = 'https://appwrite.webklar.com/v1';
const PROJECT = '6a5cee34002bb8360c34';
const DB = 'brandloop';
const UP = 'A:/developer/GitHub-desktop/videogen/uploads';
const email = process.argv[2] ?? 'demo@brandloop.test';
const password = process.argv[3] ?? 'Demo-2026-brandloop';
const label = 'Modaily';
const jar = new Map();
async function call(method, path, body, isForm = false) {
const headers = { 'X-Appwrite-Project': PROJECT };
if (!isForm) headers['Content-Type'] = 'application/json';
if (jar.size) headers.Cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
const res = await fetch(`${ENDPOINT}${path}`, {
method, headers, body: isForm ? body : body ? JSON.stringify(body) : undefined,
});
for (const c of res.headers.getSetCookie?.() ?? []) {
const [pair] = c.split(';');
const i = pair.indexOf('=');
if (i > 0) jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim());
}
const t = await res.text();
let j; try { j = JSON.parse(t); } catch { j = { message: t }; }
if (res.status >= 300) throw new Error(`${method} ${path} -> ${res.status} ${j.message}`);
return j;
}
// --- Konto -----------------------------------------------------------------
try {
await call('POST', '/account', { userId: 'unique()', email, password, name: label });
console.log('Konto angelegt:', email);
} catch (e) {
if (!String(e.message).includes('already exists')) throw e;
console.log('Konto existiert bereits:', email);
}
await call('POST', '/account/sessions/email', { email, password });
const teamsVorhanden = await call('GET', '/teams');
const team = teamsVorhanden.teams[0] ?? (await call('POST', '/teams', { teamId: 'unique()', name: label }));
const rolle = `team:${team.$id}`;
const rechte = [`read("${rolle}")`, `update("${rolle}")`, `delete("${rolle}")`];
const brandsVorhanden = await call('GET', `/tablesdb/${DB}/tables/brands/rows`);
const brand = brandsVorhanden.rows[0] ?? (await call('POST', `/tablesdb/${DB}/tables/brands/rows`, {
rowId: 'unique()',
data: { team_id: team.$id, label_name: label, status: 'trial', plan: 'trial', nische: 'beauty_kosmetik' },
permissions: rechte,
}));
console.log(`Marke: ${brand.label_name} (${brand.$id}), Team ${team.$id}`);
// --- Kategorien + Attribute + brand-weite Scores ---------------------------
// Das erzeugt später P2 aus dem Onboarding. Hier von Hand, damit ein Ordner
// mit Startwert `erben` überhaupt etwas zu erben hat.
const WISSEN = {
location: ['badezimmer morgens', 'studio grauer hintergrund', 'kueche tageslicht'],
licht: ['weiches seitenlicht', 'hartes sonnenlicht', 'flaches studiolicht'],
farben: ['warme erdtoene', 'kuehles monochrom', 'kraeftiger rotakzent'],
kamera: ['augenhoehe frontal', 'leichte aufsicht', 'detail makro'],
};
const katVorhanden = await call('GET', `/tablesdb/${DB}/tables/categories/rows`);
if (!katVorhanden.rows.length) {
let attrCount = 0;
for (const [kat, attribute] of Object.entries(WISSEN)) {
const c = await call('POST', `/tablesdb/${DB}/tables/categories/rows`, {
rowId: 'unique()',
data: { brand_id: brand.$id, name: kat, avg_score: 5000, attribute_count: attribute.length },
permissions: rechte,
});
for (const name of attribute) {
const a = await call('POST', `/tablesdb/${DB}/tables/attributes/rows`, {
rowId: 'unique()',
data: {
brand_id: brand.$id, category_id: c.$id, name,
slug: name.replace(/\s+/g, '-'), status: 'aktiv',
beschreibung_md: `Aus dem Onboarding abgeleitet: ${name}.`,
},
permissions: rechte,
});
// Brand-weite Ebene: folder_id bleibt leer.
await call('POST', `/tablesdb/${DB}/tables/attribute_scores/rows`, {
rowId: 'unique()',
data: {
brand_id: brand.$id, attribute_id: a.$id, category_id: c.$id,
score: 5000, start_value: 5000, k_factor: 32, start_quelle: 'neutral',
used_count: 0, wins: 0, losses: 0,
},
permissions: rechte,
});
attrCount++;
}
}
console.log(`Wissen: ${Object.keys(WISSEN).length} Kategorien, ${attrCount} Attribute (brand-weit)`);
} else {
console.log(`Wissen: ${katVorhanden.rows.length} Kategorien vorhanden`);
}
// --- Ordner ----------------------------------------------------------------
const ordnerVorhanden = await call('GET', `/tablesdb/${DB}/tables/folders/rows`);
if (!ordnerVorhanden.rows.length) {
for (const o of [
{ name: 'Sommerkampagne', zweck: 'wissens_scope', startwert_modus: 'erben' },
{ name: 'Cleane Studioshots', zweck: 'wissens_scope', startwert_modus: 'aus_posts' },
]) {
const r = await call('POST', `/tablesdb/${DB}/tables/folders/rows`, {
rowId: 'unique()',
data: { brand_id: brand.$id, ...o, ist_default: o.name === 'Sommerkampagne', post_count: 0, signal_count: 0 },
permissions: rechte,
});
// Gleiche Regel wie ordnerInitialisieren() im Client: nur `erben` kopiert
// die brand-weite Ebene, alles andere startet leer.
let kopiert = 0;
if (o.startwert_modus === 'erben') {
const q = encodeURIComponent(JSON.stringify({ method: 'isNull', attribute: 'folder_id' }));
const l = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] }));
const quelle = await call('GET', `/tablesdb/${DB}/tables/attribute_scores/rows?queries[]=${q}&queries[]=${l}`);
for (const z of quelle.rows) {
await call('POST', `/tablesdb/${DB}/tables/attribute_scores/rows`, {
rowId: 'unique()',
data: {
brand_id: brand.$id, attribute_id: z.attribute_id, category_id: z.category_id,
folder_id: r.$id, score: z.score, start_value: z.score, k_factor: 32,
start_quelle: 'geerbt', used_count: 0, wins: 0, losses: 0,
},
permissions: rechte,
});
kopiert++;
}
}
console.log(`Ordner: ${r.name} (${o.zweck}, ${o.startwert_modus}) ${kopiert} Scores geerbt`);
}
} else {
console.log(`Ordner: ${ordnerVorhanden.rows.length} vorhanden`);
}
// --- Modelle mit echten Referenzbildern ------------------------------------
async function bildHochladen(datei) {
const bytes = readFileSync(`${UP}/${datei}`);
const fd = new FormData();
fd.append('fileId', 'unique()');
fd.append('file', new Blob([bytes], { type: 'image/jpeg' }), basename(datei));
for (const p of rechte) fd.append('permissions[]', p);
const f = await call('POST', '/storage/buckets/asset-references/files', fd, true);
return f.$id;
}
const MODELLE = [
{ typ: 'produkt', name: 'Refine & Renew B3 Serum', datei: 'pasted-1784663200459-0.jpg', beschreibung: 'Weiße Pumpflasche, 30 ml, rote Typo, Aufschrift MODAILY vertikal links.' },
{ typ: 'produkt', name: 'Age Decoder Essence', datei: 'pasted-1784643165286-0.jpg', beschreibung: 'Pipettenflasche, rosé-transparentes Glas, mattgraue Kappe, Schriftzug okolo.' },
{ typ: 'kulisse', name: 'U-Bahn, Türbereich', datei: 'pasted-1784643111353-0.jpg', beschreibung: 'Metallische U-Bahn-Türen, Haltestangen, weiches Kunstlicht, gedämpfte Erdtöne.' },
{ typ: 'kulisse', name: 'Halle, Metallwand', datei: 'pasted-1784663261701-0.jpg', beschreibung: 'Minimalistischer Innenraum, Stahlwand mit kühlen Reflexen, polierter Boden.' },
];
const assetsVorhanden = await call('GET', `/tablesdb/${DB}/tables/assets/rows`);
if (assetsVorhanden.rows.length) {
console.log(`Modelle: ${assetsVorhanden.rows.length} vorhanden, kein Neuanlegen`);
} else {
for (const m of MODELLE) {
const fileId = await bildHochladen(m.datei);
const asset = await call('POST', `/tablesdb/${DB}/tables/assets/rows`, {
rowId: 'unique()',
data: { brand_id: brand.$id, typ: m.typ, name: m.name, ist_teilbar: false, nische: 'beauty_kosmetik' },
permissions: rechte,
});
const version = await call('POST', `/tablesdb/${DB}/tables/asset_versions/rows`, {
rowId: 'unique()',
data: { asset_id: asset.$id, version_no: 1, status: 'freigegeben', beschreibung_md: m.beschreibung, reference_file_ids: [fileId] },
permissions: rechte,
});
await call('PATCH', `/tablesdb/${DB}/tables/assets/rows/${asset.$id}`, {
data: { released_version_id: version.$id },
});
console.log(`Modell: ${m.name} [${m.typ}] Bild ${fileId}`);
}
}
console.log(`\nFertig. Anmelden mit ${email} / ${password}`);

View File

@@ -40,7 +40,7 @@ function resolveApiKey() {
}
const API_KEY = resolveApiKey();
let created = 0, skipped = 0, warned = 0;
let created = 0, skipped = 0, warned = 0, updated = 0;
async function api(method, path, body) {
const res = await fetch(`${ENDPOINT}${path}`, {
@@ -81,13 +81,63 @@ const bool = (key, opts = {}) => ({ type: 'boolean', body: { key, required: fals
const dt = (key, opts = {}) => ({ type: 'datetime', body: { key, required: false, ...opts } });
const enm = (key, elements, opts = {}) => ({ type: 'enum', body: { key, elements, required: false, ...opts } });
const P_KEYS = Array.from({ length: 18 }, (_, i) => `P${i + 1}`);
const P_KEYS = Array.from({ length: 22 }, (_, i) => `P${i + 1}`);
// Feste Branchenliste (Onboarding-Pflichtfrage 3, Feed-Filter, Kopier-Kompatibilität)
// und Motiv-Tags des Bild-Modus. Beides sind Enums eine spätere Änderung ist eine
// Migration mit Datenverlustrisiko, nicht ein Anhängen (programmier-plan.md §2.6).
const NISCHEN = [
'mode', 'beauty_kosmetik', 'fitness_sport', 'food_getraenke', 'gastronomie',
'gesundheit', 'handwerk_bau', 'immobilien', 'finanzen_versicherung', 'reisen_hotel',
'auto_mobilitaet', 'technik_software', 'moebel_interior', 'schmuck_accessoires',
'haustier', 'bildung_coaching', 'dienstleistung', 'handel_ecommerce', 'kunst_kultur',
'sonstiges',
];
const TYP_TAGS = [
'portrait', 'ganzkoerper', 'gruppe', 'produkt_freisteller', 'produkt_inszeniert',
'detail_makro', 'flatlay', 'innenraum', 'aussen', 'studio', 'natur', 'urban',
'bewegung', 'stillleben', 'text_overlay', 'logo', 'sonstiges',
];
const INTERNAL = 'team:internal';
const internalOnly = [
`read("${INTERNAL}")`, `create("${INTERNAL}")`,
`update("${INTERNAL}")`, `delete("${INTERNAL}")`,
];
/**
* Tabellen-Recht "anlegen darf jeder Angemeldete".
*
* Warum das nötig ist: bei `rowSecurity: true` regeln Zeilenrechte *lesen,
* ändern, löschen* aber eine Zeile, die es noch nicht gibt, hat keine Rechte.
* Ohne ein Tabellen-Recht zum Anlegen kann der Client also gar nichts
* schreiben, auch nicht seine eigenen Daten.
*
* Warum das trotzdem dicht ist: `create` erlaubt nur das Anlegen. Gelesen wird
* ausschließlich, was die Zeilenrechte hergeben und die setzt der Client beim
* Anlegen auf sein eigenes Team. Eine fremde Zeile wird dadurch nicht sichtbar.
*/
const userCreate = ['create("users")'];
/**
* Wer legt in welcher Tabelle Zeilen an.
*
* NICHT in dieser Liste und das mit Absicht:
* - `score_events`, `video_metrics`, `post_metrics`, `usage_records`
* append-only, geschrieben von Functions mit Server-Key. Dürfte der Client
* hier schreiben, könnte er seine eigenen Elo-Werte und Feed-Signale
* fälschen (projekt-uebersicht.md §11, "Manipulation des Feed-Scores").
* - `rules`, `prompt_templates` Betriebsgeheimnis, nur Team `internal`.
*/
const CLIENT_CREATE = new Set([
'brands', 'folders', 'categories', 'attributes', 'attribute_scores',
'assets', 'asset_versions', 'scenes', 'videos', 'votes', 'questions',
'posts', 'post_images', 'post_folders', 'follows',
'jobs', // jede KI-Aktion wird vom Client eingereiht (app-aufbau.md §2.1)
]);
const tablePermissions = (t) => t.permissions || (CLIENT_CREATE.has(t.id) ? userCreate : []);
// ---- Tabellen-Definitionen (Spalten + Indizes) ----------------------------
const TABLES = [
{
@@ -101,8 +151,18 @@ const TABLES = [
str('stripe_customer_id', 64),
int('default_video_count', { min: 3, max: 6, default: 4 }),
enm('status', ['trial', 'aktiv', 'pausiert'], { default: 'trial' }),
// Feed & Folgen: öffentliches Profil der Brand
str('anzeigename', 255),
enm('nische', NISCHEN), // Onboarding-Pflichtfrage 3, filtert den Feed
id('avatar_file_id'),
int('follower_count', { default: 0 }),
bool('ist_oeffentlich', { default: false }),
enm('plan', ['trial', 'starter', 'pro', 'business'], { default: 'trial' }),
],
indexes: [
{ key: 'idx_team', type: 'key', columns: ['team_id'] },
{ key: 'idx_oeffentlich', type: 'key', columns: ['ist_oeffentlich', 'nische'] },
],
indexes: [{ key: 'idx_team', type: 'key', columns: ['team_id'] }],
},
{
// 🔒 Dev-Modus: nur internes Team
@@ -139,8 +199,12 @@ const TABLES = [
str('name', 64, { required: true }), // location, licht, farben, kamera, voice, geraeusche, texte-hooks, handlungen
int('avg_score', { min: 0, max: 10000, default: 5000 }), // Cache per Function aktualisiert
int('attribute_count', { default: 0 }),
id('folder_id'), // null = brand-weite Ebene; Kategorie-Ø wird je Ordner geführt
],
indexes: [
{ key: 'idx_brand_name', type: 'key', columns: ['brand_id', 'name'] },
{ key: 'idx_folder', type: 'key', columns: ['folder_id'] },
],
indexes: [{ key: 'idx_brand_name', type: 'key', columns: ['brand_id', 'name'] }],
},
{
id: 'attributes', name: 'Attributes', rowSecurity: true,
@@ -151,22 +215,21 @@ const TABLES = [
str('name', 255, { required: true }),
str('slug', 255, { required: true }),
enm('status', ['aktiv', 'archiviert'], { default: 'aktiv' }), // nie löschen
int('score', { min: 0, max: 10000, default: 5000 }),
int('k_factor', { default: 32 }), // 32 neu → 8 etabliert
int('start_value', { min: 0, max: 10000 }), // Kategorie-Ø bei Anlage
int('used_count', { default: 0 }), int('wins', { default: 0 }), int('losses', { default: 0 }),
dt('last_used_at'),
// Score, k_factor, start_value, used_count, wins, losses, last_used_at liegen
// NICHT mehr hier, sondern in `attribute_scores` ein Attribut hat je Ordner
// einen eigenen Score. Diese Tabelle hält nur noch die Definition.
str('tags', 255, { array: true }),
md('beschreibung_md'), md('essenz_md'), md('details_md'),
str('prompt_bausteine', 1024, { array: true }),
str('negativ_prompts', 1024, { array: true }),
],
indexes: [
{ key: 'idx_topn', type: 'key', columns: ['brand_id', 'category_id', 'status', 'score'], orders: ['ASC', 'ASC', 'ASC', 'DESC'] },
{ key: 'idx_parent', type: 'key', columns: ['parent_id'] },
{ key: 'idx_slug', type: 'key', columns: ['brand_id', 'slug'] },
// Fulltext ist auf Array-Spalten verboten → Key-Index (reicht für Query.contains)
{ key: 'idx_tags', type: 'key', columns: ['tags'] },
// Der Top-N-Index liegt jetzt auf attribute_scores dort steht der Score.
{ key: 'idx_brand_cat', type: 'key', columns: ['brand_id', 'category_id', 'status'] },
],
},
{
@@ -175,22 +238,40 @@ const TABLES = [
columns: [
id('attribute_id', { required: true }),
id('video_id'), id('opponent_attribute_id'),
enm('event_type', ['vote', 'vote_favorit_bestaetigt', 'performance', 'gezielte_frage', 'llm_vergleich'], { required: true }),
enm('event_type', [
'vote', 'vote_favorit_bestaetigt', 'performance', 'gezielte_frage', 'llm_vergleich',
'feed_import', 'post_reichweite', // Bild-Modus: Import beim Kopieren, Rückfluss ×0,3
], { required: true }),
int('delta'), int('new_score', { min: 0, max: 10000 }),
flt('weight', { default: 1 }), // ×0,3 / ×1 / ×2
str('kommentar', 1024), // z. B. P12-Erkenntnis
id('folder_id'), // in welchem Wissens-Scope die Änderung galt
id('post_id'), // bei feed_import / post_reichweite
],
indexes: [
{ key: 'idx_attr_created', type: 'key', columns: ['attribute_id', '$createdAt'] },
{ key: 'idx_folder_created', type: 'key', columns: ['folder_id', '$createdAt'] },
],
indexes: [{ key: 'idx_attr_created', type: 'key', columns: ['attribute_id', '$createdAt'] }],
},
{
id: 'assets', name: 'Assets', rowSecurity: true,
columns: [
id('brand_id', { required: true }),
enm('typ', ['gesicht', 'produkt', 'logo', 'sonstiges'], { required: true }),
enm('typ', ['gesicht', 'produkt', 'logo', 'sonstiges', 'kulisse'], { required: true }),
str('name', 255, { required: true }),
id('released_version_id'), // nur diese Version wird in Videos verwendet
// Feed: Modelle werden nie geteilt (Marken-/Designrecht, Konsistenz), erscheinen
// aber mit eigenem Feed-Score in der Modell-Rangliste.
enm('nische', NISCHEN),
enm('typ_tags', TYP_TAGS, { array: true }),
flt('feed_score', { default: 0 }),
dt('feed_score_updated_at'),
bool('ist_teilbar', { default: false }),
],
indexes: [
{ key: 'idx_brand', type: 'key', columns: ['brand_id'] },
{ key: 'idx_teilbar', type: 'key', columns: ['ist_teilbar', 'nische'] },
],
indexes: [{ key: 'idx_brand', type: 'key', columns: ['brand_id'] }],
},
{
id: 'asset_versions', name: 'Asset Versions', rowSecurity: true,
@@ -216,6 +297,7 @@ const TABLES = [
md('script_md'), md('script_final_md'), // Diff = P6-Signal
enm('status', ['entwurf', 'script', 'generiert', 'voting', 'fertig'], { default: 'entwurf' }), // Realtime-Kanal fürs UI
int('video_count', { min: 1, default: 4 }),
id('folder_id'), // Ordner wird VOR der Generierung gewählt; P4 zieht nur dessen Attribute
],
indexes: [{ key: 'idx_brand_status', type: 'key', columns: ['brand_id', 'status'] }],
},
@@ -277,7 +359,10 @@ const TABLES = [
id: 'jobs', name: 'Jobs', rowSecurity: true,
columns: [
id('brand_id', { required: true }),
enm('typ', ['bild_gen', 'video_gen', 'tagging', 'vergleich', 'script'], { required: true }),
enm('typ', [
'bild_gen', 'video_gen', 'tagging', 'vergleich', 'script',
'slot_analyse', 'ordner_vorschlag', 'werbetext', // P19, P20, P21
], { required: true }),
str('prompt_template_key', 16), // welcher Prompt …
int('prompt_template_version'), // … in welcher Version lief
enm('status', ['wartend', 'laeuft', 'fertig', 'fehler'], { default: 'wartend' }),
@@ -301,13 +386,158 @@ const TABLES = [
],
indexes: [{ key: 'uq_brand_periode', type: 'unique', columns: ['brand_id', 'periode'] }],
},
// ---- Wissens-Scope: Ordner + Scores je Ordner ---------------------------
{
// Ein Ordner ist ein privater Wissens-Scope, kein Sortier-Ordner.
id: 'folders', name: 'Folders', rowSecurity: true,
columns: [
id('brand_id', { required: true }),
str('name', 255, { required: true }),
md('theme_md'),
str('zweck', 1024),
enm('startwert_modus', ['erben', 'aus_posts', 'neutral'], { default: 'erben' }),
bool('ist_default', { default: false }),
int('post_count', { default: 0 }),
int('signal_count', { default: 0 }), // Reifegrad des Ordners
],
indexes: [
{ key: 'idx_brand', type: 'key', columns: ['brand_id'] },
{ key: 'idx_brand_default', type: 'key', columns: ['brand_id', 'ist_default'] },
],
},
{
// Herzstück: ein Attribut hat je Ordner einen eigenen Score.
// folder_id = null ist die brand-weite Ebene.
id: 'attribute_scores', name: 'Attribute Scores', rowSecurity: true,
columns: [
id('brand_id', { required: true }),
id('attribute_id', { required: true }),
id('folder_id'), // null = brand-weit
id('category_id', { required: true }),
int('score', { min: 0, max: 10000, default: 5000 }),
int('k_factor', { default: 32 }), // 32 neu → 8 etabliert
int('start_value', { min: 0, max: 10000 }), // Kategorie-Ø bei Anlage
enm('start_quelle', ['kategorie_schnitt', 'geerbt', 'aus_posts', 'feed_import', 'neutral'], { default: 'neutral' }),
int('used_count', { default: 0 }), int('wins', { default: 0 }), int('losses', { default: 0 }),
dt('last_used_at'),
],
indexes: [
// der Top-N-Index, wegen dem Referenzen indizierte Strings sind (siehe Kopf)
{ key: 'idx_topn', type: 'key', columns: ['brand_id', 'folder_id', 'category_id', 'score'], orders: ['ASC', 'ASC', 'ASC', 'DESC'] },
{ key: 'uq_attr_folder', type: 'unique', columns: ['attribute_id', 'folder_id'] },
{ key: 'idx_folder', type: 'key', columns: ['folder_id'] },
],
},
// ---- Bild-Modus & Feed --------------------------------------------------
{
// Ein Post ist ein Slot-Rezept, kein Freitext-Prompt nur deshalb kopierbar.
// prompt_sent ist das Betriebsgeheimnis und darf nie an fremde Konten gehen.
id: 'posts', name: 'Posts', rowSecurity: true,
columns: [
id('brand_id', { required: true }),
id('folder_id'),
str('titel', 255),
md('user_prompt'),
jsonCol('slots', 65535), // das Rezept: Kulisse, Modelle, Licht/Kamera/Farbe, Werbetext
jsonCol('slot_summary', 65535), // die abstrahierten Chips (P19) das sieht der Kopierer
md('prompt_sent'), // 🔒 nur eigenes Konto + Team internal
str('format', 32), // 1:1 | 4:5 | 9:16
int('bild_count', { default: 1 }),
enm('status', ['entwurf', 'generiert', 'fehler'], { default: 'entwurf' }),
enm('sichtbarkeit', ['privat', 'oeffentlich'], { default: 'privat' }), // Veröffentlichen ist ein aktiver Schritt
flt('feed_score', { default: 0 }), // gewichtete Summe + Decay, NICHT Elo
dt('feed_score_updated_at'),
dt('veroeffentlicht_at'),
id('copied_from_post_id'),
int('kopien_count', { default: 0 }), // Kopien ×1 im Feed-Score
enm('nische', NISCHEN),
],
indexes: [
{ key: 'idx_brand_status', type: 'key', columns: ['brand_id', 'status'] },
{ key: 'idx_feed', type: 'key', columns: ['sichtbarkeit', 'feed_score'], orders: ['ASC', 'DESC'] },
// Explorations-Slot: neue Posts chronologisch, gegen Rich-get-richer
{ key: 'idx_feed_neu', type: 'key', columns: ['sichtbarkeit', 'veroeffentlicht_at'], orders: ['ASC', 'DESC'] },
{ key: 'idx_copied', type: 'key', columns: ['copied_from_post_id'] },
],
},
{
// Bilderkette: nur Position und Winkel variieren. Das Text-Overlay ist ein
// eigenes Bild, keine Ebene so lässt sich Text ohne Neugenerierung tauschen.
id: 'post_images', name: 'Post Images', rowSecurity: true,
columns: [
id('post_id', { required: true }),
id('brand_id', { required: true }),
int('position', { min: 1, default: 1 }),
enm('typ', ['motiv', 'text_overlay'], { default: 'motiv' }),
id('storage_file_id'), // → Bucket generated-images
md('prompt_sent'), // 🔒
id('asset_version_ids', { array: true }),
enm('typ_tags', TYP_TAGS, { array: true }), // P22-Tagger, Enum-Zwang
int('breite'), int('hoehe'),
],
indexes: [{ key: 'idx_post_position', type: 'key', columns: ['post_id', 'position'] }],
},
{
// m:n ein Post darf in mehreren Ordnern liegen, eigene wie fremde
id: 'post_folders', name: 'Post ↔ Folder', rowSecurity: true,
columns: [
id('post_id', { required: true }),
id('folder_id', { required: true }),
id('brand_id', { required: true }),
bool('ist_fremd', { default: false }),
enm('quelle', ['eigen', 'feed_import', 'kopie'], { default: 'eigen' }),
],
indexes: [
{ key: 'uq_post_folder', type: 'unique', columns: ['post_id', 'folder_id'] },
{ key: 'idx_folder', type: 'key', columns: ['folder_id'] },
],
},
{
// Feed-Signale append-only. Views ×0,1 · Likes ×0,5 · Kopien ×1 · Social ×2
id: 'post_metrics', name: 'Post Metrics', rowSecurity: true,
columns: [
id('post_id', { required: true }),
dt('fetched_at'),
int('views', { default: 0 }), int('likes', { default: 0 }), int('kopien', { default: 0 }),
int('social_reichweite', { default: 0 }),
str('social_plattform', 32),
],
indexes: [{ key: 'idx_post_fetched', type: 'key', columns: ['post_id', 'fetched_at'] }],
},
{
id: 'follows', name: 'Follows', rowSecurity: true,
columns: [
id('follower_brand_id', { required: true }),
id('followed_brand_id', { required: true }),
],
indexes: [
{ key: 'uq_follow', type: 'unique', columns: ['follower_brand_id', 'followed_brand_id'] },
{ key: 'idx_follower', type: 'key', columns: ['follower_brand_id'] },
{ key: 'idx_followed', type: 'key', columns: ['followed_brand_id'] },
],
},
];
/**
* Buckets. `fileSecurity` überall an gelesen wird nur, was die Datei-Rechte
* hergeben.
*
* `create("users")` aus demselben Grund wie bei den Tabellen: eine Datei, die
* noch nicht existiert, hat keine Rechte. Ohne Bucket-Recht zum Anlegen kann
* der Client nichts hochladen.
*
* **Ohne** `create`: `generated-videos` und `generated-images` dort schreibt
* ausschließlich der Job-Dispatcher mit Server-Key. Dürfte der Client das,
* könnte er Bilder unterschieben, die nie durch eine Generierung gelaufen sind.
*/
const BUCKETS = [
{ id: 'uploads', name: 'Uploads (Onboarding)' },
{ id: 'asset-references', name: 'Asset-Referenzbilder' },
{ id: 'uploads', name: 'Uploads (Onboarding)', permissions: userCreate },
{ id: 'asset-references', name: 'Asset-Referenzbilder', permissions: userCreate },
{ id: 'generated-videos', name: 'Generierte Videos' },
{ id: 'consents', name: 'Einwilligungen', permissions: [`read("${INTERNAL}")`] },
{ id: 'consents', name: 'Einwilligungen', permissions: [`read("${INTERNAL}")`, ...userCreate] },
{ id: 'generated-images', name: 'Generierte Bilder' },
];
// ---- Ablauf ---------------------------------------------------------------
@@ -338,11 +568,27 @@ async function main() {
for (const t of TABLES) {
console.log(`Tabelle ${t.id}:`);
await ensure(t.id, 'POST', `/tablesdb/${DB_ID}/tables`, {
const perms = tablePermissions(t);
const fresh = await ensure(t.id, 'POST', `/tablesdb/${DB_ID}/tables`, {
tableId: t.id, name: t.name,
permissions: t.permissions || [],
permissions: perms,
rowSecurity: t.rowSecurity,
});
// Existierte die Tabelle schon, hat POST nur ein 409 geliefert die Rechte
// wären dann nie angefasst worden. Deshalb hier abgleichen und angleichen:
// sonst driftet das Rechte-Modell genauso auseinander wie zuvor das Schema.
if (!fresh) {
const { status, json } = await api('GET', `/tablesdb/${DB_ID}/tables/${t.id}`);
const same = status === 200
&& JSON.stringify([...(json.$permissions ?? [])].sort()) === JSON.stringify([...perms].sort())
&& !!json.rowSecurity === !!t.rowSecurity;
if (!same) {
await ensure(`${t.id} Rechte`, 'PUT', `/tablesdb/${DB_ID}/tables/${t.id}`, {
name: t.name, permissions: perms, rowSecurity: t.rowSecurity,
});
updated++; created--; // ensure() zählt als "angelegt" hier ist es eine Änderung
}
}
for (const c of t.columns) {
// Erst GET: Appwrite prüft das Zeilengrößen-Limit VOR dem Duplikat-Check,
// ein erneutes POST auf große Spalten gäbe sonst 400 statt 409.
@@ -367,15 +613,29 @@ async function main() {
console.log('Storage-Buckets:');
for (const b of BUCKETS) {
await ensure(b.id, 'POST', '/storage/buckets', {
const perms = b.permissions || [];
const fresh = await ensure(b.id, 'POST', '/storage/buckets', {
bucketId: b.id, name: b.name,
fileSecurity: true,
permissions: b.permissions || [],
permissions: perms,
enabled: true,
});
// Gleiches Nachziehen wie bei den Tabellen ein 409 lässt die Rechte sonst
// auf dem Stand von damals stehen.
if (!fresh) {
const { status, json } = await api('GET', `/storage/buckets/${b.id}`);
const same = status === 200
&& JSON.stringify([...(json.$permissions ?? [])].sort()) === JSON.stringify([...perms].sort());
if (!same) {
await ensure(`${b.id} Rechte`, 'PUT', `/storage/buckets/${b.id}`, {
name: b.name, fileSecurity: true, permissions: perms, enabled: true,
});
updated++; created--;
}
}
}
console.log(`\nFertig: ${created} angelegt, ${skipped} existierten schon, ${warned} Warnung(en).`);
console.log(`\nFertig: ${created} angelegt, ${updated} geändert, ${skipped} existierten schon, ${warned} Warnung(en).`);
}
main().catch(e => { console.error(`\nAbbruch: ${e.message}`); process.exit(1); });

119
scripts/test-mandanten.mjs Normal file
View File

@@ -0,0 +1,119 @@
/**
* E2-Abnahme: Mandantentrennung geprüft über die API, nicht über das UI.
*
* Legt zwei echte Konten an und baut für beide Team + brands-Zeile genau so wie
* client/src/lib/auth.ts. Danach: sieht Konto B irgendetwas von Konto A?
*
* Kein SDK, nur fetch mit eigenem Cookie-Speicher so verhält sich der Aufruf
* wie ein Browser, und der Test hängt nicht an SDK-Eigenheiten.
*/
const ENDPOINT = 'https://appwrite.webklar.com/v1';
const PROJECT = '6a5cee34002bb8360c34';
const DB = 'brandloop';
const KEY = process.env.APPWRITE_API_KEY;
if (!KEY) { console.error('APPWRITE_API_KEY fehlt'); process.exit(1); }
const stamp = Date.now();
let fehler = 0;
const ok = (m) => console.log(` ok ${m}`);
const bad = (m) => { fehler++; console.log(` FEHL ${m}`); };
const limit = encodeURIComponent(JSON.stringify({ method: 'limit', values: [100] }));
/** Ein Konto = ein Cookie-Speicher, so wie ein Browser-Profil. */
function neueSitzung() {
const jar = new Map();
return async function call(method, path, body) {
const headers = { 'X-Appwrite-Project': PROJECT, 'Content-Type': 'application/json' };
if (jar.size) headers.Cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
const res = await fetch(`${ENDPOINT}${path}`, {
method, headers, body: body ? JSON.stringify(body) : undefined, redirect: 'manual',
});
for (const c of res.headers.getSetCookie?.() ?? []) {
const [pair] = c.split(';');
const i = pair.indexOf('=');
if (i > 0) jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim());
}
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = { message: text }; }
return { status: res.status, json };
};
}
const admin = async (method, path, body) => {
const res = await fetch(`${ENDPOINT}${path}`, {
method,
headers: { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': KEY, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = { message: text }; }
return { status: res.status, json };
};
function muss(r, was) {
if (r.status >= 200 && r.status < 300) return r.json;
throw new Error(`${was}: HTTP ${r.status} ${r.json?.message}`);
}
/** Spiegelt client/src/lib/auth.ts: Konto → Session → Team → brands-Zeile. */
async function kontoAnlegen(kennung) {
const call = neueSitzung();
const email = `e2-${kennung}-${stamp}@brandloop.test`;
const password = `Test-${stamp}-${kennung}!`;
const label = `Marke ${kennung.toUpperCase()}`;
const user = muss(await call('POST', '/account', { userId: 'unique()', email, password, name: label }), 'account');
muss(await call('POST', '/account/sessions/email', { email, password }), 'session');
const team = muss(await call('POST', '/teams', { teamId: 'unique()', name: label }), 'team');
const rolle = `team:${team.$id}`;
const brand = muss(await call('POST', `/tablesdb/${DB}/tables/brands/rows`, {
rowId: 'unique()',
data: { team_id: team.$id, label_name: label, status: 'trial', plan: 'trial' },
permissions: [`read("${rolle}")`, `update("${rolle}")`, `delete("${rolle}")`],
}), 'brands-Zeile');
return { call, userId: user.$id, teamId: team.$id, brandId: brand.$id, label };
}
console.log('Konten anlegen (wie der Client es tut):');
const A = await kontoAnlegen('a');
console.log(` A: user=${A.userId} team=${A.teamId} brand=${A.brandId}`);
const B = await kontoAnlegen('b');
console.log(` B: user=${B.userId} team=${B.teamId} brand=${B.brandId}`);
console.log('\nMandantentrennung:');
const liste = await B.call('GET', `/tablesdb/${DB}/tables/brands/rows?queries[]=${limit}`);
const ids = (liste.json.rows ?? []).map((r) => r.$id);
if (ids.includes(A.brandId)) bad(`B sieht A's Zeile in der Liste`);
else ok(`B listet ${ids.length} brands-Zeile(n), A's ist nicht dabei`);
if (!ids.includes(B.brandId)) bad('B sieht die EIGENE Zeile nicht Rechte zu streng');
else ok('B sieht die eigene Zeile');
const direkt = await B.call('GET', `/tablesdb/${DB}/tables/brands/rows/${A.brandId}`);
if (direkt.status < 300) bad(`B konnte A's Zeile direkt lesen (HTTP ${direkt.status})`);
else ok(`Direktzugriff B→A abgewiesen (HTTP ${direkt.status} ${direkt.json?.type ?? ''})`);
const schreib = await B.call('PATCH', `/tablesdb/${DB}/tables/brands/rows/${A.brandId}`, { data: { label_name: 'gekapert' } });
if (schreib.status < 300) bad(`B konnte A's Zeile ÄNDERN (HTTP ${schreib.status})`);
else ok(`Schreibzugriff B→A abgewiesen (HTTP ${schreib.status} ${schreib.json?.type ?? ''})`);
// Gegenprobe: sieht der Server-Key beide? Sonst wäre der erste Test wertlos,
// weil dann vielleicht schlicht nichts in der Tabelle steht.
const alle = await admin('GET', `/tablesdb/${DB}/tables/brands/rows?queries[]=${limit}`);
const alleIds = (alle.json.rows ?? []).map((r) => r.$id);
if (alleIds.includes(A.brandId) && alleIds.includes(B.brandId)) ok(`Server-Key sieht beide Zeilen (${alle.json.total} gesamt) der erste Test ist damit aussagekräftig`);
else bad(`Server-Key sieht nicht beide: ${JSON.stringify(alleIds)}`);
console.log('\nAufräumen:');
for (const k of [A, B]) {
await admin('DELETE', `/tablesdb/${DB}/tables/brands/rows/${k.brandId}`);
await admin('DELETE', `/teams/${k.teamId}`);
await admin('DELETE', `/users/${k.userId}`);
}
const rest = await admin('GET', `/tablesdb/${DB}/tables/brands/rows?queries[]=${limit}`);
console.log(` Testkonten entfernt, brands enthält jetzt ${rest.json.total} Zeile(n).`);
console.log(fehler ? `\n${fehler} FEHLER Mandantentrennung nicht dicht.` : '\nMandantentrennung hält.');
process.exit(fehler ? 1 : 0);