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
MediaRow.js
329 lines
| 1 | import React, { useState } from "react"; |
| 2 | import { __ } from "@wordpress/i18n"; |
| 3 | import { addQueryArgs } from "@wordpress/url"; |
| 4 | import { |
| 5 | Tooltip, |
| 6 | Button, |
| 7 | DropdownMenu, |
| 8 | Table, |
| 9 | Badge, |
| 10 | Text, |
| 11 | } from "@bsf/force-ui"; |
| 12 | import { Eye, PencilLine, Ellipsis, Copy, Check, Settings } from "lucide-react"; |
| 13 | import { decodeHTMLEntities } from "../../utils"; |
| 14 | import { getViewportBoundary } from "../../utils/viewportBoundary"; |
| 15 | import iconWhiteSvg from "../../../../../img/icon-white.svg"; |
| 16 | |
| 17 | // Statuses for which the front-end permalink would 404 (or worse — leak the |
| 18 | // slug). For these we either redirect via the WP preview URL or hide the |
| 19 | // View button entirely (trash). |
| 20 | const PREVIEW_STATUSES = ["draft", "pending", "future", "private"]; |
| 21 | |
| 22 | // Resolve the View button target for a given row, accounting for the post |
| 23 | // status. Returns null when there's no meaningful front-end URL (trash). |
| 24 | const resolveViewUrl = (item) => { |
| 25 | const status = item?.status; |
| 26 | if (status === "trash") { |
| 27 | return null; |
| 28 | } |
| 29 | const baseUrl = item?.link || `?p=${item?.id}`; |
| 30 | if (PREVIEW_STATUSES.includes(status)) { |
| 31 | return addQueryArgs(baseUrl, { preview: "true" }); |
| 32 | } |
| 33 | return baseUrl; |
| 34 | }; |
| 35 | |
| 36 | const MediaRow = ({ |
| 37 | item, |
| 38 | selected, |
| 39 | onChangeSelection, |
| 40 | onEditClick, |
| 41 | renderActionMenu, |
| 42 | getBadge, |
| 43 | formatPublishDate, |
| 44 | handleOpenSettings, |
| 45 | }) => { |
| 46 | const [imageError, setImageError] = useState(false); |
| 47 | const [copied, setCopied] = useState(false); |
| 48 | |
| 49 | const handleCopyShortcode = async (e) => { |
| 50 | e.preventDefault(); |
| 51 | e.stopPropagation(); |
| 52 | if (!item?.shortcode) return; |
| 53 | |
| 54 | const flagCopied = () => { |
| 55 | setCopied(true); |
| 56 | setTimeout(() => setCopied(false), 2000); |
| 57 | }; |
| 58 | |
| 59 | try { |
| 60 | if (navigator.clipboard?.writeText && window.isSecureContext) { |
| 61 | await navigator.clipboard.writeText(item.shortcode); |
| 62 | flagCopied(); |
| 63 | return; |
| 64 | } |
| 65 | } catch (err) { |
| 66 | // fall through to legacy path |
| 67 | } |
| 68 | |
| 69 | const textarea = document.createElement("textarea"); |
| 70 | textarea.value = item.shortcode; |
| 71 | textarea.setAttribute("readonly", ""); |
| 72 | textarea.style.position = "fixed"; |
| 73 | textarea.style.opacity = "0"; |
| 74 | document.body.appendChild(textarea); |
| 75 | textarea.select(); |
| 76 | try { |
| 77 | if (document.execCommand("copy")) { |
| 78 | flagCopied(); |
| 79 | } |
| 80 | } catch (err) { |
| 81 | console.error("Failed to copy shortcode:", err); |
| 82 | } |
| 83 | document.body.removeChild(textarea); |
| 84 | }; |
| 85 | |
| 86 | return ( |
| 87 | <Table.Row |
| 88 | value={item} |
| 89 | selected={selected} |
| 90 | onChangeSelection={onChangeSelection} |
| 91 | data-id={item?.id} |
| 92 | > |
| 93 | <Table.Cell className="text-left" style={{ maxWidth: 0 }}> |
| 94 | <div className="flex items-center gap-3 overflow-hidden"> |
| 95 | {item?.poster_image && !imageError ? ( |
| 96 | <img |
| 97 | src={item.poster_image} |
| 98 | alt={item?.title || ""} |
| 99 | className="flex-shrink-0 object-cover w-[75px] h-auto aspect-video rounded-[2px]" |
| 100 | loading="lazy" |
| 101 | onError={() => { |
| 102 | setImageError(true); |
| 103 | }} |
| 104 | /> |
| 105 | ) : ( |
| 106 | <div |
| 107 | className="flex-shrink-0 flex items-center justify-center w-[75px] aspect-video box-border bg-[#d1d5db] rounded-[2px]" |
| 108 | > |
| 109 | <img |
| 110 | src={iconWhiteSvg} |
| 111 | alt="" |
| 112 | width="20" |
| 113 | height="auto" |
| 114 | className="h-auto" |
| 115 | /> |
| 116 | </div> |
| 117 | )} |
| 118 | {(() => { |
| 119 | const decodedTitle = decodeHTMLEntities( |
| 120 | item?.title || __("Untitled", "presto-player") |
| 121 | ); |
| 122 | const MAX_TITLE_CHARS = 60; |
| 123 | const isTruncated = decodedTitle.length > MAX_TITLE_CHARS; |
| 124 | return ( |
| 125 | <span |
| 126 | className="block truncate cursor-pointer flex-1 min-w-0" |
| 127 | onClick={(e) => { |
| 128 | e.preventDefault(); |
| 129 | e.stopPropagation(); |
| 130 | if (onEditClick) { |
| 131 | onEditClick(e, item?.id); |
| 132 | } |
| 133 | }} |
| 134 | > |
| 135 | {isTruncated ? ( |
| 136 | <> |
| 137 | {decodedTitle.slice(0, MAX_TITLE_CHARS - 1)} |
| 138 | <Tooltip placement="top" content={decodedTitle} arrow> |
| 139 | <span className="inline-block">…</span> |
| 140 | </Tooltip> |
| 141 | </> |
| 142 | ) : ( |
| 143 | decodedTitle |
| 144 | )} |
| 145 | </span> |
| 146 | ); |
| 147 | })()} |
| 148 | </div> |
| 149 | </Table.Cell> |
| 150 | <Table.Cell className="text-left"> |
| 151 | {getBadge(item?.status)} |
| 152 | </Table.Cell> |
| 153 | <Table.Cell className="text-left"> |
| 154 | <div className="flex flex-wrap gap-x-1 gap-y-2"> |
| 155 | {item?.tags && item.tags.length > 0 ? ( |
| 156 | item.tags |
| 157 | .slice(0, 10) |
| 158 | .map((tag) => ( |
| 159 | <Badge |
| 160 | key={tag.id} |
| 161 | variant="neutral" |
| 162 | label={decodeHTMLEntities(tag.name)} |
| 163 | size="sm" |
| 164 | className="text-xs" |
| 165 | /> |
| 166 | )) |
| 167 | ) : ( |
| 168 | <Text className="text-text-secondary text-sm"> |
| 169 | {__("No tags", "presto-player")} |
| 170 | </Text> |
| 171 | )} |
| 172 | {item?.tags && item.tags.length > 10 && ( |
| 173 | <Tooltip |
| 174 | content={item.tags |
| 175 | .slice(10) |
| 176 | .map((tag) => decodeHTMLEntities(tag.name)) |
| 177 | .join(", ")} |
| 178 | arrow |
| 179 | placement="top" |
| 180 | > |
| 181 | <Badge |
| 182 | variant="neutral" |
| 183 | label={`+${item.tags.length - 10}`} |
| 184 | size="sm" |
| 185 | className="text-xs" |
| 186 | /> |
| 187 | </Tooltip> |
| 188 | )} |
| 189 | </div> |
| 190 | </Table.Cell> |
| 191 | <Table.Cell className="whitespace-nowrap text-left"> |
| 192 | <div className="flex items-center gap-2"> |
| 193 | {item?.shortcode ? ( |
| 194 | <code |
| 195 | className="font-mono text-xs px-3 py-2 rounded bg-field-secondary-background outline outline-1 outline-border-subtle text-text-primary cursor-text select-all" |
| 196 | onClick={(e) => e.stopPropagation()} |
| 197 | > |
| 198 | {item.shortcode} |
| 199 | </code> |
| 200 | ) : ( |
| 201 | <span className="font-mono text-xs px-3 py-2 rounded bg-field-secondary-background outline outline-1 outline-border-subtle text-text-tertiary"> |
| 202 | {__("No shortcode", "presto-player")} |
| 203 | </span> |
| 204 | )} |
| 205 | {item?.shortcode && ( |
| 206 | <Tooltip |
| 207 | content={ |
| 208 | copied |
| 209 | ? __("Copied!", "presto-player") |
| 210 | : __("Copy shortcode", "presto-player") |
| 211 | } |
| 212 | arrow |
| 213 | placement="top" |
| 214 | > |
| 215 | <Button |
| 216 | variant="ghost" |
| 217 | icon={ |
| 218 | copied ? ( |
| 219 | <Check width="14" height="14" /> |
| 220 | ) : ( |
| 221 | <Copy width="14" height="14" /> |
| 222 | ) |
| 223 | } |
| 224 | size="xs" |
| 225 | className={`flex-shrink-0 transition-colors ${ |
| 226 | copied |
| 227 | ? "text-green-600 hover:text-green-700" |
| 228 | : "text-icon-secondary hover:text-icon-primary" |
| 229 | }`} |
| 230 | aria-label={__("Copy shortcode", "presto-player")} |
| 231 | onClick={handleCopyShortcode} |
| 232 | /> |
| 233 | </Tooltip> |
| 234 | )} |
| 235 | </div> |
| 236 | </Table.Cell> |
| 237 | <Table.Cell className="text-left whitespace-nowrap"> |
| 238 | {formatPublishDate(item?.post_date)} |
| 239 | </Table.Cell> |
| 240 | <Table.Cell className="text-right"> |
| 241 | <div className="flex items-center justify-center gap-2"> |
| 242 | {(() => { |
| 243 | const viewUrl = resolveViewUrl(item); |
| 244 | if (!viewUrl) { |
| 245 | return null; |
| 246 | } |
| 247 | const isPreview = PREVIEW_STATUSES.includes(item?.status); |
| 248 | const label = isPreview |
| 249 | ? __("Preview", "presto-player") |
| 250 | : __("View", "presto-player"); |
| 251 | return ( |
| 252 | <Tooltip content={label} arrow placement="top"> |
| 253 | <Button |
| 254 | variant="ghost" |
| 255 | icon={<Eye />} |
| 256 | size="xs" |
| 257 | className="text-icon-secondary hover:text-icon-primary" |
| 258 | aria-label={label} |
| 259 | onClick={(e) => { |
| 260 | e.preventDefault(); |
| 261 | e.stopPropagation(); |
| 262 | window.open(viewUrl, "_blank", "noopener,noreferrer"); |
| 263 | }} |
| 264 | /> |
| 265 | </Tooltip> |
| 266 | ); |
| 267 | })()} |
| 268 | <Tooltip content={__("Edit", "presto-player")} arrow placement="top"> |
| 269 | <Button |
| 270 | variant="ghost" |
| 271 | icon={<PencilLine />} |
| 272 | size="xs" |
| 273 | className="text-icon-secondary hover:text-icon-primary" |
| 274 | aria-label={__("Edit", "presto-player")} |
| 275 | onClick={(e) => { |
| 276 | e.preventDefault(); |
| 277 | e.stopPropagation(); |
| 278 | if (onEditClick) { |
| 279 | onEditClick(e, item?.id); |
| 280 | } |
| 281 | }} |
| 282 | /> |
| 283 | </Tooltip> |
| 284 | <Tooltip |
| 285 | content={__("Post Settings", "presto-player")} |
| 286 | arrow |
| 287 | placement="top" |
| 288 | > |
| 289 | <Button |
| 290 | variant="ghost" |
| 291 | size="xs" |
| 292 | icon={<Settings />} |
| 293 | aria-label={__("Post Settings", "presto-player")} |
| 294 | className="text-icon-secondary hover:text-icon-primary" |
| 295 | onClick={(e) => { |
| 296 | e.preventDefault(); |
| 297 | e.stopPropagation(); |
| 298 | if (handleOpenSettings) { |
| 299 | handleOpenSettings(e, item); |
| 300 | } |
| 301 | }} |
| 302 | /> |
| 303 | </Tooltip> |
| 304 | <DropdownMenu boundary={getViewportBoundary()}> |
| 305 | <DropdownMenu.Trigger> |
| 306 | <Button |
| 307 | variant="ghost" |
| 308 | icon={<Ellipsis />} |
| 309 | size="xs" |
| 310 | className="text-icon-secondary hover:text-icon-primary z-0" |
| 311 | aria-label={__("More Options", "presto-player")} |
| 312 | /> |
| 313 | </DropdownMenu.Trigger> |
| 314 | <DropdownMenu.ContentWrapper className="z-10"> |
| 315 | <DropdownMenu.Content className="w-48"> |
| 316 | <DropdownMenu.List> |
| 317 | {renderActionMenu ? renderActionMenu(item) : null} |
| 318 | </DropdownMenu.List> |
| 319 | </DropdownMenu.Content> |
| 320 | </DropdownMenu.ContentWrapper> |
| 321 | </DropdownMenu> |
| 322 | </div> |
| 323 | </Table.Cell> |
| 324 | </Table.Row> |
| 325 | ); |
| 326 | }; |
| 327 | |
| 328 | export default MediaRow; |
| 329 |