refactor(studio): ein Startpunkt statt sechs, Schritte werden zur Anzeige
Die Bausteinspalte mischte vier Dinge in einem Kartenlayout. Ergebnisse zum Anschauen, Erzeuger zum Starten, ein Artefakt ohne Erzeuger und reine Aktionen. In jeder Karte lagen Öffnen und Neurechnen wenige Pixel auseinander, beide mit demselben Kreispfeil-Symbol. Dazu kamen sechs Stellen, an denen ein Lauf beginnen konnte, verteilt über zwei Spalten. Jetzt drei getrennte Bereiche. Oben wird gearbeitet, in der Mitte werden Ergebnisse angesehen, unten stehen die Fall-Aktionen. Gearbeitet wird über genau einen Knopf, dessen Beschriftung sich am Zustand ausrichtet. Beim leeren Fall Lauf starten, bei einer Recherche Recherche starten, bei veralteten Ergebnissen Jetzt aktualisieren, sonst Erneut durchlaufen, während eines abbrechbaren Laufs Abbrechen. Darüber steht, was gerade zu tun ist, etwa die Zahl der neuen Artikel seit dem letzten Bericht. Darunter die vier Schritte als Kette mit Zustand. Sie starten nichts, Aufklappen erklärt nur, was ein Schritt tut. Damit fällt auch eine Kostenfalle weg. Ein einzeln gestartetes Sammeln buchte denselben Satz wie der komplette Lauf, wer danach Lagebild und Faktencheck einzeln nachzog, zahlte 69 statt 45 Credits, ohne dass die Oberfläche darauf hinwies. Benannt wird ab jetzt das Ergebnis statt des Erzeugers. Artikel holen, Lagebild schreiben, Fakten prüfen, Orte erkennen. Bei Recherche-Lagen heißt der zweite Schritt Recherchebericht schreiben. Die Wörter Baustein, Geoparsing und Analyse verschwinden aus der Oberfläche. Weiter entfernt sind die Karte Netzwerkanalyse, solange es den Baustein im Server nicht gibt, der Sammeln-Knopf aus der Quellenspalte und die Wahl Nur Quellen sammeln im Startdialog. Der Server behält seine Schnittstelle für einzelne Bausteine. Die Anzeige eines so gestarteten Laufs bleibt erhalten, er kann aus der klassischen Ansicht kommen. Ein solcher Lauf wird beim Abbruch auch als der Schritt abgebrochen, der wirklich läuft, nicht pauschal als kompletter Lauf. 51 tote CSS-Regeln entfernt. Prüfungen ohne Browser, 59 neue Fälle für die Lauf-Anzeige plus 12 in der Strukturprüfung, die festhalten, dass es im ganzen Dokument genau ein Element gibt, das einen Lauf starten kann. Die Anzeige der Credits am Knopf kommt später, sie ist bewusst nicht Teil dieser Änderung. Cache-Buster auf 20260802c. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
314
tests/test_lauf.js
Normale Datei
314
tests/test_lauf.js
Normale Datei
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Testet die Lauf-Anzeige der Bausteinspalte ohne Browser.
|
||||
*
|
||||
* Die Methoden werden aus studio.js herausgeloest und gegen ein schlankes
|
||||
* DOM-Double laufen gelassen. Kein Netzzugriff, keine Kosten.
|
||||
*
|
||||
* Aufruf aus dem Projektstamm:
|
||||
* node tests/test_lauf.js
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
|
||||
function pruefe(name, bedingung, extra) {
|
||||
if (bedingung) {
|
||||
ok++;
|
||||
console.log(' OK ' + name);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(' FEHL ' + name + ' '
|
||||
+ (extra === undefined ? '' : JSON.stringify(extra)));
|
||||
}
|
||||
}
|
||||
|
||||
// --- DOM-Double ------------------------------------------------------------
|
||||
function element(id) {
|
||||
const klassen = new Set();
|
||||
const el = {
|
||||
id: id,
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
classList: {
|
||||
add: (n) => klassen.add(n),
|
||||
remove: (n) => klassen.delete(n),
|
||||
toggle: (n, an) => { if (an) klassen.add(n); else klassen.delete(n); },
|
||||
contains: (n) => klassen.has(n),
|
||||
},
|
||||
_kinder: {},
|
||||
querySelector(sel) {
|
||||
const k = sel.replace('.', '');
|
||||
if (!this._kinder[k]) this._kinder[k] = element(k);
|
||||
return this._kinder[k];
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
const IDS = ['lauf-stand', 'ls-haupt', 'ls-neben', 'ls-btn', 'ls-hinweis',
|
||||
'lauf-kette', 'lauf-fortschritt', 'lauf-schritte', 'start-panel',
|
||||
'art-summary-meta', 'art-fc-meta', 'art-snap-meta', 'art-tl-meta'];
|
||||
const elemente = {};
|
||||
const meldungen = [];
|
||||
const aufrufe = [];
|
||||
|
||||
const umgebung = {
|
||||
document: {
|
||||
getElementById: (id) => elemente[id] || null,
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
UI: {
|
||||
showToast: (t, a) => meldungen.push({ t, a }),
|
||||
escape: (s) => String(s === undefined || s === null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
||||
},
|
||||
CSS: { escape: (s) => s },
|
||||
localStorage: { _d: {}, setItem(k, v) { this._d[k] = v; }, getItem(k) { return this._d[k]; } },
|
||||
console: console,
|
||||
};
|
||||
umgebung.window = umgebung;
|
||||
|
||||
// --- Den Lauf-Block aus studio.js herausloesen -----------------------------
|
||||
const quelle = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'static', 'js', 'studio.js'), 'utf8');
|
||||
const von = quelle.indexOf(' // === Der Lauf (Spalte 3) ===');
|
||||
const bis = quelle.indexOf(' async runStage(stage) {', von);
|
||||
if (von === -1 || bis === -1) {
|
||||
console.log('FEHLER: Lauf-Block in studio.js nicht gefunden');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const kontext = vm.createContext(umgebung);
|
||||
const Studio = vm.runInContext(
|
||||
'var Studio = {\n' + quelle.slice(von, bis) + '\n'
|
||||
+ ' incident: null, fresh: null, _runningStage: null,\n'
|
||||
+ ' async runStage(s) { this._letzterStart = s; },\n'
|
||||
+ '};\nStudio;', kontext);
|
||||
|
||||
const LAGE = { id: 7, title: 'Ceuta', type: 'adhoc' };
|
||||
|
||||
// Vier Zustaende, wie sie im Betrieb wirklich vorkommen
|
||||
const FRISCH = {
|
||||
leer: { articles: 0, summary: {}, factcheck: {}, geoparse: {} },
|
||||
veraltet: { articles: 85, summary: { exists: true, last: '2026-08-02 18:10', pending: 3 },
|
||||
factcheck: { exists: true, last: '2026-08-02 16:00', facts: 19, pending: 3 },
|
||||
geoparse: { pending: 0 }, snapshots: 4, events: 12 },
|
||||
aktuell: { articles: 85, summary: { exists: true, last: '2026-08-02 18:40', pending: 0 },
|
||||
factcheck: { exists: true, last: '2026-08-02 18:41', facts: 19, pending: 0 },
|
||||
geoparse: { pending: 0 }, snapshots: 4, events: 12 },
|
||||
halb: { articles: 85, summary: { exists: false }, factcheck: { exists: false },
|
||||
geoparse: { pending: 85 } },
|
||||
};
|
||||
|
||||
function neu(zustand, ueber) {
|
||||
for (const id of IDS) elemente[id] = element(id);
|
||||
Studio.incident = Object.assign({}, LAGE, ueber || {});
|
||||
Studio.fresh = FRISCH[zustand] ? JSON.parse(JSON.stringify(FRISCH[zustand])) : null;
|
||||
Studio._runningStage = null;
|
||||
Studio._schrittOffen = null;
|
||||
Studio._letzterStart = null;
|
||||
meldungen.length = 0;
|
||||
aufrufe.length = 0;
|
||||
}
|
||||
|
||||
const g = (id) => elemente[id];
|
||||
const knopfText = () => g('ls-btn').querySelector('.ls-text').textContent;
|
||||
|
||||
async function main() {
|
||||
console.log('\nA) Der Startknopf richtet sich nach dem Zustand');
|
||||
neu('leer');
|
||||
Studio._renderLauf();
|
||||
pruefe('A1 leerer Fall lädt zum Start ein', knopfText() === 'Lauf starten', knopfText());
|
||||
pruefe('A2 der Stand sagt warum', g('ls-haupt').textContent.includes('Noch keine Artikel'),
|
||||
g('ls-haupt').textContent);
|
||||
pruefe('A3 der Kasten ist hervorgehoben', g('lauf-stand').classList.contains('betont'));
|
||||
pruefe('A4 mit einem Hinweis, was danach kommt',
|
||||
g('ls-hinweis').textContent.length > 10 && g('ls-hinweis').hidden === false);
|
||||
|
||||
neu('leer', { type: 'research' });
|
||||
Studio._renderLauf();
|
||||
pruefe('A5 bei einer Recherche heißt es Recherche starten',
|
||||
knopfText() === 'Recherche starten', knopfText());
|
||||
|
||||
neu('veraltet');
|
||||
Studio._renderLauf();
|
||||
pruefe('A6 bei neuen Artikeln heißt es aktualisieren',
|
||||
knopfText() === 'Jetzt aktualisieren', knopfText());
|
||||
pruefe('A7 der Stand nennt die Zahl',
|
||||
g('ls-haupt').textContent.includes('3 neue Artikel'), g('ls-haupt').textContent);
|
||||
pruefe('A8 und warnt', g('ls-haupt').classList.contains('warn'));
|
||||
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
pruefe('A9 wenn alles frisch ist heißt es erneut durchlaufen',
|
||||
knopfText() === 'Erneut durchlaufen', knopfText());
|
||||
pruefe('A10 der Kasten ist dann ruhig', !g('lauf-stand').classList.contains('betont'));
|
||||
pruefe('A11 und der Hinweis verschwindet', g('ls-hinweis').hidden === true);
|
||||
pruefe('A12 der Bestand steht daneben',
|
||||
g('ls-neben').textContent === '85 Artikel im Bestand', g('ls-neben').textContent);
|
||||
|
||||
console.log('\nB) Während eines Laufs');
|
||||
neu('veraltet');
|
||||
Studio._runningStage = 'full';
|
||||
Studio._renderLauf();
|
||||
pruefe('B1 der Knopf bricht ab', knopfText() === 'Abbrechen', knopfText());
|
||||
pruefe('B2 er ist rot abgesetzt', g('ls-btn').classList.contains('abbrechen'));
|
||||
pruefe('B3 er bleibt klickbar', g('ls-btn').disabled === false);
|
||||
pruefe('B4 der Stand sagt, was läuft',
|
||||
g('ls-haupt').textContent.includes('Kompletter Lauf'), g('ls-haupt').textContent);
|
||||
|
||||
neu('veraltet');
|
||||
Studio._runningStage = 'analyze';
|
||||
Studio._renderLauf();
|
||||
pruefe('B5 ein nicht abbrechbarer Schritt sperrt den Knopf', g('ls-btn').disabled === true);
|
||||
pruefe('B6 und wird nicht als Abbruch angeboten',
|
||||
!g('ls-btn').classList.contains('abbrechen'));
|
||||
pruefe('B7 der Stand nennt den Schritt beim Ergebnisnamen',
|
||||
g('ls-haupt').textContent.includes('Lagebild schreiben'), g('ls-haupt').textContent);
|
||||
|
||||
console.log('\nC) Der Knopf trifft den richtigen Schritt');
|
||||
neu('veraltet');
|
||||
await Studio.laufKnopf();
|
||||
pruefe('C1 ohne Lauf startet er den kompletten Lauf', Studio._letzterStart === 'full');
|
||||
|
||||
neu('veraltet');
|
||||
Studio._runningStage = 'collect';
|
||||
await Studio.laufKnopf();
|
||||
pruefe('C2 ein laufendes Sammeln wird auch als Sammeln abgebrochen',
|
||||
Studio._letzterStart === 'collect', Studio._letzterStart);
|
||||
|
||||
neu('veraltet');
|
||||
Studio._runningStage = 'analyze';
|
||||
await Studio.laufKnopf();
|
||||
pruefe('C3 ein nicht abbrechbarer Schritt löst nichts aus',
|
||||
Studio._letzterStart === null, Studio._letzterStart);
|
||||
pruefe('C4 und sagt das auch', meldungen.length === 1 && meldungen[0].a === 'info', meldungen);
|
||||
|
||||
console.log('\nD) Die Kette');
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
const kette = g('lauf-kette').innerHTML;
|
||||
pruefe('D1 vier Knoten', (kette.match(/class="kn /g) || []).length === 4,
|
||||
(kette.match(/class="kn /g) || []).length);
|
||||
pruefe('D2 drei Verbindungen', (kette.match(/kn-linie/g) || []).length === 3);
|
||||
pruefe('D3 alle vier erledigt', (kette.match(/kn-fertig/g) || []).length === 4);
|
||||
pruefe('D4 der Zähler stimmt',
|
||||
g('lauf-fortschritt').textContent === '4 von 4 Schritten auf dem neuesten Stand',
|
||||
g('lauf-fortschritt').textContent);
|
||||
|
||||
neu('veraltet');
|
||||
Studio._renderLauf();
|
||||
pruefe('D5 bei veralteten Ergebnissen sind nicht alle grün',
|
||||
(g('lauf-kette').innerHTML.match(/kn-fertig/g) || []).length === 2,
|
||||
(g('lauf-kette').innerHTML.match(/kn-fertig/g) || []).length);
|
||||
pruefe('D6 zwei stehen auf veraltet',
|
||||
(g('lauf-kette').innerHTML.match(/kn-veraltet/g) || []).length === 2);
|
||||
pruefe('D7 der Zähler zählt nur die fertigen',
|
||||
g('lauf-fortschritt').textContent.startsWith('2 von 4'),
|
||||
g('lauf-fortschritt').textContent);
|
||||
|
||||
neu('leer');
|
||||
Studio._renderLauf();
|
||||
pruefe('D8 ohne Artikel sind drei Schritte gesperrt',
|
||||
(g('lauf-kette').innerHTML.match(/kn-gesperrt/g) || []).length === 3,
|
||||
(g('lauf-kette').innerHTML.match(/kn-gesperrt/g) || []).length);
|
||||
|
||||
console.log('\nE) Die Schritte');
|
||||
neu('halb');
|
||||
Studio._renderLauf();
|
||||
const l = g('lauf-schritte').innerHTML;
|
||||
pruefe('E1 vier Schritte', (l.match(/class="schritt /g) || []).length === 4,
|
||||
(l.match(/class="schritt /g) || []).length);
|
||||
pruefe('E2 Ergebnisnamen statt Erzeugernamen',
|
||||
l.includes('Artikel holen') && l.includes('Lagebild schreiben')
|
||||
&& l.includes('Fakten prüfen') && l.includes('Orte erkennen'));
|
||||
pruefe('E3 keine Erzeugernamen mehr',
|
||||
!l.includes('Geoparsing') && !l.includes('>Sammeln<') && !l.includes('>Analyse<'));
|
||||
pruefe('E4 der erste Schritt ist erledigt', l.includes('schritt schritt-fertig'));
|
||||
pruefe('E5 die noch nicht erzeugten sind offen',
|
||||
(l.match(/schritt-offen/g) || []).length === 2, (l.match(/schritt-offen/g) || []).length);
|
||||
pruefe('E6 die Karte meldet offene Artikel',
|
||||
l.includes('85 Artikel noch nicht verortet'));
|
||||
|
||||
neu('aktuell', { type: 'research' });
|
||||
Studio._renderLauf();
|
||||
pruefe('E7 bei einer Recherche heißt Schritt zwei Recherchebericht',
|
||||
g('lauf-schritte').innerHTML.includes('Recherchebericht schreiben'));
|
||||
|
||||
console.log('\nF) Kein Schritt startet etwas');
|
||||
for (const z of ['leer', 'veraltet', 'aktuell', 'halb']) {
|
||||
neu(z);
|
||||
Studio._renderLauf();
|
||||
const h = g('lauf-schritte').innerHTML;
|
||||
pruefe('F ' + z + ' enthält keinen Startaufruf',
|
||||
!h.includes('runStage') && !h.includes('laufKnopf'));
|
||||
}
|
||||
|
||||
console.log('\nG) Aufklappen erklärt nur');
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
pruefe('G1 anfangs ist nichts offen',
|
||||
!g('lauf-schritte').innerHTML.includes('schritt offen')
|
||||
&& !g('lauf-schritte').innerHTML.includes(' offen"'));
|
||||
Studio.klappSchritt(1);
|
||||
pruefe('G2 ein Klick öffnet den Schritt',
|
||||
g('lauf-schritte').innerHTML.includes(' offen"'), Studio._schrittOffen);
|
||||
pruefe('G3 und zeigt die Erklärung',
|
||||
g('lauf-schritte').innerHTML.includes('Wertet alle Artikel aus'));
|
||||
Studio.klappSchritt(1);
|
||||
pruefe('G4 nochmal klicken schließt ihn', Studio._schrittOffen === null);
|
||||
Studio.klappSchritt(0);
|
||||
Studio.klappSchritt(2);
|
||||
pruefe('G5 es ist immer nur einer offen', Studio._schrittOffen === 2);
|
||||
|
||||
console.log('\nH) Nebenangaben an den Ansichten');
|
||||
neu('aktuell');
|
||||
Studio._renderLauf();
|
||||
pruefe('H1 Lagebild nennt die Artikelzahl',
|
||||
g('art-summary-meta').textContent.includes('85 Artikel ausgewertet'),
|
||||
g('art-summary-meta').textContent);
|
||||
pruefe('H2 Faktencheck nennt die Fakten',
|
||||
g('art-fc-meta').textContent.includes('19 Fakten'), g('art-fc-meta').textContent);
|
||||
pruefe('H3 frühere Berichte werden gezählt', g('art-snap-meta').textContent === '4');
|
||||
pruefe('H4 Ereignisse werden gezählt',
|
||||
g('art-tl-meta').textContent === '12 Ereignisse', g('art-tl-meta').textContent);
|
||||
|
||||
neu('leer');
|
||||
Studio._renderLauf();
|
||||
pruefe('H5 ohne Lauf steht noch nicht erzeugt',
|
||||
g('art-summary-meta').textContent === 'noch nicht erzeugt',
|
||||
g('art-summary-meta').textContent);
|
||||
|
||||
console.log('\nI) Ohne offenen Fall');
|
||||
neu('leer');
|
||||
Studio.incident = null;
|
||||
Studio.fresh = null;
|
||||
Studio._renderLauf();
|
||||
pruefe('I1 der Knopf ist gesperrt', g('ls-btn').disabled === true);
|
||||
pruefe('I2 der Stand sagt es', g('ls-haupt').textContent === 'Kein Fall geöffnet',
|
||||
g('ls-haupt').textContent);
|
||||
pruefe('I3 die Kette ist leer', g('lauf-kette').innerHTML === '');
|
||||
pruefe('I4 die Schrittliste ist leer', g('lauf-schritte').innerHTML === '');
|
||||
pruefe('I5 nichts stürzt ab', true);
|
||||
|
||||
console.log('\nJ) Ohne Frischedaten');
|
||||
neu('leer');
|
||||
Studio.fresh = null;
|
||||
Studio._renderLauf();
|
||||
pruefe('J1 die Anzeige bleibt bedienbar', g('ls-btn').disabled === false);
|
||||
pruefe('J2 und behandelt den Fall wie leer',
|
||||
g('ls-haupt').textContent.includes('Noch keine Artikel'));
|
||||
|
||||
console.log('\nErgebnis: ' + ok + ' bestanden, ' + fail + ' fehlgeschlagen');
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -39,6 +39,8 @@ studio_js = lies("js", "studio.js")
|
||||
dash_html = lies("dashboard.html")
|
||||
app_js = lies("js", "app.js")
|
||||
comp_js = lies("js", "components.js")
|
||||
style = lies("css", "style.css")
|
||||
studio_css = lies("css", "studio.css")
|
||||
|
||||
|
||||
def ids(html):
|
||||
@@ -106,8 +108,31 @@ for name, html in [("studio.html", studio_html), ("dashboard.html", dash_html)]:
|
||||
# Geprueft wird nur, dass die in diesem Zug geaenderten Dateien mitgezogen sind.
|
||||
eigene = re.findall(r"/static/(?:js|css)/(studio|app|style|components|studio)\.(?:js|css)\?v=([0-9a-z]+)", html)
|
||||
pruefe(f"E {name} nennt Stempel", bool(stempel), stempel)
|
||||
pruefe(f"E {name} geaenderte Dateien tragen 20260802b",
|
||||
all(v == "20260802b" for _, v in eigene), eigene)
|
||||
# Kein fester Wert. Geprueft wird, dass die in einem Zug geaenderten
|
||||
# Dateien denselben Stempel tragen, sonst muesste der Test bei jedem
|
||||
# Deploy mitgepflegt werden.
|
||||
stempel_eigen = {v for _, v in eigene}
|
||||
pruefe(f"E {name} alle eigenen Dateien tragen denselben Stempel",
|
||||
len(stempel_eigen) == 1, sorted(stempel_eigen))
|
||||
|
||||
print("\nE2) Die Bausteinspalte hat genau einen Startpunkt")
|
||||
_i = studio_html.index('id="studio-menu"')
|
||||
menue = studio_html[_i:studio_html.index("</section>", _i)]
|
||||
starts = re.findall(r'data-stage="([a-z]+)"', menue)
|
||||
pruefe("E2a genau ein Element startet einen Lauf", len(starts) == 1, starts)
|
||||
pruefe("E2b und zwar der komplette Lauf", starts == ["full"], starts)
|
||||
pruefe("E2c auch im ganzen Dokument nur dieses eine",
|
||||
len(re.findall(r"data-stage=", studio_html)) == 1,
|
||||
len(re.findall(r"data-stage=", studio_html)))
|
||||
pruefe("E2d die Ansichten starten nichts", "Studio.runStage" not in studio_html)
|
||||
for tot in ["art-card", "art-run", "art-note", "run-all", "collect-btn", "art-foot"]:
|
||||
pruefe(f"E2 {tot} ist restlos weg",
|
||||
tot not in studio_html and tot not in studio_js and tot not in studio_css)
|
||||
pruefe("E2e die Schritte tragen Ergebnisnamen",
|
||||
"Artikel holen" in studio_js and "Orte erkennen" in studio_js
|
||||
and "Fakten prüfen" in studio_js)
|
||||
pruefe("E2f Geoparsing steht nicht mehr in der Oberflaeche",
|
||||
"Geoparsing" not in studio_html)
|
||||
|
||||
print("\nF) Tags im neuen Panel sind ausgeglichen")
|
||||
i = studio_html.index('<div class="tab-panel" data-art="settings">')
|
||||
@@ -119,8 +144,6 @@ for tag in ["div", "select", "label", "textarea"]:
|
||||
pruefe(f"F {tag} ausgeglichen ({auf})", auf == zu, (auf, zu))
|
||||
|
||||
print("\nG) Das Kennzeichen nutzt vorhandene Stile")
|
||||
style = lies("css", "style.css")
|
||||
studio_css = lies("css", "studio.css")
|
||||
pruefe("G1 incident-ai-badge ist definiert", ".incident-ai-badge" in style)
|
||||
pruefe("G2 case-ai-tag ist definiert", ".case-ai-tag" in studio_css)
|
||||
pruefe("G3 studio.html bindet style.css ein", "/static/css/style.css" in studio_html)
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren