PluginProbe ʕ •ᴥ•ʔ
Presto Player / trunk
Presto Player vtrunk
4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / dashboard / components / MediaHub / PostSettings.js
presto-player / src / admin / dashboard / components / MediaHub Last commit date
test 1 month ago BulkActions.js 1 month ago MediaRow.js 1 month ago PostSettings.js 1 month ago SearchBar.js 1 month ago index.js 1 month ago
PostSettings.js
549 lines
1 import React, { useState, useEffect, useCallback, useMemo } from "react";
2 import { __ } from "@wordpress/i18n";
3 import apiFetch from "@wordpress/api-fetch";
4 import { decodeHTMLEntities } from "../../utils/formatters";
5 import {
6 Button,
7 Dialog,
8 Container,
9 Badge,
10 Loader,
11 Input,
12 Select,
13 Text,
14 Checkbox,
15 Label,
16 Tooltip,
17 toast,
18 } from "@bsf/force-ui";
19 import { CircleX, Settings, Info } from "lucide-react";
20 import PostScheduleField from "../PostScheduleField";
21 import {
22 getInitialState,
23 toPublishedDateTime,
24 } from "../Popup/EmailFormPopupUtils";
25
26 const statusOptions = [
27 { value: "publish", label: __("Published", "presto-player") },
28 { value: "draft", label: __("Draft", "presto-player") },
29 { value: "pending", label: __("Pending Review", "presto-player") },
30 { value: "future", label: __("Scheduled", "presto-player") },
31 ];
32
33 const getStatusLabel = (statusValue) => {
34 return (
35 statusOptions.find((o) => o.value === statusValue)?.label ||
36 __("Draft", "presto-player")
37 );
38 };
39
40 const MAX_VISIBLE_TAGS = 20;
41
42 const defaultForm = {
43 title: "",
44 slug: "",
45 status: "publish",
46 postDate: null,
47 postTimeValue: "",
48 postPeriod: "-1",
49 password: "",
50 passwordTouched: false,
51 isPrivate: false,
52 selectedTagIds: [],
53 };
54
55 const PostSettings = ({
56 open,
57 onClose,
58 onSuccess,
59 mediaId,
60 initialTitle = "",
61 initialStatus = "publish",
62 initialSlug = "",
63 initialDate = "",
64 initialPassword = "",
65 initialTags = [],
66 availableTags = [],
67 }) => {
68 const [errorMessage, setErrorMessage] = useState("");
69 const [saving, setSaving] = useState(false);
70 const [form, setForm] = useState(defaultForm);
71 // Tags created by the user during this session (not yet in availableTags).
72 const [createdTags, setCreatedTags] = useState([]);
73 // Tags currently displayed in the dropdown (filtered by search + optional "Create" entry).
74 const [displayedTags, setDisplayedTags] = useState([]);
75 // Tracks whether the user touched the date/time/period picker. Without this,
76 // every save round-trips the time through the 10-min picker granularity and
77 // drifts the stored post_date by up to 10 minutes — losing precision on edits
78 // that didn't intend to change the timestamp at all.
79 const [dateTouched, setDateTouched] = useState(false);
80
81 const updateField = useCallback(
82 (field) => (value) => setForm((prev) => ({ ...prev, [field]: value })),
83 []
84 );
85
86 useEffect(() => {
87 const isPrivate = initialStatus === "private";
88 const { publishedDate, publishedTimeValue, publishedPeriod } = getInitialState({
89 date: initialDate,
90 status: initialStatus,
91 });
92
93 setForm({
94 title: initialTitle,
95 slug: initialSlug,
96 password: initialPassword,
97 passwordTouched: false,
98 isPrivate,
99 // If private, show as "publish" in dropdown since Private is handled separately
100 status: isPrivate ? "publish" : initialStatus,
101 postDate: publishedDate,
102 postTimeValue: publishedTimeValue,
103 postPeriod: publishedPeriod,
104 selectedTagIds:
105 initialTags?.length > 0 ? initialTags.map((tag) => tag.id) : [],
106 });
107 setCreatedTags([]);
108 setDateTouched(false);
109 }, [
110 initialTitle,
111 initialStatus,
112 initialSlug,
113 initialDate,
114 initialPassword,
115 initialTags,
116 ]);
117
118
119 const allTags = useMemo(
120 () => [...availableTags, ...createdTags],
121 [availableTags, createdTags]
122 );
123
124 // Keep displayedTags in sync when allTags changes (e.g. after creating a tag).
125 useEffect(() => {
126 setDisplayedTags(allTags.slice(0, MAX_VISIBLE_TAGS));
127 }, [allTags]);
128
129 // Resolve a tag ID to its display name.
130 const getTagName = useCallback(
131 (id) => decodeHTMLEntities( allTags.find((tag) => tag.id === id)?.name ?? id ),
132 [allTags]
133 );
134
135 const resetForm = () => {
136 setForm(defaultForm);
137 setCreatedTags([]);
138 setErrorMessage("");
139 setSaving(false);
140 setDateTouched(false);
141 };
142
143 const handleClose = () => {
144 if (!saving) {
145 resetForm();
146 onClose();
147 }
148 };
149
150 const handlePasswordChange = (value) => {
151 setForm((prev) => ({
152 ...prev,
153 password: value,
154 passwordTouched: true,
155 ...(value ? { isPrivate: false } : {}),
156 }));
157 };
158
159 const handlePrivateChange = (checked) => {
160 setForm((prev) => ({
161 ...prev,
162 isPrivate: checked,
163 ...(checked ? { password: "", passwordTouched: true } : {}),
164 }));
165 };
166
167 const handleTagSearch = (searchTerm) => {
168 if (!searchTerm || searchTerm.trim() === "") {
169 setDisplayedTags(allTags.slice(0, MAX_VISIBLE_TAGS));
170 return;
171 }
172
173 const term = searchTerm.trim().toLowerCase();
174 const filtered = allTags
175 .filter((tag) => decodeHTMLEntities(tag.name).toLowerCase().includes(term))
176 .slice(0, MAX_VISIBLE_TAGS);
177
178 const exactMatch = allTags.some(
179 (tag) => decodeHTMLEntities(tag.name).toLowerCase() === term
180 );
181
182 if (!exactMatch) {
183 filtered.push({
184 id: `create:${searchTerm.trim()}`,
185 name: `${__("Create", "presto-player")} "${searchTerm.trim()}"`,
186 });
187 }
188
189 setDisplayedTags(filtered);
190 };
191
192 const handleTagChange = async (selectedValues) => {
193 if (!selectedValues) {
194 updateField("selectedTagIds")([]);
195 return;
196 }
197
198 const valuesArray = Array.isArray(selectedValues)
199 ? selectedValues
200 : [selectedValues];
201
202 const createValue = valuesArray.find(
203 (val) => typeof val === "string" && val.startsWith("create:")
204 );
205
206 if (createValue) {
207 const newTagName = createValue.replace("create:", "");
208
209 try {
210 const newTag = await apiFetch({
211 path: "/wp/v2/pp_video_tag",
212 method: "POST",
213 data: { name: newTagName },
214 });
215
216 const newTagEntry = { id: newTag.id, name: newTag.name };
217 const updatedIds = valuesArray
218 .filter((val) => val !== createValue)
219 .concat(newTag.id);
220
221 setCreatedTags((prev) => [...prev, newTagEntry]);
222 // Reset displayed tags so the badge can resolve the new ID to a name.
223 setDisplayedTags((prev) => {
224 const withoutCreate = prev.filter(
225 (t) => typeof t.id !== "string" || !t.id.startsWith("create:")
226 );
227 return [...withoutCreate, newTagEntry];
228 });
229 updateField("selectedTagIds")(updatedIds);
230 } catch (error) {
231 console.error("Failed to create tag:", error);
232 }
233 } else {
234 updateField("selectedTagIds")(valuesArray);
235 }
236 };
237
238 const handleSaveSettings = async () => {
239 if (saving) {
240 return;
241 }
242
243 setSaving(true);
244 setErrorMessage("");
245
246 try {
247 const actualStatus = form.isPrivate ? "private" : form.status;
248 const tagIds = form.selectedTagIds.filter((id) => typeof id === "number");
249
250 // Strip WP's get_the_title() prefix before saving. The row data uses
251 // `title.rendered`, which WP filters through protected_title_format /
252 // private_title_format and prepends "Protected: " / "Private: " for
253 // password-protected / private posts. Without this strip, the prefixed
254 // string gets stored as post_title and a fresh prefix is added on every
255 // render — compounding on every save.
256 const protectedPrefix = wp.i18n.__("Protected: %s").replace("%s", "");
257 const privatePrefix = wp.i18n.__("Private: %s").replace("%s", "");
258 let cleanTitle = form.title;
259 if (cleanTitle.startsWith(protectedPrefix)) {
260 cleanTitle = cleanTitle.slice(protectedPrefix.length);
261 } else if (cleanTitle.startsWith(privatePrefix)) {
262 cleanTitle = cleanTitle.slice(privatePrefix.length);
263 }
264
265 const data = {
266 title: cleanTitle,
267 slug: form.slug,
268 status: actualStatus,
269 pp_video_tag: tagIds,
270 };
271
272 // Only send password if the user modified it to avoid clearing existing passwords
273 // (WP REST API doesn't return password values in list responses).
274 if (form.passwordTouched) {
275 data.password = form.isPrivate ? "" : form.password;
276 }
277
278 if (dateTouched) {
279 const formattedDate = toPublishedDateTime(
280 form.postDate,
281 form.postTimeValue,
282 form.postPeriod
283 );
284 if (formattedDate) {
285 data.date = formattedDate;
286 }
287 }
288
289 const response = await apiFetch({
290 path: `/wp/v2/presto-videos/${mediaId}`,
291 method: "POST",
292 data,
293 });
294
295 setSaving(false);
296 // Keep the dialog open with the saved values visible — only reset
297 // passwordTouched so a subsequent save in the same session doesn't
298 // re-send an unchanged password (WP REST never echoes the real
299 // value back, so leaving the flag true would send the user's input
300 // every time they hit Save).
301 setForm((prev) => ({ ...prev, passwordTouched: false }));
302
303 if (typeof onSuccess === "function") {
304 // Pass allTags so the parent can resolve tag names without an extra API call
305 // (includes any tags the user created during this session).
306 onSuccess(response, {
307 passwordTouched: form.passwordTouched,
308 tagOptions: allTags,
309 });
310 }
311
312 toast.success(__("Settings saved.", "presto-player"));
313 } catch (error) {
314 console.error("Error saving settings:", error);
315 setSaving(false);
316 setErrorMessage(
317 error.message ||
318 __("An error occurred while saving the settings", "presto-player")
319 );
320 }
321 };
322
323 return (
324 <Dialog
325 design="simple"
326 exitOnEsc
327 scrollLock
328 open={open}
329 setOpen={(isOpen) => !isOpen && handleClose()}
330 >
331 <Dialog.Backdrop />
332
333 <Dialog.Panel className="max-w-2xl w-full overflow-visible">
334 <Dialog.Header>
335 <div className="flex items-center justify-between">
336 <div className="flex items-center gap-2">
337 <Settings size={20} />
338 <Dialog.Title>
339 {__("Post Settings", "presto-player")}
340 </Dialog.Title>
341 </div>
342 <Dialog.CloseButton onClick={handleClose} />
343 </div>
344 </Dialog.Header>
345
346 <Dialog.Body>
347 <Container containerType="flex" direction="column">
348 {errorMessage && (
349 <Container.Item>
350 <Badge
351 icon={<CircleX />}
352 label={errorMessage}
353 size="md"
354 type="rounded"
355 variant="red"
356 className="py-6 px-4 justify-start"
357 role="alert"
358 />
359 </Container.Item>
360 )}
361
362 <Container.Item>
363 <Input
364 type="text"
365 size="md"
366 label={__("Title", "presto-player")}
367 value={form.title}
368 onChange={updateField("title")}
369 placeholder={__("Enter title…", "presto-player")}
370 />
371 </Container.Item>
372
373 <Container.Item>
374 <div className="flex flex-col gap-1.5">
375 <div className="flex items-center gap-1.5">
376 <Label size="sm">{ __( "Slug", "presto-player" ) }</Label>
377 <Tooltip
378 content={ __( "The URL-friendly version of the title. Used in the media page address.", "presto-player" ) }
379 arrow
380 placement="right"
381 >
382 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
383 </Tooltip>
384 </div>
385 <Input
386 type="text"
387 size="md"
388 value={form.slug}
389 onChange={updateField("slug")}
390 placeholder={__("Enter slug…", "presto-player")}
391 />
392 </div>
393 </Container.Item>
394
395 <Container.Item>
396 <Select
397 multiple
398 combobox
399 size="md"
400 value={form.selectedTagIds}
401 onChange={handleTagChange}
402 searchPlaceholder={__(
403 "Search or create tags…",
404 "presto-player"
405 )}
406 searchFn={handleTagSearch}
407 >
408 <Select.Button
409 label={__("Media Tags", "presto-player")}
410 placeholder={__("Select tags…", "presto-player")}
411 render={(selected) =>
412 Array.isArray(selected)
413 ? selected.map(getTagName).join(", ")
414 : getTagName(selected)
415 }
416 />
417 <Select.Options>
418 {displayedTags.map((option) => (
419 <Select.Option key={option.id} value={option.id}>
420 {decodeHTMLEntities(option.name)}
421 </Select.Option>
422 ))}
423 </Select.Options>
424 </Select>
425 </Container.Item>
426
427 <Container.Item>
428 <Select
429 by="value"
430 size="md"
431 value={{
432 value: form.status,
433 label: getStatusLabel(form.status),
434 }}
435 onChange={(selectedOption) => {
436 if (selectedOption) {
437 updateField("status")(selectedOption.value);
438 }
439 }}
440 >
441 <Select.Button
442 label={__("Status", "presto-player")}
443 render={(value) => value?.label}
444 />
445 <Select.Options>
446 {statusOptions.map((option) => (
447 <Select.Option
448 key={option.value}
449 value={{
450 value: option.value,
451 label: option.label,
452 }}
453 >
454 {option.label}
455 </Select.Option>
456 ))}
457 </Select.Options>
458 </Select>
459 </Container.Item>
460
461 <PostScheduleField
462 date={form.postDate}
463 time={form.postTimeValue}
464 period={form.postPeriod}
465 setDate={(v) => { setDateTouched(true); updateField("postDate")(v); }}
466 setTime={(v) => { setDateTouched(true); updateField("postTimeValue")(v); }}
467 setPeriod={(v) => { setDateTouched(true); updateField("postPeriod")(v); }}
468 />
469
470
471 <Container.Item>
472 <Container
473 containerType="flex"
474 direction="row"
475 gap="sm"
476 className="items-end"
477 >
478 <Container.Item className="flex-1">
479 <Input
480 type="text"
481 size="md"
482 label={__("Password", "presto-player")}
483 value={form.password}
484 onChange={handlePasswordChange}
485 placeholder={__("Enter password…", "presto-player")}
486 disabled={form.isPrivate}
487 />
488 </Container.Item>
489 <Container.Item className="pb-2.5">
490 <Text size="sm" className="text-text-secondary">
491 {__("—OR—", "presto-player")}
492 </Text>
493 </Container.Item>
494 <Container.Item className="pb-2.5">
495 <Checkbox
496 size="sm"
497 checked={form.isPrivate}
498 onChange={handlePrivateChange}
499 label={{
500 heading: (
501 <span className="inline-flex items-center gap-1.5">
502 {__("Private", "presto-player")}
503 <Tooltip
504 content={__("Hides this media from everyone except administrators. Overrides password protection.", "presto-player")}
505 arrow
506 placement="right"
507 >
508 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
509 </Tooltip>
510 </span>
511 ),
512 }}
513 />
514 </Container.Item>
515 </Container>
516 </Container.Item>
517 </Container>
518 </Dialog.Body>
519
520 <Dialog.Footer className="pt-0 justify-between">
521 <Button
522 size="md"
523 variant="outline"
524 onClick={handleClose}
525 disabled={saving}
526 >
527 {__("Cancel", "presto-player")}
528 </Button>
529 <Button
530 size="md"
531 disabled={saving}
532 onClick={handleSaveSettings}
533 iconPosition="right"
534 icon={
535 saving && (
536 <Loader icon={null} size="sm" variant="primary" />
537 )
538 }
539 >
540 {__("Save Settings", "presto-player")}
541 </Button>
542 </Dialog.Footer>
543 </Dialog.Panel>
544 </Dialog>
545 );
546 };
547
548 export default PostSettings;
549