ki integration

This commit is contained in:
knso committed 2026-08-15 13:43:52 +02:00
1 parent 82d98bd8cf
commit 9ecb292c26
78 files changed
+12055 -55

No files matched your search

+32
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;
+22
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
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“),
* 4–6 sind das Optimum (projekt-uebersicht.md §8).
*/
export const BILDER_MAX = 6;
export const BILDER_EMPFOHLEN = 4;
+88
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.';
}
+16
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;
}
+51
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
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
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
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`;
}
+13
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
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
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
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;
}
+34
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;
}