Skip to content

Commit 6d17adf

Browse files
authored
Feature/theme import export, admin config cards dirty highlight and revert btn (#1367)
add theme export/import zip handling with asset preservation and ui, add admin config revert button, add admin highlight changed but unsaved config boxes.
1 parent ebd2b87 commit 6d17adf

File tree

9 files changed

+647
-37
lines changed

9 files changed

+647
-37
lines changed

admin/components/_getSettings.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
$i18ntag = $section . ':' . $key;
3939

4040
echo '<!-- ' . strtoupper($setting['type']) . ' ' . strtoupper($setting['name']) . ' -->';
41-
echo '<div class="flex flex-col rounded-xl p-3 shadow-xl bg-white ' . $hidden . '" id="' . $i18ntag . '">';
41+
echo '<div class="adminSettingCard relative flex flex-col rounded-xl p-3 shadow-xl bg-white ' . $hidden . '" id="' . $i18ntag . '">';
4242

4343
$isThemeField = ($setting['data-theme-field'] ?? '') === 'true' || ($setting['data-theme-field'] ?? false) === true;
4444
AdminInput::setThemeFieldFlag($isThemeField);

api/themes.php

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44

55
use Photobooth\Service\ThemeService;
66

7-
header('Content-Type: application/json');
8-
97
$themeService = ThemeService::getInstance();
108

9+
$sendJson = static function (array $payload): void {
10+
header('Content-Type: application/json');
11+
echo json_encode($payload);
12+
exit();
13+
};
14+
1115
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
1216
$query = $_GET;
1317

@@ -16,36 +20,80 @@
1620

1721
if ($action === 'list') {
1822
$all = $themeService->getAll();
19-
echo json_encode([
23+
$sendJson([
2024
'status' => 'success',
2125
'themes' => array_keys($all),
2226
]);
23-
exit();
2427
}
2528

2629
if ($action === 'get') {
2730
$name = (string)($query['name'] ?? '');
2831
$theme = $themeService->get($name);
2932
if ($theme === null) {
30-
echo json_encode([
33+
$sendJson([
3134
'status' => 'error',
3235
'message' => 'Theme not found',
3336
]);
34-
exit();
3537
}
3638

37-
echo json_encode([
39+
$sendJson([
3840
'status' => 'success',
3941
'theme' => $theme,
4042
]);
43+
}
44+
45+
if ($action === 'export') {
46+
$name = (string)($query['name'] ?? '');
47+
$result = $themeService->exportTheme($name);
48+
if (!$result['success'] || !isset($result['file'])) {
49+
$sendJson([
50+
'status' => 'error',
51+
'message' => $result['message'] ?? 'Failed to export theme',
52+
]);
53+
}
54+
55+
$downloadName = isset($result['downloadName']) ? basename($result['downloadName']) : 'theme.zip';
56+
$filePath = $result['file'];
57+
header('Content-Type: application/zip');
58+
header('Content-Disposition: attachment; filename="' . $downloadName . '"');
59+
header('Content-Length: ' . filesize($filePath));
60+
readfile($filePath);
61+
@unlink($filePath);
4162
exit();
4263
}
4364

44-
echo json_encode([
65+
$sendJson([
4566
'status' => 'error',
4667
'message' => 'Unknown action',
4768
]);
48-
exit();
69+
}
70+
71+
// Handle multipart/form-data import first
72+
$postAction = $_POST['action'] ?? null;
73+
if ($postAction === 'import') {
74+
if (!isset($_FILES['theme_zip']) || !is_uploaded_file($_FILES['theme_zip']['tmp_name']) || $_FILES['theme_zip']['error'] !== UPLOAD_ERR_OK) {
75+
$sendJson([
76+
'status' => 'error',
77+
'message' => 'No theme zip provided',
78+
]);
79+
}
80+
81+
$targetName = isset($_POST['name']) ? (string)$_POST['name'] : null;
82+
$tmpFile = $_FILES['theme_zip']['tmp_name'];
83+
84+
$result = $themeService->importTheme($tmpFile, $targetName);
85+
if (!$result['success']) {
86+
$sendJson([
87+
'status' => 'error',
88+
'message' => $result['message'] ?? 'Import failed',
89+
]);
90+
}
91+
92+
$sendJson([
93+
'status' => 'success',
94+
'name' => $result['name'] ?? '',
95+
'theme' => $result['theme'] ?? [],
96+
]);
4997
}
5098

5199
$rawBody = file_get_contents('php://input');
@@ -62,44 +110,39 @@
62110
$data = isset($body['theme']) && is_array($body['theme']) ? $body['theme'] : [];
63111

64112
if ($name === '') {
65-
echo json_encode([
113+
$sendJson([
66114
'status' => 'error',
67115
'message' => 'Missing theme name',
68116
]);
69-
exit();
70117
}
71118

72119
$themeService->save($name, $data);
73120

74-
echo json_encode([
121+
$sendJson([
75122
'status' => 'success',
76123
'message' => 'Theme saved',
77124
]);
78-
exit();
79125
}
80126

81127
if ($action === 'delete') {
82128
$name = isset($body['name']) ? (string)$body['name'] : '';
83129

84130
if ($name === '') {
85-
echo json_encode([
131+
$sendJson([
86132
'status' => 'error',
87133
'message' => 'Missing theme name',
88134
]);
89-
exit();
90135
}
91136

92137
$themeService->delete($name);
93138

94-
echo json_encode([
139+
$sendJson([
95140
'status' => 'success',
96141
'message' => 'Theme deleted',
97142
]);
98-
exit();
99143
}
100144

101-
echo json_encode([
145+
$sendJson([
102146
'status' => 'error',
103147
'message' => 'Unknown action',
104148
]);
105-
exit();

assets/js/admin/index.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/* globals photoboothTools */
22
$(function () {
3+
initDirtyTracking();
4+
35
// adminRangeInput
46
$(document).on('input', '.adminRangeInput', function () {
57
document.querySelector('#' + this.name.replace('[', '\\[').replace(']', '\\]') + '-value span').innerHTML =
@@ -36,3 +38,109 @@ const shellCommand = function ($mode, $filename = '') {
3638
photoboothTools.console.log($mode, 'result: ', result);
3739
});
3840
};
41+
42+
function initDirtyTracking() {
43+
const $fields = $('.adminSection').find('input, select, textarea').not('[type="hidden"]');
44+
45+
$fields.each(function () {
46+
const $el = $(this);
47+
$el.data('initial', readFieldValue($el));
48+
});
49+
50+
$(document).on('change input', '.adminSection input, .adminSection select, .adminSection textarea', function () {
51+
updateDirtyState($(this));
52+
});
53+
54+
$(document).on('click', '.adminSettingCard-revert', function (e) {
55+
e.preventDefault();
56+
const $card = $(this).closest('.adminSettingCard');
57+
revertCard($card);
58+
});
59+
}
60+
61+
function readFieldValue($el) {
62+
const el = $el[0];
63+
if (el.tagName === 'SELECT' && el.multiple) {
64+
return ($el.val() || []).slice().sort().join('|');
65+
}
66+
if (el.type === 'checkbox') {
67+
return $el.is(':checked') ? '1' : '0';
68+
}
69+
return $el.val();
70+
}
71+
72+
function updateDirtyState($el) {
73+
const initial = $el.data('initial');
74+
const current = readFieldValue($el);
75+
const isDirty = initial !== current;
76+
const $card = $el.closest('.adminSettingCard');
77+
78+
if ($card.length === 0) {
79+
return;
80+
}
81+
82+
if (isDirty) {
83+
$card.addClass('ring-2 ring-indigo-200 shadow-indigo-200');
84+
$el.data('dirty', true);
85+
ensureRevertButton($card);
86+
} else {
87+
$el.data('dirty', false);
88+
if (
89+
!$card.find('input,select,textarea').filter(function () {
90+
return $(this).data('dirty');
91+
}).length
92+
) {
93+
$card.removeClass('ring-2 ring-indigo-200 shadow-indigo-200');
94+
removeRevertButton($card);
95+
}
96+
}
97+
}
98+
99+
function ensureRevertButton($card) {
100+
if ($card.find('.adminSettingCard-revert').length) {
101+
return;
102+
}
103+
const btn = $(
104+
'<button type="button" class="adminSettingCard-revert h-7 w-7 absolute right-2 top-2 text-xs font-semibold text-amber-700 border border-amber-400 rounded-full bg-amber-50 hover:bg-amber-100" title="Revert">' +
105+
'<i class="fa fa-undo"></i>' +
106+
'</button>'
107+
);
108+
$card.append(btn);
109+
}
110+
111+
function removeRevertButton($card) {
112+
$card.find('.adminSettingCard-revert').remove();
113+
}
114+
115+
function revertCard($card) {
116+
$card.find('input,select,textarea').each(function () {
117+
const $el = $(this);
118+
const initial = $el.data('initial');
119+
restoreFieldValue($el, initial);
120+
$el.data('dirty', false);
121+
});
122+
$card.removeClass('ring-2 ring-indigo-400 shadow-indigo-200');
123+
removeRevertButton($card);
124+
}
125+
126+
function restoreFieldValue($el, value) {
127+
const el = $el[0];
128+
if (el.tagName === 'SELECT' && el.multiple) {
129+
const list = (value || '').split('|').filter((v) => v !== '');
130+
$el.val(list);
131+
} else if (el.type === 'checkbox') {
132+
$el.prop('checked', value === '1');
133+
} else {
134+
$el.val(value);
135+
}
136+
$el.trigger('change');
137+
138+
// Keep range labels in sync after revert
139+
if ($el.hasClass('adminRangeInput')) {
140+
const labelId = '#' + el.name.replace('[', '\\[').replace(']', '\\]') + '-value span';
141+
const labelEl = document.querySelector(labelId);
142+
if (labelEl) {
143+
labelEl.innerHTML = $el.val();
144+
}
145+
}
146+
}

0 commit comments

Comments
 (0)