// Die Community-Logos lesen: Tabelle `logos` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen), dazu die eigenen Herzen // (`logo_likes`, jeder liest nur seine) und die Meldungen (`logo_reports`, nur Admins). Schreiben (Hochladen, Herz, Melden, // Löschen, Ausblenden) läuft über den API-Dienst, siehe logo-api.ts. Aufbau wie community/gallery.ts. import { AppwriteException, Query, type Models } from 'appwrite'; import { ID_PATTERN, fetchFile } from '../account/cards'; import { IDS, tables } from '../account/appwrite'; import { isRowMissing } from '../account/errors'; export interface CommunityLogo { id: string; name: string; displayName: string; fileId: string; ownerId: string; /** Einfarbig: nur die Deckkraft zählt, das Logo nimmt die Farbe der Karte an (hell/dunkel). */ mono: boolean; likeCount: number; hidden: boolean; reportCount: number; createdAt: string; } export interface LogoReport { id: string; logoId: string; reason: string; createdAt: string; } /** Beliebt: meiste Herzen zuerst, bei Gleichstand die neuesten; Neu: neueste zuerst. */ export type LogoSort = 'popular' | 'new'; /** Ansicht der Logos: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */ export type LogoFilter = 'all' | 'reported' | 'hidden'; export interface LogoListOptions { sort: LogoSort; filter?: LogoFilter; cursor?: string; } export const LOGO_PAGE_SIZE = 24; /** Obergrenze für die Meldungen eines Logos und die Herzen einer Seite (eine Seite hat 24 Logos). */ const LIST_MAX = 100; type Row = Record; /** Eine Zeile, wie Appwrite sie liefert: Systemfelder (`$id`, …) plus die Spalten der Tabelle. */ type TableRow = Models.Row & Row; function text(value: unknown): string { return typeof value === 'string' ? value : ''; } function count(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; } /** Eine Zeile der Tabelle `logos` als Logo; fehlende oder falsch typisierte Felder werden leer. */ export function toLogo(row: Row): CommunityLogo { return { id: text(row.$id), name: text(row.name), displayName: text(row.displayName), fileId: text(row.fileId), ownerId: text(row.ownerId), mono: row.mono === true, likeCount: count(row.likeCount), hidden: row.hidden === true, reportCount: count(row.reportCount), createdAt: text(row.$createdAt), }; } /** * Abfragen für eine Seite der Logos. Öffentlich immer nur sichtbare; Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon * ausgeblendete), „Ausgeblendet“ = hidden. Beliebt sortiert nach Herzen, dann nach Alter (beides absteigend), Neu nur nach Alter. * Optional: Cursor (die ID des letzten Logos der Seite davor). */ export function logoQueries(opts: LogoListOptions): string[] { const base = opts.filter === 'reported' ? Query.greaterThan('reportCount', 0) : opts.filter === 'hidden' ? Query.equal('hidden', true) : Query.equal('hidden', false); const order = opts.sort === 'popular' ? [Query.orderDesc('likeCount'), Query.orderDesc('$createdAt')] : [Query.orderDesc('$createdAt')]; const queries = [base, ...order, Query.limit(LOGO_PAGE_SIZE)]; if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor)); return queries; } /** Eine Seite der Logos. `nextCursor` ist die ID des letzten Logos, solange die Seite voll war, sonst null. */ export async function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }> { const list = await tables.listRows({ databaseId: IDS.database, tableId: IDS.logos, queries: logoQueries(opts) }); const logos = list.rows.map((row) => toLogo(row)); return { logos, nextCursor: logos.length === LOGO_PAGE_SIZE ? logos[logos.length - 1].id : null }; } /** * Ein Logo per ID. Gibt es das Logo nicht oder ist es für mich ausgeblendet oder gelöscht (Appwrite antwortet beides mit 404), * ist das Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage. */ export async function getLogo(id: string): Promise { if (!ID_PATTERN.test(id)) return null; try { return toLogo(await tables.getRow({ databaseId: IDS.database, tableId: IDS.logos, rowId: id })); } catch (err) { if (isRowMissing(err)) return null; throw err; } } /** Von diesen Logos (einer Seite, höchstens 100) die, die ich mit einem Herz markiert habe; Gäste und Nutzer ohne Herzen: leer. */ export async function myHearts(userId: string, logoIds: readonly string[]): Promise> { const hearts = new Set(); if (!userId || logoIds.length === 0) return hearts; const list = await tables.listRows({ databaseId: IDS.database, tableId: IDS.logoLikes, queries: [Query.equal('userId', userId), Query.equal('logoId', [...logoIds]), Query.limit(LIST_MAX)], }); for (const row of list.rows) { const logoId = text(row.logoId); if (logoId) hearts.add(logoId); } return hearts; } /** Admin: die Meldungen eines Logos, neueste zuerst. Der Melder-Hash bleibt außen vor. */ export async function listLogoReports(logoId: string): Promise { if (!ID_PATTERN.test(logoId)) return []; const list = await tables.listRows({ databaseId: IDS.database, tableId: IDS.logoReports, queries: [Query.equal('logoId', logoId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)], }); return list.rows.map((row) => ({ id: text(row.$id), logoId: text(row.logoId), reason: text(row.reason), createdAt: text(row.$createdAt), })); } /** Lädt die PNG-Datei eines Logos aus dem Logo-Bucket. */ export async function loadLogoBlob(logo: CommunityLogo): Promise { // Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht. if (!ID_PATTERN.test(logo.fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', ''); return fetchFile(IDS.logoBucket, logo.fileId); }