Let admins choose which data to import instead of all-or-nothing
Import always replaced everything - brands, packages, services, FAQs,
gallery - in one irreversible sweep, even if you only wanted to bring in
one part (e.g. restoring just the gallery from an old backup while
leaving today's live catalog alone).
Backend: importData() now accepts a "sections" field (JSON array of
catalog/services/faqs/gallery) and only DELETE+INSERTs the tables that
belong to selected sections; anything not selected is left completely
untouched. Tables stay grouped the way their foreign keys require
(brands -> car_models -> packages -> package_products/upgrades as one
unit, gallery_brands -> gallery_projects -> gallery_photos as another) -
importing "packages" without its models would either orphan rows or
silently rewrite unrelated ones, so the groups aren't splittable further.
Restored images are filtered to just the ones referenced by the tables
actually being imported, so deselecting the gallery means its images
aren't written to /uploads either. No sections field (older callers)
still imports everything, matching prior behavior.
Frontend: picking a file parses it client-side (file.text() + JSON.parse)
to show each section with live counts pulled straight from the file
("4 Marken, 15 Modelle, 17 Pakete"), all checked by default. The
confirmation checkbox's warning text names only what's actually
selected. If parsing fails client-side the picker just doesn't render -
the import still goes through server-side with the (safe) default of
importing everything.
Verified end-to-end against a real ~54MB export: importing only "faqs"
reverted a changed FAQ while a newly added test brand survived
untouched; importing only "catalog" right after removed that test brand
while the FAQ stayed as already restored - confirming sections are
fully independent both ways.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a9aea99028
commit
541339879c
6 changed files with 217 additions and 57 deletions
|
|
@ -1,25 +1,105 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { api, API_BASE } from '../../../api/client.js';
|
import { api, API_BASE } from '../../../api/client.js';
|
||||||
|
|
||||||
|
// Gruppierung muss zur Backend-Konstante SettingsController::IMPORT_SECTIONS passen.
|
||||||
|
// Tabellen innerhalb einer Gruppe haengen per Fremdschluessel voneinander ab, darum
|
||||||
|
// werden sie beim Import immer zusammen ersetzt (nie einzeln).
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
key: 'catalog',
|
||||||
|
label: 'Fahrzeug-Katalog',
|
||||||
|
hint: 'Marken, Modelle, Pakete, Produkte, Upgrades',
|
||||||
|
tables: ['brands', 'car_models', 'packages', 'package_products', 'package_upgrades'],
|
||||||
|
},
|
||||||
|
{ key: 'services', label: 'Leistungen', hint: 'Die Leistungen-Übersicht', tables: ['services'] },
|
||||||
|
{ key: 'faqs', label: 'FAQs', hint: 'Häufig gestellte Fragen', tables: ['faqs'] },
|
||||||
|
{
|
||||||
|
key: 'gallery',
|
||||||
|
label: 'Bildergalerie',
|
||||||
|
hint: 'Galerie-Marken, Projekte und Fotos (inkl. Bilder)',
|
||||||
|
tables: ['gallery_brands', 'gallery_projects', 'gallery_photos'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TABLE_LABELS = {
|
||||||
|
brands: 'Marken',
|
||||||
|
car_models: 'Modelle',
|
||||||
|
packages: 'Pakete',
|
||||||
|
package_products: 'Produkte',
|
||||||
|
package_upgrades: 'Upgrades',
|
||||||
|
services: 'Leistungen',
|
||||||
|
faqs: 'FAQs',
|
||||||
|
gallery_brands: 'Galerie-Marken',
|
||||||
|
gallery_projects: 'Galerie-Projekte',
|
||||||
|
gallery_photos: 'Galerie-Fotos',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kurze, lesbare Zusammenfassung ("3 Marken, 12 Modelle, 45 Pakete") aus den im Export
|
||||||
|
// enthaltenen Zeilen einer Sektion - reine Vorschau, keine Validierung.
|
||||||
|
const summarize = (parsedData, tables) => {
|
||||||
|
if (!parsedData) return null;
|
||||||
|
const parts = tables
|
||||||
|
.map((t) => [t, Array.isArray(parsedData[t]) ? parsedData[t].length : 0])
|
||||||
|
.filter(([, count]) => count > 0)
|
||||||
|
.map(([t, count]) => `${count} ${TABLE_LABELS[t] || t}`);
|
||||||
|
return parts.length ? parts.join(', ') : 'leer in dieser Datei';
|
||||||
|
};
|
||||||
|
|
||||||
export default function ExportImportSettings() {
|
export default function ExportImportSettings() {
|
||||||
const [importFile, setImportFile] = useState(null);
|
const [importFile, setImportFile] = useState(null);
|
||||||
|
const [parsedData, setParsedData] = useState(null);
|
||||||
|
const [parseError, setParseError] = useState('');
|
||||||
|
const [selectedSections, setSelectedSections] = useState(() => new Set(SECTIONS.map((s) => s.key)));
|
||||||
const [importConfirmed, setImportConfirmed] = useState(false);
|
const [importConfirmed, setImportConfirmed] = useState(false);
|
||||||
const [importBusy, setImportBusy] = useState(false);
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
const [importResult, setImportResult] = useState(null);
|
const [importResult, setImportResult] = useState(null);
|
||||||
const [importError, setImportError] = useState('');
|
const [importError, setImportError] = useState('');
|
||||||
|
|
||||||
|
const handleFileChange = async (e) => {
|
||||||
|
const file = e.target.files?.[0] || null;
|
||||||
|
setImportFile(file);
|
||||||
|
setImportResult(null);
|
||||||
|
setImportError('');
|
||||||
|
setParsedData(null);
|
||||||
|
setParseError('');
|
||||||
|
setSelectedSections(new Set(SECTIONS.map((s) => s.key)));
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed.data !== 'object') throw new Error('kein gültiges Export-Format');
|
||||||
|
setParsedData(parsed.data);
|
||||||
|
} catch {
|
||||||
|
// Vorschau ist nur ein Komfort-Feature - schlaegt sie fehl, importiert der
|
||||||
|
// Server trotzdem ganz normal alle Bereiche; die eigentliche Validierung
|
||||||
|
// passiert ohnehin serverseitig.
|
||||||
|
setParseError('Datei konnte nicht gelesen werden – Vorschau der Inhalte nicht möglich. Import würde trotzdem versucht (alle Bereiche).');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSection = (key) => {
|
||||||
|
setSelectedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleImport = async (e) => {
|
const handleImport = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!importFile || !importConfirmed) return;
|
if (!importFile || !importConfirmed || selectedSections.size === 0) return;
|
||||||
setImportBusy(true);
|
setImportBusy(true);
|
||||||
setImportError('');
|
setImportError('');
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', importFile);
|
formData.append('file', importFile);
|
||||||
|
formData.append('sections', JSON.stringify([...selectedSections]));
|
||||||
const result = await api.post('/settings/import', formData);
|
const result = await api.post('/settings/import', formData);
|
||||||
setImportResult(result);
|
setImportResult(result);
|
||||||
setImportFile(null);
|
setImportFile(null);
|
||||||
|
setParsedData(null);
|
||||||
setImportConfirmed(false);
|
setImportConfirmed(false);
|
||||||
e.target.reset();
|
e.target.reset();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -29,6 +109,8 @@ export default function ExportImportSettings() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedLabels = SECTIONS.filter((s) => selectedSections.has(s.key)).map((s) => s.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
|
|
@ -49,18 +131,50 @@ export default function ExportImportSettings() {
|
||||||
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
<section className="rounded-xl border border-neutral-200 bg-white p-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Daten importieren</h2>
|
<h2 className="mb-1 font-semibold text-neutral-900 dark:text-white">Daten importieren</h2>
|
||||||
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
<p className="mb-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
Lädt eine zuvor exportierte Datei auf diesen Server. <strong>Ersetzt dabei alle</strong> aktuellen
|
Lädt eine zuvor exportierte Datei auf diesen Server. Ersetzt dabei die ausgewählten Bereiche komplett
|
||||||
Marken, Modelle, Pakete, Produkte, Upgrades, Leistungen, FAQs und die komplette Bildergalerie auf
|
durch den Inhalt der Datei – das kann nicht rückgängig gemacht werden. Kontaktanfragen und
|
||||||
diesem Server – das kann nicht rückgängig gemacht werden. Kontaktanfragen und Benutzerkonten bleiben
|
Benutzerkonten bleiben immer unangetastet.
|
||||||
unangetastet.
|
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleImport} className="space-y-3">
|
<form onSubmit={handleImport} className="space-y-4">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/json,.json"
|
accept="application/json,.json"
|
||||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
onChange={handleFileChange}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{importFile && (
|
||||||
|
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="border-b border-neutral-200 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:border-neutral-700 dark:text-neutral-400">
|
||||||
|
Was soll importiert werden?
|
||||||
|
</div>
|
||||||
|
{parseError && <p className="px-4 pt-3 text-sm text-amber-600 dark:text-amber-400">{parseError}</p>}
|
||||||
|
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<label
|
||||||
|
key={section.key}
|
||||||
|
className="flex cursor-pointer items-start gap-3 px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedSections.has(section.key)}
|
||||||
|
onChange={() => toggleSection(section.key)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-neutral-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-100">
|
||||||
|
{section.label}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{parsedData ? summarize(parsedData, section.tables) : section.hint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
<label className="flex items-start gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -68,12 +182,13 @@ export default function ExportImportSettings() {
|
||||||
onChange={(e) => setImportConfirmed(e.target.checked)}
|
onChange={(e) => setImportConfirmed(e.target.checked)}
|
||||||
className="mt-0.5 h-4 w-4 rounded border-neutral-300 text-brand-600 focus:ring-brand-500"
|
className="mt-0.5 h-4 w-4 rounded border-neutral-300 text-brand-600 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
Mir ist bewusst, dass dies alle aktuellen Marken, Modelle, Pakete, Produkte, Upgrades, Leistungen,
|
Mir ist bewusst, dass dies{' '}
|
||||||
FAQs und die Bildergalerie auf diesem Server unwiderruflich ersetzt.
|
{selectedLabels.length ? <strong>{selectedLabels.join(', ')}</strong> : 'die ausgewählten Bereiche'} auf
|
||||||
|
diesem Server unwiderruflich ersetzt.
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!importFile || !importConfirmed || importBusy}
|
disabled={!importFile || !importConfirmed || importBusy || selectedSections.size === 0}
|
||||||
className="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
className="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{importBusy ? 'Importiere…' : 'Importieren'}
|
{importBusy ? 'Importiere…' : 'Importieren'}
|
||||||
|
|
@ -84,17 +199,12 @@ export default function ExportImportSettings() {
|
||||||
<div className="mt-3 rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300">
|
<div className="mt-3 rounded-md bg-green-50 p-3 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-300">
|
||||||
<p className="mb-1 font-semibold">Import erfolgreich:</p>
|
<p className="mb-1 font-semibold">Import erfolgreich:</p>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>{importResult.counts.brands} Marken</li>
|
{Object.entries(importResult.counts).map(([table, count]) => (
|
||||||
<li>{importResult.counts.car_models} Modelle</li>
|
<li key={table}>
|
||||||
<li>{importResult.counts.packages} Pakete</li>
|
{count} {TABLE_LABELS[table] || table}
|
||||||
<li>{importResult.counts.package_products} Produkte</li>
|
</li>
|
||||||
<li>{importResult.counts.package_upgrades} Upgrades</li>
|
))}
|
||||||
<li>{importResult.counts.services} Leistungen</li>
|
{importResult.images_restored > 0 && <li>{importResult.images_restored} Bilder wiederhergestellt</li>}
|
||||||
<li>{importResult.counts.faqs} FAQs</li>
|
|
||||||
<li>{importResult.counts.gallery_brands} Galerie-Marken</li>
|
|
||||||
<li>{importResult.counts.gallery_projects} Galerie-Projekte</li>
|
|
||||||
<li>{importResult.counts.gallery_photos} Galerie-Fotos</li>
|
|
||||||
<li>{importResult.images_restored} Bilder wiederhergestellt</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,28 @@ class SettingsController
|
||||||
'gallery_photos' => ['id', 'gallery_project_id', 'image_path', 'caption', 'sort_order', 'created_at', 'updated_at'],
|
'gallery_photos' => ['id', 'gallery_project_id', 'image_path', 'caption', 'sort_order', 'created_at', 'updated_at'],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Gruppierung fuer den wahlweisen Import: jede Gruppe wird beim Import als Ganzes
|
||||||
|
// ersetzt oder komplett unangetastet gelassen. Tabellen innerhalb einer Gruppe
|
||||||
|
// haengen per Fremdschluessel voneinander ab (z.B. packages -> car_models), darum
|
||||||
|
// keine feinere Aufteilung - sonst koennten verwaiste/falsch verknuepfte Zeilen
|
||||||
|
// entstehen, wenn z.B. nur "Pakete" ohne die zugehoerigen Modelle importiert wuerden.
|
||||||
|
private const IMPORT_SECTIONS = [
|
||||||
|
'catalog' => ['brands', 'car_models', 'packages', 'package_products', 'package_upgrades'],
|
||||||
|
'services' => ['services'],
|
||||||
|
'faqs' => ['faqs'],
|
||||||
|
'gallery' => ['gallery_brands', 'gallery_projects', 'gallery_photos'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Welche Tabellenspalte pro Tabelle einen Bild-Pfad enthaelt - dieselbe Zuordnung
|
||||||
|
// wird sowohl beim Export (Bilder einsammeln) als auch beim Teil-Import (nur die
|
||||||
|
// Bilder der tatsaechlich ausgewaehlten Bereiche wiederherstellen) verwendet.
|
||||||
|
private const IMAGE_COLUMNS = [
|
||||||
|
'services' => 'image_path',
|
||||||
|
'gallery_brands' => 'cover_image_path',
|
||||||
|
'gallery_projects' => 'cover_image_path',
|
||||||
|
'gallery_photos' => 'image_path',
|
||||||
|
];
|
||||||
|
|
||||||
// Kein const, da abhängig von der Umgebung (base_path unterscheidet sich
|
// Kein const, da abhängig von der Umgebung (base_path unterscheidet sich
|
||||||
// zwischen lokalem /hifi und der Root-Domain auf IONOS).
|
// zwischen lokalem /hifi und der Root-Domain auf IONOS).
|
||||||
private static function uploadsUrlPrefix(): string
|
private static function uploadsUrlPrefix(): string
|
||||||
|
|
@ -43,13 +65,7 @@ class SettingsController
|
||||||
}
|
}
|
||||||
|
|
||||||
$images = [];
|
$images = [];
|
||||||
$imageColumns = [
|
foreach (self::IMAGE_COLUMNS as $table => $column) {
|
||||||
'services' => 'image_path',
|
|
||||||
'gallery_brands' => 'cover_image_path',
|
|
||||||
'gallery_projects' => 'cover_image_path',
|
|
||||||
'gallery_photos' => 'image_path',
|
|
||||||
];
|
|
||||||
foreach ($imageColumns as $table => $column) {
|
|
||||||
foreach ($data[$table] as $row) {
|
foreach ($data[$table] as $row) {
|
||||||
self::collectImage($row[$column] ?? null, $images);
|
self::collectImage($row[$column] ?? null, $images);
|
||||||
}
|
}
|
||||||
|
|
@ -128,6 +144,25 @@ class SettingsController
|
||||||
$data = $payload['data'];
|
$data = $payload['data'];
|
||||||
$images = is_array($payload['images'] ?? null) ? $payload['images'] : [];
|
$images = is_array($payload['images'] ?? null) ? $payload['images'] : [];
|
||||||
|
|
||||||
|
// Welche Bereiche importiert werden sollen (Formularfeld "sections", JSON-Array
|
||||||
|
// von Schluesseln aus IMPORT_SECTIONS). Ohne Angabe oder mit ungueltigem Inhalt:
|
||||||
|
// wie bisher alles importieren (abwaertskompatibel zu aelteren Aufrufen).
|
||||||
|
$requestedSections = $_POST['sections'] ?? null;
|
||||||
|
if (is_string($requestedSections)) {
|
||||||
|
$requestedSections = json_decode($requestedSections, true);
|
||||||
|
}
|
||||||
|
$sections = is_array($requestedSections)
|
||||||
|
? array_values(array_intersect(array_keys(self::IMPORT_SECTIONS), $requestedSections))
|
||||||
|
: array_keys(self::IMPORT_SECTIONS);
|
||||||
|
if (!$sections) {
|
||||||
|
Http::error('Kein Bereich zum Importieren ausgewählt', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tables = [];
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
$tables = array_merge($tables, self::IMPORT_SECTIONS[$section]);
|
||||||
|
}
|
||||||
|
|
||||||
$db = Database::connection();
|
$db = Database::connection();
|
||||||
$counts = [];
|
$counts = [];
|
||||||
|
|
||||||
|
|
@ -135,13 +170,28 @@ class SettingsController
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
$db->exec('SET FOREIGN_KEY_CHECKS=0');
|
$db->exec('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
|
||||||
foreach (self::TABLE_COLUMNS as $table => $allowedColumns) {
|
foreach ($tables as $table) {
|
||||||
$rows = is_array($data[$table] ?? null) ? $data[$table] : [];
|
$rows = is_array($data[$table] ?? null) ? $data[$table] : [];
|
||||||
$db->exec("DELETE FROM `$table`");
|
$db->exec("DELETE FROM `$table`");
|
||||||
$counts[$table] = self::insertRows($db, $table, $allowedColumns, $rows);
|
$counts[$table] = self::insertRows($db, $table, self::TABLE_COLUMNS[$table], $rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
self::restoreImages($images);
|
// Nur Bilder wiederherstellen, die von Zeilen der tatsaechlich importierten
|
||||||
|
// Tabellen referenziert werden - sonst wuerden z.B. Galerie-Bilder auf den
|
||||||
|
// Server geschrieben, obwohl "Bildergalerie" beim Import abgewaehlt war.
|
||||||
|
$wantedPaths = [];
|
||||||
|
foreach (self::IMAGE_COLUMNS as $table => $column) {
|
||||||
|
if (!in_array($table, $tables, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (($data[$table] ?? []) as $row) {
|
||||||
|
if (!empty($row[$column])) {
|
||||||
|
$wantedPaths[$row[$column]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$imagesToRestore = array_intersect_key($images, $wantedPaths);
|
||||||
|
self::restoreImages($imagesToRestore);
|
||||||
|
|
||||||
$db->exec('SET FOREIGN_KEY_CHECKS=1');
|
$db->exec('SET FOREIGN_KEY_CHECKS=1');
|
||||||
$db->commit();
|
$db->commit();
|
||||||
|
|
@ -151,7 +201,7 @@ class SettingsController
|
||||||
Http::error('Import fehlgeschlagen, es wurde nichts verändert: ' . $e->getMessage(), 500);
|
Http::error('Import fehlgeschlagen, es wurde nichts verändert: ' . $e->getMessage(), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
Http::send(['ok' => true, 'counts' => $counts, 'images_restored' => count($images)]);
|
Http::send(['ok' => true, 'sections' => $sections, 'counts' => $counts, 'images_restored' => count($imagesToRestore)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function resetServicesToDefaults(): void
|
public static function resetServicesToDefaults(): void
|
||||||
|
|
|
||||||
1
hifi/assets/index-C78wIVRz.css
Normal file
1
hifi/assets/index-C78wIVRz.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -43,9 +43,9 @@
|
||||||
"sameAs": ["https://www.youtube.com/@hifiplanet2812"]
|
"sameAs": ["https://www.youtube.com/@hifiplanet2812"]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-DQAs_YfT.js"></script>
|
<script type="module" crossorigin src="/assets/index-KOjZhXM_.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-66ioQXWI.js">
|
<link rel="modulepreload" crossorigin href="/assets/lucide-icons-66ioQXWI.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-wBHRPmjw.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C78wIVRz.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
<body class="bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue