Files
AirCardIOS/web/src/logos/logos.ts
T

151 lines
5.9 KiB
TypeScript

// 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<string, unknown>;
/** 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<TableRow>({ 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<CommunityLogo | null> {
if (!ID_PATTERN.test(id)) return null;
try {
return toLogo(await tables.getRow<TableRow>({ 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<Set<string>> {
const hearts = new Set<string>();
if (!userId || logoIds.length === 0) return hearts;
const list = await tables.listRows<TableRow>({
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<LogoReport[]> {
if (!ID_PATTERN.test(logoId)) return [];
const list = await tables.listRows<TableRow>({
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<Blob> {
// 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);
}