ki integration
This commit is contained in:
1 parent
82d98bd8cf
commit
9ecb292c26
78 files changed
+12055
-55
No files matched your search
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user