feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -350,7 +350,7 @@ describe('save: bestehende Karte', () => {
|
||||
|
||||
it('ohne Bildwechsel: nur neue Vorschau, Update, dann alte Vorschau löschen', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const c = config({ network: 'jcb' });
|
||||
const c = config({ logo: 'abc123' });
|
||||
const saved = await service(fake).save(
|
||||
newInput({ name: 'Neu', config: c, imageChanged: false, previous: previous() }),
|
||||
);
|
||||
@@ -370,7 +370,7 @@ describe('save: bestehende Karte', () => {
|
||||
expect(saved.id).toBe('row1');
|
||||
expect(saved.imageFileId).toBe('oldImage');
|
||||
expect(saved.previewFileId).toBe('file1');
|
||||
expect(saved.config.network).toBe('jcb');
|
||||
expect(saved.config.logo).toBe('abc123');
|
||||
});
|
||||
|
||||
it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
|
||||
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
|
||||
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
|
||||
import { parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import type { SavedCardRef } from '../editor/state';
|
||||
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
|
||||
import { currentUserId } from './auth';
|
||||
@@ -46,8 +46,8 @@ export interface SaveInput {
|
||||
|
||||
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
||||
const NAME_MAX = 80;
|
||||
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
|
||||
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
/** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
|
||||
export { ID_PATTERN };
|
||||
|
||||
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
|
||||
export function normalizeName(name: string): string {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="-66 -67.24 1132.01 1132.01" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-width="36" d="M-48-49.24h1096v1096H-48z"/><path fill="currentColor" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 814.11 999.91" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2m-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3"/></svg>
|
||||
|
Before Width: | Height: | Size: 655 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 -0.49 220.58 37.13" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.125 0 0 -.125 -.2539 59.38386)"><path d="M145.605 261.297c-17.113-15.453-39.343-22.195-74.535-22.195H56.453v184.644H71.07c35.192 0 56.547-6.297 74.535-22.582 18.836-16.773 30.168-42.762 30.168-69.512 0-26.804-11.332-53.589-30.168-70.355M81.981 471.055H2.03V191.832h79.53c42.289 0 72.824 9.977 99.629 32.234 31.851 26.36 50.687 66.09 50.687 107.184 0 82.402-61.566 139.805-149.894 139.805m174.956-279.223h54.465v279.223h-54.465zm187.64 172.074c-32.683 12.094-42.281 20.071-42.281 35.157 0 17.585 17.098 30.949 40.574 30.949 16.32 0 29.731-6.703 43.922-22.614l28.496 37.317c-23.418 20.484-51.437 30.957-82.051 30.957-49.402 0-87.086-34.309-87.086-80.008 0-38.469 17.547-58.164 68.7-76.574 21.32-7.52 32.171-12.531 37.648-15.902 10.883-7.106 16.332-17.165 16.332-28.891 0-22.625-17.988-39.387-42.289-39.387-25.977 0-46.902 12.992-59.441 37.242l-35.188-33.879c25.094-36.835 55.231-53.16 96.672-53.16 56.602 0 96.305 37.633 96.305 91.688 0 44.363-18.356 64.445-80.313 87.105m97.496-32.656c0-82.074 64.446-145.715 147.383-145.715 23.445 0 43.523 4.61 68.285 16.274v64.113c-21.773-21.793-41.058-30.582-65.754-30.582-54.851 0-93.785 39.769-93.785 96.312 0 53.61 40.164 95.899 91.254 95.899 25.977 0 45.641-9.27 68.285-31.418v64.082c-23.906 12.129-43.566 17.152-67 17.152-82.515 0-148.668-64.929-148.668-146.117m647.516-47.75-74.46 187.555h-59.5l118.49-286.387h29.31l120.63 286.387h-59.04zm159.1-91.668h154.46v47.27h-100.04v75.371h96.35v47.297h-96.35v61.976h100.04v47.309h-154.46zm260.84 150.672h-15.91v84.57h16.77c33.92 0 52.36-14.211 52.36-41.386 0-28.071-18.44-43.184-53.22-43.184m109.27 46.117c0 52.274-36.01 82.434-98.82 82.434h-80.76V191.832h54.4V304h7.1l75.39-112.168h66.98l-87.9 117.633c41.02 8.34 63.61 36.402 63.61 79.156m24.44 68.59h-.99v6.414h1.05c2.9 0 4.4-1.043 4.4-3.156 0-2.172-1.52-3.258-4.46-3.258m10.32 3.348c0 4.894-3.37 7.57-9.29 7.57h-7.88v-24.543h5.86v9.519l6.87-9.519h7.15l-8.07 10.121c3.45.93 5.36 3.445 5.36 6.852" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><path d="M1745.37 438.332c-9.38 0-17.05 7.805-17.05 17.543 0 9.773 7.57 17.574 17.05 17.574 9.33 0 16.94-7.98 16.94-17.574 0-9.656-7.61-17.543-16.94-17.543m.08 38.969c-11.92 0-21.38-9.512-21.38-21.395 0-11.875 9.57-21.398 21.38-21.398 11.61 0 21.12 9.625 21.12 21.398 0 11.715-9.51 21.395-21.12 21.395" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><defs><linearGradient id="discover-g" x1=".3" x2=".7" y1="1" y2="0"><stop offset="0" stop-color="#ffb000"/><stop offset="1" stop-color="#e5600f"/></linearGradient></defs><circle cx="924.03" cy="330.5" r="148.5" fill="url(#discover-g)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="1 1 298.01 229.52" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-17.94377 -13.02567)scale(1.821516)"><path d="M174 108.3c0 14-11.4 25.4-25.4 25.4H10.4V33.1c0-14 11.4-25.4 25.4-25.4H174z" style="fill:#fff"/><linearGradient id="jcb-g1_" x1="117.386" x2="157.33" y1="81.399" y2="81.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M129 82.5h10.5c.3 0 1-.1 1.3-.1 2-.4 3.7-2.2 3.7-4.7 0-2.4-1.7-4.2-3.7-4.7-.3-.1-.9-.1-1.3-.1H129z" style="fill:url(#jcb-g1_)"/><linearGradient id="jcb-g2_" x1="117.384" x2="157.332" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M138.3 16.2c-10 0-18.2 8.1-18.2 18.2v18.9h25.7c.6 0 1.3 0 1.8.1 5.8.3 10.1 3.3 10.1 8.5 0 4.1-2.9 7.6-8.3 8.3v.2c5.9.4 10.4 3.7 10.4 8.8 0 5.5-5 9.1-11.6 9.1H120v37h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g2_)"/><linearGradient id="jcb-g3_" x1="117.385" x2="157.331" y1="68.399" y2="68.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M143.2 63.1c0-2.4-1.7-4-3.7-4.3-.2 0-.7-.1-1-.1H129v8.8h9.5c.3 0 .9 0 1-.1 2-.3 3.7-1.9 3.7-4.3" style="fill:url(#jcb-g3_)"/><linearGradient id="jcb-g4_" x1="27.959" x2="68.522" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#1f286f"/><stop offset=".475" style="stop-color:#004e94"/><stop offset=".826" style="stop-color:#0066b1"/><stop offset="1" style="stop-color:#006fbc"/></linearGradient><path d="M37.7 16.2c-10 0-18.2 8.1-18.2 18.2v44.9c5.1 2.5 10.4 4.1 15.7 4.1 6.3 0 9.7-3.8 9.7-9V53.2h15.6v21.1c0 8.2-5.1 14.9-22.4 14.9-10.5 0-18.7-2.3-18.7-2.3v38.3h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g4_)"/><linearGradient id="jcb-g5_" x1="72.46" x2="111.855" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#6c2c2f"/><stop offset=".173" style="stop-color:#882730"/><stop offset=".573" style="stop-color:#be1833"/><stop offset=".859" style="stop-color:#dc0436"/><stop offset="1" style="stop-color:#e60039"/></linearGradient><path d="M88 16.2c-10 0-18.2 8.1-18.2 18.2v23.8c4.6-3.9 12.6-6.4 25.5-5.8 6.9.3 14.3 2.2 14.3 2.2v7.7c-3.7-1.9-8.1-3.6-13.8-4-9.8-.7-15.7 4.1-15.7 12.5 0 8.5 5.9 13.3 15.7 12.5 5.7-.4 10.1-2.2 13.8-4V87s-7.3 1.9-14.3 2.2c-12.9.6-20.9-1.9-25.5-5.8v42h26.7c10 0 18.2-8.1 18.2-18.2v-91z" style="fill:url(#jcb-g5_)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="1.97 1.87 296.07 177.27" xmlns="http://www.w3.org/2000/svg"><path d="M298.032 90.5c.014 48.936-39.646 88.614-88.582 88.627-48.937.012-88.614-39.646-88.627-88.582V90.5c-.013-48.935 39.647-88.615 88.581-88.628 48.937-.013 88.615 39.647 88.628 88.583z" style="fill:#f90"/><path d="M90.001 1.895C41.355 2.204 1.967 41.781 1.967 90.5c0 48.909 39.695 88.604 88.605 88.604 22.955 0 43.879-8.748 59.624-23.086l-.007-.004h.019a89.4 89.4 0 0 0 8.995-9.488H141.05a87 87 0 0 1-6.606-9.127h31.308a89 89 0 0 0 5.158-9.488h-41.635a87 87 0 0 1-3.703-9.309h49.045a88.4 88.4 0 0 0 4.56-28.103c0-6.512-.706-12.861-2.042-18.974h-54.164a88 88 0 0 1 2.528-9.308h49.063a89 89 0 0 0-3.82-9.487H129.27a85 85 0 0 1 5.084-9.307h31.285a89 89 0 0 0-6.877-9.488h-17.443a82 82 0 0 1 8.889-8.95c-15.746-14.34-36.676-23.09-59.636-23.09z" style="fill:#c00"/><path d="M289.143 136.82q.723.001 1.409.371.688.37 1.07 1.062c.256.456.384.937.384 1.435q0 .739-.379 1.424-.377.683-1.061 1.063-.678.374-1.424.375c-.746.001-.972-.126-1.426-.375a2.7 2.7 0 0 1-1.063-1.063 2.9 2.9 0 0 1-.377-1.424c0-.498.127-.979.384-1.435a2.66 2.66 0 0 1 1.071-1.062 3 3 0 0 1 1.412-.371m0 .475q-.602 0-1.176.311c-.38.207-.677.5-.891.888a2.4 2.4 0 0 0-.325 1.194q0 .62.315 1.188.321.566.888.885.57.316 1.188.315.62 0 1.189-.315.567-.319.884-.885.313-.568.313-1.188 0-.623-.321-1.194a2.2 2.2 0 0 0-.894-.888 2.4 2.4 0 0 0-1.17-.311m-1.256 3.975v-3.082h1.062q.54-.001.784.088a.76.76 0 0 1 .388.297.81.81 0 0 1-.098 1.039.95.95 0 0 1-.639.281.9.9 0 0 1 .264.164q.186.18.455.61l.375.603h-.606l-.272-.485q-.322-.572-.521-.718-.137-.104-.397-.105h-.293v1.311h-.5m.496-1.738h.604q.431 0 .588-.129a.42.42 0 0 0 .159-.342.4.4 0 0 0-.075-.244.47.47 0 0 0-.213-.162q-.134-.054-.497-.055h-.564v.932" style="fill:#fcb340"/><path d="m133.719 99.926 1.18-8.02c-.645 0-1.593.279-2.431.279-3.284 0-3.694-1.755-3.436-3.037l3.236-16.13h4.992l1.029-9.103h-4.705l.958-5.516H124.7c-.208.208-5.568 31.022-5.568 34.776 0 5.555 3.118 8.027 7.516 7.988 3.442-.029 6.125-.982 7.071-1.237m2.987-15.288c0 13.332 8.799 16.499 16.297 16.499 6.921 0 10.55-1.604 10.55-1.604l1.662-9.1s-5.848 2.378-10.601 2.378c-10.131 0-8.355-7.554-8.355-7.554l19.463.059s1.239-6.111 1.239-8.602c0-6.217-3.387-13.849-13.745-13.849-9.486.002-16.51 10.223-16.51 21.773m16.546-13.325c5.324 0 4.342 5.984 4.342 6.469H147.12c0-.62.989-6.469 6.132-6.469m59.738 28.61 1.689-10.284s-4.632 2.321-7.807 2.321c-6.693 0-9.378-5.11-9.378-10.601 0-11.137 5.758-17.265 12.168-17.265 4.808 0 8.665 2.699 8.665 2.699l1.54-9.993s-4.554-3.289-9.456-3.308c-14.745-.058-23.182 10.208-23.182 27.955 0 11.763 6.248 19.768 17.506 19.768 3.183 0 8.255-1.292 8.255-1.292M81.83 63.012c-6.469 0-11.427 2.079-11.427 2.079l-1.37 8.127s4.093-1.663 10.281-1.663c3.513 0 6.083.395 6.083 3.25 0 1.734-.314 2.374-.314 2.374s-2.772-.231-4.056-.231c-9.21 0-16.729 3.482-16.729 13.98 0 8.273 5.623 10.17 9.108 10.17 6.657 0 9.292-4.203 9.444-4.215l-.077 3.488h8.307l3.706-25.98c0-11.025-9.616-11.379-12.956-11.379m1.438 21.096c.181 1.586-.41 9.086-6.092 9.086-2.93 0-3.691-2.24-3.691-3.562 0-2.584 1.403-5.683 8.315-5.683 1.61 0 1.197.116 1.468.159m20.347 16.798c2.125 0 14.272.541 14.272-11.994 0-11.721-11.244-9.404-11.244-14.114 0-2.342 1.833-3.08 5.184-3.08 1.329 0 6.447.423 6.447.423l1.189-8.33c0 .001-3.312-.741-8.704-.741-6.979 0-14.063 2.786-14.063 12.318 0 10.802 11.812 9.717 11.812 14.267 0 3.037-3.3 3.287-5.844 3.287-4.401 0-8.363-1.511-8.377-1.438l-1.259 8.245c.229.07 2.674 1.157 10.587 1.157m187.192-45.451-1.705 12.709s-3.553-4.905-9.112-4.905c-10.459 0-15.849 10.423-15.849 22.396 0 7.73 3.844 15.307 11.699 15.307 5.651 0 8.784-3.941 8.784-3.941l-.415 3.365h9.178l7.207-44.862zm-4.052 24.701c0 4.983-2.468 11.64-7.581 11.64-3.396 0-4.988-2.851-4.988-7.324 0-7.315 3.285-12.14 7.432-12.14 3.394 0 5.137 2.33 5.137 7.824M30.749 100.423l5.743-33.87.844 33.87h6.499l12.125-33.87-5.371 33.87h9.658l7.437-44.922-15.342-.117-9.126 27.504-.25-27.387h-14.06l-7.544 44.922z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M176.101 100.487c2.746-15.615 3.724-27.947 11.732-25.393 1.15-6.044 3.891-11.3 5.143-13.858 0 0-.396-.589-2.871-.589-4.225 0-9.866 8.574-9.866 8.574l.843-5.301h-8.786l-5.884 36.566h9.689z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M-612.55 63.012c-6.472 0-11.43 2.079-11.43 2.079l-1.369 8.127s4.095-1.663 10.28-1.663c3.514 0 6.083.395 6.083 3.25 0 1.734-.313 2.374-.313 2.374s-2.771-.231-4.055-.231c-9.211 0-16.729 3.482-16.729 13.98 0 8.273 5.622 10.17 9.107 10.17 6.655 0 9.292-4.203 9.443-4.215l-.078 3.488h8.309l3.705-25.98c.001-11.025-9.615-11.379-12.953-11.379m1.436 21.096c.18 1.586-.411 9.086-6.092 9.086-2.932 0-3.692-2.24-3.692-3.562 0-2.584 1.402-5.683 8.315-5.683 1.611 0 1.199.116 1.469.159" style="fill:#006" transform="translate(831.55605 15.9939)"/><path d="M255.266 100.487c1.508-11.488 4.299-27.616 11.731-25.393 1.149-6.044.041-6.028-2.433-6.028-4.228 0-5.164.154-5.164.154l.844-5.301h-8.785l-5.884 36.567h9.691z" style="fill:#006" Line truncated
|
||||
|
Before Width: | Height: | Size: 8.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><path fill="#eb001b" d="M308 0a309 309 0 1 0 2 0z"/><path fill="#f79e1b" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#ff5f00" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>
|
||||
|
Before Width: | Height: | Size: 253 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-text-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-text-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="#8c8c8c" d="M364 66.1h270.4v485.8H364z"/><path fill="url(#mc-silver-text-g1)" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="url(#mc-silver-text-g2)" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#mc-silver-g1)" d="M308 0a309 309 0 1 0 2 0z"/><path fill="url(#mc-silver-g2)" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#8c8c8c" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>
|
||||
|
Before Width: | Height: | Size: 686 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><path fill="#ff5a00" d="M364 66.1h270.4v485.8H364z"/><path fill="#eb001b" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="#f79e1b" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated
|
||||
|
Before Width: | Height: | Size: 7.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 51.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 54.65 66.613 52.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="up-silver-g1" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#f8f8f8"/><stop offset="1" stop-color="#bdbdbd"/></linearGradient><linearGradient id="up-silver-g2" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#d6d6d6"/><stop offset="1" stop-color="#8a8a8a"/></linearGradient><linearGradient id="up-silver-g3" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#ebebeb"/><stop offset="1" stop-color="#a4a4a4"/></linearGradient><mask id="up-silver-m" width="280" height="180" x="-10" y="-10" maskUnits="userSpaceOnUse"><path fill="#fff" d="M-10-10h280v180H-10z"/><path d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.353c-4.148.802-4.246.93-8.076 1.421l-.299.267c-.042.128Line truncated
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="-9.6 -9.6 274.74 179.05" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.Line truncated
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 825 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1434cb" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 820 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1a1f71" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 820 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="visa-silver-g" x1=".15" x2=".85" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#visa-silver-g)" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CROP,
|
||||
NETWORK_IDS,
|
||||
ID_PATTERN,
|
||||
TONE_COLOR,
|
||||
parseConfig,
|
||||
serializeConfig,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
const custom: CardConfig = {
|
||||
v: 1,
|
||||
template: 'classic',
|
||||
network: 'jcb',
|
||||
logo: 'abc123',
|
||||
details: 'dark',
|
||||
brand: 'apple',
|
||||
brand: 'aircards-text',
|
||||
crop: { x: -120.5, y: 33, zoom: 2.5 },
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('Standardwerte', () => {
|
||||
expect(DEFAULT_CONFIG).toEqual({
|
||||
v: 1,
|
||||
template: 'signature',
|
||||
network: 'visa-auto',
|
||||
logo: null,
|
||||
details: 'light',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: 0, y: 0, zoom: 1 },
|
||||
@@ -36,16 +36,13 @@ describe('Standardwerte', () => {
|
||||
expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP);
|
||||
});
|
||||
|
||||
it('NETWORK_IDS: 18 Logos in der vereinbarten Reihenfolge', () => {
|
||||
expect(NETWORK_IDS.length).toBe(18);
|
||||
expect([...NETWORK_IDS]).toEqual([
|
||||
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
|
||||
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
|
||||
'up-color', 'up-silver', 'up-white-frame', 'up-english',
|
||||
'amex-blue', 'amex-outline', 'amex-select',
|
||||
'jcb', 'discover',
|
||||
]);
|
||||
expect(new Set(NETWORK_IDS).size).toBe(18);
|
||||
it('eine neue Karte hat kein Logo unten rechts', () => {
|
||||
expect(DEFAULT_CONFIG.logo).toBeNull();
|
||||
});
|
||||
|
||||
it('ID_PATTERN: Appwrite-IDs mit höchstens 36 Zeichen und ohne Sonderzeichen am Anfang', () => {
|
||||
for (const ok of ['abc123', 'A', '0', 'a.b_c-d', 'x'.repeat(36)]) expect(ID_PATTERN.test(ok), ok).toBe(true);
|
||||
for (const bad of ['', 'kaputt id', '-abc', '_abc', '.abc', 'ä', 'a/b', 'x'.repeat(37)]) expect(ID_PATTERN.test(bad), bad).toBe(false);
|
||||
});
|
||||
|
||||
it('TONE_COLOR: Hell weiß, Dunkel fast schwarz', () => {
|
||||
@@ -72,9 +69,31 @@ describe('parseConfig / serializeConfig', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('unbekanntes Netzwerk setzt nur dieses Feld auf den Standard', () => {
|
||||
const raw = JSON.stringify({ ...custom, network: 'paypal' });
|
||||
expect(parseConfig(raw)).toEqual({ ...custom, network: DEFAULT_CONFIG.network });
|
||||
it('eine alte Konfiguration mit network und Apple-Logo: network entfällt, Logo oben links fällt auf den Standard', () => {
|
||||
const raw = '{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}';
|
||||
expect(parseConfig(raw)).toEqual({
|
||||
v: 1,
|
||||
template: 'classic',
|
||||
logo: null,
|
||||
details: 'dark',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: 0, y: 0, zoom: 1 },
|
||||
});
|
||||
// andere unbekannte Marken ebenso
|
||||
expect(parseConfig('{"brand":"visa"}').brand).toBe('aircards-symbol');
|
||||
});
|
||||
|
||||
it('logo: eine gültige ID bleibt, alles andere ergibt null', () => {
|
||||
expect(parseConfig('{"logo":"abc123"}').logo).toBe('abc123');
|
||||
expect(parseConfig(JSON.stringify({ logo: 'x'.repeat(36) })).logo).toBe('x'.repeat(36));
|
||||
for (const bad of ['kaputt id', '', '-abc', 'x'.repeat(37), 42, true, null, ['abc'], { id: 'abc' }]) {
|
||||
expect(parseConfig(JSON.stringify({ logo: bad })).logo, JSON.stringify(bad)).toBeNull();
|
||||
}
|
||||
expect(parseConfig('{}').logo).toBeNull();
|
||||
});
|
||||
|
||||
it('das gewählte Logo bleibt nur dem Feld logo zugeordnet: ein altes network wird nie zum Logo', () => {
|
||||
expect(parseConfig('{"network":"abc123"}').logo).toBeNull();
|
||||
});
|
||||
|
||||
it('jedes Feld wird einzeln validiert', () => {
|
||||
@@ -88,11 +107,11 @@ describe('parseConfig / serializeConfig', () => {
|
||||
});
|
||||
|
||||
it('Namen aus Object.prototype gelten nicht als gültige Werte', () => {
|
||||
const raw = JSON.stringify({ ...custom, network: 'constructor', template: '__proto__', brand: 'toString' });
|
||||
const raw = JSON.stringify({ ...custom, template: '__proto__', details: 'constructor', brand: 'toString' });
|
||||
expect(parseConfig(raw)).toEqual({
|
||||
...custom,
|
||||
network: DEFAULT_CONFIG.network,
|
||||
template: DEFAULT_CONFIG.template,
|
||||
details: DEFAULT_CONFIG.details,
|
||||
brand: DEFAULT_CONFIG.brand,
|
||||
});
|
||||
});
|
||||
@@ -137,21 +156,29 @@ describe('parseConfig / serializeConfig', () => {
|
||||
const raw = JSON.stringify({ ...custom, evil: '<script>', crop: { ...custom.crop, extra: 1 } });
|
||||
const c = parseConfig(raw);
|
||||
expect(c).toEqual(custom);
|
||||
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'network', 'template', 'v']);
|
||||
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'logo', 'template', 'v']);
|
||||
expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
|
||||
});
|
||||
|
||||
it('serializeConfig schreibt nur bekannte Felder', () => {
|
||||
const s = serializeConfig({ ...custom, evil: 1 } as CardConfig);
|
||||
const s = serializeConfig({ ...custom, evil: 1, network: 'jcb' } as CardConfig);
|
||||
expect(s).not.toContain('evil');
|
||||
expect(s).not.toContain('network');
|
||||
expect(JSON.parse(s)).toEqual(custom);
|
||||
});
|
||||
|
||||
it('serializeConfig schreibt genau {v, template, logo, details, brand, crop} in dieser Reihenfolge', () => {
|
||||
expect(Object.keys(JSON.parse(serializeConfig(custom)))).toEqual(['v', 'template', 'logo', 'details', 'brand', 'crop']);
|
||||
expect(serializeConfig(DEFAULT_CONFIG)).toBe(
|
||||
'{"v":1,"template":"signature","logo":null,"details":"light","brand":"aircards-symbol","crop":{"x":0,"y":0,"zoom":1}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('serializeConfig bleibt unter 2000 Zeichen', () => {
|
||||
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
|
||||
const worst: CardConfig = {
|
||||
...custom,
|
||||
network: 'mc-silver-text',
|
||||
logo: 'x'.repeat(36),
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 },
|
||||
};
|
||||
|
||||
@@ -3,21 +3,17 @@
|
||||
|
||||
const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const;
|
||||
const TONES = ['light', 'dark'] as const;
|
||||
const BRANDS = ['aircards-symbol', 'aircards-text', 'apple', 'none'] as const;
|
||||
const NETWORKS = [
|
||||
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
|
||||
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
|
||||
'up-color', 'up-silver', 'up-white-frame', 'up-english',
|
||||
'amex-blue', 'amex-outline', 'amex-select',
|
||||
'jcb', 'discover',
|
||||
] as const;
|
||||
const BRANDS = ['aircards-symbol', 'aircards-text', 'none'] as const;
|
||||
|
||||
export type TemplateId = (typeof TEMPLATES)[number];
|
||||
export type DetailsTone = (typeof TONES)[number];
|
||||
export type BrandId = (typeof BRANDS)[number];
|
||||
export type NetworkId = (typeof NETWORKS)[number];
|
||||
|
||||
export const NETWORK_IDS: readonly NetworkId[] = NETWORKS;
|
||||
/**
|
||||
* Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. Steht hier, damit die reine
|
||||
* Konfiguration die ID des Community-Logos prüfen kann, ohne Appwrite zu laden (account/cards.ts reicht sie weiter).
|
||||
*/
|
||||
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
|
||||
/** Zoom-Grenzen relativ zum Cover-Maßstab. */
|
||||
export const MIN_ZOOM = 1;
|
||||
@@ -33,8 +29,10 @@ export interface Crop {
|
||||
export interface CardConfig {
|
||||
v: 1;
|
||||
template: TemplateId;
|
||||
network: NetworkId;
|
||||
/** ID des Community-Logos unten rechts; null = keins. */
|
||||
logo: string | null;
|
||||
details: DetailsTone;
|
||||
/** Das Logo oben links (AirCard-IOS Symbol oder Text, oder keins). */
|
||||
brand: BrandId;
|
||||
crop: Crop;
|
||||
}
|
||||
@@ -44,7 +42,7 @@ export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
|
||||
export const DEFAULT_CONFIG: CardConfig = {
|
||||
v: 1,
|
||||
template: 'signature',
|
||||
network: 'visa-auto',
|
||||
logo: null,
|
||||
details: 'light',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { ...DEFAULT_CROP },
|
||||
@@ -61,6 +59,11 @@ function pick<T extends string>(value: unknown, allowed: readonly T[], fallback:
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** Die ID eines Community-Logos; alles, was keine Appwrite-ID sein kann, ergibt null (kein Logo). */
|
||||
function parseLogo(value: unknown): string | null {
|
||||
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function finite(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
@@ -82,6 +85,8 @@ function parseCrop(value: unknown): Crop {
|
||||
/**
|
||||
* Liest eine gespeicherte Konfiguration. Jedes Feld wird einzeln geprüft, Unbekanntes oder
|
||||
* Kaputtes fällt auf den Standard dieses Feldes zurück. Liefert immer ein neues Objekt.
|
||||
* Ältere Karten kennen noch das Feld `network` (Kartennetzwerk) und das Apple-Logo: `network` wird ignoriert,
|
||||
* `brand: 'apple'` fällt auf den Standard.
|
||||
*/
|
||||
export function parseConfig(json: string | null | undefined): CardConfig {
|
||||
let raw: unknown = null;
|
||||
@@ -96,7 +101,7 @@ export function parseConfig(json: string | null | undefined): CardConfig {
|
||||
return {
|
||||
v: 1,
|
||||
template: pick(r.template, TEMPLATES, DEFAULT_CONFIG.template),
|
||||
network: pick(r.network, NETWORKS, DEFAULT_CONFIG.network),
|
||||
logo: parseLogo(r.logo),
|
||||
details: pick(r.details, TONES, DEFAULT_CONFIG.details),
|
||||
brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand),
|
||||
crop: parseCrop(r.crop),
|
||||
@@ -108,7 +113,7 @@ export function serializeConfig(c: CardConfig): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
template: c.template,
|
||||
network: c.network,
|
||||
logo: c.logo,
|
||||
details: c.details,
|
||||
brand: c.brand,
|
||||
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
|
||||
|
||||
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CARD_H,
|
||||
CARD_W,
|
||||
LOGO_MAX_H,
|
||||
MARGIN,
|
||||
PREVIEW_RADIUS,
|
||||
WALLET_KEEP_CLEAR,
|
||||
fitBrand,
|
||||
fitNetwork,
|
||||
fitLogo,
|
||||
layoutFor,
|
||||
type Box,
|
||||
} from './geometry';
|
||||
@@ -41,36 +42,48 @@ describe('Konstanten', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitNetwork', () => {
|
||||
it('Standardbreite 330 × 109 sitzt unten rechts auf dem Rand', () => {
|
||||
const b = fitNetwork(330 / 109, 109);
|
||||
expect(b.x).toBeCloseTo(1150, 1);
|
||||
expect(b.y).toBeCloseTo(804, 1);
|
||||
expect(b.w).toBeCloseTo(330, 1);
|
||||
expect(b.h).toBeCloseTo(109, 1);
|
||||
describe('fitLogo', () => {
|
||||
it('LOGO_MAX_H ist 125', () => {
|
||||
expect(LOGO_MAX_H).toBe(125);
|
||||
});
|
||||
|
||||
it('Seitenverhältnis 2 bei Höhe 125: 250 × 125, rechte Kante 1480, Unterkante 913', () => {
|
||||
expect(fitLogo(2, 125)).toEqual({ x: 1230, y: 788, w: 250, h: 125 });
|
||||
});
|
||||
|
||||
it('ohne maxH gilt LOGO_MAX_H', () => {
|
||||
expect(fitLogo(2)).toEqual(fitLogo(2, LOGO_MAX_H));
|
||||
expect(fitLogo(1)).toEqual({ x: 1355, y: 788, w: 125, h: 125 });
|
||||
});
|
||||
|
||||
it('sehr breite Logos werden auf 360 begrenzt', () => {
|
||||
const b = fitNetwork(6, 109);
|
||||
const b = fitLogo(10, 125);
|
||||
expect(b.w).toBeCloseTo(360, 1);
|
||||
expect(b.h).toBeCloseTo(60, 1);
|
||||
expect(b.h).toBeCloseTo(36, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('reine Symbole dürfen bis 125 px hoch sein', () => {
|
||||
const b = fitNetwork(1, 125);
|
||||
expect(b.w).toBeCloseTo(125, 1);
|
||||
it('hohe Logos werden auf die Höhe begrenzt', () => {
|
||||
const b = fitLogo(0.5, 125);
|
||||
expect(b.h).toBeCloseTo(125, 1);
|
||||
expect(b.w).toBeCloseTo(62.5, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('maxH ist einstellbar', () => {
|
||||
const b = fitLogo(1, 100);
|
||||
expect(b.w).toBeCloseTo(100, 1);
|
||||
expect(b.h).toBeCloseTo(100, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('Seitenverhältnis bleibt erhalten', () => {
|
||||
for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) {
|
||||
const b = fitNetwork(aspect, 109);
|
||||
const b = fitLogo(aspect);
|
||||
expect(b.w / b.h).toBeCloseTo(aspect, 1);
|
||||
expect(b.h).toBeLessThanOrEqual(109 + 1e-9);
|
||||
expect(b.h).toBeLessThanOrEqual(LOGO_MAX_H + 1e-9);
|
||||
expect(b.w).toBeLessThanOrEqual(360 + 1e-9);
|
||||
}
|
||||
});
|
||||
@@ -105,23 +118,23 @@ describe('fitBrand', () => {
|
||||
});
|
||||
|
||||
describe('layoutFor', () => {
|
||||
it('image: kein Netzwerk, keine Marke, keine optionalen Felder', () => {
|
||||
it('image: kein Logo, keine Marke, keine optionalen Felder', () => {
|
||||
const l = layoutFor('image');
|
||||
expect(l.network).toBe(false);
|
||||
expect(l.logo).toBe(false);
|
||||
expect(l.brand).toBe(false);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
|
||||
});
|
||||
|
||||
it('minimal: nur Netzwerk', () => {
|
||||
it('minimal: nur Logo', () => {
|
||||
const l = layoutFor('minimal');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(false);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
|
||||
});
|
||||
|
||||
it('classic: alles', () => {
|
||||
const l = layoutFor('classic');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(true);
|
||||
expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' });
|
||||
expect(l.chip).toEqual({
|
||||
@@ -141,9 +154,9 @@ describe('layoutFor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('signature: Netzwerk, Marke und PLATINUM links', () => {
|
||||
it('signature: Logo, Marke und PLATINUM links', () => {
|
||||
const l = layoutFor('signature');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(true);
|
||||
expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' });
|
||||
expect(l.chip).toBeUndefined();
|
||||
@@ -193,9 +206,9 @@ describe.each(TEMPLATES)('Vorlage %s: Wallet-Bereich unten links bleibt frei', (
|
||||
if (l.contactless) boxes.contactless = l.contactless.box;
|
||||
if (l.number) boxes.number = NUMBER_BOX;
|
||||
if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!;
|
||||
if (l.network) {
|
||||
boxes.networkSymbol = fitNetwork(1, 125);
|
||||
boxes.networkWide = fitNetwork(6, 109);
|
||||
if (l.logo) {
|
||||
boxes.logoSquare = fitLogo(1);
|
||||
boxes.logoWide = fitLogo(10);
|
||||
}
|
||||
if (l.brand) boxes.brand = fitBrand(3.6);
|
||||
|
||||
|
||||
@@ -20,19 +20,21 @@ export interface Box {
|
||||
/** Unten links blendet Wallet die letzten vier Ziffern ein, hier darf nichts liegen. */
|
||||
export const WALLET_KEEP_CLEAR: Box = { x: 0, y: 779, w: 620, h: 190 };
|
||||
|
||||
const NETWORK_MAX_W = 360;
|
||||
const NETWORK_RIGHT = CARD_W - MARGIN; // 1480
|
||||
const NETWORK_BOTTOM = CARD_H - MARGIN; // 913
|
||||
/** Höhe, bis zu der ein Community-Logo unten rechts auf der Karte wachsen darf. */
|
||||
export const LOGO_MAX_H = 125;
|
||||
const LOGO_MAX_W = 360;
|
||||
const LOGO_RIGHT = CARD_W - MARGIN; // 1480
|
||||
const LOGO_BOTTOM = CARD_H - MARGIN; // 913
|
||||
const BRAND_MAX_W = 360;
|
||||
|
||||
/** Netzwerk-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
|
||||
export function fitNetwork(aspect: number, maxH: number): Box {
|
||||
const w = Math.min(NETWORK_MAX_W, maxH * aspect);
|
||||
/** Community-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
|
||||
export function fitLogo(aspect: number, maxH = LOGO_MAX_H): Box {
|
||||
const w = Math.min(LOGO_MAX_W, maxH * aspect);
|
||||
const h = w / aspect;
|
||||
return { x: NETWORK_RIGHT - w, y: NETWORK_BOTTOM - h, w, h };
|
||||
return { x: LOGO_RIGHT - w, y: LOGO_BOTTOM - h, w, h };
|
||||
}
|
||||
|
||||
/** AirCard-/Apple-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
|
||||
/** AirCard-IOS-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
|
||||
export function fitBrand(aspect: number, maxH = 100): Box {
|
||||
const w = Math.min(BRAND_MAX_W, maxH * aspect);
|
||||
const h = w / aspect;
|
||||
@@ -40,7 +42,9 @@ export function fitBrand(aspect: number, maxH = 100): Box {
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
network: boolean;
|
||||
/** Community-Logo unten rechts. */
|
||||
logo: boolean;
|
||||
/** AirCard-IOS-Logo oben links. */
|
||||
brand: boolean;
|
||||
platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
|
||||
chip?: {
|
||||
@@ -55,10 +59,10 @@ export interface Layout {
|
||||
}
|
||||
|
||||
const LAYOUTS: Record<TemplateId, Layout> = {
|
||||
image: { network: false, brand: false },
|
||||
minimal: { network: true, brand: false },
|
||||
image: { logo: false, brand: false },
|
||||
minimal: { logo: true, brand: false },
|
||||
classic: {
|
||||
network: true,
|
||||
logo: true,
|
||||
brand: true,
|
||||
platinum: { x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' },
|
||||
chip: {
|
||||
@@ -80,7 +84,7 @@ const LAYOUTS: Record<TemplateId, Layout> = {
|
||||
},
|
||||
},
|
||||
signature: {
|
||||
network: true,
|
||||
logo: true,
|
||||
brand: true,
|
||||
platinum: { x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' },
|
||||
},
|
||||
|
||||
@@ -1,62 +1,39 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { NETWORK_IDS } from './config';
|
||||
import favicon from '../../public/favicon.svg?raw';
|
||||
import {
|
||||
BRAND_MARKS,
|
||||
NETWORK_MARKS,
|
||||
getBrandMark,
|
||||
getNetworkMark,
|
||||
loadMarkImage,
|
||||
svgAspect,
|
||||
tintSvg,
|
||||
} from './marks';
|
||||
import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg } from './marks';
|
||||
|
||||
const NETWORK_LABELS = [
|
||||
'Automatisch', 'Blau', 'Marineblau', 'Metallisches Silber',
|
||||
'Farbe', 'Metallisches Silber', 'Wortmarke', 'Silber + Text', 'Klassisch',
|
||||
'Farbe', 'Metallisches Silber', 'Weißer Rahmen', 'Englisch',
|
||||
'Blau', 'Umriss', 'Select',
|
||||
'JCB', 'Discover',
|
||||
];
|
||||
const BRAND_IDS = ['aircards-symbol', 'aircards-text'];
|
||||
|
||||
const ALL = [...NETWORK_MARKS, ...BRAND_MARKS];
|
||||
const TINT_IDS = ['visa-auto', 'amex-outline', 'aircards-symbol', 'aircards-text', 'apple'];
|
||||
|
||||
describe('Registers', () => {
|
||||
it('NETWORK_MARKS folgt NETWORK_IDS', () => {
|
||||
expect(NETWORK_MARKS.map((m) => m.id)).toEqual([...NETWORK_IDS]);
|
||||
});
|
||||
|
||||
it('Labels stehen im Wortlaut der Vorlage in Reihenfolge', () => {
|
||||
expect(NETWORK_MARKS.map((m) => m.label)).toEqual(NETWORK_LABELS);
|
||||
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text', 'Apple']);
|
||||
});
|
||||
|
||||
it('BRAND_MARKS: Logo-Gruppe, feste Reihenfolge', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.id)).toEqual(['aircards-symbol', 'aircards-text', 'apple']);
|
||||
describe('Register', () => {
|
||||
it('BRAND_MARKS: nur die zwei AirCard-IOS-Marken, in fester Reihenfolge, Logo-Gruppe', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.id)).toEqual(BRAND_IDS);
|
||||
expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true);
|
||||
});
|
||||
|
||||
it('Gruppen der Netzwerke', () => {
|
||||
const groups = NETWORK_MARKS.map((m) => m.group);
|
||||
expect(groups).toEqual([
|
||||
'Visa', 'Visa', 'Visa', 'Visa',
|
||||
'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard',
|
||||
'UnionPay', 'UnionPay', 'UnionPay', 'UnionPay',
|
||||
'American Express', 'American Express', 'American Express',
|
||||
'JCB', 'Discover',
|
||||
]);
|
||||
it('Labels stehen im Wortlaut der Vorlage', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text']);
|
||||
});
|
||||
|
||||
it('getNetworkMark / getBrandMark finden den Eintrag', () => {
|
||||
for (const id of NETWORK_IDS) expect(getNetworkMark(id).id).toBe(id);
|
||||
expect(getBrandMark('apple').id).toBe('apple');
|
||||
it('getBrandMark findet den Eintrag', () => {
|
||||
for (const id of BRAND_IDS) expect(getBrandMark(id as 'aircards-symbol').id).toBe(id);
|
||||
expect(getBrandMark('aircards-text').label).toBe('AirCard-IOS · Text');
|
||||
});
|
||||
|
||||
it('keine Netzwerk- und keine Apple-Marken mehr im Register', () => {
|
||||
const registry = BRAND_MARKS.map((m) => `${m.id} ${m.group} ${m.label}`).join('\n');
|
||||
expect(registry).not.toMatch(/visa|mastercard|unionpay|american express|jcb|discover|apple/i);
|
||||
});
|
||||
|
||||
it('assets/marks enthält nur noch die zwei AirCard-IOS-Dateien (keine fremden Marken-Logos)', () => {
|
||||
const files = readdirSync(join(import.meta.dirname, '../assets/marks')).sort();
|
||||
expect(files).toEqual(['aircards-symbol.svg', 'aircards-text.svg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG-Inhalt', () => {
|
||||
it.each(ALL.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
|
||||
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
|
||||
expect(m.svg.trimStart().startsWith('<svg')).toBe(true);
|
||||
expect(m.svg).toMatch(/viewBox="[^"]+"/);
|
||||
expect(m.svg).not.toContain('<image');
|
||||
@@ -67,7 +44,7 @@ describe('SVG-Inhalt', () => {
|
||||
});
|
||||
|
||||
it('Wurzelelement: xmlns für Blob-Bilder, keine feste Größe (die viewBox bestimmt die Größe)', () => {
|
||||
for (const m of ALL) {
|
||||
for (const m of BRAND_MARKS) {
|
||||
const root = /^<svg\b[^>]*>/.exec(m.svg.trimStart())![0];
|
||||
expect(root, m.id).toContain('xmlns="http://www.w3.org/2000/svg"');
|
||||
expect(root, m.id).not.toMatch(/\s(width|height)=/);
|
||||
@@ -84,24 +61,15 @@ describe('SVG-Inhalt', () => {
|
||||
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
|
||||
});
|
||||
|
||||
it('svgAspect: Visa ist ein breites Wortlogo', () => {
|
||||
expect(svgAspect(getNetworkMark('visa-auto').svg)).toBeCloseTo(1000 / 324.68, 1);
|
||||
});
|
||||
|
||||
it('Einfärbbar genau bei den fünf Tint-Marken', () => {
|
||||
const tinted = ALL.filter((m) => m.tint).map((m) => m.id).sort();
|
||||
expect(tinted).toEqual([...TINT_IDS].sort());
|
||||
});
|
||||
|
||||
it('Tint-Marken enthalten currentColor, alle anderen nicht', () => {
|
||||
for (const m of ALL) {
|
||||
expect(m.svg.includes('currentColor'), m.id).toBe(m.tint);
|
||||
it('beide Marken sind einfärbbar und enthalten currentColor', () => {
|
||||
for (const m of BRAND_MARKS) {
|
||||
expect(m.tint, m.id).toBe(true);
|
||||
expect(m.svg.includes('currentColor'), m.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('tintSvg ersetzt jedes currentColor', () => {
|
||||
for (const id of TINT_IDS) {
|
||||
const m = [...NETWORK_MARKS, ...BRAND_MARKS].find((x) => x.id === id)!;
|
||||
for (const m of BRAND_MARKS) {
|
||||
const dark = tintSvg(m.svg, 'dark');
|
||||
expect(dark).toContain('#111111');
|
||||
expect(dark).not.toContain('currentColor');
|
||||
@@ -111,14 +79,14 @@ describe('SVG-Inhalt', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('tintSvg lässt Marken ohne currentColor unverändert', () => {
|
||||
const m = getNetworkMark('jcb');
|
||||
expect(tintSvg(m.svg, 'dark')).toBe(m.svg);
|
||||
it('tintSvg lässt ein SVG ohne currentColor unverändert', () => {
|
||||
const svg = '<svg viewBox="0 0 10 10"><path fill="#123456" d="M0 0h10v10z"/></svg>';
|
||||
expect(tintSvg(svg, 'dark')).toBe(svg);
|
||||
});
|
||||
|
||||
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const m of ALL) {
|
||||
for (const m of BRAND_MARKS) {
|
||||
for (const hit of m.svg.matchAll(/\bid="([^"]+)"/g)) {
|
||||
const id = hit[1];
|
||||
expect(seen.has(id), `${id} doppelt in ${m.id} und ${seen.get(id)}`).toBe(false);
|
||||
@@ -129,36 +97,15 @@ describe('SVG-Inhalt', () => {
|
||||
});
|
||||
|
||||
describe('Verweise innerhalb der Dateien', () => {
|
||||
it.each(ALL.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
|
||||
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
|
||||
const defined = new Set([...m.svg.matchAll(/\bid="([^"]+)"/g)].map((h) => h[1]));
|
||||
const refs = [...m.svg.matchAll(/url\(\s*['"]?#([^)'"\s]+)/g)].map((h) => h[1]);
|
||||
for (const ref of refs) expect(defined.has(ref), `${ref} fehlt in ${m.id}`).toBe(true);
|
||||
});
|
||||
|
||||
it('die Silber-/Verlaufs-Marken verwenden überhaupt Verweise', () => {
|
||||
for (const id of ['visa-silver', 'mc-silver', 'mc-silver-text', 'up-silver', 'jcb', 'discover']) {
|
||||
expect(getNetworkMark(id as never).svg, id).toMatch(/url\(#/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxH', () => {
|
||||
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH;
|
||||
|
||||
it('109 für Visa, Mastercard-Wortmarken, UnionPay English, Discover', () => {
|
||||
for (const id of ['visa-auto', 'visa-blue', 'visa-navy', 'visa-silver', 'mc-wordmark', 'mc-silver-text', 'up-english', 'discover']) {
|
||||
expect(h(id), id).toBe(109);
|
||||
}
|
||||
});
|
||||
|
||||
it('125 für Symbol-Logos', () => {
|
||||
for (const id of ['mc-color', 'mc-silver', 'mc-classic', 'up-color', 'up-silver', 'up-white-frame', 'amex-blue', 'amex-outline', 'jcb']) {
|
||||
expect(h(id), id).toBe(125);
|
||||
}
|
||||
});
|
||||
|
||||
it('90 für Amex Select, 100 für alle Marken', () => {
|
||||
expect(h('amex-select')).toBe(90);
|
||||
it('100 für beide Marken', () => {
|
||||
expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -290,11 +237,11 @@ describe('loadMarkImage', () => {
|
||||
|
||||
it('gleiches (id, tone) liefert dieselbe Promise und lädt nur einmal, anderer Ton eine andere', async () => {
|
||||
const m = await fresh();
|
||||
const visa = m.getNetworkMark('visa-auto');
|
||||
const a = m.loadMarkImage(visa, 'dark');
|
||||
const b = m.loadMarkImage(visa, 'dark');
|
||||
const c = m.loadMarkImage(visa, 'light');
|
||||
const d = m.loadMarkImage(m.getNetworkMark('jcb'), 'dark');
|
||||
const symbol = m.getBrandMark('aircards-symbol');
|
||||
const a = m.loadMarkImage(symbol, 'dark');
|
||||
const b = m.loadMarkImage(symbol, 'dark');
|
||||
const c = m.loadMarkImage(symbol, 'light');
|
||||
const d = m.loadMarkImage(m.getBrandMark('aircards-text'), 'dark');
|
||||
expect(b).toBe(a);
|
||||
expect(c).not.toBe(a);
|
||||
expect(d).not.toBe(a);
|
||||
@@ -302,15 +249,15 @@ describe('loadMarkImage', () => {
|
||||
expect(imgs[0]).toBeInstanceOf(FakeImage);
|
||||
expect(blobs).toHaveLength(3);
|
||||
// nach dem Laden bleibt der Eintrag im Cache
|
||||
expect(m.loadMarkImage(visa, 'dark')).toBe(a);
|
||||
expect(m.loadMarkImage(symbol, 'dark')).toBe(a);
|
||||
expect(blobs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('Blob ist ein eingefärbtes SVG mit eigener Größe und dem richtigen MIME-Typ', async () => {
|
||||
const m = await fresh();
|
||||
const visa = m.getNetworkMark('visa-auto');
|
||||
await m.loadMarkImage(visa, 'dark');
|
||||
await m.loadMarkImage(visa, 'light');
|
||||
const symbol = m.getBrandMark('aircards-symbol');
|
||||
await m.loadMarkImage(symbol, 'dark');
|
||||
await m.loadMarkImage(symbol, 'light');
|
||||
const [dark, light] = await Promise.all(blobs.map((b) => b.text()));
|
||||
expect(blobs[0].type).toBe('image/svg+xml');
|
||||
expect(dark).toContain('#111111');
|
||||
@@ -320,33 +267,25 @@ describe('loadMarkImage', () => {
|
||||
const root = /^<svg\b[^>]*>/.exec(dark)![0];
|
||||
const w = Number(/\swidth="([\d.]+)"/.exec(root)![1]);
|
||||
const h = Number(/\sheight="([\d.]+)"/.exec(root)![1]);
|
||||
expect(h).toBe(visa.maxH * 4);
|
||||
expect(w / h).toBeCloseTo(m.svgAspect(visa.svg), 1);
|
||||
expect(h).toBe(symbol.maxH * 4);
|
||||
expect(w / h).toBeCloseTo(m.svgAspect(symbol.svg), 1);
|
||||
// Bild bekommt die Blob-URL
|
||||
expect(env.images[0].src).toBe('blob:fake/1');
|
||||
expect(env.images[1].src).toBe('blob:fake/2');
|
||||
});
|
||||
|
||||
it('Marken ohne currentColor bleiben im Blob unverändert (bis auf die Größe)', async () => {
|
||||
const m = await fresh();
|
||||
const jcb = m.getNetworkMark('jcb');
|
||||
await m.loadMarkImage(jcb, 'dark');
|
||||
const text = await blobs[0].text();
|
||||
expect(text.replace(/^<svg width="[\d.]+" height="[\d.]+"/, '<svg')).toBe(jcb.svg);
|
||||
});
|
||||
|
||||
it('gibt die Blob-URL nach dem Laden frei', async () => {
|
||||
const m = await fresh();
|
||||
await m.loadMarkImage(m.getNetworkMark('visa-blue'), 'light');
|
||||
await m.loadMarkImage(m.getBrandMark('aircards-text'), 'light');
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
});
|
||||
|
||||
it('Fehler: Promise wird abgelehnt, URL freigegeben, Cache-Eintrag verworfen, zweiter Aufruf versucht es neu', async () => {
|
||||
const m = await fresh();
|
||||
const def = m.getNetworkMark('visa-auto');
|
||||
const def = m.getBrandMark('aircards-symbol');
|
||||
env.mode = 'error';
|
||||
const first = m.loadMarkImage(def, 'dark');
|
||||
await expect(first).rejects.toThrow(/visa-auto/);
|
||||
await expect(first).rejects.toThrow(/aircards-symbol/);
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
|
||||
env.mode = 'load';
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
// Logo-Register: SVG-Dateien der Netzwerke und Marken, Einfärben und Laden als Bild für den Canvas.
|
||||
// Logo-Register: SVG-Dateien der AirCard-IOS-Marken (Logo oben links), Einfärben und Laden als Bild für den Canvas.
|
||||
// Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox.
|
||||
// Die Logos unten rechts kommen von der Community (logos/logo-asset.ts), hier steht keine fremde Marke.
|
||||
|
||||
import { TONE_COLOR, type BrandId, type DetailsTone, type NetworkId, NETWORK_IDS } from './config';
|
||||
import { TONE_COLOR, type BrandId, type DetailsTone } from './config';
|
||||
|
||||
import visaAuto from '../assets/marks/visa-auto.svg?raw';
|
||||
import visaBlue from '../assets/marks/visa-blue.svg?raw';
|
||||
import visaNavy from '../assets/marks/visa-navy.svg?raw';
|
||||
import visaSilver from '../assets/marks/visa-silver.svg?raw';
|
||||
import mcColor from '../assets/marks/mc-color.svg?raw';
|
||||
import mcSilver from '../assets/marks/mc-silver.svg?raw';
|
||||
import mcWordmark from '../assets/marks/mc-wordmark.svg?raw';
|
||||
import mcSilverText from '../assets/marks/mc-silver-text.svg?raw';
|
||||
import mcClassic from '../assets/marks/mc-classic.svg?raw';
|
||||
import upColor from '../assets/marks/up-color.svg?raw';
|
||||
import upSilver from '../assets/marks/up-silver.svg?raw';
|
||||
import upWhiteFrame from '../assets/marks/up-white-frame.svg?raw';
|
||||
import upEnglish from '../assets/marks/up-english.svg?raw';
|
||||
import amexBlue from '../assets/marks/amex-blue.svg?raw';
|
||||
import amexOutline from '../assets/marks/amex-outline.svg?raw';
|
||||
import amexSelect from '../assets/marks/amex-select.svg?raw';
|
||||
import jcb from '../assets/marks/jcb.svg?raw';
|
||||
import discover from '../assets/marks/discover.svg?raw';
|
||||
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
|
||||
import aircardsText from '../assets/marks/aircards-text.svg?raw';
|
||||
import apple from '../assets/marks/apple.svg?raw';
|
||||
|
||||
export type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo';
|
||||
export type MarkGroup = 'Logo';
|
||||
|
||||
export interface MarkDef {
|
||||
id: NetworkId | Exclude<BrandId, 'none'>;
|
||||
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Visa – Blau“). */
|
||||
id: Exclude<BrandId, 'none'>;
|
||||
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“). */
|
||||
group: MarkGroup;
|
||||
label: string;
|
||||
svg: string;
|
||||
@@ -39,45 +21,17 @@ export interface MarkDef {
|
||||
maxH: number;
|
||||
}
|
||||
|
||||
type NetworkEntry = Omit<MarkDef, 'id'>;
|
||||
type MarkEntry = Omit<MarkDef, 'id'>;
|
||||
|
||||
const NETWORK_TABLE: Record<NetworkId, NetworkEntry> = {
|
||||
'visa-auto': { group: 'Visa', label: 'Automatisch', svg: visaAuto, tint: true, maxH: 109 },
|
||||
'visa-blue': { group: 'Visa', label: 'Blau', svg: visaBlue, tint: false, maxH: 109 },
|
||||
'visa-navy': { group: 'Visa', label: 'Marineblau', svg: visaNavy, tint: false, maxH: 109 },
|
||||
'visa-silver': { group: 'Visa', label: 'Metallisches Silber', svg: visaSilver, tint: false, maxH: 109 },
|
||||
'mc-color': { group: 'Mastercard', label: 'Farbe', svg: mcColor, tint: false, maxH: 125 },
|
||||
'mc-silver': { group: 'Mastercard', label: 'Metallisches Silber', svg: mcSilver, tint: false, maxH: 125 },
|
||||
'mc-wordmark': { group: 'Mastercard', label: 'Wortmarke', svg: mcWordmark, tint: false, maxH: 109 },
|
||||
'mc-silver-text': { group: 'Mastercard', label: 'Silber + Text', svg: mcSilverText, tint: false, maxH: 109 },
|
||||
'mc-classic': { group: 'Mastercard', label: 'Klassisch', svg: mcClassic, tint: false, maxH: 125 },
|
||||
'up-color': { group: 'UnionPay', label: 'Farbe', svg: upColor, tint: false, maxH: 125 },
|
||||
'up-silver': { group: 'UnionPay', label: 'Metallisches Silber', svg: upSilver, tint: false, maxH: 125 },
|
||||
'up-white-frame': { group: 'UnionPay', label: 'Weißer Rahmen', svg: upWhiteFrame, tint: false, maxH: 125 },
|
||||
'up-english': { group: 'UnionPay', label: 'Englisch', svg: upEnglish, tint: false, maxH: 109 },
|
||||
'amex-blue': { group: 'American Express', label: 'Blau', svg: amexBlue, tint: false, maxH: 125 },
|
||||
'amex-outline': { group: 'American Express', label: 'Umriss', svg: amexOutline, tint: true, maxH: 125 },
|
||||
'amex-select': { group: 'American Express', label: 'Select', svg: amexSelect, tint: false, maxH: 90 },
|
||||
jcb: { group: 'JCB', label: 'JCB', svg: jcb, tint: false, maxH: 125 },
|
||||
discover: { group: 'Discover', label: 'Discover', svg: discover, tint: false, maxH: 109 },
|
||||
};
|
||||
|
||||
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, NetworkEntry> = {
|
||||
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = {
|
||||
'aircards-symbol': { group: 'Logo', label: 'AirCard-IOS · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 },
|
||||
'aircards-text': { group: 'Logo', label: 'AirCard-IOS · Text', svg: aircardsText, tint: true, maxH: 100 },
|
||||
apple: { group: 'Logo', label: 'Apple', svg: apple, tint: true, maxH: 100 },
|
||||
};
|
||||
|
||||
export const NETWORK_MARKS: readonly MarkDef[] = NETWORK_IDS.map((id) => ({ id, ...NETWORK_TABLE[id] }));
|
||||
|
||||
export const BRAND_MARKS: readonly MarkDef[] = (
|
||||
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
|
||||
).map((id) => ({ id, ...BRAND_TABLE[id] }));
|
||||
|
||||
export function getNetworkMark(id: NetworkId): MarkDef {
|
||||
return NETWORK_MARKS.find((m) => m.id === id) ?? NETWORK_MARKS[0];
|
||||
}
|
||||
|
||||
export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
|
||||
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadLogoAsset } from '../logos/logo-asset';
|
||||
import { DEFAULT_CONFIG, type CardConfig } from './config';
|
||||
import { fitLogo } from './geometry';
|
||||
import { loadMarkImage } from './marks';
|
||||
import {
|
||||
drawCard,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
type CardAssets,
|
||||
type CardImage,
|
||||
type Ctx,
|
||||
type LogoAsset,
|
||||
} from './render';
|
||||
|
||||
// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
|
||||
@@ -22,7 +25,6 @@ vi.mock('./marks', () => {
|
||||
maxH,
|
||||
});
|
||||
return {
|
||||
getNetworkMark: (id: string) => mark(id, '0 0 330 109', 109),
|
||||
getBrandMark: (id: string) => mark(id, '0 0 100 100', 100),
|
||||
svgAspect: (svg: string) => {
|
||||
const p = /viewBox="([^"]+)"/.exec(svg)![1].split(' ').map(Number);
|
||||
@@ -32,6 +34,9 @@ vi.mock('./marks', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Der Standard-Lader der Logos (Appwrite, Dekodierung) kommt in diesen Tests nie zum Einsatz.
|
||||
vi.mock('../logos/logo-asset', () => ({ loadLogoAsset: vi.fn() }));
|
||||
|
||||
interface Call {
|
||||
name: string;
|
||||
args: unknown[];
|
||||
@@ -99,13 +104,15 @@ function fakeCtx() {
|
||||
return { ctx: ctx as unknown as Ctx, calls, strokePath };
|
||||
}
|
||||
|
||||
const NETWORK = { tag: 'network' } as unknown as CanvasImageSource;
|
||||
const LOGO_SOURCE = { tag: 'logo' } as unknown as CanvasImageSource;
|
||||
const BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
|
||||
const PHOTO = { tag: 'photo' } as unknown as CanvasImageSource;
|
||||
/** Ein Community-Logo mit Seitenverhältnis 2 : 1 (die Größe in Pixeln spielt für den Platz auf der Karte keine Rolle). */
|
||||
const LOGO: LogoAsset = { source: LOGO_SOURCE, w: 200, h: 100 };
|
||||
|
||||
const assets = (over: Partial<CardAssets> = {}): CardAssets => ({
|
||||
image: null,
|
||||
network: NETWORK,
|
||||
logo: LOGO,
|
||||
brand: BRAND,
|
||||
...over,
|
||||
});
|
||||
@@ -187,18 +194,42 @@ describe('renderCard: Grund und Bild', () => {
|
||||
});
|
||||
|
||||
describe('renderCard: Vorlagen', () => {
|
||||
it('minimal: nur das Netzwerk-Logo unten rechts', () => {
|
||||
it('minimal: nur das Community-Logo unten rechts (Seitenverhältnis 2 : 1 → 250 × 125, rechte Kante 1480, Unterkante 913)', () => {
|
||||
const calls = render(cfg({ template: 'minimal' }), assets());
|
||||
expect(named(calls, 'drawImage')).toHaveLength(1);
|
||||
const [src, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
|
||||
expect(src).toBe(NETWORK);
|
||||
expect(x).toBeCloseTo(1150, 1);
|
||||
expect(y).toBeCloseTo(804, 1);
|
||||
expect(w).toBeCloseTo(330, 1);
|
||||
expect(h).toBeCloseTo(109, 1);
|
||||
const [src, x, y, w, h] = drawsOf(calls, LOGO_SOURCE)[0].args as number[];
|
||||
expect(src).toBe(LOGO_SOURCE);
|
||||
expect([x, y, w, h]).toEqual([1230, 788, 250, 125]);
|
||||
expect(named(calls, 'fillText')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('das Logo sitzt dort, wo fitLogo es hinlegt (Seitenverhältnis aus w und h des Logos)', () => {
|
||||
const wide: LogoAsset = { source: LOGO_SOURCE, w: 720, h: 72 };
|
||||
const [src, x, y, w, h] = drawsOf(render(cfg({ template: 'classic' }), assets({ logo: wide })), LOGO_SOURCE)[0].args as number[];
|
||||
const box = fitLogo(10);
|
||||
expect(src).toBe(LOGO_SOURCE);
|
||||
expect([x, y, w, h]).toEqual([box.x, box.y, box.w, box.h]);
|
||||
expect(w).toBe(360);
|
||||
});
|
||||
|
||||
it('Vorlage image: auch mit Logo wird es nicht gezeichnet', () => {
|
||||
const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(0);
|
||||
expect(named(calls, 'drawImage')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ohne Logo (assets.logo null) wird nichts unten rechts gezeichnet, auch wenn die Konfiguration eines wählt', () => {
|
||||
for (const template of ['minimal', 'classic', 'signature'] as const) {
|
||||
const calls = render(cfg({ template, logo: 'abc123' }), assets({ logo: null }));
|
||||
expect(drawsOf(calls, LOGO_SOURCE), template).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('das Logo hängt nur an den Bildern, nicht an config.logo (die Bilder gehören zur Konfiguration, mit der sie geladen wurden)', () => {
|
||||
const calls = render(cfg({ template: 'minimal', logo: null }), assets());
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('classic dunkel: Text in #111111, PLATINUM endet bei x 1480, Ziffern und Punkte stimmen', () => {
|
||||
const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
|
||||
|
||||
@@ -238,11 +269,10 @@ describe('renderCard: Vorlagen', () => {
|
||||
expect(named(calls, 'arc').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
|
||||
});
|
||||
|
||||
it('classic: Brand-Logo links oben, Netzwerk-Logo unten rechts', () => {
|
||||
it('classic: Brand-Logo links oben, Community-Logo unten rechts', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets());
|
||||
expect(drawsOf(calls, BRAND)[0].args).toEqual([BRAND, 56, 56, 100, 100]);
|
||||
const [, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
|
||||
expect([x, y, w, h].map((v) => Math.round(v))).toEqual([1150, 804, 330, 109]);
|
||||
expect(drawsOf(calls, LOGO_SOURCE)[0].args).toEqual([LOGO_SOURCE, 1230, 788, 250, 125]);
|
||||
});
|
||||
|
||||
it('classic: Kontaktlos-Bögen in Details-Farbe mit runden Enden', () => {
|
||||
@@ -256,7 +286,7 @@ describe('renderCard: Vorlagen', () => {
|
||||
it('signature, Brand none: kein Brand-drawImage, PLATINUM beginnt bei x 60', () => {
|
||||
const calls = render(cfg({ template: 'signature', brand: 'none' }), assets());
|
||||
expect(drawsOf(calls, BRAND)).toHaveLength(0);
|
||||
expect(drawsOf(calls, NETWORK)).toHaveLength(1);
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
|
||||
const plat = letters(calls);
|
||||
expect(plat[0].args).toEqual(['P', 60, 226]);
|
||||
expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"');
|
||||
@@ -273,7 +303,7 @@ describe('renderCard: Vorlagen', () => {
|
||||
});
|
||||
|
||||
it('fehlende Logo-Bilder werden übersprungen', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ network: null, brand: null }));
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ logo: null, brand: null }));
|
||||
expect(named(calls, 'drawImage')).toHaveLength(0);
|
||||
expect(letters(calls)).toHaveLength(8);
|
||||
});
|
||||
@@ -319,7 +349,7 @@ describe('renderCard: Chip', () => {
|
||||
});
|
||||
|
||||
describe('renderCard: Reihenfolge', () => {
|
||||
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Netzwerk', () => {
|
||||
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Logo', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ image: photo(1536, 969) }));
|
||||
const at = (pred: (c: Call) => boolean) => calls.findIndex(pred);
|
||||
const order = [
|
||||
@@ -330,7 +360,7 @@ describe('renderCard: Reihenfolge', () => {
|
||||
at((c) => c.name === 'arc'),
|
||||
at((c) => c.name === 'fillText' && c.args[0] === 'P'),
|
||||
at((c) => c.name === 'drawImage' && c.args[0] === BRAND),
|
||||
at((c) => c.name === 'drawImage' && c.args[0] === NETWORK),
|
||||
at((c) => c.name === 'drawImage' && c.args[0] === LOGO_SOURCE),
|
||||
];
|
||||
expect(order.every((i) => i >= 0)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
@@ -386,14 +416,17 @@ describe('drawCard', () => {
|
||||
});
|
||||
|
||||
describe('loadCardAssets', () => {
|
||||
const NET_IMG = { tag: 'net-img' };
|
||||
const BRAND_IMG = { tag: 'brand-img' };
|
||||
const fontsLoad = vi.fn();
|
||||
const mocked = vi.mocked(loadMarkImage);
|
||||
const defaultLoader = vi.mocked(loadLogoAsset);
|
||||
const loadLogo = vi.fn<(id: string, tone: 'light' | 'dark') => Promise<LogoAsset | null>>();
|
||||
|
||||
beforeEach(() => {
|
||||
fontsLoad.mockReset().mockResolvedValue([]);
|
||||
mocked.mockReset().mockImplementation(async (def) => (def.id === 'apple' || def.id.startsWith('aircards') ? BRAND_IMG : NET_IMG) as unknown as HTMLImageElement);
|
||||
mocked.mockReset().mockImplementation(async () => BRAND_IMG as unknown as HTMLImageElement);
|
||||
defaultLoader.mockReset().mockResolvedValue(LOGO);
|
||||
loadLogo.mockReset().mockResolvedValue(LOGO);
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
|
||||
});
|
||||
|
||||
@@ -401,35 +434,68 @@ describe('loadCardAssets', () => {
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
});
|
||||
|
||||
it('lädt Netzwerk- und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
|
||||
it('lädt Community-Logo und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
|
||||
const image = photo(100, 100);
|
||||
const result = await loadCardAssets(cfg({ network: 'jcb', brand: 'apple', details: 'dark', template: 'image' }), image);
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123', brand: 'aircards-text', details: 'dark', template: 'image' }), image, loadLogo);
|
||||
expect(result).toEqual({ image, logo: LOGO, brand: BRAND_IMG, logoMissing: false });
|
||||
expect(result.image).toBe(image);
|
||||
expect(result.network).toBe(NET_IMG);
|
||||
expect(result.brand).toBe(BRAND_IMG);
|
||||
expect(mocked).toHaveBeenCalledTimes(2);
|
||||
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['jcb', 'dark'], ['apple', 'dark']]);
|
||||
expect(loadLogo).toHaveBeenCalledTimes(1);
|
||||
expect(loadLogo).toHaveBeenCalledWith('abc123', 'dark');
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['aircards-text', 'dark']]);
|
||||
expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"');
|
||||
});
|
||||
|
||||
it('ohne Logo (logo null): kein Lader-Aufruf, kein fehlendes Logo', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: null }), null, loadLogo);
|
||||
expect(loadLogo).not.toHaveBeenCalled();
|
||||
expect(result.logo).toBeNull();
|
||||
expect(result.logoMissing).toBe(false);
|
||||
});
|
||||
|
||||
it('eine leere logo-Angabe zählt wie kein Logo', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: '' }), null, loadLogo);
|
||||
expect(loadLogo).not.toHaveBeenCalled();
|
||||
expect(result.logoMissing).toBe(false);
|
||||
});
|
||||
|
||||
it('der Lader liefert null (gelöscht, ausgeblendet): logo null und logoMissing true, Brand-Logo wird trotzdem geladen', async () => {
|
||||
loadLogo.mockResolvedValue(null);
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo);
|
||||
expect(result.logo).toBeNull();
|
||||
expect(result.logoMissing).toBe(true);
|
||||
expect(result.brand).toBe(BRAND_IMG);
|
||||
});
|
||||
|
||||
it('der Lader wirft: loadCardAssets wirft (ein echter Ladefehler ist kein fehlendes Logo)', async () => {
|
||||
loadLogo.mockRejectedValue(new Error('offline'));
|
||||
await expect(loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo)).rejects.toThrow('offline');
|
||||
});
|
||||
|
||||
it('ohne dritten Parameter lädt loadLogoAsset', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123', details: 'light' }), null);
|
||||
expect(defaultLoader).toHaveBeenCalledWith('abc123', 'light');
|
||||
expect(result.logo).toBe(LOGO);
|
||||
});
|
||||
|
||||
it('Brand none: kein Brand-Logo laden', async () => {
|
||||
const result = await loadCardAssets(cfg({ brand: 'none' }), null);
|
||||
const result = await loadCardAssets(cfg({ brand: 'none' }), null, loadLogo);
|
||||
expect(result.image).toBeNull();
|
||||
expect(result.brand).toBeNull();
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Schrift lädt nicht oder document.fonts fehlt: Karte wird trotzdem fertig', async () => {
|
||||
fontsLoad.mockRejectedValue(new Error('offline'));
|
||||
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
|
||||
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
|
||||
});
|
||||
|
||||
it('Logo-Fehler wird weitergereicht', async () => {
|
||||
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: jcb'));
|
||||
await expect(loadCardAssets(cfg({ network: 'jcb' }), null)).rejects.toThrow(/Logo/);
|
||||
it('Marken-Fehler wird weitergereicht', async () => {
|
||||
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: aircards-symbol'));
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).rejects.toThrow(/Logo/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
// Kartenrenderer: zeichnet eine Karte in 1536 x 969-Koordinaten auf einen Canvas-Kontext.
|
||||
// Vorschau, Vorlagen-Miniaturen, PNG-Download und Vorschaubild beim Speichern benutzen alle drawCard.
|
||||
import { loadLogoAsset } from '../logos/logo-asset';
|
||||
import { TONE_COLOR, type CardConfig, type DetailsTone } from './config';
|
||||
import { clampCrop, imageRect } from './crop';
|
||||
import { CARD_H, CARD_W, fitBrand, fitNetwork, layoutFor, type Layout } from './geometry';
|
||||
import { getBrandMark, getNetworkMark, loadMarkImage, svgAspect } from './marks';
|
||||
import { CARD_H, CARD_W, fitBrand, fitLogo, layoutFor, type Layout } from './geometry';
|
||||
import { getBrandMark, loadMarkImage, svgAspect } from './marks';
|
||||
|
||||
export type CardImage = { source: CanvasImageSource; w: number; h: number };
|
||||
|
||||
/** Ein Community-Logo, fertig zum Zeichnen (einfarbige schon in der Details-Farbe); w/h = Pixel der Quelle, daraus folgt das Seitenverhältnis. */
|
||||
export interface LogoAsset {
|
||||
source: CanvasImageSource;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CardAssets {
|
||||
image: CardImage | null;
|
||||
network: CanvasImageSource | null;
|
||||
/** Das Community-Logo unten rechts; null = keins oder nicht verfügbar. */
|
||||
logo: LogoAsset | null;
|
||||
/** Das AirCard-IOS-Logo oben links. */
|
||||
brand: CanvasImageSource | null;
|
||||
}
|
||||
|
||||
/** Was `loadCardAssets` liefert: dazu die Auskunft, ob die Karte ein Logo wählt, das es nicht (mehr) gibt. */
|
||||
export interface LoadedAssets extends CardAssets {
|
||||
logoMissing: boolean;
|
||||
}
|
||||
|
||||
/** Der kleine Ausschnitt von CanvasRenderingContext2D, den der Renderer braucht (leicht zu faken). */
|
||||
export type Ctx = Pick<
|
||||
CanvasRenderingContext2D,
|
||||
@@ -112,7 +127,7 @@ function drawPlatinum(ctx: Ctx, p: NonNullable<Layout['platinum']>, color: strin
|
||||
});
|
||||
}
|
||||
|
||||
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Logo, Netzwerk. */
|
||||
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Marke oben links, Logo unten rechts. */
|
||||
export function renderCard(
|
||||
ctx: Ctx,
|
||||
config: CardConfig,
|
||||
@@ -150,10 +165,10 @@ export function renderCard(
|
||||
ctx.drawImage(assets.brand, b.x, b.y, b.w, b.h);
|
||||
}
|
||||
|
||||
if (layout.network && assets.network) {
|
||||
const mark = getNetworkMark(config.network);
|
||||
const n = fitNetwork(svgAspect(mark.svg), mark.maxH);
|
||||
ctx.drawImage(assets.network, n.x, n.y, n.w, n.h);
|
||||
// Gezeichnet wird, was die Bilder mitbringen (sie gehören zu der Konfiguration, mit der sie geladen wurden).
|
||||
if (layout.logo && assets.logo) {
|
||||
const l = fitLogo(assets.logo.w / assets.logo.h);
|
||||
ctx.drawImage(assets.logo.source, l.x, l.y, l.w, l.h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,17 +188,25 @@ export function drawCard(
|
||||
renderCard(ctx, config, assets);
|
||||
}
|
||||
|
||||
/** Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch. */
|
||||
export async function loadCardAssets(config: CardConfig, image: CardImage | null): Promise<CardAssets> {
|
||||
/**
|
||||
* Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch.
|
||||
* Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet), ist `logo` null und `logoMissing` true; die Karte
|
||||
* lässt sich dann ohne Logo zeichnen. Ein echter Ladefehler (Netz, Server) wirft dagegen.
|
||||
*/
|
||||
export async function loadCardAssets(
|
||||
config: CardConfig,
|
||||
image: CardImage | null,
|
||||
loadLogo: (id: string, tone: DetailsTone) => Promise<LogoAsset | null> = loadLogoAsset,
|
||||
): Promise<LoadedAssets> {
|
||||
// Ohne geladene Schrift misst und zeichnet der Canvas mit der Ersatzschrift.
|
||||
const fontReady = Promise.resolve(typeof document !== 'undefined' ? document.fonts?.load(FONT_LOAD) : undefined)
|
||||
.catch(() => undefined);
|
||||
const [network, brand] = await Promise.all([
|
||||
loadMarkImage(getNetworkMark(config.network), config.details),
|
||||
const [logo, brand] = await Promise.all([
|
||||
config.logo ? loadLogo(config.logo, config.details) : null,
|
||||
config.brand === 'none' ? null : loadMarkImage(getBrandMark(config.brand), config.details),
|
||||
fontReady,
|
||||
]);
|
||||
return { image, network, brand };
|
||||
return { image, logo, brand, logoMissing: Boolean(config.logo) && logo === null };
|
||||
}
|
||||
|
||||
/** Rendert die Karte offscreen in der gewünschten Breite (Höhe nach 1536 : 969) und liefert die Bilddatei. */
|
||||
|
||||
@@ -6,10 +6,11 @@ vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: ()
|
||||
|
||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { ApiError } from './api';
|
||||
import { NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
|
||||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
|
||||
import {
|
||||
CONSENT_TEXT,
|
||||
publishDialog,
|
||||
publishFailureText,
|
||||
validatePublishForm,
|
||||
type PublishFormValues,
|
||||
type PublishOutcome,
|
||||
@@ -382,6 +383,25 @@ describe('Veröffentlichen-Dialog: Senden', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
|
||||
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
|
||||
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(
|
||||
'Veröffentlichen fehlgeschlagen – Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||||
);
|
||||
expect(errorText()).toContain(LOGO_MISSING_PUBLISH);
|
||||
expect(errorText()).not.toContain('schiefgelaufen');
|
||||
});
|
||||
|
||||
it('publishFailureText reicht ihn ebenso durch', () => {
|
||||
expect(publishFailureText(new Error(LOGO_MISSING_PUBLISH))).toBe(`Veröffentlichen fehlgeschlagen – ${LOGO_MISSING_PUBLISH}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Schließen', () => {
|
||||
it('„Abbrechen“ schließt ohne zu senden', async () => {
|
||||
const outcome = open();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { el } from '../editor/dom';
|
||||
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
|
||||
import { ApiError, isAlreadyPublished } from './api';
|
||||
import { NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
|
||||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
|
||||
|
||||
/** Wortlaut aus Spec § 6. */
|
||||
export const CONSENT_TEXT = 'Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.';
|
||||
@@ -67,7 +67,7 @@ export function validatePublishForm(values: PublishFormValues): { field: Publish
|
||||
}
|
||||
|
||||
/** Meldungen aus dem Rendern und dem Bildlesen sind schon deutsch; alles andere übersetzt `errorMessage`. */
|
||||
const GERMAN_MESSAGES = new Set([TOO_LARGE, NOT_RENDERED, READ_MESSAGE, PIXELS_MESSAGE]);
|
||||
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
|
||||
|
||||
/** Der Text zu einem fehlgeschlagenen Veröffentlichen (Spec § 11: „Veröffentlichen fehlgeschlagen – …“). */
|
||||
export function publishFailureText(err: unknown): string {
|
||||
|
||||
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG } from '../card/config';
|
||||
import type { SavedCard } from '../account/cards';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ createJWT: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createJWT: vi.fn(),
|
||||
decodeImage: vi.fn(),
|
||||
loadCardAssets: vi.fn(),
|
||||
exportCard: vi.fn(),
|
||||
}));
|
||||
|
||||
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
|
||||
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
|
||||
@@ -14,9 +19,22 @@ vi.mock('../account/appwrite', () => ({
|
||||
storage: {},
|
||||
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
||||
}));
|
||||
// Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt.
|
||||
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
|
||||
vi.mock('../editor/upload', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../editor/upload')>()),
|
||||
decodeImage: mocks.decodeImage,
|
||||
}));
|
||||
|
||||
import { ApiError, publishCard } from './api';
|
||||
import { PNG_MAX_BYTES, normalizeTags, publishSavedCard, type PublishDeps } from './publish';
|
||||
import {
|
||||
LOGO_MISSING_PUBLISH,
|
||||
PNG_MAX_BYTES,
|
||||
normalizeTags,
|
||||
publishSavedCard,
|
||||
renderForGallery,
|
||||
type PublishDeps,
|
||||
} from './publish';
|
||||
|
||||
const card: SavedCard = {
|
||||
id: 'card1',
|
||||
@@ -242,6 +260,79 @@ describe('publishSavedCard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo der Karte beim Veröffentlichen', () => {
|
||||
const photo = { source: {}, w: 3000, h: 2000 };
|
||||
const source = new Blob(['src'], { type: 'image/jpeg' });
|
||||
const withLogo = (logo: string | null): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, logo } });
|
||||
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.decodeImage.mockReset().mockResolvedValue(photo);
|
||||
mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor());
|
||||
mocks.exportCard
|
||||
.mockReset()
|
||||
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
|
||||
});
|
||||
|
||||
it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => {
|
||||
const { t, setLang } = await import('../i18n/i18n');
|
||||
expect(LOGO_MISSING_PUBLISH).toBe(
|
||||
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||||
);
|
||||
try {
|
||||
setLang('en', { persist: false });
|
||||
expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.');
|
||||
} finally {
|
||||
setLang('de', { persist: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } }));
|
||||
const out = await renderForGallery(withLogo('abc123'), source);
|
||||
expect(mocks.decodeImage).toHaveBeenCalledWith(source);
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo);
|
||||
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
|
||||
expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 });
|
||||
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
|
||||
});
|
||||
|
||||
it('eine Karte ohne Logo wird ganz normal gerendert', async () => {
|
||||
const out = await renderForGallery(withLogo(null), source);
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo);
|
||||
expect(out.png.type).toBe('image/png');
|
||||
});
|
||||
|
||||
it('fehlendes Logo (gelöscht oder ausgeblendet): Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||||
await expect(renderForGallery(withLogo('gone'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
|
||||
const failure = new TypeError('Failed to fetch');
|
||||
mocks.loadCardAssets.mockRejectedValue(failure);
|
||||
await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it('publishSavedCard: mit fehlendem Logo wird nichts hochgeladen, nichts gelöscht und die API nicht gerufen', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||||
const fake = fakeDeps({ render: renderForGallery });
|
||||
await expect(publishSavedCard(withLogo('gone'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage']);
|
||||
expect(fake.of('createFile')).toHaveLength(0);
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
expect(fake.of('publishCard')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
|
||||
const fake = fakeDeps({ render: renderForGallery });
|
||||
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
|
||||
const json = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
@@ -8,12 +8,17 @@ import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
|
||||
import { clampCrop } from '../card/crop';
|
||||
import { exportCard, loadCardAssets } from '../card/render';
|
||||
import { decodeImage } from '../editor/upload';
|
||||
import { msg } from '../i18n/i18n';
|
||||
import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
|
||||
|
||||
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
|
||||
export const PNG_MAX_BYTES = 5_242_880;
|
||||
export const TOO_LARGE = 'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).';
|
||||
export const NOT_RENDERED = 'Das Kartenbild konnte nicht erzeugt werden.';
|
||||
/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: veröffentlicht wird sie nicht ohne dieses Logo. */
|
||||
export const LOGO_MISSING_PUBLISH = msg(
|
||||
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||||
);
|
||||
const GALLERY_TYPES = ['image/png', 'image/webp'];
|
||||
|
||||
export interface PublishForm {
|
||||
@@ -47,10 +52,12 @@ export function normalizeTags(input: string): string[] {
|
||||
return tags;
|
||||
}
|
||||
|
||||
async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
|
||||
export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
|
||||
const image = await decodeImage(imageBlob);
|
||||
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
|
||||
const assets = await loadCardAssets(config, image);
|
||||
// Ohne das gewählte Logo würde eine andere Karte veröffentlicht, als der Nutzer sie gestaltet hat: lieber abbrechen.
|
||||
if (assets.logoMissing) throw new Error(LOGO_MISSING_PUBLISH);
|
||||
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
|
||||
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
|
||||
return { png, preview };
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { User } from '../account/auth';
|
||||
import type { SavedCard } from '../account/cards';
|
||||
import { DEFAULT_CONFIG } from '../card/config';
|
||||
import type { CardImage } from '../card/render';
|
||||
import { PNG_SIZE_WARNING } from './download';
|
||||
import { createStore, initialEditorState, type EditorStore } from './state';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -15,6 +16,8 @@ const mocks = vi.hoisted(() => ({
|
||||
decodeImage: vi.fn(),
|
||||
loadCardAssets: vi.fn(),
|
||||
exportCard: vi.fn(),
|
||||
drawCard: vi.fn(),
|
||||
saveBlobAs: vi.fn(),
|
||||
getGalleryCard: vi.fn(),
|
||||
loadGalleryImage: vi.fn(),
|
||||
logoutListeners: new Set<() => void>(),
|
||||
@@ -35,10 +38,15 @@ vi.mock('../account/cards', () => ({
|
||||
cardsService: () => ({ get: mocks.get, save: mocks.save, loadImage: mocks.loadImage }),
|
||||
}));
|
||||
vi.mock('../card/render', () => ({
|
||||
drawCard: vi.fn(),
|
||||
drawCard: mocks.drawCard,
|
||||
loadCardAssets: mocks.loadCardAssets,
|
||||
exportCard: mocks.exportCard,
|
||||
}));
|
||||
// Der Browser-Download selbst (Object-URL, Anker) gehört nicht in diese Tests.
|
||||
vi.mock('./download', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./download')>()),
|
||||
saveBlobAs: mocks.saveBlobAs,
|
||||
}));
|
||||
vi.mock('../community/gallery', () => ({
|
||||
getGalleryCard: mocks.getGalleryCard,
|
||||
loadGalleryImage: mocks.loadGalleryImage,
|
||||
@@ -79,7 +87,7 @@ beforeEach(() => {
|
||||
store = createStore(initialEditorState());
|
||||
mocks.getCurrentUser.mockResolvedValue(user);
|
||||
mocks.requireLogin.mockResolvedValue(true);
|
||||
mocks.loadCardAssets.mockResolvedValue({ image: null, network: {}, brand: {} });
|
||||
mocks.loadCardAssets.mockResolvedValue({ image: null, logo: null, brand: {}, logoMissing: false });
|
||||
mocks.exportCard.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' }));
|
||||
unmount = null;
|
||||
});
|
||||
@@ -110,7 +118,7 @@ function withImage(blob = png(), img = image()): { blob: Blob; img: CardImage }
|
||||
describe('Speichern', () => {
|
||||
it('speichert eine neue Karte mit Schnappschuss, Vorschau und Nutzer-Prüfung', async () => {
|
||||
const { blob } = withImage();
|
||||
store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, network: 'jcb' } });
|
||||
store.set({ cardName: 'Mein Urlaub', config: { ...store.get().config, logo: 'abc123' } });
|
||||
const result = savedCard({ id: 'neu', name: 'Mein Urlaub' });
|
||||
mocks.save.mockResolvedValue(result);
|
||||
mount();
|
||||
@@ -122,14 +130,14 @@ describe('Speichern', () => {
|
||||
await vi.waitFor(() => expect(saveButton().textContent).toBe('Gespeichert'));
|
||||
|
||||
expect(mocks.getCurrentUser).toHaveBeenCalled();
|
||||
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ network: 'jcb' }), expect.anything(), {
|
||||
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), expect.anything(), {
|
||||
width: 480,
|
||||
type: 'image/webp',
|
||||
quality: 0.85,
|
||||
});
|
||||
const input = mocks.save.mock.calls[0][0];
|
||||
expect(input).toMatchObject({ name: 'Mein Urlaub', image: blob, imageChanged: true, previous: null });
|
||||
expect(input.config.network).toBe('jcb');
|
||||
expect(input.config.logo).toBe('abc123');
|
||||
expect(input.preview.type).toBe('image/webp');
|
||||
|
||||
const s = store.get();
|
||||
@@ -982,6 +990,219 @@ describe('Speichern: Schnappschuss beim Klick', () => {
|
||||
});
|
||||
|
||||
|
||||
describe('Community-Logo auf der Karte', () => {
|
||||
const LOGO_GONE = 'Das gewählte Logo ist nicht mehr verfügbar.';
|
||||
const MISSING = { image: null, logo: null, brand: {}, logoMissing: true };
|
||||
const FOUND = { image: null, logo: { source: {}, w: 200, h: 100 }, brand: {}, logoMissing: false };
|
||||
const withLogo = (logo: string | null, patch: Record<string, unknown> = {}) => ({ ...store.get().config, logo, ...patch });
|
||||
/** Das Logo „gone“ gibt es nicht mehr, alle anderen schon. */
|
||||
const goneLoader = async (config: { logo: string | null }) => (config.logo === 'gone' ? MISSING : FOUND);
|
||||
const downloadButton = () => [...root.querySelectorAll('button')].find((b) => b.textContent === 'Herunterladen') as HTMLButtonElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Hier wird wirklich gezeichnet: requestAnimationFrame läuft (asynchron) ab.
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => cb(0), 0) as unknown as number);
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id));
|
||||
});
|
||||
|
||||
it('die Bilder werden mit dem gewählten Logo geladen (loadCardAssets bekommt die Konfiguration samt logo)', async () => {
|
||||
store.set({ config: withLogo('abc123', { details: 'dark' }) });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalled());
|
||||
const [config, image] = mocks.loadCardAssets.mock.calls[0];
|
||||
expect(config).toMatchObject({ logo: 'abc123', details: 'dark' });
|
||||
expect(image).toBeNull();
|
||||
expect(warningText()).toBe('');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Logo fehlt: Warnung „Das gewählte Logo ist nicht mehr verfügbar.“, kein Fehler, die Karte wird trotzdem gezeichnet', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(MISSING);
|
||||
withImage();
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
expect(errorText()).toBe('');
|
||||
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
|
||||
// Vorschau (Canvas „card-canvas“) und die vier Vorlagen-Miniaturen: alle ohne Logo, mit unveränderter Auswahl
|
||||
const calls = mocks.drawCard.mock.calls;
|
||||
expect(calls.some(([canvas]) => (canvas as HTMLElement).classList.contains('card-canvas'))).toBe(true);
|
||||
expect(new Set(calls.map(([canvas]) => canvas)).size).toBe(5);
|
||||
for (const [, config, assets] of calls) {
|
||||
expect(config.logo).toBe('gone');
|
||||
expect(assets.logo).toBeNull();
|
||||
}
|
||||
// die Auswahl bleibt im Zustand
|
||||
expect(store.get().config.logo).toBe('gone');
|
||||
});
|
||||
|
||||
it('die Warnung erscheint auch ohne Bild (die Miniaturen zeigen die Karte)', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(MISSING);
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
// ohne Bild keine Vorschau, aber die vier Vorlagen-Miniaturen
|
||||
await vi.waitFor(() => expect(new Set(mocks.drawCard.mock.calls.map(([canvas]) => canvas)).size).toBe(4));
|
||||
expect(mocks.drawCard.mock.calls.some(([canvas]) => (canvas as HTMLElement).classList.contains('card-canvas'))).toBe(false);
|
||||
});
|
||||
|
||||
it('Wechsel auf ein vorhandenes Logo: die Warnung verschwindet', async () => {
|
||||
mocks.loadCardAssets.mockImplementation(goneLoader);
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
store.set({ config: withLogo('da') });
|
||||
await vi.waitFor(() => expect(warningText()).toBe(''));
|
||||
expect(errorText()).toBe('');
|
||||
// und zurück auf das fehlende Logo: der Hinweis kommt wieder
|
||||
store.set({ config: withLogo('gone') });
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
});
|
||||
|
||||
it('Wechsel auf „Kein Logo“: die Warnung verschwindet', async () => {
|
||||
mocks.loadCardAssets.mockImplementation(goneLoader);
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
store.set({ config: withLogo(null) });
|
||||
await vi.waitFor(() => expect(warningText()).toBe(''));
|
||||
});
|
||||
|
||||
it('Hell/Dunkel bei fehlendem Logo: der Hinweis bleibt stehen', async () => {
|
||||
mocks.loadCardAssets.mockImplementation(goneLoader);
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
store.set({ config: withLogo('gone', { details: 'dark' }) });
|
||||
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(2));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(warningText()).toBe(LOGO_GONE);
|
||||
});
|
||||
|
||||
it('ein fehlendes Logo wird nicht immer wieder geladen (kein Fehler-Loop)', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(MISSING);
|
||||
withImage();
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
store.set({ cardName: 'Andere Karte' });
|
||||
store.set({ config: withLogo('gone', { template: 'classic' }) });
|
||||
store.set({ config: withLogo('gone', { template: 'classic', crop: { x: 10, y: 5, zoom: 2 } }) });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1);
|
||||
expect(warningText()).toBe(LOGO_GONE);
|
||||
});
|
||||
|
||||
it('kein „Die Logos konnten nicht geladen werden.“ bei fehlendem Logo, nur bei einem echten Ladefehler', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(MISSING);
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
expect(errorText()).toBe('');
|
||||
|
||||
unmount?.();
|
||||
mocks.loadCardAssets.mockRejectedValue(new Error('offline'));
|
||||
store.set({ config: withLogo('anderes') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
|
||||
expect(warningText()).toBe('');
|
||||
// kein Wiederholungs-Loop: derselbe Schlüssel wird nicht noch einmal versucht
|
||||
const attempts = mocks.loadCardAssets.mock.calls.length;
|
||||
store.set({ cardName: 'x' });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledTimes(attempts);
|
||||
});
|
||||
|
||||
it('gespeicherte Karte mit gelöschtem Logo: öffnet ohne Fehler, zeigt den Hinweis, behält die Auswahl und lässt sich herunterladen', async () => {
|
||||
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), template: 'classic', logo: 'gone' } }));
|
||||
mocks.loadImage.mockResolvedValue(png());
|
||||
mocks.decodeImage.mockResolvedValue(image());
|
||||
mocks.loadCardAssets.mockImplementation(goneLoader);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(store.get().saved?.id).toBe('k1'));
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
expect(errorText()).toBe('');
|
||||
expect(store.get().config.logo).toBe('gone');
|
||||
await vi.waitFor(() => expect(mocks.drawCard).toHaveBeenCalled());
|
||||
|
||||
downloadButton().click();
|
||||
await vi.waitFor(() => expect(mocks.saveBlobAs).toHaveBeenCalled());
|
||||
expect(mocks.exportCard).toHaveBeenCalledWith(expect.objectContaining({ logo: 'gone' }), expect.objectContaining({ logo: null }), {
|
||||
width: 1536,
|
||||
type: 'image/png',
|
||||
});
|
||||
expect(errorText()).toBe('');
|
||||
// Der Hinweis bleibt: das Logo fehlt weiterhin.
|
||||
expect(warningText()).toBe(LOGO_GONE);
|
||||
});
|
||||
|
||||
it('Download: scheitert das Laden der Logos (Netz), steht „Die Logos konnten nicht geladen werden.“ statt der rohen Fehlermeldung', async () => {
|
||||
withImage();
|
||||
store.set({ config: withLogo('da') });
|
||||
mocks.loadCardAssets.mockResolvedValueOnce(FOUND).mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
mount();
|
||||
await vi.waitFor(() => expect(mocks.loadCardAssets).toHaveBeenCalledTimes(1));
|
||||
downloadButton().click();
|
||||
await vi.waitFor(() => expect(errorText()).toBe('Die Logos konnten nicht geladen werden.'));
|
||||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||||
expect(mocks.saveBlobAs).not.toHaveBeenCalled();
|
||||
expect(downloadButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('der Logo-Hinweis und andere Hinweise stehen zusammen und löschen sich nicht gegenseitig', async () => {
|
||||
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||
mocks.get.mockResolvedValue(savedCard({ config: { ...structuredClone(DEFAULT_CONFIG), logo: 'gone' } }));
|
||||
mocks.loadImage.mockRejectedValue(Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' }));
|
||||
mocks.loadCardAssets.mockImplementation(goneLoader);
|
||||
mount();
|
||||
// Bilddatei fehlt UND das Logo fehlt: beide Hinweise
|
||||
await vi.waitFor(() => expect(warningText()).toBe(`Das Bild dieser Karte fehlt. ${LOGO_GONE}`));
|
||||
// Ein anderes Logo: nur der Logo-Hinweis geht, der Bild-Hinweis bleibt.
|
||||
store.set({ config: withLogo('da') });
|
||||
await vi.waitFor(() => expect(warningText()).toBe('Das Bild dieser Karte fehlt.'));
|
||||
});
|
||||
|
||||
it('der Größenhinweis des Downloads bleibt, wenn der Logo-Hinweis verschwindet (und umgekehrt)', async () => {
|
||||
mocks.loadCardAssets.mockImplementation(goneLoader);
|
||||
mocks.exportCard.mockResolvedValue(new Blob([new Uint8Array(4_000_001)], { type: 'image/png' }));
|
||||
withImage();
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
|
||||
downloadButton().click();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(`${PNG_SIZE_WARNING} ${LOGO_GONE}`));
|
||||
|
||||
store.set({ config: withLogo('da') });
|
||||
await vi.waitFor(() => expect(warningText()).toBe(PNG_SIZE_WARNING));
|
||||
});
|
||||
|
||||
it('Neue Karte: der Hinweis zum Logo der alten Karte verschwindet', async () => {
|
||||
mocks.loadCardAssets.mockImplementation(goneLoader);
|
||||
store.set({
|
||||
config: withLogo('gone'),
|
||||
saved: { id: 'k1', name: 'Urlaub', imageFileId: 'i', previewFileId: 'p', updatedAt: 'x' },
|
||||
savedConfig: withLogo('gone'),
|
||||
cardName: 'Urlaub',
|
||||
});
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe(LOGO_GONE));
|
||||
root.querySelector<HTMLButtonElement>('.bound-row button')!.click();
|
||||
await vi.waitFor(() => expect(store.get().saved).toBeNull());
|
||||
await vi.waitFor(() => expect(warningText()).toBe(''));
|
||||
});
|
||||
|
||||
it('Englisch: der Hinweis erscheint übersetzt', async () => {
|
||||
const { setLang } = await import('../i18n/i18n');
|
||||
setLang('en', { persist: false });
|
||||
mocks.loadCardAssets.mockResolvedValue(MISSING);
|
||||
store.set({ config: withLogo('gone') });
|
||||
mount();
|
||||
await vi.waitFor(() => expect(warningText()).toBe('The selected logo is no longer available.'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
|
||||
const openTemplate = (id: string) => history.replaceState(null, '', `/gestalten?vorlage=${id}`);
|
||||
const galleryCard = (patch: Record<string, unknown> = {}) => ({
|
||||
@@ -1029,7 +1250,7 @@ describe('Vorlage übernehmen (/gestalten?vorlage=<id>)', () => {
|
||||
it('eine zuvor geöffnete gespeicherte Karte wird entkoppelt: Speichern legt eine neue Karte an', async () => {
|
||||
openTemplate('g1');
|
||||
const { blob } = available();
|
||||
const config = { ...store.get().config, template: 'classic' as const, network: 'jcb' as const };
|
||||
const config = { ...store.get().config, template: 'classic' as const, logo: 'abc123' };
|
||||
store.set({
|
||||
image: image(),
|
||||
imageBlob: png(),
|
||||
|
||||
@@ -5,11 +5,13 @@ import { getCurrentUser, onLogout } from '../account/auth';
|
||||
import { errorMessage, errorType } from '../account/errors';
|
||||
import { requireLogin } from '../account/login-dialog';
|
||||
import { LoginCancelled, withLogin } from '../account/with-login';
|
||||
import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type NetworkId, type TemplateId } from '../card/config';
|
||||
import { serializeConfig, type BrandId, type CardConfig, type DetailsTone, type TemplateId } from '../card/config';
|
||||
import { clampCrop, panBy, zoomTo } from '../card/crop';
|
||||
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH } from '../card/geometry';
|
||||
import { drawCard, exportCard, loadCardAssets, type CardAssets, type CardImage } from '../card/render';
|
||||
import { getGalleryCard, loadGalleryImage } from '../community/gallery';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { LOGO_MISSING } from '../logos/logo-asset';
|
||||
import { dropCardParam, dropTemplateParam } from './card-url';
|
||||
import { confirmDialog } from './confirm-dialog';
|
||||
import { downloadFileName, pngSizeWarning, saveBlobAs } from './download';
|
||||
@@ -30,10 +32,11 @@ const TEMPLATE_UNAVAILABLE = 'Diese Vorlage ist nicht (mehr) verfügbar.';
|
||||
const LOGIN_TO_OPEN = 'Melde dich an, um diese Karte zu öffnen.';
|
||||
const SAVED_FLASH_MS = 2000;
|
||||
|
||||
type Logos = Pick<CardAssets, 'network' | 'brand'>;
|
||||
/** Die geladenen Logo-Bilder; `missing`: die Karte wählt ein Community-Logo, das es nicht (mehr) gibt. */
|
||||
type Logos = Pick<CardAssets, 'logo' | 'brand'> & { missing: boolean };
|
||||
|
||||
/** Alles, was Logo-Bilder bestimmt: wechselt der Schlüssel, müssen sie neu geladen werden. */
|
||||
const logoKey = (c: CardConfig) => `${c.network}|${c.details}|${c.brand}`;
|
||||
const logoKey = (c: CardConfig) => `${c.logo ?? ''}|${c.details}|${c.brand}`;
|
||||
|
||||
/** Der Teil einer gespeicherten Karte, den der Editor-Zustand festhält (ohne Konfiguration). */
|
||||
function toRef(card: SavedCardRef): SavedCardRef {
|
||||
@@ -65,7 +68,6 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
|
||||
const templates = createTemplatesPanel((template: TemplateId) => patchConfig({ template }));
|
||||
const options = createOptionsPanel({
|
||||
onNetwork: (network: NetworkId) => patchConfig({ network }),
|
||||
onDetails: (details: DetailsTone) => patchConfig({ details }),
|
||||
onBrand: (brand: BrandId) => patchConfig({ brand }),
|
||||
});
|
||||
@@ -100,6 +102,19 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
onNewCard: () => void startNewCard(),
|
||||
});
|
||||
|
||||
// Der Hinweis unter der Vorschau: ein allgemeiner (Bild fehlt, PNG zu groß …) und der zum fehlenden Community-Logo (er gilt,
|
||||
// solange die geladenen Logos `missing` sind, siehe ensureLogos). Beide teilen sich das Feld, werden aber getrennt gehalten,
|
||||
// damit das Ende des einen den anderen nicht löscht.
|
||||
let generalWarning: string | null = null;
|
||||
function showWarning(): void {
|
||||
const parts = [generalWarning, logos?.missing ? t(LOGO_MISSING) : null].filter((part): part is string => !!part);
|
||||
preview.setWarning(parts.length > 0 ? parts.join(' ') : null);
|
||||
}
|
||||
function setWarning(message: string | null): void {
|
||||
generalWarning = message;
|
||||
showWarning();
|
||||
}
|
||||
|
||||
const heading = el('h1', { class: 'sr-only' }, 'Kartenmotiv gestalten');
|
||||
const page = el('div', { class: 'editor' }, templates.el, preview.el, options.el);
|
||||
root.replaceChildren(heading, page);
|
||||
@@ -136,12 +151,15 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
exporting = true;
|
||||
sync(store.get());
|
||||
preview.setError(null);
|
||||
preview.setWarning(null);
|
||||
setWarning(null);
|
||||
try {
|
||||
const assets = await loadCardAssets(s.config, s.image);
|
||||
// Die Logos kommen aus dem Netz: scheitert das, soll der Nutzer „Logos konnten nicht geladen werden“ lesen, nicht „Failed to fetch“.
|
||||
const assets = await loadCardAssets(s.config, s.image).catch(() => {
|
||||
throw new Error(LOGO_ERROR);
|
||||
});
|
||||
const blob = await exportCard(s.config, assets, { width: CARD_W, type: 'image/png' });
|
||||
saveBlobAs(blob, downloadFileName(s.cardName, new Date()));
|
||||
preview.setWarning(pngSizeWarning(blob.size));
|
||||
setWarning(pngSizeWarning(blob.size));
|
||||
} catch (error) {
|
||||
preview.setError(error instanceof Error && error.message ? error.message : EXPORT_ERROR);
|
||||
} finally {
|
||||
@@ -217,7 +235,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
const stale = () => disposed || token !== loadToken;
|
||||
const service = cardsService();
|
||||
preview.setError(null);
|
||||
preview.setWarning(null);
|
||||
setWarning(null);
|
||||
preview.setNotice(LOADING_CARD);
|
||||
try {
|
||||
// Appwrite beantwortet Gäste mit 404 statt 401: ohne Sitzung zuerst anmelden, statt „nicht gefunden“ zu melden.
|
||||
@@ -262,11 +280,11 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
savedConfig: config,
|
||||
cardName: card.name,
|
||||
});
|
||||
preview.setWarning(missingImage);
|
||||
setWarning(missingImage);
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
if (err instanceof LoginCancelled) {
|
||||
preview.setWarning(LOGIN_TO_OPEN);
|
||||
setWarning(LOGIN_TO_OPEN);
|
||||
} else if (errorType(err) === 'row_not_found') {
|
||||
// Die Karte gibt es nicht (für dieses Konto). Ein leerer Editor wird zurückgesetzt; hält er Arbeit, bleibt sie.
|
||||
const s = store.get();
|
||||
@@ -287,7 +305,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
const token = ++loadToken;
|
||||
const stale = () => disposed || token !== loadToken;
|
||||
preview.setError(null);
|
||||
preview.setWarning(null);
|
||||
setWarning(null);
|
||||
preview.setNotice(LOADING_TEMPLATE);
|
||||
try {
|
||||
// Eine ausgeblendete oder zurückgezogene Karte liefert null (Appwrite antwortet Fremden mit 404).
|
||||
@@ -363,12 +381,14 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
cancelCardLoad(); // eine noch ladende Karte soll nicht in den leeren Editor springen
|
||||
fileToken += 1; // … und ein noch dekodiertes Bild auch nicht
|
||||
preview.setError(null);
|
||||
preview.setWarning(null);
|
||||
setWarning(null);
|
||||
store.set(initialEditorState());
|
||||
dropCardParam();
|
||||
}
|
||||
|
||||
// Logos und Schrift: werden nur bei Wechsel von Netzwerk, Details oder Logo geladen (Logos sind zwischengespeichert).
|
||||
// Logos und Schrift: werden nur bei Wechsel von Community-Logo, Details oder Marke geladen (Logos sind zwischengespeichert).
|
||||
// Fehlt das gewählte Community-Logo, wird die Karte ohne es gezeichnet und der Hinweis gezeigt, solange das so bleibt; ein echter
|
||||
// Ladefehler (Netz, Server) zeigt dagegen LOGO_ERROR, und derselbe Schlüssel wird nicht noch einmal versucht.
|
||||
let logos: (Logos & { key: string }) | null = null;
|
||||
let pendingKey = '';
|
||||
let failedKey = '';
|
||||
@@ -382,7 +402,8 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
(assets) => {
|
||||
if (disposed || token !== logoToken) return;
|
||||
pendingKey = '';
|
||||
logos = { network: assets.network, brand: assets.brand, key };
|
||||
logos = { logo: assets.logo, brand: assets.brand, missing: assets.logoMissing, key };
|
||||
showWarning();
|
||||
schedule();
|
||||
},
|
||||
() => {
|
||||
@@ -406,9 +427,9 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
|
||||
function draw(): void {
|
||||
const s = store.get();
|
||||
// Mit Logos eines anderen Netzwerks würde das Seitenverhältnis nicht passen; lieber auf die neuen warten.
|
||||
// Mit den Logos einer anderen Auswahl würde das Seitenverhältnis nicht passen; lieber auf die neuen warten.
|
||||
if (!logos || logos.key !== logoKey(s.config)) return;
|
||||
const assets: CardAssets = { image: s.image, network: logos.network, brand: logos.brand };
|
||||
const assets: CardAssets = { image: s.image, logo: logos.logo, brand: logos.brand };
|
||||
try {
|
||||
if (s.image) drawCard(preview.canvas, s.config, assets);
|
||||
templates.draw(s.config, assets);
|
||||
@@ -428,7 +449,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
||||
// Der Größenhinweis gehört zum letzten Download; ein anderes Bild macht ihn hinfällig.
|
||||
if (s.image !== lastImage) {
|
||||
lastImage = s.image;
|
||||
preview.setWarning(null);
|
||||
setWarning(null);
|
||||
}
|
||||
sync(s);
|
||||
ensureLogos(s.config);
|
||||
|
||||
@@ -73,11 +73,12 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.panel-title + .panel-subtitle {
|
||||
.panel-title + .panel-subtitle,
|
||||
.editor-logo-slot:empty + .panel-subtitle {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Kacheln (Vorlagen, Netzwerke, Logos) */
|
||||
/* Kacheln (Vorlagen, Logos) */
|
||||
|
||||
.tile {
|
||||
appearance: none;
|
||||
@@ -154,7 +155,7 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Kartennetzwerk und Logo */
|
||||
/* Logo oben links */
|
||||
|
||||
.mark-grid {
|
||||
display: grid;
|
||||
@@ -503,7 +504,7 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Ab 1024 px: schmale Optionsspalte → zwei statt drei Netzwerk-Kacheln pro Reihe, damit Beschriftungen nicht mitten im Wort brechen */
|
||||
/* Ab 1024 px: schmale Optionsspalte → zwei statt drei Logo-Kacheln pro Reihe, damit Beschriftungen nicht mitten im Wort brechen */
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mark-grid {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG, type CardConfig } from '../card/config';
|
||||
import { setLang } from '../i18n/i18n';
|
||||
import { createOptionsPanel, type OptionsPanel } from './options-panel';
|
||||
|
||||
const handlers = { onDetails: vi.fn(), onBrand: vi.fn() };
|
||||
let panel: OptionsPanel;
|
||||
|
||||
const cfg = (patch: Partial<CardConfig> = {}): CardConfig => ({ ...DEFAULT_CONFIG, crop: { ...DEFAULT_CONFIG.crop }, ...patch });
|
||||
const headings = () => [...panel.el.querySelectorAll('h2, h3')].map((h) => h.textContent);
|
||||
const tiles = () => [...panel.el.querySelectorAll<HTMLButtonElement>('.mark-tile')];
|
||||
const tile = (label: string) => tiles().find((b) => b.getAttribute('aria-label') === label)!;
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.onDetails.mockReset();
|
||||
handlers.onBrand.mockReset();
|
||||
panel = createOptionsPanel(handlers);
|
||||
});
|
||||
|
||||
describe('Optionen: Logo oben links', () => {
|
||||
it('keine Netzwerk-Kacheln mehr: nur drei Kacheln (Symbol, Text, Kein Logo), keine Überschrift „Kartennetzwerk“', () => {
|
||||
expect(headings()).not.toContain('Kartennetzwerk');
|
||||
expect(tiles().map((b) => b.getAttribute('aria-label'))).toEqual([
|
||||
'Logo – AirCard-IOS · Symbol',
|
||||
'Logo – AirCard-IOS · Text',
|
||||
'Kein Logo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('kein Apple und keine fremden Marken in den Kacheln', () => {
|
||||
const text = panel.el.textContent! + tiles().map((b) => b.getAttribute('title')).join(' ');
|
||||
expect(text).not.toMatch(/apple|visa|mastercard|unionpay|american express|jcb|discover/i);
|
||||
});
|
||||
|
||||
it('die Überschrift lautet „Logo oben links“ und beschriftet die Kachelgruppe', () => {
|
||||
const heading = [...panel.el.querySelectorAll('h3')].find((h) => h.textContent === 'Logo oben links')!;
|
||||
expect(heading).toBeDefined();
|
||||
const group = panel.el.querySelector(`[aria-labelledby="${heading.id}"]`)!;
|
||||
expect(group.getAttribute('role')).toBe('group');
|
||||
expect([...group.querySelectorAll('.mark-tile')]).toEqual(tiles());
|
||||
});
|
||||
|
||||
it('Reihenfolge der Überschriften: Individuell gestalten, Details, Logo oben links', () => {
|
||||
expect(headings()).toEqual(['Individuell gestalten', 'Details', 'Logo oben links']);
|
||||
});
|
||||
|
||||
it('die Überschrift „Logo oben links“ wird übersetzt', () => {
|
||||
setLang('en', { persist: false });
|
||||
panel = createOptionsPanel(handlers);
|
||||
expect(headings()).toContain('Top-left logo');
|
||||
expect(headings()).not.toContain('Logo oben links');
|
||||
});
|
||||
|
||||
it('Klick auf eine Kachel meldet die Marke (oder none)', () => {
|
||||
tile('Logo – AirCard-IOS · Symbol').click();
|
||||
tile('Logo – AirCard-IOS · Text').click();
|
||||
tile('Kein Logo').click();
|
||||
expect(handlers.onBrand.mock.calls).toEqual([['aircards-symbol'], ['aircards-text'], ['none']]);
|
||||
});
|
||||
|
||||
it('select markiert die gewählte Marke mit aria-pressed', () => {
|
||||
const pressed = () => tiles().filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.getAttribute('aria-label'));
|
||||
panel.select(cfg({ brand: 'aircards-text' }));
|
||||
expect(pressed()).toEqual(['Logo – AirCard-IOS · Text']);
|
||||
panel.select(cfg({ brand: 'none' }));
|
||||
expect(pressed()).toEqual(['Kein Logo']);
|
||||
panel.select(cfg({ brand: 'aircards-symbol' }));
|
||||
expect(pressed()).toEqual(['Logo – AirCard-IOS · Symbol']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Optionen: Details und Platz für das Community-Logo', () => {
|
||||
it('Details Hell/Dunkel: Auswahl und Meldung', () => {
|
||||
const inputs = [...panel.el.querySelectorAll<HTMLInputElement>('input[name="card-details"]')];
|
||||
expect(inputs.map((i) => i.value)).toEqual(['light', 'dark']);
|
||||
panel.select(cfg({ details: 'dark' }));
|
||||
expect(inputs.map((i) => i.checked)).toEqual([false, true]);
|
||||
inputs[0].checked = true;
|
||||
inputs[0].dispatchEvent(new Event('change'));
|
||||
expect(handlers.onDetails).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('logoSlot: ein leerer Platz ganz oben, direkt unter „Individuell gestalten“, vor den Details', () => {
|
||||
expect(panel.logoSlot).toBeInstanceOf(HTMLElement);
|
||||
expect(panel.logoSlot.childElementCount).toBe(0);
|
||||
expect(panel.el.contains(panel.logoSlot)).toBe(true);
|
||||
const children = [...panel.el.children];
|
||||
const slot = children.indexOf(panel.logoSlot);
|
||||
const details = children.findIndex((c) => c.textContent === 'Details');
|
||||
expect(children[0].textContent).toBe('Individuell gestalten');
|
||||
expect(slot).toBe(1);
|
||||
expect(slot).toBeLessThan(details);
|
||||
});
|
||||
|
||||
it('der Platz bleibt beim Auswählen unberührt (Task 8 füllt ihn)', () => {
|
||||
const before = panel.logoSlot;
|
||||
panel.select(cfg({ logo: 'abc123' }));
|
||||
expect(panel.logoSlot).toBe(before);
|
||||
expect(panel.logoSlot.childElementCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
// Rechte Spalte „Individuell gestalten“: Kartennetzwerk, Details (Hell/Dunkel) und Logo.
|
||||
import type { BrandId, CardConfig, DetailsTone, NetworkId } from '../card/config';
|
||||
import { BRAND_MARKS, NETWORK_MARKS, tintSvg, type MarkDef } from '../card/marks';
|
||||
// Rechte Spalte „Individuell gestalten“: oben der Platz für das Community-Logo (unten rechts auf der Karte),
|
||||
// dann Details (Hell/Dunkel) und das Logo oben links.
|
||||
import type { BrandId, CardConfig, DetailsTone } from '../card/config';
|
||||
import { BRAND_MARKS, tintSvg, type MarkDef } from '../card/marks';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { el, icon } from './dom';
|
||||
|
||||
export interface OptionsHandlers {
|
||||
onNetwork(id: NetworkId): void;
|
||||
onDetails(tone: DetailsTone): void;
|
||||
onBrand(id: BrandId): void;
|
||||
}
|
||||
@@ -12,6 +13,8 @@ export interface OptionsHandlers {
|
||||
export interface OptionsPanel {
|
||||
el: HTMLElement;
|
||||
select(config: CardConfig): void;
|
||||
/** Leerer Platz oben in der Spalte, für den Bereich „Logo unten rechts“ (der Logo-Auswahl). */
|
||||
logoSlot: HTMLElement;
|
||||
}
|
||||
|
||||
const TONES: readonly { id: DetailsTone; label: string }[] = [
|
||||
@@ -41,11 +44,6 @@ function heading(id: string, text: string, level: 'h2' | 'h3') {
|
||||
}
|
||||
|
||||
export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel {
|
||||
const networkTiles = NETWORK_MARKS.map((def) => ({
|
||||
id: def.id as NetworkId,
|
||||
button: markTile(def, () => handlers.onNetwork(def.id as NetworkId)),
|
||||
}));
|
||||
|
||||
const brandTiles: { id: BrandId; button: HTMLButtonElement }[] = BRAND_MARKS.map((def) => ({
|
||||
id: def.id as BrandId,
|
||||
button: markTile(def, () => handlers.onBrand(def.id as BrandId)),
|
||||
@@ -59,38 +57,39 @@ export function createOptionsPanel(handlers: OptionsHandlers): OptionsPanel {
|
||||
none.addEventListener('click', () => handlers.onBrand('none'));
|
||||
brandTiles.push({ id: 'none', button: none });
|
||||
|
||||
const toneInputs = TONES.map((t) => {
|
||||
const input = el('input', { type: 'radio', name: 'card-details', value: t.id, class: 'pill-input' });
|
||||
const toneInputs = TONES.map((tone) => {
|
||||
const input = el('input', { type: 'radio', name: 'card-details', value: tone.id, class: 'pill-input' });
|
||||
input.addEventListener('change', () => {
|
||||
if (input.checked) handlers.onDetails(t.id);
|
||||
if (input.checked) handlers.onDetails(tone.id);
|
||||
});
|
||||
const label = el(
|
||||
'label',
|
||||
{ class: 'pill' },
|
||||
input,
|
||||
el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${t.id}` }), t.label),
|
||||
el('span', { class: 'pill-face' }, el('span', { class: `swatch swatch-${tone.id}` }), tone.label),
|
||||
);
|
||||
return { id: t.id, input, label };
|
||||
return { id: tone.id, input, label };
|
||||
});
|
||||
|
||||
const logoSlot = el('div', { class: 'editor-logo-slot' });
|
||||
|
||||
const section = el(
|
||||
'section',
|
||||
{ class: 'editor-side editor-options' },
|
||||
heading('editor-options-heading', 'Individuell gestalten', 'h2'),
|
||||
heading('editor-network-heading', 'Kartennetzwerk', 'h3'),
|
||||
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-network-heading' }, ...networkTiles.map((t) => t.button)),
|
||||
logoSlot,
|
||||
heading('editor-details-heading', 'Details', 'h3'),
|
||||
el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((t) => t.label)),
|
||||
heading('editor-brand-heading', 'Logo', 'h3'),
|
||||
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((t) => t.button)),
|
||||
el('div', { class: 'pill-row', role: 'radiogroup', 'aria-labelledby': 'editor-details-heading' }, ...toneInputs.map((input) => input.label)),
|
||||
heading('editor-brand-heading', t('Logo oben links'), 'h3'),
|
||||
el('div', { class: 'mark-grid', role: 'group', 'aria-labelledby': 'editor-brand-heading' }, ...brandTiles.map((tile) => tile.button)),
|
||||
);
|
||||
|
||||
return {
|
||||
el: section,
|
||||
logoSlot,
|
||||
select(config) {
|
||||
for (const t of networkTiles) t.button.setAttribute('aria-pressed', String(t.id === config.network));
|
||||
for (const t of brandTiles) t.button.setAttribute('aria-pressed', String(t.id === config.brand));
|
||||
for (const t of toneInputs) t.input.checked = t.id === config.details;
|
||||
for (const tile of brandTiles) tile.button.setAttribute('aria-pressed', String(tile.id === config.brand));
|
||||
for (const tone of toneInputs) tone.input.checked = tone.id === config.details;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,12 @@ export const EN: Readonly<Record<string, string>> = {
|
||||
'Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.':
|
||||
'Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.',
|
||||
|
||||
// Logo auf der Karte (logos/logo-asset.ts, community/publish.ts, editor/options-panel.ts)
|
||||
'Das gewählte Logo ist nicht mehr verfügbar.': 'The selected logo is no longer available.',
|
||||
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.':
|
||||
'This card’s logo is no longer available. Open the card in the editor and choose another one.',
|
||||
'Logo oben links': 'Top-left logo',
|
||||
|
||||
// API-Fehlertexte (i18n/api-messages.ts). Schlüssel und deutsche Texte stammen aus MSG (deploy/api/common.js); api-messages.test.ts hält sie gleich.
|
||||
'Bitte melde dich an.': 'Please sign in.',
|
||||
'Nicht gefunden.': 'Not found.',
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CommunityLogo } from './logos';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getLogo: vi.fn(), loadLogoBlob: vi.fn(), createImageBitmap: vi.fn() }));
|
||||
|
||||
// Zeile und Datei kommen über getLogo/loadLogoBlob; Appwrite, Bilddekodierung und Canvas sind hier ersetzt.
|
||||
vi.mock('./logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
|
||||
|
||||
/** Eine frische Kopie des Moduls, damit der Zwischenspeicher je Test leer beginnt. */
|
||||
async function fresh() {
|
||||
vi.resetModules();
|
||||
return import('./logo-asset');
|
||||
}
|
||||
|
||||
const logo = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
|
||||
id: 'l1',
|
||||
name: 'Mein Logo',
|
||||
displayName: 'Kenso',
|
||||
fileId: 'l1',
|
||||
ownerId: 'u1',
|
||||
mono: false,
|
||||
likeCount: 0,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
createdAt: '2026-10-04T10:00:00.000Z',
|
||||
...patch,
|
||||
});
|
||||
|
||||
const bitmap = (w: number, h: number) => ({ tag: 'bitmap', width: w, height: h }) as unknown as ImageBitmap;
|
||||
const file = () => new Blob(['png'], { type: 'image/png' });
|
||||
const fileGone = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
|
||||
|
||||
interface CanvasCall {
|
||||
name: string;
|
||||
args: unknown[];
|
||||
/** Stand von `globalCompositeOperation` und `fillStyle` im Moment des Aufrufs. */
|
||||
globalCompositeOperation: unknown;
|
||||
fillStyle: unknown;
|
||||
}
|
||||
|
||||
interface FakeCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
calls: CanvasCall[];
|
||||
getContext: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
const canvases: FakeCanvas[] = [];
|
||||
|
||||
/** `document.createElement('canvas')` liefert aufzeichnende Fakes (happy-dom hat keinen 2D-Kontext). */
|
||||
function stubCanvas(options: { noContext?: boolean } = {}): void {
|
||||
vi.spyOn(document, 'createElement').mockImplementation(((tag: string) => {
|
||||
if (tag !== 'canvas') throw new Error(`unerwartetes Element: ${tag}`);
|
||||
const ctx: Record<string, unknown> = { globalCompositeOperation: 'source-over', fillStyle: '#000000' };
|
||||
const canvas: FakeCanvas = { width: 0, height: 0, calls: [], getContext: vi.fn(() => (options.noContext ? null : ctx)) };
|
||||
const record =
|
||||
(name: string) =>
|
||||
(...args: unknown[]) =>
|
||||
void canvas.calls.push({ name, args, globalCompositeOperation: ctx.globalCompositeOperation, fillStyle: ctx.fillStyle });
|
||||
ctx.drawImage = record('drawImage');
|
||||
ctx.fillRect = record('fillRect');
|
||||
canvases.push(canvas);
|
||||
return canvas as unknown as HTMLElement;
|
||||
}) as typeof document.createElement);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
mocks.getLogo.mockResolvedValue(logo());
|
||||
mocks.loadLogoBlob.mockResolvedValue(file());
|
||||
mocks.createImageBitmap.mockResolvedValue(bitmap(200, 100));
|
||||
vi.stubGlobal('createImageBitmap', mocks.createImageBitmap);
|
||||
canvases.length = 0;
|
||||
stubCanvas();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('LOGO_MISSING', () => {
|
||||
it('der Hinweis aus der Spec, auf Englisch übersetzt', async () => {
|
||||
const { LOGO_MISSING } = await fresh();
|
||||
// Nach resetModules ist auch i18n frisch (die Sprache des Setups gilt für die alte Kopie): die Sprache hier selbst setzen.
|
||||
const { t, setLang } = await import('../i18n/i18n');
|
||||
setLang('de', { persist: false });
|
||||
expect(LOGO_MISSING).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||
expect(t(LOGO_MISSING)).toBe('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||
setLang('en', { persist: false });
|
||||
expect(t(LOGO_MISSING)).toBe('The selected logo is no longer available.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLogoAsset: Laden', () => {
|
||||
it('mehrfarbiges Logo: das dekodierte Bild in Originalgröße, ohne Canvas', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const blob = file();
|
||||
const decoded = bitmap(200, 100);
|
||||
mocks.loadLogoBlob.mockResolvedValue(blob);
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
const asset = await loadLogoAsset('l1', 'light');
|
||||
expect(asset?.source).toBe(decoded);
|
||||
expect([asset?.w, asset?.h]).toEqual([200, 100]);
|
||||
expect(mocks.getLogo).toHaveBeenCalledWith('l1');
|
||||
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'l1', fileId: 'l1' }));
|
||||
expect(mocks.createImageBitmap).toHaveBeenCalledWith(blob);
|
||||
expect(canvases).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fehlende Zeile (gelöscht oder ausgeblendet für mich): null, die Datei wird nicht geladen', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(null);
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ausgeblendetes Logo (Hochlader und Admins können die Zeile lesen): trotzdem null, die Datei wird nicht geladen', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ hidden: true }));
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Datei fehlt (404): null', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.loadLogoBlob.mockRejectedValue(fileGone());
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
mocks.loadLogoBlob.mockRejectedValue(Object.assign(new Error('weg'), { code: 404 }));
|
||||
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('ein echter Ladefehler (Netz, 5xx) ist kein fehlendes Logo: er wird weitergereicht', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const failure = Object.assign(new Error('Bad gateway'), { code: 502, type: 'general_server_error' });
|
||||
mocks.loadLogoBlob.mockRejectedValue(failure);
|
||||
await expect(loadLogoAsset('l1', 'light')).rejects.toBe(failure);
|
||||
const offline = new TypeError('Failed to fetch');
|
||||
mocks.getLogo.mockRejectedValue(offline);
|
||||
await expect(loadLogoAsset('l2', 'light')).rejects.toBe(offline);
|
||||
});
|
||||
|
||||
it('nicht lesbare Datei: null (das Logo ist nicht verfügbar)', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.createImageBitmap.mockRejectedValue(new DOMException('kaputt', 'InvalidStateError'));
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
mocks.createImageBitmap.mockResolvedValue(bitmap(0, 0));
|
||||
await expect(loadLogoAsset('l2', 'light')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('mehr als 1 000 000 Pixel (nur eine veränderte Datei): null, genau 1 000 000 und die größte erlaubte Größe: ein Bild', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.createImageBitmap.mockResolvedValue(bitmap(1001, 1000));
|
||||
await expect(loadLogoAsset('big', 'light')).resolves.toBeNull();
|
||||
mocks.createImageBitmap.mockResolvedValue(bitmap(1000, 1000));
|
||||
await expect(loadLogoAsset('limit', 'light')).resolves.toMatchObject({ w: 1000, h: 1000 });
|
||||
mocks.createImageBitmap.mockResolvedValue(bitmap(720, 250));
|
||||
await expect(loadLogoAsset('max', 'light')).resolves.toMatchObject({ w: 720, h: 250 });
|
||||
});
|
||||
|
||||
it('zu großes einfarbiges Logo: null, es wird kein Canvas dafür angelegt', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
mocks.createImageBitmap.mockResolvedValue(bitmap(30000, 30000));
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
expect(canvases).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLogoAsset: einfarbige Logos', () => {
|
||||
it.each([
|
||||
['light', '#FFFFFF'],
|
||||
['dark', '#111111'],
|
||||
] as const)('Details %s: Canvas in Bildgröße, Bild zeichnen, dann source-in mit %s füllen', async (tone, color) => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const decoded = bitmap(200, 100);
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
|
||||
const asset = await loadLogoAsset('l1', tone);
|
||||
|
||||
expect(canvases).toHaveLength(1);
|
||||
const canvas = canvases[0];
|
||||
expect(asset?.source).toBe(canvas);
|
||||
expect([asset?.w, asset?.h]).toEqual([200, 100]);
|
||||
expect([canvas.width, canvas.height]).toEqual([200, 100]);
|
||||
expect(canvas.getContext).toHaveBeenCalledWith('2d');
|
||||
expect(canvas.calls.map((c) => c.name)).toEqual(['drawImage', 'fillRect']);
|
||||
// erst das Bild (die Form kommt aus dem Alphakanal) …
|
||||
expect(canvas.calls[0].args[0]).toBe(decoded);
|
||||
expect(canvas.calls[0].globalCompositeOperation).toBe('source-over');
|
||||
// … dann die Farbe nur dort, wo das Bild deckt
|
||||
expect(canvas.calls[1]).toMatchObject({ args: [0, 0, 200, 100], globalCompositeOperation: 'source-in', fillStyle: color });
|
||||
});
|
||||
|
||||
it('mehrfarbige Logos bleiben unverändert (kein Canvas, beide Tonarten dasselbe Bild)', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const decoded = bitmap(200, 100);
|
||||
mocks.createImageBitmap.mockResolvedValue(decoded);
|
||||
expect((await loadLogoAsset('l1', 'light'))?.source).toBe(decoded);
|
||||
expect((await loadLogoAsset('l1', 'dark'))?.source).toBe(decoded);
|
||||
expect(canvases).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ohne 2D-Kontext wirft es (das ist kein fehlendes Logo)', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
stubCanvas({ noContext: true });
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow(/Canvas/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLogoAsset: Zwischenspeicher', () => {
|
||||
it('gleiche (id, tone) lädt nur einmal, auch bei gleichzeitigen Aufrufen und später', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const a = loadLogoAsset('l1', 'light');
|
||||
const b = loadLogoAsset('l1', 'light');
|
||||
expect(b).toBe(a);
|
||||
const first = await a;
|
||||
expect(await loadLogoAsset('l1', 'light')).toBe(first);
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createImageBitmap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('eine andere Tonart ist ein eigenes Bild, die Zeile wird aber nur einmal geholt', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
const light = await loadLogoAsset('l1', 'light');
|
||||
const dark = await loadLogoAsset('l1', 'dark');
|
||||
expect(dark).not.toBe(light);
|
||||
expect(canvases).toHaveLength(2);
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ein fehlendes Logo (null) bleibt ebenfalls zwischengespeichert', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(null);
|
||||
await loadLogoAsset('l1', 'light');
|
||||
await loadLogoAsset('l1', 'light');
|
||||
await loadLogoAsset('l1', 'dark');
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forgetLogo: danach wird Zeile und Bild neu geladen', async () => {
|
||||
const { loadLogoAsset, forgetLogo } = await fresh();
|
||||
const first = await loadLogoAsset('l1', 'light');
|
||||
await loadLogoAsset('l1', 'dark');
|
||||
forgetLogo('l1');
|
||||
mocks.getLogo.mockResolvedValue(null); // inzwischen gelöscht
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
|
||||
expect(first).not.toBeNull();
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('forgetLogo vergisst nur dieses Logo', async () => {
|
||||
const { loadLogoAsset, forgetLogo } = await fresh();
|
||||
mocks.getLogo.mockImplementation(async (id: string) => logo({ id, fileId: id }));
|
||||
const other = await loadLogoAsset('l2', 'light');
|
||||
await loadLogoAsset('l1', 'light');
|
||||
forgetLogo('l1');
|
||||
expect(await loadLogoAsset('l2', 'light')).toBe(other);
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||||
// l1 und l1.x sind verschiedene Logos: das Präfix darf nicht auch l1.x erwischen
|
||||
mocks.getLogo.mockClear();
|
||||
const dotted = await loadLogoAsset('l1.x', 'light');
|
||||
forgetLogo('l1');
|
||||
expect(await loadLogoAsset('l1.x', 'light')).toBe(dotted);
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ein fehlgeschlagener Ladevorgang bleibt nicht im Cache (Zeile und Bild)', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
await expect(loadLogoAsset('l1', 'light')).rejects.toThrow('Failed to fetch');
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||||
|
||||
mocks.loadLogoBlob.mockRejectedValueOnce(Object.assign(new Error('Bad gateway'), { code: 502 }));
|
||||
await expect(loadLogoAsset('l2', 'light')).rejects.toThrow('Bad gateway');
|
||||
await expect(loadLogoAsset('l2', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
|
||||
});
|
||||
|
||||
it('ein Fehler nach forgetLogo wirft den neuen Eintrag nicht aus dem Cache', async () => {
|
||||
const { loadLogoAsset, forgetLogo } = await fresh();
|
||||
let fail!: (e: Error) => void;
|
||||
mocks.getLogo.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)));
|
||||
const stale = loadLogoAsset('l1', 'light');
|
||||
stale.catch(() => undefined);
|
||||
forgetLogo('l1');
|
||||
const current = await loadLogoAsset('l1', 'light');
|
||||
fail(new Error('spät'));
|
||||
await expect(stale).rejects.toThrow('spät');
|
||||
expect(await loadLogoAsset('l1', 'light')).toBe(current);
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rememberLogo legt die Zeile in den Cache: getLogo wird nicht gebraucht', async () => {
|
||||
const { loadLogoAsset, rememberLogo } = await fresh();
|
||||
rememberLogo(logo({ id: 'neu', fileId: 'neu' }));
|
||||
await expect(loadLogoAsset('neu', 'light')).resolves.toMatchObject({ w: 200, h: 100 });
|
||||
expect(mocks.getLogo).not.toHaveBeenCalled();
|
||||
expect(mocks.loadLogoBlob).toHaveBeenCalledWith(expect.objectContaining({ id: 'neu', fileId: 'neu' }));
|
||||
});
|
||||
|
||||
it('rememberLogo mit einem ausgeblendeten Logo: null', async () => {
|
||||
const { loadLogoAsset, rememberLogo } = await fresh();
|
||||
rememberLogo(logo({ hidden: true }));
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
expect(mocks.loadLogoBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rememberLogo mit einem jetzt ausgeblendeten Logo verwirft schon geladene Bilder (ein gesperrtes Logo wird nicht mehr gezeichnet)', async () => {
|
||||
const { loadLogoAsset, rememberLogo } = await fresh();
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.not.toBeNull();
|
||||
await expect(loadLogoAsset('l1', 'dark')).resolves.not.toBeNull();
|
||||
rememberLogo(logo({ hidden: true }));
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
await expect(loadLogoAsset('l1', 'dark')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rememberLogo mit einem sichtbaren Logo lässt geladene Bilder stehen', async () => {
|
||||
const { loadLogoAsset, rememberLogo } = await fresh();
|
||||
const first = await loadLogoAsset('l1', 'light');
|
||||
rememberLogo(logo({ likeCount: 7 }));
|
||||
expect(await loadLogoAsset('l1', 'light')).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadLogoAsset: ohne createImageBitmap', () => {
|
||||
class FakeImage {
|
||||
static mode: 'load' | 'error' = 'load';
|
||||
static size = { w: 200, h: 100 };
|
||||
naturalWidth = 0;
|
||||
naturalHeight = 0;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#src = '';
|
||||
get src() {
|
||||
return this.#src;
|
||||
}
|
||||
set src(value: string) {
|
||||
this.#src = value;
|
||||
this.naturalWidth = FakeImage.size.w;
|
||||
this.naturalHeight = FakeImage.size.h;
|
||||
queueMicrotask(() => (FakeImage.mode === 'load' ? this.onload?.() : this.onerror?.()));
|
||||
}
|
||||
}
|
||||
|
||||
const created: string[] = [];
|
||||
const revoked: string[] = [];
|
||||
const realCreate = URL.createObjectURL;
|
||||
const realRevoke = URL.revokeObjectURL;
|
||||
|
||||
beforeEach(() => {
|
||||
FakeImage.mode = 'load';
|
||||
FakeImage.size = { w: 200, h: 100 };
|
||||
created.length = 0;
|
||||
revoked.length = 0;
|
||||
URL.createObjectURL = vi.fn(() => {
|
||||
created.push(`blob:fake/${created.length + 1}`);
|
||||
return created[created.length - 1];
|
||||
});
|
||||
URL.revokeObjectURL = vi.fn((url: string) => void revoked.push(url));
|
||||
vi.stubGlobal('createImageBitmap', undefined);
|
||||
vi.stubGlobal('Image', FakeImage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = realCreate;
|
||||
URL.revokeObjectURL = realRevoke;
|
||||
});
|
||||
|
||||
it('lädt das Logo über ein Image aus einer Object-URL und gibt die URL danach frei', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
const asset = await loadLogoAsset('l1', 'light');
|
||||
expect(asset?.source).toBeInstanceOf(FakeImage);
|
||||
expect([asset?.w, asset?.h]).toEqual([200, 100]);
|
||||
expect(mocks.createImageBitmap).not.toHaveBeenCalled();
|
||||
expect(created).toEqual(['blob:fake/1']);
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
});
|
||||
|
||||
it('Ladefehler des Bildes: null, die URL ist trotzdem freigegeben', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
FakeImage.mode = 'error';
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
});
|
||||
|
||||
it('auch hier gilt die Pixelgrenze', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
FakeImage.size = { w: 2000, h: 1000 };
|
||||
await expect(loadLogoAsset('l1', 'light')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('einfarbig: das Image wird in den Canvas gezeichnet und gefüllt', async () => {
|
||||
const { loadLogoAsset } = await fresh();
|
||||
mocks.getLogo.mockResolvedValue(logo({ mono: true }));
|
||||
const asset = await loadLogoAsset('l1', 'dark');
|
||||
expect(asset?.source).toBe(canvases[0]);
|
||||
expect(canvases[0].calls[0].args[0]).toBeInstanceOf(FakeImage);
|
||||
expect(canvases[0].calls[1]).toMatchObject({ globalCompositeOperation: 'source-in', fillStyle: '#111111' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// Community-Logos als Bild für den Kartenrenderer: Zeile und PNG laden, dekodieren, einfarbige Logos in der Details-Farbe füllen.
|
||||
// Zeilen und fertige Bilder bleiben im Speicher (je Logo und Details-Farbe nur ein Ladevorgang). Ein Logo, das es nicht (mehr)
|
||||
// gibt, ergibt null; ein echter Ladefehler (Netz, Server) wirft und bleibt nicht im Zwischenspeicher.
|
||||
import { TONE_COLOR, type DetailsTone } from '../card/config';
|
||||
// Nur der Typ: render.ts importiert dieses Modul (Standard-Lader), zur Laufzeit darf kein Kreis entstehen.
|
||||
import type { LogoAsset } from '../card/render';
|
||||
import { msg } from '../i18n/i18n';
|
||||
import { getLogo, loadLogoBlob, type CommunityLogo } from './logos';
|
||||
|
||||
/** Hinweis im Editor, wenn die Karte ein Logo wählt, das gelöscht oder ausgeblendet wurde. */
|
||||
export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||
|
||||
/**
|
||||
* Obergrenze für Breite × Höhe eines geladenen Logos. Der Server nimmt höchstens 720 × 250 an (rund 180 000 Pixel); alles
|
||||
* Größere kann nur eine veränderte Datei sein. Es gilt als nicht verfügbar und wird weder in einen Canvas noch auf die Karte gezeichnet.
|
||||
*/
|
||||
const MAX_LOGO_PIXELS = 1_000_000;
|
||||
|
||||
type Decoded = LogoAsset;
|
||||
|
||||
// Zeilen (auch „gibt es nicht“ = null) und fertige Bilder; die Schlüssel der Bilder sind „<id>:<tone>“ (IDs enthalten kein „:“).
|
||||
const rows = new Map<string, Promise<CommunityLogo | null>>();
|
||||
const assets = new Map<string, Promise<LogoAsset | null>>();
|
||||
|
||||
/** Verwirft die fertigen Bilder eines Logos (beide Details-Farben). */
|
||||
function dropImages(id: string): void {
|
||||
for (const key of assets.keys()) if (key.startsWith(`${id}:`)) assets.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Zeile, die der Aufrufer schon hat (Logo-Liste, frisch hochgeladenes Logo), in den Zwischenspeicher: spart `getLogo`.
|
||||
* Ist sie ausgeblendet, fallen schon geladene Bilder des Logos weg: ein gesperrtes Logo darf nicht mehr gezeichnet werden.
|
||||
*/
|
||||
export function rememberLogo(logo: CommunityLogo): void {
|
||||
rows.set(logo.id, Promise.resolve(logo));
|
||||
if (logo.hidden) dropImages(logo.id);
|
||||
}
|
||||
|
||||
/** Vergisst Zeile und Bilder eines Logos (nach Löschen oder Ausblenden); der nächste Zugriff lädt neu. */
|
||||
export function forgetLogo(id: string): void {
|
||||
rows.delete(id);
|
||||
dropImages(id);
|
||||
}
|
||||
|
||||
/** Die Zeile eines Logos, einmal je ID; ein Fehler wird nicht festgehalten. */
|
||||
function rowFor(id: string): Promise<CommunityLogo | null> {
|
||||
const cached = rows.get(id);
|
||||
if (cached) return cached;
|
||||
const request = getLogo(id);
|
||||
// Nur entfernen, was noch derselbe Eintrag ist: forgetLogo/rememberLogo können ihn inzwischen ersetzt haben.
|
||||
request.catch(() => {
|
||||
if (rows.get(id) === request) rows.delete(id);
|
||||
});
|
||||
rows.set(id, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Die Datei gibt es nicht (mehr): Appwrite antwortet mit 404 / `storage_file_not_found`. */
|
||||
function isFileMissing(err: unknown): boolean {
|
||||
if (typeof err !== 'object' || err === null) return false;
|
||||
const { code, type } = err as { code?: unknown; type?: unknown };
|
||||
return code === 404 || type === 'storage_file_not_found';
|
||||
}
|
||||
|
||||
/** Ersatz ohne `createImageBitmap`: ein `Image` aus einer Object-URL (die URL ist nach dem Laden wieder frei). */
|
||||
function decodeWithImage(blob: Blob): Promise<Decoded> {
|
||||
const url = URL.createObjectURL(blob);
|
||||
return new Promise<Decoded>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ source: img, w: img.naturalWidth, h: img.naturalHeight });
|
||||
img.onerror = () => reject(new Error('Das Logo konnte nicht gelesen werden.'));
|
||||
img.src = url;
|
||||
}).finally(() => URL.revokeObjectURL(url));
|
||||
}
|
||||
|
||||
/** Dekodiert die PNG-Datei; null, wenn sie sich nicht lesen lässt oder keine Fläche hat. */
|
||||
async function decode(blob: Blob): Promise<Decoded | null> {
|
||||
try {
|
||||
let decoded: Decoded;
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
decoded = { source: bitmap, w: bitmap.width, h: bitmap.height };
|
||||
} else {
|
||||
decoded = await decodeWithImage(blob);
|
||||
}
|
||||
return decoded.w > 0 && decoded.h > 0 ? decoded : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Canvas in Bildgröße, in dem nur noch die Form des Bildes (sein Alphakanal) steht, gefüllt mit `color`. */
|
||||
function fillShape({ source, w, h }: Decoded, color: string): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
|
||||
ctx.drawImage(source, 0, 0);
|
||||
// „source-in“: die Farbe bleibt nur dort, wo das Bild schon deckt.
|
||||
ctx.globalCompositeOperation = 'source-in';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function build(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
|
||||
const logo = await rowFor(id);
|
||||
// Ausgeblendete Logos können Hochlader und Admins noch lesen. Ein gesperrtes Logo darf aber nie in einer Karte stehen,
|
||||
// die sich veröffentlichen lässt: es gilt als nicht verfügbar.
|
||||
if (!logo || logo.hidden) return null;
|
||||
|
||||
let blob: Blob;
|
||||
try {
|
||||
blob = await loadLogoBlob(logo);
|
||||
} catch (err) {
|
||||
if (isFileMissing(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const decoded = await decode(blob);
|
||||
if (!decoded || decoded.w * decoded.h > MAX_LOGO_PIXELS) return null;
|
||||
if (!logo.mono) return decoded;
|
||||
return { source: fillShape(decoded, TONE_COLOR[tone]), w: decoded.w, h: decoded.h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Community-Logo `id` als Bild für den Renderer. Einfarbige Logos (`mono`) haben die Farbe der Details (Hell/Dunkel),
|
||||
* mehrfarbige bleiben unverändert. null: das Logo gibt es nicht (mehr), ist ausgeblendet, oder seine Datei ist unbrauchbar.
|
||||
* Wirft bei einem echten Ladefehler; der bleibt nicht im Zwischenspeicher, ein späterer Aufruf versucht es neu.
|
||||
*/
|
||||
export function loadLogoAsset(id: string, tone: DetailsTone): Promise<LogoAsset | null> {
|
||||
const key = `${id}:${tone}`;
|
||||
const cached = assets.get(key);
|
||||
if (cached) return cached;
|
||||
const request = build(id, tone);
|
||||
request.catch(() => {
|
||||
if (assets.get(key) === request) assets.delete(key);
|
||||
});
|
||||
assets.set(key, request);
|
||||
return request;
|
||||
}
|
||||
@@ -261,7 +261,7 @@ describe('Meine Karten: Öffnen und Herunterladen', () => {
|
||||
mocks.list.mockResolvedValue([card('a', 'Mein Urlaub', { config: { ...DEFAULT_CONFIG, crop: { x: 99999, y: 0, zoom: 1 } } })]);
|
||||
mocks.loadImage.mockResolvedValue(new Blob(['i'], { type: 'image/jpeg' }));
|
||||
mocks.decodeImage.mockResolvedValue(image);
|
||||
mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null });
|
||||
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false });
|
||||
mocks.exportCard.mockResolvedValue(png);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
@@ -327,7 +327,7 @@ describe('Meine Karten: Sitzung weg, Appwrite antwortet mit 404 statt 401', () =
|
||||
mocks.list.mockResolvedValue([card('a', 'Alpha')]);
|
||||
mocks.loadImage.mockRejectedValueOnce(fileGone()).mockResolvedValueOnce(new Blob(['i'], { type: 'image/jpeg' }));
|
||||
mocks.decodeImage.mockResolvedValue(image);
|
||||
mocks.loadCardAssets.mockResolvedValue({ image, network: null, brand: null });
|
||||
mocks.loadCardAssets.mockResolvedValue({ image, logo: null, brand: null, logoMissing: false });
|
||||
mocks.exportCard.mockResolvedValue(png);
|
||||
mount();
|
||||
await vi.waitFor(() => expect(tiles()).toHaveLength(1));
|
||||
|
||||
@@ -141,7 +141,11 @@ describe('Theme und Kartenausgabe', () => {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const assets: CardAssets = { image: null, network: {} as CanvasImageSource, brand: {} as CanvasImageSource };
|
||||
const assets: CardAssets = {
|
||||
image: null,
|
||||
logo: { source: {} as CanvasImageSource, w: 200, h: 100 },
|
||||
brand: {} as CanvasImageSource,
|
||||
};
|
||||
renderCard(ctx as unknown as Ctx, { ...DEFAULT_CONFIG, template: 'classic' }, assets, () => log.push('strokePath'));
|
||||
return log;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ describe('Einzelne Quellen', () => {
|
||||
expect(definedIn(/\b(?:const|let|var)\s+CARD_H\b/)).toEqual(['card/geometry.ts']);
|
||||
});
|
||||
|
||||
it('ID_PATTERN (Muster der Appwrite-IDs, auch für die Logo-ID der Konfiguration) wird nur in card/config.ts definiert', () => {
|
||||
expect(definedIn(/\b(?:const|let|var)\s+ID_PATTERN\b/)).toEqual(['card/config.ts']);
|
||||
});
|
||||
|
||||
it('reportsLabel wird nur in pages/format.ts definiert', () => {
|
||||
expect(definedIn(/\bfunction\s+reportsLabel\b/)).toEqual(['pages/format.ts']);
|
||||
});
|
||||
|
||||